다음을 통해 공유


8부. ASP.NET Core에서 EF Core를 사용한 Razor Pages - 동시성

Tom Dykstra, Jon P Smith

Contoso University 웹앱은 EF Core 및 Visual Studio를 사용하여 Razor Pages 웹앱을 만드는 방법을 보여줍니다. 자습서 시리즈에 대한 정보는 첫 번째 자습서를 참조합니다.

해결할 수 없는 문제가 발생한 경우 완성된 앱을 다운로드하고 자습서를 따라 만든 코드와 해당 코드를 비교합니다.

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

동시성 충돌

동시성 충돌이 발생한 경우:

  • 사용자는 엔터티에 대한 편집 페이지를 탐색합니다.
  • 첫 번째 사용자가 데이터베이스에 변경 내용을 기록하기 전에 다른 사용자가 동일한 엔터티를 업데이트합니다.

동시성 검색이 사용하도록 설정되지 않으면 누구든지 데이터베이스를 마지막으로 업데이트하면 다른 사용자의 변경 내용을 덮어씁니다. 이 위험이 허용 가능한 경우 동시성에 대한 프로그래밍의 비용은 이점보다 클 수 있습니다.

비관적 동시성

동시성 충돌을 방지하는 한 가지 방법은 데이터베이스 잠금을 사용하는 것입니다. 이를 비관적 동시성이라고 합니다. 앱은 업데이트하려는 데이터베이스 행을 읽기 전에 잠금을 요청합니다. 업데이트 액세스를 위해 행이 잠긴 후에는 첫 번째 잠금이 해제될 때까지 다른 사용자가 행을 잠글 수 없습니다.

잠금 관리에는 단점이 있습니다. 프로그래밍하기에 복잡할 수 있으며 사용자 수가 증가하면 성능 문제가 발생할 수 있습니다. 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이 먼저 저장을 클릭하고 변경 내용이 적용되는 것을 확인합니다. 브라우저에는 예산 금액이 0인 인덱스 페이지가 표시되기 때문입니다.

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

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

    이 시나리오에서는 데이터가 손실되지 않습니다. 다른 속성이 두 사용자에 의해 업데이트되었습니다. 다음에 누군가가 영어 부서를 찾아볼 때는 Jane과 John의 변경 내용을 모두 볼 수 있습니다. 이 업데이트 메서드는 데이터 손실로 이어질 수 있는 충돌 횟수를 줄일 수 있습니다. 이 방법이 제공하는 몇 가지 단점은 다음과 같습니다.

    • 같은 속성에 변경 사항이 적용된 경우 데이터 손실을 방지할 수 없습니다.
    • 일반적으로 웹앱에서는 실현할 수 없습니다. 페치된 값과 새 값을 모두 추적하기 위해 유효한 상태를 유지해야 합니다. 많은 양의 상태를 유지하는 것은 응용 프로그램 성능에 영향을 미칠 수 있습니다.
    • 엔터티에 대한 동시성 감지보다 앱 복잡성이 증가할 수 있습니다.
  • Jane의 변경 사항을 John의 변경 사항으로 덮어쓸 수 있습니다.

    다음에 누군가가 영어 부서를 찾아볼 때 2013년 9월 1일과 페치된 $350,000.00 값을 볼 수 있습니다. 이 방법을 클라이언트 우선 또는 최종 우선 시나리오라고 합니다. 클라이언트의 모든 값은 데이터 저장소에 포함된 값보다 우선 순위를 지닙니다. 스캐폴드 코드는 동시성 처리를 수행하지 않으며 클라이언트 Wins가 자동으로 수행됩니다.

  • John의 변경 내용이 데이터베이스에서 업데이트되지 않도록 할 수 있습니다. 일반적으로 앱은:

    • 오류 메시지를 표시합니다.
    • 데이터의 현재 상태를 표시합니다.
    • 사용자가 변경 내용을 다시 적용하도록 허용합니다.

    이를 저장소 우선 시나리오라고 합니다. 데이터 저장소 값은 클라이언트가 전송한 값보다 우선 순위를 지닙니다. 이 자습서에서는 Store Wins 시나리오가 사용됩니다. 이 메서드는 사용자 알림 없이 덮어쓴 변경 내용이 없는지 확인합니다.

EF Core에서 충돌 검색

동시성 토큰으로 구성된 속성은 낙관적 동시성 제어를 구현하는데 사용됩니다. 업데이트 또는 삭제 작업이 SaveChanges 또는 SaveChangesAsync에 의해 트리거되면 데이터베이스의 동시성 토큰 값은 EF Core에서 읽은 원래 값과 비교됩니다.

  • 값이 일치하면 작업이 완료됩니다.
  • 값이 일치하지 않으면 EF Core는 다른 사용자가 충돌하는 작업을 수행했다고 가정하고 현재 트랜잭션을 중단하고 DbUpdateConcurrencyException을 throw합니다.

다른 사용자 또는 프로세스가 현재 작업과 충돌하는 작업을 수행한 경우를 동시성 충돌이라고 합니다.

관계형 데이터베이스에서는 EF Core가 동시성 충돌을 탐지하기 위하여 UPDATEDELETE문의 WHERE절 안에 있는 동시성 토큰의 값을 확인합니다.

행이 변경된 시기를 결정하는 데 사용할 수 있는 추적 열을 포함하여 충돌 검색을 사용할 수 있도록 데이터 모델을 구성해야 합니다. EF는 동시성 토큰에 대한 두 가지 방법을 제공합니다.

SQL Server 방법과 SQLite 구현 세부 정보는 약간 다릅니다. 차이점을 나열하는 자습서의 뒷부분에서 차이점 파일이 표시됩니다. Visual Studio 탭에 SQL Server 방법이 표시됩니다. Visual Studio Code 탭은 SQLite와 같은 SQL Server가 아닌 데이터베이스에 대한 방법을 보여줍니다.

  • 이 모델에서는 행이 변경된 시기를 확인하는 데 사용될 수 있는 추적 열을 포함합니다.
  • 동시성 속성에 TimestampAttribute을 적용합니다.

Models/Department.cs 다음 강조 표시된 코드로 파일을 업데이트합니다.

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[] ConcurrencyToken { get; set; }

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

TimestampAttribute은 열을 동시성 추적 열로 식별합니다. 흐름 API는 추적 속성을 지정하는 대체 방법입니다.

modelBuilder.Entity<Department>()
  .Property<byte[]>("ConcurrencyToken")
  .IsRowVersion();

엔터티 속성의 [Timestamp] 특성은 ModelBuilder 메서드에서 다음 코드를 생성합니다.

 b.Property<byte[]>("ConcurrencyToken")
     .IsConcurrencyToken()
     .ValueGeneratedOnAddOrUpdate()
     .HasColumnType("rowversion");

앞의 코드가 하는 역할은 다음과 같습니다.

  • 속성 형식 ConcurrencyToken을 바이트 배열로 설정합니다. byte[]은 SQL Server를 위해 필요합니다.
  • IsConcurrencyToken. IsConcurrencyToken은 속성을 동시성 토큰으로 구성합니다. 업데이트 시 데이터베이스에서 인스턴스가 검색된 이후 변경되지 않았는지 확인하기 위해 데이터베이스의 동시성 토큰 값을 원래 값과 비교합니다. 변경된 경우 DbUpdateConcurrencyException이 throw되고 변경 내용이 적용되지 않습니다.
  • 엔터티를 추가하거나 업데이트할 때 값이 자동으로 생성되도록 ConcurrencyToken 속성을 구성하는 ValueGeneratedOnAddOrUpdate을 호출합니다.
  • HasColumnType("rowversion")은 SQL Server 데이터베이스의 열 형식을 rowversion으로 설정합니다.

다음 코드는 Department 이름이 업데이트될 때 EF Core에서 생성된 T-SQL의 일부를 보여줍니다.

SET NOCOUNT ON;
UPDATE [Departments] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2;
SELECT [ConcurrencyToken]
FROM [Departments]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

위에 강조 표시된 코드는 ConcurrencyToken을 포함하는 WHERE 절을 보여 줍니다. 데이터베이스 ConcurrencyTokenConcurrencyToken 매개 변수 @p2와 다를 경우 행은 업데이트되지 않습니다.

다음 강조 표시된 코드는 정확히 한 개의 행이 업데이트되었음을 확인하는 T-SQL을 보여 줍니다.

SET NOCOUNT ON;
UPDATE [Departments] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2;
SELECT [ConcurrencyToken]
FROM [Departments]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

@@ROWCOUNT는 마지막 명령문의 영향을 받는 행 수를 반환합니다. 업데이트된 행이 없으면 EF Core는 DbUpdateConcurrencyException을 throw합니다.

마이그레이션 추가

ConcurrencyToken 속성을 추가하면 마이그레이션이 필요한 데이터 모델이 변경됩니다.

프로젝트를 빌드합니다.

PMC에서 다음 명령을 실행합니다.

Add-Migration RowVersion
Update-Database

이전 명령은

  • Migrations/{time stamp}_RowVersion.cs 마이그레이션 파일을 만듭니다.
  • Migrations/SchoolContextModelSnapshot.cs 파일을 업데이트. 업데이트는 다음 코드를 BuildModel 메서드에 추가합니다.
 b.Property<byte[]>("ConcurrencyToken")
     .IsConcurrencyToken()
     .ValueGeneratedOnAddOrUpdate()
     .HasColumnType("rowversion");

