자습서: 동시성 처리 - ASP.NET MVC 및 EF Core 사용

이전 자습서에서 데이터를 업데이트하는 방법을 배웠습니다. 이 자습서에는 여러 사용자가 동시에 같은 엔터티를 업데이트하는 경우 충돌을 처리하는 방법을 보여 줍니다.

Department 엔터티를 사용하고 동시성 오류를 처리하는 웹 페이지를 만듭니다. 다음 그림은 동시성 충돌이 발생하는 경우 표시되는 일부 메시지를 포함하여 편집 및 삭제 페이지를 보여 줍니다.

Department Edit page

Department Delete page

이 자습서에서는 다음을 수행합니다.

  • 동시성 충돌에 대해 알아보기
  • 추적 속성 추가
  • 부서 컨트롤러 및 뷰 만들기
  • 인덱스 뷰 업데이트
  • Edit 메서드 업데이트
  • 편집 뷰 업데이트
  • 동시성 충돌 테스트
  • Delete 페이지 업데이트
  • 세부 정보 및 만들기 보기 업데이트

필수 조건

동시성 충돌

한 명의 사용자가 편집하기 위해 엔터티의 데이터를 표시한 다음, 다른 사용자가 첫 번째 사용자의 변경 내용이 데이터베이스에 기록되기 전에 동일한 엔터티의 데이터를 업데이트하는 경우 동시성 충돌이 발생합니다. 이러한 충돌의 감지를 활성화하지 않는 경우 누구든지 데이터베이스를 마지막으로 업데이트하면 다른 사용자의 변경 내용을 덮어씁니다. 많은 애플리케이션에서 이 위험은 허용 가능합니다. 적은 수의 사용자 또는 적은 업데이트가 있거나 일부 변경 내용이 덮어쓰여지는지 여부가 실제로 중요하지 않은 경우 동시성에 대한 프로그래밍의 비용은 이점보다 클 수 있습니다. 이 경우 동시성 충돌을 처리하도록 애플리케이션을 구성할 필요가 없습니다.

비관적 동시성(잠금)

애플리케이션에서 동시성 시나리오에서 실수로 인한 데이터 손실을 방지할 필요가 있는 경우 해당 작업을 수행하는 한 가지 방법은 데이터베이스 잠금을 사용하는 것입니다. 이를 비관적 동시성이라고 합니다. 예를 들어 데이터베이스에서 행을 읽기 전에 읽기 전용 또는 업데이트 액세스에 대한 잠금을 요청합니다. 업데이트 액세스에 대한 행을 잠그는 경우 변경 중인 데이터의 복사본을 가져오기 때문에 다른 사용자는 읽기 전용 또는 업데이트 액세스에 대한 행을 잠그도록 허용되지 않습니다. 읽기 전용 액세스에 대한 행을 잠그는 경우 다른 사용자도 읽기 전용에 대해 잠글 수 있지만 업데이트에 대해서는 잠글 수 없습니다.

잠금 관리에는 단점이 있습니다. 프로그램을 설정하는 데 복잡할 수 있습니다. 상당한 데이터베이스 관리 리소스가 필요하며 애플리케이션의 사용자 수가 증가할 수록 성능 문제가 발생할 수 있습니다. 이러한 이유로 모든 데이터베이스 관리 시스템은 비관적 동시성을 지원하지 않습니다. Entity Framework Core는 이에 대한 기본 제공 지원을 제공하지 않으며 이 자습서에서는 구현하는 방법을 보여 주지 않습니다.

낙관적 동시성

비관적 동시성에 대한 대안은 낙관적 동시성입니다. 낙관적 동시성은 동시성 충돌 발생을 허용한 다음, 그럴 경우 적절하게 반응하는 것을 의미합니다. 예를 들어 Jane이 부서 편집 페이지를 방문하여 영어 부서 예산을 $350,000.00에서 $0.00으로 변경합니다.

Changing budget to 0

Jane이 저장을 클릭하기 전에, John이 동일한 페이지를 방문하여 시작 날짜 필드를 2007년 9월 1일에서 2013년 9월 1일로 변경합니다.

Changing start date to 2013

Jane이 먼저 저장을 클릭하여 브라우저가 인덱스 페이지로 반환될 때 변경 사항을 확인합니다.

Budget changed to zero

