파트 8: Entity Framework를 이용해서 관련 데이터 갱신하기

등록일시: 2016-05-30 08:00,  수정일시: 2016-05-30 08:00
조회수: 6,740
이 문서는 ASP.NET MVC 기술을 널리 알리고자 하는 개인적인 취지로 제공되는 번역문서입니다. 이 문서에 대한 모든 저작권은 마이크로소프트에 있으며 요청이 있을 경우 언제라도 게시가 중단될 수 있습니다. 번역 내용에 오역이 존재할 수 있고 주석은 번역자 개인의 의견일 뿐이며 마이크로소프트는 이에 관한 어떠한 보장도 하지 않습니다. 번역이 완료된 이후에도 대상 제품 및 기술이 개선되거나 변경됨에 따라 원문의 내용도 변경되거나 보완되었을 수 있으므로 주의하시기 바랍니다.
이번 파트에서는 외래 키 필드 및 탐색 속성을 갱신하거나 직접 탐색 속성에 엔터티를 추가 또는 제거해서 관련 데이터를 처리하는 기본적인 개념들을 살펴봅니다.

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

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

본 자습서 시리즈의 지난 파트에서는 관련 데이터를 출력해봤습니다. 이번에는 반대로 관련 데이터를 갱신해보도록 하겠습니다. 대부분의 관계들은 외래 키 필드나 탐색 속성을 갱신하는 방법으로 갱신할 수 있습니다. 그러나 다대다(Many-to-Many) 관계는 Entity Framework가 직접적으로 조인 테이블을 노출하지 않기 때문에 적절한 탐색 속성에서 직접 엔터티를 추가하거나 제거해야 합니다.

다음 그림들은 본문을 통해서 작업하게 될 페이지들을 보여줍니다.

강의 Create 페이지 및 Edit 페이지 변경하기

강의 엔터티를 새로 생성하기 위해서는 반드시 기존 학과들 중 한 학과와의 관계를 지정해야만 합니다. 스케폴드 된 코드에는 이 작업을 용이하게 처리하기 위한 컨트롤러 메서드와 학과를 선택할 수 있는 드롭다운 목록이 추가되어 있는 Create 뷰와 Edit 뷰가 포함되어 있습니다. 이 드롭다운 목록에는 Course.DepartmentID 외래 키 속성이 설정되는데, 이것이 Entity Framework가 Department 탐색 속성에 적절한 Department 엔터티를 로드하기 위해서 필요한 작업의 전부입니다. 이번 예제에서는 스캐폴드 된 코드를 일부 변경하여 오류 처리와 드롭다운 목록 정렬만 추가해서 그대로 사용해보려고 합니다.

먼저 CourseController.cs 파일을 열고 네 개의 Create 메서드들과 Edit 메서드들을 삭제한 다음, 이를 다음 코드로 대체합니다:

public ActionResult Create()
{
    PopulateDepartmentsDropDownList();
    return View();
}

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Create([Bind(Include = "CourseID,Title,Credits,DepartmentID")]Course course)
{
    try
    {
        if (ModelState.IsValid)
        {
            db.Courses.Add(course);
            db.SaveChanges();
            return RedirectToAction("Index");
        }
    }
    catch (RetryLimitExceededException /* dex */)
    {
        //Log the error (uncomment dex variable name and add a line here to write a log.)
        ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, see your system administrator.");
    }
    PopulateDepartmentsDropDownList(course.DepartmentID);
    return View(course);
}

public ActionResult Edit(int? id)
{
    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }
    Course course = db.Courses.Find(id);
    if (course == null)
    {
        return HttpNotFound();
    }
    PopulateDepartmentsDropDownList(course.DepartmentID);
    return View(course);
}

[HttpPost, ActionName("Edit")]
[ValidateAntiForgeryToken]
public ActionResult EditPost(int? id)
{
    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }
    var courseToUpdate = db.Courses.Find(id);
    if (TryUpdateModel(courseToUpdate, "",
        new string[] { "Title", "Credits", "DepartmentID" }))
    {
        try
        {
            db.SaveChanges();

            return RedirectToAction("Index");
        }
        catch (RetryLimitExceededException /* dex */)
        {
            //Log the error (uncomment dex variable name and add a line here to write a log.
            ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, see your system administrator.");
        }
    }
    PopulateDepartmentsDropDownList(courseToUpdate.DepartmentID);
    return View(courseToUpdate);
}

private void PopulateDepartmentsDropDownList(object selectedDepartment = null)
{
    var departmentsQuery = from d in db.Departments
                           orderby d.Name
                           select d;
    ViewBag.DepartmentID = new SelectList(departmentsQuery, "DepartmentID", "Name", selectedDepartment);
}

그리고 파일의 시작 부분에 다음 using 구문을 추가합니다:

using System.Data.Entity.Infrastructure;

