파트 10: Entity Framework를 이용한 동시성 충돌 제어

등록일시: 2016-06-20 08:00,  수정일시: 2016-06-20 08:00
조회수: 8,209
이 문서는 ASP.NET MVC 기술을 널리 알리고자 하는 개인적인 취지로 제공되는 번역문서입니다. 이 문서에 대한 모든 저작권은 마이크로소프트에 있으며 요청이 있을 경우 언제라도 게시가 중단될 수 있습니다. 번역 내용에 오역이 존재할 수 있고 주석은 번역자 개인의 의견일 뿐이며 마이크로소프트는 이에 관한 어떠한 보장도 하지 않습니다. 번역이 완료된 이후에도 대상 제품 및 기술이 개선되거나 변경됨에 따라 원문의 내용도 변경되거나 보완되었을 수 있으므로 주의하시기 바랍니다.
이번 파트에서는 먼저 낙관적 동시성 제어와 비관적 동시성 제어의 개념을 간단하게 살펴보고, Entity Framework를 이용해서 낙관적 동시성 제어를 코드로 직접 구현해봅니다.

전체 프로젝트 다운로드PDF 다운로드

본 자습서의 Contoso University 예제 웹 응용 프로그램은 Entity Framework 6와 Visual Studio 2013을 이용해서 ASP.NET MVC 5 응용 프로그램을 구축하는 방법을 보여줍니다. 보다 자세한 정보는 본 자습서 시리즈의 첫 번째 자습서를 참고하시기 바랍니다.

지금까지 본 자습서 시리즈에서는 데이터를 갱신하는 방법 그 자체에 관해서 알아봤습니다. 이번 파트에서는 여러 사용자가 동일한 엔터티를 동일한 시점에 갱신하려고 시도할 때 발생하는 동시성 충돌을 처리하는 방법을 살펴보겠습니다.

본문에서는 Department 엔터티를 관리하는 웹 페이지들이 동시성 오류를 처리할 수 있도록 개선해봅니다. 다음 그림은 그 중 Index 페이지와 Edit 페이지를 보여주고 있는데, 동시성 오류가 발생한 경우 사용자에게 출력되는 메시지를 볼 수 있습니다.

동시성 충돌

동시성 충돌(Concurrency Conflicts)은 사용자가 특정 엔터티의 데이터를 편집하기 위해서 조회한 상태에서, 다른 사용자가 첫 번째 사용자의 변경사항이 데이터베이스에 저장되기 전에, 같은 엔터티의 데이터를 갱신했을 때 발생합니다. 이런 유형의 충돌을 별도로 처리하지 않으면 항상 가장 마지막으로 데이터베이스를 갱신한 사용자가 다른 사용자들의 변경사항을 덮어쓰게 됩니다. 그러나 대부분의 응용 프로그램들이 이 정도의 위험성은 무시하는 것이 일반적입니다. 사용자가 많지 않거나, 갱신 빈도가 높지 않아서, 또는 일부 변경사항이 덮어 쓰여도 그다지 치명적이지 않기 때문에, 오히려 동시성 관리를 위한 프로그래밍 비용이 그로 인한 이익을 훨씬 웃도는 경우가 많기 때문입니다. 이런 경우라면 응용 프로그램이 동시성 충돌을 처리할 수 있도록 구성해야 할 필요가 별로 없습니다.

비관적 동시성 제어 (잠금)

응용 프로그램이 동시성 시나리오에서 발생하는 돌발적인 데이터 유실에 대응할 수 있어야 한다면, 그 한 가지 방법으로 데이터베이스 잠금을 사용할 수 있습니다. 이 방식을 비관적 동시성 제어(Pessimistic Concurrency)라고 합니다. 이 방식에서는 데이터베이스에서 로우를 읽기 전에 먼저 읽기-전용 접근 잠금이나 업데이트 접근 잠금을 요청합니다. 만약 여러분이 특정 로우에 대해 업데이트 접근 잠금을 요청했다면, 다른 사용자들은 아무도 해당 로우에 대해 읽기-전용 접근 잠금이나 업데이트 접근 잠금을 요청할 수 없으며, 이는 변경이 진행 중인 데이터의 사본을 얻게 될 가능성이 존재하기 때문입니다. 반면 읽기-전용 접근 잠금을 요청한다면, 다른 사용자는 해당 로우에 대해 읽기-전용 접근 잠금만 요청할 수 있고 업데이트 접근 잠금은 요청할 수 없습니다.

