파트 7: Edit 메서드와 Edit 뷰 살펴보기

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

계속해서 본문에서는 MoviesController 클래스에 자동으로 생성된 Edit 액션 메서드와 Edit 뷰를 자세히 살펴보도록 하겠습니다. 다만 그 전에 먼저 코드를 일부 수정해서 개봉 일자 필드가 조금 더 적절하게 출력되도록 만들어 보겠습니다. Models\Movie.cs 파일을 열고 다음에 강조된 코드들을 추가합니다:

using System;
using System.ComponentModel.DataAnnotations;
using System.Data.Entity;

namespace MvcMovie.Models
{
    public class Movie
    {
        public int ID { get; set; }
        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; }
        public string Genre { get; set; }
        public decimal Price { get; set; }
    }

    public class MovieDBContext : DbContext
    {
        public DbSet<Movie> Movies { get; set; }
    }
}

개봉 일자 필드의 문화권(Culture)은 다음과 같이 지정해도 무방합니다:

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

이 코드에 사용된 DataAnnotations 네임스페이스에 관해서는 다음 자습서에서 더 자세하게 살펴보려고 합니다. 일단 간단하게 정리해보자면, 먼저 Display 어트리뷰트를 지정하면 해당 필드의 이름으로 출력될 문구를 명시적으로 설정할 수 있습니다. 결과적으로 이 예제에서는 "ReleaseDate"라는 속성 명 대신 "Release Date"라는 문구가 필드 이름으로 출력됩니다. 그리고 DataType 어트리뷰트를 이용하면 출력될 데이터의 형식을 지정할 수 있는데, 이 예제 코드에서는 date 형식을 지정하고 있으므로 필드에 저장된 시간 관련 정보가 출력되지 않게 됩니다. 마지막으로 DisplayFormat 어트리뷰트는 날짜 형식이 부정확하게 렌더되는 Chrome 브라우저의 버그를 피하기 위한 목적으로 지정된 것입니다.

이제 예제 응용 프로그램을 실행하고 브라우저 주소 표시줄에 /Movies를 추가해서 Movies 컨트롤러로 이동합니다. 그리고 마우스 포인터를 Edit 링크 위에 올려 놓은 상태에서 링크가 가리키는 URL을 확인해보시기 바랍니다.

Edit 링크는 Views\Movies\Index.cshtml 뷰에서 Html.ActionLink 메서드에 의해서 만들어진 링크입니다:

@Html.ActionLink("Edit", "Edit", new { id=item.ID }) 

이 코드에 사용된 Html 개체는 System.Web.Mvc.WebViewPage 기본 클래스의 속성을 통해서 노출되는 헬퍼 개체로, 이 개체가 제공해주는 ActionLink 메서드를 이용하면 특정 컨트롤러의 특정 액션 메서드를 가리키는 HTML 하이퍼링크를 동적으로 손쉽게 생성할 수 있습니다. ActionLink 메서드의 첫 번째 매개변수에는 링크에 렌더될 문구를 지정하면 되는데, 가령 <a>Edit Me</a>라는 링크를 생성하고자 한다면 "Edit Me"를 지정하면 됩니다. 그리고 두 번째 매개변수에는 링크를 클릭했을 때 호출될 액션 메서드의 이름을 지정해야 하므로 여기에서는 당연히 Edit 액션을 지정해야 합니다. 마지막으로 세 번째 매개변수에는 라우트 데이터를 (이 예제에서는 ID 값인 4를) 담고 있는 익명 개체를 지정하면 됩니다. 본문의 가장 첫 번째 그림은 이런 방식으로 생성된, http://localhost:1234/Movies/Edit/4 라는 URL이 지정된 링크를 보여주고 있습니다.