이 예제 코드에서 PopulateDepartmentsDropDownList 메서드는 이름을 기준으로 정렬된 모든 학과들의 목록을 가져와서 드롭다운 목록을 구성하게 될 SelectList 컬렉션을 생성한 다음, 이를 ViewBag의 속성에 담아서 뷰로 전달합니다. 이 메서드는 드롭다운 목록이 렌더될 때 선택될 항목을, 호출하는 코드 측에서 지정할 수 있는 선택적 매개변수인 selectedDepartment 매개변수를 전달받습니다. 그러면 뷰에서는 DropDownList 헬퍼 메서드에 이 컬렉션을 담고 있는 DepartmentID 속성의 이름을 전달함으로써, 헬퍼 메서드가 ViewBag 개체의 DepartmentID 속성에서 SelectList 컬렉션을 찾을 수 있다는 사실을 인지하도록 합니다.

가령 새로 생성하려는 강의는 당연히 아직까지 학과가 지정되지 않았기 때문에 HttpGet Create 메서드에서는 선택된 항목을 지정하지 않고 PopulateDepartmentsDropDownList 메서드를 호출합니다:

public ActionResult Create()
{
    PopulateDepartmentsDropDownList();
    return View();
}

반면 HttpGet Edit 메서드에서는 편집할 강의에 이미 지정된 학과의 ID를 전달하여 선택된 항목을 지정합니다:

public ActionResult Edit(int? id)
{
    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }
    Course course = db.Courses.Find(id);
    if (course == null)
    {
        return HttpNotFound();
    }
    PopulateDepartmentsDropDownList(course.DepartmentID);
    return View(course);
}

그뿐만 아니라 Create 메서드와 Edit 메서드의 HttpPost 버전에도 오류가 발생해서 페이지를 다시 렌더할 경우를 대비하여 선택된 항목을 설정하는 코드가 포함되어 있습니다:

catch (RetryLimitExceededException /* dex */)
{
    //Log the error (uncomment dex variable name and add a line here to write a log.)
    ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, see your system administrator.");
}
PopulateDepartmentsDropDownList(course.DepartmentID);
return View(course);

이 코드는 오류 메시지를 출력하기 위해서 페이지가 다시 렌더된 경우에도, 항상 선택된 학과가 선택된 상태를 유지될 수 있도록 보장해줍니다.

또한 스캐폴드 된 Course 관련 뷰들에도 이미 학과 필드에 대한 드롭다운 목록이 추가되어 있습니다. 다만 DepartmentID라는 이름은 필드명으로는 그다지 적당하지 않으므로 Views\Course\Create.cshtml  파일에서 다음에 강조된 부분을 변경하여 필드명을 변경합니다.

@model ContosoUniversity.Models.Course

@{
    ViewBag.Title = "Create";
}

<h2>Create</h2>

