2부. ASP.NET Core에서 EF Core를 사용한 Razor Pages - CRUD

작성자 : Tom Dykstra, Jeremy Likness, Jon P Smith

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

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

이 자습서에서는 스캐폴드된 CRUD(만들기, 읽기, 업데이트, 삭제) 코드를 검토 및 사용자 지정합니다.

리포지토리 없음

일부 개발자는 서비스 계층 또는 리포지토리 패턴을 사용하여 UI(Razor Pages) 및 데이터 액세스 계층 간에 추상화 계층을 만듭니다. 이 자습서에서는 이 작업을 수행하지 않습니다. 복잡성을 최소화하고 자습서의 초점을 EF Core로 유지하기 위해 EF Core 코드가 페이지 모델 클래스에 직접 추가됩니다.

세부 정보 페이지 업데이트

학생 페이지의 스캐폴드된 코드에는 등록 데이터가 포함되지 않습니다. 이 섹션에서는 Details 페이지에 등록을 추가합니다.

등록 읽기

페이지에 학생의 등록 데이터를 표시하려면 등록 데이터를 읽혀야 합니다. Pages/Students/Details.cshtml.cs의 스캐폴드된 코드는 Enrollment 데이터 없이 Student 데이터만 읽습니다.

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

    Student = await _context.Students.FirstOrDefaultAsync(m => m.ID == id);

    if (Student == null)
    {
        return NotFound();
    }
    return Page();
}

OnGetAsync 메서드를 다음 코드로 바꿔서 선택한 학생에 대한 등록 데이터를 읽습니다. 변경 내용은 강조 표시되어 있습니다.

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

    Student = await _context.Students
        .Include(s => s.Enrollments)
        .ThenInclude(e => e.Course)
        .AsNoTracking()
        .FirstOrDefaultAsync(m => m.ID == id);

    if (Student == null)
    {
        return NotFound();
    }
    return Page();
}

IncludeThenInclude 메서드로 인해 컨텍스트가 Enrollment.Course 탐색 속성 및 각 등록 내에서 Student.Enrollments 탐색 속성을 로드합니다. 이 메서드는 관련 데이터 읽기 자습서에서 자세히 검토합니다.

이 메서드는 AsNoTracking 반환된 엔터티가 현재 컨텍스트에서 업데이트되지 않는 시나리오에서 성능을 향상시킵니다. AsNoTracking은 이 자습서의 뒷부분에서 설명합니다.

등록 표시

코드를 다음 코드 Pages/Students/Details.cshtml 로 바꿔 등록 목록을 표시합니다. 변경 내용은 강조 표시되어 있습니다.

@page
@model ContosoUniversity.Pages.Students.DetailsModel

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

<h1>Details</h1>

<div>
    <h4>Student</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.LastName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.LastName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.FirstMidName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.FirstMidName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.EnrollmentDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.EnrollmentDate)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.Enrollments)
        </dt>
        <dd class="col-sm-10">
            <table class="table">
                <tr>
                    <th>Course Title</th>
                    <th>Grade</th>
                </tr>
                @foreach (var item in Model.Student.Enrollments)
                {
                    <tr>
                        <td>
                            @Html.DisplayFor(modelItem => item.Course.Title)
                        </td>
                        <td>
                            @Html.DisplayFor(modelItem => item.Grade)
                        </td>
                    </tr>
                }
            </table>
        </dd>
    </dl>
</div>
<div>
    <a asp-page="./Edit" asp-route-id="@Model.Student.ID">Edit</a> |
    <a asp-page="./Index">Back to List</a>
</div>

위의 코드는 Enrollments 탐색 속성의 엔터티를 통해 반복됩니다. 각 등록의 경우 강좌 제목과 등급을 표시합니다. 강좌 제목은 등록 엔터티의 Course 탐색 속성에 저장되어 있는 Course 엔터티에서 검색됩니다.

앱을 실행하고, 학생 탭을 클릭하고, 학생에 대한 세부 정보 링크를 클릭합니다. 선택한 학생에 대한 강좌 및 등급의 목록이 표시됩니다.

단일 엔터티를 읽는 방법

생성된 코드는 FirstOrDefaultAsync를 사용하여 하나의 엔터티를 읽습니다. 이 메서드는 검색된 항목이 없는 경우 Null을 반환합니다. 그렇지 않으면 쿼리 필터 조건을 충족하는 첫 번째 행을 반환합니다. FirstOrDefaultAsync는 일반적으로 다음 대안보다 더 적합한 선택입니다.

  • SingleOrDefaultAsync - 쿼리 필터를 충족하는 엔터티가 둘 이상인 경우 예외를 throw합니다. 쿼리에서 두 개 이상의 행이 반환될 수 있는지 확인하기 위해 SingleOrDefaultAsync는 여러 행을 페치하려고 시도합니다. 쿼리가 고유 키를 검색하는 경우처럼 하나의 엔터티만 반환할 수 있는 경우에는 이 추가 작업이 필요하지 않습니다.
  • FindAsync - PK(기본 키)가 있는 엔터티를 찾습니다. PK가 있는 엔터티를 컨텍스트에서 추적하는 경우 요청 없이 데이터베이스에 반환됩니다. 이 메서드는 단일 엔터티를 조회하는 데 최적화되어 있지만 FindAsync를 사용하여 Include를 호출할 수 없습니다. 따라서 관련 데이터가 필요한 경우 FirstOrDefaultAsync를 선택하는 것이 좋습니다.

경로 데이터 및 쿼리 문자열

세부 정보 페이지의 URL은 https://localhost:<port>/Students/Details?id=1입니다. 엔터티의 기본 키 값은 쿼리 문자열에 있습니다. 일부 개발자는 키 값을 경로 데이터로 전달하는 것을 선호합니다. https://localhost:<port>/Students/Details/1 자세한 내용은 생성된 코드 업데이트를 참조하세요.

만들기 페이지 업데이트

만들기 페이지의 스캐폴드된 OnPostAsync 코드는 초과 게시에 취약합니다. Pages/Students/Create.cshtml.cs에서 OnPostAsync 메서드를 다음 코드로 바꿉니다.