본 자습서의 예제 응용 프로그램의 기본 라우트는 App_Start\RouteConfig.cs 파일에 설정된 {controller}/{action}/{id} 형태의 URL 패턴과 매핑됩니다. 따라서 ASP.NET은 http://localhost:1234/Movies/Edit/4 라는 URL을 ID 매개변수 값으로 4를 전달받는 Movies 컨트롤러의 Edit 액션 메서드에 대한 요청으로 변환해줍니다. Visual Studio에서 App_Start\RouteConfig.cs 파일을 열고 다음 코드 부분을 살펴보시기 바랍니다. 이 코드에서 MapRoute 메서드는 HTTP 요청을 올바른 컨트롤러 및 액션 메서드로 라우트하고 선택적 매개변수인 ID를 액션 메서드에 제공하기 위한 정보들을 설정합니다. 반대로 방금 살펴본 HtmlHelpers 개체의 ActionLink 메서드에 컨트롤러와 액션 메서드, 그리고 라우트 데이터를 지정해서 URL을 생성할 때도, 여기에서 MapRoute 메서드로 설정한 정보들이 사용됩니다.

public static void RegisterRoutes(RouteCollection routes)
{
    routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

    routes.MapRoute(
        name: "Default",
        url: "{controller}/{action}/{id}",
        defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
    );
}

쿼리 문자열을 이용해서도 액션 메서드 매개변수들을 전달할 수 있습니다. 가령 http://localhost:1234/Movies/Edit?ID=3 이라는 URL 요청은 Movies 컨트롤러의 Edit 액션 메서드에 ID 매개변수 값 3을 전달하게 됩니다.

계속해서 이번에는 다시 Movies 컨트롤러를 열어서 살펴보십시오. 그러면 다음과 같은 두 개의 Edit 액션 메서드를 확인할 수 있을 것입니다.

// GET: /Movies/Edit/5
public ActionResult Edit(int? id)
{
    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }
    Movie movie = db.Movies.Find(id);
    if (movie == null)
    {
        return HttpNotFound();
    }
    return View(movie);
}

// POST: /Movies/Edit/5
// 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 Edit([Bind(Include="ID,Title,ReleaseDate,Genre,Price")] Movie movie)
{
    if (ModelState.IsValid)
    {
        db.Entry(movie).State = EntityState.Modified;
        db.SaveChanges();
        return RedirectToAction("Index");
    }
    return View(movie);
}

여기서 두 번째 Edit 액션 메서드에 HttpPost 어트리뷰트가 지정되어 있는 점에 주목하시기 바랍니다. 이 어트리뷰트가 지정된 Edit 메서드의 오버로드는 오직 POST 요청인 경우에만 호출됩니다. 물론 명확한 구분을 위해서 첫 번째 Edit 액션 메서드에도 HttpGet 어트리뷰트를 지정하는 것도 좋겠지만, 기본값이므로 굳이 그럴 필요는 없습니다. (본문에서는 설명의 편의를 위해서 첫 번째 액션 메서드에 암시적으로 HttpGet 어트리뷰트가 지정되었다고 간주하고 HttpGet 메서드라고 부르도록 하겠습니다.) 또한 movie 매개변수에 지정된 Bind 어트리뷰트 역시 해커의 초과 게시(Over-Posting Sata) 공격으로부터 모델을 보호할 수 있는 또 다른 중요한 보안 메커니즘을 제공해줍니다. 기본적으로 Bind 어트리뷰트에는 변경을 허용할 속성들만 포함시켜야 하지만, 본 자습서에서 사용하고 있는 모델은 극히 단순하므로 모델의 모든 데이터를 바인딩하고 있습니다. 초과 게시 공격과 Bind 어트리뷰트에 관한 더 자세한 정보는 Overposting Security Note의 설명을 참고하시기 바랍니다. 마지막으로 ValidateAntiForgeryToken 어트리뷰트는 요청 위조를 막기 위한 용도로 사용되며, 다음의 Edit 뷰 파일에서(Views\Movies\Edit.cshtml) 볼 수 있는 것과 같은 @Html.AntiForgeryToken() 호출과 한 쌍을 이룹니다:

@model MvcMovie.Models.Movie

@{
    ViewBag.Title = "Edit";
}
<h2>Edit</h2>
@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()
    <div class="form-horizontal">
        <h4>Movie</h4>
        <hr />
        @Html.ValidationSummary(true)
        @Html.HiddenFor(model => model.ID)

        <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>

이렇게 @Html.AntiForgeryToken() 메서드를 호출하면 위조-방지 토큰을 값으로 담고 있는 숨겨진 폼 요소가 생성되는데, Movies 컨트롤러의 Edit 메서드에서 이 값의 일치 여부가 검사됩니다. 교차-사이트 요청 위조(Cross-Site Request Forgery, XSRF 또는 CSRF로 알려진 위조 방식)에 관한 더 자세한 정보는 XSRF/CSRF Prevention in MVC 자습서를 참고하시기 바랍니다.

반면 HttpGet Edit 메서드는 선택된 영화의 ID 값을 매개변수로 받아서 Entity Framework의 Find 메서드로 해당 영화의 데이터를 찾은 다음, 검색된 영화 데이터를 Edit 뷰로 반환합니다. 만약 일치하는 영화 데이터가 존재하지 않는다면 HttpNotFound가 반환됩니다. 스캐폴딩 시스템은 Edit 뷰를 생성할 때, Movie 클래스를 분석해서 각 속성들에 대한 <label> 요소와 <input> 요소를 렌더하는 코드를 생성합니다. 다음은 Visual Studio의 스캐폴딩 시스템이 생성한 Edit 뷰를 보여줍니다:

@model MvcMovie.Models.Movie

@{
    ViewBag.Title = "Edit";
}
<h2>Edit</h2>
@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()    
    <div class="form-horizontal">
        <h4>Movie</h4>
        <hr />
        @Html.ValidationSummary(true)
        @Html.HiddenFor(model => model.ID)

        <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">
            @Html.LabelFor(model => model.ReleaseDate, new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                @Html.EditorFor(model => model.ReleaseDate)
                @Html.ValidationMessageFor(model => model.ReleaseDate)
            </div>
        </div>
        @*Genre and Price removed for brevity.*@
        <div class="form-group">
            <div class="col-md-offset-2 col-md-10">
                <input type="submit" value="Save" class="btn btn-default" />
            </div>
        </div>
    </div>
}
<div>
    @Html.ActionLink("Back to List", "Index")
</div>
@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")
}

뷰 템플릿 파일의 가장 상단에 작성되어 있는 @model MvcMovie.Models.Movie 구문에 주목하시기 바랍니다. 이 구문은 뷰가 이 뷰 템플릿의 모델 형식으로 Movie 형식을 기대하고 있음을 나타냅니다.

그리고 스캐폴드로 생성된 이 코드에서는 HTML 마크업을 생성하기 위해 몇 가지 헬퍼 메서드 들이 사용됩니다. 먼저 Html.LabelFor 헬퍼 메서드로 필드들의 이름("Title", "ReleaseDate", "Genre", "Price")을 출력하고 있으며, Html.EditorFor 헬퍼 메서드로 HTML <input> 요소를 렌더합니다. 그리고 Html.ValidationMessageFor 헬퍼 메서드를 이용해서 각 속성들에 대한 모든 유효성 검사 메시지를 출력합니다.

다시 응용 프로그램을 실행하고 /Movies URL로 이동합니다. 그리고 Edit 링크를 클릭한 다음, 이번에는 브라우저에서 해당 페이지의 소스를 살펴보시기 바랍니다. 그러면 다음과 같은 형태의 폼 요소 HTML을 확인할 수 있을 것입니다.