부서 페이지 스캐폴드

다음 예외가 포함된 학생 페이지 스캐폴드의 지침을 따릅니다.

  • Pages/Departments 폴더를 만듭니다.
  • 모델 클래스에 Department를 사용합니다.
  • 새 컨텍스트 클래스를 만드는 대신 기존 컨텍스트 클래스를 사용합니다.

유틸리티 클래스를 추가하려면

프로젝트 폴더에서 다음 코드로 Utility 클래스를 만듭니다.

namespace ContosoUniversity
{
    public static class Utility
    {
        public static string GetLastChars(byte[] token)
        {
            return token[7].ToString();
        }
    }
}

Utility 클래스는 동시성 토큰의 마지막 몇 개 문자를 표시하는 데 사용되는 GetLastChars 메서드를 제공합니다. 다음 코드는 SQLite와 SQL Server가 함께 작동하는 코드를 보여줍니다.

#if SQLiteVersion
using System;

namespace ContosoUniversity
{
    public static class Utility
    {
        public static string GetLastChars(Guid token)
        {
            return token.ToString().Substring(
                                    token.ToString().Length - 3);
        }
    }
}
#else
namespace ContosoUniversity
{
    public static class Utility
    {
        public static string GetLastChars(byte[] token)
        {
            return token[7].ToString();
        }
    }
}
#endif

#if SQLiteVersion전처리기 지시문은 SQLite와 SQL Server 버전의 차이를 격리하고 다음을 지원합니다.

  • 작성자는 두 버전 모두에 대해 하나의 코드 베이스를 유지 관리합니다.
  • SQLite 개발자는 앱을 Azure에 배포하고 SQL Azure를 사용합니다.

프로젝트를 빌드합니다.

인덱스 페이지 업데이트

스캐폴딩 도구는 인덱스 페이지에 대한 ConcurrencyToken 열을 만들지만, 해당 필드는 프로덕션 앱에 표시되지 않습니다. 이 자습서에서는 동시성 처리의 작동 방식을 보여줄 수 있는 ConcurrencyToken의 마지막 부분이 표시됩니다. 마지막 부분은 자체적으로 고유하게 보증되지 않습니다.

Pages\Departments\Index.cshtml 페이지를 업데이트합니다.

  • 인덱스를 부서로 바꿉니다.
  • 마지막 문자만 표시하도록 ConcurrencyToken을 포함하는 코드를 변경합니다.
  • FirstMidNameFullName로 교체합니다.

다음 코드에서는 업데이트된 페이지를 보여 줍니다.

@page
@model ContosoUniversity.Pages.Departments.IndexModel

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

<h2>Departments</h2>

<p>
    <a asp-page="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Name)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Budget)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].StartDate)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Administrator)
            </th>
            <th>
                Token
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model.Department)
        {
            <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>
                    @Utility.GetLastChars(item.ConcurrencyToken)
                </td>
                <td>
                    <a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
                    <a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
                    <a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
                </td>
            </tr>
        }
    </tbody>
</table>

편집 페이지 모델 업데이트

다음 코드를 사용하여 Pages/Departments/Edit.cshtml.cs을 업데이트합니다.

using ContosoUniversity.Data;
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class EditModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public EditModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        // Replace ViewData["InstructorID"] 
        public SelectList InstructorNameSL { get; set; }

        public async Task<IActionResult> OnGetAsync(int id)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)  // eager loading
                .AsNoTracking()                 // tracking not required
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                return NotFound();
            }

            // Use strongly typed data rather than ViewData.
            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FirstMidName");

            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }

            // Fetch current department from DB.
            // ConcurrencyToken may have changed.
            var departmentToUpdate = await _context.Departments
                .Include(i => i.Administrator)
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (departmentToUpdate == null)
            {
                return HandleDeletedDepartment();
            }

            // Set ConcurrencyToken to value read in OnGetAsync
            _context.Entry(departmentToUpdate).Property(
                 d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;

            if (await TryUpdateModelAsync<Department>(
                departmentToUpdate,
                "Department",
                s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
            {
                try
                {
                    await _context.SaveChangesAsync();
                    return RedirectToPage("./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. " +
                            "The department was deleted by another user.");
                        return Page();
                    }

                    var dbValues = (Department)databaseEntry.ToObject();
                    await SetDbErrorMessage(dbValues, clientValues, _context);

                    // Save the current ConcurrencyToken so next postback
                    // matches unless an new concurrency issue happens.
                    Department.ConcurrencyToken = (byte[])dbValues.ConcurrencyToken;
                    // Clear the model error for the next postback.
                    ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
                }
            }

            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FullName", departmentToUpdate.InstructorID);

            return Page();
        }

        private IActionResult HandleDeletedDepartment()
        {
            // ModelState contains the posted data because of the deletion error
            // and overides the Department instance values when displaying Page().
            ModelState.AddModelError(string.Empty,
                "Unable to save. The department was deleted by another user.");
            InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
            return Page();
        }

        private async Task SetDbErrorMessage(Department dbValues,
                Department clientValues, SchoolContext context)
        {

            if (dbValues.Name != clientValues.Name)
            {
                ModelState.AddModelError("Department.Name",
                    $"Current value: {dbValues.Name}");
            }
            if (dbValues.Budget != clientValues.Budget)
            {
                ModelState.AddModelError("Department.Budget",
                    $"Current value: {dbValues.Budget:c}");
            }
            if (dbValues.StartDate != clientValues.StartDate)
            {
                ModelState.AddModelError("Department.StartDate",
                    $"Current value: {dbValues.StartDate:d}");
            }
            if (dbValues.InstructorID != clientValues.InstructorID)
            {
                Instructor dbInstructor = await _context.Instructors
                   .FindAsync(dbValues.InstructorID);
                ModelState.AddModelError("Department.InstructorID",
                    $"Current value: {dbInstructor?.FullName}");
            }

            ModelState.AddModelError(string.Empty,
                "The record you attempted to edit "
              + "was modified by another user after you. 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.");
        }
    }
}

동시성 업데이트

OriginalValueOnGetAsync 메서드에서 페치될 때 엔터티의 ConcurrencyToken값을 사용하여 업데이트됩니다. EF Core는 원본 ConcurrencyToken 값을 포함하는 WHERE절과 함께 SQL UPDATE 명령을 생성합니다. UPDATE 명령의 영향을 받는 행이 없으면 DbUpdateConcurrencyException 예외가 throw됩니다. 원래 ConcurrencyToken 값이 있는 행이 없으면 UPDATE 명령의 영향을 받는 행이 없습니다.