public async Task<IActionResult> OnPostAsync()
{
    var emptyStudent = new Student();

    if (await TryUpdateModelAsync<Student>(
        emptyStudent,
        "student",   // Prefix for form value.
        s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
    {
        _context.Students.Add(emptyStudent);
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }

    return Page();
}

TryUpdateModelAsync

앞의 코드는 Student 개체를 만든 다음, 게시된 양식 필드를 사용하여 Student 개체의 속성을 업데이트합니다. TryUpdateModelAsync 메서드:

  • 에 있는 속성PageModelPageContext 게시된 양식 값을 사용합니다.
  • 나열된 속성만 업데이트합니다(s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate).
  • “Student” 접두사가 있는 양식 필드를 찾습니다. 예: Student.FirstMidName. 대/소문자를 구분하지 않습니다.
  • 모델 바인딩 시스템을 사용하여 양식 값을 문자열에서 Student 모델의 형식으로 변환합니다. 예를 들어 EnrollmentDateDateTime으로 변환됩니다.

앱을 실행하고 학생 엔터티를 만들어 만들기 페이지를 테스트합니다.

초과 게시

게시된 값으로 TryUpdateModel을 사용하는 것은 초과 게시가 방지되는 보안 모범 사례입니다. 예를 들어, 학생 엔터티가 이 웹 페이지가 업데이트하거나 추가해서는 안 되는 Secret 속성을 포함한다고 가정합니다.

public class Student
{
    public int ID { get; set; }
    public string LastName { get; set; }
    public string FirstMidName { get; set; }
    public DateTime EnrollmentDate { get; set; }
    public string Secret { get; set; }
}

앱에 만들기 또는 업데이트 Razor 페이지의 Secret 필드가 없는 경우에도 해커는 초과 게시를 통해 Secret 값을 설정할 수 있습니다. 해커가 Fiddler와 같은 도구를 사용하거나 일부 JavaScript를 작성하여 Secret 양식 값을 게시할 수 있습니다. 원본 코드는 학생 인스턴스를 만들 때 모델 바인더가 사용하는 필드를 제한하지 않습니다.

해커가 Secret 양식 필드에 대해 지정한 모든 값은 데이터베이스에서 업데이트됩니다. 다음 이미지에는 게시된 양식 값에 Secret 필드를 추가(값 “OverPost” 사용)하는 Fiddler 도구가 나와 있습니다.

Fiddler adding Secret field

값 “OverPost”가 삽입된 된 행의 Secret 속성에 성공적으로 추가되었습니다. 앱 디자이너가 Secret 속성이 만들기 페이지를 통해 설정되는 것을 의도하지 않더라도 추가됩니다.

뷰 모델

뷰 모델은 초과 게시를 방지하기 위한 다른 방법을 제공합니다.

애플리케이션 모델은 흔히 도메인 모델이라고 합니다. 도메인 모델은 일반적으로 데이터베이스의 해당 엔터티에 필요한 모든 속성을 포함합니다. 뷰 모델은 UI 페이지(예: 만들기 페이지)에 필요한 속성만 포함합니다.

뷰 모델 외에도 일부 앱은 바인딩 모델 또는 입력 모델을 사용하여 Razor Pages 페이지 모델 클래스와 브라우저 간에 데이터를 전달합니다.

다음 StudentVM 뷰 모델을 살펴보세요.

public class StudentVM
{
    public int ID { get; set; }
    public string LastName { get; set; }
    public string FirstMidName { get; set; }
    public DateTime EnrollmentDate { get; set; }
}

다음 코드는 StudentVM 뷰 모델을 사용하여 새 학생을 만듭니다.

[BindProperty]
public StudentVM StudentVM { get; set; }

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

    var entry = _context.Add(new Student());
    entry.CurrentValues.SetValues(StudentVM);
    await _context.SaveChangesAsync();
    return RedirectToPage("./Index");
}

SetValues 메서드는 다른 PropertyValues 개체의 값을 읽어서 이 개체의 값을 설정합니다. SetValues는 속성 이름 일치를 사용합니다. 뷰 모델 형식:

  • 모델 유형과 관련될 필요는 없습니다.
  • 일치하는 속성이 있어야 합니다.

StudentVM을 사용하려면 만들기 페이지가 Student 대신 StudentVM을 사용해야 합니다.

@page
@model CreateVMModel

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

<h1>Create</h1>

<h4>Student</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <div class="form-group">
                <label asp-for="StudentVM.LastName" class="control-label"></label>
                <input asp-for="StudentVM.LastName" class="form-control" />
                <span asp-validation-for="StudentVM.LastName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="StudentVM.FirstMidName" class="control-label"></label>
                <input asp-for="StudentVM.FirstMidName" class="form-control" />
                <span asp-validation-for="StudentVM.FirstMidName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="StudentVM.EnrollmentDate" class="control-label"></label>
                <input asp-for="StudentVM.EnrollmentDate" class="form-control" />
                <span asp-validation-for="StudentVM.EnrollmentDate" class="text-danger"></span>
            </div>
            <div class="form-group">
                <input type="submit" value="Create" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

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

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

편집 페이지 업데이트

Pages/Students/Edit.cshtml.cs에서 OnGetAsyncOnPostAsync 메서드를 다음 코드로 바꿉니다.

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

    Student = await _context.Students.FindAsync(id);

    if (Student == null)
    {
        return NotFound();
    }
    return Page();
}