그런 다음, John이 예산이 여전히 $350,000.00인 편집 페이지에서 저장을 클릭합니다. 다음 작업은 동시성 충돌을 처리하는 방법에 따라 결정됩니다.

몇 가지 옵션에는 다음이 포함됩니다.

  • 사용자가 수정한 속성의 추적을 유지하고 데이터베이스에서 해당하는 열만 업데이트할 수 있습니다.

    예제 시나리오에서 서로 다른 속성이 두 사용자에 의해 업데이트되었기 때문에 데이터가 손실되지 않습니다. 다음에 누군가가 영어 부서를 찾아볼 때는 Jane과 John의 변경 내용을 모두 볼 수 있습니다. - 2013년 9월 1일의 시작 날짜 및 0달러의 예산 이 업데이트의 메서드는 데이터 손실이 발생할 수 있는 충돌 수를 줄일 수 있지만 경쟁하는 변경 내용이 동일한 엔터티의 속성에 만들어지는 경우 데이터 손실을 방지할 수 없습니다. Entity Framework가 이 방식으로 작동하는지 여부는 업데이트 코드를 구현하는 방법에 따라 달라집니다. 엔터티에 대한 모든 기존 속성 값 뿐만 아니라 새 값의 추적을 유지하기 위해 많은 양의 상태를 유지 관리해야 하므로 웹 애플리케이션에서는 종종 실용적이지 않습니다. 서버 리소스가 필요하거나 웹 페이지 자체(예: 숨겨진 필드에) 또는 cookie에 포함되어야 하기 때문에 많은 양의 상태를 유지 관리하는 것은 애플리케이션 성능에 영향을 줄 수 있습니다.

  • Jane의 변경 사항을 John의 변경 사항으로 덮어쓸 수 있습니다.

    다음에 누군가가 영어 부서를 찾아볼 때 2013년 9월 1일과 복원된 $350,000.00 값을 볼 수 있습니다. 이를 클라이언트 우선 또는 최종 우선 시나리오라고 합니다. (클라이언트의 모든 값이 데이터 저장소에 있는 값보다 우선합니다.) 이 섹션 소개에서 설명한 것처럼 동시성 처리를 위해 코딩을 수행하지 않으면 자동으로 발생합니다.

  • John의 변경 내용이 데이터베이스에서 업데이트되지 않도록 할 수 있습니다.

    일반적으로 오류 메시지를 표시하고, 데이터의 현재 상태를 보여 주고, 여전히 변경하고자 하는 경우 변경 내용을 다시 적용하도록 허용합니다. 이를 저장소 우선 시나리오라고 합니다. (데이터 저장소 값은 클라이언트가 제출한 값보다 우선합니다.) 이 자습서에서는 Store Wins 시나리오를 구현합니다. 이 메서드는 상황에 대한 경고를 받는 사용자 없이 변경 내용을 덮어쓰지 않도록 합니다.

동시성 충돌 검색

Entity Framework에서 throw하는 DbConcurrencyException 예외를 처리하여 충돌을 해결할 수 있습니다. 이러한 예외를 throw하는 시기를 확인하기 위해 Entity Framework에서 충돌을 검색할 수 있어야 합니다. 따라서 데이터베이스와 데이터 모델을 적절하게 구성해야 합니다. 충돌 검색을 활성화하기 위한 몇 가지 옵션은 다음과 같습니다.

  • 데이터베이스 테이블에서 행이 변경된 시기를 확인하는 데 사용될 수 있는 추적 열을 포함합니다. 그런 다음, SQL Update의 Where 절 또는 Delete 명령에 해당 열을 포함하도록 Entity Framework를 구성할 수 있습니다.

    추적 열의 데이터 형식은 일반적으로 rowversion입니다. rowversion 값은 행이 업데이트될 때마다 증가되는 순차적 번호입니다. Update 또는 Delete 명령에서 Where 절은 추적 열의 원래 값을 포함합니다(원래 행 버전). 업데이트되는 행이 다른 사용자에 의해 변경된 경우 rowversion 열의 값은 원래 값과 다르므로 Update 또는 Delete 문은 Where 절 때문에 업데이트할 행을 찾을 수 없습니다. Entity Framework에서 Update 또는 Delete 명령에 의해 업데이트된 행이 없다는 것을 찾은 경우(즉, 영향을 받은 행의 수가 0인 경우) 동시성 충돌로 해석합니다.

  • Update 및 Delete 명령의 Where 절에서 테이블의 모든 열의 원래 값을 포함하도록 Entity Framework를 구성합니다.

    첫 번째 옵션에서 행이 먼저 읽혔기 때문에 행의 일부가 변경된 경우 Where 절은 업데이트할 열을 반환하지 않습니다. Entity Framework는 동시성 충돌로 해석합니다. 많은 열이 있는 데이터베이스 테이블의 경우 이 방법으로 많은 Where 절이 발생할 수 있으며 많은 양의 상태를 유지 관리해야 할 수 있습니다. 앞에서 설명한 것처럼 많은 양의 상태를 유지 관리하는 것은 애플리케이션 성능에 영향을 미칠 수 있습니다. 따라서 이 방법은 일반적으로 권장되지 않으며 이 자습서에서 사용되는 방법이 아닙니다.

    동시성에 대해 이 방법을 구현하려는 경우 ConcurrencyCheck 특성을 추가하여 동시성을 추적하려는 엔터티의 모든 비 기본 키 속성을 표시해야 합니다. 해당 변경 내용을 통해 Entity Framework는 Update 및 Delete 문의 SQL Where 절에 모든 열을 포함할 수 있습니다.

