2023. 9. 16. 15:30

EF(Entity Framework, 엔트리 프레임워크)에서 데이터의 무결성을 확보하기 위해 '낙관적 동시성(Optimistic Concurrency)'을 어떻게 구현하는지 알아봅시다.

 

 

0. 낙관적 동시성을 쓰는 이유

낙관적 동시성을 쓰는 이유를 알려면 알아두어야 할 내용이 있습니다.

 

0-1. 비관적 동시성

DB에서 데이터의 일관성유지를 위해 락(lock)을 거는 방법이 있습니다.

이렇게 락을 거는 방법을 '비관적 동시성(Pessimisitc Concurrency)'이라고 합니다.

 

이 방법은 누군가 데이터를 수정하는 동안 다른 사람은 수정할 수 없게 만드는 방법입니다.

다른 사람이 수정할 수 없으니 절대적인 데이터 무결성이 보장된다는 장점이 있습니다.

하지만 동시에 여러 사람이 수정하려는 경우 나머지 사람들은 대기 창을 봐야 한다는 문제가 있습니다.

(동시성 저해)

 

0-2. 낙관적 동시성

데이터를 수정하는 시점에서 기존 데이터가 수정되었는지 확인하고 수정되지 않았을 때 데이터를 수정하는 방법을 낙관적 동시성(Optimistic Concurrency)이라고 합니다.

 

테이블에 수정 시 비교할 고유값(예> '타임스탬프(Timestamp)') 컬럼을 넣어두고 을 넣어두고,

이 값이 일치하면 수정하고

일치하지 않는다면 데이터를 다시 로드(Select)하여 수정할 데이터를 다시 입력하고 수정을 시도합니다.

낙관적 동시성의 구현 사이클

 

 

'타임스탬프(Timestamp)'를 지원하는 DB엔진이라면 타임스템프로 지정하면 됩니다.

이렇게 되면 DB에서 해당 데이터(row)가 수정되면 자동으로 타임스탬프를 갱신해 줍니다.

 

 

'타임스탬프(Timestamp)'와 같은 자동생성 고유값을 지원하지 않는 DB엔진이라면 프로그래밍으로 구현해야 합니다.

이때 '낙관적 동시성' 자체를 지원하지 않는 DB엔진은 락을 걸어 처리해야 합니다.

 

락을 걸어 직접 구현하는 방법은

 - 락(Lock)을 걸고

 - 업데이트할 데이터(row)의 고유값을 로드합니다.(Select)

 - 고유값이 일치하면 업데이트해 줍니다.

 

락을 거니까 "비관적 동시성과 같은 거 아니냐??"라고 생각할 수 있지만

이렇게 구현하면 아주 짧은 시간만 락이 걸리므로 낙관적 동시성과 비슷한 효과가 나옵니다.

(애초에 DB엔진이 지원하지 않는 걸 프로그래밍으로 구현하는 것이라 한계이기도 합니다 ㅎㅎㅎ)

 

 

1. EF에서 낙관적 동시성

* '낙관적 동시성' 기능을 지원하는 DB를 기준으로 작성되어 있습니다. *

 

EF에서 낙관적 동시성을 사용하면 업데이트하려는 순간 오류가 발생합니다.

대충 예상과 다른 방향으로 row에 영향을 줄거 같아서 에러 났다는 소리

Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: 'The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded.

 

 

이렇게 오류가 나는 것을 이용하여

- 오류가 나면 데이터(row)를 다시 로드하고(Select)

- 데이터를 수정한 다음

- 업데이트를 시도합니다.

 

 

2. 낙관적 동시성 모델 구현

위에서 설명한 대로 낙관적 동시성을 구현해 봅시다.

 

타임스탬프를 지원하는 DB엔진과 지원하지 않는 DB엔진의 선언이 약간 다릅니다.

 

2-1. '동시성 토큰' 직접 제어

타임스탬프를 지원하지 않거나 '동시성 토큰'을 직접 제어하고 싶다면 모델을 아래와 같이 선언합니다.

(참고 : github - dang-gun/EntityFrameworkSample/EntityFrameworkSample.DB/ModelsDB/TestOC/TestOC1.cs )

/// <summary>
/// 테스트용 테이블 - 클라이언트 관리 토큰
/// </summary>
public class TestOC1
{
    /// <summary>
    /// 고유키
    /// </summary>
    [Key]
    public int idTestOC1 { get; set; }

    /// <summary>
    /// 숫자형
    /// </summary>
    public int Int { get; set; }

    /// <summary>
    /// 문자형
    /// </summary>
    [MaxLength(32)]
    public string Str { get; set; } = string.Empty;


    /// <summary>
    /// 클라이언트 관리 토큰(GUID로 생성)
    /// </summary>
    [ConcurrencyCheck]
    public Guid Version { get; set; }
}

 