public async Task<IActionResult> OnPostAsync(int id)
{
    var studentToUpdate = await _context.Students.FindAsync(id);

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

    if (await TryUpdateModelAsync<Student>(
        studentToUpdate,
        "student",
        s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }

    return Page();
}

코드 변경은 만들기 페이지와 유사합니다. 단, 다음과 같은 몇 가지 예외가 있습니다.

  • FirstOrDefaultAsyncFindAsync로 바뀌었습니다. 관련 데이터를 포함할 필요가 없는 경우에는 FindAsync가 더 효율적입니다.
  • OnPostAsync에는 id 매개 변수가 있습니다.
  • 현재 학생은 빈 학생을 만드는 대신 데이터베이스에서 페치합니다.

앱을 실행한 후 학생을 만들고 편집하여 테스트합니다.

엔터티 상태

데이터베이스 컨텍스트는 메모리의 엔터티가 해당하는 데이터베이스의 행과 동기화하는지 여부를 추적합니다. 이 추적 정보는 SaveChangesAsync가 호출될 때 수행할 작업을 결정합니다. 예를 들어 새 엔터티가 AddAsync 메서드에 전달된 경우 해당 엔터티의 상태가 Added로 설정됩니다. SaveChangesAsync가 호출되면 데이터베이스 컨텍스트가 SQL INSERT 명령을 발급합니다.

엔터티는 다음 상태 중 하나일 수 있습니다.

  • Added: 엔터티가 데이터베이스에 아직 존재하지 않습니다. SaveChanges 메서드는 INSERT 문을 발급합니다.

  • Unchanged: 이 엔터티로 저장할 변경 사항이 없습니다. 엔터티는 데이터베이스에서 읽을 때 이 상태입니다.

  • Modified: 일부 또는 모든 엔터티의 속성 값이 수정되었습니다. SaveChanges 메서드는 UPDATE 문을 발급합니다.

  • Deleted: 엔터티가 삭제되도록 표시되었습니다. SaveChanges 메서드는 DELETE 문을 발급합니다.

  • Detached: 데이터베이스 컨텍스트에서 엔터티를 추적하지 않습니다.

데스크톱 앱에서는 일반적으로 상태 변경 내용이 자동으로 설정됩니다. 엔터티를 읽고 변경이 수행되면, 엔터티 상태가 자동으로 Modified로 바뀝니다. SaveChanges를 호출하면 변경된 속성만 업데이트하는 SQL UPDATE 문이 생성됩니다.

웹앱에서는 페이지를 렌더링한 후 엔터티를 읽고 데이터를 표시하는 DbContext가 삭제됩니다. 페이지 OnPostAsync 메서드가 호출되면 DbContext의 새 인스턴스와 함께 새 웹 요청이 만들어집니다. 새로운 컨텍스트의 엔터티를 다시 읽으면 데스크톱 처리가 시뮬레이트됩니다.

Delete 페이지 업데이트

이 섹션에서는 SaveChanges 호출이 실패하는 경우의 사용자 지정 오류 메시지를 구현합니다.

Pages/Students/Delete.cshtml.cs의 코드를 다음 코드로 바꿉니다.

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

namespace ContosoUniversity.Pages.Students
{
    public class DeleteModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;
        private readonly ILogger<DeleteModel> _logger;

        public DeleteModel(ContosoUniversity.Data.SchoolContext context,
                           ILogger<DeleteModel> logger)
        {
            _context = context;
            _logger = logger;
        }

        [BindProperty]
        public Student Student { get; set; }
        public string ErrorMessage { get; set; }

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

            Student = await _context.Students
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.ID == id);

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

            if (saveChangesError.GetValueOrDefault())
            {
                ErrorMessage = String.Format("Delete {ID} failed. Try again", id);
            }

            return Page();
        }

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

            var student = await _context.Students.FindAsync(id);

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

            try
            {
                _context.Students.Remove(student);
                await _context.SaveChangesAsync();
                return RedirectToPage("./Index");
            }
            catch (DbUpdateException ex)
            {
                _logger.LogError(ex, ErrorMessage);

                return RedirectToAction("./Delete",
                                     new { id, saveChangesError = true });
            }
        }
    }
}

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

  • 로깅을 추가합니다.
  • 선택적 매개 변수 saveChangesErrorOnGetAsync 메서드 시그니처에 추가합니다. saveChangesError는 학생 개체 삭제에 실패한 후 메서드가 호출되는지 여부를 나타냅니다.

일시적인 네트워크 문제로 인해 삭제 작업이 실패할 수 있습니다. 일시적인 네트워크 오류는 데이터베이스가 클라우드에 있을 때 발생 가능성이 더 큽니다. saveChangesError 매개 변수는 삭제 페이지 OnGetAsync가 UI에서 호출되는 경우 false입니다. OnGetAsyncOnPostAsync에 의해 호출되면 삭제 작업이 실패했으므로 saveChangesError 매개 변수는 true입니다.

OnPostAsync 메서드는 선택한 엔터티를 검색한 다음, Remove 메서드를 호출하여 엔터티의 상태를 Deleted로 설정합니다. SaveChanges가 호출되면 SQL DELETE 명령이 생성됩니다. Remove가 실패하는 경우:

  • 데이터베이스 예외가 catch되었습니다.
  • 삭제 페이지 OnGetAsync 메서드가 saveChangesError=true로 호출됩니다.

Pages/Students/Delete.cshtml에 오류 메시지를 추가합니다.

@page
@model ContosoUniversity.Pages.Students.DeleteModel

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

<h1>Delete</h1>

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

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Student</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.LastName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.LastName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.FirstMidName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.FirstMidName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.EnrollmentDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.EnrollmentDate)
        </dd>
    </dl>

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

앱을 실행하고 학생을 삭제하여 삭제 페이지를 테스트합니다.

다음 단계

이 자습서에서는 스캐폴드된 CRUD(만들기, 읽기, 업데이트, 삭제) 코드를 검토 및 사용자 지정합니다.

리포지토리 없음

일부 개발자는 서비스 계층 또는 리포지토리 패턴을 사용하여 UI(Razor Pages) 및 데이터 액세스 계층 간에 추상화 계층을 만듭니다. 이 자습서에서는 이 작업을 수행하지 않습니다. 복잡성을 최소화하고 자습서의 초점을 EF Core로 유지하기 위해 EF Core 코드가 페이지 모델 클래스에 직접 추가됩니다.

세부 정보 페이지 업데이트

학생 페이지의 스캐폴드된 코드에는 등록 데이터가 포함되지 않습니다. 이 섹션에서는 Details 페이지에 등록을 추가합니다.

등록 읽기

페이지에 학생의 등록 데이터를 표시하려면 등록 데이터를 읽혀야 합니다. Pages/Students/Details.cshtml.cs의 스캐폴드된 코드는 Enrollment 데이터 없이 Student 데이터만 읽습니다.

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

    Student = await _context.Students.FirstOrDefaultAsync(m => m.ID == id);

    if (Student == null)
    {
        return NotFound();
    }
    return Page();
}

OnGetAsync 메서드를 다음 코드로 바꿔서 선택한 학생에 대한 등록 데이터를 읽습니다. 변경 내용은 강조 표시되어 있습니다.

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

    Student = await _context.Students
        .Include(s => s.Enrollments)
        .ThenInclude(e => e.Course)
        .AsNoTracking()
        .FirstOrDefaultAsync(m => m.ID == id);

    if (Student == null)
    {
        return NotFound();
    }
    return Page();
}