public async Task<IActionResult> OnPostAsync(int id)
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

    // Fetch current department from DB.
    // ConcurrencyToken may have changed.
    var departmentToUpdate = await _context.Departments
        .Include(i => i.Administrator)
        .FirstOrDefaultAsync(m => m.DepartmentID == id);

    if (departmentToUpdate == null)
    {
        return HandleDeletedDepartment();
    }

    // Set ConcurrencyToken to value read in OnGetAsync
    _context.Entry(departmentToUpdate).Property(
         d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;

앞의 강조 표시된 코드에서 다음을 수행합니다.

  • Department.ConcurrencyToken 안의 값은 엔터티가 Edit 페이지의 Get 요청에 페치될 때의 값입니다. 이 값은 편집할 엔터티를 표시하는 Razor 페이지의 숨겨진 필드를 통해 OnPost 메서드에 제공됩니다. 숨겨진 필드 값은 모델 바인더를 통해 Department.ConcurrencyToken에 복사됩니다.
  • OriginalValue는 EF Core가 WHERE절 안에서 사용하는 것입니다. 강조 표시된 코드 줄이 실행되기 전에:
    • FirstOrDefaultAsync가 메서드 안에서 호출되었을 때 OriginalValue은 데이터베이스 안에 있던 값을 갖고 있습니다.
    • 이 값은 편집 페이지에 표시된 것과 다를 수 있습니다.
  • 강조 표시된 코드는 EF Core가 SQL UPDATE문의 WHERE절에 표시된 Department 엔터티의 원래 ConcurrencyToken 값을 사용하는지 확인합니다.

다음 코드에서는 Department모델을 보여줍니다. Department은 다음과 같은 곳에 초기화되어 있습니다.

  • EF 쿼리에 의한 OnGetAsync 메서드
  • 모델 바인딩을 사용한 Razor 페이지 안에 숨겨진 필드에 의한 OnPostAsync 메서드
public class EditModel : PageModel
{
    private readonly ContosoUniversity.Data.SchoolContext _context;

    public EditModel(ContosoUniversity.Data.SchoolContext context)
    {
        _context = context;
    }

    [BindProperty]
    public Department Department { get; set; }
    // Replace ViewData["InstructorID"] 
    public SelectList InstructorNameSL { get; set; }

    public async Task<IActionResult> OnGetAsync(int id)
    {
        Department = await _context.Departments
            .Include(d => d.Administrator)  // eager loading
            .AsNoTracking()                 // tracking not required
            .FirstOrDefaultAsync(m => m.DepartmentID == id);

        if (Department == null)
        {
            return NotFound();
        }

        // Use strongly typed data rather than ViewData.
        InstructorNameSL = new SelectList(_context.Instructors,
            "ID", "FirstMidName");

        return Page();
    }

    public async Task<IActionResult> OnPostAsync(int id)
    {
        if (!ModelState.IsValid)
        {
            return Page();
        }

        // Fetch current department from DB.
        // ConcurrencyToken may have changed.
        var departmentToUpdate = await _context.Departments
            .Include(i => i.Administrator)
            .FirstOrDefaultAsync(m => m.DepartmentID == id);

        if (departmentToUpdate == null)
        {
            return HandleDeletedDepartment();
        }

        // Set ConcurrencyToken to value read in OnGetAsync
        _context.Entry(departmentToUpdate).Property(
             d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;

위의 코드에서는 HTTP POST 요청에서 Department 엔터티의 ConcurrencyToken 값을 HTTP GET 요청의 ConcurrencyToken 값으로 설정하는 방법을 보여 줍니다.

동시성 오류가 발생하면 다음 강조 표시된 코드는 클라이언트 값(이 메서드에 게시된 값)과 데이터베이스 값을 가져옵니다.

if (await TryUpdateModelAsync<Department>(
    departmentToUpdate,
    "Department",
    s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
    try
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./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. " +
                "The department was deleted by another user.");
            return Page();
        }

        var dbValues = (Department)databaseEntry.ToObject();
        await SetDbErrorMessage(dbValues, clientValues, _context);

        // Save the current ConcurrencyToken so next postback
        // matches unless an new concurrency issue happens.
        Department.ConcurrencyToken = dbValues.ConcurrencyToken;
        // Clear the model error for the next postback.
        ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
    }

다음 코드는 데이터베이스 값이 OnPostAsync에 게시된 값과 다른 각 열에 대한 사용자 오류 메시지를 추가합니다.

private async Task SetDbErrorMessage(Department dbValues,
        Department clientValues, SchoolContext context)
{

    if (dbValues.Name != clientValues.Name)
    {
        ModelState.AddModelError("Department.Name",
            $"Current value: {dbValues.Name}");
    }
    if (dbValues.Budget != clientValues.Budget)
    {
        ModelState.AddModelError("Department.Budget",
            $"Current value: {dbValues.Budget:c}");
    }
    if (dbValues.StartDate != clientValues.StartDate)
    {
        ModelState.AddModelError("Department.StartDate",
            $"Current value: {dbValues.StartDate:d}");
    }
    if (dbValues.InstructorID != clientValues.InstructorID)
    {
        Instructor dbInstructor = await _context.Instructors
           .FindAsync(dbValues.InstructorID);
        ModelState.AddModelError("Department.InstructorID",
            $"Current value: {dbInstructor?.FullName}");
    }

    ModelState.AddModelError(string.Empty,
        "The record you attempted to edit "
      + "was modified by another user after you. 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.");
}

다음 강조 표시된 코드는 ConcurrencyToken 값을 데이터베이스에서 검색된 새 값으로 설정합니다. 다음에 사용자가 저장을 클릭하면, 편집 페이지의 마지막 표시 이후 발생한 동시성 오류만 catch됩니다.

if (await TryUpdateModelAsync<Department>(
    departmentToUpdate,
    "Department",
    s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
    try
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./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. " +
                "The department was deleted by another user.");
            return Page();
        }

        var dbValues = (Department)databaseEntry.ToObject();
        await SetDbErrorMessage(dbValues, clientValues, _context);

        // Save the current ConcurrencyToken so next postback
        // matches unless an new concurrency issue happens.
        Department.ConcurrencyToken = dbValues.ConcurrencyToken;
        // Clear the model error for the next postback.
        ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
    }

ModelState에 이전 ConcurrencyToken값이 있으므로 ModelState.Remove 문이 필요합니다. Razor 페이지에서 필드의 ModelState 값은 모델 속성 값에 우선합니다(둘 다 있는 경우).

SQL Server와 SQLite 코드의 차이점

다음은 SQL Server와 SQLite 버전의 차이점을 보여줍니다.

+ using System;    // For GUID on SQLite

+ departmentToUpdate.ConcurrencyToken = Guid.NewGuid();

 _context.Entry(departmentToUpdate)
    .Property(d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;

- Department.ConcurrencyToken = (byte[])dbValues.ConcurrencyToken;
+ Department.ConcurrencyToken = dbValues.ConcurrencyToken;

편집 Razor 페이지 업데이트

다음 코드를 사용하여 Pages/Departments/Edit.cshtml을 업데이트합니다.

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
    ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Department.DepartmentID" />
            <input type="hidden" asp-for="Department.ConcurrencyToken" />
            <div class="form-group">
                <label>Version</label>
                @Utility.GetLastChars(Model.Department.ConcurrencyToken)
            </div>
            <div class="form-group">
                <label asp-for="Department.Name" class="control-label"></label>
                <input asp-for="Department.Name" class="form-control" />
                <span asp-validation-for="Department.Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.Budget" class="control-label"></label>
                <input asp-for="Department.Budget" class="form-control" />
                <span asp-validation-for="Department.Budget" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.StartDate" class="control-label"></label>
                <input asp-for="Department.StartDate" class="form-control" />
                <span asp-validation-for="Department.StartDate" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <label class="control-label">Instructor</label>
                <select asp-for="Department.InstructorID" class="form-control"
                        asp-items="@Model.InstructorNameSL"></select>
                <span asp-validation-for="Department.InstructorID" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>
<div>
    <a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

앞의 코드가 하는 역할은 다음과 같습니다.

  • page 지시어를 @page에서 @page "{id:int}"로 업데이트합니다.
  • 숨겨진 행 버전을 추가합니다. 포스트백은 값을 바인딩하므로 ConcurrencyToken을 추가해야 합니다.
  • 디버깅을 위해 ConcurrencyToken의 마지막 바이트를 표시합니다.
  • ViewData를 강력한 형식의 InstructorNameSL로 바꿉니다.

편집 페이지로 동시성 충돌 테스트

영어 부서에 있는 편집의 두 브라우저 인스턴스를 엽니다.

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

두 개의 브라우저 탭에 동일한 정보가 표시됩니다.

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

Department Edit page 1 after change

브라우저에 변경된 값과 업데이트된 ConcurrencyToken 표시기가 있는 인덱스 페이지가 표시됩니다. 업데이트된 ConcurrencyToken 표시기는 다른 탭의 두 번째 포스트백에 표시됩니다.

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

Department Edit page 2 after change

저장을 클릭합니다. 데이터베이스 값과 일치하지 않는 모든 필드에 대한 오류 메시지가 표시됩니다.

Department Edit page error message

이 브라우저 창은 Name 필드 변경용으로 의도되지 않았습니다. 현재 값(Languages)을 복사하여 Name 필드에 붙여넣습니다. 탭아웃합니다. 클라이언트 쪽 유효성 검사를 수행하면 오류 메시지가 제거됩니다.

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

삭제 페이지 업데이트 모델

다음 코드를 사용하여 Pages/Departments/Delete.cshtml.cs을 업데이트합니다.

using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class DeleteModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public DeleteModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        public string ConcurrencyErrorMessage { get; set; }

        public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                 return NotFound();
            }

            if (concurrencyError.GetValueOrDefault())
            {
                ConcurrencyErrorMessage = "The record you attempted to delete "
                  + "was modified by another user after you selected delete. "
                  + "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.";
            }
            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            try
            {
                if (await _context.Departments.AnyAsync(
                    m => m.DepartmentID == id))
                {
                    // Department.ConcurrencyToken value is from when the entity
                    // was fetched. If it doesn't match the DB, a
                    // DbUpdateConcurrencyException exception is thrown.
                    _context.Departments.Remove(Department);
                    await _context.SaveChangesAsync();
                }
                return RedirectToPage("./Index");
            }
            catch (DbUpdateConcurrencyException)
            {
                return RedirectToPage("./Delete",
                    new { concurrencyError = true, id = id });
            }
        }
    }
}

페이지 삭제는 엔터티가 페치된 후 변경될 때 동시성 충돌을 감지합니다. Department.ConcurrencyToken은 엔터티가 페치될 때 행 버전입니다. EF Core가 SQL DELETE 명령을 만들 때 ConcurrencyToken과 함께 WHERE 절이 포함됩니다. SQL DELETE 명령의 영향을 받는 행이 없는 경우에는

  • SQL DELETE 명령의 ConcurrencyToken이 데이터베이스의 ConcurrencyToken과 일치하지 않습니다.
  • DbUpdateConcurrencyException 예외가 throw됩니다.
  • OnGetAsyncconcurrencyError를 사용하여 호출됩니다.

Delete Razor 페이지 업데이트

다음 코드를 사용하여 Pages/Departments/Delete.cshtml을 업데이트합니다.

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel

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

<h1>Delete</h1>

<p class="text-danger">@Model.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.Department.Name)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Department.Name)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.Budget)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Department.Budget)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.StartDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Department.StartDate)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.ConcurrencyToken)
        </dt>
        <dd class="col-sm-10">
            @Utility.GetLastChars(Model.Department.ConcurrencyToken)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.Administrator)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Department.Administrator.FullName)
        </dd>
    </dl>

    <form method="post">
        <input type="hidden" asp-for="Department.DepartmentID" />
        <input type="hidden" asp-for="Department.ConcurrencyToken" />
        <input type="submit" value="Delete" class="btn btn-danger" /> |
        <a asp-page="./Index">Back to List</a>
    </form>
