.NET/ASP.NET

ASP.NET Core - 18. Model Validation

클리엘 2023. 2. 18. 20:57
728x90

Application이 전달받은 data를 단순히 표시만 할 것이 아니라면 사용자가 제공한 data는 Application이 해당 data를 사용하기 이전에 사전 검사가 이루어져야 합니다. 실제로도 사용자는 유효성이 검증되지 않은 사용할 수 없는 data를 입력할 수 있기 때문에 유효성검사가 이루어져야 하며 ASP.NET Core에서는 이를 실행하는 방법으로 model validation을 지원하고 있습니다.

 

model validation은 application으로 전달된 data가 model로 binding 하기에 알맞은지를 보증하기 위한 process에 해당되며 만약 그렇지 않은 경우라면 사용자에게 문제를 해결하는데 도움이 될 수 있는 유용한 정보를 제공해 주는 역할도 수행할 수 있습니다.

 

이러한 기능을 실행하기 위한 첫번째 절차는 전달받은 data를 확인하는 것으로 applidation data의 무결성을 지켜내는데 가장 중요한 것 중 하나이며 사용될 수 없는 data를 반려함으로써 application에서 문제가 있거나 원하지 않는 data가 들어가는 것을 방지할 수 있습니다. validation처리의 두 번째는 사용자가 문제점을 바로잡을 수 있도록 돕는 것이며 첫 번째만큼이나 중요한 부분에 해당합니다. 문제점을 해결하기 위한 feedback이 없으면 사용자는 실망과 함께 혼란스러운 상황에 맞닥뜨릴 수 있고 어떤 사용자는 application의 사용을 중지할 수도 있을 것이며 업무 중이라면 사용자의 업무흐름을 방해하는 요인이 됩니다. 이제 ASP.NET Core의 광범위한 model validation기능을 통해 이러한 문제를 어떻게 해결할 수 있을지를 알아보도록 하겠습니다.

 

1. Project 준비하기

 

예제를 위한 project는 이전예제를 계속 사용하되 FormController의 Form View를 아래와 같이 변경하여 Products class에서 정의된 각 속성의 input요소를 포함할 수 있도록 합니다. 단 Entity Framework Core에서 사용된 Category Name속성은 제외합니다.

@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>
	<div class="form-group">
		<label>CategoryId</label>
		<input class="form-control" asp-for="CategoryId" />
	</div>
	<div class="form-group">
		<label>SupplierId</label>
		<input class="form-control" asp-for="SupplierId" />
	</div>
	<button type="submit" class="btn btn-primary mt-2">Submit</button>
</form>

FormController.cs또한 아래와 같이 변경하여 위에서 정의된 속성의 값을 표시할 수 있도록 하고 더 이상 필요하지 않은 model binding attribute와 action method는 제거합니다.

[AutoValidateAntiforgeryToken]
public class FormController : Controller
{
    private NorthwindContext context;

    public FormController(NorthwindContext dbContext)
    {
        context = dbContext;
    }

    public async Task<IActionResult> Index([FromQuery] int? id)
    {
	    return View("Form", await context.Products.FirstOrDefaultAsync(p => id == null || p.ProductId== id));
    }

	[HttpPost]
	public IActionResult SubmitForm(Products product)
	{
		TempData["productName"] = product.ProductName;
		TempData["unitPrice"] = product.UnitPrice.ToString();
		TempData["categoryId"] = product.CategoryId.ToString();
		TempData["supplierId"] = product.SupplierId.ToString();
		return RedirectToAction(nameof(Results));
	}
	public IActionResult Results()
	{
		return View(TempData);
	}
}

project를 실행한 뒤 처음 나오는 input요소를 확인하고 submit button을 눌러 다음과 같은 결과를 확인합니다.

2. model validation의 필요성

 

model validation은 application이 client로 부터 전달받을 data에 대해 가져야 할 요구사항을 강제하도록 하는 process입니다. validation이 없이  application은 전달받을 수 있는 모든 data에 대한 운영을 시도해야 하는데 이들 중에는 즉각적인 예외와 예상치 못한 동작 혹은 database에 악의적이거나 미완의 data를 채우게 됨으로써 점진적으로 문제를 일으킬 수 있는 것들이 포함될 수 있습니다.

 

현재 form data를 전달받는 action과 handler method는 사용자가 submit을 시도한 모든 data를 받을 수 있으며 database에 저장하지 않고 전달받은 data를 그대로 표시할 뿐이지만 대부분의 application에서 data값은 각자 필요한 몇몇 종류의 제약을 가질 수 있습니다. 반드시 제공되어야할 값이 필요할 수 있고 특정 type이나 특정한 범위 내에 있어야 하는 값과 같은 것들이 대표적입니다.

 

예제에서와 같은 상황에서 database에 Products개체를 저장해야 한다면 우선은 사용자가 제공한 ProductName, UnitPrice, CategoryId, SuplierId속성에 대한 값이 안전하게 application에 전달할 수 있도록 만들 필요가 있습니다. ProductName은 정확한 문자열이 되어야 하며 UnitPrice는 통화값, CategoryId와 SupplierId속성은 현재 database에 존재하는 Supplier와 Category에 해당하는 값이 되어야 할 것입니다.

 

이에 따라 model validation이 어떻게 application이 전달받은 data를 확인하고 이러한 요구사항을 강제할 수 있는지, 올바른 값이 아닐 경우 또 어떻게 사용자에게 feedback을 전달할 수 있는지에 대한 방법을 알아볼 필요가 있습니다.

 

3. Data 유효성 검증

 

비록 관련된 error가 표시되고 있지는 않지만 ASP.NET Core는 model binding이 처리되는 동안 이미 기본적인 validation을 수행하고 있습니다. 아래 예제는 Controllers folder에 있는 FormController.cs file을 변경한 것으로 validation처리의 결과를 확인하도록 함으로서 사용자가 제공한 data가 유효한 경우에만 사용될 수 있도록 하고 있습니다.

[HttpPost]
public IActionResult SubmitForm(Products product)
{
	if (ModelState.IsValid)
	{
		TempData["productName"] = product.ProductName;
		TempData["unitPrice"] = product.UnitPrice.ToString();
		TempData["categoryId"] = product.CategoryId.ToString();
		TempData["supplierId"] = product.SupplierId.ToString();
		return RedirectToAction(nameof(Results));
	}
	else
	{
		return View("Form");
	}
}

예제에서는 사용자에 의해 제공된 data가 ModelStateDictionary개체를 사용하여 유효한 것인지를 판단하고 있습니다. 해당 개체는 ControllerBase class에서 상속된 ModelState속성에서 반환됩니다.

 

이름으로서 추정해 보면 ModelStateDictionary class는 validation error에 중점을 두고 model개체의 상태에 대한 상세정보를 추적하기 위해 사용되는 dictionary임을 알 수 있습니다. 아래 표는 ModelStateDictionary에서 가장 주요한 member들을 나열한 것입니다.

AddModelError(property, message) 해당 method는 특정 속성에 대한 model validation error를 지정하는데 사용됩니다.
GetValidationState(prop erty) 해당 method는 ModelValidationState열거로 부터 값으로 표현되는 특정 속성에 대한 model validation error가 존재하는지의 여부를 판단하는데 사용됩니다.
IsValid 해당 속성은 모든 model 속성의 유효한 경우 true를 그렇지 않으면 false를 반환합니다.
Clear() 해당 method는 validation상태를 초기화 합니다.

validation process가 문제점을 감지한 경우라면 IsValid 속성은 false를 반환할 것입니다. SubmitForm action method는 같은 view에서 반환되는 유효하지 않은 data는 다음과 같이 처리하고 있습니다.

return View("Form");