IncludeThenInclude 메서드로 인해 컨텍스트가 Enrollment.Course 탐색 속성 및 각 등록 내에서 Student.Enrollments 탐색 속성을 로드합니다. 이 메서드는 관련 데이터 읽기 자습서에서 자세히 검토합니다.

이 메서드는 AsNoTracking 반환된 엔터티가 현재 컨텍스트에서 업데이트되지 않는 시나리오에서 성능을 향상시킵니다. AsNoTracking은 이 자습서의 뒷부분에서 설명합니다.

등록 표시

코드를 다음 코드 Pages/Students/Details.cshtml 로 바꿔 등록 목록을 표시합니다. 변경 내용은 강조 표시되어 있습니다.

@page
@model ContosoUniversity.Pages.Students.DetailsModel

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

<h1>Details</h1>

<div>
    <h4>Student</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.LastName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.LastName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.FirstMidName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.FirstMidName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.EnrollmentDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.EnrollmentDate)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.Enrollments)
        </dt>
        <dd class="col-sm-10">
            <table class="table">
                <tr>
                    <th>Course Title</th>
                    <th>Grade</th>
                </tr>
                @foreach (var item in Model.Student.Enrollments)
                {
                    <tr>
                        <td>
                            @Html.DisplayFor(modelItem => item.Course.Title)
                        </td>
                        <td>
                            @Html.DisplayFor(modelItem => item.Grade)
                        </td>
                    </tr>
                }
            </table>
        </dd>
    </dl>
</div>
<div>
    <a asp-page="./Edit" asp-route-id="@Model.Student.ID">Edit</a> |
    <a asp-page="./Index">Back to List</a>
</div>

위의 코드는 Enrollments 탐색 속성의 엔터티를 통해 반복됩니다. 각 등록의 경우 강좌 제목과 등급을 표시합니다. 강좌 제목은 등록 엔터티의 Course 탐색 속성에 저장되어 있는 Course 엔터티에서 검색됩니다.

앱을 실행하고, 학생 탭을 클릭하고, 학생에 대한 세부 정보 링크를 클릭합니다. 선택한 학생에 대한 강좌 및 등급의 목록이 표시됩니다.

단일 엔터티를 읽는 방법

생성된 코드는 FirstOrDefaultAsync를 사용하여 하나의 엔터티를 읽습니다. 이 메서드는 검색된 항목이 없는 경우 Null을 반환합니다. 그렇지 않으면 쿼리 필터 조건을 충족하는 첫 번째 행을 반환합니다. FirstOrDefaultAsync는 일반적으로 다음 대안보다 더 적합한 선택입니다.

  • SingleOrDefaultAsync - 쿼리 필터를 충족하는 엔터티가 둘 이상인 경우 예외를 throw합니다. 쿼리에서 두 개 이상의 행이 반환될 수 있는지 확인하기 위해 SingleOrDefaultAsync는 여러 행을 페치하려고 시도합니다. 쿼리가 고유 키를 검색하는 경우처럼 하나의 엔터티만 반환할 수 있는 경우에는 이 추가 작업이 필요하지 않습니다.
  • FindAsync - PK(기본 키)가 있는 엔터티를 찾습니다. PK가 있는 엔터티를 컨텍스트에서 추적하는 경우 요청 없이 데이터베이스에 반환됩니다. 이 메서드는 단일 엔터티를 조회하는 데 최적화되어 있지만 FindAsync를 사용하여 Include를 호출할 수 없습니다. 따라서 관련 데이터가 필요한 경우 FirstOrDefaultAsync를 선택하는 것이 좋습니다.

경로 데이터 및 쿼리 문자열

세부 정보 페이지의 URL은 https://localhost:<port>/Students/Details?id=1입니다. 엔터티의 기본 키 값은 쿼리 문자열에 있습니다. 일부 개발자는 키 값을 경로 데이터로 전달하는 것을 선호합니다. https://localhost:<port>/Students/Details/1 자세한 내용은 생성된 코드 업데이트를 참조하세요.

만들기 페이지 업데이트

만들기 페이지의 스캐폴드된 OnPostAsync 코드는 초과 게시에 취약합니다. Pages/Students/Create.cshtml.cs에서 OnPostAsync 메서드를 다음 코드로 바꿉니다.

