ASP.NET Core - 17. Model Binding
Model binding은 HTTP요청으로 들어온 값을 사용해 .NET 개체를 생성하는 것으로 action method와 Razor Page에서 필요한 data로의 일관적인 접근을 제공해 줄 수 있습니다. 이와 관련해 model binding system이 작동하는 방식과 단순한 type에서 복잡한 type, 그리고 collection에 이르기까지 데이터를 어떻게 bind 할지를 같이 알아볼 것입니다. 또한 process를 제어해 요청의 일부를 지정하여 application에서 필요한 data를 제공해 줄 수 있을지도 함께 살펴볼 것입니다.
1. project 준비하기
예제를 위한 project는 이전글에서 사용하던 것을 계속해서 사용할 것입니다. 다만 Views/Form folder에 Form.cshtml file을 아래와 같이 변경하도록 합니다.
@model Products
@{
Layout = "_SimpleLayout";
}
<h5 class="bg-primary text-white text-center p-2">HTML Form</h5>
<form asp-action="submitform" method="post" id="htmlform">
<div class="form-group">
<label asp-for="ProductName"></label>
<input class="form-control" asp-for="ProductName" />
</div>
<div class="form-group">
<label asp-for="UnitPrice"></label>
<input class="form-control" asp-for="UnitPrice" />
</div>
<button type="submit" class="btn btn-primary mt-2">Submit</button>
</form>
그 다음 Model folder에 있는 Product.cs file의 Product model class에 적용된 DisplayFormat attribute를 주석처리합니다.
//[DisplayFormat(DataFormatString = "{0:c2}", ApplyFormatInEditMode = true)]
public decimal? UnitPrice { get; set; }
project를 실행하고 /controllers/form으로 URL을 요청합니다. 그리고 응답 HTML에서 submit button을 click 하여 다음과 같은 응답이 생성되지는 확인합니다.
2. Model Binding
Model binding은 HTTP요청과 action 또는 page handler method사이에서 훌륭한 다리 역활을 수행하는데 대부분의 ASP.NET Core Application은 예제 Project를 포함하여 상당 부분을 Model binding에 의존하고 있습니다.
project를 실행해 /controllers/form/index/5로 URL을 요청해 보면 실제 model binding이 자동하는 것을 볼 수 있는데 여기서 URL은 Products개체의 ProductId속성의 값을 포함하고 있습니다.
URL에서 맨끝의 숫자는 controller routing pattern에서 정의된 id segment변수에 해당하며 Form controller의 Index action에서 정의된 매개변수이름과 일치합니다.
public async Task<IActionResult> Index(int id = 1)
id 매개변수의 값은 MVC Framework가 action method를 호출하기 전에 요구되며 model binding system은 매개변수를 위한 적절한 값을 찾게 됩니다. 여기서 model binding system은 요청 또는 application의 일부로부터 data값을 제공하는 model binder에 의존하는데 기본 model binder는 아래 4곳에서 data값을 찾습니다.
- Form data
- 요청 body (ApiController attribute가 적용된 Controller에서만 해당)
- Routing segment 변수
- Query string
각 data source는 인수의 값이 발견될때까지 순서대로 검색됩니다. 예제에서는 form data가 없으므로 여기서 값을 찾을 수 없고 Form controller는 ApiController attribute가 적용되지 않았으므로 요청 body 또한 확인되지 않습니다. 따라서 다음으로 id라는 이름의 segment변수를 포함하고 있는 routing data를 확인하게 되면서 model binding system이 index method를 호출할 수 있도록 하는 값을 제공할 수 있게 됩니다. 이러한 방식의 검색절차는 적절한 값이 발견되면 중지되므로 이 이후에 남은 절차인 query string검색은 수행되지 않습니다.
나중에 살펴볼 'Model Binding Source 지정'부분에서 어떻게 attribute를 사용해 model binding data의 source를 지정할 수 있을지 알아볼 것입니다. 이것은 예를 들어 비록 routing data에 적절한 data가 있다고 하더라도 query string에서 가져온 값을 사용할 수 있도록 지정할 수 있습니다.
data값을 찾는 순서를 파악하는 것은 요청에서 다음과 같이 여러값을 포함할 수 있기 때문에 중요한 부분입니다.
/controllers/form/index/5?id=1 |
routing system은 요청을 처리하고 URL에서 5의 값을 id segment와 일치시키게 될 것입니다. query string에서도 1의 값을 id로 포함하고 있는데 routing data는 query string이전에 data를 찾게 되므로 Index action method는 결국 5 값을 전달받게 되며 query string의 값은 무시됩니다.
다른 한편으로 id segment를 가지지 않는 URL을 요청하게 된다면 이때는 query string의 값을 확인하게 되므로 다음과 같은 URL에서는 model binding system이 id인수의 값을 제공하게 되면서 Index method를 호출하게 됩니다.
/controllers/form/index?id=3 |
3. 단순 Data 유형 binding
요청 data값은 C#의 data값으로 변환되어야 하며 action 또는 page hander method를 호출하는데 사용됩니다. 여기서 단순 유형은 요청의 한 data값으로 부터 발생한 것으로 문자열로 parsing 될 수 있는데 숫자나, bool, date 유형은 물론 string까지도 포함될 수 있습니다.
단순유형의 data bindidng은 요청으로 부터 정의된 곳을 찾기 위해 context data를 통한 작업을 수행하지 않고도 단일 data item을 쉽게 추출할 수 있도록 합니다. 아래 예제는 Controllers folder의 FormController.cs file을 변경하여 Form controller method에서 정의된 SubmitForm action method에 매개변수를 추가하였습니다. 이렇게 해서 model binder가 ProductName과 UnitPrice값을 제공할 수 있게 됩니다.
[HttpPost]
public IActionResult SubmitForm(string ProductName, decimal UnitPrice)
{
TempData["name param"] = ProductName;
TempData["price param"] = UnitPrice.ToString();
return RedirectToAction(nameof(Results));
}
model binding system은 ASP.NET Core가 SubmitForm action method에서 처리될 요청을 받을때 ProductName과 UnitPrice값을 가져오기 위해 사용됩니다. 매개변수의 사용은 action method를 단순화하며 또한 요청 data를 C# data로 변환하는 역할을 수행함으로써 price값은 C#의 action method가 호출되기 전에 decimal type으로 변환될 것입니다.(예제에서는 decimal을 temp data로 저장하기 위해 다시 string로 변환하였습니다.) project를 실행하여 /controllers/form URL을 요청한 뒤 submit button을 click 합니다. 그러면 model binding기능에 의해 요청으로부터 값이 다음과 같이 추출될 것입니다.
(1) Razor Page에서 단순 Data 유형 binding
Razor Page에서도 model binding을 사용할 수 있지만 form요소의 name attribute의 값이 handler method의 매개변수 name과 일치한다는 것에 주의해야 합니다. asp-for attribute가 중첩된 속성을 선택하는데 사용되는 경우라면 더욱 그렇습니다. 따라서 name을 일치시키려면 아래와 같이 명시적으로 name attribute를 정의해야 합니다. 아래 예제는 Pages folder의 FormHandler.cshtml file을 변경한 것입니다.
<div class="form-group">
<label>Name</label>
<input class="form-control" asp-for="Products.ProductName" name="ProductName" />
</div>
<div class="form-group">
<label>Price</label>
<input class="form-control" asp-for="Products.UnitPrice" name="UnitPrice" />
</div>
public IActionResult OnPost(string ProductName, decimal UnitPrice)
{
TempData["name param"] = ProductName;
TempData["price param"] = UnitPrice.ToString();
return RedirectToPage("FormResults");
}
tag helper는 input요소의 name attribute를 Product.Name와 Product. Price로 설정하게 되는데 이는 model binder가 값을 일치시키지 못하도록 합니다. 따라서 위와 같이 name attribute를 명시적으로 정의하게 되면 tag helper를 재정의하게 되고 model binding이 정확히 처리될 수 있도록 요소가 생성됩니다. project를 실행하여 /pages/form URL을 요청하고 Submit button을 click 하면 다음과 같은 결과를 볼 수 있습니다.
(2) Default Binding 값
Model binding은 model binder가 method의 매개변수의 값을 가져오기 위한 최선의 기능이긴 하지만 실제 data값이 존재하지 않는 경우에도 여전히 method를 호출하게 됩니다. 이것이 어떻게 작동하는지를 확인해 보기위해 Controllers folder에 있는 FormControllers.cs file을 아래와 같이 변경하여 Form Controller에 있는 Index action method의 id 매개변수에 대한 기본값을 삭제하였습니다.
public async Task<IActionResult> Index(int id)
project를 실행하고 /controllers/form으로 URL을 요청해 보면 URL은 model binder가 id 매개변수로 사용할 수 있는 값을 포함하고 있지 않고 심지어 query strring이나 form data에도 존재하지 않음에도 method가 호출되어 다음과 같은 예외를 발생시키게 됩니다.
이러한 예외는 model binding system에 의해 발생된 것이 아니고 Entity Framework Core가 query를 실행할때 발생한 것입니다. MVC Framework는 Index action method를 호출하기 위해 id인수에 값을 제공해야 하므로 기본값을 제공하고 최상의 결과를 기대하게 됩니다. int형식의 인수에서 기본값은 0이 되고 결국 query가 실행되면서 예외가 발생하는 것인데 index action method는 id값을 database에 대한 query에서 Product개체의 key로 사용하고 있기 때문입니다.
return View("Form", await context.Products.Include(p => p.Category).Include(p => p.Supplier).FirstAsync(p => p.ProductId == id));
model binding에서는 가능한 값이 존재하지 않을 경우 action method는 id에 대한 0값을 가지고 database에 질의를 시도합니다. 하지만 아무런 결과도 가져올 수 없으므로 Entity Framework가 결과에 대한 처리를 시도할 때 위와 같은 error가 발생하게 되는 것입니다.
따라서 Application은 기본값을 통해 이러한 상황에 대처할 수 있도록 작성되어야 하며 몇가지 방식을 통해 이를 구현할 수 있습니다. 우선 controller나 page에서 사용되는 routing URL pattern에 대체값을 추가할 수 있고 action이나 page handler method에서 매개변수를 정의할 때 기본값을 할당할 수 있습니다. 또는 간단하게 error를 유발하지 않고 기본값을 수용하는 method를 추가해 줄 수도 있을 것입니다.
return View("Form", await context.Products.Include(p => p.Category).Include(p => p.Supplier).FirstOrDefaultAsync(p => p.ProductId == id));
Entity Framework Core의 FirstOrDefaultAsync method는 database에서 일치되는 개체를 찾을 수 없는 경우 null을 반환하게 되고 더이상 data를 읽으려고 시도하지 않습니다. 이때 tag helper는 null값에 대처한 결과로 빈 field를 표시하게 됩니다. 이러한 작동방식은 project를 실행하고 /controllers/form URL을 요청함으로써 다음과 같이 확인해 볼 수 있습니다.
어떤 경우에는 값이 없는 경우와 사용자가 제공한 값을 구별하기 위해 null가능한 매개변수 type을 사용하기도 합니다.
public async Task<IActionResult> Index(int? id)
{
ViewBag.Categories = new SelectList(context.Categories, "CategoryId", "CategoryName");
return View("Form", await context.Products.Include(p => p.Category).Include(p => p.Supplier).FirstOrDefaultAsync(p => id == null || p.ProductId == id));
}
id매개변수는 요청에서 적절한 값을 포함하고 있지 않은 경우 null이 될 것이며 FirstOrDefaultAsync method로 전달된 기본식이 database의 첫번째 개체로 기본 설정됩니다. 적용사항을 확인해 보기 위해 project를 실행하고 /controller/form과 /controller/form/index/0 2개의 URL을 요청해 봅니다. 첫 번째 URL은 id값을 포함하고 있지 않으므로 database에서 첫 번째 개체가 선택되며 두 번째는 id에 0 값을 제공하고 있지만 database의 어떠한 개체와도 일치되는 것이 없습니다. 따라서 다음과 같은 결과를 표시하게 됩니다.
4. 복합 type binding
model binding system은 단일 문자열로 parsing될 수 없는 거의 모든 type인 복합 data type을 처리하는 경우에야 비로소 빛을 발합니다. model binding process는 복합 type을 확인하여 각각 정의된 public 속성을 binding 하게 됩니다. 즉, ProductName이나 UnitPrice와 같은 개별적인 값을 일일이 처리하는 대신 완전한 Products개체를 만들기 위해 다음 예제와 같이 binder를 사용할 수 있는 것입니다. 아래 예제는 Controllers folder에 있는 FormController.cs file을 변경한 것입니다.
[HttpPost]
public IActionResult SubmitForm(Products product)
{
TempData["product"] = System.Text.Json.JsonSerializer.Serialize(product);
return RedirectToAction(nameof(Results));
}
예제에서는 SubmitForm method를 변경하여 Product라는 매개변수를 정의하였습니다. 이렇게 되면 action method가 호출되기 전에 새로운 Products개체를 생성하고 이 후 model binding process는 해당 개체의 각각의 속성을 적용하게 됩니다. 그리고 생성된 Products개체를 인수로 사용하여 SubmitForm method를 호출합니다.
project를 실행하고 /controllers/form URL을 요청한 후 submit button을 click합니다. 그러면 model binding process는 요청으로부터 data값을 추출하고 model binding process에 의해 생성된 Product개체는 JSON data로 serialize 하여 이를 곧 temp data로 저장하게 됩니다. 그리고 처리의 결과로 다음과 같은 결과를 생성합니다.
복합 type을 위한 data binding process는 Products class에 정의된 각 public속성에서 값을 찾음으로서 최선의 기능이 될 수 있지만 필수 값이 누락되면 action method를 호출하지 않습니다. 대신 검색될 값이 없는 각 속성은 해당 type의 기본값으로 남을 수 있습니다. 위 예제는 ProductName과 UnitPrice 속성에 대한 값을 제공하고 있지만 ProductId, CategoryId, SupplierId와 같은 속성의 값은 제공하지 않고 있으므로 기본값이 0이 되며 Category와 Supplier 등은 null이 됩니다.
(1) 속성 binding
model binding에서 매개변수를 사용할때 매개변수는 page model class에 의해 종종 중복된 속성이 정의될 수 있기 때문에 Razor Pages development style에서는 알맞은 방법은 아닙니다. 아래 예제는 Pages folder의 FormHandler.cshtml.cs file을 위의 예제와 같이 변경한 것으로 이러한 상황이 어떻게 발생될 수 있는지를 보여주고 있습니다.
public IActionResult OnPost(Products products)
{
TempData["products"] = System.Text.Json.JsonSerializer.Serialize(products);
return RedirectToPage("FormResults");
}
이 code는 잘 작동하기는 하지만 OnPost handler method는 OnGetAsync handler에서 사용된 속성을 복제하는 Products 개체의 자체 version을 가지고 있습니다. 이렇게 하기보다 좀 더 나은 접근방법은 기존에 model binding에서 사용하던 속성을 그대로 사용하는 것입니다. 우선 이를 위해 FormHandler.cshtml file을 아래와 같이 변경합니다.
<div class="m-2">
<h5 class="bg-primary text-white text-center p-2">HTML Form</h5>
<form asp-page="FormHandler" method="post" id="htmlform">
<div class="form-group">
<label>Name</label>
<input class="form-control" asp-for="Products.ProductName" />
</div>
<div class="form-group">
<label>Price</label>
<input class="form-control" asp-for="Products.UnitPrice" />
</div>
<button type="submit" class="btn btn-primary mt-2">Submit</button>
</form>
</div>
그리고 FormHandler.cshtml.cs file도 아래와 같이 변경합니다.
[BindProperty]
public Products? Products { get; set; }
public async Task OnGetAsync(int id = 1)
{
Products = await context.Products.Include(p => p.Category).Include(p => p.Supplier).FirstAsync(p => p.ProductId == id);
}
public IActionResult OnPost()
{
TempData["products"] = System.Text.Json.JsonSerializer.Serialize(Products);
return RedirectToPage("FormResults");
}
BindProperty attribute를 적용하면 해당 속성이 model binding process의 대상이 될 수 있음을 나타내는 것이 되며 OnPost handler method는 매개변수의 정의없이 필요한 data를 받을 수 있습니다. BindProperty을 사용하면 model binder는 data값을 찾을 때 속성 이름을 사용하게 되므로 input 요소에 명시적인 name속성을 추가할 필요가 없습니다. 기본적으로 BindProperty속성은 GET요청에서 data를 bind 하지 않는데 이는 SupportsGet인수를 true로 설정함으로써 동작을 바꿀 수 있습니다.
BindPropertiy는 model binding process에 필요한 정의된 모든 public속성을 가진 class에도 적용할 수 있는데 이것은 각각의 속성에 모두 BindProperty attribute를 적용하는 것보다는 훨씬 편리한 방법이 될 수 있습니다. 반대로 BindNever attribute를 적용하면 model binding으로 부터 해당 속성을 제외할 수 있습니다.
(2) 복합 Type의 중첩 Binding
model binding의 대상인 속성이 복합 Type으로 정의되었다면 model binding process는 접두사로서 속성이름을 사용해 반복됩니다. 예를 들어 Products class는 Category라는 속성을 정의하고 있는데 이 속성은 복합 Category Type입니다. 아래 예제는 Views/Form folder의 Form.cshtml file을 변경한 것으로 HTML Form에 새로운 요소를 추가하여 model binder에게 Category class에서 정의된 속성에 값을 제공하고 있습니다.
<div class="form-group">
<label>Category Name</label>
@{
#pragma warning disable CS8602
}
<input class="form-control" name="Category.CategoryName" value="@Model.Category.CategoryName" />
@{
#pragma warning restore CS8602
}
</div>
name attribute는 마침표로 구분된 속성이름을 결합합니다. 이 경우 요소는 view model의 Category속성이 할당된 개체의 Name속성에 대한 것입니다. 따라서 name attribute는 Category.CategoryName으로 설정되었습니다. input 요소 tag helper는 Views/Form의 Form.cshtml에 적용된것과 같이 asp-for attribute가 적용될 때 자동적으로 name attribute에 이러한 형식을 사용할 것입니다.
<div class="form-group">
<label>Category Name</label>
@{
#pragma warning disable CS8602
}
<input class="form-control" asp-for="Category.CategoryName" />
@{
#pragma warning restore CS8602
}
</div>
tag helper는 중첩된 속성에 대한 요소를 생성하기에 더욱 신뢰할만한 method이며 직접 typing하여 요소를 생성하는 경우 model binding process에서 취급하지 않을 수 있는 등의 손해를 피할 수 있습니다. 새로운 요소의 동작을 확인해 보기 위해 project를 실행하고 /controllers/form URL을 요청한 뒤 submit button을 click 합니다. 그러면 다음과 같은 결과를 보게 됩니다.
model binding process중 새로운 Category개체가 생성되고 Products개체의 Category속성에 할당됩니다. model binder는 Category개체의 Name속성에 대한 값을 찾게 되지만 CategoryId속성에 대한 값은 없기에 기본값으로 남게 됩니다.
● 중첩 Type에 사용자 정의 접두사 지정하기
만약 Products개체에서 Name속성과 Category에서 Name속성이 동일하게 존재하고 View/Form folder의 Form.cshtml이 아래와 같이 되어 있다고 했을 때
<div class="form-group">
<label asp-for="ProductName"></label>
<input class="form-control" asp-for="Name" />
</div>
<div class="form-group">
<label asp-for="UnitPrice"></label>
<input class="form-control" asp-for="UnitPrice" />
</div>
<div class="form-group">
<label>Category Name</label>
@{
#pragma warning disable CS8602
}
<input class="form-control" asp-for="Category.Name" />
@{
#pragma warning restore CS8602
}
</div>
생성한 HTML이 개체의 한 Type과 관련된 경우가 있지만 다른 것을 bind 해야 하는 경우도 존재합니다. 이 것은 뷰를 포함하는 접두사가 model binder가 예상한 구조에 해당되지 않으며 data는 적절히 처리될 수 없게 됩니다. 아래 예제는 Controllers folder에 있는 FormController.cs file에서 coantroller의 submitForm action method에 정의된 매개변수의 Type을 변경함으로써 이러한 문제가 어떻게 발생할 수 있는지를 나타내고 있습니다.
[HttpPost]
public IActionResult SubmitForm(Categories category)
{
TempData["category"] = System.Text.Json.JsonSerializer.Serialize(category);
return RedirectToAction(nameof(Results));
}
새롭게 변경된 매개변수의 Type은 Category이지만 Form view에서 전성된 form data가 Category개체의 Name속성에 대한 값을 포함하고 있다 할지라도 model binding process는 data값을 정확히 가져올 수 없습니다. 대신 model binder는 Product개체의 Name값을 찾고 이것을 대신 사용하게 됩니다.
Product와 Category가 서로 위에서 언급한 것처럼 같은 Name속성을 사용하고 있는 상황이라 할 때 BindProperty를 사용하는 경우라면 접두사는 Page folder의 FormHandler.cshtml처럼 Name인수를 통해 아래 예제와 같이 지정될 수 있습니다.
<div class="form-group">
<label>Category Name</label>
@{
#pragma warning disable CS8602
}
<input class="form-control" asp-for="Products.Category.Name" />
@{
#pragma warning restore CS8602
}
</div>
[BindProperty]
public Products? Products { get; set; }
[BindProperty(Name = "Products.Category")]
public Categories Category { get; set; } = new();
public async Task OnGetAsync(int id = 1)
{
Products = await context.Products.Include(p => p.Category).Include(p => p.Supplier).FirstAsync(p => p.ProductId == id);
}
public IActionResult OnPost()
{
TempData["products"] = System.Text.Json.JsonSerializer.Serialize(Products);
TempData["category"] = System.Text.Json.JsonSerializer.Serialize(Category);
return RedirectToPage("FormResults");
}
위 예제는 Product.Category 속성을 선택하기 위해 asp-for attribute를 사용한 input요소를 추가하였습니다. page handler class에서는 Category속성을 정의하였으며 여기에 BindProperty attribute를 적용하고 Name인수를 통해 구성되었습니다. Project를 실행하고 /pages/form URL을 요청한 뒤 submit button을 click 합니다. model binding은 정의된 2개의 속성에서 값을 찾게 되지만 접두사에 의해 다음과 같은 결과를 만들게 됩니다.
(3) 선별적인 Binding 속성
몇몇 model class에는 중요하면서도 사용자가 직접 값을 지정할 수 없는 속성이 정의되는 경우가 있습니다. 예를 들어 사용자는 Products개체의 Category를 변경할 수는 있으나 가격을 변경해서는 안될 수 있습니다.
이런 경우 중요한 속성에 mapping 되는 HTML요소를 무시하는 view를 생성할 수도 있겠지만 악의적인 HTTP요청을 고의적으로 생성(over-binding 공격으로 알려진)하는 사용자까지는 막을 수 없습니다. 중요한 속성에 대해 model binder가 값을 사용하는 것을 방지하려면 아래 Controllers folder의 FormController.cs file 예제와 같이 bound 되어야 하는 속성이 목록을 지정해야 합니다.
[HttpPost]
public IActionResult SubmitForm([Bind("ProductName", "Category")] Products product)
{
TempData["name"] = product.ProductName;
TempData["price"] = product.UnitPrice.ToString();
TempData["category name"] = product.Category?.CategoryName;
return RedirectToAction(nameof(Results));
}
위 예제의 action method 매개변수는 다시 Products type으로 되돌렸습니다. 다만 속성의 이름을 지정하는 Bind attribute를 적용하여 해당 속성이 model binding process에 포함되도록 하였습니다. 이것으로 model binding기능은 ProductName과 CategoryName 속성을 찾을 것이며 process로부터 다른 속성은 제외할 것입니다. project를 실행하여 /controllers/form URL을 요청하고 form을 submit 합니다. browser가 HTTP POST요청의 일부로서 Price속성을 보낸다 하더라도 model binder는 이를 무시하게 됩니다.
● Model Class의 선별적 binding
Razor Page를 사용하거나 application전역에서 model binding에 같은 일련의 속성을 사용하고자 한다면 BindNever attribute를 model class에 적용할 수 있습니다. 아래 예제는 Models folder의 Products.cs file에서 해당 attribute를 적용한 것을 나타내고 있습니다.
[BindNever]
public decimal? UnitPrice { get; set; }
BindNever attribute는 model binder에서 해당 속성을 제외하게 되며 위에서 사용한 목록을 지정하는 것과 같은 효과를 가질 수 있습니다. project를 실행하여 /pages/form URL을 요청하고 form을 submit합니다. 이전 예제에서 처럼 UnitPrice속성은 model binder가 무시하게 됩니다.
이와 반대로 BindRequired attribute를 사용할 수도 있습니다. 이렇게 하면 model binding process에게 반드시 요청에서 해당 속성의 값이 포함됨을 알려줄 수 있습니다. 만약 요청에서 필요한 값을 포함하고 있지 않으면 model validation error를 발생시키게 됩니다.
5. Array와 Collection Binding
model binding process는 array와 collection에 대한 요청 data를 binding 하기 위한 몇몇 기능을 지원하고 있습니다.
(1) Array binding
default model binder의 기능 중에는 array를 위한 기능을 포함하고 있습니다. 해당 기능이 어떻게 동작하는지를 살펴보기 위해 Pages folder에 Bindings.cshtml이름의 file을 아래와 같이 추가합니다.
@page "/pages/bindings"
@model BindingsModel
@using Microsoft.AspNetCore.Mvc
@using Microsoft.AspNetCore.Mvc.RazorPages
<div class="container-fluid">
<div class="row">
<div class="col">
<form asp-page="Bindings" method="post">
<div class="form-group">
<label>Value #1</label>
<input class="form-control" name="Data" value="Item 1" />
</div>
<div class="form-group">
<label>Value #2</label>
<input class="form-control" name="Data" value="Item 2" />
</div>
<div class="form-group">
<label>Value #3</label>
<input class="form-control" name="Data" value="Item 3" />
</div>
<button type="submit" class="btn btn-primary">Submit</button>
<a class="btn btn-secondary" asp-page="Bindings">Reset</a>
</form>
</div>
<div class="col">
<ul class="list-group">
@foreach (string s in Model.Data.Where(s => s != null))
{
<li class="list-group-item">@s</li>
}
</ul>
</div>
</div>
</div>
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace MyWebApp.Pages
{
public class BindingsModel : PageModel
{
[BindProperty(Name = "Data")]
public string[] Data { get; set; } = Array.Empty<string>();
}
}
array를 위한 model binding에서는 arrary값으로 제공해야 할 모든 요소에서 같은 값을 가진 name attribute의 설정을 필요로 합니다. 예제에서는 3개의 input요소를 표시하고 있으며 이들 모두는 data라는 값의 name attribute를 가지고 있습니다. 이에 따라 model binder가 array값을 찾을 수 있도록 page model의 data속성에 BindProperty attribute를 Name인수를 사용해 적용하였습니다.
위 예제에서는 handler method가 존재하지 않음에 주의해야 합니다. 비록 일반적인 경우는 아니지만 예제는 아무런 문제 없이 작동하며 요청은 단지 array에 대한 값만을 제공하고 표시할 뿐이기 때문에 이러한 요청에 대해서 필요한 명시적인 처리는 존재할 필요가 없습니다.
HTML form이 submit 되면 새로운 array가 생성되고 사용자에게 표시된 3개의 요소로부터 값을 채우게 될 것입니다. binding process를 확인해 보기 위해 project를 실행한 후 /pages/bindings로 URL을 요청합니다. 각 field의 값을 입력한 다음 submit button을 눌러보면 Data array의 content가 @foreach표현식을 사용해 list로 표시됨을 확인할 수 있습니다.
다만 arrary content가 표시될 때 null값은 제외되도록 하였으므로
@foreach (string s in Model.Data.Where(s => s != null))
field의 값이 비어있게 되면 array에서 null이 생성되고 결과에서 표시할 때 제외될 것입니다.
● Array값의 Index 위치 지정하기
기본적으로 array는 browser로부터 전달받은 form값의 순서(일반적으로 HTML요소가 정의된 순서인)로 채워집니다. 이때 name attribute는 array의 값의 순서를 지정하는 데 사용될 수 있습니다.
<div class="form-group">
<label>Value #1</label>
<input class="form-control" name="Data[1]" value="Item 1" />
</div>
<div class="form-group">
<label>Value #2</label>
<input class="form-control" name="Data[0]" value="Item 2" />
</div>
<div class="form-group">
<label>Value #3</label>
<input class="form-control" name="Data[2]" value="Item 3" />
</div>
array에 대한 index표시는 array의 data-bound에서 값의 순서를 특정하게 됩니다. project를 실행하여 /pages/bindings URL을 요청하고 form을 submit 하여 name요소에 의해 순서가 좌우된 item내용을 다음과 같이 확인합니다. index표기는 array로 값을 제공하는 모든 요소에 적용되어야 하며 번호의 순서에 차이가 있어서는 안 됩니다. (0,1,2와 같은 순서는 되지만 0,2,3과 같은 순서는 안됩니다.)
(2) 단순 Collection binding
model binding process는 array 이외에 collection 또한 생성할 수 있습니다. 목록과 집합에서와 같은 collection의 순서를 위해서는 model binder에 의해 사용되는 속성 혹은 매개변수 type만이 변경됩니다.
[BindProperty(Name = "Data")]
public SortedSet<string> Data { get; set; } = new SortedSet<string>();
예제에서 Data속성의 type은 SortedSet<string>으로 변경되었습니다. 이에 따라 model binder는 alphabet순으로 정렬될 input요소로 부터의 값을 채우게 될 것입니다. cshtml에서는 여전히 input요소의 name attribute에 index표시가 남아 있기는 하지만 collection class는 alphabet순서로 값을 정렬할 것이기 때문에 큰 의미는 없습니다. project를 실행하고 /pages/bindings URL을 요청한 뒤 field를 채우고 form을 submit 합니다. model binding process는 정렬된 form값을 채우게 되고 다음과 같은 순서로 표현될 것입니다.
(3) Dictionary binding
index표기법을 통해 name attribute가 정의된 요소에서 model binder는 일련의 요소를 key와 값을 가진 개체로 변환하도록 하는 Dictionary를 binding 하는 경우 해당 값을 key로서 사용하게 됩니다.
@page "/pages/bindings"
@model BindingsModel
@using Microsoft.AspNetCore.Mvc
@using Microsoft.AspNetCore.Mvc.RazorPages
<div class="container-fluid">
<div class="row">
<div class="col">
<form asp-page="Bindings" method="post">
<div class="form-group">
<label>Value #1</label>
<input class="form-control" name="Data[1idx]" value="Item 1" />
</div>
<div class="form-group">
<label>Value #2</label>
<input class="form-control" name="Data[3idx]" value="Item 2" />
</div>
<div class="form-group">
<label>Value #3</label>
<input class="form-control" name="Data[2idx]" value="Item 3" />
</div>
<button type="submit" class="btn btn-primary">Submit</button>
<a class="btn btn-secondary" asp-page="Bindings">Reset</a>
</form>
</div>
<div class="col">
<table class="table table-sm table-striped">
<tbody>
@foreach (string key in Model.Data.Keys)
{
<tr>
<th>@key</th>
<td>@Model.Data[key]</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace MyWebApp.Pages
{
public class BindingsModel : PageModel
{
[BindProperty(Name = "Data")]
public Dictionary<string, string> Data { get; set; } = new Dictionary<string, string>();
}
}
collection으로 값을 제공하는 모든 요소는 공통된 접두사를 공유해야 하며 예제에서는 Data가 그 값이 되고 중괄호 속의 값이 key가 됩니다. 따라서 예제에서의 key는 1idx, 2idx, 3idx등의 문자열이며 text field에 사용자가 입력한 값을 위해 key로서 활용되는 것입니다. project를 실행하고 /pages/bindings URL을 요청합니다. 그리고 text field에 값을 설정하고 form을 submit 합니다. 그러면 form data로부터의 key와 값은 다음과 같이 table로 표시됩니다.
(4) 복합 type의 collection binding
이제까지의 예제에서는 단순 type에 해당하는 collection만을 대상으로 하였지만 같은 처리방식을 복합 type에서도 적용할 수 있습니다. 아래 예제는 이전 예제를 변경하여 변경하여 Products개체의 array에 bind 하기 위해 사용된 상세정보를 가져올 수 있도록 하고 있습니다.
<div class="container-fluid">
<div class="row">
<div class="col">
<form asp-page="Bindings" method="post">
@for (int i = 0; i < 2; i++)
{
<div class="form-group">
<label>Name #@i</label>
<input class="form-control" name="Data[@i].ProductName" value="Product-@i" />
</div>
<div class="form-group">
<label>Price #@i</label>
<input class="form-control" name="Data[@i].UnitPrice" value="@(100 + i)" />
</div>
}
<button type="submit" class="btn btn-primary">Submit</button>
<a class="btn btn-secondary" asp-page="Bindings">Reset</a>
</form>
</div>
<div class="col">
<table class="table table-sm table-striped">
<tbody>
<tr><th>Name</th><th>Price</th></tr>
@foreach (Products p in Model.Data)
{
<tr>
<td>@p.ProductName</td>
<td>@p.UnitPrice</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
public class BindingsModel : PageModel
{
[BindProperty(Name = "Data")]
public Products[] Data { get; set; } = Array.Empty<Products>();
}
input요소에서 name attribute는 array표기법을 사용하고 있으며 마침표에 따라 그 뒤에 복합 type속성의 이름을 나타냅니다.
binding처리동안 model binder는 대상 type에서 정의된 모든 public속성의 값을 찾을 것이며 form data에서 각 값의 설정처리를 반복하게 됩니다.
해당 예제에서는 ProductClass에 정의된 UnitPrice속성의 model binding에 의존하고 있는데 속성은 BindNever attribute를 적용하여 binding process로부터 제외처리되었으므로 attribute를 아래와 같이 제거해야 합니다.
//[BindNever]
public decimal? UnitPrice { get; set; }
project를 실행하여 /pages/bindings URL을 요청하고 text field에 ProductName과 UnitPrice값을 입력합니다. 그리고 form을 submit 하면 table에 표시된 data로부터 생성된 Products개체의 상세를 다음과 같이 볼 수 있을 것입니다.
6. Model Binding Source 지정하기
기본적으로 model binding process는 form data값, 요청 body(web service controller에서만 해당됨), routing data 그리고 요청 query string 이렇게 4곳에서 필요한 data를 찾게 됩니다.
이러한 검색 규칙은 요청의 특정 부분에서 data값을 가져와야 하거나 기본적인 검색대상이 아닌 data source를 사용해야 하는 경우에는 그다지 도움이 되지 않을 것입니다. 하지만 model binding은 기본적인 검색동작을 재정의 하기 위한 아래 표에 나열된 일련의 attribute를 포함하고 있습니다.
이외에 FromService attribute도 존재하는데 이는 요청으로부터 값을 가져오지는 않지만 종속성주입 기능을 대신 사용합니다.
FromForm | 해당 attribute는 binding data의 source로서 form data를 지정하는데 사용됩니다. 매개변수의 이름은 기본적으로 form 값을 찾는데 사용되지만 Name속성을 사용해 특정한 다른 이름을 사용할 수도 있습니다. |
FromRoute | 해당 attribute는 binding data의 source로서 routing system을 지정하는데 사용됩니다. 이 역시 매개변수의 이름은 기본적으로 form값을 찾는데 사용되지만 Name속성을 사용해 특정한 다른 이름을 사용할 수도 있습니다. |
FromQuery | 해당 attribute는 binding data의 source로서 query string을 지정하는데 사용됩니다. 이 역시 매개변수의 이름은 기본적으로 form값을 찾는데 사용되지만 Name속성을 사용해 특정한 다른 query string key를 사용할 수도 있습니다. |
FromHeader | 해당 attribute는 binding data의 source로서 요청 header를 지정하는데 사용됩니다. 이 역시 매개변수의 이름은 기본적으로 header name으로서 사용되지만 Name속성을 사용해 특정한 다른 header name을 사용할 수도 있습니다. |
FromBody | 해당 attribute는 binding data의 source로서 사용되어야 하는 요청 body를 지정하는데 사용됩니다. 요청 body는 web service를 제공하는 API Controller와 같이 form으로 encode되지 않은 요청으로 부터 data를 전달받고자 할때 사용됩니다. |
FromForm, FromRoute, FromQuery attribute는 기본적인 검색 sequence 없이 표준 위치 중 하나로부터 가져오게 될 model binding data를 특정합니다. 예를 들어 아래와 같은 URL의 경우
/controllers/form/index/5?id=3 |
해당 URL은 Form controller에 있는 Index action method의 id 매개변수로 사용될 수 있는 2개의 값을 포함하고 있는데 routing system은 URL의 마지막 segment를 controller의 기본 URL pattern에 정의된 id라는 변수에 할당할 것이며 query string 또한 id값을 포함합니다.
이러한 기본 검색 pattern은 model binding data가 route data로부터 가져오게 될 것이며 결국 query string은 무시될 것입니다.
아래 예제는 Controller folder에 있는 FormController.cs file을 변경한 것으로 Index action method에서 정의된 id매개변수에 FromQuery attribute를 적용하여 기본 검색 sequence를 재정의 하도록 하였습니다.
public async Task<IActionResult> Index([FromQuery] int? id)
{
ViewBag.Categories = new SelectList(context.Categories, "CategoryId", "CategoryName");
return View("Form", await context.Products.Include(p => p.Category).Include(p => p.Supplier).FirstOrDefaultAsync(p => id == null || p.ProductId == id));
}
예제에서 attribute는 model binding process의 source를 지정하고 있으며 project를 실행하여 /controllers/form/index/5?id=1을 요청함으로써 결과를 통해 이를 확인해 볼 수 있습니다.
routing system에 의해 일치되는 값을 사용하는 대신 query string값을 대신 사용할 것입니다. 심지어는 model binding process 위한 적절한 값을 query string이 포함하지 않고 있다 하더라도 다른 위치를 대신할 source로서 사용하지 않습니다.
model binding source를 query string과 같은 것으로 특정하는 경우에도 복합 type을 여전히 binding 할 수 있습니다. 매개변수 type에서 단순 속성과 같은 경우 model binding process는 query string key를 같은 이름을 통해 찾게 될 것입니다.
(1) 속성을 통한 Binding Source 지정하기
같은 attribute는 Pages folder의 Bindings.cshtml을 변경한 아래 예제와 같이 page model이나 controller에서 정의된 model bind 속성에도 사용될 수 있습니다.
//[BindProperty(Name = "Data")]
[FromQuery(Name = "Data")]
public Products[] Data { get; set; } = Array.Empty<Products>();
FromQuery attribute는 query string을 통해 model binder값의 source로서 사용하도록 함으로써 Products array를 생성하게 됩니다. project를 실행하여 /pages/bindings?data[0].productname=Skis&data[0].unitprice=500 으로 URL을 호출하면 다음과 같은 결과를 볼 수 있을 것입니다.
해당 예제에서는 query string을 사용하여야 하므로 GET요청을 사용하였습니다. 물론 예제의 query string은 아무런 악의도 존재하지 않지만 실제 Application의 상태를 변경할 수 있는 GET요청을 보낼 때는 문제를 유발할 수도 있으므로 주의해야 합니다.
(2) Model Binding을 위한 Header사용하기
FromHeader는 HTTP request의 header가 binding data를 위한 source로서 사용될 수 있도록 합니다. 아래 예제는 Controllers folder의 FormController.cs file에서 추가된 간단한 action method입니다. 해당 action method는 Form controller에 기본 HTTP 요청 header로부터 model bound가 될 매개변수를 정의하고 있습니다.
public string Header([FromHeader] string accept)
{
return $"Header: {accept}";
}
Header action method는 accept라는 매개변수를 정의하고 있는데 해당 값은 현재 요청의 Accept header로 부터 가져오게 될 것이며 다시 method의 결과로써 반환하게 됩니다. project를 실행하여 /controllers/form/header URL을 호출하면 아마도 아래와 비슷한 결과를 보게 될 것입니다.
모든 HTTP header name을 action method 매개변수 이름을 통해 쉽게 선택할 수 있는 것은 아닙니다. model binding system은 C#의 naming규칙을 HTTP header에서 사용되는 규칙으로 변환하지 않기 때문입니다. 이러한 상황에서는 FromHeader attribute를 header의 이름을 특정하기 위한 Name속성을 사용해 설정해야 합니다. 아래 예제는 Controllers folder의 FormController.cs file에서 FromHeader를 어떻게 적용할 수 있는지를 나타내고 있습니다.
public string Header([FromHeader(Name = "Accept-Language")] string accept)
{
return $"Header: {accept}";
}
예제에서 사용된 'Accept-Language'명칭은 C#의 매개변수 이름으로서는 사용할 수 없으며 model binder는 자동적으로 AcceptLanguage와 같은 이름을 header와 일치하는 Accept-Language로 변환하지 않습니다. 대신 attribute를 설정하는 Name속성을 사용하여 원하는 header와 일치하도록 하였습니다. project를 실행하고 /controllers/form/header로 URL을 요청하면 다음과 비슷한 결과를 볼 수 있게 됩니다.
(3) 요청 body를 Binding Source로 사용하기
javascript client가 API Controller로 JSON data를 보내는 경우와 같이 client에 의해 전송된 모든 data가 form data로서만 전송되는 것은 아닙니다. FromBody attribute는 decode 되어야 할 요청 body를 지정하고 model binding data의 source로서 사용할 수 있도록 합니다. 아래 예제에서는 Controllers folder의 FormController.cs file에 새로운 action method를 FromBody attribute가 적용된 매개변수와 함께 추가하였습니다.
FromBody attribute는 ApiController가 적용된 Controller에서는 필요하지 않습니다.
[HttpPost]
[IgnoreAntiforgeryToken]
public Products Body([FromBody] Products model)
{
return model;
}
project를 실행한 뒤 POSTMAN을 열어 URL을 /controllers/form/body로 설정하고 JSON형식으로 ProductName과 UnitPirce를 전송하면 다음과 같은 결과를 볼 수 있을 것입니다.
아래 예제에서는 action method에 IgnoreAntiforgeryToken attribute를 추가하여 anti-forgery token을 포함하지 않은 요청을 받을 수 있도록 하였습니다.
JSON-encode 요청 body는 action method 매개변수로의 model bind에 사용되었습니다.
7. 수동적 model binding
Model binding은 action 또는 hander method에서 매개변수를 정의하거나 BindProperty attribute를 적용하면 자동적으로 적용됩니다. 자동 model binding은 name규칙을 잘 따르고 이에 따라 model binding이 항상 적용되기를 원하는 경우라면 별문제 없이 잘 작동할 것입니다. 하지만 binding processs를 따로 제어하고자 하거나 선별적인 binding처리가 수행되어야 한다면 model binding을 아래 예제와 같이 수동적으로 작동시킬 수 있습니다. 예제는 Pages folder의 Bindings.cshtml를 변경한 것입니다.
<div class="container-fluid">
<div class="row">
<div class="col">
<form asp-page="Bindings" method="post">
<div class="form-group">
<label>Name</label>
<input class="form-control" asp-for="Data.ProductName" />
</div>
<div class="form-group">
<label>Price</label>
<input class="form-control" asp-for="Data.UnitPrice" value="@(Model.Data.UnitPrice + 1)" />
</div>
<div class="form-check m-2">
<input class="form-check-input" type="checkbox" name="bind"
value="true" checked />
<label class="form-check-label">Model Bind?</label>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
<a class="btn btn-secondary" asp-page="Bindings">Reset</a>
</form>
</div>
<div class="col">
<table class="table table-sm table-striped">
<tbody>
<tr><th>Name</th><th>Price</th></tr>
<tr>
<td>@Model.Data.ProductName</td>
<td>@Model.Data.UnitPrice</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
public class BindingsModel : PageModel
{
public Products Data { get; set; } = new Products() { ProductName = "Skis", UnitPrice = 500 };
public async Task OnPostAsync([FromForm] bool bind)
{
if (bind)
await TryUpdateModelAsync<Products>(Data, "data", p => p.ProductName, p => p.UnitPrice);
}
}
수동 model binding은 PageModel과 ControllerBase class에 의해 제공되는 TryUpdateModelAsync method를 사용해 수행되는데 해당 method는 Razor Pages와 MVC Controller모두에서 사용할 수 있습니다.
이 예제는 수동과 자동 model binding을 섞은 것으로 OnPostAsync method는 자동 model binding을 사용해 FromForm attribute가 적용된 bind 매개변수의 값을 받고 있습니다. 이때 bind의 값이 true라면 model binding을 적용하기 위해 TryUpdateModelAsync method를 사용하게 됩니다. TryUpdateModelAsync method의 인수는 model bounding 될 개체, 값의 접두사 및 처리에 포함될 속성을 선택하는 일련의 표현식입니다.
Data속성에 대한 model binding process는 사용자가 예제의 form에 추가된 checkbox를 check 한 경우에만 수행됩니다. 따라서 checkbox가 check 되어 있지 않다면 어떠한 model binding도 처리되지 않으며 form data는 무시됩니다. model binding이 사용된다는 것을 분명히 알아보기 위해 form이 render 될 때 UnitPrice의 값이 증가되도록 하였습니다. project를 실행하고 /pages/bindings URL을 요청한 뒤 checkbox를 check 하고 form을 submit 하면 다음과 같은 결과를 볼 수 있습니다.