파트 10: 유효성 검사 추가하기

등록일시: 2015-06-15 08:00,  수정일시: 2016-09-02 09:04
조회수: 8,885
이 문서는 ASP.NET MVC 기술을 널리 알리고자 하는 개인적인 취지로 제공되는 번역문서입니다. 이 문서에 대한 모든 저작권은 마이크로소프트에 있으며 요청이 있을 경우 언제라도 게시가 중단될 수 있습니다. 번역 내용에 오역이 존재할 수 있고 주석은 번역자 개인의 의견일 뿐이며 마이크로소프트는 이에 관한 어떠한 보장도 하지 않습니다. 번역이 완료된 이후에도 대상 제품 및 기술이 개선되거나 변경됨에 따라 원문의 내용도 변경되거나 보완되었을 수 있으므로 주의하시기 바랍니다.

이번 단계에서는 Movie 모델에 유효성 검사 로직을 추가해보고, 사용자가 응용 프로그램을 이용해서 영화 정보를 생성하거나 수정하려고 할 때마다 항상 추가한 유효성 검사 규칙이 적용되도록 만들어 보겠습니다.

중복배제(DRY, Don't Repeat Yourself)의 원칙 유지하기

가장 기본적인 ASP.NET MVC의 설계 원칙들 중 한 가지는 "중복배제(DRY, Don't Repeat Yourself)"의 원칙입니다. ASP.NET MVC에서 권장되는 작업 방식은 기능이나 동작을 단 한 번만 지정하면 응용 프로그램의 모든 영역에 그 효과가 반영되는 것입니다. 이런 방식은 작성해야 할 코드의 양을 줄여줄뿐만 아니라 작성된 코드 자체의 오류도 적어지는 편이고 유지보수도 쉽습니다.

이번 과정에서 살펴보게 될 ASP.NET MVC와 Entity Framework Code First가 함께 제공해주는 유효성 검사 기능이야말로 바로 이 중복배제의 원칙이 반영된 멋진 실제 사례입니다. 모델 클래스에서 한 번만 선언적으로 유효성 검사 규칙을 지정하면 응용 프로그램의 모든 곳에 이 규칙을 적용할 수 있습니다.

지금부터 영화 정보 응용 프로그램에 유효성 검사 기능을 적용하기 위한 구체적인 방법을 살펴보도록 하겠습니다.

Movie 모델에 유효성 검사 규칙 추가하기

가장 먼저 해야할 일은 Movie 클래스에 몇 가지 유효성 검사 로직을 추가하는 것입니다. Visual Studio에서 Movie.cs 파일을 열고 코드를 살펴보면 System.ComponentModel.DataAnnotations 네임스페이스가 지정되어 있는 것을 볼 수 있는데, 바로 이 DataAnnotations 네임스페이스에서 모든 클래스와 속성에 선언적으로 지정할 수 있는 내장 유효성 검사 어트리뷰트들의 모음이 제공됩니다. (DataType 같이 단지 서식만 지정할 뿐 어떠한 유효성 검사 기능도 제공해주지 않는 서식과 관련된 어트리뷰트도 이 네임스페이스에 포함되어 있습니다.) 이 네임스페이스는 System.Web 네임스페이스의 하위에 속해 있지 않다는 점에 주의하시기 바랍니다.

기본으로 제공되는 내장 Required, StringLength, RegularExpression, 그리고 Range 유효성 검사 어트리뷰트들을 적용해서 Movie 클래스를 수정해보겠습니다. Movie 클래스의 코드를 다음 코드로 대체합니다:

public class Movie
{
    public int ID { get; set; }

    [StringLength(60, MinimumLength = 3)]
    public string Title { get; set; }

    [Display(Name = "Release Date")]
    [DataType(DataType.Date)]
    [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
    public DateTime ReleaseDate { get; set; }

    [RegularExpression(@"^[A-Z]+[a-zA-Z''-'\s]*$")]
    [Required]
    [StringLength(30)]
    public string Genre { get; set; }

    [Range(1, 100)]
    [DataType(DataType.Currency)]
    public decimal Price { get; set; }

    [RegularExpression(@"^[A-Z]+[a-zA-Z''-'\s]*$")]
    [StringLength(5)]
    public string Rating { get; set; }
}

이 코드에 사용된 StringLength 어트리뷰트는 문자열 속성의 최대 길이를 지정하면서, 동시에 해당 속성에 대한 데이터베이스 제약조건까지 함께 설정하기 때문에 결과적으로 데이터베이스의 스키마가 변경되게 됩니다. 서버 탐색기(Server explorer)에서 Movies 테이블을 마우스 오른쪽 버튼으로 클릭한 다음, 테이블 정의 열기(Open Table Definition)를 선택해서 현재의 스키마를 확인해보십시오:

이 그림을 살펴보면 현재 모든 문자열 필드들이 nvarchar(MAX) 형식으로 설정되어 있는 것을 알 수 있습니다. 따라서 이전 과정에서 살펴본 마이그레이션을 이용해서 스키마를 변경해줘야 합니다. 솔루션을 빌드한 다음, 패키지 관리자 콘솔(Package Manager Console) 창을 열고 다음 두 가지 명령을 입력합니다:

add-migration DataAnnotations
update-database

명령이 정상적으로 수행되고 나면 Visual Studio가 DbMIgration 클래스를 상속 받으면서 이름에 DataAnnotations라는 단어가 포함되는 새로운 클래스 파일을 열어주는데, 이 클래스의 Up 메서드를 살펴보면 스키마의 제약조건들을 갱신하기 위한 코드를 확인할 수 있습니다:

public override void Up()
{
    AlterColumn("dbo.Movies", "Title", c => c.String(maxLength: 60));
    AlterColumn("dbo.Movies", "Genre", c => c.String(nullable: false, maxLength: 30));
    AlterColumn("dbo.Movies", "Rating", c => c.String(maxLength: 5));
}

이 코드에 따르면 더 이상 Genre 필드는 nullable 형식이 아닙니다 (즉, 반드시 값을 입력해야만 합니다). 그리고 Rating 필드의 최대 길이는 5이고, Title 필드의 최대 길이는 60입니다. 반면 3으로 지정된 Title 필드의 최소 길이와 Price 필드의 범위는 스키마에 아무런 변화도 일으키지 않았습니다.

이제 다시 Movies 테이블의 스키마를 살펴보겠습니다:

문자열 필드들이 새로운 길이 제한을 갖게 되었으며 Genre 필드가 더 이상 NULL을 허용하지 않음을 알 수 있습니다.

이처럼 유효성 검사 어트리뷰트들은 해당 어트리뷰트가 적용된 모델 속성에 강제하고자 하는 동작들을 지정합니다. 이를테면 Required 어트리뷰트나 StringLength 어트리뷰트의 MinimumLength 속성을 지정하면 해당 속성은 반드시 값이 지정되야만 유효합니다. 다만 사용자가 이 유효성 검사를 통과하기 위해서 공백을 입력하는 것까지 제한하지는 못합니다. 그리고 RegularExpression 어트리뷰트는 입력 가능한 문자들을 제한하기 위한 용도로 사용됩니다. 가령 이번 예제 코드에서 Genre 필드와 Rating 필드에는 반드시 문자만 입력할 수 있습니다 (공백, 숫자 또는 특수 문자는 허용되지 않습니다). Range 어트리뷰트는 지정된 속성에 입력할 수 있는 값을 특정 범위 내로 제한합니다. StringLength 어트리뷰트는 문자열 속성의 최대 길이를 지정하며, 필요한 경우 선택적으로 최소 길이를 지정할 수도 있습니다. 또한 값 형식들은 (decimal, int, float, DateTime 등) 기본적으로 값이 존재해야만 유효한 것으로 간주되므로 Required 어트리뷰트를 명시적으로 지정하지 않아도 동일한 효과를 갖습니다.

그리고 이런 유효성 검사 어트리뷰트들을 본문의 예제 응용 프로그램에서처럼 Code First와 함께 사용하면 응용 프로그램이 데이터베이스에 변경 사항을 저장하기 전에 자동으로 모델 클래스에 지정된 유효성 검사 규칙들이 적용됩니다. 가령 다음 코드는 Movie 클래스의 몇 가지 필수 속성 값들이 누락되어 있기 때문에, SaveChanges 메서드가 호출될 때 DbEntityValidationException 예외가 던져집니다:

MovieDBContext db = new MovieDBContext();
Movie movie = new Movie();
movie.Title = "Gone with the Wind";
db.Movies.Add(movie);
db.SaveChanges();        // <= 서버 측 유효성 검사 예외가 던져집니다.

이 코드는 다음과 같은 예외를 던집니다:

Validation failed for one or more entities. See 'EntityValidationErrors' property for more details.

이처럼 .NET 프레임워크가 유효성 검사 규칙을 자동으로 적용해주므로 응용 프로그램이 더욱 견고해질 뿐만 아니라, 유효성 검사의 수행을 잊는 등 부주의로 인해서 잘못된 데이터가 데이터베이스에 저장되는 일도 방지됩니다.

ASP.NET MVC의 유효성 검사 UI

다시 응용 프로그램을 실행하고 /Movies URL로 이동해보겠습니다.

그리고 Create New 링크를 클릭한 다음, 새로운 영화 정보를 추가해보시기 바랍니다. 의도적으로 유효하지 않은 임의의 값을 폼에 입력해보면, 클라이언트 측 jQuery 유효성 검사 기능이 오류를 감지하는 즉시 오류 메시지를 출력해 줄 것입니다.

8_validationErrors

노트: 본 자습서의 이전 과정에서 살펴본 바와 같이, 쉼표를 소수점으로 사용하는 비-영어권 로케일에서 jQuery 유효성 검사 기능을 정상적으로 지원하기 위해서는 NuGe에서 Globalize 패키지를 설치해서 적용해야만 합니다.

자동으로 유효하지 않은 값이 입력된 텍스트 박스들의 테두리가 빨간색로 강조되고, 해당 텍스트 박스 옆에 적절한 유효성 검사 오류 메시지가 나타나는 것을 확인할 수 있습니다. 이런 오류들은 클라이언트 측(JavaScript와 jQuery를 통해서)과 서버 측(사용자가 JavaScript를 비활성화시킨 경우 등)에서 모두 발생하게 됩니다.

이 기능의 가장 중요한 장점은 이런 유효성 검사 UI를 활성화시키기 위해서 따로 MoviesController 클래스나 Create.cshtml 뷰의 코드를 단 한줄도 변경할 필요가 없다는 점입니다. 단적으로 본 자습서의 이전 단계들에서 만들었던 컨트롤러와 뷰는 유효성 검사 어트리뷰트를 적용해서 Movie 모델 클래스의 속성들에 지정한 유효성 검사 규칙들을 자동으로 인식합니다. 가령 지금 바로 Edit 액션 메서드의 유효성 검사 기능을 테스트해보면 아무런 추가 작업을 하지 않았음에도 동일한 유효성 검사 기능이 적용되는 것을 확인할 수 있습니다.

폼 데이터는 클라이언트 측 유효성 검사 오류들이 모두 해결될 때까지는 서버로 전송되지 않습니다. 이는 HTTP POST 메서드에 중단점을 설정해보거나, Fiddler 또는 IE의 F12 개발자 도구 등을 이용해서 직접 테스트 해볼 수 있습니다.

Create 뷰와 Create 액션 메서드에서 유효성 검사가 수행되는 방식

아마도 컨트롤러나 뷰의 코드를 전혀 수정하지 않았음에도 불구하고 어떻게 이런 유효성 검사 UI가 생성되는지 궁금할 것입니다. 다음 코드는 MovieController 클래스의 Create 메서드들을 보여주고 있습니다. 본 자습서의 앞 부분에서 작성했던 기존 코드와 다른 부분이 전혀 없음을 확인할 수 있습니다.

// GET: /Movies/Create
public ActionResult Create()
{
    return View();
}

// POST: /Movies/Create
// To protect from overposting attacks, please enable the specific properties you want to bind to, for 
// more details see http://go.microsoft.com/fwlink/?LinkId=317598.
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Create([Bind(Include = "ID,Title,ReleaseDate,Genre,Price,Rating")] Movie movie)
{
    if (ModelState.IsValid)
    {
        db.Movies.Add(movie);
        db.SaveChanges();
        return RedirectToAction("Index");
    }

    return View(movie);
}

이 두 메서드 중, HTTP GET 버전의 첫 번째 Create 액션 메서드는 초기의 Create 폼을 출력해주고, [HttpPost] 어트리뷰트가 적용된 두 번째 Create 액션 메서드는 폼 전송을 처리해줍니다. HttpPost 버전의 두 번째 Create 메서드는 전송된 영화 정보에 유효성 검사 오류가 존재하는지 여부를 검사하기 위해서 ModelState.IsValid를 호출하는데, 바로 이 메서드가 호출될 때 개체에 적용된 모든 유효성 검사 어트리뷰트들이 평가됩니다. 그 결과에 따라 영화 정보 개체에 유효성 검사 오류가 존재하면 Create 메서드가 폼을 다시 출력하게 되고, 오류가 존재하지 않으면 메서드가 새로운 영화 정보를 데이터베이스에 저장하게 됩니다. 그러나 본 자습서의 영화 응용 프로그램에서는 클라이언트 측에서 유효성 검사 오류가 감지되면 애초에 서버로 폼이 전송되지 않으므로, 현재 상태에서는 두 번째 Create 메서드가 호출되는 일은 결코 일어나지 않습니다. 따라서 브라우저에서 JavaScript를 비활성화시키는 경우에만 클라이언트 측 유효성 검사 자체가 비활성화되어, HTTP POST Create 메서드가 ModelState.IsValid 메서드를 호출함으로서 서버 측에서 영화 정보의 유효성 검사 오류 여부를 확인할 수 있게 됩니다.

직접 HttpPost Create 메서드에 중단점을 설정해보면, 유효성 검사 오류가 발생했을 때 클라이언트 측 유효성 검사로 인해서 폼 데이터가 제출되지 않기 때문에 메서드 자체가 호출되지 않는 것을 확인하실 수 있습니다. 이 때 브라우저에서 JavaScript를 비활성화시켜보면 오류가 존재하는 폼이 서버로 제출되고 중단점에서 실행이 중지될 것입니다. 다시 말해서 JavaScript의 지원 없이도 완벽한 유효성 검사를 수행할 수 있는 것입니다. 다음 그림은 Internet Explorer에서 JavaScript를 비활성화시키는 방법을 보여줍니다.

그리고 다음 그림은 FireFox 브라우저에서 JavaScript를 비활성화시키는 방법을 보여주고 있습니다.

마지막으로 다음 그림은 Chrome 브라우저에서 JavaScript를 비활성화시키는 방법을 보여주고 있습니다.

다음 코드는 본 자습서의 앞 부분에서 스캐폴드로 만든 Create.cshtml 뷰 템플릿을 보여주고 있습니다. 이 뷰 템플릿은 폼이 최초에 출력될 때, 그리고 유효성 검사 오류가 발생해서 다시 출력될 때 위의 액션 메서드들에 의해서 사용됩니다.

@model MvcMovie.Models.Movie
@{
    ViewBag.Title = "Create";
}
<h2>Create</h2>
@using (Html.BeginForm()) 
{
    @Html.AntiForgeryToken()    
    <div class="form-horizontal">
        <h4>Movie</h4>
        <hr />
        @Html.ValidationSummary(true)
        <div class="form-group">
            @Html.LabelFor(model => model.Title, new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                @Html.EditorFor(model => model.Title)
                @Html.ValidationMessageFor(model => model.Title)
            </div>
        </div>

        @* 나머지 필드들은 생략되었습니다. *@

        <div class="form-group">
            <div class="col-md-offset-2 col-md-10">
                <input type="submit" value="Create" class="btn btn-default" />
            </div>
        </div>
    </div>
}
<div>
    @Html.ActionLink("Back to List", "Index")
</div>
@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")
}

이 뷰 템플릿에서 Html.EditorFor 헬퍼 메서드를 호출해서 각각의 Movie 클래스 속성들에 대한 <input> 요소를 출력한 다음, 바로 뒤이어서 Html.ValidationMessageFor 헬퍼 메서드를 호출하고 있는 방식을 주의 깊게 살펴보시기 바랍니다. 이 두 헬퍼 메서드는 컨트롤러에서 뷰로 전달된 모델 개체(여기서는 Movie 개체)를 대상으로 동작하며, 모델에 적용되어 있는 유효성 검사 어트리뷰트들을 자동으로 평가한 다음, 적절한 오류 메시지 등을 출력해줍니다.

이 접근방식의 가장 큰 장점은 컨트롤러나 Create 뷰 템플릿 모두, 적용될 실제 유효성 검사 규칙이나 출력될 특정 오류 메시지에 관해서 알고 있어야 할 필요가 전혀 없다는 점입니다. 즉 유효성 검사 규칙과 오류 메시지는 Movie 클래스에만 지정하면 됩니다. 그러면 동일한 유효성 검사 규칙이 자동으로 Edit 뷰 뿐만 아니라 모델을 생성하거나 수정하는 모든 다른 뷰 템플릿들까지 적용됩니다.

나중에 유효성 검사 로직을 변경하고 싶다면, 모델 단 한 곳에서만(본문의 경우 movie 클래스에서만) 유효성 검사 어트리뷰트를 추가하면 됩니다. 응용 프로그램의 다른 부분들에 대한 일관성 유지에 대해서는 전혀 걱정할 필요가 없습니다. 모든 유효성 검사 로직이 한 장소에서 정의되어 모든 곳에서 사용되기 때문입니다. 이런 접근방식을 사용하면 코드를 매우 깔끔하게 유지할 수 있으며 유지보수와 개선이 쉬워집니다. 결과적으로 중복배제의 원칙을 완벽하게 따르게 되는 셈입니다.

DataType 어트리뷰트 사용하기

다시 Movie.cs 파일을 열고 Movie 클래스를 살펴보겠습니다. 이 클래스에 사용된 System.ComponentModel.DataAnnotations 네임스페이스는 이미 언급했던 것처럼 내장 유효성 검사 어트리뷰트들의 모음과 함께 서식 관련 어트리뷰트들을 제공해줍니다. 가령 Movie 클래스에는 이미 다음 코드에서 볼 수 있는 것처럼 ReleaseDate 속성과 Price 속성에 DataType 어트리뷰트 및 DataType 열거형 값이 적용되어 있습니다.

[DataType(DataType.Date)]
public DateTime ReleaseDate { get; set; }
       
[DataType(DataType.Currency)]
public decimal Price { get; set; }

DataType 어트리뷰트는 단지 뷰 엔진에게 데이터의 서식을 지정하기 위한 힌트만 알려줄 뿐입니다. (추가로 URL에 대해 <a> 태그를 생성해주거나 전자메일에 대한 <a href="mailto:EmailAddress.com"> 태그를 생성해주는 등의 어트리뷰트들도 제공해줍니다.) 이 어트리뷰트 대신 대신 RegularExpression 어트리뷰트를 이용해서 데이터 서식의 유효성을 검증할 수도 있습니다. 일반적으로 DataType 어트리뷰트는 데이터베이스 내부 형식보다는 더 구체적인 고수준의 데이터 서식을 지정하는데 사용되는데, 중요한 점은 유효성 검사 어트리뷰트가 아니라는 것입니다. 가령 이번 예제 코드를 살펴본다면 ReleaseDate 속성의 경우 날짜와 시간 전부가 아닌 날짜만 담는 것이 그 목적입니다. DataType 열거형Date, Time, PhoneNumber, Currency, EmailAddress 등 다양한 데이터 형식들을 제공해줍니다. 또한 DataType 어트리뷰트를 이용해서 응용 프로그램이 자동으로 특정 데이터 형식에 최적화된 기능들을 제공해주도록 활성화시킬 수도 있습니다. 예를 들어서 DataType.EmailAddress 열거형이 지정되면 자동으로 mailto: 링크가 생성되고, DataType.Date 열거형이 지정되면 HTML5를 지원하는 브라우저인 경우 날짜 선택기가 제공됩니다. DataType 어트리뷰트가 HTML 5를 지원하는 브라우저가 인식 가능한 HTML 5 data- ("데이터 대시"라고 읽습니다) 어트리뷰트를 만들어내기 때문입니다. 그러나 다시 한 번 강조하지만 DataType 어트리뷰트는 어떠한 유효성 검사 기능도 제공해주지 않습니다.

그리고 DataType.Date 열거형 값이 출력되는 날짜의 서식 그 자체를 지정하는 것은 아니라는 점에 주의하시기 바랍니다. 기본적으로 날짜 필드는 서버의 문화권(CultureInfo)에 기반한 기본 서식에 의해서 출력됩니다. 따라서 명시적으로 날짜의 서식을 지정하려면 다음과 같이 DisplayFormat 어트리뷰트를 사용해야 합니다:

[DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
public DateTime EnrollmentDate { get; set; }

이 코드에 사용된 ApplyFormatInEditMode 속성을 설정하면 편집하기 위해서 텍스트 박스에 값이 출력된 경우에도 지정된 서식이 적용됩니다. (그러나 일부 필드에서는 이 기능이 거추장스러울 수도 있습니다. 가령 통화 값 같은 경우, 편집하기 위한 텍스트 박스에 통화 기호가 함께 나타나기를 바라지는 않을 것입니다.)

이처럼 DisplayFormat 어트리뷰트만 사용할 수도 있지만, 일반적으로는 DataType 어트리뷰트와 함께 사용하는 것이 좋습니다. DataType 어트리뷰트는 데이터가 화면에 렌더되는 방식이 아닌 데이터의 의미(Semantics)를 전달해줄뿐만 아니라, DisplayFormat 어트리뷰트가 제공해주지 않는 다음과 같은 이점들을 제공해줍니다:

  • 브라우저의 HTML5 기능을 활성화시킬 수 있습니다 (달력 컨트롤을 보여주거나 지역에 적합한 통화 기호가 출력되고, 전자메일 링크가 만들어지는 등).
  • 브라우저가 기본적으로 여러분의 로케일(Locale)에 기반한 올바른 서식으로 데이터를 렌더해 줄 것입니다.
  • DataType 어트리뷰트를 사용하면 MVC가 데이터를 렌더할 적합한 필드 템플릿을 선택하도록 할 수 있습니다 (DisplayFormat 어트리뷰트만 적용하면 문자열 템플릿이 사용됩니다). 더 자세한 정보는 Brad Wilson의 ASP.NET MVC 2 Templates 기사를 참고하시기 바랍니다. (비록 MVC 2를 대상으로 작성된 글이지만 ASP.NET MVC의 현재 버전에서도 유효한 정보를 담고 있습니다.)

반대로 데이터 필드에 DataType 어트리뷰트를 적용할 경우, Chrome 브라우저에서 필드가 항상 올바르게 렌더되도록 DisplayFormat 어트리뷰트를 함께 적용하는 것이 좋습니다. 이에 대한 더 자세한 정보는 StackOverflow의 쓰레드를 참고하시기 바랍니다.

노트: DateTime 형식에 Range 어트리뷰트를 적용하는 경우에는 jQuery 유효성 검사 기능이 정상적으로 동작하지 않습니다. 가령 다음 코드는 입력된 날짜가 지정한 범위 내에 존재하더라도 항상 클라이언트 측 유효성 검사 오류가 출력됩니다:
[Range(typeof(DateTime), "1/1/1966", "1/1/2020")]
결과적으로 DateTime 형식에 Range 어트리뷰트를 적용하려면 jQuery 날짜 유효성 검사를 비활성화시켜야 할 필요가 있습니다. 일반적으로 모델에서 날짜를 일일이 점검하는 것은 좋은 방식이 아니므로, DateTime 형식에 대해 Range 어트리뷰트를 사용하는 것은 권장되지 않습니다.

참고로 다음 코드는 한 줄로 결합된 어트리뷰트들을 보여줍니다:

public class Movie
{
   public int ID { get; set; }
   [Required,StringLength(60, MinimumLength = 3)]
   public string Title { get; set; }
   [Display(Name = "Release Date"),DataType(DataType.Date)]
   public DateTime ReleaseDate { get; set; }
   [Required]
   public string Genre { get; set; }
   [Range(1, 100),DataType(DataType.Currency)]
   public decimal Price { get; set; }
   [Required,StringLength(5)]
   public string Rating { get; set; }
}

계속해서 다음 과정에서는 응용 프로그램을 다시 검토해보고 자동으로 생성된 Details 메서드와 Delete 메서드를 조금 더 개선해보도록 하겠습니다.

이 기사는 2013년 10월 17일에 최초 작성되었습니다.