@using (Html.BeginForm()) 
{
    @Html.AntiForgeryToken()
    
    <div class="form-horizontal">
        <h4>Course</h4>
        <hr />
        @Html.ValidationSummary(true)

        <div class="form-group">
            @Html.LabelFor(model => model.CourseID, new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                @Html.EditorFor(model => model.CourseID)
                @Html.ValidationMessageFor(model => model.CourseID)
            </div>
        </div>

        <div class="form-group">
            @Html.LabelFor(model => model.Title, new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                @Html.EditorFor(model => model.Title)
                @Html.ValidationMessageFor(model => model.Title)
            </div>
        </div>

        <div class="form-group">
            @Html.LabelFor(model => model.Credits, new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                @Html.EditorFor(model => model.Credits)
                @Html.ValidationMessageFor(model => model.Credits)
            </div>
        </div>

        <div class="form-group">
            <label class="control-label col-md-2" for="DepartmentID">Department</label>
            <div class="col-md-10">
                @Html.DropDownList("DepartmentID", String.Empty)
                @Html.ValidationMessageFor(model => model.DepartmentID)
            </div>
        </div>

        <div class="form-group">
            <div class="col-md-offset-2 col-md-10">
                <input type="submit" value="Create" class="btn btn-default" />
            </div>
        </div>
    </div>
}

<div>
    @Html.ActionLink("Back to List", "Index")
</div>

@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")
}

동일한 변경 작업을 Views\Course\Edit.cshtml 파일에서도 수행합니다.

일반적으로 스캐폴더는 기본 키를 스캐폴드 하지 않습니다. 기본 키 값은 데이터베이스에 의해서 만들어지고 변경될 수 없으며, 사용자에게 출력될만한 의미 있는 값이 아니기 때문입니다. 그러나 Course 엔터티의 경우에는 스캐폴더가 CourseID 필드에 대한 텍스트 상자를 포함시켰는데, 그 이유는 스캐폴더가 DatabaseGeneratedOption.None 어트리뷰트의 의미를 이해하여 사용자가 기본 키의 값을 입력할 수 있어야 한다는 점을 인식하기 때문입니다. 그러나 스캐폴더는 여러분이 이 의미 있는 숫자 값을 다른 뷰들에서도 조회하기를 원한다는 점까지는 이해하지 못하기 때문에, 이를 위해서 필요한 추가 작업은 직접 처리해야만 합니다.

따라서 이번에는 Views\Course\Edit.cshtml 파일에서 Title 필드 앞에 강의 번호 필드를 추가합니다. 이 필드는 기본 키이기 때문에 출력만 될 뿐 변경할 수는 없습니다.

<div class="form-group">
    @Html.LabelFor(model => model.CourseID, new { @class = "control-label col-md-2" })
    <div class="col-md-10">
        @Html.DisplayFor(model => model.CourseID)
    </div>
</div>

이미 Edit 뷰에는 강의 번호에 대한 숨겨진 필드가 존재합니다 (Html.HiddenFor 헬퍼 메서드). 그러나 강의 번호에 대한 Html.DisplayFor 헬퍼 메서드를 추가했다고 해서 이 숨겨진 필드의 필요성이 없어지는 것은 아닙니다. 이 메서드만으로는 사용자가 Edit 페이지에서 Save 버튼을 클릭했을 때, 게시되는 데이터에 강의 번호가 포함되지 않기 때문입니다.

계속해서 이번에는 Views\Course\Delete.cshtml  파일과 Views\Course\Details.cshtml  파일을 열어서 학과 필드의 이름을 "Name"에서 "Department"로 변경하고 Title 필드 앞에 강의 번호 필드를 추가합니다.

<dt>
    Department
</dt>

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

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

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

이제 Create 페이지를 실행하고 (Courses 메뉴를 선택한 다음 Create New 링크를 클릭합니다) 새로운 강의 데이터를 입력합니다:

그런 다음 Create 버튼을 클릭하면, 새로운 강의가 목록에 추가된 Course Index 페이지가 출력되는 것을 확인할 수 있습니다. 이 Index 페이지 목록에 나타나는 학과명은 탐색 속성에서 가져온 것으로, 이를 통해서 관계가 올바르게 수립되었음을 알 수 있습니다.

이번에는 Edit 페이지로 이동합니다 (Course Index 페이지에서 특정 강의의 Edit 링크를 클릭합니다).

페이지의 데이터를 변경하고 Save 버튼을 클릭하면, Course Index 페이지에 변경된 강의 데이터가 반영되어 나타나게 됩니다.

강사 Edit 페이지 보완하기

강사 레코드를 편집할 때는 사무실 배정 정보를 함께 변경할 수 있어야 합니다. Instructor 엔터티와 OfficeAssignment 엔터티는 일대영 또는 일(One-to-Zero-or-One) 관계를 갖고 있으므로, 결국 이 말은 다음과 같은 상황들을 모두 처리할 수 있어야만 한다는 뜻입니다:

  • 사용자가 기존에 배정됐던 사무실 정보를 지우면 OfficeAssignment 엔터티를 제거하고 삭제해야 합니다.
  • 사용자가 지금까지 입력되지 않았던 사무실 배정 정보를 새로 입력하면 새로운 OfficeAssignment 엔터티를 생성해야 합니다.
  • 사용자가 사무실 배정 정보를 변경하면 기존 OfficeAssignment 엔터티의 값을 변경해야 합니다.

그러면 이제 InstructorController.cs 파일을 열고 HttpGet Edit 메서드를 살펴보시기 바랍니다:

{
    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }
    Instructor instructor = db.Instructors.Find(id);
    if (instructor == null)
    {
        return HttpNotFound();
    }
    ViewBag.ID = new SelectList(db.OfficeAssignments, "InstructorID", "Location", instructor.ID);
    return View(instructor);
}

이 메서드에 자동으로 스캐폴드 되어 있는 코드는 우리가 원하는 형태가 아닙니다. 다시 말해서 이 코드는 드롭다운 목록을 설정하기 위한 데이터를 구성하고 있지만, 실제로는 텍스트 상자에 설정할 데이터가 필요합니다. 따라서 다음 코드로 이 메서드의 코드를 대체합니다:

public ActionResult Edit(int? id)
{
    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }
    Instructor instructor = db.Instructors
        .Include(i => i.OfficeAssignment)
        .Where(i => i.ID == id)
        .Single();
    if (instructor == null)
    {
        return HttpNotFound();
    }
    return View(instructor);
}

이 새로운 예제 코드에서는 ViewBag에 관한 구문이 제거되었으며 관련 OfficeAssignment 엔터티를 즉시 로드하는 코드가 추가되었습니다. 그리고 Find 메서드를 사용하면 즉시 로드를 수행할 수 없기 때문에, Where 메서드와 Single 메서드를 대신 사용해서 강사를 선택하고 있습니다.

HttpPost Edit 메서드도 강의실 배정 정보를 갱신하는 다음의 코드로 대체합니다:

[HttpPost, ActionName("Edit")]
[ValidateAntiForgeryToken]
public ActionResult EditPost(int? id)
{
    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }

    var instructorToUpdate = db.Instructors
       .Include(i => i.OfficeAssignment)
       .Where(i => i.ID == id)
       .Single();

    if (TryUpdateModel(instructorToUpdate, "",
        new string[] { "LastName", "FirstMidName", "HireDate", "OfficeAssignment" }))
    {
        try
        {
            if (String.IsNullOrWhiteSpace(instructorToUpdate.OfficeAssignment.Location))
            {
                instructorToUpdate.OfficeAssignment = null;
            }

            db.SaveChanges();

            return RedirectToAction("Index");
        }
        catch (RetryLimitExceededException /* dex */)
        {
            //Log the error (uncomment dex variable name and add a line here to write a log.
            ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, see your system administrator.");
        }
   }
   return View(instructorToUpdate);
}

이 코드에 사용된 RetryLimitExceededException 개체를 참조하려면 using 구문이 필요합니다. RetryLimitExceededException 구문을 마우스 오른쪽 버튼으로 클릭한 다음, Resolve - using System.Data.Entity.Infrastructure를 선택합니다. (역주: Visual Studio 2015에서는 구문에 캐럿을 위치시킨 다음, 컨트롤+마침표 단축키를 누르면 됩니다.)

이 코드는 다음과 같은 작업들을 수행합니다:

  • HttpGet 메서드와 HttpPost 메서드의 시그니처가 동일해지므로 메서드 이름을 EditPost로 변경합니다 (URL은 ActionName 어트리뷰트를 지정해서 계속 /Edit/에 라우트되도록 설정합니다).

  • 편집될 Instructor 엔터티를 즉시 로드를 이용하여 OfficeAssignment 탐색 속성과 함께 데이터베이스로부터 조회합니다. 이 작업은 HttpGet Edit 메서드에서 처리했던 작업과 동일한 것입니다.

  • 모델 바인더에서 가져온 값으로 조회된 Instructor 엔터티를 갱신합니다. 이때, TryUpdateModel 메서드의 오버로드 버전을 사용해서 갱신을 허용할 속성들의 화이트리스트를 지정합니다. 이 방식을 사용하면 본 자습서의 두 번째 파트에서 설명했던 것처럼 오버포스팅(Over-Posting) 공격을 방지할 수 있습니다.

    if (TryUpdateModel(instructorToUpdate, "",
        new string[] { "LastName", "FirstMidName", "HireDate", "OfficeAssignment" }))
  • 만약 사무실 위치가 빈 상태로 제출됐다면 Instructor.OfficeAssignment 속성을 null로 설정합니다. OfficeAssignment 테이블에서 관련된 로우가 삭제됩니다.

    if (String.IsNullOrWhiteSpace(instructorToUpdate.OfficeAssignment.Location))
    {
        instructorToUpdate.OfficeAssignment = null;
    }
  • 변경사항을 데이터베이스에 저장합니다.

계속해서 Views\Instructor\Edit.cshtml 파일을 열고 Hire Date 필드의 div 요소 뒤에 사무실 위치를 편집하기 위한 새로운 필드를 추가합니다:

<div class="form-group">
    @Html.LabelFor(model => model.OfficeAssignment.Location, new { @class = "control-label col-md-2" })
    <div class="col-md-10">
        @Html.EditorFor(model => model.OfficeAssignment.Location)
        @Html.ValidationMessageFor(model => model.OfficeAssignment.Location)
    </div>
</div>

이제 Edit 페이지를 실행합니다 (Instructors 메뉴를 선택한 다음, 특정 강사에 대한 Edit 링크를 클릭합니다). 그리고 사무실 위치(Office Location)를 변경한 다음 Save 버튼을 클릭해봅니다.

강사 Edit 페이지에 강의 배정 기능 추가하기

강사는 제한 없이 강의를 배정받을 수 있습니다. 이번 절에서는 다음 그림에서 볼 수 있는 것처럼 배정된 강의들을 변경할 수 있는 체크 상자 그룹을 강사 Edit 페이지에 추가하여 기능을 개선해보겠습니다:

이미 설명했던 것처럼 Course 엔터티와 Instructor 엔터티는 다대다(Many-to-Many) 관계를 갖고 있기 때문에, 직접 조인 테이블에 존재하는 외래 키 속성에 접근할 수 없습니다. 대신 Instructor.Courses 탐색 속성을 대상으로 엔터티를 추가하거나 제거해야 합니다.

이렇게 체크 상자 그룹 형태로 제공되는 UI를 이용하면 해당 강사에게 배정된 강의들을 변경할 수 있습니다. 데이터베이스에 존재하는 모든 강의가 체크 상자로 출력되고, 그 중 강사에게 현재 배정된 강의들은 선택된 상태로 나타납니다. 사용자는 체크 상자를 선택하거나 선택을 해제하여 강의 배정 여부를 변경할 수 있습니다. 이후 강의들의 개수가 너무 많아지면 데이터를 뷰에 출력하는 방식을 변경할 수도 있겠지만, 그렇더라도 관계를 생성하거나 제거하기 위해 탐색 속성을 처리하는 방식은 그대로 사용할 수 있습니다.