이 것은 View method를 호출함으로서 validation error를 처리하기에 이상해 보일 수 있지만 view로 제공되는 context data는 model validation error에 대한 상세정보를 포함하고 있고 이들 상세정보는 tag helper에 의해서 input요소를 변환하기 위해 사용되는 것들입니다.

 

이와 같은 동작이 어떻게 작동하는지를 확인해 보기 위해 project를 실행하고 /controllers/form으로 URL을 요청합니다. 그리고 ProductName field의 값을 지우고 submit button을 click 합니다. browser에 의해 표시되는 content에서는 어떠한 눈에 띄는 변화도 없을 테지만 input field에 대한 HTML요소를 확인해 보면 요소가 이전과는 좀 다른 것으로 변환되어 있음을 알 수 있습니다. submit 되기 이전에 요소는 아래와 같지만

<input class="form-control" type="text" id="ProductName" name="ProductName" value="Chai">

form이 submit되고 난 이후에 input요소는 다음과 같이 변환될 것입니다.

<input class="form-control input-validation-error" type="text" data-val="true" data-val-required="Please enter a product name" id="ProductName" name="ProductName" value="">

tag helper는 input-validation-error class라는 유효성 검증이 실패한 경우의 값을 가진 요소를 추가하게 되는데 사용자에게 문제점을 명확하게 표시할 수 있도록 CSS style로 꾸며질 수 있습니다.

 

이를 위해 stylesheet에서 사용자 정의 CSS style을 정의할 수 있지만 Bootstrap이 제공하는 CSS Library와 같은 내장 validation style을 사용하고자 한다면 약간의 작업만으로도 style처리가 가능해질 수 있습니다. input요소에 추가된 CSS class의 이름은 바뀔 수 없습니다. 따라서 ASP.NET Core에서 사용되는 이름과 Bootstrap에서 제공되는 CSS error class사이를 mapping 시키기 위한 javascript code를 추가해야 합니다.

아래와 같은 javascript code의 사용은 불편하기도 하고 어색할 수 있기 때문에 Boostrap과 같은 CSS Library를 사용하고 있는 경우에도 직접 해당 CSS style을 정의하고자 하는 생각이 들 수 있습니다. 하지만 Bootstrap에서 validation error를 위해 사용되는 색상은 theme를 사용하거나 package의 사용자지정 그리고 자신만의 자체 style을 정의함으로서 재정의될 수 있습니다. 따라서 theme의 모든 변경내용이 직접 정의한 모든 사용자 style의 해당 변경내용과 일치하는지를 확인해야 합니다. 이상적으로 Microsoft는 향후 ASP.NET Core release에서 validation class의 이름을 설정가능하도록 만들 수 있지만 아직까지는 Bootstrap style을 적용하기 위해 JavaScript를 사용하는 것이 사용자 정의 stylesheet를 만드는 것보다는 더 강력한 접근법이라고 할 수 있습니다.

Controller와 Razor Page 모두에서 사용할 수 있는 JavaScript를 정의하기 위해 Visual Studio의 Razor View - Empty를 사용하고 Views/Shared folder에 _Validation.cshtml이름의 file을 아래와 같이 추가합니다.Visual Studio Code를 사용하는 경우라면 template이 필요하지 않으므로 동일한 file을 아래 내용으로 추가하면 됩니다. 다만 해당 file에서 bebind code는 필요하지 않으므로 cshtml.cs file이 같이 생성되는 경우 해당 file을 삭제하고 cshtml에 포함되는 @using구문도 제거해야 합니다.

<script type="text/javascript">
	window.addEventListener("DOMContentLoaded", () => {
		document.querySelectorAll("input.input-validation-error").forEach(
			(elem) => { elem.classList.add("is-invalid"); }
		);
	});
</script>

partial view로서 사용하게될 위의 file은 input-validation-error class와 is-invalid class(Bootstrap이 form요소에 error color를 설정하기 위해 사용하는)가 추가된 input요소를 찾기 위해 browser의 JavaScript Document Object Model (DOM) API를 사용하는 script요소를 포함하고 있습니다. 아래 예제는 Views/Form folder의 Form.cshtml file을 변경하여 partial tag helper를 사용하도록 하는 것으로 위의 partial view를 HTML요소에 삽입하여 validation error를 가진 field가 강조되도록 하고 있습니다.

<h5 class="bg-primary text-white text-center p-2">HTML Form</h5>
<partial name="_Validation" />

JavaScript code는 Browser가 HTML문서의 모든 요소에 대한 Parsing을 끝내고 난 다음에 실행되며 효과는 input-validation-error class가 할당 input요소를 강조하는 것입니다. project를 실행하여 /controllers/form URL을 요청하고 ProductName field를 지운다음 submit button을 click 하면 다음과 같은 결과를 볼 수 있게 됩니다.

(1) Validation message 표시

 

위 예제의 결과는 ProductName field에서 무엇인가 잘못되었다는 점을 명확히 하고 있지만 발견된 문제점이 무엇인지에 대한 어떠한 상세정보도 제공하고 있지 않습니다. 따라서 사용자에게 좀더 상세한 정보를 제공할 필요가 있고 그렇게 하기 위해서는 다른 tag helper를 사용해야 합니다. 아래 예제는 Views/Form folder의 Form.csthml file을 변경하여 View에 해당 문제점에 관한 요약정보를 표시할 수 있도록 새로운 tag helper를 추가한 것입니다.

<form asp-action="submitform" method="post" id="htmlform">
	<div asp-validation-summary="All" class="text-danger"></div>
	<div class="form-group">
		<label asp-for="ProductName"></label>
		<input class="form-control" asp-for="ProductName" />
	</div>

ValidationSummaryTagHelper class는 DIV 요소에 있는 asp-validation-summary attribute를 발견하고 기록된 모든 Validation error를 표시하는 message를 추가하여 응답하게 됩니다. asp-validation-summary attribute의 값은 ValidationSummary 열거로 부터의 값이며 아래 표의 값을 정의합니다.

All 해당 속성의 값은 기록된 모든 Validation error의 값을 표시하도록 합니다.
ModelOnly 해당 속성의 값은 주요 model에 대한 Validation error만을 표시하도록 하며 개별 속성에 의해 기록된 error는 제외됩니다.
None 해당 속성의 값은 tag helper를 비활성화하여 HTML요소로서 변환되지 않도록 합니다.

표시된 error message는 사용자로 하여금 form이 처리될 수 없는지를 이해하는데 도움을 줄 것입니다. project를 실행하고 /controllers/form으로 URL을 요청합니다. 그리고 ProductName field를 모두 지우고 form을 submit 합니다. 그러면 감지된 문제점이 무엇인지를 설명하는 error message와 함께 다음과 같은 결과를 볼 수 있을 것입니다.

(2) 암시적인 Validation 수행

 

위 예제에서 표시된 error message는 model binding동안 자동적으로 수행된 암시적인 validation process에 의해 생성된 것입니다.

 

암시적인 validation은 간단하지만 효과적이며 2가지 기본 검사가 존재합니다.(non-nullable형식으로 정의된 모든 속성에 대해 사용자는 값을 제공해야 하며, ASP.NET Core는 HTTP요청으로 수신된 문자열값을 해당 속성 유형으로 구문분석할 수 있어야 합니다.)

 

다음은 현재 form data를 전달받는데 사용되는 Products clsss를 표시한 것입니다.

using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;

namespace MyWebApp.Models
{
    public partial class Products
    {
        public Products()
        {
            OrderDetails = new HashSet<OrderDetails>();
        }

        public int ProductId { get; set; }

        [Required(ErrorMessage = "Please enter a product name")]
        public string ProductName { get; set; }
        public int? SupplierId { get; set; }
        public int? CategoryId { get; set; }
        public string QuantityPerUnit { get; set; }

