동시성 충돌 처리

GitHub에서 이 문서의 샘플을 볼 수 있습니다.

대부분의 시나리오에서 데이터베이스는 여러 애플리케이션 인스턴스에서 동시에 사용되며, 각 인스턴스는 서로 독립적으로 데이터를 수정합니다. 동일한 데이터가 동시에 수정되면 불일치 및 데이터 손상이 발생할 수 있습니다. 예를 들어 두 클라이언트가 어떤 식으로든 관련된 동일한 행에서 서로 다른 열을 수정하는 경우와 같이 발생할 수 있습니다. 이 페이지에서는 이러한 동시 변경에 직면하여 데이터가 일관되게 유지되도록 하는 메커니즘에 대해 설명합니다.

낙관적 동시성

EF Core는 동시성 충돌이 비교적 드물다고 가정하는 낙관적 동시성을 구현합니다. 데이터를 미리 잠그고 수정을 진행하는 비관적 접근 방식과 달리 낙관적 동시성은 잠금을 사용하지 않지만 쿼리된 이후 데이터가 변경된 경우 저장 시 데이터 수정이 실패하도록 준비합니다. 이 동시성 오류는 새 데이터에 대한 전체 작업을 다시 시도하여 해당 오류를 처리하는 애플리케이션에 보고됩니다.

EF Core에서 낙관적 동시성은 속성을 동시성 토큰으로 구성하여 구현됩니다. 동시성 토큰은 다른 속성과 마찬가지로 엔터티를 쿼리할 때 로드되고 추적됩니다. 그런 다음 SaveChanges() 중에 업데이트 또는 삭제 작업이 수행되면 데이터베이스의 동시성 토큰 값은 EF Core에서 읽은 원래 값과 비교됩니다.

작동 방식을 이해하려면 SQL Server 있다고 가정하고 특수 Version 속성을 사용하여 일반적인 Person 엔터티 형식을 정의해 보겠습니다.

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }

    [Timestamp]
    public byte[] Version { get; set; }
}

SQL Server 행이 변경될 때마다 데이터베이스에서 자동으로 변경되는 동시성 토큰을 구성합니다(자세한 내용은 아래 참조). 이 구성을 사용하여 간단한 업데이트 작업에서 발생하는 작업을 살펴보겠습니다.

var person = context.People.Single(b => b.FirstName == "John");
person.FirstName = "Paul";
context.SaveChanges();
  1. 첫 번째 단계에서는 데이터베이스에서 Person을 로드합니다. 여기에는 동시성 토큰이 포함되며, 이 토큰은 이제 나머지 속성과 함께 EF에서 평소와 같이 추적됩니다.
  2. 그런 다음 Person 인스턴스가 어떤 식으로든 수정됩니다. FirstName 속성을 변경합니다.
  3. 그런 다음 EF Core에 수정 사항을 유지하도록 지시합니다. 동시성 토큰이 구성되었으므로 EF Core는 다음 SQL을 데이터베이스로 보냅니다.
UPDATE [People] SET [FirstName] = @p0
WHERE [PersonId] = @p1 AND [Version] = @p2;

WHERE 절의 PersonId 외에도 EF Core는 Version에 대한 조건을 추가했습니다. 그러면 쿼리한 순간부터 Version 열이 변경되지 않은 경우에만 행을 수정합니다.

일반적인("낙관적인") 경우 동시 업데이트가 발생하지 않고 UPDATE가 성공적으로 완료되어 행이 수정됩니다. 데이터베이스는 예상대로 한 행이 UPDATE의 영향을 받았다고 EF Core에 보고합니다. 그러나 동시 업데이트가 발생한 경우 UPDATE는 일치하는 행을 찾지 못하고 0이 영향을 받았다고 보고합니다. 결과적으로 EF Core의 SaveChanges()는 애플리케이션이 적절하게 catch하고 처리해야 하는 DbUpdateConcurrencyException을 throw합니다. 이 작업을 수행하는 방법은 동시성 충돌 해결에서 아래에 자세히 설명되어 있습니다.

위의 예제에서는 기존 엔터티에 대한 업데이트를 설명했습니다. EF는 동시에 수정된 행을 삭제하려고 할 때도 DbUpdateConcurrencyException을 throw합니다. 그러나 엔터티를 추가할 때는 이 예외가 throw되지 않습니다. 동일한 키를 가진 행을 삽입하는 경우 데이터베이스가 실제로 고유한 제약 조건 위반을 발생할 수 있지만, 이로 인해 DbUpdateConcurrencyException이 아닌 공급자별 예외가 throw됩니다.

네이티브 데이터베이스 생성 동시성 토큰