public async Task<IActionResult> OnPostAsync()
{
    var emptyStudent = new Student();

    if (await TryUpdateModelAsync<Student>(
        emptyStudent,
        "student",   // Prefix for form value.
        s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
    {
        _context.Students.Add(emptyStudent);
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }

    return Page();
}

TryUpdateModelAsync

앞의 코드는 Student 개체를 만든 다음, 게시된 양식 필드를 사용하여 Student 개체의 속성을 업데이트합니다. TryUpdateModelAsync 메서드:

  • 에 있는 속성PageModelPageContext 게시된 양식 값을 사용합니다.
  • 나열된 속성만 업데이트합니다(s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate).
  • “Student” 접두사가 있는 양식 필드를 찾습니다. 예: Student.FirstMidName. 대/소문자를 구분하지 않습니다.
  • 모델 바인딩 시스템을 사용하여 양식 값을 문자열에서 Student 모델의 형식으로 변환합니다. 예를 들어 EnrollmentDateDateTime으로 변환됩니다.

앱을 실행하고 학생 엔터티를 만들어 만들기 페이지를 테스트합니다.

초과 게시

게시된 값으로 TryUpdateModel을 사용하는 것은 초과 게시가 방지되는 보안 모범 사례입니다. 예를 들어, 학생 엔터티가 이 웹 페이지가 업데이트하거나 추가해서는 안 되는 Secret 속성을 포함한다고 가정합니다.

public class Student
{
    public int ID { get; set; }
    public string LastName { get; set; }
    public string FirstMidName { get; set; }
    public DateTime EnrollmentDate { get; set; }
    public string Secret { get; set; }
}

앱에 만들기 또는 업데이트 Razor 페이지의 Secret 필드가 없는 경우에도 해커는 초과 게시를 통해 Secret 값을 설정할 수 있습니다. 해커가 Fiddler와 같은 도구를 사용하거나 일부 JavaScript를 작성하여 Secret 양식 값을 게시할 수 있습니다. 원본 코드는 학생 인스턴스를 만들 때 모델 바인더가 사용하는 필드를 제한하지 않습니다.

해커가 Secret 양식 필드에 대해 지정한 모든 값은 데이터베이스에서 업데이트됩니다. 다음 이미지에는 게시된 양식 값에 Secret 필드를 추가(값 “OverPost” 사용)하는 Fiddler 도구가 나와 있습니다.

Fiddler adding Secret field

값 “OverPost”가 삽입된 된 행의 Secret 속성에 성공적으로 추가되었습니다. 앱 디자이너가 Secret 속성이 만들기 페이지를 통해 설정되는 것을 의도하지 않더라도 추가됩니다.

뷰 모델

뷰 모델은 초과 게시를 방지하기 위한 다른 방법을 제공합니다.

애플리케이션 모델은 흔히 도메인 모델이라고 합니다. 도메인 모델은 일반적으로 데이터베이스의 해당 엔터티에 필요한 모든 속성을 포함합니다. 뷰 모델은 UI 페이지(예: 만들기 페이지)에 필요한 속성만 포함합니다.

뷰 모델 외에도 일부 앱은 바인딩 모델 또는 입력 모델을 사용하여 Razor Pages 페이지 모델 클래스와 브라우저 간에 데이터를 전달합니다.

다음 StudentVM 뷰 모델을 살펴보세요.

public class StudentVM
{
    public int ID { get; set; }
    public string LastName { get; set; }
    public string FirstMidName { get; set; }
    public DateTime EnrollmentDate { get; set; }
}

다음 코드는 StudentVM 뷰 모델을 사용하여 새 학생을 만듭니다.

[BindProperty]
public StudentVM StudentVM { get; set; }

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

    var entry = _context.Add(new Student());
    entry.CurrentValues.SetValues(StudentVM);
    await _context.SaveChangesAsync();
    return RedirectToPage("./Index");
}

SetValues 메서드는 다른 PropertyValues 개체의 값을 읽어서 이 개체의 값을 설정합니다. SetValues는 속성 이름 일치를 사용합니다. 뷰 모델 형식:

  • 모델 유형과 관련될 필요는 없습니다.
  • 일치하는 속성이 있어야 합니다.

StudentVM을 사용하려면 만들기 페이지가 Student 대신 StudentVM을 사용해야 합니다.

@page
@model CreateVMModel

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

<h1>Create</h1>

<h4>Student</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <div class="form-group">
                <label asp-for="StudentVM.LastName" class="control-label"></label>
                <input asp-for="StudentVM.LastName" class="form-control" />
                <span asp-validation-for="StudentVM.LastName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="StudentVM.FirstMidName" class="control-label"></label>
                <input asp-for="StudentVM.FirstMidName" class="form-control" />
                <span asp-validation-for="StudentVM.FirstMidName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="StudentVM.EnrollmentDate" class="control-label"></label>
                <input asp-for="StudentVM.EnrollmentDate" class="form-control" />
                <span asp-validation-for="StudentVM.EnrollmentDate" class="text-danger"></span>
            </div>
            <div class="form-group">
                <input type="submit" value="Create" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

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

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

편집 페이지 업데이트

Pages/Students/Edit.cshtml.cs에서 OnGetAsyncOnPostAsync 메서드를 다음 코드로 바꿉니다.

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

    Student = await _context.Students.FindAsync(id);

    if (Student == null)
    {
        return NotFound();
    }
    return Page();
}

public async Task<IActionResult> OnPostAsync(int id)
{
    var studentToUpdate = await _context.Students.FindAsync(id);

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

    if (await TryUpdateModelAsync<Student>(
        studentToUpdate,
        "student",
        s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }

    return Page();
}

코드 변경은 만들기 페이지와 유사합니다. 단, 다음과 같은 몇 가지 예외가 있습니다.

  • FirstOrDefaultAsyncFindAsync로 바뀌었습니다. 관련 데이터를 포함할 필요가 없는 경우에는 FindAsync가 더 효율적입니다.
  • OnPostAsync에는 id 매개 변수가 있습니다.
  • 현재 학생은 빈 학생을 만드는 대신 데이터베이스에서 페치합니다.

앱을 실행한 후 학생을 만들고 편집하여 테스트합니다.

엔터티 상태

데이터베이스 컨텍스트는 메모리의 엔터티가 해당하는 데이터베이스의 행과 동기화하는지 여부를 추적합니다. 이 추적 정보는 SaveChangesAsync가 호출될 때 수행할 작업을 결정합니다. 예를 들어 새 엔터티가 AddAsync 메서드에 전달된 경우 해당 엔터티의 상태가 Added로 설정됩니다. SaveChangesAsync가 호출되면 데이터베이스 컨텍스트가 SQL INSERT 명령을 발급합니다.

엔터티는 다음 상태 중 하나일 수 있습니다.

  • Added: 엔터티가 데이터베이스에 아직 존재하지 않습니다. SaveChanges 메서드는 INSERT 문을 발급합니다.

  • Unchanged: 이 엔터티로 저장할 변경 사항이 없습니다. 엔터티는 데이터베이스에서 읽을 때 이 상태입니다.

  • Modified: 일부 또는 모든 엔터티의 속성 값이 수정되었습니다. SaveChanges 메서드는 UPDATE 문을 발급합니다.

  • Deleted: 엔터티가 삭제되도록 표시되었습니다. SaveChanges 메서드는 DELETE 문을 발급합니다.

  • Detached: 데이터베이스 컨텍스트에서 엔터티를 추적하지 않습니다.

데스크톱 앱에서는 일반적으로 상태 변경 내용이 자동으로 설정됩니다. 엔터티를 읽고 변경이 수행되면, 엔터티 상태가 자동으로 Modified로 바뀝니다. SaveChanges를 호출하면 변경된 속성만 업데이트하는 SQL UPDATE 문이 생성됩니다.

웹앱에서는 페이지를 렌더링한 후 엔터티를 읽고 데이터를 표시하는 DbContext가 삭제됩니다. 페이지 OnPostAsync 메서드가 호출되면 DbContext의 새 인스턴스와 함께 새 웹 요청이 만들어집니다. 새로운 컨텍스트의 엔터티를 다시 읽으면 데스크톱 처리가 시뮬레이트됩니다.

Delete 페이지 업데이트

이 섹션에서는 SaveChanges 호출이 실패하는 경우의 사용자 지정 오류 메시지를 구현합니다.

Pages/Students/Delete.cshtml.cs의 코드를 다음 코드로 바꿉니다.

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

namespace ContosoUniversity.Pages.Students
{
    public class DeleteModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;
        private readonly ILogger<DeleteModel> _logger;

        public DeleteModel(ContosoUniversity.Data.SchoolContext context,
                           ILogger<DeleteModel> logger)
        {
            _context = context;
            _logger = logger;
        }

        [BindProperty]
        public Student Student { get; set; }
        public string ErrorMessage { get; set; }

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

            Student = await _context.Students
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.ID == id);

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

            if (saveChangesError.GetValueOrDefault())
            {
                ErrorMessage = String.Format("Delete {ID} failed. Try again", id);
            }

            return Page();
        }

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

            var student = await _context.Students.FindAsync(id);

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

            try
            {
                _context.Students.Remove(student);
                await _context.SaveChangesAsync();
                return RedirectToPage("./Index");
            }
            catch (DbUpdateException ex)
            {
                _logger.LogError(ex, ErrorMessage);

                return RedirectToAction("./Delete",
                                     new { id, saveChangesError = true });
            }
        }
    }
}

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

  • 로깅을 추가합니다.
  • 선택적 매개 변수 saveChangesErrorOnGetAsync 메서드 시그니처에 추가합니다. saveChangesError는 학생 개체 삭제에 실패한 후 메서드가 호출되는지 여부를 나타냅니다.