<form action="/movies/Edit/4" method="post">
    <input name="__RequestVerificationToken" type="hidden" value="UxY6bkQyJCXO3Kn5AXg-6TXxOj6yVBi9tghHaQ5Lq_qwKvcojNXEEfcbn-FGh_0vuw4tS_BRk7QQQHlJp8AP4_X4orVNoQnp2cd8kXhykS01" />
    <fieldset class="form-horizontal">
        <legend>Movie</legend>
        
        <input data-val="true" data-val-number="The field ID must be a number." data-val-required="The ID field is required." id="ID" name="ID" type="hidden" value="4" />
        
        <div class="control-group">
            <label class="control-label" for="Title">Title</label>
            <div class="controls">
               <input class="text-box single-line" id="Title" name="Title" type="text" value="GhostBusters" />
               <span class="field-validation-valid help-inline" data-valmsg-for="Title" data-valmsg-replace="true"></span>
            </div>
        </div>
        
        <div class="control-group">
            <label class="control-label" for="ReleaseDate">Release Date</label>
            <div class="controls">
               <input class="text-box single-line" data-val="true" data-val-date="The field Release Date must be a date." data-val-required="The Release Date field is required." id="ReleaseDate" name="ReleaseDate" type="date" value="1/1/1984" />
               <span class="field-validation-valid help-inline" data-valmsg-for="ReleaseDate" data-valmsg-replace="true"></span>
            </div>
        </div>
        
        <div class="control-group">
            <label class="control-label" for="Genre">Genre</label>
            <div class="controls">
               <input class="text-box single-line" id="Genre" name="Genre" type="text" value="Comedy" />
               <span class="field-validation-valid help-inline" data-valmsg-for="Genre" data-valmsg-replace="true"></span>
            </div>
        </div>
        
        <div class="control-group">
            <label class="control-label" for="Price">Price</label>
            <div class="controls">
               <input class="text-box single-line" data-val="true" data-val-number="The field Price must be a number." data-val-required="The Price field is required." id="Price" name="Price" type="text" value="7.99" />
               <span class="field-validation-valid help-inline" data-valmsg-for="Price" data-valmsg-replace="true"></span>
            </div>
        </div>
        
        <div class="form-actions no-color">
            <input type="submit" value="Save" class="btn" />
        </div>
    </fieldset>
</form>

이 소스에 존재하는 모든 <input> 요소들은 action 어트리뷰트가 /Movies/Edit URL로 게시되도록 설정된 HTML <form> 요소 내부에 존재하며, Save 버튼이 클릭되면 폼의 데이터들이 서버로 전송됩니다. 그리고 두 번째 라인은 @Html.AntiForgeryToken() 메서드 호출로 인해서 생성된 숨겨진 XSRF 토큰을 보여주고 있습니다.

POST 요청 처리하기

다음 코드는 Edit 액션 메서드의 HttpPost 버전을 다시 보여줍니다.

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Edit([Bind(Include="ID,Title,ReleaseDate,Genre,Price")] Movie movie)
{
    if (ModelState.IsValid)
    {
        db.Entry(movie).State = EntityState.Modified;
        db.SaveChanges();
        return RedirectToAction("Index");
    }
    return View(movie);
}

이 메서드가 호출되면, 먼저 ValidateAntiForgeryToken 어트리뷰트가 뷰에서 @Html.AntiForgeryToken() 메서드 호출로 생성된 XSRF 토큰의 유효성을 검사합니다.

그런 다음, ASP.NET MVC 모델 바인더가 전송된 폼의 값들을 이용해서 Movie 개체를 생성하고, 이를 movie 매개변수에 전달해줍니다. 그러면 ModelState.IsValid 메서드를 호출해서 제출된 폼 데이터가 Movie 개체의 편집에 적절한 유효한 데이터인지를 점검합니다. 만약 전달된 데이터가 유효하면 db 개체(MovieDBContext 클래스의 인스턴스)의 Movies 컬렉션에 영화 데이터가 저장되고, MovieDBContextSaveChanges 메서드가 호출될 때 데이터베이스에 저장됩니다. 끝으로 데이터 저장이 완료되면 다시 사용자를 MoviesController 클래스의 Index 액션 메서드로 재전송해서 방금 변경한 데이터를 비롯한 영화 목록을 보여줍니다.

