파트 8: 검색 기능 구현하기

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

이번 단계에서는 장르나 제목으로 영화를 검색할 수 있도록 Index 액션 메서드에 검색 기능을 추가해보겠습니다.

Index 메서드 및 Index 뷰 변경하기

먼저 MoviesController 클래스의 Index 액션 메서드 코드를 다음과 같이 변경합니다:

public ActionResult Index(string searchString)
{
    var movies = from m in db.Movies
                 select m;
 
    if (!String.IsNullOrEmpty(searchString))
    {
        movies = movies.Where(s => s.Title.Contains(searchString));
    }
 
    return View(movies);
}

Index 메서드의 첫 번째 코드 부분은 다음과 같이 영화 정보들을 가져오기 위한 LINQ 쿼리를 생성합니다:

var movies = from m in db.Movies
             select m;

그러나 아직 이 시점까지는 쿼리가 단지 정의만 되었을 뿐, 실제로 데이터베이스를 대상으로 수행된 것은 아니라는 점에 주의하시기 바랍니다.

그런 다음, searchString 매개변수에 검색할 문자열이 담겨 있다면, 다음의 코드를 통해서 검색 문자열 값으로 필터링되도록 movies 쿼리를 변경합니다:

if (!String.IsNullOrEmpty(searchString))
{
    movies = movies.Where(s => s.Title.Contains(searchString));
}

이 예제 코드에 사용된 s => s.TitleTitle.Contains(searchString) 구문은 람다 식(Lambda Expression)입니다. 람다는 메서드 기반의 LINQ 쿼리에서 이 코드에 사용된 Where 메서드 같은 표준 쿼리 연산자 메서드의 매개변수로 사용됩니다. LINQ 쿼리는 해당 쿼리를 정의하는 시점이나, WhereOrderBy 같은 메서드 호출에 의해서 쿼리가 변경되는 시점에는 실행되지 않습니다. 대신 쿼리의 실제 값이 루프문 등에서 구체적으로 접근되거나, ToList 같은 특정 메서드가 호출될 때까지 표현식의 평가가 미뤄지고 쿼리 실행이 지연됩니다. 가령 이번 예제의 경우, 쿼리가 실제로 실행되는 곳은 Index.cshtml 뷰입니다. 지연된 쿼리 실행(Deferred Query Execution)에 관한 보다 자세한 정보는 쿼리 실행(Query Execution) 문서를 참고하시기 바랍니다.

노트: 예제의 람다 식에 사용된 Contains 메서드는 C# 코드가 아닌 데이터베이스에서 실행됩니다. Contains 메서드는 데이터베이스에서 대소문자를 구분하지 않는 SQL LIKE 문으로 매핑됩니다.

계속해서 이번에는 Index 뷰를 변경해서 사용자에게 검색 폼을 제공해보도록 하겠습니다.

그 전에, 먼저 예제 응용 프로그램을 실행하고 /Movies/Index 로 이동합니다. 그리고 주소 표시줄의 URL에 ?searchString=ghost 같은 형태의 쿼리 문자열을 추가해보면 필터링 된 영화 목록이 나타나는 것을 확인할 수 있을 것입니다.

한 단계 더 나가서 Index 메서드의 시그니처를 변경해서 매개변수의 이름을 id로 바꾸면, App_Start\RouteConfig.cs 파일에 다음과 같이 정의된 기본 라우트 설정의 {id} 자리 표시자와 id 매개변수의 이름이 매치되게 됩니다.

{controller}/{action}/{id}

다시 정리해보면 이렇습니다. 우선 본문에서 최초로 변경했던 Index 메서드의 코드는 다음과 같았습니다:

public ActionResult Index(string searchString)
{           
    var movies = from m in db.Movies
                 select m;
 
    if (!String.IsNullOrEmpty(searchString))
    {
        movies = movies.Where(s => s.Title.Contains(searchString));
    }
 
    return View(movies);
}

Index 메서드의 코드를 다시 한 번 변경하면 다음과 비슷한 모습이 됩니다:

public ActionResult Index(string id)
{
    string searchString = id;
    var movies = from m in db.Movies
                 select m;
 
    if (!String.IsNullOrEmpty(searchString))
    {
        movies = movies.Where(s => s.Title.Contains(searchString));
    }
 
    return View(movies);
}