그러나 잠금을 이용한 방식에는 단점이 존재합니다. 무엇보다 프로그램의 구현이 복잡해질 수 있습니다. 그리고 상당한 데이터베이스 관리 자원을 요구하며 응용 프로그램의 사용자의 수가 증가함에 따라 성능 상의 문제가 발생할 수도 있습니다. 이런 이유로, 모든 데이터베이스 관리 시스템이 비관적 동시성 제어를 지원하지는 않습니다. 기본적으로 Entity Framework는 비관적 동시성 제어 기능을 지원하지 않으며 본문에서도 이를 구현하는 방법은 다루지 않습니다.

낙관적 동시성 제어

비관적 동시성 제어의 대안으로 낙관적 동시성 제어(Optimistic Concurrency) 방식을 선택할 수도 있습니다. 낙관적 동시성 제어 방식에서는 일단 동시성 충돌이 발생할 수 있는 가능성 자체는 허용하고 실제로 충돌이 발생하면 그때 적절하게 대응합니다. 예를 들어서, John이 Departments Edit 페이지를 실행해서 English 학과의 Budget 필드 금액을 $350,000.00에서 $0.00으로 변경한다고 가정해보겠습니다.

그런데 John이 미처 Save 버튼을 클릭하기도 전에, Jane이 같은 페이지를 실행해서 Start Date 필드를 9/1/2007에서 8/8/2013로 변경하려고 시도하는 경우를 생각해보십시오.

잠시 후, John이 Save 버튼을 클릭하면 브라우저가 Index 페이지로 이동하고 John이 변경된 결과를 확인하는 동안, 다시 Jane이 Save 버튼을 클릭합니다. 이 시나리오의 결과는 여러분이 동시성 충돌 처리를 위해서 선택한 방식에 따라서 완전히 달라지는데, 다음과 같은 상황들이 가능합니다:

  • 먼저, 사용자가 수정한 속성을 일일이 추적해서 실제로 수정된 속성과 대응하는 데이터베이스 컬럼들만 갱신하는 방법이 있습니다. 이 방법을 선택하면 방금 예로 든 시나리오의 경우, 두 사용자가 서로 다른 속성을 수정했으므로 모든 변경내용들이 정상적으로 저장됩니다. 따라서 나중에 누군가가 English 학과를 조회하면 John이 변경한 0달러의 예산(Budget)과 Jane이 변경한 학과 개설일자(Start Date)인 8/8/2013을 모두 확인할 수 있습니다.

    이 갱신 방식을 사용하면 데이터 유실이 발생하는 충돌 횟수는 줄어들지만 같은 엔터티의 같은 속성을 동시에 변경하는 경우에는 여전히 데이터 유실을 피할 수 없습니다. Entity Framework가 이런 식으로 동작할지는 여러분이 갱신 코드를 구현하는 방법에 좌우됩니다. 다만 이 방식은 웹 응용 프로그램에서는 그다지 실용적이지 않은 경우가 많은데, 새로 입력된 값들 뿐만 아니라 엔터티에 존재하는 모든 속성의 원본 값들을 추적하려면 대부분 대량의 상태를 관리해야 하기 때문입니다. 게다가 이런 대량의 상태 관리 작업은 응용 프로그램의 성능에 영향을 미칠 수도 있습니다. 서버의 자원을 소모하는 형태로, 또는 웹 페이지 자체(숨겨진 필드 등)나 쿠키 등에 그 상태들을 저장해야만 하기 때문입니다.

  • 또는 Jane이 변경한 내용으로 John이 변경한 내용을 덮어쓰는 방법도 있습니다. 결과적으로 나중에 누군가가 English 학과를 조회해보면 변경된 학과 개설일자인 8/8/2013과 원래대로 돌아간 예산 값인 $350,000.00을 보게 됩니다. 이런 방식을 클라이언트 우선(Client Wins) 시나리오 또는 마지막 입력 우선(Last in Wins) 시나리오라고 합니다. (즉, 클라이언트에서 전달된 값이 항상 데이터 저장소의 값보다 우선권을 갖습니다.) 이번 절의 도입부에서 설명했던 것처럼 동시성 처리를 위한 코딩을 전혀 구현하지 않으면 자연스럽게 이런 결과를 얻게 됩니다.

  • 마지막으로 Jane이 변경한 내용을 데이터베이스에 반영하지 않는 방법이 있습니다. 이 방식에서는 충돌이 발생하면 대부분 오류 메시지를 출력하고 데이터의 현재 상태를 보여준 다음, 사용자가 그래도 원할 경우 다시 데이터를 변경하도록 유도합니다. 이 방식을 저장소 우선(Store Wins) 시나리오라고 합니다. (즉, 데이터 저장소에 존재하는 값이 클라이언트에서 제출한 값들보다 우선권을 갖습니다.) 본문에서는 바로 이 저장소 우선 시나리오를 구현해보려고 합니다. 이 방식은 무슨 일이 발생했는지 사용자에게 경고 없이 임의적으로 변경사항이 덮어 쓰는 일이 벌어지지 않도록 확실하게 보장해줍니다.