이 자습서의 나머지 부분에서는 부서 엔터티에 rowversion 추적 속성을 추가하고, 컨트롤러와 보기를 만들고, 모든 항목이 올바르게 작동하는지 확인하도록 테스트합니다.

추적 속성 추가

에서 Models/Department.csRowVersion이라는 추적 속성을 추가합니다.

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    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; }

        public int? InstructorID { get; set; }

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

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

Timestamp 특성은 데이터베이스에 전송된 Update 및 Delete 명령의 Where 절에 이 열이 포함되도록 지정합니다. SQL rowversion이 대체하기 전에 이전 버전의 SQL Server가 SQL timestamp 데이터 형식을 사용했으므로 특성을 Timestamp라고 합니다. rowversion에 대한 .NET 유형은 바이트 배열입니다.

흐름 API를 사용하는 것을 선호하는 경우 IsConcurrencyToken 메서드(Data/SchoolContext.cs에서)를 사용하여 다음 예제와 같이 추적 속성을 지정할 수 있습니다.

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

속성을 추가하여 데이터베이스 모델을 변경했으므로 다른 마이그레이션을 수행해야 합니다.

변경 내용을 저장하고 프로젝트를 빌드한 다음, 명령 창에 다음 명령을 입력합니다.

dotnet ef migrations add RowVersion
dotnet ef database update

부서 컨트롤러 및 뷰 만들기

학생, 강좌 및 강사에 대해 이전에 수행한 것처럼 부서 컨트롤러와 보기를 스캐폴드합니다.

Scaffold Department

DepartmentsController.cs 파일에서 "FirstMidName"의 4개 항목을 모두 "FullName"으로 변경하여 부서 관리자 드롭다운 목록에 성이 아닌 강사의 전체 이름이 포함되도록 합니다.

ViewData["InstructorID"] = new SelectList(_context.Instructors, "ID", "FullName", department.InstructorID);

인덱스 뷰 업데이트

스캐폴딩 엔진은 인덱스 보기에 RowVersion 열을 만들었지만 해당 필드는 표시되지 않아야 합니다.

Views/Departments/Index.cshtml의 코드를 다음 코드로 바꿉니다.

@model IEnumerable<ContosoUniversity.Models.Department>

@{
    ViewData["Title"] = "Departments";
}

<h2>Departments</h2>

<p>
    <a asp-action="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.Name)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Budget)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.StartDate)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Administrator)
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model)
        {
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.Name)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Budget)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.StartDate)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Administrator.FullName)
                </td>
                <td>
                    <a asp-action="Edit" asp-route-id="@item.DepartmentID">Edit</a> |
                    <a asp-action="Details" asp-route-id="@item.DepartmentID">Details</a> |
                    <a asp-action="Delete" asp-route-id="@item.DepartmentID">Delete</a>
                </td>
            </tr>
        }
    </tbody>
</table>

이는 제목을 “Departments”로 변경하고, RowVersion 열을 삭제하고, 관리자의 이름 대신 전체 이름을 표시합니다.

Edit 메서드 업데이트