메서드를 이렇게 변경하고 나면 검색할 제목을 쿼리 문자열 값 대신 라우트 데이터(URL 세그먼트)로 전달할 수 있게 됩니다.

그렇지만 그 어떤 개발자도 사용자들이 영화를 검색할 때마다 이렇게 매번 URL을 수정하길 바라지는 않을 것입니다. 따라서 이번에는 사용자들이 영화를 검색할 때 편리하게 사용할 수 있도록 검색용 UI를 추가해보겠습니다. 라우트 기반의 ID 매개변수 전달 방식을 살펴보기 위해서 방금 변경했던 Index 메서드의 시그니처를 다시 원래대로 되돌려서, 본래대로 Index 메서드가 searchString 문자열 매개변수를 받도록 변경합니다:

public ActionResult Index(string searchString)
{
    var movies = from m in db.Movies
                 select m;
 
    if (!String.IsNullOrEmpty(searchString))
    {
        movies = movies.Where(s => s.Title.Contains(searchString));
    }
 
    return View(movies);
}

그런 다음, Views\Movies\Index.cshtml 파일을 열고 다음 코드에 강조된 것처럼 @Html.ActionLink("Create New", "Create") 라인 바로 뒤에 폼 마크업을 추가합니다:

@model IEnumerable<MvcMovie.Models.Movie>

@{ 
    ViewBag.Title = "Index"; 
} 

<h2>Index</h2>
 
<p>
    @Html.ActionLink("Create New", "Create")     
    @using (Html.BeginForm()) {
        <p> Title: @Html.TextBox("SearchString") <br />
        <input type="submit" value="Filter" /></p>
    }
</p>

이 코드에 사용된 Html.BeginForm 헬퍼 메서드는 여는 <form> 태그를 생성하고, 사용자가 Filter 버튼을 클릭해서 폼을 제출하면 현재 페이지로 폼이 재전송되도록 구성합니다.

참고로 Visual Studio 2013은 뷰 파일의 출력과 편집에 관련된 한 가지 멋진 개선된 기능을 제공해줍니다. 즉 뷰 파일을 열어 놓은 상태에서 응용 프로그램을 실행하면 Visual Studio 2013이 적절한 컨트롤러와 액션 메서드를 호출해서 브라우저에 해당 뷰를 바로 출력해줍니다.

역주: 이 기능에 대해서는 개발자들 간에 호불호가 많이 갈리는 편으로 알고 있으며, 저 역시 개인적으로 선호하지 않습니다. 저는 페이지를 수정할 때마다 응용 프로그램을 매번 실행하는 방식 보다는, 독립된 브라우저에 계속해서 응용 프로그램을 실행시켜 놓은 상태에서 코드가 수정될 때마다 새로 고침만 하는 개발 방식을 선호하는 편입니다.

바로 위의 그림에서 볼 수 있는 것처럼 Visual Studio에서 Index 뷰 파일을 열어 놓고, 컨트롤+F5 키나 F5 키를 눌러서 응용 프로그램을 실행한 다음, 영화를 검색해보시기 바랍니다.

Index 메서드에 대한 HttpPost 버전의 오버로드는 필요 없습니다. 이 메서드는 데이터를 필터링하기만 할 뿐이고, 응용 프로그램의 어떠한 상태도 변경하지 않기 때문입니다. 물론 그렇다고 해서 다음과 같은 HttpPost Index 메서드를 추가한다고 해서 크게 문제가 되는 것은 아닙니다. 그러면 액션 호출자(Action Invoker)가 HttpPost Index 메서드를 인식해서 실행하게 되고, 그 결과 아래 그림과 같은 결과가 나타날 것입니다.

[HttpPost]
public string Index(FormCollection fc, string searchString)
{
    return "<h3>From [HttpPost]Index: " + searchString + "</h3>";
}