동시성 충돌 감지하기

동시성 충돌을 해결하기 위해서는 Entity Framework가 던지는 OptimisticConcurrencyException 예외를 처리하면 됩니다. 그러나 그 전에 먼저 Entity Framework가 충돌을 감지하고 이 예외를 던질 시점을 알 수 있어야만 합니다. 그러려면 데이터베이스와 데이터 모델을 적절하게 구성해야 하는데, 이때 선택할 수 있는 방식으로는 다음과 같은 것들이 있습니다:

  • 데이터베이스 테이블에 로우가 변경된 시점을 판단하기 위한 추적용 컬럼을 추가합니다. 그런 다음, 이 컬럼이 UpdateDelete SQL 명령의 Where 절에 포함되도록 Entity Framework를 구성합니다.

    일반적으로 추적용 컬럼의 데이터 형식으로는 rowversion 형식이 사용됩니다. rowversion 형식의 값은 매번 로우가 갱신될 때마다 순차적으로 증가하는 일련번호입니다. 구성을 마치고 나면 UpdateDelete 명령의 Where 절에 이 추적용 컬럼의 원본 값(로우의 원래 버전 값), 즉 갱신 작업을 위해서 조회했던 시점의 값이 포함됩니다. 다른 사용자의 변경작업으로 인해서 로우가 갱신될 경우, rowversion 컬럼의 값과 원본 값이 서로 달라지므로, 이 Where 절로 인해서 UpdateDelete 명령이 갱신할 로우를 찾을 수 없게 됩니다. Entity Framework는 이렇게 Update 또는 Delete 명령이 수행됐지만 갱신된 로우가 없는 경우를 발견하면 (즉, 영향을 받은 로우의 수가 0이면) 동시성 충돌 때문인 것으로 간주합니다.

  • 테이블에 존재하는 모든 컬럼들의 원본 값이 UpdateDelete 명령의 Where 절에 포함되도록 Entity Framework를 구성합니다.

    이 경우에도 첫 번째 방식처럼 최초로 로우를 조회한 뒤에 해당 로우의 어떤 컬럼이라도 변경되면, Where 절의 조건으로 인해 갱신할 대상 로우를 찾지 못하므로 Entity Framework가 동시성 충돌이 발생한 것으로 간주합니다. 그러나 데이터베이스 테이블에 많은 컬럼이 존재할 경우, 이 접근 방식으로는 Where 절이 대단히 비대해지고, 그에 따라 대량의 상태를 관리해야만 할 수도 있습니다. 이미 언급했던 것처럼, 상태를 대량으로 관리하면 응용 프로그램의 성능에 부정적인 영향을 미치게 됩니다. 그런 이유 때문에 이 접근 방식은 일반적으로 권장되지 않는 편이며 본문에서도 다루지 않습니다.

    동시성 충돌 처리를 위해서 이 접근 방식을 구현하려면 동시성을 추적할 엔터티의 모든 비-기본 키 속성에 ConcurrencyCheck 어트리뷰트를 지정해야 합니다. 그러면 Entity Framework가 UPDATE 구문의 SQL WHERE 절에 해당 컬럼들을 포함시킵니다.