HttpGet Edit 메서드 및 Details 메서드 모두에 AsNoTracking을 추가합니다. HttpGet Edit 메서드에서 관리자에 대해 즉시 로드를 추가합니다.

var department = await _context.Departments
    .Include(i => i.Administrator)
    .AsNoTracking()
    .FirstOrDefaultAsync(m => m.DepartmentID == id);

HttpPost Edit 메서드에 대한 기존 코드를 다음 코드로 바꿉니다.

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int? id, byte[] rowVersion)
{
    if (id == null)
    {
        return NotFound();
    }

    var departmentToUpdate = await _context.Departments.Include(i => i.Administrator).FirstOrDefaultAsync(m => m.DepartmentID == id);

    if (departmentToUpdate == null)
    {
        Department deletedDepartment = new Department();
        await TryUpdateModelAsync(deletedDepartment);
        ModelState.AddModelError(string.Empty,
            "Unable to save changes. The department was deleted by another user.");
        ViewData["InstructorID"] = new SelectList(_context.Instructors, "ID", "FullName", deletedDepartment.InstructorID);
        return View(deletedDepartment);
    }

    _context.Entry(departmentToUpdate).Property("RowVersion").OriginalValue = rowVersion;

    if (await TryUpdateModelAsync<Department>(
        departmentToUpdate,
        "",
        s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
    {
        try
        {
            await _context.SaveChangesAsync();
            return RedirectToAction(nameof(Index));
        }
        catch (DbUpdateConcurrencyException ex)
        {
            var exceptionEntry = ex.Entries.Single();
            var clientValues = (Department)exceptionEntry.Entity;
            var databaseEntry = exceptionEntry.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: {databaseValues.Budget:c}");
                }
                if (databaseValues.StartDate != clientValues.StartDate)
                {
                    ModelState.AddModelError("StartDate", $"Current value: {databaseValues.StartDate:d}");
                }
                if (databaseValues.InstructorID != clientValues.InstructorID)
                {
                    Instructor databaseInstructor = await _context.Instructors.FirstOrDefaultAsync(i => i.ID == databaseValues.InstructorID);
                    ModelState.AddModelError("InstructorID", $"Current value: {databaseInstructor?.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 = (byte[])databaseValues.RowVersion;
                ModelState.Remove("RowVersion");
            }
        }
    }
    ViewData["InstructorID"] = new SelectList(_context.Instructors, "ID", "FullName", departmentToUpdate.InstructorID);
    return View(departmentToUpdate);
}

코드는 업데이트될 부서 읽기를 시도하여 시작합니다. FirstOrDefaultAsync 메서드가 Null을 반환하는 경우 부서가 다른 사용자에 의해 삭제되었습니다. 이 경우 코드는 편집 페이지가 오류 메시지와 함께 다시 표시될 수 있도록 게시된 양식 값을 사용하여 Department 엔터티를 만듭니다. 대신 Department 필드를 다시 표시하지 않고 오류 메시지만을 표시하는 경우 부서 엔터티를 다시 만들 필요가 없습니다.

보기는 숨겨진 필드에 원래 RowVersion 값을 저장하고, 이 메서드는 rowVersion 매개 변수에서 해당 값을 받습니다. SaveChanges를 호출하기 전에 엔터티에 대한 OriginalValues 컬렉션에 해당 원래 RowVersion 속성 값을 넣어야 합니다.

_context.Entry(departmentToUpdate).Property("RowVersion").OriginalValue = rowVersion;

그런 다음, Entity Framework에서 SQL UPDATE 명령을 만들 때 해당 명령은 원래 RowVersion 값이 있는 행을 찾는 WHERE 절을 포함합니다. UPDATE 명령의 영향을 받는 행이 없는 경우(행에 원래 RowVersion 값이 없음) Entity Framework는 DbUpdateConcurrencyException 예외를 throw합니다.

해당 예외에 대한 catch 블록의 코드에서 예외 개체의 Entries 속성에서 업데이트된 값이 있는 영향을 받은 부서 엔터티를 가져옵니다.

var exceptionEntry = ex.Entries.Single();

Entries 컬렉션은 하나의 EntityEntry 개체만 갖습니다. 해당 개체를 사용하여 사용자가 입력한 새 값 및 현재 데이터베이스 값을 가져올 수 있습니다.

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

코드는 사용자가 편집 페이지에서 입력한 것과 다른 데이터베이스 값이 있는 각 열에 대해 사용자 지정 오류 메시지를 추가합니다(여기에서는 간단히 하기 위해 하나의 필드만 표시됨).

var databaseValues = (Department)databaseEntry.ToObject();

if (databaseValues.Name != clientValues.Name)
{
    ModelState.AddModelError("Name", $"Current value: {databaseValues.Name}");

마지막으로 코드는 departmentToUpdateRowVersion 값을 데이터베이스에서 검색된 새 값으로 설정합니다. 이 새로운 RowVersion 값은 편집 페이지가 다시 표시되고, 다음 번에 사용자가 저장을 클릭할 때 숨겨진 필드에 저장되고, 편집 페이지의 다시 표시로 인해 발생하는 동시성 오류만 catch됩니다.

departmentToUpdate.RowVersion = (byte[])databaseValues.RowVersion;
ModelState.Remove("RowVersion");

ModelState에 이전 RowVersion 값이 있으므로 ModelState.Remove 문이 필요합니다. 보기에서 필드에 대한 ModelState 값은 모델 속성 값에 우선합니다(둘 다 있는 경우).

편집 뷰 업데이트

Views/Departments/Edit.cshtml에서 다음과 같이 변경합니다.

  • 숨겨진 필드를 추가하여 DepartmentID 속성에 대한 숨겨진 필드 바로 다음에 RowVersion 속성 값을 저장합니다.

  • 드롭다운 목록에 "관리자 선택" 옵션을 추가합니다.

@model ContosoUniversity.Models.Department

@{
    ViewData["Title"] = "Edit";
}

<h2>Edit</h2>

<h4>Department</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form asp-action="Edit">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="DepartmentID" />
            <input type="hidden" asp-for="RowVersion" />
            <div class="form-group">
                <label asp-for="Name" class="control-label"></label>
                <input asp-for="Name" class="form-control" />
                <span asp-validation-for="Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Budget" class="control-label"></label>
                <input asp-for="Budget" class="form-control" />
                <span asp-validation-for="Budget" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="StartDate" class="control-label"></label>
                <input asp-for="StartDate" class="form-control" />
                <span asp-validation-for="StartDate" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="InstructorID" class="control-label"></label>
                <select asp-for="InstructorID" class="form-control" asp-items="ViewBag.InstructorID">
                    <option value="">-- Select Administrator --</option>
                </select>
                <span asp-validation-for="InstructorID" class="text-danger"></span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-default" />
            </div>
        </form>
    </div>
</div>

<div>
    <a asp-action="Index">Back to List</a>
</div>

@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

동시성 충돌 테스트

앱을 실행하고 부서 인덱스 페이지로 이동합니다. 영어 부서에 대한 편집 하이퍼링크를 마우스 오른쪽 단추로 클릭하고 새 탭에서 열기를 선택한 다음, 영어 부서에 대한 편집 하이퍼링크를 클릭합니다. 이제 두 개의 브라우저 탭에 동일한 정보가 표시됩니다.

첫 번째 브라우저 탭의 필드를 변경하고 저장을 클릭합니다.

Department Edit page 1 after change

브라우저에 변경된 값과 인덱스 페이지가 표시됩니다.

두 번째 브라우저 탭에서 필드를 변경합니다.

Department Edit page 2 after change

저장을 클릭합니다. 오류 메시지가 표시됩니다.

Department Edit page error message

다시 저장을 클릭합니다. 두 번째 브라우저 탭에 입력한 값이 저장됩니다. 인덱스 페이지가 나타날 때 저장된 값이 표시됩니다.

Delete 페이지 업데이트

삭제 페이지의 경우 Entity Framework는 비슷한 방식으로 부서를 편집하는 사용자에 의해 발생한 동시성 충돌을 감지합니다. HttpGet Delete 메서드가 확인 보기를 표시하는 경우 보기는 숨겨진 필드에 원래 RowVersion 값을 포함합니다. 그러면 해당 값을 사용자가 삭제를 확인할 때 호출된 HttpPost Delete 메서드에 사용할 수 있습니다. Entity Framework가 SQL DELETE 명령을 만들 때 원래 RowVersion 값과 함께 WHERE 절을 포함합니다. 명령으로 인해 영향을 받은 0개의 행이 발생하는 경우(삭제 확인 페이지가 표시된 후 행이 변경되었음을 의미함) 동시성 예외가 throw되고, HttpGet Delete 메서드가 오류 메시지와 함께 확인 페이지를 다시 표시하기 위해 true로 설정된 오류 플래그와 함께 호출됩니다. 행이 다른 사용자에 의해 삭제되었기 때문에 0개의 행이 영향을 받았을 수도 있습니다. 따라서 이 경우 오류 메시지가 표시되지 않습니다.

부서 컨트롤러의 Delete 메서드 업데이트

에서 DepartmentsController.csHttpGet Delete 메서드를 다음 코드로 바꿉다.

public async Task<IActionResult> Delete(int? id, bool? concurrencyError)
{
    if (id == null)
    {
        return NotFound();
    }

    var department = await _context.Departments
        .Include(d => d.Administrator)
        .AsNoTracking()
        .FirstOrDefaultAsync(m => m.DepartmentID == id);
    if (department == null)
    {
        if (concurrencyError.GetValueOrDefault())
        {
            return RedirectToAction(nameof(Index));
        }
        return NotFound();
    }

    if (concurrencyError.GetValueOrDefault())
    {
        ViewData["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);
}

메서드는 동시성 오류가 발생한 후 페이지가 다시 표시되고 있는지 여부를 나타내는 선택적 매개 변수를 허용합니다. 이 플래그가 true이고 지정된 부서가 더 이상 존재하지 않는 경우 다른 사용자에 의해 삭제되었습니다. 이 경우 코드는 인덱스 페이지로 리디렉션합니다. 이 플래그가 true이고 부서가 존재하는 경우 다른 사용자에 의해 변경되었습니다. 이 경우 코드는 ViewData를 사용하여 보기에 오류 메시지를 보냅니다.

HttpPost Delete 메서드의 코드(DeleteConfirmed라는)를 다음 코드로 바꿉니다.

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(Department department)
{
    try
    {
        if (await _context.Departments.AnyAsync(m => m.DepartmentID == department.DepartmentID))
        {
            _context.Departments.Remove(department);
            await _context.SaveChangesAsync();
        }
        return RedirectToAction(nameof(Index));
    }
    catch (DbUpdateConcurrencyException /* ex */)
    {
        //Log the error (uncomment ex variable name and write a log.)
        return RedirectToAction(nameof(Delete), new { concurrencyError = true, id = department.DepartmentID });
    }
}

방금 바꾼 스캐폴드된 코드에서 이 메서드는 레코드 ID만 허용했습니다.

public async Task<IActionResult> DeleteConfirmed(int id)

이 매개 변수를 모델 바인더로 만든 Department 엔터티 인스턴스로 변경했습니다. 이는 레코드 키뿐만 아니라 RowVersion 속성 값에 대한 EF 액세스를 제공합니다.

public async Task<IActionResult> Delete(Department department)

또한 DeleteConfirmed에서 Delete로 작업 메서드 이름을 변경했습니다. 스캐폴드된 코드는 HttpPost 메서드에 고유한 서명을 제공하기 위해 이름 DeleteConfirmed를 사용했습니다. (CLR에는 오버로드된 메서드에 다른 메서드 매개 변수가 있어야 합니다.) 이제 서명이 고유하므로 MVC 규칙을 고수하고 HttpPost 및 HttpGet 삭제 메서드에 동일한 이름을 사용할 수 있습니다.

부서가 이미 삭제된 경우 AnyAsync 메서드에서 false를 반환하고 애플리케이션은 인덱스 메서드로 다시 이동합니다.

동시성 오류가 catch되는 경우 코드는 삭제 확인 페이지를 다시 표시하고 동시성 오류 메시지를 표시해야 함을 나타내는 플래그를 제공합니다.

삭제 보기 업데이트

에서 Views/Departments/Delete.cshtml스캐폴드된 코드를 오류 메시지 필드와 DepartmentID 및 RowVersion 속성에 대한 숨겨진 필드를 추가하는 다음 코드로 바꿉니다. 변경 내용은 강조 표시되어 있습니다.

@model ContosoUniversity.Models.Department

@{
    ViewData["Title"] = "Delete";
}

<h2>Delete</h2>

<p class="text-danger">@ViewData["ConcurrencyErrorMessage"]</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Department</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Name)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Name)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Budget)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Budget)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.StartDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.StartDate)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Administrator)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Administrator.FullName)
        </dd>
    </dl>
    
    <form asp-action="Delete">
        <input type="hidden" asp-for="DepartmentID" />
        <input type="hidden" asp-for="RowVersion" />
        <div class="form-actions no-color">
            <input type="submit" value="Delete" class="btn btn-default" /> |
            <a asp-action="Index">Back to List</a>
        </div>
    </form>
