2부. ASP.NET Core에서 EF Core를 사용한 Razor Pages - CRUD
참고 항목
이 문서의 최신 버전은 아닙니다. 현재 릴리스는 이 문서의 .NET 9 버전을 참조 하세요.
Important
이 정보는 상업적으로 출시되기 전에 실질적으로 수정될 수 있는 시험판 제품과 관련이 있습니다. Microsoft는 여기에 제공된 정보에 대해 어떠한 명시적, 또는 묵시적인 보증을 하지 않습니다.
현재 릴리스는 이 문서의 .NET 9 버전을 참조 하세요.
작성자 : 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();
}
Include 및 ThenInclude 메서드로 인해 컨텍스트가 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 메서드:
- 에 있는 속성PageModel의 PageContext 게시된 양식 값을 사용합니다.
- 나열된 속성만 업데이트합니다(
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 도구가 나와 있습니다.
값 “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
에서 OnGetAsync
및 OnPostAsync
메서드를 다음 코드로 바꿉니다.
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();
}
코드 변경은 만들기 페이지와 유사합니다. 단, 다음과 같은 몇 가지 예외가 있습니다.
FirstOrDefaultAsync
가 FindAsync로 바뀌었습니다. 관련 데이터를 포함할 필요가 없는 경우에는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 });
}
}
}
}
앞의 코드가 하는 역할은 다음과 같습니다.
- 로깅을 추가합니다.
- 선택적 매개 변수
saveChangesError
를OnGetAsync
메서드 시그니처에 추가합니다.saveChangesError
는 학생 개체 삭제에 실패한 후 메서드가 호출되는지 여부를 나타냅니다.
일시적인 네트워크 문제로 인해 삭제 작업이 실패할 수 있습니다. 일시적인 네트워크 오류는 데이터베이스가 클라우드에 있을 때 발생 가능성이 더 큽니다. saveChangesError
매개 변수는 삭제 페이지 OnGetAsync
가 UI에서 호출되는 경우 false
입니다. OnGetAsync
가 OnPostAsync
에 의해 호출되면 삭제 작업이 실패했으므로 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();
}
Include 및 ThenInclude 메서드로 인해 컨텍스트가 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 메서드:
- 에 있는 속성PageModel의 PageContext 게시된 양식 값을 사용합니다.
- 나열된 속성만 업데이트합니다(
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 도구가 나와 있습니다.
값 “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
에서 OnGetAsync
및 OnPostAsync
메서드를 다음 코드로 바꿉니다.
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();
}
코드 변경은 만들기 페이지와 유사합니다. 단, 다음과 같은 몇 가지 예외가 있습니다.
FirstOrDefaultAsync
가 FindAsync로 바뀌었습니다. 관련 데이터를 포함할 필요가 없는 경우에는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 });
}
}
}
}
앞의 코드가 하는 역할은 다음과 같습니다.
- 로깅을 추가합니다.
- 선택적 매개 변수
saveChangesError
를OnGetAsync
메서드 시그니처에 추가합니다.saveChangesError
는 학생 개체 삭제에 실패한 후 메서드가 호출되는지 여부를 나타냅니다.
일시적인 네트워크 문제로 인해 삭제 작업이 실패할 수 있습니다. 일시적인 네트워크 오류는 데이터베이스가 클라우드에 있을 때 발생 가능성이 더 큽니다. saveChangesError
매개 변수는 삭제 페이지 OnGetAsync
가 UI에서 호출되는 경우 false
입니다. OnGetAsync
가 OnPostAsync
에 의해 호출되면 삭제 작업이 실패했으므로 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();
}
Include 및 ThenInclude 메서드로 인해 컨텍스트가 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 메서드:
- 에 있는 속성PageModel의 PageContext 게시된 양식 값을 사용합니다.
- 나열된 속성만 업데이트합니다(
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 도구가 나와 있습니다.
값 “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
에서 OnGetAsync
및 OnPostAsync
메서드를 다음 코드로 바꿉니다.
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();
}
코드 변경은 만들기 페이지와 유사합니다. 단, 다음과 같은 몇 가지 예외가 있습니다.
FirstOrDefaultAsync
가 FindAsync로 바뀌었습니다. 포함된 관련 데이터가 필요하지 않은 경우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 });
}
}
}
}
앞의 코드는 선택적 매개 변수 saveChangesError
를 OnGetAsync
메서드 시그니처에 추가합니다. saveChangesError
는 학생 개체 삭제에 실패한 후 메서드가 호출되는지 여부를 나타냅니다. 일시적인 네트워크 문제로 인해 삭제 작업이 실패할 수 있습니다. 일시적인 네트워크 오류는 데이터베이스가 클라우드에 있을 때 발생 가능성이 더 큽니다. saveChangesError
매개 변수는 삭제 페이지 OnGetAsync
가 UI에서 호출되는 경우 false입니다. OnGetAsync
가 OnPostAsync
에 의해 호출되면(삭제 작업이 실패했으므로) 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>
앱을 실행하고 학생을 삭제하여 삭제 페이지를 테스트합니다.
다음 단계
ASP.NET Core