본문의 이후 절들에서는 Department 엔터티에 추적용 rowversion 속성을 추가하고 컨트롤러와 뷰를 수정한 다음, 모든 기능이 정상적으로 동작하는지 테스트해봅니다.

Department 엔터티에 낙관적 동시성 제어용 속성 추가하기

먼저 Models\Department.cs 파일에 RowVersion라는 이름으로 추적용 속성을 추가합니다:

public class Department
{
    public int DepartmentID { get; set; }

    [StringLength(50, MinimumLength = 3)]
    public string Name { get; set; }

    [DataType(DataType.Currency)]
    [Column(TypeName = "money")]
    public decimal Budget { get; set; }

    [DataType(DataType.Date)]
    [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
    [Display(Name = "Start Date")]
    public DateTime StartDate { get; set; }

    [Display(Name = "Administrator")]
    public int? InstructorID { get; set; }

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

    public virtual Instructor Administrator { get; set; }
    public virtual ICollection<Course> Courses { get; set; }
}

이 코드에 사용된 Timestamp 어트리뷰트는 해당 컬럼이 데이터베이스로 전송되는 UpdateDelete 명령의 Where 절에 포함되어야 함을 나타냅니다. 이 어트리뷰트의 이름이 Timestamp인 이유는 SQL rowversion 데이터 형식이 도입되기 전까지 이전 버전의 SQL Server들에서는 SQL timestamp 데이터 형식을 사용했었기 때문입니다. rowversion에 대응하는 .NET 형식은 바이트 배열입니다.

만약 여러분이 Fluent API 방식을 선호한다면, 다음 예제에서 볼 수 있는 것처럼 IsConcurrencyToken 메서드를 이용해서 추적용 속성을 지정할 수도 있습니다:

modelBuilder.Entity<Department>()
    .Property(p => p.RowVersion).IsConcurrencyToken();

이제 속성을 추가해서 데이터베이스 모델을 변경했으므로 다시 마이그레이션을 수행해야 합니다. 패키지 관리자 콘솔(Package Manager Console)에 다음 명령들을 입력합니다:

Add-Migration RowVersion
Update-Database

Department 컨트롤러 수정하기

이번에는 Controllers\DepartmentController.cs 파일에 다음 using 구문을 추가합니다:

using System.Data.Entity.Infrastructure;

그리고 학과 관리자 드롭다운 목록에 강사의 성만 출력되는 대신 전체 이름이 출력되도록 DepartmentController.cs 파일에 네 차례 나타나는 "LastName"을 모두 "FullName"으로 변경합니다.

ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName");

그런 다음, HttpPost Edit 메서드의 기존 코드를 다음 코드로 대체합니다:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Edit(int? id, byte[] rowVersion)
{
    string[] fieldsToBind = new string[] { "Name", "Budget", "StartDate", "InstructorID", "RowVersion" };

    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }

    var departmentToUpdate = await db.Departments.FindAsync(id);
    if (departmentToUpdate == null)
    {
        Department deletedDepartment = new Department();
        TryUpdateModel(deletedDepartment, fieldsToBind);
        ModelState.AddModelError(string.Empty,
            "Unable to save changes. The department was deleted by another user.");
        ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName", deletedDepartment.InstructorID);
        return View(deletedDepartment);
    }