그러나 비록 Index 메서드의 HttpPost 버전을 추가해서 코드까지 실제로 모두 구현하더라도 한계가 존재할 수 밖에는 없습니다. 가령, 특정 검색 결과를 북마크 해두거나, 친구에게 링크를 보내서 친구가 그 링크를 클릭했을 때, 필터링 된 동일한 영화 목록이 나타나기를 원한다고 상상해보십시오. 여기서 주목해야 할 점은 HTTP POST 요청의 URL이 기본적인 GET 요청의 URL(localhost:xxxxx/Movies/Index)과 동일하기 때문에, URL에 검색에 관한 정보가 전혀 포함되어 있지 않다는 부분입니다. 검색 문자열 정보가 폼 필드 값의 형태로 서버에 전달되기 때문입니다. 결과적으로 북마크나 친구에게 보낼 URL에 결코 검색 정보를 담을 수가 없다는 결론을 얻게 되는 셈입니다.

이 문제를 해결하려면 BeginForm 메서드의 다른 오버로드를 사용해서, URL에 검색 정보를 추가하고 요청을 Index 메서드의 HttpGet 버전으로 라우트되도록 만드는 것입니다. 매개변수가 지정되지 않은 기존의 BeginForm 메서드를 다음과 같은 마크업으로 변경합니다:

@using (Html.BeginForm("Index", "Movies", FormMethod.Get))

이제 다시 검색을 수행해보면 URL에 검색 쿼리 문자열이 포함되는 것을 확인할 수 있습니다. 이제는 HttpPost Index 메서드가 존재하더라도 HttpGet Index 액션 메서드가 실행됩니다.

장르 검색 추가하기

다음 과정을 진행하기 전에, 먼저 이전 절에서 Index 메서드의 HttpPost 버전을 추가했었다면 이를 제거합니다.

이번에는 사용자들이 장르로 영화 정보를 검색할 수 있는 기능을 추가해보겠습니다. Index 메서드를 다음의 코드로 대체합니다:

public ActionResult Index(string movieGenre, string searchString)
{
    var GenreLst = new List<string>();

    var GenreQry = from d in db.Movies
                   orderby d.Genre
                   select d.Genre;

    GenreLst.AddRange(GenreQry.Distinct());
    ViewBag.movieGenre = new SelectList(GenreLst);

    var movies = from m in db.Movies
                 select m;

    if (!String.IsNullOrEmpty(searchString))
    {
        movies = movies.Where(s => s.Title.Contains(searchString));
    }

    if (!string.IsNullOrEmpty(movieGenre))
    {
        movies = movies.Where(x => x.Genre == movieGenre);
    }

    return View(movies);
}

이번 버전의 Index 메서드는 movieGenre라는 이름의 매개변수를 추가로 더 받습니다. 그리고 처음 몇 라인의 코드는 데이터베이스로부터 가져온 영화의 장르들을 저장하는 List 개체를 생성하기 위한 코드입니다. 우선 다음 코드는 데이터베이스에서 모든 장르들을 가져오는 LINQ 쿼리입니다.

var GenreQry = from d in db.Movies
               orderby d.Genre
               select d.Genre;

그런 다음, 제네릭 List 컬랙션의 AddRange 메서드를 이용해서 중복되지 않는 모든 장르들을 목록에 추가합니다. (이 때, Distinct 수정자를 지정하지 않으면 중복된 장르들이 여러 번 추가될 것입니다. 예를 들어서 본문의 예제에서는 "Comedy" 장르가 반복됩니다.) 그리고 이 장르들의 목록을 ViewBag.movieGenre 개체에 저장하는데, MVC 응용 프로그램에서는 영화 장르 같은 카테고리 데이터를 이 예제 코드에서처럼 ViewBagSelectList 개체로 저장해뒀다가, 뷰의 드롭다운 리스트 박스에서 다시 이 카테고리 데이터에 접근하는 것이 일반적인 활용 방법입니다.

다음 코드는 movieGenre 매개변수를 점검하는 방법을 보여주고 있습니다. 이 매개변수가 비어있지 않다면 쿼리에 지정된 장르로 검색된 영화 정보를 필터링하는 제약조건을 다시 한 번 추가합니다.

if (!string.IsNullOrEmpty(movieGenre))
{
    movies = movies.Where(x => x.Genre == movieGenre);
}

이 쿼리는 이미 설명했던 것처럼 Index 액션 메서드가 반환된 이후, 뷰에서 영화들의 목록을 출력하기 위해서 값이 실제로 조회되기 전까지는 데이터베이스를 대상으로 실행되지 않습니다.

Index 뷰에 장르 검색 지원을 위한 마크업 추가하기