위의 코드에서는 특성을 사용하여 [Timestamp] 속성을 SQL Server rowversion 열에 매핑했습니다. rowversion은 행이 업데이트될 때 자동으로 변경되므로 전체 행을 보호하는 최소 작업 동시성 토큰으로 매우 유용합니다. SQL Server rowversion 열을 동시성 토큰으로 구성은 다음과 같이 수행됩니다.

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }

    [Timestamp]
    public byte[] Version { get; set; }
}

위에 표시된 rowversion 형식은 SQL Server 특정 기능입니다. 자동으로 업데이트되는 동시성 토큰 설정에 대한 세부 정보는 데이터베이스마다 다르며 일부 데이터베이스는 이를 전혀 지원하지 않습니다(예: SQLite). 자세한 내용은 공급자 설명서를 참조하세요.

애플리케이션 관리 동시성 토큰

데이터베이스에서 동시성 토큰을 자동으로 관리하는 대신 애플리케이션 코드에서 관리할 수 있습니다. 이렇게 하면 네이티브 자동 업데이트 형식이 없는 데이터베이스(예: SQLite)에서 낙관적 동시성을 사용할 수 있습니다. 그러나 SQL Server 애플리케이션 관리 동시성 토큰은 토큰을 다시 생성하게 하는 열 변경 내용에 대한 세분화된 제어를 제공할 수 있습니다. 예를 들어 일부 캐시되거나 중요하지 않은 값이 포함된 속성이 있을 수 있으며 동시성 충돌을 트리거하기 위해 해당 속성을 변경하지 않도록 합니다.

다음은 GUID 속성을 동시성 토큰으로 구성합니다.

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }

    [ConcurrencyCheck]
    public Guid Version { get; set; }
}

이 속성은 데이터베이스 생성되지 않으므로 변경 내용을 유지할 때마다 애플리케이션에 할당해야 합니다.

var person = context.People.Single(b => b.FirstName == "John");
person.FirstName = "Paul";
person.Version = Guid.NewGuid();
context.SaveChanges();

새 GUID 값을 항상 할당하려면 SaveChanges 인터셉터를 통해 이 작업을 수행할 수 있습니다. 그러나 동시성 토큰을 수동으로 관리하는 한 가지 이점은 동시성 토큰이 다시 생성되는 시기를 정확하게 제어하여 불필요한 동시성 충돌을 방지할 수 있다는 것입니다.

동시성 충돌 해결

동시성 토큰을 설정하는 방법에 관계없이 낙관적 동시성을 구현하려면 애플리케이션이 동시성 충돌이 발생하고 DbUpdateConcurrencyException이 throw되는 경우를 적절하게 처리해야 합니다. 이를 동시성 충돌 해결이라고 합니다.

한 가지 옵션은 충돌하는 변경으로 인해 업데이트가 실패했음을 사용자에게 알리는 것입니다. 그러면 사용자가 새 데이터를 로드하고 다시 시도할 수 있습니다. 또는 애플리케이션이 자동화된 업데이트를 수행하는 경우 데이터를 다시 쿼리한 후 즉시 반복하고 다시 시도할 수 있습니다.

동시성 충돌을 해결하는 보다 정교한 방법은 보류 중인 변경 내용을 데이터베이스의 새 값과 병합하는 것입니다. 병합되는 값에 대한 정확한 세부 정보는 애플리케이션에 따라 달라지며, 두 값 집합이 모두 표시되는 사용자 인터페이스에서 프로세스를 지시할 수 있습니다.

세 가지 값 집합을 동시성 충돌 해결에 사용할 수 있습니다.

  • 현재 값은 애플리케이션이 데이터베이스에 쓰려고 시도하는 값입니다.
  • 원래 값은 편집을 수행하기 전에 데이터베이스에서 처음에 검색된 값입니다.
  • 데이터베이스 값은 현재 데이터베이스에 저장된 값입니다.

동시성 충돌을 처리하는 일반적인 접근 방법은 다음과 같습니다.

  1. SaveChangesDbUpdateConcurrencyException을 catch합니다.
  2. DbUpdateConcurrencyException.Entries를 사용하여 영향을 받는 엔터티에 대해 새로운 변경 내용 집합을 준비합니다.
  3. 데이터베이스의 현재 값을 반영하도록 동시성 토큰의 원래 값을 새로 고칩니다.
  4. 충돌이 발생하지 않을 때까지 프로세스를 다시 시도합니다.

다음 예제에서는 Person.FirstNamePerson.LastName을 동시성 토큰으로 설정합니다. 저장할 값을 선택하는 애플리케이션별 논리를 포함하는 위치에 // TODO: 주석이 있습니다.

using var context = new PersonContext();
// Fetch a person from database and change phone number
var person = context.People.Single(p => p.PersonId == 1);
person.PhoneNumber = "555-555-5555";

// Change the person's name in the database to simulate a concurrency conflict
context.Database.ExecuteSqlRaw(
    "UPDATE dbo.People SET FirstName = 'Jane' WHERE PersonId = 1");