    if (TryUpdateModel(departmentToUpdate, fieldsToBind))
    {
        try
        {
            db.Entry(departmentToUpdate).OriginalValues["RowVersion"] = rowVersion;
            await db.SaveChangesAsync();

            return RedirectToAction("Index");
        }
        catch (DbUpdateConcurrencyException ex)
        {
            var entry = ex.Entries.Single();
            var clientValues = (Department)entry.Entity;
            var databaseEntry = entry.GetDatabaseValues();
            if (databaseEntry == null)
            {
                ModelState.AddModelError(string.Empty,
                    "Unable to save changes. The department was deleted by another user.");
            }
            else
            {
                var databaseValues = (Department)databaseEntry.ToObject();

                if (databaseValues.Name != clientValues.Name)
                    ModelState.AddModelError("Name", "Current value: "
                        + databaseValues.Name);
                if (databaseValues.Budget != clientValues.Budget)
                    ModelState.AddModelError("Budget", "Current value: "
                        + String.Format("{0:c}", databaseValues.Budget));
                if (databaseValues.StartDate != clientValues.StartDate)
                    ModelState.AddModelError("StartDate", "Current value: "
                        + String.Format("{0:d}", databaseValues.StartDate));
                if (databaseValues.InstructorID != clientValues.InstructorID)
                    ModelState.AddModelError("InstructorID", "Current value: "
                        + db.Instructors.Find(databaseValues.InstructorID).FullName);
                ModelState.AddModelError(string.Empty, "The record you attempted to edit "
                    + "was modified by another user after you got the original value. The "
                    + "edit operation was canceled and the current values in the database "
                    + "have been displayed. If you still want to edit this record, click "
                    + "the Save button again. Otherwise click the Back to List hyperlink.");
                departmentToUpdate.RowVersion = databaseValues.RowVersion;
            }
        }
        catch (RetryLimitExceededException /* dex */)
        {
            //Log the error (uncomment dex variable name and add a line here to write a log.
            ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, see your system administrator.");
        }
    }
    ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName", departmentToUpdate.InstructorID);
    return View(departmentToUpdate);
}

만약 이 예제 코드에서 FindAsync 메서드가 null을 반환한다면 이미 다른 사용자가 레코드를 삭제한 것으로 볼 수 있습니다. 이 경우에는 전송된 폼 값들을 이용해서 Department 엔터티를 생성한 다음, Edit 페이지에서 오류 메시지와 함께 값들을 다시 출력할 수 있도록 이를 뷰에 전달합니다. 물론 Department 엔터티의 필드들을 다시 출력하지 않고 오류 메시지만 출력한다면 Department 엔터티를 이런 식으로 다시 생성할 필요는 없습니다.

원본 RowVersion 값은 뷰의 숨겨진 필드에 저장되는데 이 메서드에서는 rowVersion 매개변수를 통해서 그 값을 전달받습니다. 그런 다음, SaveChanges 메서드를 호출하기 전에 반드시 엔터티의 OriginalValues 컬렉션에 이 원본 RowVersion 속성 값을 입력해야 합니다. 그러면 Entity Framework가 SQL UPDATE 명령을 생성할 때, WHERE 절에 원본 RowVersion 값을 갖고 있는 로우를 검색하는 구문을 포함시킵니다.

만약 UPDATE 명령에 의해서 영향 받은 로우가 없다면 (즉, 원본 RowVersion 값을 갖고 있는 로우가 존재하지 않으면), Entity Framework가 DbUpdateConcurrencyException 예외를 던지고, 그러면 catch 블럭의 코드에서 예외 개체로부터 관련된 Department 엔터티를 가져옵니다.

var entry = ex.Entries.Single();

이 개체의 Entity 속성에는 사용자가 입력한 새로운 값들이 담겨 있으며, GetDatabaseValues 메서드를 호출하면 현재 데이터베이스에 저장되어 있는 값들을 가져올 수 있습니다.

var clientValues = (Department)entry.Entity;
var databaseEntry = entry.GetDatabaseValues(); 

만약 다른 사용자가 데이터베이스에서 로우를 삭제했다면 GetDatabaseValues 메서드가 null을 반환합니다. 그렇지 않을 경우, 반환된 개체를 Department 클래스 형식으로 형변환 하면 Department의 속성에 접근할 수 있습니다. (이전 단계에서 이미 레코드 삭제 여부를 확인했기 때문에, FindAsync 메서드가 실행된 후, 그리고 SaveChanges 메서드가 실행되기 전에 레코드가 삭제된 경우에만 databaseEntry 변수가 null이 됩니다.)