27번 줄 : '[ConcurrencyCheck]'이 속성은 동시성 체크에 사용하는 컬럼을 지정해 줍니다.

DB에 생성된 컬럼은 'uniqueidentifier'형식으로 저장됩니다.

 

 

2-2. '동시성 토큰' 자동 제어

타임스탬프를 지원하는 DB엔진은 타임스탬프로 지정만 하면 자동으로 '낙관적 동시성' 토큰이 제어됩니다.

(참고 : github - dang-gun/EntityFrameworkSample/EntityFrameworkSample.DB/ModelsDB/TestOC/TestOC2.cs )

/// <summary>
/// 테스트용 테이블 - SQL 서버 관리 토큰
/// </summary>
public class TestOC2
{
    /// <summary>
    /// 고유키
    /// </summary>
    [Key]
    public int idTestOC2 { get; set; }

    /// <summary>
    /// 숫자형
    /// </summary>
    public int Int { get; set; }

    /// <summary>
    /// 문자형
    /// </summary>
    [MaxLength(32)]
    public string Str { get; set; } = string.Empty;


    /// <summary>
    /// SQL 서버 관리 토큰
    /// </summary>
    /// <remarks>
    /// 서버에서 관리하는 토큰은 SQL서버에 따라 지원하지 않는 경우가 있다.
    /// (SQLite는 지원하지 않음)
    /// </remarks>
    [Timestamp]
    public byte[]? Version { get; set; }
}

 

31번 줄 : '[Timestamp]'속성을 지정하면 해당 데이터(row)가 수정될 때마다 타임스탬프가 기록됩니다.

이 값이 다르면 데이터가 수정됐다고 판단 할 수 있습니다.

 

 

3. 기능 구현

동시성 토큰을 직접 제어할지 아닐지에 따라 코드가 약간 달라집니다.

 

3-1. 클라이언트에서 제어

동시성 토큰을 DB엔진에서 제어하는 것이 아니고 직접 제어하려면 아래와 같이 작성합니다.

(참고 : github - dang-gun/EntityFrameworkSample/OptimisticConcurrencyTest/Form1.cs 130~ )

//수정할 개체
TestOC1? findTarget = null;

using (ModelsDbContext db1 = new ModelsDbContext())
{
    //저장이 실패했는지 여부
    bool bSave = true;
    //수정할 대상 찾기
    findTarget = db1.TestOC1.Where(w => w.idTestOC1 == 1).FirstOrDefault();

    do
    {
        bSave = true;
        try
        {
            if (null != findTarget)
            {//수정할 대상이 있다.

                //값 변경
                findTarget.Int += 1;
                findTarget.Str = sStr;

                //☆☆☆☆ 저장할때 항상 GUID를 변경해야 한다.
                findTarget.Version = Guid.NewGuid();

                //DB에 업데이트 요청
                db1.SaveChanges();
            }
        }
        catch (DbUpdateConcurrencyException ex)
        {
            bSave = false;

            // Update the values of the entity that failed to save from the store
            // 수정하려던 요소를 다시 로드 한다.
            // 수정하려던 값이 초기화 되므로 넣으려는 값을 다시 계산해야 한다.
            ex.Entries.Single().Reload();
        }

        //저장에 실패했다면 다시 시도
    } while (false == bSave);

}//end using db1

 

9번 줄 : 수정할 대상을 찾습니다.

 

11번 줄 : 낙관적 동시성 저장이 실패하면 반복할 루프(do~loop)입니다.

 

19번 줄 : 낙관적 동시성 저장이 실패하면 다시 값을 세팅해야 합니다.

 

24번 줄 : 직접 동시성 토큰을 관리하려고 중복되지 않는 값을 생성하기 위해 GUID로 토큰을 생성합니다.

 

30번 줄 : 낙관적 동시성 에러가 난다면 'DbUpdateConcurrencyException'개체로 에러가 넘어옵니다.

 

37번 줄 : 낙관적 동시성 에러가 났다는 것은 가지고 있는 데이터가 최신데이터가 아니라는 의미이므로 데이터를 다시 불러옵니다.

여기서 'ex.Entries.Single().Reload();'를 사용하면 이 에러를 낸 컨택스트(DB Context)가 추적 중인 모든 개체를 갱신하므로 컨택스트의 범위를 최소화할 필요가 있습니다.

 

41번 줄 : 저장에 실패하면 다시 시도합니다.

 

 

3-2. DB에서 제어

'타임스탬프(Timestamp)'가 지원된다면 직접 동시성 토큰을 제어할 필요가 없습니다.

 

위 코드와 거의 비슷하지만 '동시성 토큰' 제어 부분만 없습니다.