일시적인 네트워크 문제로 인해 삭제 작업이 실패할 수 있습니다. 일시적인 네트워크 오류는 데이터베이스가 클라우드에 있을 때 발생 가능성이 더 큽니다. saveChangesError 매개 변수는 삭제 페이지 OnGetAsync가 UI에서 호출되는 경우 false입니다. OnGetAsyncOnPostAsync에 의해 호출되면 삭제 작업이 실패했으므로 saveChangesError 매개 변수는 true입니다.

OnPostAsync 메서드는 선택한 엔터티를 검색한 다음, Remove 메서드를 호출하여 엔터티의 상태를 Deleted로 설정합니다. SaveChanges가 호출되면 SQL DELETE 명령이 생성됩니다. Remove가 실패하는 경우:

  • 데이터베이스 예외가 catch되었습니다.
  • 삭제 페이지 OnGetAsync 메서드가 saveChangesError=true로 호출됩니다.

Pages/Students/Delete.cshtml에 오류 메시지를 추가합니다.

@page
@model ContosoUniversity.Pages.Students.DeleteModel

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

<h1>Delete</h1>

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

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Student</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.LastName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.LastName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.FirstMidName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.FirstMidName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.EnrollmentDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.EnrollmentDate)
        </dd>
    </dl>

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

앱을 실행하고 학생을 삭제하여 삭제 페이지를 테스트합니다.

다음 단계

이 자습서에서는 스캐폴드된 CRUD(만들기, 읽기, 업데이트, 삭제) 코드를 검토 및 사용자 지정합니다.

리포지토리 없음

일부 개발자는 서비스 계층 또는 리포지토리 패턴을 사용하여 UI(Razor Pages) 및 데이터 액세스 계층 간에 추상화 계층을 만듭니다. 이 자습서에서는 이 작업을 수행하지 않습니다. 복잡성을 최소화하고 자습서의 초점을 EF Core로 유지하기 위해 EF Core 코드가 페이지 모델 클래스에 직접 추가됩니다.

세부 정보 페이지 업데이트

학생 페이지의 스캐폴드된 코드에는 등록 데이터가 포함되지 않습니다. 이 섹션에서는 세부 정보 페이지에 등록을 추가합니다.

등록 읽기

페이지에 학생의 등록 데이터를 표시하려면 등록 데이터를 읽어야 합니다. 스캐폴드된 코드 Pages/Students/Details.cshtml.cs 는 등록 데이터 없이 학생 데이터만 읽습니다.

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

    Student = await _context.Students.FirstOrDefaultAsync(m => m.ID == id);

    if (Student == null)
    {
        return NotFound();
    }
    return Page();
}

OnGetAsync 메서드를 다음 코드로 바꿔서 선택한 학생에 대한 등록 데이터를 읽습니다. 변경 내용은 강조 표시되어 있습니다.

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

    Student = await _context.Students
        .Include(s => s.Enrollments)
        .ThenInclude(e => e.Course)
        .AsNoTracking()
        .FirstOrDefaultAsync(m => m.ID == id);

    if (Student == null)
    {
        return NotFound();
    }
    return Page();
}

IncludeThenInclude 메서드로 인해 컨텍스트가 Enrollment.Course 탐색 속성 및 각 등록 내에서 Student.Enrollments 탐색 속성을 로드합니다. 이 메서드는 읽기 관련 데이터 자습서에서 자세히 검토합니다.

이 메서드는 AsNoTracking 반환된 엔터티가 현재 컨텍스트에서 업데이트되지 않는 시나리오에서 성능을 향상시킵니다. AsNoTracking은 이 자습서의 뒷부분에서 설명합니다.

등록 표시

코드를 다음 코드 Pages/Students/Details.cshtml 로 바꿔 등록 목록을 표시합니다. 변경 내용은 강조 표시되어 있습니다.

@page
@model ContosoUniversity.Pages.Students.DetailsModel

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

<h1>Details</h1>

<div>
    <h4>Student</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.LastName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.LastName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.FirstMidName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.FirstMidName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.EnrollmentDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.EnrollmentDate)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.Enrollments)
        </dt>
        <dd class="col-sm-10">
            <table class="table">
                <tr>
                    <th>Course Title</th>
                    <th>Grade</th>
                </tr>
                @foreach (var item in Model.Student.Enrollments)
                {
                    <tr>
                        <td>
                            @Html.DisplayFor(modelItem => item.Course.Title)
                        </td>
                        <td>
                            @Html.DisplayFor(modelItem => item.Grade)
                        </td>
                    </tr>
                }
            </table>
        </dd>
    </dl>
</div>
<div>
    <a asp-page="./Edit" asp-route-id="@Model.Student.ID">Edit</a> |
    <a asp-page="./Index">Back to List</a>
</div>

