본문 바로가기
Entity Framework Core

.NET Core MVC] EF Core - 7. DB 동시성 제어(2) DbUpdateConcurrencyException

by Fastlane 2023. 8. 29.
728x90
반응형

Department.cs

namespace WEB.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; }
}

DepartmentsController.cs

using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using WEB.Data;
using WEB.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Mvc.Rendering;

namespace WEB.Controllers;

public class DepartmentsController : Controller
{
    private readonly SchoolContext _context;

    public DepartmentsController(SchoolContext context)
    {
        _context = context;
    }

    public async Task<IActionResult> Index()
    {
        var SchoolContext = _context.Departments.Include(d => d.Administrator);
        return View(await SchoolContext.ToListAsync());
    }

    public async Task<IActionResult> Details(int? id)
    {
        if (id == null)
        {
            return NotFound();
        }

        string query = "SELECT * FROM Department WHERE DepartmentID = {0}";
        var department = await _context.Departments
            .FromSqlRaw(query, id)
            .Include(d => d.Administrator)
            .AsNoTracking()
            .FirstOrDefaultAsync();

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

        return View(department);
    }


    public async Task<IActionResult> Edit(int? id)
    {
        if (id == null)
        {
            return NotFound();
        }

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

        if (department == null)
        {
            return NotFound();
        }
        ViewData["InstructorID"] = new SelectList(_context.Instructors, "ID", "FullName", department.InstructorID);
        return View(department);
    }
    
    [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)
        {  
            //다른 사용자에 의해 수정하려는 entity가 삭제된 경우, posted form values를 사용하여 entity를 생성하고,
            //에러메시지와 함께 수정화면에 노출한다.
            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);
        }

        //view는 hidden field에 original RowVersion 값을 저장하고, Edit 함수는 그 값을 rowVersion parameter로 받는다.
        //original RowVersion 값을 entity의 OriginalValues collection에 넣는다.
        _context.Entry(departmentToUpdate).Property("RowVersion").OriginalValue = rowVersion;

        //UPDATE 구문에 original RowVersion row를 찾는 WHERE 조건이 추가된다.
        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));
            }
            //영향받은 row가 없으면(original RowVersion값의 row가 없음), EF는 DbUpdateConcurrencyException exception을 발생시킨다.  
            catch (DbUpdateConcurrencyException ex)
            {
                var exceptionEntry = ex.Entries.Single(); //일반적으로 한개의 EntityEntry object를 갖는다.
                var clientValues = (Department)exceptionEntry.Entity; //사용자가 입력한 값을 갖는 entity
                var databaseEntry = exceptionEntry.GetDatabaseValues(); //현재 database 값을 갖는 entity
                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.");
                   
                    //db에 저장된 new RowVersion값을 할당한다.
                    //hidden field에 저장되며, 다음 번 저장 시 redisplay 이후 발생한 동시성 에러만 잡는다.
                    departmentToUpdate.RowVersion = (byte[])databaseValues.RowVersion;
                    //ModelState가 old RowVersion값을 갖기 때문에 삭제한다.
                    //ModelState 값이 model property보다 우선순위를 갖는다.
                    ModelState.Remove("RowVersion");
                }
            }
        }
            ViewData["InstructorID"] = new SelectList(_context.Instructors, "ID", "FullName", departmentToUpdate.InstructorID);
            return View(departmentToUpdate);
    }
}

Index.cshtml

@model IEnumerable<WEB.Models.Department>

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

<h1>Departments</h1>

<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.RowVersion)
            </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.RowVersion)
            </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>

Edit.cshtml

@model WEB.Models.Department

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

<h1>Edit</h1>

<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-primary" />
            </div>
        </form>
    </div>
</div>


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


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

동시성 충돌 테스트

앱을 실행한 다음 Department Index page로 이동한다. English department 의 Edit page로 간 다음, 탭을 복제하여 동일한 화면 2개를 띄운다. 

왼쪽  browser의 Budget을 50000.00으로 수정한 다음 Save를 클릭한다. 

English department의 RowVersion이 update된 것을 확인할 수 있다. 

오른쪽 browser의 Budget을 200000.00으로 수정한 다음 Save를 클릭한다. 

에러메시지를 확인할 수 있다. Save 버튼을 다시 한번 클릭한다. 

Budget, RowVersion이 수정된 것을 확인할 수 있다. 

728x90
반응형

댓글