</div>

위의 코드로 다음이 변경됩니다.

  • page 지시어를 @page에서 @page "{id:int}"로 업데이트합니다.
  • 오류 메시지를 추가합니다.
  • Administrator 필드의 FirstMidName을 FullName으로 바꿉니다.
  • 마지막 바이트를 표시하도록 ConcurrencyToken을 변경합니다.
  • 숨겨진 행 버전을 추가합니다. 포스트백은 값을 바인딩하므로 ConcurrencyToken을 추가해야 합니다.

동시성 충돌 테스트

테스트 부서를 만듭니다.

테스트 부서에 있는 삭제의 두 브라우저 인스턴스를 엽니다.

  • 앱을 실행하고 부서를 선택합니다.
  • 테스트 부서에 대한 삭제 하이퍼링크를 마우스 오른쪽 단추로 클릭하고 새 탭에서 열기를 선택합니다.
  • 테스트 부서에 대한 편집 하이퍼링크를 클릭합니다.

두 개의 브라우저 탭에 동일한 정보가 표시됩니다.

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

브라우저에 변경된 값과 업데이트된 ConcurrencyToken 표시기가 있는 인덱스 페이지가 표시됩니다. 업데이트된 ConcurrencyToken 표시기는 다른 탭의 두 번째 포스트백에 표시됩니다.

두 번째 탭에서 테스트 부서를 삭제합니다. 데이터베이스의 현재 값과 함께 동시성 오류가 표시됩니다. ConcurrencyToken이 업데이트되지 않았다면 삭제를 클릭하면 엔터티가 삭제됩니다.

추가 리소스

다음 단계

이 자습서는 시리즈의 마지막 자습서입니다. 추가 항목은 이 자습서 시리즈의 MVC 버전에서 다룹니다.

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

동시성 충돌

동시성 충돌이 발생한 경우:

  • 사용자는 엔터티에 대한 편집 페이지를 탐색합니다.
  • 첫 번째 사용자가 데이터베이스에 변경 내용을 기록하기 전에 다른 사용자가 동일한 엔터티를 업데이트합니다.

동시성 검색이 사용하도록 설정되지 않으면 누구든지 데이터베이스를 마지막으로 업데이트하면 다른 사용자의 변경 내용을 덮어씁니다. 이 위험이 허용 가능한 경우 동시성에 대한 프로그래밍의 비용은 이점보다 클 수 있습니다.

비관적 동시성(잠금)

동시성 충돌을 방지하는 한 가지 방법은 데이터베이스 잠금을 사용하는 것입니다. 이를 비관적 동시성이라고 합니다. 앱은 업데이트하려는 데이터베이스 행을 읽기 전에 잠금을 요청합니다. 업데이트 액세스를 위해 행이 잠긴 후에는 첫 번째 잠금이 해제될 때까지 다른 사용자가 행을 잠글 수 없습니다.

잠금 관리에는 단점이 있습니다. 프로그래밍하기에 복잡할 수 있으며 사용자 수가 증가하면 성능 문제가 발생할 수 있습니다. 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이 먼저 저장을 클릭하고 변경 내용이 적용되는 것을 확인합니다. 브라우저에는 예산 금액이 0인 인덱스 페이지가 표시되기 때문입니다.

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

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

    이 시나리오에서는 데이터가 손실되지 않습니다. 다른 속성이 두 사용자에 의해 업데이트되었습니다. 다음에 누군가가 영어 부서를 찾아볼 때는 Jane과 John의 변경 내용을 모두 볼 수 있습니다. 이 업데이트 메서드는 데이터 손실로 이어질 수 있는 충돌 횟수를 줄일 수 있습니다. 이 방법이 제공하는 몇 가지 단점은 다음과 같습니다.

    • 같은 속성에 변경 사항이 적용된 경우 데이터 손실을 방지할 수 없습니다.
    • 일반적으로 웹앱에서는 실현할 수 없습니다. 페치된 값과 새 값을 모두 추적하기 위해 유효한 상태를 유지해야 합니다. 많은 양의 상태를 유지하는 것은 응용 프로그램 성능에 영향을 미칠 수 있습니다.
    • 엔터티에 대한 동시성 감지보다 앱 복잡성이 증가할 수 있습니다.
  • Jane의 변경 사항을 John의 변경 사항으로 덮어쓸 수 있습니다.

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

  • John의 변경 내용이 데이터베이스에서 업데이트되지 않도록 할 수 있습니다. 일반적으로 앱은:

    • 오류 메시지를 표시합니다.
    • 데이터의 현재 상태를 표시합니다.
    • 사용자가 변경 내용을 다시 적용하도록 허용합니다.

    이를 저장소 우선 시나리오라고 합니다. (데이터 저장소 값은 클라이언트가 제출한 값보다 우선합니다.) 이 자습서에서는 Store Wins 시나리오를 구현합니다. 이 메서드는 사용자 알림 없이 덮어쓴 변경 내용이 없는지 확인합니다.

EF Core에서 충돌 검색

EF Core는 충돌을 검색할 때 DbConcurrencyException 예외를 throw합니다. 데이터 모델은 충돌 검색을 사용하도록 구성해야 합니다. 충돌 검색을 사용하도록 설정하기 위한 몇 가지 옵션은 다음과 같습니다.

  • Update 및 Delete 명령의 Where 절에서 동시성 토큰으로 구성된 열의 원래 값을 포함하도록 EF Core를 구성합니다.

    SaveChanges가 호출되면 Where 절은 ConcurrencyCheckAttribute 특성으로 주석 지정된 속성의 원래 값을 찾습니다. Update 문은 행을 처음 읽은 후 동시성 토큰 속성이 변경된 경우 업데이트할 행을 찾지 못합니다. EF Core는 이 상황을 동시성 충돌로 해석합니다. 많은 열이 있는 데이터베이스 테이블의 경우 이 방법으로 많은 Where 절이 발생할 수 있으며 많은 양의 상태가 필요할 수 있습니다. 따라서 이 방법은 일반적으로 권장되지 않으며 이 자습서에서 사용되는 방법이 아닙니다.

  • 데이터베이스 테이블에서 행이 변경된 시기를 확인하는 데 사용될 수 있는 추적 열을 포함합니다.

    SQL Server 데이터베이스에서 추적 열의 데이터 형식은 rowversion입니다. rowversion 값은 행이 업데이트될 때마다 증가되는 순차적 번호입니다. Update 또는 Delete 명령에서 Where 절은 추적 열의 원래 값을 포함합니다(원래 행 버전 번호). 업데이트되는 행이 다른 사용자에 의해 변경된 경우 rowversion 열의 값은 원래 값과 다릅니다. 이 경우 Update 또는 Delete 문은 Where 절 때문에 업데이트할 행을 찾을 수 없습니다. Update 또는 Delete 명령의 영향을 받는 행이 없는 경우 EF Core는 동시성 예외를 throw합니다.

추적 속성 추가

에서 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; }
    }
}

TimestampAttribute 특성은 열을 동시성 추적 열로 식별합니다. 흐름 API는 추적 속성을 지정하는 대체 방법입니다.

modelBuilder.Entity<Department>()
  .Property<byte[]>("RowVersion")
  .IsRowVersion();

SQL Server 데이터베이스의 경우 엔터티 속성의 [Timestamp] 특성은 바이트 배열로 정의됩니다.

  • DELETE 및 UPDATE WHERE 절에 열이 포함됩니다.
  • 데이터베이스의 열 형식을 rowversion으로 설정합니다.

데이터베이스는 행이 업데이트될 때마다 증가하는 순차적 행 버전 번호를 생성합니다. Update 또는 Delete 명령에서 Where 절은 페치된 행 버전 값을 포함합니다. 페치된 후 업데이트되는 행이 변경된 경우:

  • 현재 행 버전 값이 페치된 값과 일치하지 않습니다.
  • Where 절이 페치된 행 버전 값을 검색하므로 Update 또는 Delete 명령은 행을 찾지 않습니다.
  • DbUpdateConcurrencyException이 throw됩니다.

다음 코드는 부서 이름이 업데이트될 때 EF Core에서 생성된 T-SQL의 일부를 보여 줍니다.

SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

위에 강조 표시된 코드는 RowVersion을 포함하는 WHERE 절을 보여 줍니다. 데이터베이스 RowVersionRowVersion 매개 변수(@p2)와 다를 경우 행은 업데이트되지 않습니다.

다음 강조 표시된 코드는 정확히 한 개의 행이 업데이트되었음을 확인하는 T-SQL을 보여 줍니다.

SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

@@ROWCOUNT는 마지막 명령문의 영향을 받는 행 수를 반환합니다. 업데이트된 행이 없으면 EF Core는 DbUpdateConcurrencyException을 throw합니다.

데이터베이스 업데이트

RowVersion 속성을 추가하면 마이그레이션이 필요한 데이터 모델이 변경됩니다.

프로젝트를 빌드합니다.

  • PMC에서 다음 명령을 실행합니다.

    Add-Migration RowVersion
    