계속해서 Views\Movies\Index.cshtml 파일을 연 다음, TextBox 헬퍼 메서드를 호출하기 직전에 다음과 같은 Html.DropDownList 헬퍼 메서드 호출을 추가합니다. 이 작업을 마치고 나면 마크업은 다음과 같을 것입니다:

@model IEnumerable<MvcMovie.Models.Movie>
@{
    ViewBag.Title = "Index";
}
<h2>Index</h2>
<p>
    @Html.ActionLink("Create New", "Create")
    @using (Html.BeginForm("Index", "Movies", FormMethod.Get))
    {
    <p>
        Genre: @Html.DropDownList("movieGenre", "All")
        Title: @Html.TextBox("SearchString")
        <input type="submit" value="Filter" />
    </p>
    }
</p>
<table class="table">

이번에 새로 추가된 코드는 다음과 같습니다:

@Html.DropDownList("movieGenre", "All")

이 코드의 첫 번째 매개변수인 "movieGenre"는 DropDownList 헬퍼 메서드가 ViewBag에서 IEnumerable<SelectListItem> 형식의 값을 조회하기 위해서 검색해야 할 키로, 이 ViewBag 데이터는 액션 메서드의 다음 코드를 통해서 채워진 것입니다:

public ActionResult Index(string movieGenre, string searchString)
{
    var GenreLst = new List<string>();

    var GenreQry = from d in db.Movies
                   orderby d.Genre
                   select d.Genre;

    GenreLst.AddRange(GenreQry.Distinct());
    ViewBag.movieGenre = new SelectList(GenreLst);

    var movies = from m in db.Movies
                 select m;

    if (!String.IsNullOrEmpty(searchString))
    {
        movies = movies.Where(s => s.Title.Contains(searchString));
    }

    if (!string.IsNullOrEmpty(movieGenre))
    {
        movies = movies.Where(x => x.Genre == movieGenre);
    }

    return View(movies);
}

두 번째 매개변수인 "All"은 목록에서 미리 선택되어 있어야 할 항목을 지정합니다. 이 부분의 코드를 다음과 같이 변경할 수도 있습니다:

@Html.DropDownList("movieGenre", "Comedy")

그러면 데이터베이스에 장르가 "Comedy"인 영화들의 정보가 존재하므로, 드롭다운 목록에서 "Comedy" 항목이 미리 선택된 상태로 출력될 것입니다. 반면, 장르가 "All"인 영화는 존재하지 않기 때문에 SelectList 개체에도 "All"에 대한 항목은 존재하지 않으며, 따라서 다른 장르를 선택하지 않고 그대로 재전송을 수행하면 movieGenre 쿼리 문자열 값이 비어 있는 상태가 됩니다.

역주: 두 번째 매개변수에 대한 이 부분의 설명은 완전히 잘못되었으며 원문의 저자 역시 코멘트를 통해서 그 사실을 인정하고 있습니다. 다시 말해서 DropDownList 헬퍼 메서드의 두 번째 인자가 문자열 형식인 경우, 그 값은 자동으로 추가되는 비어 있는 기본 항목에 대한 문구를 나타냅니다 (참고: DropDownList(HtmlHelper, String, String). 즉, 본문의 설명대로 코드를 변경하면 "Comedy"인 항목이 두 번 나타나게 됩니다. 따라서 본문의 원래 의도처럼 "Comedy" 항목이 기본으로 선택되도록 만들려면, Index 액션 메서드에서 ViewBag에 데이터를 설정하는 부분의 코드를 다음과 같이 수정해야 합니다.
ViewBag.movieGenre = new SelectList(GenreLst, "Comedy");

다시 응용 프로그램을 실행하고 /Movies/Index로 이동한 다음, 장르 검색이나 영화 제목 검색, 그리고 두 가지 모두를 이용한 검색을 테스트 해보십시요.

본문에서는 사용자가 제목과 장르를 이용해서 영화를 검색할 수 있도록 Index 액션 메서드와 Index 뷰를 수정해봤습니다. 다음 단계에서는 Movie 모델에 속성을 추가하는 방법과 자동으로 테스트 데이터베이스를 생성해주는 이니셜라이저(Initializer)의 추가 방법을 살펴보도록 하겠습니다.

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