</div>

이렇게 하면 다음이 변경됩니다.

  • h2h3 제목 사이에 오류 메시지를 추가합니다.

  • Administrator 필드의 FirstMidName을 FullName으로 바꿉니다.

  • RowVersion 필드를 제거합니다.

  • RowVersion 속성에 대해 숨겨진 필드를 추가합니다.

앱을 실행하고 부서 인덱스 페이지로 이동합니다. 영어 부서에 대한 삭제 하이퍼링크를 마우스 오른쪽 단추로 클릭하고 새 탭에서 열기를 선택한 다음, 첫 번째 탭에서 영어 부서에 대한 편집 하이퍼링크를 클릭합니다.

첫 번째 창에서 값 중 하나를 변경하고, 저장을 클릭합니다.

Department Edit page after change before delete

두 번째 탭에서 삭제를 클릭합니다. 동시성 오류 메시지가 표시되며 부서 값은 데이터베이스의 현재 값으로 새로 고쳐집니다.

Department Delete confirmation page with concurrency error

삭제를 다시 클릭하면 부서가 삭제되었음을 보여 주는 인덱스 페이지로 리디렉션됩니다.

세부 정보 및 만들기 보기 업데이트

세부 정보 및 만들기 보기에서 스캐폴드된 코드를 필요에 따라 정리할 수 있습니다.