이 명령은 다음을 수행합니다.

  • Migrations/{time stamp}_RowVersion.cs 마이그레이션 파일을 만듭니다.

  • Migrations/SchoolContextModelSnapshot.cs 파일을 업데이트. 업데이트는 다음 강조 표시된 코드를 BuildModel 메서드에 추가합니다.

    modelBuilder.Entity("ContosoUniversity.Models.Department", b =>
        {
            b.Property<int>("DepartmentID")
                .ValueGeneratedOnAdd()
                .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
    
            b.Property<decimal>("Budget")
                .HasColumnType("money");
    
            b.Property<int?>("InstructorID");
    
            b.Property<string>("Name")
                .HasMaxLength(50);
    
            b.Property<byte[]>("RowVersion")
                .IsConcurrencyToken()
                .ValueGeneratedOnAddOrUpdate();
    
            b.Property<DateTime>("StartDate");
    
            b.HasKey("DepartmentID");
    
            b.HasIndex("InstructorID");
    
            b.ToTable("Department");
        });
    
  • PMC에서 다음 명령을 실행합니다.

    Update-Database
    

부서 페이지 스캐폴드

  • 다음 예외가 포함된 학생 페이지 스캐폴드의 지침을 따릅니다.

  • Pages/Departments 폴더를 만듭니다.

  • 모델 클래스에 Department를 사용합니다.

    • 새 컨텍스트 클래스를 만드는 대신 기존 컨텍스트 클래스를 사용합니다.

프로젝트를 빌드합니다.

인덱스 페이지 업데이트

스캐폴딩 도구는 인덱스 페이지에 대한 RowVersion 열을 만들지만, 해당 필드는 프로덕션 앱에 표시되지 않습니다. 이 자습서에서는 동시성 처리의 작동 방식을 보여 줄 수 있는 RowVersion의 마지막 바이트가 표시됩니다. 마지막 바이트는 자체적으로 고유하게 보장되지 않습니다.

Pages\Departments\Index.cshtml 페이지를 업데이트합니다.

  • 인덱스를 부서로 바꿉니다.
  • 바이트 배열의 마지막 바이트만 표시하도록 RowVersion을 포함하는 코드를 변경합니다.
  • FirstMidName을 FullName으로 바꿉니다.

다음 코드에서는 업데이트된 페이지를 보여 줍니다.

@page
@model ContosoUniversity.Pages.Departments.IndexModel

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

<h2>Departments</h2>

<p>
    <a asp-page="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].Name)
                </th>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].Budget)
                </th>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].StartDate)
                </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Administrator)
            </th>
            <th>
                RowVersion
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model.Department)
        {
            <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>
                    @item.RowVersion[7]
                </td>
                <td>
                    <a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
                    <a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
                    <a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
                </td>
            </tr>
        }
    </tbody>
</table>

편집 페이지 모델 업데이트

다음 코드를 사용하여 Pages/Departments/Edit.cshtml.cs을 업데이트합니다.

using ContosoUniversity.Data;
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class EditModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public EditModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        // Replace ViewData["InstructorID"] 
        public SelectList InstructorNameSL { get; set; }

        public async Task<IActionResult> OnGetAsync(int id)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)  // eager loading
                .AsNoTracking()                 // tracking not required
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                return NotFound();
            }

            // Use strongly typed data rather than ViewData.
            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FirstMidName");

            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }

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

            if (departmentToUpdate == null)
            {
                return HandleDeletedDepartment();
            }

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

            if (await TryUpdateModelAsync<Department>(
                departmentToUpdate,
                "Department",
                s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
            {
                try
                {
                    await _context.SaveChangesAsync();
                    return RedirectToPage("./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. " +
                            "The department was deleted by another user.");
                        return Page();
                    }

                    var dbValues = (Department)databaseEntry.ToObject();
                    await setDbErrorMessage(dbValues, clientValues, _context);

                    // Save the current RowVersion so next postback
                    // matches unless an new concurrency issue happens.
                    Department.RowVersion = (byte[])dbValues.RowVersion;
                    // Clear the model error for the next postback.
                    ModelState.Remove("Department.RowVersion");
                }
            }

            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FullName", departmentToUpdate.InstructorID);

            return Page();
        }

        private IActionResult HandleDeletedDepartment()
        {
            var deletedDepartment = new Department();
            // ModelState contains the posted data because of the deletion error
            // and will overide the Department instance values when displaying Page().
            ModelState.AddModelError(string.Empty,
                "Unable to save. The department was deleted by another user.");
            InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
            return Page();
        }

        private async Task setDbErrorMessage(Department dbValues,
                Department clientValues, SchoolContext context)
        {

            if (dbValues.Name != clientValues.Name)
            {
                ModelState.AddModelError("Department.Name",
                    $"Current value: {dbValues.Name}");
            }
            if (dbValues.Budget != clientValues.Budget)
            {
                ModelState.AddModelError("Department.Budget",
                    $"Current value: {dbValues.Budget:c}");
            }
            if (dbValues.StartDate != clientValues.StartDate)
            {
                ModelState.AddModelError("Department.StartDate",
                    $"Current value: {dbValues.StartDate:d}");
            }
            if (dbValues.InstructorID != clientValues.InstructorID)
            {
                Instructor dbInstructor = await _context.Instructors
                   .FindAsync(dbValues.InstructorID);
                ModelState.AddModelError("Department.InstructorID",
                    $"Current value: {dbInstructor?.FullName}");
            }

            ModelState.AddModelError(string.Empty,
                "The record you attempted to edit "
              + "was modified by another user after you. 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.");
        }
    }
}

OriginalValueOnGetAsync 메서드에서 페치될 때 엔터티의 rowVersion 값을 사용하여 업데이트됩니다. EF Core는 원본 RowVersion 값을 포함하는 WHERE 절과 함께 SQL UPDATE 명령을 생성합니다. UPDATE 명령의 영향을 받는 행이 없는 경우(행에 원래 RowVersion 값이 없음) DbUpdateConcurrencyException 예외가 throw됩니다.

public async Task<IActionResult> OnPostAsync(int id)
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

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

    if (departmentToUpdate == null)
    {
        return HandleDeletedDepartment();
    }

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

앞의 강조 표시된 코드에서 다음을 수행합니다.

  • Department.RowVersion의 값은 편집 페이지에 대한 Get 요청에서 원래 페치되었을 때 엔터티에 있었던 값입니다. 이 값은 편집할 엔터티를 표시하는 Razor 페이지의 숨겨진 필드를 통해 OnPost 메서드에 제공됩니다. 숨겨진 필드 값은 모델 바인더를 통해 Department.RowVersion에 복사됩니다.
  • OriginalValue는 EF Core가 Where 절에서 사용할 값입니다. 강조 표시된 코드 줄이 실행되기 전에 OriginalValue에는 FirstOrDefaultAsync가 이 메서드에서 호출될 때 데이터베이스에 있었던 값이 포함되며, 이 값은 편집 페이지에 표시된 값과 다를 수 있습니다.
  • 강조 표시된 코드는 EF Core가 SQL UPDATE 문의 Where 절에 표시된 Department 엔터티의 원래 RowVersion 값을 사용하는지 확인합니다.

동시성 오류가 발생하면 다음 강조 표시된 코드는 클라이언트 값(이 메서드에 게시된 값)과 데이터베이스 값을 가져옵니다.

if (await TryUpdateModelAsync<Department>(
    departmentToUpdate,
    "Department",
    s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
    try
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./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. " +
                "The department was deleted by another user.");
            return Page();
        }

        var dbValues = (Department)databaseEntry.ToObject();
        await setDbErrorMessage(dbValues, clientValues, _context);

        // Save the current RowVersion so next postback
        // matches unless an new concurrency issue happens.
        Department.RowVersion = (byte[])dbValues.RowVersion;
        // Clear the model error for the next postback.
        ModelState.Remove("Department.RowVersion");
    }

다음 코드는 데이터베이스 값이 OnPostAsync에 게시된 값과 다른 각 열에 대한 사용자 오류 메시지를 추가합니다.

private async Task setDbErrorMessage(Department dbValues,
        Department clientValues, SchoolContext context)
{

    if (dbValues.Name != clientValues.Name)
    {
        ModelState.AddModelError("Department.Name",
            $"Current value: {dbValues.Name}");
    }
    if (dbValues.Budget != clientValues.Budget)
    {
        ModelState.AddModelError("Department.Budget",
            $"Current value: {dbValues.Budget:c}");
    }
    if (dbValues.StartDate != clientValues.StartDate)
    {
        ModelState.AddModelError("Department.StartDate",
            $"Current value: {dbValues.StartDate:d}");
    }
    if (dbValues.InstructorID != clientValues.InstructorID)
    {
        Instructor dbInstructor = await _context.Instructors
           .FindAsync(dbValues.InstructorID);
        ModelState.AddModelError("Department.InstructorID",
            $"Current value: {dbInstructor?.FullName}");
    }

    ModelState.AddModelError(string.Empty,
        "The record you attempted to edit "
      + "was modified by another user after you. 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.");
}

다음 강조 표시된 코드는 RowVersion 값을 데이터베이스에서 검색된 새 값으로 설정합니다. 다음에 사용자가 저장을 클릭하면, 편집 페이지의 마지막 표시 이후 발생한 동시성 오류만 catch됩니다.

if (await TryUpdateModelAsync<Department>(
    departmentToUpdate,
    "Department",
    s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
    try
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./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. " +
                "The department was deleted by another user.");
            return Page();
        }

        var dbValues = (Department)databaseEntry.ToObject();
        await setDbErrorMessage(dbValues, clientValues, _context);

        // Save the current RowVersion so next postback
        // matches unless an new concurrency issue happens.
        Department.RowVersion = (byte[])dbValues.RowVersion;
        // Clear the model error for the next postback.
        ModelState.Remove("Department.RowVersion");
    }

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