        //[DisplayFormat(DataFormatString = "{0:c2}", ApplyFormatInEditMode = true)]
        //[BindNever]
        public decimal? UnitPrice { get; set; }
        public short? UnitsInStock { get; set; }
        public short? UnitsOnOrder { get; set; }
        public short? ReorderLevel { get; set; }
        public bool Discontinued { get; set; }

        [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
        public virtual Categories Category { get; set; }
        public virtual Suppliers Supplier { get; set; }
        public virtual ICollection<OrderDetails> OrderDetails { get; set; }
    }
}

Products class의 ProductName은 non-nullable type이며 이 때문에 해당 field가 비어 있는 상태에서 form을 submit 되면 validation error에 발생하게 됩니다. 하지만 ProductName의 구문분석에 관련된 오류는 나타나지 않는데 그 이유는 HTTP 요청으로 전달받은 string값은 다른 어떠한 type으로도 변환할 필요가 없기 때문입니다. 하지만 UnitPrice에 '백만 원'이라는 값을 입력하고 form을 sumit 하면 ASP.NET Core는 HTTP 요청에서의 문자열을 decimal로 변환할 수 없다는 error message를 다음과 같이 표시하게 됩니다.

(3) 명시적인 Validation 수행

 

암시적인 validation은 자동적으로 기본적인 유효성검증을 처리할 수 있지만 대부분의 application에서는 검증된 data를 전달받기위해 추가적인 확인절차를 필요로 합니다. 이를 위해 명시적인 validation을 구현해야 하며 이때 ModelStateDictionary method를 사용하게 됩니다.

 

error message가 겹쳐서 표시되는 것을 방지하기 위해 명시적인 validation은 일반적으로 사용자가 제공한 값이 암시적인 validation을 통과한 경우에만 작동하도록 합니다. ModelStateDictionary.GetValidationState method는 model 속성에서 기록된 error message가 있는지의 여부를 확인하기 위해 사용되며 아래 표의 값을 정의하고 있는 ModelValidationState 열거형의 값을 반환합니다.

Unvalidated 이 값은 model 속성하에서 어떤 validation도 수행되지 않았음을 의미합니다. 대게는 요청에서 속성 name에 해당하는 값이 없는 경우입니다.
Valid 이 값은 속성과 관련된 요청값이 유효함을 의미합니다.
Invalid 이 값은 속성과 관련된 요청값이 유효하지 않고 사용될 수 없음을 의미합니다.
Skipped 이 값은 model 속성이 처리되지 않았음을 의미하는데 대게는 너무 많은 validation error가 발생해서 유효성 확인을 계속 수행하는 것이 의미가 없는 경우입니다.

아래 예제는 Controllers folder에 FormController.cs file을 변경하여 Products class의 몇몇 속성에 대한 명시적인 validation을 수행하도록 한 것입니다.

[HttpPost]
public IActionResult SubmitForm(Products product)
{
	if (ModelState.GetValidationState(nameof(Products.UnitPrice)) == ModelValidationState.Valid && product.UnitPrice <= 0)
		ModelState.AddModelError(nameof(Products.UnitPrice), "Enter a positive price");

	if (ModelState.GetValidationState(nameof(Products.CategoryId)) == ModelValidationState.Valid && !context.Categories.Any(c => c.CategoryId == product.CategoryId))
		ModelState.AddModelError(nameof(Products.CategoryId), "Enter an existing category ID");

	if (ModelState.GetValidationState(nameof(Products.SupplierId)) == ModelValidationState.Valid && !context.Suppliers.Any(s => s.SupplierId == product.SupplierId))
		ModelState.AddModelError(nameof(Products.SupplierId), "Enter an existing supplier ID");

	if (ModelState.IsValid)
	{
		TempData["productName"] = product.ProductName;
		TempData["unitPrice"] = product.UnitPrice.ToString();
		TempData["categoryId"] = product.CategoryId.ToString();
		TempData["supplierId"] = product.SupplierId.ToString();
		return RedirectToAction(nameof(Results));
	}
	else
	{
		return View("Form");
	}
}

ModelStateDictionary를 사용한 위 예제에서는 어떻게 UnitPrice 속성의 유효성을 확인하는지를 보여주고 있습니다. 예제에서 Products class에 대한 유효성검사중 하나는 사용자가 UnitPrice에 양수값을 제공하는지를 확인하는 것인데 현재상태에서 ASP.NET Core는 Products class만으로 이러한 유효성이 필요한지를 추론할 수 없으므로 위 예제와 같은 명시적인 유효성검사가 필요한 것입니다.

 

우선 ModelValidationState.Valid를 통해 해당 속성에 대한 validation error가 존재하는지의 여부를 확인하고 있고

ModelState.GetValidationState(nameof(Products.UnitPrice)) == ModelValidationState.Valid

또한 사용자가 UnitPrice값으로 0이상의 값을 제공하는지를 확인하고자 하지만 만약 model binder가 decimal유형으로 변환할 수 없는 값이 전달된다면 0이나 음수값에 대한 error를 기록하는 것은 더 이상 의미가 없을 것입니다. 따라서 GetValidationState method를 사용하여 UnitPrice속성에 대한 validation상태를 자체 validation을 수행하기 전에 확인하도록 하고 있습니다.

product.UnitPrice <= 0

마지막으로 사용자가 0이나 0보다 더 작은 값을 제공하는 경우에는 AddModelError method를 통하여 validation error를 아래와 같이 기록하도록 합니다.

ModelState.AddModelError(nameof(Products.UnitPrice), "Enter a positive price");

AddModelError method의 인수는 해당 속성의 name과 사용자에게 유효성과 관련된 내용으로 표시하게될 문자열입니다.

 

CategoryId와 SupplierId 속성에서도 위와 비슷한 처리를 따르고 있고 Entity Framework Core를 사용하여 사용자가 제공한 값이 database에 저장된 ID에 해당하는지의 여부를 확인하고 있습니다.

 

명시적인 validation확인을 수행한 후에는 ModelState.IsValid속성을 통해 명시적이거나 암시적인 validation error가 이미  존재하는지의 여부를 확인하고 있습니다.

 

이렇게 적용한 validation의 효과를 확인해 보기 위해 /controllers/form URL을 요청한뒤 UnitPrice와 CategoryId, SupplierId field에 0을 입력하고 form을 submit 하면 다음과 같인 validation error를 보게 될 것입니다.

(4) 기본 validation error message 설정

 

validation process는 표시되는 validation message에 대한 몇가지 불일치한 면을 가지고 있습니다.  model binder에 의해 생성되는 모든 validation message가 사용자에게 도움이 되는 것은 아니며 UnitPrice field를 지우고 form을 submit 해보면 이러한 면을 직접 확인해 볼 수 있습니다. 실제 비어있는 field는 다음과 같은 message를 생성합니다.

The value '' is invaild

위 message는 암시적인 validation process에 의해 속성에 대한 값을 찾을 수 없는 경우 ModelStateDictionary로 추가됩니다. 예를 들어 decimal속성에 대한 결측값은 string속성에 대한 결측값보다도 더 유용성이 떨어지는 message를 생성하는데 이 것은 validation확인이 수행되는 방식에 대한 차이에서 기인합니다. 이런 경우 몇몇 validation error의 기본 message는 DefaultModelBindingMessageProvider class에서 정의된 아래 표의 method를 사용해 사용자 정의 message로 교체될 수 있습니다.

SetValueMustNotBeNullAccessor 해당 속성에 할당된 함수는 non-nullable인 model 속성의 값이 null인 경우의 validation error message를 생성합니다.
SetMissingBindRequiredValueAccessor 해당 속성에 할당된 함수는 요청에서 속성에 필요한 값을 포함하고 있지 않은 경우의 validation error message를 생성합니다.
SetMissingKeyOrValueAccessor 해당 속성에 할당된 함수는 dictionary model 개체에 필요한 data가 null key나 값을 포함하고 있는 경우의 validation error message를 생성합니다.
SetAttemptedValueIsInvalidAccessor 해당 속성에 할당된 함수는 model binding system이 data값을 필요한 C#유형으로 변환할 수 없는 경우의 validation error message를 생성합니다.
SetUnknownValueIsInvalidAccessor 위와 동일합니다.
SetValueMustBeANumberAccessor 해당 속성에 할당된 함수는 data값이 C# numeric type으로 parsing할 수 없는 경우의 validation error message를 생성합니다.
SetValueIsInvalidAccessor 해당 속성에 할당된 함수는 최후의 수단으로서 사용될 validation error message를 생성합니다.

위 표에 설명된 각 method는 사용자에게 표시할 validation message를 얻기위해 호출되는 함수를 수용할 수 있으며 Program.cs file에서 아래 예제와 같이 option pattern을 통해 적용될 수 있습니다. 예제에서는 값이 null이거나 변환될 수 없는 경우 표시될 기본 message를 변경한 것입니다.

builder.Services.Configure<MvcOptions>(opts => opts.ModelBindingMessageProvider.SetValueMustNotBeNullAccessor(value => "필요한 값이 입력되지 않았습니다."));
var app = builder.Build();

비록 null값이 처리될때 특별히 유용한 것은 아니지만 지정한 함수는 사용자가 제공한 값을 받게 될 것입니다. 변경한 message를 확인해 보기 위해 우선 Form.cshtml에서 UnitPrice field를 ProductId로 변경해 줍니다.

<div class="form-group">
	<label asp-for="ProductId"></label>
	<input class="form-control" asp-for="ProductId" />
</div>

project를 실행하고 /controllers/form URL을 요청한 뒤 ProductId의 값을 지우고 form을 submit 합니다. 그러면 다음과 같은 결과를 볼 수 있을 것입니다.

해당 예제는 non-nullable model 속성을 validation하는 특이한 방식으로서 마치 non-nullable속성에 적용된 Required처럼 동작합니다.

 

(5) 속성수준 validation message표시하기

 

비록 사용자지정 message가 기본 message보다는 더 깊은 의미를 가지기는 하지만 문제가 되는 field를 명확하게 드러내지 않고 있으므로 아직은 부족하다고 할 수 있습니다. 이러한 상황에서는 문제가 되는 data를 포함하는 HTML요소와 함께 validation error를 표시하는 것이 훨씬 유용할 것이며 span요소를 찾는 ValidationMessageTag라는 tag helper를 통해 해당 기능을 구현할 수 있습니다. 이때 span요소는 표시되어야 할  error message의 속성을 연결할 asp-validation-for attribute를 가지고 있습니다.

 

아래 예제에서는 Views/Form folder의 Form.cshtml file을 변경하여 속성 수준 validation message를 form의 각 input요소에 추가한 것입니다.

<form asp-action="submitform" method="post" id="htmlform">
	<div asp-validation-summary="All" class="text-danger"></div>
	<div class="form-group">
		<label asp-for="ProductName"></label>
		<div><span asp-validation-for="ProductName" class="text-danger"></span></div>
		<input class="form-control" asp-for="ProductName" />
	</div>
	<div class="form-group">
		<label asp-for="ProductId"></label>
		<div><span asp-validation-for="ProductId" class="text-danger"></span></div>
		<input class="form-control" asp-for="ProductId" />
	</div>
	<div class="form-group">
		<label>CategoryId</label>
		<div><span asp-validation-for="CategoryId" class="text-danger"></span></div>
		<input class="form-control" asp-for="CategoryId" />
	</div>
	<div class="form-group">
		<label>SupplierId</label>
		<div><span asp-validation-for="SupplierId" class="text-danger"></span></div>
		<input class="form-control" asp-for="SupplierId" />
	</div>
	<button type="submit" class="btn btn-primary mt-2">Submit</button>
</form>

span 요소는 inline으로 표시되기 때문에 message와 관련된 요소를 분명하게 나타내는 validation error를 표시할 때 주의해야 합니다. 위와 같은 효과를 확인해 보기 위해 project를 실행하고 ProductId field의 값을 삭제한 뒤 form을 submit 합니다. 그러면 대략 다음과 같은 결과를 볼 수 있습니다.

(6) Model 수준 message 표시하기

 

validation summary message는 속성 수준의 message로 인해 어쩌면 불필요하다고 생각할 수 있지만 model 전체에 대한 validation error message를 표시할 수 있는 유용한 기능을 가지고 있으며 이 것은 속성 수준의 validation message로는 표시할 수 없는 기능이라 할 수 있습니다. 이 기능을 통해 여러 속성에 대한 결합으로 발생할 수 있는 error를 사용자에게 제공할 수 있습니다. 아래 예제는 Controllers folder의 FormController.cs file을 변경하여 UnitPrice값이 100을 초과하고 ProductName이 Small로 시작하는 경우 SubmitForm에서 별도의 validation error가 추가될 수 있도록 하였습니다.

[HttpPost]
public IActionResult SubmitForm(Products product)
{
	if (ModelState.GetValidationState(nameof(Products.UnitPrice)) == ModelValidationState.Valid && product.UnitPrice <= 0)
		ModelState.AddModelError(nameof(Products.UnitPrice), "Enter a positive price");

	if (ModelState.GetValidationState(nameof(Products.ProductName)) == ModelValidationState.Valid && ModelState.GetValidationState(nameof(Products.UnitPrice)) == ModelValidationState.Valid && product.ProductName.ToLower().StartsWith("small") && product.UnitPrice > 100)
		ModelState.AddModelError(string.Empty, "Small products cannot cost more than 100");

	if (ModelState.GetValidationState(nameof(Products.CategoryId)) == ModelValidationState.Valid && !context.Categories.Any(c => c.CategoryId == product.CategoryId))
		ModelState.AddModelError(nameof(Products.CategoryId), "Enter an existing category ID");

	if (ModelState.GetValidationState(nameof(Products.SupplierId)) == ModelValidationState.Valid && !context.Suppliers.Any(s => s.SupplierId == product.SupplierId))
		ModelState.AddModelError(nameof(Products.SupplierId), "Enter an existing supplier ID");

	if (ModelState.IsValid)
	{
		TempData["productName"] = product.ProductName;
		TempData["unitPrice"] = product.UnitPrice.ToString();
		TempData["categoryId"] = product.CategoryId.ToString();
		TempData["supplierId"] = product.SupplierId.ToString();
		return RedirectToAction(nameof(Results));
	}
	else
	{
		return View("Form");
	}
}

위 예제에 따라 사용자가 Small이라는 이름의 ProductName과 100이상의 UnitPrice값을 입력한다면 model validation error가 기록될 것입니다. 이때 전체 model과 관련된 validation error는 첫번째 인수가 빈문자열인 AddModelError를 사용해 추가됩니다. 다만 해당 확인을 UnitPrice속성의 개별 Validation처리 밑에 둠으로서 사용자가 validation error가 충돌되는 message를 보는 경우가 없도록 하였습니다.

 

아래 예제는 Views/Form folder의 Form.cshtml file에서 asp-validation-summary attribute의 값을 ModelOnly로 변경하였습니다. 이에 따라 속성 수준의 error를 제외하고 전체 model에 적용되는 error만이 summary에 표시될 것입니다. 또한 이전에 사용하던 ProductId는 제거하고 다시 UnitPrice속성로 변경하였습니다.

<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
	<label asp-for="ProductName"></label>
	<div><span asp-validation-for="ProductName" class="text-danger"></span></div>
	<input class="form-control" asp-for="ProductName" />
</div>
<div class="form-group">
	<label asp-for="UnitPrice"></label>
	<div><span asp-validation-for="UnitPrice" class="text-danger"></span></div>
	<input class="form-control" asp-for="UnitPrice" />
</div>

Project를 실행하고 /controllers/form URL을 요청합니다. 그리고 ProductName field에 SmallProduct라고 입력하고 UnitPrice에 120의 값을 입력한 뒤 form을 submit합니다. 그러면 model 수준의 error message를 포함한 다음과 같은 응답을 볼 수 있게 됩니다.

4. Razor Page에서의 명시적인 Validation data

 

Razor Page validation은 위에서 Controller에서 사용된 것과 같은 기능에 의존합니다. 아래 예제는 Pages folder의 FormHandler.cshtml file을 변경하여 명시적인 validation 확인과 error summary를 추가한 것입니다.

public class FormHandlerModel : PageModel
{
	private NorthwindContext context;