이 페이지에서는 뷰 모델 클래스를 사용해서 체크 상자 그룹을 구성할 데이터를 뷰에 전달해 보겠습니다. ViewModels 폴더를 마우스 오른쪽 버튼으로 선택하여 AssignedCourseData.cs 파일을 생성하고 자동으로 생성된 코드를 다음 코드로 대체합니다:

namespace ContosoUniversity.ViewModels
{
    public class AssignedCourseData
    {
        public int CourseID { get; set; }
        public string Title { get; set; }
        public bool Assigned { get; set; }
    }
}

그리고 InstructorController.cs 파일에서 HttpGet Edit 메서드를 다음 코드로 대체합니다. 변경된 부분들이 코드에 강조되어 있습니다.

public ActionResult Edit(int? id)
{
    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }
    Instructor instructor = db.Instructors
        .Include(i => i.OfficeAssignment)
        .Include(i => i.Courses)
        .Where(i => i.ID == id)
        .Single();
    PopulateAssignedCourseData(instructor);
    if (instructor == null)
    {
        return HttpNotFound();
    }
    return View(instructor);
}

private void PopulateAssignedCourseData(Instructor instructor)
{
    var allCourses = db.Courses;
    var instructorCourses = new HashSet<int>(instructor.Courses.Select(c => c.CourseID));
    var viewModel = new List<AssignedCourseData>();
    foreach (var course in allCourses)
    {
        viewModel.Add(new AssignedCourseData
        {
            CourseID = course.CourseID,
            Title = course.Title,
            Assigned = instructorCourses.Contains(course.CourseID)
        });
    }
    ViewBag.Courses = viewModel;
}

변경된 코드에는 Courses 탐색 속성에 대한 즉시 로드가 추가되었으며, AssignedCourseData 뷰 모델 클래스를 이용해서 체크 상자 그룹을 구성하기 위한 정보를 제공하기 위해서 새로 추가된 PopulateAssignedCourseData 메서드를 호출하고 있습니다.

PopulateAssignedCourseData 메서드는 뷰 모델 클래스에 담을 강의 목록을 로드하기 위해서 먼저 모든 Course 엔터티들을 조회합니다. 그런 다음, 각각의 강의들이 강사의 Courses 탐색 속성에 존재하는지 여부를 확인합니다. 이때 특정 강의가 강사에게 배정되어 있는지 확인하는 조회 작업을 보다 효율적으로 수행하기 위해서, 강사에게 배정된 강의들을 HashSet 컬렉션에 담습니다. 결과적으로 강사에게 배정된 강의는 뷰 모델의 Assigned 속성이 true로 설정되고, 뷰에서는 이 속성을 이용해서 선택된 상태로 출력될 체크 상자를 결정하게 됩니다. 마지막으로 강의 목록은 ViewBag의 속성을 통해서 뷰로 전달됩니다.

계속해서 이번에는 사용자가 Save 버튼을 클릭할 때 실행될 코드를 추가해보겠습니다. EditPost 메서드의 코드를 다음 코드로 대체합니다. 이 코드에서는 Instructor 엔터티의 Courses 탐색 속성을 갱신하는 새로운 메서드를 호출합니다. 변경된 부분들이 코드에 강조되어 있습니다.

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Edit(int? id, string[] selectedCourses)
{
    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }
    var instructorToUpdate = db.Instructors
       .Include(i => i.OfficeAssignment)
       .Include(i => i.Courses)
       .Where(i => i.ID == id)
       .Single();

    if (TryUpdateModel(instructorToUpdate, "",
        new string[] { "LastName", "FirstMidName", "HireDate", "OfficeAssignment" }))
    {
        try
        {
            if (String.IsNullOrWhiteSpace(instructorToUpdate.OfficeAssignment.Location))
            {
                instructorToUpdate.OfficeAssignment = null;
            }

            UpdateInstructorCourses(selectedCourses, instructorToUpdate);

            db.SaveChanges();

            return RedirectToAction("Index");
        }
        catch (RetryLimitExceededException /* dex */)
        {
            //Log the error (uncomment dex variable name and add a line here to write a log.
            ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, see your system administrator.");
        }
    }
    PopulateAssignedCourseData(instructorToUpdate);
    return View(instructorToUpdate);
}

private void UpdateInstructorCourses(string[] selectedCourses, Instructor instructorToUpdate)
{
    if (selectedCourses == null)
    {
        instructorToUpdate.Courses = new List<Course>();
        return;
    }
 
    var selectedCoursesHS = new HashSet<string>(selectedCourses);
    var instructorCourses = new HashSet<int>
        (instructorToUpdate.Courses.Select(c => c.CourseID));
    foreach (var course in db.Courses)
    {
        if (selectedCoursesHS.Contains(course.CourseID.ToString()))
        {
            if (!instructorCourses.Contains(course.CourseID))
            {
                instructorToUpdate.Courses.Add(course);
            }
        }
        else
        {
            if (instructorCourses.Contains(course.CourseID))
            {
                instructorToUpdate.Courses.Remove(course);
            }
        }
    }
}