편집 페이지 업데이트

다음 코드를 사용하여 Pages/Departments/Edit.cshtml을 업데이트합니다.

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
    ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Department.DepartmentID" />
            <input type="hidden" asp-for="Department.RowVersion" />
            <div class="form-group">
                <label>RowVersion</label>
                @Model.Department.RowVersion[7]
            </div>
            <div class="form-group">
                <label asp-for="Department.Name" class="control-label"></label>
                <input asp-for="Department.Name" class="form-control" />
                <span asp-validation-for="Department.Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.Budget" class="control-label"></label>
                <input asp-for="Department.Budget" class="form-control" />
                <span asp-validation-for="Department.Budget" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.StartDate" class="control-label"></label>
                <input asp-for="Department.StartDate" class="form-control" />
                <span asp-validation-for="Department.StartDate" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <label class="control-label">Instructor</label>
                <select asp-for="Department.InstructorID" class="form-control"
                        asp-items="@Model.InstructorNameSL"></select>
                <span asp-validation-for="Department.InstructorID" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>
<div>
    <a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

앞의 코드가 하는 역할은 다음과 같습니다.

  • page 지시어를 @page에서 @page "{id:int}"로 업데이트합니다.
  • 숨겨진 행 버전을 추가합니다. 포스트백은 값을 바인딩하므로 RowVersion을 추가해야 합니다.
  • 디버깅을 위해 RowVersion의 마지막 바이트를 표시합니다.
  • ViewData를 강력한 형식의 InstructorNameSL로 바꿉니다.

편집 페이지로 동시성 충돌 테스트

영어 부서에 있는 편집의 두 브라우저 인스턴스를 엽니다.

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

두 개의 브라우저 탭에 동일한 정보가 표시됩니다.

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

Department Edit page 1 after change

브라우저에 변경된 값과 업데이트된 rowVersion 표시기가 있는 인덱스 페이지가 표시됩니다. 업데이트된 rowVersion 표시기는 다른 탭의 두 번째 포스트백에 표시됩니다.

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

Department Edit page 2 after change

저장을 클릭합니다. 데이터베이스 값과 일치하지 않는 모든 필드에 대한 오류 메시지가 표시됩니다.

Department Edit page error message

이 브라우저 창은 Name 필드 변경용으로 의도되지 않았습니다. 현재 값(Languages)을 복사하여 Name 필드에 붙여넣습니다. 탭아웃합니다. 클라이언트 쪽 유효성 검사를 수행하면 오류 메시지가 제거됩니다.

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

삭제 페이지 업데이트 모델

다음 코드를 사용하여 Pages/Departments/Delete.cshtml.cs을 업데이트합니다.

using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class DeleteModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public DeleteModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        public string ConcurrencyErrorMessage { get; set; }

        public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                 return NotFound();
            }

            if (concurrencyError.GetValueOrDefault())
            {
                ConcurrencyErrorMessage = "The record you attempted to delete "
                  + "was modified by another user after you selected delete. "
                  + "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.";
            }
            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            try
            {
                if (await _context.Departments.AnyAsync(
                    m => m.DepartmentID == id))
                {
                    // Department.rowVersion value is from when the entity
                    // was fetched. If it doesn't match the DB, a
                    // DbUpdateConcurrencyException exception is thrown.
                    _context.Departments.Remove(Department);
                    await _context.SaveChangesAsync();
                }
                return RedirectToPage("./Index");
            }
            catch (DbUpdateConcurrencyException)
            {
                return RedirectToPage("./Delete",
                    new { concurrencyError = true, id = id });
            }
        }
    }
}

페이지 삭제는 엔터티가 페치된 후 변경될 때 동시성 충돌을 감지합니다. Department.RowVersion은 엔터티가 페치될 때 행 버전입니다. EF Core가 SQL DELETE 명령을 만들 때 RowVersion과 함께 WHERE 절이 포함됩니다. SQL DELETE 명령의 영향을 받는 행이 없는 경우:

  • SQL DELETE 명령의 RowVersion이 데이터베이스의 RowVersion과 일치하지 않습니다.
  • DbUpdateConcurrencyException 예외가 throw됩니다.
  • OnGetAsyncconcurrencyError를 사용하여 호출됩니다.

Delete 페이지 업데이트

다음 코드를 사용하여 Pages/Departments/Delete.cshtml을 업데이트합니다.

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel

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

<h2>Delete</h2>

<p class="text-danger">@Model.ConcurrencyErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Department</h4>
    <hr />
    <dl class="dl-horizontal">
        <dt>
            @Html.DisplayNameFor(model => model.Department.Name)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Name)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.Budget)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Budget)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.StartDate)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.StartDate)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.RowVersion)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.RowVersion[7])
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.Administrator)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Administrator.FullName)
        </dd>
    </dl>
    
    <form method="post">
        <input type="hidden" asp-for="Department.DepartmentID" />
        <input type="hidden" asp-for="Department.RowVersion" />
        <div class="form-actions no-color">
            <input type="submit" value="Delete" class="btn btn-danger" /> |
            <a asp-page="./Index">Back to List</a>
        </div>
</form>
</div>

위의 코드로 다음이 변경됩니다.

  • page 지시어를 @page에서 @page "{id:int}"로 업데이트합니다.
  • 오류 메시지를 추가합니다.
  • Administrator 필드의 FirstMidName을 FullName으로 바꿉니다.
  • 마지막 바이트를 표시하도록 RowVersion을 변경합니다.
  • 숨겨진 행 버전을 추가합니다. 포스트백은 값을 바인딩하므로 RowVersion을 추가해야 합니다.

동시성 충돌 테스트

테스트 부서를 만듭니다.

테스트 부서에 있는 삭제의 두 브라우저 인스턴스를 엽니다.

  • 앱을 실행하고 부서를 선택합니다.
  • 테스트 부서에 대한 삭제 하이퍼링크를 마우스 오른쪽 단추로 클릭하고 새 탭에서 열기를 선택합니다.
  • 테스트 부서에 대한 편집 하이퍼링크를 클릭합니다.

두 개의 브라우저 탭에 동일한 정보가 표시됩니다.

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

브라우저에 변경된 값과 업데이트된 rowVersion 표시기가 있는 인덱스 페이지가 표시됩니다. 업데이트된 rowVersion 표시기는 다른 탭의 두 번째 포스트백에 표시됩니다.

두 번째 탭에서 테스트 부서를 삭제합니다. 데이터베이스의 현재 값과 함께 동시성 오류가 표시됩니다. RowVersion이 업데이트되지 않았다면 삭제를 클릭하면 엔터티가 삭제됩니다.

추가 리소스

다음 단계

이 자습서는 시리즈의 마지막 자습서입니다. 추가 항목은 이 자습서 시리즈의 MVC 버전에서 다룹니다.

이 자습서에는 여러 사용자가 동시에(같은 시간에) 엔터티를 업데이트하는 경우 충돌을 처리하는 방법을 보여 줍니다. 해결할 수 없는 문제가 발생할 경우 완성된 앱을 다운로드하거나 봅니다.지침을 다운로드합니다.

동시성 충돌

동시성 충돌이 발생한 경우:

  • 사용자는 엔터티에 대한 편집 페이지를 탐색합니다.
  • 첫 번째 사용자가 DB에 변경 내용을 기록하기 전에 다른 사용자가 동일한 엔터티를 업데이트합니다.

동시성 감지가 활성화되지 않으면 동시 업데이트 시 다음이 발생합니다.

  • 마지막 업데이트가 적용됩니다. 즉, 마지막 업데이트 값이 DB에 저장됩니다.
  • 현재 업데이트 중 첫 번째 작업이 손실됩니다.

낙관적 동시성

낙관적 동시성은 동시성 충돌 발생을 허용하고, 이에 적절하게 반응합니다. 예를 들어, 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인 편집 페이지에서 저장을 클릭합니다. 다음 작업은 동시성 충돌을 처리하는 방법에 따라 결정됩니다.

낙관적 동시성에는 다음과 같은 옵션이 포함됩니다.

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

    이 시나리오에서는 데이터가 손실되지 않습니다. 다른 속성이 두 사용자에 의해 업데이트되었습니다. 다음에 누군가가 영어 부서를 찾아볼 때는 Jane과 John의 변경 내용을 모두 볼 수 있습니다. 이 업데이트 메서드는 데이터 손실로 이어질 수 있는 충돌 횟수를 줄일 수 있습니다. 이 방법은:

    • 같은 속성에 변경 사항이 적용된 경우 데이터 손실을 방지할 수 없습니다.
    • 일반적으로 웹앱에서는 실현할 수 없습니다. 페치된 값과 새 값을 모두 추적하기 위해 유효한 상태를 유지해야 합니다. 많은 양의 상태를 유지하는 것은 응용 프로그램 성능에 영향을 미칠 수 있습니다.
    • 엔터티에 대한 동시성 감지보다 앱 복잡성이 증가할 수 있습니다.
  • Jane의 변경 사항을 John의 변경 사항으로 덮어쓸 수 있습니다.

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

  • John의 변경 내용이 DB에서 업데이트되지 않도록 할 수 있습니다. 일반적으로 앱은:

    • 오류 메시지를 표시합니다.
    • 데이터의 현재 상태를 표시합니다.
    • 사용자가 변경 내용을 다시 적용하도록 허용합니다.

    이를 저장소 우선 시나리오라고 합니다. (데이터 저장소 값은 클라이언트가 제출한 값보다 우선합니다.) 이 자습서에서는 Store Wins 시나리오를 구현합니다. 이 메서드는 사용자 알림 없이 덮어쓴 변경 내용이 없는지 확인합니다.