(참고 : github - dang-gun/EntityFrameworkSample/OptimisticConcurrencyTest/Form1.cs 220~ )

//수정할 개체
TestOC2? findTarget = null;

using (ModelsDbContext db1 = new ModelsDbContext())
{
    //저장이 실패했는지 여부
    bool bSave = true;
    //수정할 대상 찾기
    findTarget = db1.TestOC2.Where(w => w.idTestOC2 == 1).FirstOrDefault();

    do
    {
        bSave = true;
        try
        {
            if (null != findTarget)
            {//수정할 대상이 있다.

                //값 변경
                findTarget.Int += 1;
                findTarget.Str = sStr;

                //DB에 업데이트 요청
                db1.SaveChanges();
            }
        }
        catch (DbUpdateConcurrencyException ex)
        {
            bSave = false;

            // Update the values of the entity that failed to save from the store
            // 수정하려던 요소를 다시 로드 한다.
            // 수정하려던 값이 초기화 되므로 넣으려는 값을 다시 계산해야 한다.
            ex.Entries.Single().Reload();
        }

    } while (false == bSave);

}//end using db1

 

동시성 토큰 제어를 DB엔진이 하므로 이 코드에는 GUID를 생성하여 넣는 코드가 없습니다.

 

 

4. 낙관적 동시성 처리 함수

위의 기능을 낙관적 동시성이 있을 때마다 반복 작성한다는 건 비효율적이니 따로 함수를 빼야겠습니다.

 

4-1. 저장 오류를 확인하는 함수(SaveChanges_UpdateConcurrencyCheck)

컨택스트의 변경 사항을 저장 시도를 하고 낙관적 동시성 오류를 확인합니다.

오류가 확인되면 컨택스트를 다시 로드합니다.

(참고 : github - dang-gun/EntityFrameworkSample/OptimisticConcurrencyTest/OptimisticConcurrencyUtil.cs 86~ )

    /// <summary>
    /// 낙관적 동시성 체크
    /// </summary>
    /// <remarks>
    /// 낙관적 동시성 체크가 성공면 저장하고 true가 리턴되고
    /// 실패하면 false가 리턴된다.
    /// </remarks>
    /// <param name="db1"></param>
    /// <returns></returns>
    public bool SaveChanges_UpdateConcurrencyCheck(ModelsDbContext db1)
    {
        bool bReturn = false;

        try
        {
            db1.SaveChanges();
            bReturn = true;
        }
        catch (DbUpdateConcurrencyException ex)
        {
            // Update the values of the entity that failed to save from the store
            // 수정하려던 요소를 다시 로드 한다.
            // 수정하려던 값이 초기화 되므로 넣으려는 값을 다시 계산해야 한다.
            ex.Entries.Single().Reload();
        }

        return bReturn;
    }

 

 

4-2. 낙관적 동시성 제어 함수

개발자가 지정한 옵션에 따라 자동으로 제어해 주는 함수입니다.

(참고 : github - dang-gun/EntityFrameworkSample/OptimisticConcurrencyTest/OptimisticConcurrencyUtil.cs 38~ )

/// <summary>
/// 낙관적 동시성 적용
/// </summary>
/// <remarks>
/// <para>낙관적 동시성이 체크가 성공하면 저장한다.</para>
/// <para>지정된 실행식을 지정된 횟수만큼 반복한다.</para>
/// <para>추적중인 데이터가 모두 다시 로드 되므로 가급적 Context를 짧게 만들어야
/// 부하도 적고 속도도 빨라진다.</para>
/// </remarks>
/// <param name="db1"></param>
/// <param name="nMaxLoop">최대 반복수. 마이너스 값이면 무한반복한다.</param>
/// <param name="callback">반복해서 동작시킬 실행식.</param>
/// <returns>업데이트 성공 여부</returns>
public static bool SaveChanges_UpdateConcurrency(
    ModelsDbContext db1
    , int nMaxLoop
    , Func<bool> callback)
{
    //반복수
    int nLoopCount = 0;

    //저장 성공 여부
    bool bSaveSuccess = false;
    while (false == bSaveSuccess)
    {//낙관적 동시성 처리

        ++nLoopCount;
        if (0 < nMaxLoop
            && nLoopCount > nMaxLoop)
        {//마이너스값이 아니고
            //지정한 횟수(nMaxLoop)를 넘어섰다.

            //낙관적 동시성 종료
            break;
        }

        //동작 재실행
        bool bCallReturn = callback();
        if(false == bCallReturn)
        {//결과가 거짓이다.

            //낙관적 동시성 종료
            break;
        }

        bSaveSuccess = SaveChanges_UpdateConcurrencyCheck(db1);            
    }//end while (false == bSaveSuccess)

    
    return bSaveSuccess;
}

 