이제 메서드의 시그니처가 HttpGet Edit 메서드와 달라졌으므로 다시 메서드 이름을 EditPost에서 Edit로 변경했습니다.

뷰에는 Course 엔터티의 컬렉션이 존재하지 않기 때문에 모델 바인더가 Courses 탐색 속성을 자동으로 갱신할 수 없습니다. 따라서 이번에는 모델 바인더를 통해서 Courses 탐색 속성을 갱신하는 대신, 새로 추가한 UpdateInstructorCourses 메서드를 이용해서 작업을 처리하고 있습니다. 그에 따라 모델 바인딩에서 Courses 속성을 제외시켜야 할 필요성이 생기는데, 그렇다고 해서 TryUpdateModel 메서드를 호출하는 코드를 변경할 필요는 전혀 없습니다. 왜냐하면 이 코드는 이미 화이트리스트를 전달받는 오버로드 버전의 메서드를 사용하고 있으며 Courses 속성은 그 목록에 포함되어 있지 않기 때문입니다.

만약 선택된 체크 상자가 하나도 없다면 UpdateInstructorCourses 메서드의 코드가 Courses 탐색 속성을 빈 컬렉션으로 초기화합니다:

if (selectedCourses == null)
{
    instructorToUpdate.Courses = new List<Course>();
    return;
}

그렇지 않다면, 데이터베이스의 모든 강의들을 대상으로 루프를 돌면서 각 강의들이 강사에게 현재 배정된 강의인지 또는 뷰에서 선택된 강의인지 교차적으로 검사합니다. 참고로 검색을 효율적으로 용이하게 수행하기 위해서 마지막 두 컬렉션들을 HashSet 개체에 담고 있습니다.

뷰에서 특정 강의에 대한 체크 상자가 선택됐지만 Instructor.Courses 탐색 속성에는 해당 강의가 존재하지 않으면, 강의가 탐색 속성의 컬렉션에 추가됩니다.

if (selectedCoursesHS.Contains(course.CourseID.ToString()))
{
    if (!instructorCourses.Contains(course.CourseID))
    {
        instructorToUpdate.Courses.Add(course);
    }
}

반면 뷰에서는 특정 강의에 대한 체크 상자가 선택되지 않았지만 Instructor.Courses 탐색 속성에는 강의가 존재한다면, 탐색 속성의 컬렉션에서 강의가 제거됩니다.

else
{
    if (instructorCourses.Contains(course.CourseID))
    {
        instructorToUpdate.Courses.Remove(course);
    }
}

이번에는 Views\Instructor\Edit.cshtml 파일을 열고, 다음의 코드를 OfficeAssignment 필드에 대한 div 요소와 Save 버튼에 대한 div 요소 사이에 추가해서 체크 상자 그룹으로 구성된 Courses 필드를 추가합니다:

<div class="form-group">
    <div class="col-md-offset-2 col-md-10">
        <table>
            <tr>
                @{
                    int cnt = 0;
                    List<ContosoUniversity.ViewModels.AssignedCourseData> courses = ViewBag.Courses;

                    foreach (var course in courses)
                    {
                        if (cnt++ % 3 == 0)
                        {
                            @:</tr><tr>
                        }
                        @:<td>
                            <input type="checkbox"
                               name="selectedCourses"
                               value="@course.CourseID"
                               @(Html.Raw(course.Assigned ? "checked=\"checked\"" : "")) />
                               @course.CourseID @: @course.Title
                        @:</td>
                    }
                    @:</tr>
                }
        </table>
    </div>
</div>

코드를 붙여 넣은 뒤에, 줄바꿈이나 들여쓰기가 위의 예제 코드와 다르다면 직접 수정해서 위의 코드와 완전히 동일하게 만듭니다. 사실 들여쓰기까지 완벽해야할 필요는 없지만 @:</tr><tr>, @:<td>, @:</td>, 그리고 @:</tr> 줄은 여기에 보이는 것처럼 반드시 각각 한 줄로 작성되어야 합니다. 만약 그렇지 않으면 런타임 오류가 발생합니다.

이 코드는 세 개의 컬럼으로 구성된 HTML 테이블을 생성합니다. 그리고 각각의 컬럼들에는 체크 상자와 강의 번호 및 제목으로 구성된 캡션이 만들어집니다. 모든 체크 상자들의 name 어트리뷰트는 "selectedCourses"로 동일하게 지정되어 모델 바인더에게 그룹 항목으로 처리되어야 한다는 점을 알려줍니다. 그리고 각 체크 상자들의 value 어트리뷰트에는 CourseID의 값이 설정됩니다. 그 결과 페이지가 제출되면 모델 바인더가 선택된 체크 상자들의 CourseID 값들로만 구성된 배열을 컨트롤러에게 전달하게 됩니다.