동시성 처리

속성이 동시성 토큰으로 구성되는 경우:

DbUpdateConcurrencyException의 throw를 지원하도록 DB 및 데이터 모델을 구성해야 합니다.

속성에서 동시성 충돌 감지

동시성 충돌은 ConcurrencyCheck 특성을 사용하여 속성 수준에서 감지될 수 있습니다. 특성은 모델에서 여러 속성에 적용할 수 있습니다. 자세한 내용은 데이터 주석 - ConcurrencyCheck를 참조하세요.

[ConcurrencyCheck] 특성은 이 자습서에서 사용되지 않습니다.

행에서 동시성 충돌 감지

동시성 충돌을 감지하기 위해 rowversion 추적 열이 모델에 추가됩니다. rowversion :

  • SQL Server 한정적입니다. 다른 데이터베이스는 유사한 기능을 제공하지 않을 수 있습니다.
  • DB에서 페치된 이후로 엔터티가 변경되지 않았는지를 확인하는 데 사용됩니다.

DB는 행이 업데이트될 때마다 증가되는 순차적 rowversion 번호를 생성합니다. Update 또는 Delete 명령에서 Where 절은 rowversion의 페치된 값을 포함합니다. 업데이트되는 행이 변경되는 경우:

  • rowversion은 페치된 값에 일치하지 않습니다.
  • Update 또는 Delete 명령은 Where 절이 페치된 rowversion을 포함하므로 행을 찾지 않습니다.
  • DbUpdateConcurrencyException이 throw됩니다.

EF Core에서 Update 또는 Delete 명령에 의해 업데이트된 행이 없는 경우 동시성 예외가 throw됩니다.

추적 속성을 부서 엔터티에 추가

에서 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; }
    }
}

타임스탬프 특성은 이 열이 UpdateDelete 명령의 Where 절에 포함되어 있음을 지정합니다. SQL rowversion 형식이 대체하기 전에 이전 버전의 SQL Server가 SQL timestamp 데이터 형식을 사용했으므로 특성은 Timestamp라고 합니다.

또한 흐름 API가 추적 속성을 지정할 수 있습니다.

modelBuilder.Entity<Department>()
  .Property<byte[]>("RowVersion")
  .IsRowVersion();

다음 코드는 부서 이름이 업데이트될 때 EF Core에서 생성된 T-SQL의 일부를 보여 줍니다.

SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

위에 강조 표시된 코드는 RowVersion을 포함하는 WHERE 절을 보여 줍니다. DB RowVersionRowVersion 매개 변수(@p2)와 다를 경우 행은 업데이트되지 않습니다.

다음 강조 표시된 코드는 정확히 한 개의 행이 업데이트되었음을 확인하는 T-SQL을 보여 줍니다.

SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

@@ROWCOUNT는 마지막 명령문의 영향을 받는 행 수를 반환합니다. 업데이트된 행이 없으므로 EF Core는 DbUpdateConcurrencyException을 throw합니다.

Visual Studio의 출력 창에서 T-SQL EF Core 생성을 확인할 수 있습니다.

DB 업데이트

RowVersion 속성을 추가하면 마이그레이션이 필요한 DB 모델이 변경됩니다.

프로젝트를 빌드합니다. 명령 창에서 다음을 입력합니다.

dotnet ef migrations add RowVersion
dotnet ef database update

이전 명령은

  • Migrations/{time stamp}_RowVersion.cs 마이그레이션 파일을 추가합니다.

  • Migrations/SchoolContextModelSnapshot.cs 파일을 업데이트. 업데이트는 다음 강조 표시된 코드를 BuildModel 메서드에 추가합니다.

  • 마이그레이션을 실행하여 DB를 업데이트합니다.

부서 모델 스캐폴드

학생 모델 스캐폴드의 지침을 따르고 Department를 모델 클래스로 사용합니다.

위의 명령은 Department 모델을 스캐폴드합니다. Visual Studio에서 프로젝트를 엽니다.

프로젝트를 빌드합니다.

부서 인덱스 페이지 업데이트

스캐폴딩 엔진은 인덱스 페이지에 대한 RowVersion 열을 만들지만, 해당 필드는 표시되지 않아야 합니다. 이 자습서에서는 동시성을 이해하는 데 도움을 주기 위해 RowVersion의 마지막 바이트가 표시됩니다. 마지막 바이트는 고유하게 보장되지 않습니다. 실제 앱에서는 RowVersion 또는 RowVersion의 마지막 바이트가 표시되지 않습니다.

인덱스 페이지를 업데이트합니다.

  • 인덱스를 부서로 바꿉니다.
  • RowVersion을 포함하는 표시를 RowVersion의 마지막 바이트로 바꿉니다.
  • FirstMidName을 FullName으로 바꿉니다.

다음 표시는 업데이트된 페이지를 보여 줍니다.

@page
@model ContosoUniversity.Pages.Departments.IndexModel

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

<h2>Departments</h2>

<p>
    <a asp-page="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].Name)
                </th>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].Budget)
                </th>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].StartDate)
                </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Administrator)
            </th>
            <th>
                RowVersion
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
@foreach (var item in Model.Department) {
        <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>
                @item.RowVersion[7]
            </td>
            <td>
                <a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
                <a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
                <a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
            </td>
        </tr>
}
    </tbody>
</table>

편집 페이지 모델 업데이트

다음 코드를 사용하여 Pages/Departments/Edit.cshtml.cs을 업데이트합니다.

using ContosoUniversity.Data;
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class EditModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public EditModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        // Replace ViewData["InstructorID"] 
        public SelectList InstructorNameSL { get; set; }

        public async Task<IActionResult> OnGetAsync(int id)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)  // eager loading
                .AsNoTracking()                 // tracking not required
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                return NotFound();
            }

            // Use strongly typed data rather than ViewData.
            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FirstMidName");

            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }

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

            // null means Department was deleted by another user.
            if (departmentToUpdate == null)
            {
                return HandleDeletedDepartment();
            }

            // Update the RowVersion to the value when this entity was
            // fetched. If the entity has been updated after it was
            // fetched, RowVersion won't match the DB RowVersion and
            // a DbUpdateConcurrencyException is thrown.
            // A second postback will make them match, unless a new 
            // concurrency issue happens.
            _context.Entry(departmentToUpdate)
                .Property("RowVersion").OriginalValue = Department.RowVersion;

            if (await TryUpdateModelAsync<Department>(
                departmentToUpdate,
                "Department",
                s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
            {
                try
                {
                    await _context.SaveChangesAsync();
                    return RedirectToPage("./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. " +
                            "The department was deleted by another user.");
                        return Page();
                    }

                    var dbValues = (Department)databaseEntry.ToObject();
                    await SetDbErrorMessage(dbValues, clientValues, _context);

                    // Save the current RowVersion so next postback
                    // matches unless an new concurrency issue happens.
                    Department.RowVersion = (byte[])dbValues.RowVersion;
                    // Must clear the model error for the next postback.
                    ModelState.Remove("Department.RowVersion");
                }
            }

            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FullName", departmentToUpdate.InstructorID);

            return Page();
        }

        private IActionResult HandleDeletedDepartment()
        {
            // ModelState contains the posted data because of the deletion error and will overide the Department instance values when displaying Page().
            ModelState.AddModelError(string.Empty,
                "Unable to save. The department was deleted by another user.");
            InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
            return Page();
        }

        private async Task SetDbErrorMessage(Department dbValues,
                Department clientValues, SchoolContext context)
        {

            if (dbValues.Name != clientValues.Name)
            {
                ModelState.AddModelError("Department.Name",
                    $"Current value: {dbValues.Name}");
            }
            if (dbValues.Budget != clientValues.Budget)
            {
                ModelState.AddModelError("Department.Budget",
                    $"Current value: {dbValues.Budget:c}");
            }
            if (dbValues.StartDate != clientValues.StartDate)
            {
                ModelState.AddModelError("Department.StartDate",
                    $"Current value: {dbValues.StartDate:d}");
            }
            if (dbValues.InstructorID != clientValues.InstructorID)
            {
                Instructor dbInstructor = await _context.Instructors
                   .FindAsync(dbValues.InstructorID);
                ModelState.AddModelError("Department.InstructorID",
                    $"Current value: {dbInstructor?.FullName}");
            }

            ModelState.AddModelError(string.Empty,
                "The record you attempted to edit "
              + "was modified by another user after you. 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.");
        }
    }
}

동시성 문제를 감지하기 위해 OriginalValue가 페치된 엔터티의 rowVersion 값으로 업데이트됩니다. EF Core는 원본 RowVersion 값을 포함하는 WHERE 절과 함께 SQL UPDATE 명령을 생성합니다. UPDATE 명령의 영향을 받는 행이 없는 경우(행에 원래 RowVersion 값이 없음) DbUpdateConcurrencyException 예외가 throw됩니다.