if (databaseEntry == null)
{
    ModelState.AddModelError(string.Empty,
        "Unable to save changes. The department was deleted by another user.");
}
else
{
    var databaseValues = (Department)databaseEntry.ToObject();

그런 다음, 데이터베이스의 값과 사용자가 Edit 페이지에서 입력한 값이 서로 일치하지 않는 컬럼들에 대해 각각 사용자 지정 오류 메시지를 추가합니다:

if (databaseValues.Name != currentValues.Name)
    ModelState.AddModelError("Name", "Current value: " + databaseValues.Name);
    // ...

이어지는 장문의 오류 메시지는 어떤 일이 발생했는지, 그리고 이후에 어떻게 처리해야 하는지를 안내합니다:

ModelState.AddModelError(string.Empty, "The record you attempted to edit "
    + "was modified by another user after you got the original value. The"
    + "edit operation was canceled and the current values in the database "
    + "have been displayed. If you still want to edit this record, click "
    + "the Save button again. Otherwise click the Back to List hyperlink.");

마지막으로 Department 개체의 RowVersion 값을 데이터베이스에서 가져온 새로운 값으로 설정합니다. 새로운 RowVersion 값은 Edit 페이지가 다시 출력될 때 숨겨진 필드에 저장되며, 사용자가 다시 Save 버튼을 클릭하면 Edit 페이지가 다시 출력된 이후에 다시 발생할 수 있는 동시성 오류를 감지하는데 사용됩니다.

이제 Views\Department\Edit.cshtml 파일을 열고 DepartmentID 속성에 대한 숨겨진 필드 바로 뒤에 RowVersion 속성 값을 저장할 숨겨진 필드를 하나 더 추가하면 작업이 마무리됩니다:

@model ContosoUniversity.Models.Department

@{
    ViewBag.Title = "Edit";
}

<h2>Edit</h2>

@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()
    
    <div class="form-horizontal">
        <h4>Department</h4>
        <hr />
        @Html.ValidationSummary(true)
        @Html.HiddenFor(model => model.DepartmentID)
        @Html.HiddenFor(model => model.RowVersion)

낙관적 동시성 제어 테스트하기

응용 프로그램을 실행하고 Departments 메뉴를 선택합니다:

그리고 English 학과의 Edit 링크를 마우스 오른쪽 버튼으로 클릭하고 새 탭에서 열기(Open in new tab)를 선택한 다음, 다시 English 학과의 Edit 링크를 클릭합니다. 결과적으로 두 탭은 같은 정보를 출력하고 있는 상태가 됩니다.

브라우저의 첫 번째 탭에서 필드 값을 변경하고 Save 버튼을 클릭합니다.

그러면 브라우저가 Index 페이지로 이동해서 변경된 값을 보여줍니다.

브라우저의 두 번째 탭에서 필드 값을 변경하고 Save 버튼을 클릭합니다.

그러면 다음과 같은 오류 메시지가 나타날 것입니다:

다시 Save 버튼을 클릭합니다. 그러면 브라우저의 두 번째 탭에 입력했던 값이 첫 번째 탭에서 변경했던 데이터의 원본 값과 함께 저장됩니다. Index 페이지로 이동하고 나면 저장된 값들을 확인할 수 있습니다.

Delete 페이지 수정하기

Entity Framework는 Delete 페이지의 경우에도 비슷한 방식으로 다른 사용자가 학과 정보를 갱신함으로 인해서 발생하는 동시성 충돌을 감지합니다. 먼저 HttpGet Delete 메서드가 Delete 확인 뷰를 출력할 때 뷰의 숨겨진 필드에 원본 RowVersion 값을 저장합니다. 그러면 사용자가 삭제를 승인할 때 호출되는 HttpPost Delete 메서드에서 이 값을 사용할 수 있습니다.

그리고 Entity Framework는 이번에도 SQL DELETE 명령을 생성하면서 WHERE 절에 원본 RowVersion 값을 포함시킵니다. 만약 이 명령으로 인해서 영향을 받은 로우가 없다면 (즉, Delete 확인 페이지가 출력된 이후에 로우가 변경됐다면) 동시성 오류가 던져지고, 오류 플래그가 true로 설정된 상태로 HttpGet Delete 메서드가 호출되어 오류 메시지와 함께 Delete 확인 페이지가 다시 출력됩니다. 다른 사용자에 의해서 로우가 삭제된 경우에도 영향을 받는 로우가 없을 수 있으므로, 이 경우에는 다른 오류 메시지를 출력합니다. (역주: 이 부분은 에제 코드에 구현되어 있지 않습니다.)

이번에는 DepartmentController.cs 파일에서 HttpGet Delete 메서드의 코드를 다음 코드로 대체합니다:

public async Task<ActionResult> Delete(int? id, bool? concurrencyError)
{
    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }
    Department department = await db.Departments.FindAsync(id);
    if (department == null)
    {
        if (concurrencyError.GetValueOrDefault())
        {
            return RedirectToAction("Index");
        }
        return HttpNotFound();
    }

    if (concurrencyError.GetValueOrDefault())
    {
        ViewBag.ConcurrencyErrorMessage = "The record you attempted to delete "
            + "was modified by another user after you got the original values. "
            + "The delete operation was canceled and the current values in the "
            + "database have been displayed. If you still want to delete this "
            + "record, click the Delete button again. Otherwise "
            + "click the Back to List hyperlink.";
    }

    return View(department);
}