코드를 Views/Departments/Details.cshtml 바꿔 RowVersion 열을 삭제하고 관리istrator의 전체 이름을 표시합니다.

@model ContosoUniversity.Models.Department

@{
    ViewData["Title"] = "Details";
}

<h2>Details</h2>

<div>
    <h4>Department</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Name)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Name)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Budget)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Budget)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.StartDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.StartDate)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Administrator)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Administrator.FullName)
        </dd>
    </dl>
</div>
<div>
    <a asp-action="Edit" asp-route-id="@Model.DepartmentID">Edit</a> |
    <a asp-action="Index">Back to List</a>
</div>

드롭다운 목록에 선택 옵션을 추가하려면 코드를 Views/Departments/Create.cshtml 바꿉니다.

@model ContosoUniversity.Models.Department

@{
    ViewData["Title"] = "Create";
}

<h2>Create</h2>

<h4>Department</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form asp-action="Create">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <div class="form-group">
                <label asp-for="Name" class="control-label"></label>
                <input asp-for="Name" class="form-control" />
                <span asp-validation-for="Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Budget" class="control-label"></label>
                <input asp-for="Budget" class="form-control" />
                <span asp-validation-for="Budget" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="StartDate" class="control-label"></label>
                <input asp-for="StartDate" class="form-control" />
                <span asp-validation-for="StartDate" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="InstructorID" class="control-label"></label>
                <select asp-for="InstructorID" class="form-control" asp-items="ViewBag.InstructorID">
                    <option value="">-- Select Administrator --</option>
                </select>
            </div>
            <div class="form-group">
                <input type="submit" value="Create" class="btn btn-default" />
            </div>
        </form>
    </div>
</div>

<div>
    <a asp-action="Index">Back to List</a>
</div>

@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

코드 가져오기

완성된 애플리케이션을 다운로드하거나 확인합니다.

추가 리소스

EF Core에서 동시성을 처리하는 방법에 대한 자세한 내용은 동시성 충돌을 참조하세요.

다음 단계

이 자습서에서는 다음을 수행합니다.

  • 동시성 충돌에 대해 알아보기
  • 추적 속성 추가
  • 부서 컨트롤러 및 뷰 만들기
  • 인덱스 뷰 업데이트
  • Edit 메서드 업데이트
  • 편집 뷰 업데이트
  • 동시성 충돌 테스트
  • 삭제 페이지 업데이트
  • 세부 정보 및 만들기 뷰 업데이트

강사 및 학생 엔터티에 대한 계층당 테이블 상속을 구현하는 방법을 알아보려면 다음 자습서로 진행합니다.