public async Task<IActionResult> OnPostAsync(int id)
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

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

    // null means Department was deleted by another user.
    if (departmentToUpdate == null)
    {
        return HandleDeletedDepartment();
    }

    // Update the RowVersion to the value when this entity was
    // fetched. If the entity has been updated after it was
    // fetched, RowVersion won't match the DB RowVersion and
    // a DbUpdateConcurrencyException is thrown.
    // A second postback will make them match, unless a new 
    // concurrency issue happens.
    _context.Entry(departmentToUpdate)
        .Property("RowVersion").OriginalValue = Department.RowVersion;

위의 코드에서 Department.RowVersion은 엔터티가 페치될 때 값입니다. OriginalValueFirstOrDefaultAsync가 이 메서드에서 호출될 때 DB의 값입니다.

다음 코드는 클라이언트 값(이 메서드에 게시된 값) 및 DB 값을 가져옵니다.

try
{
    await _context.SaveChangesAsync();
    return RedirectToPage("./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. " +
            "The department was deleted by another user.");
        return Page();
    }

    var dbValues = (Department)databaseEntry.ToObject();
    await SetDbErrorMessage(dbValues, clientValues, _context);

    // Save the current RowVersion so next postback
    // matches unless an new concurrency issue happens.
    Department.RowVersion = (byte[])dbValues.RowVersion;
    // Must clear the model error for the next postback.
    ModelState.Remove("Department.RowVersion");
}

다음 코드는 DB 값이 OnPostAsync에 게시된 값과 다른 각 열에 대한 사용자 오류 메시지를 추가합니다.

private async Task SetDbErrorMessage(Department dbValues,
        Department clientValues, SchoolContext context)
{

    if (dbValues.Name != clientValues.Name)
    {
        ModelState.AddModelError("Department.Name",
            $"Current value: {dbValues.Name}");
    }
    if (dbValues.Budget != clientValues.Budget)
    {
        ModelState.AddModelError("Department.Budget",
            $"Current value: {dbValues.Budget:c}");
    }
    if (dbValues.StartDate != clientValues.StartDate)
    {
        ModelState.AddModelError("Department.StartDate",
            $"Current value: {dbValues.StartDate:d}");
    }
    if (dbValues.InstructorID != clientValues.InstructorID)
    {
        Instructor dbInstructor = await _context.Instructors
           .FindAsync(dbValues.InstructorID);
        ModelState.AddModelError("Department.InstructorID",
            $"Current value: {dbInstructor?.FullName}");
    }

    ModelState.AddModelError(string.Empty,
        "The record you attempted to edit "
      + "was modified by another user after you. 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.");
}

다음 강조 표시된 코드는 RowVersion 값을 DB에서 검색된 새 값으로 설정합니다. 다음에 사용자가 저장을 클릭하면, 편집 페이지의 마지막 표시 이후 발생한 동시성 오류만 catch됩니다.

try
{
    await _context.SaveChangesAsync();
    return RedirectToPage("./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. " +
            "The department was deleted by another user.");
        return Page();
    }

    var dbValues = (Department)databaseEntry.ToObject();
    await SetDbErrorMessage(dbValues, clientValues, _context);

    // Save the current RowVersion so next postback
    // matches unless an new concurrency issue happens.
    Department.RowVersion = (byte[])dbValues.RowVersion;
    // Must clear the model error for the next postback.
    ModelState.Remove("Department.RowVersion");
}

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

편집 페이지 업데이트

다음 태그를 사용하여 Pages/Departments/Edit.cshtml을 업데이트합니다.

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
    ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Department.DepartmentID" />
            <input type="hidden" asp-for="Department.RowVersion" />
            <div class="form-group">
                <label>RowVersion</label>
                @Model.Department.RowVersion[7]
            </div>
            <div class="form-group">
                <label asp-for="Department.Name" class="control-label"></label>
                <input asp-for="Department.Name" class="form-control" />
                <span asp-validation-for="Department.Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.Budget" class="control-label"></label>
                <input asp-for="Department.Budget" class="form-control" />
                <span asp-validation-for="Department.Budget" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.StartDate" class="control-label"></label>
                <input asp-for="Department.StartDate" class="form-control" />
                <span asp-validation-for="Department.StartDate" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <label class="control-label">Instructor</label>
                <select asp-for="Department.InstructorID" class="form-control"
                        asp-items="@Model.InstructorNameSL"></select>
                <span asp-validation-for="Department.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-page="./Index">Back to List</a>
</div>
@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

위의 표시는:

  • page 지시어를 @page에서 @page "{id:int}"로 업데이트합니다.
  • 숨겨진 행 버전을 추가합니다. 포스트백은 값을 바인딩하므로 RowVersion을 추가해야 합니다.
  • 디버깅을 위해 RowVersion의 마지막 바이트를 표시합니다.
  • ViewData를 강력한 형식의 InstructorNameSL로 바꿉니다.

편집 페이지로 동시성 충돌 테스트

영어 부서에 있는 편집의 두 브라우저 인스턴스를 엽니다.

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

두 개의 브라우저 탭에 동일한 정보가 표시됩니다.

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

Department Edit page 1 after change

브라우저에 변경된 값과 업데이트된 rowVersion 표시기가 있는 인덱스 페이지가 표시됩니다. 업데이트된 rowVersion 표시기는 다른 탭의 두 번째 포스트백에 표시됩니다.

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

Department Edit page 2 after change

저장을 클릭합니다. DB 값과 일치하지 않는 모든 필드에 대한 오류 메시지가 표시됩니다.

Department Edit page error message 1

이 브라우저 창은 Name 필드 변경용으로 의도되지 않았습니다. 현재 값(Languages)을 복사하여 Name 필드에 붙여넣습니다. 탭아웃합니다. 클라이언트 쪽 유효성 검사를 수행하면 오류 메시지가 제거됩니다.

Department Edit page error message 2

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

Delete 페이지 업데이트

다음 코드로 삭제 페이지 모델을 업데이트합니다.

using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class DeleteModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public DeleteModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        public string ConcurrencyErrorMessage { get; set; }

        public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                 return NotFound();
            }

            if (concurrencyError.GetValueOrDefault())
            {
                ConcurrencyErrorMessage = "The record you attempted to delete "
                  + "was modified by another user after you selected delete. "
                  + "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.";
            }
            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            try
            {
                if (await _context.Departments.AnyAsync(
                    m => m.DepartmentID == id))
                {
                    // Department.rowVersion value is from when the entity
                    // was fetched. If it doesn't match the DB, a
                    // DbUpdateConcurrencyException exception is thrown.
                    _context.Departments.Remove(Department);
                    await _context.SaveChangesAsync();
                }
                return RedirectToPage("./Index");
            }
            catch (DbUpdateConcurrencyException)
            {
                return RedirectToPage("./Delete",
                    new { concurrencyError = true, id = id });
            }
        }
    }
}

페이지 삭제는 엔터티가 페치된 후 변경될 때 동시성 충돌을 감지합니다. Department.RowVersion은 엔터티가 페치될 때 행 버전입니다. EF Core가 SQL DELETE 명령을 만들 때 RowVersion과 함께 WHERE 절이 포함됩니다. SQL DELETE 명령의 영향을 받는 행이 없는 경우:

  • SQL DELETE 명령의 RowVersion이 DB의 RowVersion과 일치하지 않습니다.
  • DbUpdateConcurrencyException 예외가 throw됩니다.
  • OnGetAsyncconcurrencyError를 사용하여 호출됩니다.

Delete 페이지 업데이트

다음 코드를 사용하여 Pages/Departments/Delete.cshtml을 업데이트합니다.

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel

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

<h2>Delete</h2>

<p class="text-danger">@Model.ConcurrencyErrorMessage</p>

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

위의 코드로 다음이 변경됩니다.

  • page 지시어를 @page에서 @page "{id:int}"로 업데이트합니다.
  • 오류 메시지를 추가합니다.
  • Administrator 필드의 FirstMidName을 FullName으로 바꿉니다.
  • 마지막 바이트를 표시하도록 RowVersion을 변경합니다.
  • 숨겨진 행 버전을 추가합니다. 포스트백은 값을 바인딩하므로 RowVersion을 추가해야 합니다.

삭제 페이지로 동시성 충돌 테스트

테스트 부서를 만듭니다.

테스트 부서에 있는 삭제의 두 브라우저 인스턴스를 엽니다.

  • 앱을 실행하고 부서를 선택합니다.
  • 테스트 부서에 대한 삭제 하이퍼링크를 마우스 오른쪽 단추로 클릭하고 새 탭에서 열기를 선택합니다.
  • 테스트 부서에 대한 편집 하이퍼링크를 클릭합니다.

두 개의 브라우저 탭에 동일한 정보가 표시됩니다.

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

브라우저에 변경된 값과 업데이트된 rowVersion 표시기가 있는 인덱스 페이지가 표시됩니다. 업데이트된 rowVersion 표시기는 다른 탭의 두 번째 포스트백에 표시됩니다.

두 번째 탭에서 테스트 부서를 삭제합니다. 동시성 오류는 DB의 현재 값과 함께 표시됩니다. RowVersion이 업데이트되지 않았다면 삭제를 클릭하면 엔터티가 삭제됩니다.

데이터 모델을 상속하는 방법은 상속을 참조하세요.

추가 리소스