이 메서드는 동시성 오류가 발생해서 페이지가 다시 출력되는 상황인지를 구분하기 위한 선택적 매개변수를 받고 있습니다. 만약 이 플래그가 trueViewBag 속성을 통해서 오류 메시지가 뷰로 전달됩니다.

이번에는 HttpPost Delete 메서드의 (현재 메서드 명이 DeleteConfirmed인) 코드를 다음 코드로 대체합니다:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Delete(Department department)
{
    try
    {
        db.Entry(department).State = EntityState.Deleted;
        await db.SaveChangesAsync();
        return RedirectToAction("Index");
    }
    catch (DbUpdateConcurrencyException)
    {
        return RedirectToAction("Delete", new { concurrencyError = true, id=department.DepartmentID });
    }
    catch (DataException /* dex */)
    {
        //Log the error (uncomment dex variable name after DataException and add a line here to write a log.
        ModelState.AddModelError(string.Empty, "Unable to delete. Try again, and if the problem persists contact your system administrator.");
        return View(department);
    }
}

방금 대체한 메서드의 스캐폴드 된 기존 코드에서는 매개변수로 레코드의 ID만 전달받았었습니다:

public async Task<ActionResult> DeleteConfirmed(int id)

그러나 이제는 이 매개변수 대신 모델 바인더가 생성해주는 Department 엔터티의 인스턴스를 매개변수로 전달받습니다. 결과적으로 레코드의 키 뿐만 아니라 RowVersion 속성 값에도 접근할 수 있게 됩니다.

public async Task<ActionResult> Delete(Department department)

그리고 액션 메서드의 이름도 DeleteConfirmed에서 Delete로 변경됐습니다. 스캐폴드 된 코드에서 HttpPost Delete 메서드의 이름으로 사용되던 DeleteConfirmedHttpPost 메서드의 시그니처를 유일하게 만들기 위한 것이었습니다. (CLR에서 오버로드 된 메서드들은 서로 다른 매개변수들을 갖고 있어야 합니다.) 이제 메서드의 시그니처가 유일하므로 MVC의 규약을 준수하여 HttpPostHttpGet 삭제 메서드에 동일한 이름을 부여합니다.

만약 동시성 오류가 감지되면, 동시성 오류 메시지의 출력 여부를 지정하는 플래그와 함께 Delete 확인 페이지를 다시 출력합니다.