강사에게 현재 배정되어 있는 체크 상자들은 처음 렌더될 때 checked 어트리뷰트가 추가되어 선택된 상태로 설정됩니다 (즉, 체크된 상태로 나타납니다).

강사에 대한 강의 배정 정보를 변경한 다음, Index 페이지로 이동하고 나면 변경된 결과를 확인할 수 있어야 합니다. 그러려면 Index 페이지의 테이블에 관련 컬럼을 추가해야 합니다. 그러나 이번 경우에는 ViewBag 개체를 사용할 필요는 없는데, 출력하고자 하는 정보가 뷰에 모델로 전달하고 있는 Instructor 엔터티의 Courses 탐색 속성에 이미 담겨 있기 때문입니다.

다음 예제 코드처럼 Views\Instructor\Index.cshtml  파일을 열고 Office 컬럼 머리글 바로 뒤에 Courses 컬럼 머리글을 추가합니다:

<tr>
    <th>Last Name</th>
    <th>First Name</th>
    <th>Hire Date</th>
    <th>Office</th>
    <th>Courses</th>
    <th></th>
</tr>

그런 다음, 사무실 위치 정보 컬럼 바로 뒤에 새로운 컬럼을 추가합니다:

<td>
    @if (item.OfficeAssignment != null)
    {
        @item.OfficeAssignment.Location
    }
</td>
<td>
    @{
        foreach (var course in item.Courses)
        {
            @course.CourseID @: @course.Title <br />
        }
    }
</td>
<td>
    @Html.ActionLink("Select", "Index", new { id = item.ID }) |
    @Html.ActionLink("Edit", "Edit", new { id = item.ID }) |
    @Html.ActionLink("Details", "Details", new { id = item.ID }) |
    @Html.ActionLink("Delete", "Delete", new { id = item.ID })
</td>

이제 Instructor Index 페이지를 실행해서 각 강사들에게 배정된 강의 정보들을 확인해봅니다:

그리고 특정 강사에 대한 Edit 링크를 클릭해서 Edit 페이지로 이동합니다.

일부 강의 배정 정보들을 변경하고 Save 버튼을 클릭합니다. 그러면 변경된 내용들이 Index 페이지에 반영되어 나타나는 것을 확인할 수 있습니다.

노트: 본문에서 강사의 강의 배정 데이터를 편집하기 위해서 사용된 접근 방식은 제한된 개수의 강의가 존재하는 경우에 적합합니다. 보다 큰 컬렉션의 경우에는 다른 유형의 UI와 갱신 방법이 필요합니다.

DeleteConfirmed 메서드 수정하기

이번에는 InstructorController.cs 파일에서 DeleteConfirmed 메서드를 삭제하고 그 자리에 다음 코드를 추가합니다.