	public FormHandlerModel(NorthwindContext dbContext)
	{
		context = dbContext;
	}

	[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.FirstAsync(p => p.ProductId == id);
	}

	public IActionResult OnPost()
	{
		if (ModelState.GetValidationState("Products.UnitPrice") == ModelValidationState.Valid && Products.UnitPrice < 1)
			ModelState.AddModelError("Products.UnitPrice", "Enter a positive price");
			
		if (ModelState.GetValidationState("Products.ProductName") == ModelValidationState.Valid && ModelState.GetValidationState("Products.Price") == ModelValidationState.Valid && Products.ProductName.ToLower().StartsWith("small") && Products.UnitPrice > 100)
			ModelState.AddModelError(string.Empty, "Small products cannot cost more than 100");
			
		if (ModelState.GetValidationState("Products.CategoryId") == ModelValidationState.Valid && !context.Categories.Any(c => c.CategoryId == Products.CategoryId))
			ModelState.AddModelError("Products.CategoryId", "Enter an existing category ID");

		if (ModelState.GetValidationState("Products.SupplierId") == ModelValidationState.Valid && !context.Suppliers.Any(s => s.SupplierId == Products.SupplierId))
			ModelState.AddModelError("Products.SupplierId", "Enter an existing supplier ID");

		if (ModelState.IsValid)  {
			TempData["name"] = Products.ProductName;
			TempData["price"] = Products.UnitPrice.ToString();
			TempData["categoryId"] = Products.CategoryId.ToString();
			TempData["supplierId"] = Products.SupplierId.ToString();

			return RedirectToPage("FormResults");
		}
		else
		{
			return Page();
		}
	}
}

PageModel class에서는 controller에서 사용한 것과 동일하고 validation error를 기록할 수 있는 ModelState속성을 정의하고 있습니다. validation prcoess역시 동일하지만 Razor Page에서 사용된 pattern과 일치하는 이름에 error를 기록할 때는 주의해야 합니다. controller에서 error를 기록할 때는 아래와 같이 error와 관련된 속성을 선택하기 위해 nameof keyword를 사용했었는데

ModelState.AddModelError(nameof(Products.UnitPrice), "Enter a positive price");

이것은 오타로 인해 error가 잘못 기록되는 것을 방지하므로 가장 흔한 규칙에 해당한다고 할 수 있습니다. 하지만 error가 Razor page에서의 @Model 표현식이 page model 개체를 반환함을 반영하기 위해 사용한 Products.UnitPrice로 부터 기록되어야 하는 Razor Page에서는 위와 같은 표현식은 사용할 수 없습니다. 따라서 아래와 같은 방법을 통해 validation error를 기록해야 합니다.

ModelState.AddModelError("Products.UnitPrice", "Enter a positive price");

validation process를 확인하기 위해 project를 실행하고 /pages/form으로 URL을 요청한 뒤 field를 비우거나 Products class에서 필요한 C#유형으로 변환할 수 없는 값을 입력하고 form을 submit합니다. 그러면 이전 결과와 같은 응답을 볼 수 있습니다.

5. Metadata를 사용하여 Validation 규칙 지정하기

 

action method안에서 validation logic를 넣어두는 위와 같은 예제에서 발생할 수 있는 문제점중 하나는 사용자로부터 data를 전달받으면서 여러 해당 validation이 필요한 모든 action 또는 handler method에서 중복적으로 logic을 구현해야 할 수 있다는 것입니다. 이러한 상황을 해결하기 위해 validation process는 model class에 직접적으로 model validation 규칙을 표현하는 attribute를 사용함으로써 필요한 모든 action method에 동일한 validation 규칙을 사용하여 요청을 처리할 수 있습니다. 아래 예제에서는 Models folder에서 Products.cs file을 변경하여 ProductName과 UnitPrice속성에 필요한 validation속성을 적용하고 있습니다.

[Required(ErrorMessage = "Please enter a value")]
public string ProductName { get; set; }
[Range(1, 999999, ErrorMessage = "Please enter a positive price")]
public decimal? UnitPrice { get; set; }

예제에서는 Required와 Range라는 2개의 attribute를 사용하고 있습니다. Required attribute는 사용자가 해당 속성에 값을 submit하지 않는 경우에 대한 validation error를 지정하고 있으며 null가능한 속성을 가지고 있으면서도 사용자로 하여금 값을 요구하고자 하는 경우 유용하게 사용될 수 있습니다.

 

특히 Required attribute는 사용자가 ProductName속성에 값을 제공하지 않는 경우 표시될 error message를 변경할 수도 있는데 암시적인 validation 확인이 non-nullable속성이 처리되는 방식에서 일관성이 없으나 모든 validation attribute에서 정의하고 있는 ErrorMessage 인수를 사용함으로써 정정될 수 있습니다.

 

예제는 또한 Range attribute를 적용함으로서 UnitPrice속성에서 받아들일 수 있는 값의 범위를 지정하고 있습니다. 아래 표에서는 자주 사용되는 일련의 내장 validation attribute를 소개하고 있습니다.

Compare [Compare ("비교속성")] 해당 attribute는 비교하는 속성들이 같은 값을 가져야 하는 경우 사용되며 email이나 password같이 사용자에게 같은 정보를 이중으로 요청하는 경우에 유용하게 사용될 수 있습니다.
Range [Range(10, 20)] 해당 attribute는 숫자형 값(또는 IComparable를 구현하는 다른 모든 속성)이 minimum과 maximun으로 설정된 값의 범위를 벗어나지 않도록 하는데 사용됩니다. 한쪽의 범위만을 지정해야 한다면 그 맞는 최소값 혹은 최대값 상수를 사용합니다.
RegularExpression [RegularExpression ("pattern")] 해당 attribute는 문자열값이 지정한 정규식 pattern과 일치해야 하는 경우 사용됩니다. 이때 pattern은 사용자가 제공한 일부분값이 아닌 전체값과 일치해야 합니다. 기본적으로는 대소문자를 구분하지만 (?i)수식어를 [RegularExpression("(?i)pattern")] 처럼 적용함으로서 대소문자를 구분하지 않는 것으로 만들 수 있습니다.
Required [Required] 해당 attribute는 값이 비어있거나 공백만을 포함하는 문자열이 아니어야 할때 사용될 수 있습니다. 만약 공백이라도 이를 유효하게 처리되어야 한다면 [Required(AllowEmptyStrings = true)]처럼 호출되어야 합니다.
StringLength [StringLength(10)] 해당 attribute는 문자열값의 길이가 지정된 최대제한값을 넘지않아야 하는 경우 사용됩니다. 또한 필요하다면 아래와 같이 최소값을 같이 설정할 수도 있습니다.
[StringLength(10, MinimumLength=2)]

위와 같은 validation attribute를 사용함으로서 Controllers folder의 FormController.cs와 같은 이전 예제의 action method에서는 아래와 같이 명시적인 validation을 일부 제거할 수 있게 되었습니다.

//if (ModelState.GetValidationState(nameof(Products.UnitPrice)) == ModelValidationState.Valid && product.UnitPrice <= 0)
//	ModelState.AddModelError(nameof(Products.UnitPrice), "Enter a positive price");

if (ModelState.GetValidationState(nameof(Products.ProductName)) == ModelValidationState.Valid && ModelState.GetValidationState(nameof(Products.UnitPrice)) == ModelValidationState.Valid && product.ProductName.ToLower().StartsWith("small") && product.UnitPrice > 100)
	ModelState.AddModelError(string.Empty, "Small products cannot cost more than 100");

해당 validation attribute의 기능을 확인해 보기 위해 project를 실행하고 /controllers/form URL을 요청합니다. 그리고 ProductName field의 값을 삭제하고 UnitPrice field의 값은 0으로 설정한 뒤 form을 submit 합니다. 이에 대한 응답은 UnitPrice의 attribute에 의해 생성된 validation error와 ProductName field의 새로운 message를 포함하여 다음과 같이 사용자에게 표시될 것입니다.

validation attribute는 action method가 호출되기 이전에 적용되므로 사용자는 각각의 속성이 유효한지의 여부를 확인하기 위해 model 수준의 validation을 수행할때 model 상태에 의존할 수 있습니다.

validation관련 작업 사항
필요한 validation결과를 가져올때는 validation attribute사용 시 주의해야 합니다. 예를 들어 사용자가 checkbox를 check 해야 하는 상황에서는 Required attribute를 사용할 수 없습니다. 왜냐하면 사용자가 checkbox를 check 하지 않으면 Browser는 false값을 보낼 수 있기 때문입니다. 대신 Range attribute사용을 통해 아래와 같이 minimum과 maximum값을 true값으로 설정할 수 있습니다.
[Range(typeof(bool), "true", "true", ErrorMessage="약관에 동의해야 합니다.")]
만약 이러한 해결책이 마음에 들지 않는다면 사용자정의 validation attribute를 직접 제작할 수도 있습니다.
Web service controller validation
ApiController attribute가 적용된 controller는 ModelState.IsValid속성을 확인할 필요가 없습니다. 대신 validation error가 없는 경우에만 action method가 호출됩니다. 즉, model binding기능을 통해 항상 유효성이 검증된 개체를 전달받는 것입니다. 만 어떠한 validation error가 감지되면 요청은 만료되고 error응답이 browser로 전송됩니다.

 

(1) 사용자 정의 속성 validation attribute 생성하기

 

validation process는 ValidationAttribute class를 사용한 attribute를 생성함으로서 기능이 확장될 수 있습니다. Project에 Validation folder를 생성하고 PrimaryKeyAttribute.cs라는 file을 아래와 같이 추가합니다.

namespace MyWebApp.Validation
{
	using Microsoft.EntityFrameworkCore;
	using System.ComponentModel.DataAnnotations;
	namespace WebApp.Validation
	{
		public class PrimaryKeyAttribute : ValidationAttribute
		{
			public Type? ContextType { get; set; }
			public Type? DataType { get; set; }

			protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
			{
				if (ContextType != null && DataType != null)
				{
					DbContext? context = validationContext.GetRequiredService(ContextType) as DbContext;

					if (context != null && context.Find(DataType, value) == null)
						return new ValidationResult(ErrorMessage ?? "Enter an existing key value");
				}

				return ValidationResult.Success;
			}
		}
	}
}

위 예제에서는 확인할 value로 호출되는 IsValid와 validation process의 context를 제공하고 GetService method를 통해 application의 service에 대한 접근을 제공하는 ValidationContext개체를 재정의하고 있습니다.

 

또한 예제를 통한 사용자 정의 attribute는 Entity Framework Core database context class의 Type과 model class의 Type을 전달받도록 하고 있으며 IsValid method안에서 context class의 instance를 가져와 value가 primary key값으로 사용되었는지를 확인하기 위해 database로 질의할 때 해당 instance를 사용하고 있습니다.

 

(2) 사용자 정의 Model validation attribute 생성하기

 

사용자정의 validation attribute 또한 model 수준의 validation에도 사용될 수 있습니다. project의 Validation folder에 PhraseAndPriceAttribute.cs 이름의 file을 아래와 같이 추가합니다.

using MyWebApp.Models;
using System.ComponentModel.DataAnnotations;

namespace MyWebApp.Validation
{
	public class PhraseAndPriceAttribute : ValidationAttribute
	{
		public string? Phrase { get; set; }
		public string? Price { get; set; }
		protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
		{
			if (value != null && Phrase != null && Price != null)	{
				Products? product = value as Products;

				if (product != null && product.ProductName.StartsWith(Phrase, StringComparison.OrdinalIgnoreCase) && product.UnitPrice > decimal.Parse(Price))
					return new ValidationResult(ErrorMessage ?? $"{Phrase} products cannot cost more than ${Price}");
			}

			return ValidationResult.Success;
		}
	}
}

해당 attribute는 Phrase와 Price속성을 가지고 있으며 IsValid method에서 ProductName과 UnitPrice속성을 확인하기 위해 사용됩니다. 속성수준의 사용자 정의 validation attribute는 검증하고자 하는 속성에 직접적으로 적용되며 model수준의 attribute는 Models folder의 Products.cs에서와 같이 model class전체에 적용됩니다.

[PhraseAndPrice(Phrase = "Small", Price = "100")]
public partial class Products

사용자 지정 attribute를 사용하면 Form controller의 action method로 부터 남아있는 명시적 validation문을 아래와 같이 제거할 수 있습니다.

[HttpPost]
public IActionResult SubmitForm(Products product)
{
	if (ModelState.IsValid)
	{
		TempData["productName"] = product.ProductName;
		TempData["unitPrice"] = product.UnitPrice.ToString();
		TempData["categoryId"] = product.CategoryId.ToString();
		TempData["supplierId"] = product.SupplierId.ToString();
		return RedirectToAction(nameof(Results));
	}
	else
	{
		return View("Form");
	}
}

validation attribute는 action method가 호출되기 전에 자동적으로 적용되며 validation결과는 ModelState.IsValid속성을 확인함으로써 간단하게 확인할 수 있습니다.

 

● Razor Page에서 사용자정의 Model Validation Attribute사용하기

 

Razor Page에서 사용자정의 model validation attribute를 사용하려면 일부 추가적인 작업이 필요합니다. validation attribute가 Razor Page에 적용될 때 발생하는 오류는 Model자체가 아닌 Product속성과 관련이 있으며 이는 결국 validation summary tag helper에서 표시되지 않을 수 있다는 것을 의미합니다.

 

이러한 문제를 해결하기 위해 project의 Validation folder에서 ModelStateExtensions.cs이름의 file을 아래와 같이 추가하고 확장 method를 정의합니다.

using Microsoft.AspNetCore.Mvc.ModelBinding;

namespace MyWebApp.Validation
{
	public static class ModelStateExtensions
	{
		public static void PromotePropertyErrors(this ModelStateDictionary modelState, string propertyName)
		{
			foreach (var err in modelState)
			{
				if (err.Key == propertyName && err.Value.ValidationState == ModelValidationState.Invalid)
				{
					foreach (var e in err.Value.Errors)
					{
						modelState.AddModelError(string.Empty, e.ErrorMessage);
					}
				}
			}
		}
	}
}

PromotePropertyErrors 확장 method는 특정한 속성과 관련있는 validation error를 찾고 해당 model수준 error에 추가할 것입니다. 이어서 Pages folder의 FormHandler.cshtml Razor Page에서는 명시적인 validation을 제거하고 새로운 확장 method를 아래와 같이 적용합니다.

if (ModelState.IsValid)
{
	TempData["productName"] = product.ProductName;
	TempData["unitPrice"] = product.UnitPrice.ToString();
	TempData["categoryId"] = product.CategoryId.ToString();
	TempData["supplierId"] = product.SupplierId.ToString();
	return RedirectToAction(nameof(Results));
}
else
{
	ModelState.PromotePropertyErrors(nameof(Products));
	return View("Form");
}

사용자정의 attribute를 통해 validation을 표현하면 controller와 Razor Page 간 중복 code를 제거할 수 있고 Products 개체에 Model binding이 사용되는 곳이면 어디든 일관성 있게 validation을 적용할 수 있습니다. Project를 실행하고 /controllers/form 혹은 /pages/form URL을 요청한 뒤 ProductName field의 값을 삭제하고 form을 submit 합니다. 그러면 attribute에 의해 만들어진 다음과 같은 오류 message를 보게 될 것입니다.

6. Client-Side Validation

 

지금까지 설명된 모든 validation관련 내용은 전부 server-side validation입니다. 즉, 사용자가 data를 server로 submit하면 server는 data를 validation 하고 그 결과(처리의 성공여부 혹은 수정이 필요한 오류사항들 등)를 다시 돌려주는 형태입니다.

 

하지만 web application에서 대게는 data를 submit하는 것 없이 사용자에게 validation feedback이 즉각적으로 이루어지는 경우가 많습니다. 이러한 것을 client-side validation이라고 하며 JavaScript를 통해 구현됩니다. 사용자가 입력한 data는 server로 전송되기 전에 validation처리가 이루어지고 사용자에게 즉각적인 feedback을 제공하여 문제를 해결할 수 있는 기회를 제공하게 됩니다.

 

ASP.NET Core는 unobtrusive client-side validation이라는 것을 지원하는데 unobtrusive라는 말은 validation규칙이 view가 생성하는 HTML요소로 추가되는 속성을 사용하여 표현됨을 의미합니다. 이들 속성은 Microsoft가 배포한 javascript library에 의해 해석되고 다시 실제 validation을 수행하는 jQuery Validation library를 구성하게 됩니다. 이제 내장 validation 지원기능이 어떻게 작동하는지를 알아보고 사용자정의 client-side validation을 제공하기 위해 어떻게 기능을 확장할 수 있는지를 같이 살펴보도록 하겠습니다.

 

우선 가장먼저 다음과 같이 validation을 처리하는 JavaScript package를 설치해야 합니다.

설치해야 하는 package 목록은 jquery, jquery-validate, jquery-validate-unobtrusive입니다. 다만 이미 위의 해당 package가 설치되어 있을 수 있습니다. 만약 그런 경우라면 다시 설치할 필요가 없습니다.

 

해당 package가 설치되었다면 Pages/Shared folder에 있는 _Validation.cshtml file에 아래와 같은 구문을 추가하여 application에서 이미 존재하는 jQuery code와 함께 validation을 도입하여 사용할 준비를 시작합니다.(단, 예제에서 보이는 순서대로 작성되어야 합니다.)

<script type="text/javascript">
	window.addEventListener("DOMContentLoaded", () => {
		document.querySelectorAll("input.input-validation-error").forEach(
			(elem) => { elem.classList.add("is-invalid"); }
		);
	});
</script>

<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>

ASP.NET Core form tag helper는 data-val*  attribute를 input요소에 추가하여 field의 validation조건을 표현하게 됩니다. 예를 들어 ProductName field의 input요소라면 attribute는 다음과 같이 추가될 것입니다.

<input class="form-control input-validation-error is-invalid" type="text" data-val="true" 
data-val-required="Please enter a value" id="ProductName" name="ProductName" value="">

unobtrusive validation JavaScript code는 이들 attribute를 찾아 사용자가 form의 submit을 시도할 때 browser안에서 validation을 수행하게 됩니다. 만약 validation이 걸리게 된다면 form은 submit 되지 않을 것이며 관련 error가 대신 표시될 것입니다. 물론 data 또한 validation issue가 없어질 때까지 application으로 전송되지 않습니다.

 

JavaScript code는 data-val attribute를 가진 요소를 찾고 사용자가 form을 submit할때 server로 HTTP요청을 보내는 것 없이 browser안에서 validation을 수행하게 됩니다. project를 실행하고 /controllers/form URL을 요청한 뒤 F12 key를 눌러 개발자 도구를 열어줍니다. 그리고 ProductName field의 값을 삭제한 뒤 form의 submit을 시도하면 Server로 HTTP요청을 보내지 않고도 validation error message가 표시됨을 확인하실 수 있습니다.

Browser validation과의 충돌 피하기
현재 HTML5를 지원하는 몇몇 Browser의 경우 input요소에 적용된 attribute에 기반하여 간단한 client-side validation을 자체적으로 지원하기도 합니다. 보통 required attribute가 적용된 input요소가 해당되며 사용자가 값을 입력하지 않고 form의 submit을 시도할 때 browser가 validation error를 표시하게 됩니다.

tag helper를 통해 form요소를 생성하는 경우 data attribute가 할당된 요소라면 browser에 의해 무시되므로 browser자체 validation과의 충돌은 없을 것입니다.

그러나 application에서 markup을 완전히 제어할 수 없는 상황이라면 문제가 발생할 수 있으며 때때로 다른 곳에서 생성된 content를 전달할때 더 자주 발생합니다. jQuery validation과 browser validation은 하나의 form에서 모두 동작할 수 있으며 이는 곧 사용자를 혼란스럽게 만들 수 있습니다. 이러한 문제를 피하려면 form요소에 novalidate라는 attribute를 추가하여 browser의 validation을 제거해야 합니다.

client-side validation 유용한 기능중의 하나는 validation규칙을 특정하는 몇몇 속성이 client와 server에서 적용될 수 있다는 것입니다. 즉, JavaScript를 지원하지 않는 browser로부터의 data는 JavaScript를 지원하는 browser의 data와 어떠한 추가적인 노력이 없이도 동일한 validation을 적용할 수 있음을 의미합니다.

 

위 예제의 결과를 보면 언뜻 server-side validation에 의해 생성된 error message처럼 보일 수 있으나 해당 field에 어떠한 값이라도 입력하게 된다면 즉각작으로 error message가 사라지는 것을 볼 수 있습니다. 이 것은 javascript code에 의해 사용자와의 상호작용이 이루어지고 있음을 의미하는 것입니다.

Client-Side Validation 확장
client-side validation기능은 내장 속성수준의 attribute를 지원하고 있습니다. 이 기능은 확장이 가능하지만 JavaScript의 능숙함과 jQuery Validation package를 통한 직접적인 작업이 필요합니다. 상세한 정보는 아래 글에서 확인하실 수 있습니다.

Documentation | jQuery Validation Plugin

 

Documentation | jQuery Validation Plugin

link Validate forms like you've never validated before! "But doesn't jQuery make it easy to write your own validation plugin?" Sure, but there are still a lot of subtleties to take care of: You need a standard library of validation methods (such as emails,

jqueryvalidation.org

굳이 JavaScript code로의 작업을 원하지 않는다면 내장 validation확인을 위한 client-side validation과 사용자 정의 validation을 위한 server-side validation을 사용한 기본 pattern을 따를 수 있습니다.

 

7. 원격 validation

 

Remote validation에서 유효성확인은 client-side Javascript code에 의해 요청되지만 실제 확인은 사용자가 form에 입력한 값의 확인을 위해 application으로 비동기 HTTP 요청을 보냄으로써 유효성 확인을 수행하게 됩니다. 이러한 특징 때문에  client와 server-side validation 간에 다소 모호한면을 가지고 있기도 합니다.

 

remote validation의 가장 흔한 예로는 사용자ID와 같이 고유성을 필요로 하는 값을 사용자가 submit 하게 되면 Client-side validation이 수행되면서 Application에서 해당 값이 사용가능한지를 확인하는 경우입니다. 이러한 처리의 일부로 요청된 사용자 ID의 유효성확인을 위해 Server로 비동기 HTTP요청이 만들어지게 되며 해당 사용자 ID가 이미 존재한다면 validation error가 표시되고 사용자는 비로소 다른 값을 입력하게 됩니다.

 

이러한 처리 방식이 어쩌면 평범한 Server-side validation처럼 보일 수 있지만 해당 접근법을 통해 우리는 또 다른 기능적인 이점을 얻을 수 있습니다. 우선은 일부 속성만을 원격 validation으로 처리할 수 있으며 client-side validation은 client가 입력한 다른 모든 값에 적용할 수 있습니다. 두번째로 요청은 오로지 유효성검증에만 초점을 맞춰 모델 개체 전체에 대한 처리보다는 상대적으로 훨씬 가벼워질 수 있습니다.

 

세 번째로는 remote validation이 background로 수행됨으로써 사용자는 submit button을 click 하고 새로운 view가 render 되고 반환될 때까지 기다릴 필요가 없습니다. 이는 사용자 경험을 더 신속하게 만들 수 있고 특히 browser와 server사이에 트린 network가 구성된 경우에는 더 효휼적으로 작동할 수 있습니다.

 

하지만 remote validation은 절충안으로서 application server로의 요청을 필요로 하며 일반적인 상황에서는 client-side validation만큼 빠르지는 않다는 특징때문에 client-side와 server-side 간 균형을 맞추고 있는 형태라고 볼 수 있습니다.

 

지금 만들어볼 예제를 통해서는 remote validation을 사용하여 사용자가 CategoryId와 SupplierId속성에 이미 존재하는 key값을 입력할 수 있도록 유도할 것입니다. 이를 위한 첫번째 절차로 우선 유효성확인을 위해 수행될 action method가 있는 web service controller를 생성해야 합니다. 따라서 project의 Controllers folder에 ValidationController.cs이름의 file을 아래와 같이 추가합니다.

using Microsoft.AspNetCore.Mvc;
using MyWebApp.Models;

namespace MyWebApp.Controllers
{
	[ApiController]
	[Route("api/[controller]")]
	public class ValidationController : ControllerBase
	{
		private NorthwindContext dataContext;