위의 코드는 Enrollments 탐색 속성의 엔터티를 통해 반복됩니다. 각 등록의 경우 강좌 제목과 등급을 표시합니다. 강좌 제목은 등록 엔터티의 Course 탐색 속성에 저장되어 있는 강좌 엔터티에서 검색됩니다.

앱을 실행하고, 학생 탭을 클릭하고, 학생에 대한 세부 정보 링크를 클릭합니다. 선택한 학생에 대한 강좌 및 등급의 목록이 표시됩니다.

단일 엔터티를 읽는 방법

생성된 코드는 FirstOrDefaultAsync를 사용하여 하나의 엔터티를 읽습니다. 이 메서드는 검색된 항목이 없는 경우 Null을 반환합니다. 그렇지 않으면 쿼리 필터 조건을 충족하는 첫 번째 행을 반환합니다. FirstOrDefaultAsync는 일반적으로 다음 대안보다 더 적합한 선택입니다.

  • SingleOrDefaultAsync - 쿼리 필터를 충족하는 엔터티가 둘 이상인 경우 예외를 throw합니다. 쿼리에서 두 개 이상의 행이 반환될 수 있는지 확인하기 위해 SingleOrDefaultAsync는 여러 행을 페치하려고 시도합니다. 쿼리가 고유 키를 검색하는 경우처럼 하나의 엔터티만 반환할 수 있는 경우에는 이 추가 작업이 필요하지 않습니다.
  • FindAsync - PK(기본 키)가 있는 엔터티를 찾습니다. PK가 있는 엔터티를 컨텍스트에서 추적하는 경우 요청 없이 데이터베이스에 반환됩니다. 이 메서드는 단일 엔터티를 조회하는 데 최적화되어 있지만 FindAsync를 사용하여 Include를 호출할 수 없습니다. 따라서 관련 데이터가 필요한 경우 FirstOrDefaultAsync를 선택하는 것이 좋습니다.

경로 데이터 및 쿼리 문자열

세부 정보 페이지의 URL은 https://localhost:<port>/Students/Details?id=1입니다. 엔터티의 기본 키 값은 쿼리 문자열에 있습니다. 일부 개발자는 키 값을 경로 데이터로 전달하는 것을 선호합니다. https://localhost:<port>/Students/Details/1 자세한 내용은 생성된 코드 업데이트를 참조하세요.

만들기 페이지 업데이트

만들기 페이지의 스캐폴드된 OnPostAsync 코드는 초과 게시에 취약합니다. Pages/Students/Create.cshtml.cs에서 OnPostAsync 메서드를 다음 코드로 바꿉니다.

public async Task<IActionResult> OnPostAsync()
{
    var emptyStudent = new Student();

    if (await TryUpdateModelAsync<Student>(
        emptyStudent,
        "student",   // Prefix for form value.
        s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
    {
        _context.Students.Add(emptyStudent);
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }

    return Page();
}

TryUpdateModelAsync

앞의 코드는 Student 개체를 만든 다음, 게시된 양식 필드를 사용하여 Student 개체의 속성을 업데이트합니다. TryUpdateModelAsync 메서드:

  • 에 있는 속성PageModelPageContext 게시된 양식 값을 사용합니다.
  • 나열된 속성만 업데이트합니다(s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate).
  • “Student” 접두사가 있는 양식 필드를 찾습니다. 예: Student.FirstMidName. 대/소문자를 구분하지 않습니다.
  • 모델 바인딩 시스템을 사용하여 양식 값을 문자열에서 Student 모델의 형식으로 변환합니다. 예를 들어 EnrollmentDate는 DateTime으로 변환해야 합니다.

앱을 실행하고 학생 엔터티를 만들어 만들기 페이지를 테스트합니다.

초과 게시

게시된 값으로 TryUpdateModel을 사용하는 것은 초과 게시가 방지되는 보안 모범 사례입니다. 예를 들어, 학생 엔터티가 이 웹 페이지가 업데이트하거나 추가해서는 안 되는 Secret 속성을 포함한다고 가정합니다.

public class Student
{
    public int ID { get; set; }
    public string LastName { get; set; }
    public string FirstMidName { get; set; }
    public DateTime EnrollmentDate { get; set; }
    public string Secret { get; set; }
}

앱에 만들기 또는 업데이트 Razor 페이지의 Secret 필드가 없는 경우에도 해커는 초과 게시를 통해 Secret 값을 설정할 수 있습니다. 해커가 Fiddler와 같은 도구를 사용하거나 일부 JavaScript를 작성하여 Secret 양식 값을 게시할 수 있습니다. 원본 코드는 학생 인스턴스를 만들 때 모델 바인더가 사용하는 필드를 제한하지 않습니다.

해커가 Secret 양식 필드에 대해 지정한 모든 값은 데이터베이스에서 업데이트됩니다. 다음 이미지에는 게시된 양식 값에 Secret 필드를 추가(값 “OverPost” 사용)하는 Fiddler 도구가 나와 있습니다.

Fiddler adding Secret field

값 “OverPost”가 삽입된 된 행의 Secret 속성에 성공적으로 추가되었습니다. 앱 디자이너가 Secret 속성이 만들기 페이지를 통해 설정되는 것을 의도하지 않더라도 추가됩니다.

뷰 모델

뷰 모델은 초과 게시를 방지하기 위한 다른 방법을 제공합니다.

애플리케이션 모델은 흔히 도메인 모델이라고 합니다. 도메인 모델은 일반적으로 데이터베이스의 해당 엔터티에 필요한 모든 속성을 포함합니다. 보기 모델은 사용되는 UI에 필요한 속성만 포함합니다(예: 만들기 페이지).

뷰 모델 외에도 일부 앱은 바인딩 모델 또는 입력 모델을 사용하여 Razor Pages 페이지 모델 클래스와 브라우저 간에 데이터를 전달합니다.

다음 Student 뷰 모델을 살펴보세요.

using System;

namespace ContosoUniversity.Models
{
    public class StudentVM
    {
        public int ID { get; set; }
        public string LastName { get; set; }
        public string FirstMidName { get; set; }
        public DateTime EnrollmentDate { get; set; }
    }
}

다음 코드는 StudentVM 뷰 모델을 사용하여 새 학생을 만듭니다.

[BindProperty]
public StudentVM StudentVM { get; set; }

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

    var entry = _context.Add(new Student());
    entry.CurrentValues.SetValues(StudentVM);
    await _context.SaveChangesAsync();
    return RedirectToPage("./Index");
}

SetValues 메서드는 다른 PropertyValues 개체의 값을 읽어서 이 개체의 값을 설정합니다. SetValues는 속성 이름 일치를 사용합니다. 뷰 모델 형식은 모델 형식과 연결될 필요는 없으며 일치하는 속성만 있으면 됩니다.

StudentVM을 사용하려면 Student 대신 StudentVM을 사용하도록 Create.cshtml을 업데이트해야 합니다.

편집 페이지 업데이트

Pages/Students/Edit.cshtml.cs에서 OnGetAsyncOnPostAsync 메서드를 다음 코드로 바꿉니다.

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

    Student = await _context.Students.FindAsync(id);

    if (Student == null)
    {
        return NotFound();
    }
    return Page();
}