16번 줄 : 데이터 변경에 사용할 코드를 전달합니다.

낙관적 동시성이 실패한다면 반복하여 동작하는 코드입니다.

 

매개변수는 없고 리턴은 'bool'값입니다.

낙관적 동시성 반복을 끝내고 싶으면 'false'를 리턴하면 됩니다.

예>

() =>
{
    findTarget.Int += 1;
    findTarget.Str = sStr;

    Thread.Sleep(nDelay);

    return true;
}

 

17번 줄 : 최대 반복 횟수

낙관적 동시성이 계속 실패할 경우 몇 번까지 재시도 할지 여부입니다.

마이너스(예> -1)값을 주면 무한 반복합니다.

 

38번 줄 : 전달받은 액션을 실행합니다.

 

46번 줄 : 저장 시도

 

 

4-3. 사용 방법

귀찮다면 'OptimisticConcurrencyUtil.cs'를 그대로 복사하여 자신의 DB에 맞게 수정해도 됩니다.

(참고 : github - dang-gun/EntityFrameworkSample/OptimisticConcurrencyTest/OptimisticConcurrencyUtil.cs 38~ )

 

 

1) DB컨택스트를 생성하고

2) 수정할 개체를 찾은 다음

3) 수정할 식을 함수로 만들어 넣어주고

4) 공통화 함수를 호출하면 됩니다.

(참고 : github - dang-gun/EntityFrameworkSample/OptimisticConcurrencyTest/Form1.cs 305~ )

예제 코드 > 

TestOC2? findTarget = null;

using (ModelsDbContext db1 = new ModelsDbContext())
{
    findTarget = db1.TestOC2.Where(w => w.idTestOC2 == 1).FirstOrDefault();

    if (null != findTarget)
    {
        GlobalDb.SaveChanges_UpdateConcurrency(
            db1
            , -1
            , () =>
            {
                findTarget.Int += 1;
                findTarget.Str = sStr;
                
                return true;
            });
    }

}//end using db1

 

 

5. 샘플 프로그램 사용하기

샘플 프로그램을 사용하여 낙관적 동시성이 어떻게 동작하는지 확인해 보세요.

(참고 : github - dang-gun/EntityFrameworkSample/OptimisticConcurrencyTest )

 

(1) 사용할 DB에 커넥트 스트링을 설정하고 '사용 시작'을 누릅니다.

 

(2) 

애플리케이션 : 동시성 토큰을 프로그램에서 제어하는 방식입니다.

서버 : DB엔진에서 동시성 토큰을 관리하는 방식입니다.

없음 : 동시성이 없으면 어떤 일이 벌어지는지 확인합니다.

 

(3) 실시간으로 업데이트되는 내용을 확인해 보세요.

TestOC1 : 애플리케이션에서 관리되는 동시성 토큰

TestOC2 : DB엔진에서 관리되는 동시성 토큰

TestOC2 : 동시성 토큰 없음

 

 

 

마무리

샘플 프로젝트 : github - dang-gun/EntityFrameworkSample/OptimisticConcurrencyTest 

참고 : MS Learn - 낙관적 동시성, 동시성 충돌 처리

Entity Framework Tutorial - Concurrency in Entity Framework

 

여러줄을 한번에 업데이트 할때 처리 방법은 다음 포스팅에 있습니다

참고 : [Entity Framework 6] 낙관적 동시성(Optimistic Concurrency) 여러줄 처리

 

 

비관적/낙관적 동시성을 설명하는 글에 동시에 '수정하지 않을 꺼라는 가정'이라는 말이 있는데....

전 아무리봐도 이 말이 더 혼란만 가중하는게 아닌가 하는 생각이 자꾸 듭니다.

 

비관적 동시성은 다른 사람이 수정할 거라는 가정하에 미리 수정 못 하게 막아두는 개념이라

얼핏 들으면 혼자 쓰는 데이터에 사용해야 할 것처럼 보이거든요.

근데 혼자 쓰는 데이터는 동시성 걱정을 할 필요가 없죠.

 

오히려 "다른 사람이 접근할 수 없게 막는다"라는 설명이 더 맞는다고 봅니다.

그래서 '비관적'인 접근되는거죠.

 

 

그것과 별개로 요즘은 사용자 경험(UX)이 중요해져서 무작정 락걸고 기다리는 식의 처리는 힘들어졌습니다.

물론 한 번에 접근하는 유저 숫자를 적절하게 제어할 수 있다면 상관없지만

그게 가능하면 낙관적이든 비관적이든 상관없는 상황이기도 하거든요 ㅎㅎㅎㅎㅎ

 

결국 가급적 낙관적 동시성을 쓰는 게 났다.