		public ValidationController(NorthwindContext context)
		{
			dataContext = context;
		}

		[HttpGet("categorykey")]
		public bool CategoryKey(string categoryId)
		{
			int keyVal;

			return int.TryParse(categoryId, out keyVal) && dataContext.Categories.Find(keyVal) != null;
		}

		[HttpGet("supplierkey")]
		public bool SupplierKey(string supplierId)
		{
			int keyVal;

			return int.TryParse(supplierId, out keyVal) && dataContext.Suppliers.Find(keyVal) != null;
		}
	}
}

Validation action method는 유효성을 확인할 field와 일치하는 이름의 매개변수를 정의해야 하며 이를 통해 model binding process는 query string요청으로 부터 값을 추출할 수 있게 됩니다. 또한 action method에서 응답의 형식은 JSON이 되어야 하며 값이 유효한지를 나타내는 true 혹은 false로 판단될 수 있어야 합니다. 예제의 action method는 값을 전달받아 Category 또는 Supplier 개체를 위한 key가 database에서 사용되고 있는지의 여부를 확인하게 됩니다.

예제에서는 model binding을 이용하여 action method의 매개변수를 long값으로 변환할 수 있었지만 그렇게 하지 않고 예제와 같이 처리함으로서 사용자가 int형식으로 변환될 수 없는 값을 입력하는 경우 아예 validation method가 호출되지 않도록 하였습니다. model binder가 값을 변환할 수 없으면 MVC Framework는 action method를 호출하는 것이 불가능하고 validation은 수행될 수 없습니다. 대체로 원격 validation의 가장 좋은 접근법은 action method에 문자열 매개변수를 사용하는 것이며 값을 읽거나 model binding이 명시적으로 다른 형식으로 변환하는 것입니다.

원격 validation을 사용하기 위해 Products class의 CategoryId와 SupplierId 속성에 아래와 같이 Remote attribute를 적용하였습니다.

[Remote("SupplierKey", "Validation", ErrorMessage = "Enter an existing key")]
public int? SupplierId { get; set; }

[Remote("CategoryKey", "Validation", ErrorMessage = "Enter an existing key")]
public int? CategoryId { get; set; }

예제에서 Remote attribute의 인수로는 validation controller와 action method의 이름을 지정하였으며 또한 선택적 ErrorMessage인수를 통해 validation이 실패하는 경우 표시할 error message를 지정하였습니다. project를 실행하고 /controllers/form URL을 요청한 뒤 유효하지 않은 값을 입력하고 form을 submit 합니다. 그러면 다음과 같은 응답을 통해 해당 오류를 확인할 수 있습니다.

그리고 input요소의 값을 key입력을 통해 변경할때 정확한 값을 입력하게 되면 error message는 사라지게 됩니다.

주의
validation action method는 사용자가 처음 action method를 submit 할 때 호출되고 그다음 data를 변경할 때마다 다시 호출하게 됩니다. text입력 요소의 경우 모든 keystroke는 곧 server로의 호출로 이어지게 됩니다. 일부 application에서는 다소 많은 요청이 발생할 수 있으며 따라서 application에서 필요한 server의 대역폭과 가용성을 고려해야 합니다. 또한 비용이 많이 드는 속성에 대해서는 원격 validation을 사용하지 않도록 하는 것이 필요할 수 있습니다.(예제에서는 반복적으로 key값에 대해 database에 질의를 수행하고 있는데 이 것은 application이나 database에 대해 그다지 실용적이지 않을 수 있습니다.)

(1) Razor Page에서 원격 validation 수행하기

 

원격 validation은 Razor Page에서도 작동할 수 있지만 값의 검증을 위해 비동기 HTTP요청에서 사용되는 이름에는 주의해야 합니다. 이전 controller예제에서 browser는 URL을 다음과 같이 요청할 수 있습니다.

/api/validation/categorykey?categoryid=1

하지만 Razor page예제에서 URL은 page model의 사용을 반영하여 다음과 같이 될 수 있습니다.

/api/validation/categorykey?product.categoryid=1

이러한 차이를 해결하기 위해서는 Controllers folder의 ValidationControllers.cs file에서와 같이 model binding기능을 사용해 validation action method에 요청에서 2가지 유형을 수용할 수 있는 매개변수를 추가하는것입니다.

namespace MyWebApp.Controllers
{
	[ApiController]
	[Route("api/[controller]")]
	public class ValidationController : ControllerBase
	{
		private NorthwindContext dataContext;