public async Task<IActionResult> OnPostAsync(int id)
{
    var studentToUpdate = await _context.Students.FindAsync(id);

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

    if (await TryUpdateModelAsync<Student>(
        studentToUpdate,
        "student",
        s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }

    return Page();
}

코드 변경은 만들기 페이지와 유사합니다. 단, 다음과 같은 몇 가지 예외가 있습니다.

  • FirstOrDefaultAsyncFindAsync로 바뀌었습니다. 포함된 관련 데이터가 필요하지 않은 경우 FindAsync가 더 효율적입니다.
  • OnPostAsync에는 id 매개 변수가 있습니다.
  • 현재 학생은 빈 학생을 만드는 대신 데이터베이스에서 페치합니다.

앱을 실행한 후 학생을 만들고 편집하여 테스트합니다.

엔터티 상태

데이터베이스 컨텍스트는 메모리의 엔터티가 해당하는 데이터베이스의 행과 동기화하는지 여부를 추적합니다. 이 추적 정보는 SaveChangesAsync가 호출될 때 수행할 작업을 결정합니다. 예를 들어 새 엔터티가 AddAsync 메서드에 전달된 경우 해당 엔터티의 상태가 Added로 설정됩니다. SaveChangesAsync가 호출되면 데이터베이스 컨텍스트가 SQL INSERT 명령을 실행합니다.

엔터티는 다음 상태 중 하나일 수 있습니다.

  • Added: 엔터티가 데이터베이스에 아직 존재하지 않습니다. SaveChanges 메서드가 INSERT 문을 발급합니다.

  • Unchanged: 이 엔터티로 저장할 변경 사항이 없습니다. 엔터티는 데이터베이스에서 읽을 때 이 상태입니다.

  • Modified: 일부 또는 모든 엔터티의 속성 값이 수정되었습니다. SaveChanges 메서드는 UPDATE 문을 발급합니다.

  • Deleted: 엔터티가 삭제되도록 표시되었습니다. SaveChanges 메서드는 DELETE 문을 발급합니다.

  • Detached: 데이터베이스 컨텍스트에서 엔터티를 추적하지 않습니다.

데스크톱 앱에서는 일반적으로 상태 변경 내용이 자동으로 설정됩니다. 엔터티를 읽고 변경이 수행되면, 엔터티 상태가 자동으로 Modified로 바뀝니다. SaveChanges를 호출하면 변경된 속성만 업데이트하는 SQL UPDATE 문이 생성됩니다.

웹앱에서는 페이지를 렌더링한 후 엔터티를 읽고 데이터를 표시하는 DbContext가 삭제됩니다. 페이지 OnPostAsync 메서드가 호출되면 DbContext의 새 인스턴스와 함께 새 웹 요청이 만들어집니다. 새로운 컨텍스트의 엔터티를 다시 읽으면 데스크톱 처리가 시뮬레이트됩니다.

Delete 페이지 업데이트

이 섹션에서는 SaveChanges 호출이 실패하는 경우 사용자 지정 오류 메시지를 구현합니다.

Pages/Students/Delete.cshtml.cs의 코드를 다음 코드로 바꿉니다. 변경 내용이 강조 표시됩니다(using 문도 정리됨).

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

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

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

        [BindProperty]
        public Student Student { get; set; }
        public string ErrorMessage { get; set; }

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

            Student = await _context.Students
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.ID == id);

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

            if (saveChangesError.GetValueOrDefault())
            {
                ErrorMessage = "Delete failed. Try again";
            }

            return Page();
        }

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

            var student = await _context.Students.FindAsync(id);

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

            try
            {
                _context.Students.Remove(student);
                await _context.SaveChangesAsync();
                return RedirectToPage("./Index");
            }
            catch (DbUpdateException /* ex */)
            {
                //Log the error (uncomment ex variable name and write a log.)
                return RedirectToAction("./Delete",
                                     new { id, saveChangesError = true });
            }
        }
    }
}

앞의 코드는 선택적 매개 변수 saveChangesErrorOnGetAsync 메서드 시그니처에 추가합니다. saveChangesError는 학생 개체 삭제에 실패한 후 메서드가 호출되는지 여부를 나타냅니다. 일시적인 네트워크 문제로 인해 삭제 작업이 실패할 수 있습니다. 일시적인 네트워크 오류는 데이터베이스가 클라우드에 있을 때 발생 가능성이 더 큽니다. saveChangesError 매개 변수는 삭제 페이지 OnGetAsync가 UI에서 호출되는 경우 false입니다. OnGetAsyncOnPostAsync에 의해 호출되면(삭제 작업이 실패했으므로) saveChangesError 매개 변수는 true입니다.

OnPostAsync 메서드는 선택한 엔터티를 검색한 다음, Remove 메서드를 호출하여 엔터티의 상태를 Deleted로 설정합니다. SaveChanges가 호출되면 SQL DELETE 명령이 생성됩니다. Remove가 실패하는 경우:

  • 데이터베이스 예외가 catch되었습니다.
  • 삭제 페이지의 OnGetAsync 메서드가 saveChangesError=true와 함께 호출됩니다.

삭제 Razor 페이지에 오류 메시지 추가(Pages/Students/Delete.cshtml):

@page
@model ContosoUniversity.Pages.Students.DeleteModel

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

<h1>Delete</h1>

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

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Student</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.LastName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.LastName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.FirstMidName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.FirstMidName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.EnrollmentDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.EnrollmentDate)
        </dd>
    </dl>

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

앱을 실행하고 학생을 삭제하여 삭제 페이지를 테스트합니다.

다음 단계