var saved = false;
while (!saved)
{
    try
    {
        // Attempt to save changes to the database
        context.SaveChanges();
        saved = true;
    }
    catch (DbUpdateConcurrencyException ex)
    {
        foreach (var entry in ex.Entries)
        {
            if (entry.Entity is Person)
            {
                var proposedValues = entry.CurrentValues;
                var databaseValues = entry.GetDatabaseValues();

                foreach (var property in proposedValues.Properties)
                {
                    var proposedValue = proposedValues[property];
                    var databaseValue = databaseValues[property];

                    // TODO: decide which value should be written to database
                    // proposedValues[property] = <value to be saved>;
                }

                // Refresh original values to bypass next concurrency check
                entry.OriginalValues.SetValues(databaseValues);
            }
            else
            {
                throw new NotSupportedException(
                    "Don't know how to handle concurrency conflicts for "
                    + entry.Metadata.Name);
            }
        }
    }
}

동시성 제어에 격리 수준 사용

동시성 토큰을 통한 낙관적 동시성은 동시 변경에 직면하여 데이터가 일관되게 유지되도록 하는 유일한 방법은 아닙니다.

일관성을 보장하는 한 가지 메커니즘은 반복 가능한 읽기 트랜잭션 격리 수준입니다. 대부분의 데이터베이스에서 이 수준은 트랜잭션이 후속 동시 작업의 영향을 받지 않고 트랜잭션이 시작될 때와 같이 데이터베이스의 데이터를 볼 수 있도록 보장합니다. 위에서 기본 샘플을 가져와서 어떤 식으로든 업데이트하기 위해 Person에 대해 쿼리할 때 데이터베이스는 트랜잭션이 완료될 때까지 다른 트랜잭션이 해당 데이터베이스 행을 방해하지 않도록 해야 합니다. 데이터베이스 구현에 따라 다음 두 가지 방법 중 하나로 발생합니다.

  1. 행을 쿼리할 때 트랜잭션은 공유 잠금을 사용합니다. 행을 업데이트하려는 모든 외부 트랜잭션은 트랜잭션이 완료될 때까지 차단됩니다. 이는 비관적 잠금의 한 형태이며 SQL Server "반복 가능한 읽기" 격리 수준에 의해 구현됩니다.
  2. 데이터베이스를 잠그는 대신 외부 트랜잭션에서 행을 업데이트할 수 있지만, 사용자 고유의 트랜잭션이 해당 행을 업데이트하려고 하면 동시성 충돌이 발생했음을 나타내는 "serialization" 오류가 발생합니다. 이는 EF의 동시성 토큰 기능과 달리 낙관적 잠금의 한 형태이며 SQL Server 스냅샷 격리 수준뿐만 아니라 PostgreSQL 반복 가능한 읽기 격리 수준에 의해 구현됩니다.

"serializable" 격리 수준은 반복 가능한 읽기와 동일한 보장을 제공하므로(그리고 추가 항목을 추가) 위의 경우와 동일한 방식으로 작동합니다.

더 높은 격리 수준을 사용하여 동시성 충돌을 관리하는 것이 더 간단하고, 동시성 토큰이 필요하지 않으며, 다른 이점을 제공합니다. 예를 들어 반복 가능한 읽기는 트랜잭션이 항상 트랜잭션 내의 쿼리에서 동일한 데이터를 볼 수 있도록 보장하여 불일치를 방지합니다. 그러나 이 접근 방식에는 단점이 있습니다.

먼저 데이터베이스 구현에서 잠금을 사용하여 격리 수준을 구현하는 경우 동일한 행을 수정하려는 다른 트랜잭션이 트랜잭션 전체에 대해 차단되어야 합니다. 이는 동시 성능에 부정적인 영향을 미칠 수 있지만(트랜잭션을 짧게 유지하세요!), EF의 메커니즘은 예외를 throw하고 대신 다시 시도하도록 강제하므로 영향을 줍니다. 이는 SQL Server 반복 가능한 읽기 수준에 적용되지만 쿼리된 행을 잠그지 않는 스냅샷 수준에는 적용되지 않습니다.

더 중요한 것은 이 방법을 사용하려면 트랜잭션이 모든 작업에 걸쳐 있어야 한다는 것입니다. 예를 들어 사용자에게 세부 정보를 표시하기 위해 Person을 쿼리한 다음 사용자가 변경될 때까지 기다리는 경우 트랜잭션은 잠재적으로 오랫동안 활성 상태로 유지되어야 하며 대부분의 경우 피해야 합니다. 따라서 이 메커니즘은 일반적으로 포함된 모든 작업이 즉시 실행되고 트랜잭션이 외부 입력에 의존하지 않아 기간이 늘어나게 될 때 적합합니다.

추가 리소스

충돌 검색이 있는 ASP.NET Core 샘플은 EF Core의 충돌 검색을 참조하세요.