		public ValidationController(NorthwindContext context)
		{
			dataContext = context;
		}

		[HttpGet("categorykey")]
		public bool CategoryKey(string? categoryId, [FromQuery] KeyTarget target)
		{
			int keyVal;

			return int.TryParse(categoryId ?? target.CategoryId, out keyVal) && dataContext.Categories.Find(keyVal) != null;
		}

		[HttpGet("supplierkey")]
		public bool SupplierKey(string? supplierId, [FromQuery] KeyTarget target)
		{
			int keyVal;

			return int.TryParse(supplierId ?? target.SupplierId, out keyVal) && dataContext.Suppliers.Find(keyVal) != null;
		}
	}

	[Bind(Prefix = "Product")]
	public class KeyTarget
	{
		public string? CategoryId { get; set; }
		public string? SupplierId { get; set; }
	}
}

KeyTarget class는 원격 validation요청의 2가지 유형과 일치되는 속성과 함께 요청의 Product부분으로 bind 되도록 설정되었습니다. KeyTarget 매개변수를 가진 각 action method는 존재하는 매개변수로 전달되는 값이 없는 경우 사용됩니다. 따라서 동일한 action method가 요청에서 2가지 유형을 모두 수용할 수 있게 됩니다. project를 실행하고 /pages/form으로 URL을 요청합니다. 그리고 존재하지 않은 key 값을 입력한 뒤 submit button을 click 하면 다음과 같은 결과를 볼 수 있게 됩니다.

728x90