필드에 입력된 값이 유효하지 않으면 클라이언트 측 유효성 검사 기능이 이를 감지해서 그 즉시 오류 메시지를 출력합니다. JavaScript를 비활성화시키면 클라이언트 측 유효성 검사 기능은 동작하지 않지만, 서버 측 유효성 검사 기능이 전송된 값들을 검사해서 필요한 경우 오류 메시지들과 함께 폼의 값들을 다시 출력합니다. 다음은 Edit.cshtml 뷰 템플릿에 작성된 Html.ValidationMessageFor 헬퍼 메서드가 적절한 오류 메시지들을 출력하고 있는 모습을 보여주고 있는 그림입니다. 유효성 검사에 대해서는 본 자습서의 뒷부분에서 다시 자세하게 살펴보도록 하겠습니다.

대부분 HttpGet 버전의 메서드들은 본 예제의 HttpGet 버전 메서드와 비슷한 패턴으로 구현됩니다. 이를테면, 모델 개체를 (Index 메서드의 경우에는 모델 개체들의 목록을) 가져온 다음, 뷰에 다시 그 모델을 전달하는 방식입니다. 다만, Create 메서드의 경우에는 빈 모델 개체를 Create 뷰로 전달한다는 점만 다릅니다. 그리고 데이터를 생성하거나, 수정하거나, 삭제하는 등 모든 데이터 변경 작업은 각 메서드의 HttpPost 버전에서 수행됩니다. HTTP GET 메서드에서 데이터를 변경하면 ASP.NET MVC Tip #46 – Don’t use Delete Links because they create Security Holes 블로그 포스트에서 설명하고 있는 것처럼 보안상의 위험 요소가 만들어집니다. 더불어 HttpGet 버전의 메서드에서 데이터를 변경하는 일은, GET 요청은 응용 프로그램의 상태를 변경해서는 안된다고 규정하고 있는 REST 패턴의 아키텍처와 HTTP 모범 사례를 위배하는 행위이기도 합니다. 간단히 말해서 GET 동작은 부작용을 발생시키지 않고 영속화 데이터를 변경하지 않는 안전한 작업만 수행해야 합니다.

Globalize 패키지 설치하기

만약 여러분이 US-English 로케일로 설정된 컴퓨터를 사용 중이라면, 이번 절은 건너뛰고 바로 자습서의 다음 과정으로 이동하시기 바랍니다. 그리고 ASP.NET MVC 5 응용 프로그램의 국제화에 대한 보다 자세한 설명은 Nadeem's ASP.NET MVC 5 Internationalization 포스트와 ASP.NET MVC 3 Internationalization - Part 2 (NerdDinner) 포스트를 참고하시기 바랍니다.