이번에는 Views\Department\Delete.cshtml 파일을 열고 스캐폴드 된 코드를 다음 코드로 대체합니다. 이 코드에는 페이지에 오류 메시지 필드가 추가되고, DepartmentID 속성과 RowVersion 속성에 대한 숨겨진 필드가 추가되었습니다. 변경된 부분들이 다음 코드에 강조되어 있습니다.

@model ContosoUniversity.Models.Department

@{
    ViewBag.Title = "Delete";
}

<h2>Delete</h2>

<p class="error">@ViewBag.ConcurrencyErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Department</h4>
    <hr />
    <dl class="dl-horizontal">
        <dt>
            Administrator
        </dt>

        <dd>
            @Html.DisplayFor(model => model.Administrator.FullName)
        </dd>

        <dt>
            @Html.DisplayNameFor(model => model.Name)
        </dt>

        <dd>
            @Html.DisplayFor(model => model.Name)
        </dd>

        <dt>
            @Html.DisplayNameFor(model => model.Budget)
        </dt>

        <dd>
            @Html.DisplayFor(model => model.Budget)
        </dd>

        <dt>
            @Html.DisplayNameFor(model => model.StartDate)
        </dt>

        <dd>
            @Html.DisplayFor(model => model.StartDate)
        </dd>
    </dl>

    @using (Html.BeginForm()) {
        @Html.AntiForgeryToken()
        @Html.HiddenFor(model => model.DepartmentID)
        @Html.HiddenFor(model => model.RowVersion)

        <div class="form-actions no-color">
            <input type="submit" value="Delete" class="btn btn-default" /> |
            @Html.ActionLink("Back to List", "Index")
        </div>
    }
</div>

먼저 오류 메시지가 h2 머리글과 h3 머리글 사이에 추가됐습니다:

<p class="error">@ViewBag.ConcurrencyErrorMessage</p>

그리고 Administrator 필드의 LastName 속성이 FullName 속성으로 변경되었습니다:

<dt>
    Administrator
</dt>

<dd>
    @Html.DisplayFor(model => model.Administrator.FullName)
</dd>

마지막으로 Html.BeginForm 구문 뒤에 DepartmentID 속성 및 RowVersion 속성에 대한 숨겨진 필드가 추가되었습니다:

@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.RowVersion)

다시 응용 프로그램을 실행하고, Departments 메뉴의 Index 페이지로 이동합니다. English 학과의 Delete 링크를 마우스 오른쪽 버튼으로 클릭하고 새 탭에서 열기(Open in new tab)를 선택한 다음, 다시 첫 번째 탭에서 English 학과의 Edit 링크를 클릭합니다.

그런 다음, 첫 번째 브라우저 탭에서 임의의 필드 값을 변경하고 Save 버튼을 클릭합니다:

그러면 Index 페이지에서 변경사항을 확인할 수 있습니다.

이제 두 번째 브라우저 탭에서 Delete 버튼을 클릭합니다.

그러면 동시성 오류 메시지가 나타날 것입니다. 또한 Department 엔터티의 값들이 현재 데이터베이스에 저장되어 있는 값으로 갱신되어 있음을 알 수 있습니다.

다시 Delete 버튼을 클릭하면 Index 페이지로 이동하게 되고, English 학과의 레코드가 삭제된 것을 확인할 수 있습니다.

요약

본문에서는 동시성 충돌을 처리하는 방법을 살펴봤습니다. 다양한 동시성 시나리오에 대응하기 위한 여러 가지 다른 방법들에 대한 보다 자세한 내용은 MSDN의 Optimistic Concurrency Patterns 문서와 Working with Property Values 문서를 참고하시기 바랍니다. 다음 파트에서는 Instructor 엔터티와 Student 엔터티에 대해 계층당 하나의 테이블 상속(Table-per-Hierarchy Inheritance)을 구현하는 방법을 알아봅니다.

다른 Entity Framework 리소스들에 대한 링크들은 ASP.NET Data Access - Recommended Resources 기사를 참고하시기 바랍니다.

이 기사는 2014년 2월 14일에 최초 작성되었습니다.