[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public ActionResult DeleteConfirmed(int id)
{
    Instructor instructor = db.Instructors
        .Include(i => i.OfficeAssignment)
        .Where(i => i.ID == id)
        .Single();
    
    db.Instructors.Remove(instructor);
    
    var department = db.Departments
        .Where(d => d.InstructorID == id)
        .SingleOrDefault();
    if (department != null)
    {
         department.InstructorID = null;
    }
    
    db.SaveChanges();
    return RedirectToAction("Index");
}

이 코드는 다음과 같은 부분을 개선합니다:

  • 만약 강사가 특정 학과의 관리자로 지정되어 있다면 해당 학과에서 강사의 관리자 배정을 제거합니다. 이 코드를 적용하지 않은 상태에서 특정 학과에 관리자로 지정된 강사를 삭제하려고 시도하면 참조 무결성(Referential Integrity) 오류가 발생합니다.

그러나 이 코드는 한 강사가 여러 학과의 관리자로 지정된 경우에는 대응하지 못합니다. 본 자습서 시리즈의 마지막 파트에서는 이런 시나리오가 발생하는 것을 방지하기 위한 코드를 추가해볼 것입니다.

Create 페이지에 사무실 위치 필드 및 강의 배정 필드 추가하기

다시 InstructorController.cs 파일에서 HttpGetHttpPost Create 메서드를 삭제한 다음, 그 자리에 다음 코드를 추가합니다:

public ActionResult Create()
{
    var instructor = new Instructor();
    instructor.Courses = new List<Course>();
    PopulateAssignedCourseData(instructor);
    return View();
}

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Create([Bind(Include = "LastName,FirstMidName,HireDate,OfficeAssignment" )]Instructor instructor, string[] selectedCourses)
{
    if (selectedCourses != null)
    {
        instructor.Courses = new List<Course>();
        foreach (var course in selectedCourses)
        {
            var courseToAdd = db.Courses.Find(int.Parse(course));
            instructor.Courses.Add(courseToAdd);
        }
    }
    if (ModelState.IsValid)
    {
        db.Instructors.Add(instructor);
        db.SaveChanges();
        return RedirectToAction("Index");
    }
    PopulateAssignedCourseData(instructor);
    return View(instructor);
}

이 코드는 최초에 선택된 강의가 존재하지 않는다는 점만 제외하면 Edit 메서드에서 살펴봤던 코드와 매우 비슷합니다. HttpGet Create 메서드는 선택된 강의들을 가져오기 위해서가 아니라, 선택된 강의가 존재하지 않는 모든 강의들의 컬렉션을 뷰의 foreach 루프에 전달하기 위한 목적으로 PopulateAssignedCourseData 메서드를 호출합니다 (컬렉션을 아예 전달하지 않으면 뷰의 코드에서 null 참조 예외가 던져집니다).

그리고 HttpPost Create 메서드는 템플릿 코드가 모델의 유효성 오류를 검사하기 전에, 먼저 선택된 각각의 강의들을 Courses 탐색 속성에 추가한 다음, 새로운 강사를 데이터베이스에 추가합니다. 모델에 오류가 존재해서 페이지가 오류 메시지와 함께 다시 출력되는 경우에도 (사용자가 올바르지 않는 날짜를 입력하는 등) 선택된 강의들의 상태가 그대로 유지되도록, 모델의 오류 유무에 관계 없이 항상 강의를 추가하는 것입니다.

또한 Courses 탐색 속성에 강의를 추가할 수 있으려면 이 속성을 빈 컬렉션으로 초기화시켜야만 한다는 점에 주의하시기 바랍니다:

instructor.Courses = new List<Course>();

컨트롤러의 코드에서 이 작업을 처리하는 대신, 필요할 때 컬렉션이 자동으로 생성되도록 Instructor 모델에서 Courses 속성의 getter를 다음과 같이 변경할 수도 있습니다:

private ICollection<Course> _courses;
public virtual ICollection<Course> Courses
{
    get
    {
        return _courses ?? (_courses = new List<Course>());
    }
    set
    {
        _courses = value;
    }
}

이렇게 Courses 속성을 변경하면 컨트롤러에서 속성을 명시적으로 초기화시키는 코드는 제거할 수 있습니다.

마지막으로 Views\Instructor\Create.cshtml  파일을 열고, 채용일자(Hire Date) 필드와 Submit 버튼 사이에 사무실 위치 텍스트 상자와 강의 체크 상자 그룹을 추가합니다.

<div class="form-group">
    @Html.LabelFor(model => model.OfficeAssignment.Location, new { @class = "control-label col-md-2" })
    <div class="col-md-10">
        @Html.EditorFor(model => model.OfficeAssignment.Location)
        @Html.ValidationMessageFor(model => model.OfficeAssignment.Location)
    </div>
</div>

<div class="form-group">
    <div class="col-md-offset-2 col-md-10">
        <table>
            <tr>
                @{
                    int cnt = 0;
                    List<ContosoUniversity.ViewModels.AssignedCourseData> courses = ViewBag.Courses;

                    foreach (var course in courses)
                    {
                        if (cnt++ % 3 == 0)
                        {
                            @:</tr><tr>
                        }
                        @:<td>
                            <input type="checkbox"
                               name="selectedCourses"
                               value="@course.CourseID"
                               @(Html.Raw(course.Assigned ? "checked=\"checked\"" : "")) />
                               @course.CourseID @:  @course.Title
                        @:</td>
                    }
                    @:</tr>
                }
        </table>
    </div>
</div>

이 코드를 붙여 넣은 뒤에는 Edit 페이지에서 했던 것처럼 깨진 줄바꿈과 들여쓰기를 정리해줘야만 합니다.

이제 Create 페이지를 실행하고 새로운 강사를 추가해보시기 바랍니다.

트랜잭션 처리하기

이미 ASP.NET MVC 응용 프로그램에서 Entity Framework로 CRUD 구현하기 파트에서 살펴봤던 것처럼 Entity Framework는 기본적으로 암시적 트랜잭션을 구현합니다. Entity Framework의 외부에서 수행되는 작업을 트랜잭션에 포함시키고 싶은 경우처럼, 더 세밀한 제어가 필요한 시나리오에 대해서는 MSDN의 Working with Transactions 문서를 참고하시기 바랍니다.

요약

본문에서는 관련 데이터를 처리하는 방법에 대한 기본적인 개념들을 살펴봤습니다. 지금까지 본 자습서 시리즈에서는 I/O를 동기적으로 처리하는 코드를 이용해서 모든 작업을 수행했습니다. 그러나 비동기 코드를 구현해서 응용 프로그램이 서버의 자원을 보다 효율적으로 사용하도록 만들 수도 있는데, 다음 파트에서는 바로 그 방법에 대해서 살펴보도록 하겠습니다.

자습서 내용 중 만족스러웠던 점이나 개선사항에 대한 의견이 있으시면 피드백으로 남겨주시기 바랍니다. 또한 Show Me How With Code를 통해서 새로운 주제를 요청하실 수도 있습니다.

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

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