역주 2015년 5월 현재 Globalize 패키지의 최신 버전은 1.0.0으로, 프로젝트 폴더의 구조를 비롯한 많은 부분들이 이하 본문의 설명과 달라졌습니다. 이번 절의 내용을 적용하시려면 반드시 그 전에 Migrating from Globalize 0.x 문서를 살펴보시고 변경된 사항들부터 확인하시기 바랍니다. 가령 수 많은 로케일 파일들이 모두 사라지고 대신 CLDR(Unicode Common Locale Data Repository)로 대체됐으며, 잠시 후 살펴보게 될 예제 코드에 사용된 culture 함수와 parseFloat 함수도 각각 locale 함수와 parseNumber 함수로 대체되었습니다. 본문의 원문은 Globalize 패키지의 버전이 0.0.1이던 시점에 작성되었습니다.
노트 쉼표를 소수점으로 사용하는 비-영어권 로케일에 대한 jQuery 유효성 검사 기능을 정상적으로 지원하기 위해서는 globalize.js 파일과 적합한 cultures/globalize.cultures.js 파일(https://github.com/jquery/globalize)을 참조해야 하고 Globalize.parseFloat 함수를 사용해서 약간의 JavaScript 코드도 작성해야 하므로 참고하시기 바랍니다. 비-영어권 로케일에 대한 jQuery 유효성 검사 기능은 NuGet에서 다운로드 받을 수 있습니다. (영어권 로케일을 사용하고 있다면 Globalize 패키지를 다운로드 받을 필요가 없습니다.)
  1. 도구(Tools) 메뉴에서 NuGet 패키지 관리자(Library Package Manager)를 클릭한 다음, 솔루션용 NuGet 패키지 관리(Manage NuGet Packages for Solution)를 클릭합니다.
  2. 그런 다음, 좌측 패인에서 온라인(Online)을 선택합니다. (아래 그림을 참고하십시오.)
  3. 계속해서 우측 상단의 검색할 단어 입력(Search Installed packages) 입력 상자에 Globalize 를 입력합니다.

    잠시 후 패키지가 검색되면 검색 결과에서 Globalize 패키지의 설치(Install) 버튼을 클릭해서 패키지를 설치합니다. 그러면 프로젝트에 Scripts\jquery.globalize\globalize.js 파일이 추가되고 Scripts\jquery.globalize\cultures\ 폴더에는 다양한 문화권들에 대응하는 JavaScript 파일들이 추가됩니다. 이 패키지가 설치되는 데는 약 5분 가량 소요되므로 참고하시기 바랍니다.

다음 코드는 변경된 Views\Movies\Edit.cshtml 파일을 보여줍니다:

@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")

<script src="~/Scripts/globalize/globalize.js"></script>
<script src="~/Scripts/globalize/cultures/globalize.culture.@(System.Threading.Thread.CurrentThread.CurrentCulture.Name).js"></script>
<script>
    $.validator.methods.number = function (value, element) {
        return this.optional(element) ||
            !isNaN(Globalize.parseFloat(value));
    }
    $(document).ready(function () {
        Globalize.culture('@(System.Threading.Thread.CurrentThread.CurrentCulture.Name)');
    });
</script>
<script>
    jQuery.extend(jQuery.validator.methods, {
        range: function (value, element, param) {
            //Use the Globalization plugin to parse the value
            var val = Globalize.parseFloat(value);
            return this.optional(element) || (
                val >= param[0] && val <= param[1]);
        }
    });
    $.validator.methods.date = function (value, element) {
        return this.optional(element) ||
            Globalize.parseDate(value) ||
            Globalize.parseDate(value, "yyyy-MM-dd");
    }
</script>
}

매번 Edit 뷰를 작성할 때마다 이 코드를 반복해서 작성하지 않으려면, 레이아웃 파일에 이 코드를 작성하면 됩니다. 또한 스크립트의 다운로드를 최적화시키려면 ASP.NET 묶기(Bundling) 및 축소(Minification) 자습서를 참고하시기 바랍니다.

그리고 어쩔 수 없는 이유 때문에 이 문제를 임시로라도 피해가고 싶다면, 강제로 응용 프로그램이 미국-영어 문화권을 사용하도록 설정하거나 브라우저에서 JavaScript를 비활성화시키면 됩니다. 응용 프로그램을 강제로 미국-영어 문화권을 사용하도록 설정하려면 프로젝트 루트의 web.config 파일에 globalization 요소를 추가합니다. 다음 코드는 문화권이 미국-영어로 설정된 globalization 요소를 보여줍니다.

<system.web>
  <globalization culture ="en-US" />
  <!--설명의 편의를 위해 다른 요소들은 제거했습니다.-->
</system.web>

다음 단계에서는 검색 기능을 구현해보도록 하겠습니다.

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