.NET/ASP.NET

[ASP.NET Core] - 7. Shopping mall project 만들기 - 2 (2nd)

클리엘 2024. 4. 15. 12:23
728x90

Shopping mall project 만들기 - 2에서는 'Shopping mall project 만들기 - 1'에 이어서 CompuMall에 Navigation과 Cart기능을 추가하고자 합니다.

1. Navigation 만들기

사용자가 mall에 방문했을 때 Product를 Category별로 나열해 볼 수 있으면 원하는 제품을 찾기에 훨씬 유용할 것입니다. 이를 위해 다음 3가지 단계를 거쳐 해당 기능을 추가하고자 합니다.

  • HomeController의 Index method를 변경하여 repository에서 Product 개체를 filter할 수 있도록 할 것입니다.
  • URL scheme를 더 친화적으로 개선할 것입니다.
  • Site의 한쪽 편에 표시할 Category List영역을 만들고 현재 선택한 Category와 link를 강조하여 표시할 것입니다.

(1) Product list filter 하기

 

 Product list를 filter 하기 위해 이전에 추가한 ProductsListViewModel view model class를 아래와 같이 변경합니다. 최종 목적은 현재 Category를 View로 전달하여 sidebar에 표시하는 것입니다.

public class ProductListViewModel
{
    public IEnumerable<Product> Products { get; set; } = Enumerable.Empty<Product>();

    public PagingData PagingData { get; set; } = new();
    public string? CurrentCategory { get; set; }
}

 

예제에서는 CurrentCategory 속성을 추가한 것이며 이제 HomeController를 변경하여 Index method에서 Product 개체를 Category와 어떤 Category를 선택했는지 나타내기 위해 위에서 추가한 속성을 사용하여 filter 할 수 있도록 해줍니다.

public IActionResult Index(string? category, int page = 1) => View(
    new ProductListViewModel {
        Products = repository.Products
            .Where(p => category == null || p.Category == category)
            .OrderBy(p => p.ProductID)
            .Skip((page - 1) * PageSize)
            .Take(PageSize),
        PagingData = new PagingData {
            CurrentPage = page,
            ItemPerPage = PageSize,
            TotalItems = repository.Products.Count()
        },
        CurrentCategory = category
    });

 

위 예제에서는 3가지 정도가 변경되었는데 그중 하나는 category라는 매개변수가 추가된 것이고 두 번째는 추가한 category를 통해 Where method에서 category를 filter 하는 데 사용한 것입니다. category가 null이 아니라면 Category속성과 일치하는 Product개체만이 선택됩니다. 마지막으로 ProductListViewModel class에 추가한 CurrentCategory속성의 값을 category값으로 설정하는 것입니다. 그런데 이 변경사항으로 인해 본래 전체 Count를 가져오도록 되어 있는 PagingData의 TotalItems값이 부정확해는 상황이 발생하므로 이 부분 역시 변경해야 합니다.

단위 TEST
View model의 변경으로 인해 기존의 단위 test도 영향을 받기 때문에 이에 대한 변경이 필요합니다. 우선은 Index method의 매개변수가 바뀐 것이므로 여기에 null을 전달하여 test가 정상적으로 진행될 수 있도록 해줘야 합니다. 따라서 CheckRepsository의 Act를 아래와 같이 변경하고
ProductListViewModel result = (controller.Index(null) as ViewResult)?.ViewData.Model as ProductListViewModel ?? new();
CheckPaging의 Act 역시 아래와 같이 변경해 줍니다.
ProductListViewModel result = (controller.Index(null, 2) as ViewResult)?.ViewData.Model as ProductListViewModel ?? new();
CheckPagingData의 Act도 동일하게 변경합니다.
ProductListViewModel result = (controller.Index(null, 2) as ViewResult)?.ViewData.Model as ProductListViewModel ?? new();

 

Category filter가 적용된 결과를 확인하기 위해 Project를 실행하고 아래와 같이 URL을 요청합니다.

 

물론 사용자에게 URL로 Category를 filter 하도록 하지는 않을 테지만 일단 ASP.NET Application의 기본 구조가 갖추어지면 약간의 변화만으로 어떻게 큰 효과를 가져올 수 있는지 확인할 수 있습니다.

단위 TEST
Category filter기능이 정확하게 작동함에 따라 지정한 Category의 Product개체가 올바르게 생성되는지를 확인하기 위해 HomeControllerTests에 다음 test method를 추가합니다.
[Fact]
public void CheckCategoryFilter()
{
    // Arrange
    Mock<IStoreRepository> mock = new Mock<IStoreRepository>();
    mock.Setup(m => m.Products).Returns((new Product[] {
        new Product {ProductID = 1, Name = "P1", Category = "Cat1"},
        new Product {ProductID = 2, Name = "P2", Category = "Cat1"},
        new Product {ProductID = 3, Name = "P3", Category = "Cat2"},
        new Product {ProductID = 4, Name = "P4", Category = "Cat2"},
        new Product {ProductID = 5, Name = "P5", Category = "Cat2"}
    }).AsQueryable<Product>());

    // Arrange - create a controller and make the page size 3 items
    HomeController controller = new HomeController(mock.Object);
    controller.PageSize = 3;

    // Action
    Product[] result = ((controller.Index("Cat1", 1) as ViewResult)?.ViewData.Model as ProductListViewModel ?? new()).Products.ToArray();

    // Assert
    Assert.Equal(2, result.Length);
    Assert.True(result[0].Name == "P1" && result[0].Category == "Cat1");
    Assert.True(result[1].Name == "P2" && result[1].Category == "Cat1");
}
위 Test는 특정 Category를 가진 Product 개체를 통해 mock repository를 생성하고 action method를 통해 특정 Category를 요청합니다. 그리고 그 결과를 예상한 Product개체와 비교함으로써 test를 수행합니다.

 

(2) URL scheme 개선

 

이전 예제에서는 Category filter의 작동여부를 확인하기 위해 아래와 같은 URL을 사용했는데

http://localhost:5162/?category=Peripheral

 

읽기 어려운 위의 URL을 개선하여 좀 더 사용자 친화적인 URL scheme로 바꿀 것입니다. 이를 위해 Program.cs에서 routing설정을 아래와 같이 변경합니다.

app.UseStaticFiles();

app.MapControllerRoute("catpage", "{category}/Page{Page:int}", new { Controller = "Home", action = "Index" });
app.MapControllerRoute("page", "Page{Page:int}", new { Controller = "Home", action = "Index", Page = 1 });
app.MapControllerRoute("category", "{category}", new { Controller = "Home", action = "Index", Page = 1 });
app.MapControllerRoute("pagination", "Products/Page{Page}", new { Controller = "Home", action = "Index", Page = 1 });

app.MapDefaultControllerRoute();

 

Routing system에 관해서는 추후에 자세히 설명하겠지만 당장은 아래 표를 통해 위 Routing이 어떻게 반영되는지를 알 수 있습니다.

URL  
/ 모든 Category의 Product를 첫 Page에 표시합니다.
/Page2 모든 Category의 Product를 2 page에 해당하는 Product를 표시합니다.
/Peripheral 해당 Category의 Product를 첫 page에 표시합니다.
/Peripheral/Page2 지정한 Category의 Product중 2 page에 해당하는 Product를 표시합니다.

 

ASP.NET Core routing system은 사용자로부터의 요청을 처리하기도 하지만 또한 URL scheme를 준수하고 web page에 삽입할 수 있는 발신 URL을 생성하기도 합니다. 즉, routing system을 사용하여 요청을 처리하고 발신 URL을 생성함으로써 Application의 모든 URL에 일관성을 부여할 수 있는 것입니다.

 

IUrlHelper interface에서는 URL 생성 기능으로의 접근을 제공하는데 이를 이전에 만든 tag helper에서 이 interface와 interface가 정의하는 action method를 사용했습니다. 지금은 좀 더 복합적인 URL생성이 필요하므로 tag helper class에 외부 속성을 추가하지 않고도 View에서 필요한 정보를 받을 수 있어야 합니다. 다행히도 tag helper는 공통접두사를 가진 속성들을 단일 collection에서 모두 받을 수 있는 기능이 있으므로 다음과 같이 PageTagHelper를 다음과 같이 구현할 수 있습니다.

public string? PageAction { get; set; }

[HtmlAttributeName(DictionaryAttributePrefix = "page-url-")]
public Dictionary<string, object> PageUrlValues { get; set; } = new Dictionary<string, object>();

public override void Process(TagHelperContext context, TagHelperOutput output)
{
    if (ViewContext != null && PageModel != null) {
        IUrlHelper urlHelper = urlHelperFactory.GetUrlHelper(ViewContext);
        TagBuilder result = new TagBuilder("div");

        for (int i = 1; i <= PageModel.TotalPages; i++) {
            TagBuilder tag = new TagBuilder("a");
            PageUrlValues["Page"] = i;
            tag.Attributes["href"] = urlHelper.Action(PageAction, PageUrlValues);
            //tag.Attributes["href"] = urlHelper.Action(PageAction, new { Page = i });
            tag.InnerHtml.Append(i.ToString());

            result.InnerHtml.AppendHtml(tag);
        }
        
        output.Content.AppendHtml(result.InnerHtml);
    }
}

 

예제에서는 tag helper 속성에 HtmlAttributeName attribute를 적용하여 요소의 속성이름에 대한 접두사를 지정하고 있습니다. 이에 따라 해당 접두사로 시작하는 이름에 대한 모든 속성값이 PageUrlValues속성에 할당된 dictionary에 추가되고 이는 다시 IUrlHelper.Action method로 전달되어 tag helper가 만들어 내는 a 요소의 href attribute에 URL을 생성하게 됩니다.

 

아래 예제에서는 div요소에 tag helper에서 처리할 새로운 속성을 추가하고 URL을 생성하는 데 사용될 category를 지정하고 있습니다. View에서는 하나의 속성만을 추가하고 있으나 같은 접두사를 가진 모든 attribute가 dictionary에 추가될 것입니다.

<div page-model="@Model.PagingData" page-action="Index" page-url-category="@Model.CurrentCategory!"></div>

 

page-url-category 표현식에는 null-forgiving연산자를 사용하여 compiler경고 없이 null이 사용될 수 있도록 하였습니다. 현재 상태에서 Page link는 다음과 같은 URL을 생성하고 있는데

http://localhost:5162/Page1

 

사용자가 link를 click 하게 되면 Category filter기능은 동작하지 않게 되고 Application은 모든 Category의 Product를 표시할 것입니다. filter 하고자 하는 Category를 다음과 같이 직접 추가하게 되면

http://localhost:5162/Peripheral/Page1

 

해당 link에서는 지정된 Category가 Index method로 전달됨으로써 filtering을 작동시키게 됩니다. 해당 변경 결과를 확인하기 위해 Project를 실행하고 아래 URL을 요청하면

http://localhost:5162/Peripheral

 

해당 Category의 Project가 표시될 것입니다.

 

(3) Category navigation menu 만들기

 

사용자에게는 직접 URL을 수정할 필요 없이 선택가능한 Category menu를 제공해 줄 필요가 있습니다. 이를 통해 가능한 Category목록을 제공하고 현재 선택한 Category를 별도로 표시하여 사용자편의성을 높이고자 합니다.

 

ASP.NET Core에서는 view component라는 기능을 통해 어디서든 재사용 가능한 control을 만들 수 있습니다. view component는 소량의 재사용 가능한 application logic을 제공하는 C# class로서 이를 통해 Razor partial view를 선택하고 표시할 수 있습니다. view component는 별도의 장을 통해 상세히 알아볼 것입니다.

 

예제에서는 navigation menu를 render 하는 view component를 만들고 이를 shared layout에서 component를 호출하여 application에 통합시킬 것입니다. 이러한 접근법은 필요한 모든 Application loginc을 포함시킬 수 있고 다른 class처럼 단위 test가 가능한 C# class를 만들 수 있습니다.

 

● navigation view component 만들기

 

Project folder에 Components라는 folder를 추가합니다. 통상 해당 이름의 folder는 필요한 모든 View component를 담는 folder가 됩니다. folder를 추가하고 나면 그 안에 NavigationViewComponent.cs이름의 file을 아래와 같이 추가합니다.

using Microsoft.AspNetCore.Mvc;

namespace CompuMallStore.Components;

public class NavigationViewComponent: ViewComponent
{
    public string Invoke()
    {
        return "View Component";
    }
}

 

view component의 invoke method는 Razor view에서 component가 사용될 때 호출되며 그 결과는 web browser로 전달되는 HTML에 포함됩니다. 예제에서 우선은 간단한 문자열을 표시하도록 하였지만 곧 실제 HTML로 바꿀 것입니다.

 

추가하고자 하는 Category list는 모든 Page에서 표시해야 하므로 해당 View component를 특정 View가 아닌 Shared안에 _Layout에서 사용할 것입니다. View안에서 View component는 tag helper를 사용해 적용합니다.

<!DOCTYPE html>
<html>
    <head>
        <meta name="viewport" content="width=device-width" />
        <title>CompuMall</title>
        <style>
            .column {
                float: left;
                width: 20%;
            }

            .row:after {
                content: "";
                display: table;
                clear: both;
            }
        </style>
    </head>
    <body>
        <div class="row">
            <div class="column"><vc:navigation /></div>
            <div class="column">@RenderBody()</div>
        </div>
    </body>
</html>

 

예제에서는 vc:navigation 요소를 사용하여 View component를 추가하였습니다. 이때 요소이름은 class명에서 ViewComponent부분은 무시하며 만약 NavigationMenu와 같이 이름이 구성된 경우 각 단어사이에 navigation-menu와 같이 hyphen구분자가 사용됩니다.

 

Project를 실행하면 다음과 같이 View component에서 Invoke method가 호출된 결과를 아래와 같이 볼 수 있습니다.

 

● Category List 생성

 

이제 다시 navigation view component로 돌아가 일련의 category를 생성할 수 있게 되었습니다. 여기서 tag helper에서 처럼 category에 대한 HTML을 programming적으로 만들 수 있지만 view component는 Razor partial view를 render 할 수 있으므로 view component를 사용해 category의 list를 생성하고 표현력이 풍부한 Razor 구문을 사용해 이들을 표시하기 위한 HTML을 생성할 것입니다. 이를 위한 첫 번째 절차로 아래와 같이 Navigation View Component를 변경합니다.

using CompuMallStore.Models;
using Microsoft.AspNetCore.Mvc;

namespace CompuMallStore.Components;

public class NavigationViewComponent: ViewComponent
{
    private IStoreRepository repository;

    public NavigationViewComponent(IStoreRepository repo)
    {
        repository = repo;
    }

    public IViewComponentResult Invoke()
    {
        return View(repository.Products.Select(p => p.Category).Distinct().OrderBy(p => p));
    }
}

 

위 예제에서 생성자는 IStoreRepository 매개변수를 정의하고 있습니다. 따라서 ASP.NET Core가 NavigationViewComponent의 instance를 생성할 때 해당 매개변수에 값을 제공해야 함을 인식하게 되고 Program.cs의 설정을 확인한 뒤 구현개체로 무엇을 사용해야 할지를 결정하게 됩니다. 이는 이전에 사용했었던 의존성 주입기능으로 view component는 어떤 repository 구현이 사용될지와는 상관없이 data에 접근할 수 있게 됩니다.

 

Invoke method에서는 LINQ를 사용하여 repository안에서 category를 선택하고 정렬하고 있으며 이것을 기본 Razor partial view를 render 하는 View의 인수로 전달하고 있는데 세부사항은 IViewComponentResult 개체를 사용하는 method에서 반환됩니다.

단위 TEST
category list를 생성하는 것에 대한 단위 test는 비교적 단순합니다. 생성할 category list의 목표는 중복되지 않으면서 alphabet순으로 정렬된 list를 생성하는 것인데 이를 위한 가장 간단한 방법은 중복되며 정렬되지 않은 test data를 제공하여 이를 view component class로 전달하고 data가 적절하게 정리되었는지 확인하는 것입니다. 단위 test생성을 위해 Test project의 Controllers folder안에 NavigationViewComponentTests.cs file을 다음과 같이 추가합니다.
using CompuMallStore.Components;
using CompuMallStore.Models;
using Microsoft.AspNetCore.Mvc.ViewComponents;
using Moq;

namespace CompuMallStore.Tests;

public class NavigationViewComponentTests
{
    [Fact]
    public void CheckSelectedCategories()
    {
        // Given
        Mock<IStoreRepository> mock = new Mock<IStoreRepository>();
        mock.Setup(m => m.Products).Returns((new Product[] {
            new Product { ProductID = 1, Name = "P1", Category = "CategoryA" },
            new Product { ProductID = 2, Name = "P2", Category = "CategoryA" },
            new Product { ProductID = 3, Name = "P3", Category = "CategoryC" },
            new Product { ProductID = 4, Name = "P4", Category = "CategoryB" },
        }).AsQueryable<Product>());

        NavigationViewComponent target = new NavigationViewComponent(mock.Object);
    
        // When
        string[] results = ((IEnumerable<string>?)(target.Invoke() as ViewViewComponentResult)?.ViewData?.Model ?? Enumerable.Empty<string>()).ToArray();

        // Then
        Assert.True(Enumerable.SequenceEqual(new string[] {"CategoryA", "CategoryB", "CategoryC"}, results));
    }
}

 

예제에서는 반복적이고 정렬되지 않은 category를 포함하는 mock repository 구현을 생성한 뒤 중복이 제거되었는지, 제대로 정렬이 이루어졌는지를 확인하고 있습니다.

 

● View 만들기

 

Razor는 View component에서 선택한 View를 찾기 위해 다양한 규칙을 사용합니다. View의 기본이름과 View에서 검색되는 위치 둘 다 Controller에서 사용된 것과는 다릅니다. 따라서 Project에 Views > Shared > Components > Navigation folder를 만들고 여기에 Default.cshtml file을 아래와 같이 추가합니다.

@model IEnumerable<string>
 <div>
    <a asp-action="Index" asp-controller="Home" asp-route-category="">
        Home
    </a>
    <br />
    @foreach (string category in Model ?? Enumerable.Empty<string>()) {
        <a asp-action="Index" asp-controller="Home" asp-route-category="@category" asp-route-page="1">
            @category
        </a>
        <br />
    }
 </div>

 

예제에서는 내장된 tag helper를 사용해 href 속성을 가진 a 요소를 생성하고 있으며 여러 Product category를 선택하기 위한 URL을 가지게 됩니다.

 

Project를 실행하면 navigation link요소를 확인할 수 있으며 link를 선택하게 되면 표시되는 item list는 선택한 category의 것만 표시될 것입니다.

 

● 현재 category 강조하기

 

현재 까지는 category를 선택하면 현재 선택된 category가 무엇인지 URL 외에는 알 수 있는 방법이 없습니다. 따라서 visual적으로 명확하게 이를 처리할 방법이 필요합니다. Controller나 View와 같은 ASP.NET Core component는 context개체를 요청함으로써 현재 요청에 관한 정보를 전달받을 수 있습니다. 대부분의 경우 Controller를 만들기 위해 Controller 기반 class를 사용하는 것처럼 Component를 생성할 때 사용하는 기반 class에 의존하여 context개체를 가져올 수 있습니다.

 

ViewComponent 역시 예외가 아니며 일련의 속성을 통해 context 개체로의 접근을 제공합니다. 이들 속성 중 하나는 RouteData가 있으며 이를 통해 routing system에 의해 요청 URL이 어떻게 처리되는지에 관한 정보를 확인할 수 있습니다.

 

아래 예제에서는 실제 RouteData속성을 사용해 요청 data로 접근하여 현재 선택된 category의 값을 확인하고 있습니다. 물론 다른 View model class를 사용해 View로 category정보를 전달할 수 있지만 Project의 다양한 구현을 위해 아래와 같이 view bag기능을 사용할 것입니다. view bag을 사용하면 view model 개체와 함께 view로 전달할 수 있습니다.

public IViewComponentResult Invoke()
{
    ViewBag.SelectedCategory = RouteData?.Values["category"];
    return View(repository.Products.Select(p => p.Category).Distinct().OrderBy(p => p));
}

 

Invoke method안에서는 SelectedCategory라는 동적 속성에 현재 선택한 category를 할당하고 있는데 이때 RouteData속성에서 반환된 context 개체를 통해서 선택한 category값을 가져오고 있습니다. ViewBag은 동적개체로서 원하는 속성을 임의로 정의하여 해당 속성에 필요한 값을 할당할 수 있습니다.

단위 TEST
단위 test에서는 view component가 선택한 category를 추가하는지 여부를 ViewViewComponentResult class의 view bag속성을 읽음으로써 test 할 수 있습니다. 이를 위해 test project에 NavigationViewComponentTests에 다음과 같이 test method를 추가합니다.
[Fact]
public void Indicates_Selected_Category()
{
    // Arrange
    string categoryToSelect = "C2";
    Mock<IStoreRepository> mock = new Mock<IStoreRepository>();
    
    mock.Setup(m => m.Products).Returns((new Product[] {
        new Product {ProductID = 1, Name = "P1", Category = "C1"},
        new Product {ProductID = 2, Name = "P2", Category = "C2"},
    }).AsQueryable<Product>());
    
    NavigationViewComponent target = new NavigationViewComponent(mock.Object);
    target.ViewComponentContext = new ViewComponentContext {
        ViewContext = new ViewContext {
            RouteData = new Microsoft.AspNetCore.Routing.RouteData()
        }
    };
    
    target.RouteData.Values["category"] = categoryToSelect;

    // Action
    string? result = (string?)(target.Invoke() as ViewViewComponentResult)?.ViewData?["SelectedCategory"];

    // Assert
    Assert.Equal(categoryToSelect, result);
}

 

위 예제에서는 ViewComponentContext속성을 통해 routing data를 view component에 제공하고 있는데 이는 view component가 모든 context data를 수신하는 방법입니다. ViewComponentContext속성은 ViewContext속성을 통해 view별 context data로의 접근을 제공하며 여기 안에서 다시 RouteData속성을 통해 routing data로의 접근을 제공합니다. 단위 test에서 대부분의 code는 application이 동작할 때 나타낼 것과 동일한 방법으로 선택할 category를 제공할 context 개체를 생성하는데 구현되며 context data는 ASP.NET Core MVC에서 제공됩니다.

 

위 예제를 통해 이제 어떤 category가 선택되었는지에 대한 정보를 제공할 수 있게 되었습니다. 이제 view component에 의해 선택된 view를 변경하여 현재 category를 분명하게 표시하고자 합니다. 이를 위해 Default.cshtml file을 아래와 같이 변경합니다.

@model IEnumerable<string>
 <div>
    <a asp-action="Index" asp-controller="Home" asp-route-category="">
        Home
    </a>
    <br />
    @foreach (string category in Model ?? Enumerable.Empty<string>()) {
        <a asp-action="Index" asp-controller="Home" asp-route-category="@category" asp-route-page="1">
            @if (category == ViewBag.SelectedCategory)
            {
                <b>@category</b>
            }
            else
            {
                @category
            }
        </a>
        <br />
    }
 </div>

 

예제에서는 선택한 category에 b 요소를 적용하여 진하게 category가 표시되도록 하였습니다. Project를 실행하면 다음과 같이 표시될 것입니다.

 

(4) Page Counter 바로잡기

 

위 결과를 보면 Category link는 정확하게 동작하나 Page 수가 정상적이지 않은 걸 확인할 수 있습니다. 현재 Page 수는 repository의 모든 product 수에 의해 결정된 것일 뿐 선택된 category에 대한 product의 수가 아닙니다. 따라서 현재 상태에서는 사용자가 Peripheral category에서 3 page link를 click 하면 빈 page를 아래와 같이 보게 됩니다. Peripheral category에 속한 모든 product를 표시하기에는 2 page만으로 충분하기 때문입니다.

 

이 상황에서는 Home controller의 Index method를 아래와 같이 변경하여 선택한 category의 product만 가져올 수 있도록 해야 합니다.

public IActionResult Index(string? category, int page = 1) => View(
    new ProductListViewModel {
        Products = repository.Products
            .Where(p => category == null || p.Category == category)
            .OrderBy(p => p.ProductID)
            .Skip((page - 1) * PageSize)
            .Take(PageSize),
        PagingData = new PagingData {
            CurrentPage = page,
            ItemPerPage = PageSize,
            TotalItems = category == null ? repository.Products.Count() : repository.Products.Where(tp => tp.Category == category).Count()
        },
        CurrentCategory = category
    });

 

위 예제에 따라 만약 사용자가 category를 선택하게 된다면 TotalItems에는 category에 속한 Product 수만을 갖게 되며 그렇지 않으면 모든 Product 수를 갖게 될 것입니다. Project를 실행하면 다음과 같이 표시된 Page의 수를 확인합니다.

단위 TEST
이제 다양한 category에 해당하는 Product의 수를 확인할 수 있게 되었으므로 여기에 맞춰 관련 test method를 다음과 같이 추가합니다. 해당 method는 임의의 category별로 product를 생성하고 index method를 각 category별로 순서대로 호출한 뒤 그 결과를 비교합니다.
[Fact]
public void CheckProductCountByCategory()
{
    // Arrange
    Mock<IStoreRepository> mock = new Mock<IStoreRepository>();
    mock.Setup(m => m.Products).Returns((new Product[] {
        new Product {ProductID = 1, Name = "P1", Category = "Cat1"},
        new Product {ProductID = 2, Name = "P2", Category = "Cat1"},
        new Product {ProductID = 3, Name = "P3", Category = "Cat2"},
        new Product {ProductID = 4, Name = "P4", Category = "Cat2"},
        new Product {ProductID = 5, Name = "P5", Category = "Cat2"}
    }).AsQueryable<Product>());

    // Arrange
    HomeController controller = new HomeController(mock.Object);
    controller.PageSize = 3;

    Func<ViewResult?, ProductListViewModel?> GetModel = result => result?.ViewData?.Model as ProductListViewModel;

    // Action
    int? res1 = GetModel(controller.Index("Cat1", 1) as ViewResult)?.PagingData.TotalItems;
    int? res2 = GetModel(controller.Index("Cat2", 1) as ViewResult)?.PagingData.TotalItems;
    int? res3 = GetModel(controller.Index(null, 1) as ViewResult)?.PagingData.TotalItems;

    // Assert
    Assert.Equal(2, res1);
    Assert.Equal(3, res2);
    Assert.Equal(5, res3);
}
2. Cart 만들기

지금까지 예제의 Shopping mall은 잘 작동하고 있지만 정작 제품을 판매하기 위한 Cart가 마련되어 있지 않습니다. 따라서 아래와 같은 일반적인 process를 거치는 Cart를 추가할 것입니다.

<그림>

장바구니 담기 button은 각각의 product마다 붙여서 표시할 것이며 해당 button을 click 하면 지금까지 선택한 모든 product에 대한 요약정보를 합계금액과 함께 표시할 것입니다. 이때 사용자는 '계속 쇼핑하기' button을 통해 본래 Product List화면으로 돌아가거나 '구매하기' button으로 주문완료 page로 계속 진행할 수 있습니다.

 

(1) Razor Page 구성하기

 

지금까지는 MVC Framework를 사용해 Project의 기능을 구현해 왔습니다. 하지만 project의 다양성을 위해 이번에는 ASP.NET Core에서 지원하는 또 다른 application framework로  Razor Page를 사용해 cart기능을 구현해 보고자 합니다. 이를 위해 Program.cs를 아래와 같이 변경하여 Razor Page를 사용하기 위한 준비를 설정합니다.

builder.Services.AddScoped<IStoreRepository, EFStoreRepository>();
builder.Services.AddRazorPages();

...생략

app.MapDefaultControllerRoute();
app.MapRazorPages();

 

AddRazorPages method는 Razor Page사용을 위한 service를 구성하며 MapRazorPages method는 URL routing system이 요청을 처리하기 위한 endpoint로서 Razor Page를 등록합니다.

 

위와 같이 설정한 후 Project에 Pages folder를 생성합니다. 해당 folder는 project에 Razor Page를 위치시키는 일반적인 folder에 해당하며 folder를 생성하고 나면 해당 folder에  _ViewImports .cshtml이름의 Razor View Import file을 다음과 같이 추가합니다. 아래 예제는 Razor Page가 속할 namespace를 설정하며 Razor Page안에서 별도의 namespace를 지정하지 않고도 Project의 class를 사용할 수 있도록 하는 표현식입니다.

@namespace CompuMallStore.Pages
@using Microsoft.AspNetCore.Mvc.RazorPages
@using CompuMallStore.Models
@using CompuMallStore.Classes
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

 

다음으로 _ViewStart.cshtml이름의 Razor View Start file을 Pages에 folder에 다음과 같이 추가합니다. Razor Page는 자신만의 구성을 별도로 가질 수 있는데 아래 설정은 Project가 _CartLayout이름의 file을 기본 layout file로 사용하도록 하는 설정에 해당합니다.

@{
    Layout = "_CartLayout";
}

 

따라서 Razor Page가 사용할 layout을 제공하기 위해 _CartLayout.cshtml이름의 Razor View를 아래와 같이 추가합니다.

<!DOCTYPE html>
<html>
    <head>
        <meta name="viewport" content="width=device-width" />
        <title>CompuMallStore</title>
    </head>
    <body>
        <h3>CompuMall</h3>
        <div>
            @RenderBody()
        </div>
    </body>
</html>

 

(2) Razor Page 만들기

 

Visual Studio를 사용한다면 Razor Page template item을 사용하고 item이름을 Cart.cshtml로 설정합니다. 이렇게 하면 Cart.cshtml와 Cart.cshtml.cs가 같이 만들어지게 됩니다. Visual Studio Code를 사용한다면 Cart.cshtml file만 아래와 같이 추가합니다.

@page

<h4>Cart Page</h4>

 

Project를 실행하고 /cart로 URL을 요청하여 아래와 응답이 생성되는지 확인합니다. Page를 등록할 필요가 없을 뿐 아니라 /cart URL 경로와 Razor page 간 연결이 자동으로 처리되고 있음에 주목합니다.

 

(3) 장바구니 추가 button 만들기

 

Cart기능을 구현하기 전 우선 cart에 product를 추가하는 button을 만들 필요가 있습니다. 이를 위해 Classes folder에 UrlExtensions.cs이름의 file을 아래와 같이 추가합니다.

namespace CompuMallStore.Classes;

public static class UrlExtensions
{
    public static string PathAndQuery(this HttpRequest request)
    {
        return request.QueryString.HasValue ? $"{request.Path}{request.QueryString}" : request.Path.ToString();
    }
}

 

예제의 PathAndQuery 확장 method는 ASP.NET Core가 HTTP요청을 서술하기 위해 사용하는 HttpRequest class하에서 동작하는 것으로 cart가 update 되고 난 후 query string이 존재한다면 그것을 고려하여 browser에 반환될 URL을 생성합니다. 그런 후 확장 method를 포함하는 namespace를 Views folder(Page folder가 아님에 주의)의 view imports file에 추가함으로써 partial view에서 이를 사용할 수 있도록 합니다.

@using CompuMallStore.Models
@using CompuMallStore.Models.ViewModels
@using CompuMallStore.Classes;
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, CompuMallStore

 

이제 각 Product를 나타내는 partial view를 변경하여 '장바구니 추가' button을 표시될 수 있도록 합니다.

@model Product

<div>
    <h3>@Model.Name</h3>
    @Model.Description
    <h4>@Model.Price.ToString("c")</h4>
</div>

<form id="@Model.ProductID" asp-page="/cart" method="post">
    <input type="hidden" asp-for="ProductID" />
    <input type="hidden" name="returnUrl" value="@ViewContext.HttpContext.Request.PathAndQuery()" />
    <button type="submit">Add To Cart</button>
</form>

 

예제에서는 view model로부터 ProductID값을 지정하고 cart가 update 되고 난 후 browser에 반환될 URL을 지정하는 2개의 hidden input요소를 가진 form요소를 정의하고 있습니다. form과 첫 번째 input요소에서는 내장 tag helper가 사용하였는데 이는 model 값과 controller 혹은 Razor Page를 대상으로 하는 form을 생성하는 일반적인 방법입니다. 두 번째 input요소에서 확장 method를 사용하여 반환 URL을 설정하고 있으며 form을 application으로 전송할 button요소도 갖추고 있습니다.

예제에서는 form요소의 method attribute를 post로 설정하고 있음에 주목해 주시기 바랍니다. 이는 browser가 form의 data를 HTTP Post요청을 사용해 전송해야 함을 말하는 것입니다. 사실 이 설정은 GET으로 변경할 수 있으나 HTTP명세는 GET요청에서 상태가 바뀌어서는 안 된다는 것을 명시하고 있습니다. 예제는 Product를 cart에 추가하는 것으로 엄밀히 상태를 변경하고 있으므로 HTTP명세에 맞게 post로 설정하는 것입니다.

 

(4) Session 사용하기

 

Session state는 사용자에 의해서 만들어진 일련의 요청과 관련된 data라고 할 수 있으며 사용자 cart에 대한 정보는 이 data를 통해 저장할 것입니다. ASP.NET은 session 상태를 저장하기 위한 다양한 방법을 제공하고 있으며 예제에서는 비교적 간단하게 처리가 가능한 memory에서의 저장방식을 사용하고자 합니다.(실제 project에서는 application이 중단되면 memory의 모든 data를 잃게 되므로 사용하지 않는 방식입니다.) Seesion을 사용하기 위해서는 일부 service와 component가 필요하므로 Program.cs를 아래와 같이 변경하여야 합니다.

builder.Services.AddScoped<IStoreRepository, EFStoreRepository>();
builder.Services.AddRazorPages();
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession();

var app = builder.Build();
//app.MapGet("/", () => "Hello World!");
app.UseStaticFiles();
app.UseSession();

 

AddDistributedMemoryCache method는 memory에서의 data저장을 설정하며 AddSession method는 session data로의 접근에 사용되는 service를 등록합니다. 또한 UseSession method는 요청이 client로부터 도달하면 session system이 자동적으로 요청을 session과 연결할 수 있도록 합니다.

 

(5) Cart 기능 구현하기

 

이제 Cart기능을 추가하기 위한 모든 준비를 완료하였으므로 Cart를 위한 Cart.cs이름의 file을 Models folder에 아래와 같이 추가합니다.

namespace CompuMallStore.Models;

public class Cart
{
    public List<CartItem> CartItems { get; set; } = new List<CartItem>();
    
    public void AddItem(Product product, int quantity)
    {
            CartItem? item = CartItems.Where(p => p.Product.ProductID == product.ProductID).FirstOrDefault();
            
            if (item == null) {
                CartItems.Add(new CartItem {
                    Product = product,
                    Quantity = quantity
                });
            }
            else
                item.Quantity += quantity;
        }

        public void RemoveItem(Product product)
        {
            CartItems.RemoveAll(l => l.Product.ProductID == product.ProductID);
        }
            
        public decimal ComputeTotalValue()
        {
            return CartItems.Sum(e => e.Product.Price * e.Quantity);
        }

        public void Clear() => CartItems.Clear();
}

public class CartItem
{
    public int CartItemID { get; set; }
    public Product Product { get; set; } = new();
    public int Quantity { get; set; }
}

 

예제에서는 같은 file안에 Cart class와 CartItem class를 같이 정의하고 있으며 Cart에서 CartItem을 사용하여 사용자가 선택한 Product와 구매하고자 하는 수량을 나타내고 있습니다. 또한 cart에 item을 추가하고 제거하는 method와 cart에서의 item에 대한 최종 가격을 계산하고 cart를 초기화하는 method 역시 같이 정의하고 있습니다.

단위 TEST
Cart class자체는 간단하지만 적절히 작동되어야 할 많은 중요한 기능을 가지고 있습니다. Cart가 작동하는데 문제가 생긴다면 전체 application의 신뢰성을 약화시킬 수도 있습니다. 이번 단위 test에서는 이들을 test 하기 위해 CartTests.cs라는 별도의 file을 통해 test를 작성할 것입니다.
첫 번째로는 cart에 product를 추가하는 동작입니다. 처음 product가 cart에 추가되는 경우 새로운 CartItem에 추가되어야 합니다.
using CompuMallStore.Models;

namespace CompuMallStore.Controllers.Tests;

[Fact]
public void CheckAddItem()
{
    // Arrange
    Product p1 = new Product { ProductID = 1, Name = "P1" };
    Product p2 = new Product { ProductID = 2, Name = "P2" };
    // Arrange
    Cart target = new Cart();

    // Act
    target.AddItem(p1, 1);
    target.AddItem(p2, 1);
    CartItem[] results = target.CartItems.ToArray();

    // Assert
    Assert.Equal(2, results.Length);
    Assert.Equal(p1, results[0].Product);
    Assert.Equal(p2, results[1].Product);
}
하지만 Product가 이미 cart에 추가된 경우 새로운 intance를 생성하지 않고 기존의 CartItem에 해당하는 quantity값이 증가되어야 합니다.
[Fact]
public void CheckExistsAddItem()
{
    // Arrange
    Product p1 = new Product { ProductID = 1, Name = "P1" };
    Product p2 = new Product { ProductID = 2, Name = "P2" };
    // Arrange
    Cart target = new Cart();

    // Act
    target.AddItem(p1, 1);
    target.AddItem(p2, 1);
    target.AddItem(p1, 2);
    CartItem[] results = (target.CartItems ?? new()).OrderBy(c => c.Product.ProductID).ToArray();
    
    // Assert
    Assert.Equal(2, results.Length);
    Assert.Equal(3, results[0].Quantity);
    Assert.Equal(1, results[1].Quantity);
}
또한 사용자가 변심하는 경우 특정 Product를 cart에서 제거할 수 있어야 합니다.
[Fact]
public void CheckRemoveItem()
{
    // Arrange
    Product p1 = new Product { ProductID = 1, Name = "P1" };
    Product p2 = new Product { ProductID = 2, Name = "P2" };
    // Arrange
    Cart target = new Cart();
    // Arrange
    target.AddItem(p1, 1);
    target.AddItem(p2, 1);

    // Act
    target.RemoveItem(p2);

    // Assert
    Assert.Empty(target.CartItems.Where(c => c.Product == p2));
    Assert.Single(target.CartItems);
}
다음으로 cart에 있는 모든 item에 대한 단가의 합계를 구해야 합니다.
[Fact]
public void CheckSumCost()
{
    // Arrange - create some test products
    Product p1 = new Product { ProductID = 1, Name = "P1", Price = 100 };
    Product p2 = new Product { ProductID = 2, Name = "P2", Price = 200 };
    // Arrange - create a new cart
    Cart target = new Cart();

    // Act
    target.AddItem(p1, 1);
    target.AddItem(p2, 1);

    decimal result = target.ComputeTotalValue();

    // Assert
    Assert.Equal(300, result);
}
마지막으로 Cart의 초기화에 대한 test입니다.
[Fact]
public void CheckClearCart()
{
    // Arrange - create some test products
    Product p1 = new Product { ProductID = 1, Name = "P1", Price = 100 };
    Product p2 = new Product { ProductID = 2, Name = "P2", Price = 200 };
    // Arrange - create a new cart
    Cart target = new Cart();
    // Arrange - add some items
    target.AddItem(p1, 1);
    target.AddItem(p2, 1);

    // Act - reset the cart
    target.Clear();

    // Assert
    Assert.Empty(target.CartItems);
}
때로는 test를 구현하기 위해 필요한 code가 해당 기능을 구현하는 code보다 더 길고 복잡해질 수 있습니다. 그렇다고 해서 단위 test작성을 중단하는 건 좋은 생각이 아닙니다. 아주 작은 결함이라도 큰 영향을 끼칠 수 있기 때문이며 예제에서의 cart와 같이 중요한 규칙이 적용되는 경우 더욱 그렇습니다.

 

● Session state 확장 method 정의하기

 

ASP.NET Core의 session state기능은 단지 int와 string, byte[] 값만 저장할 수 있습니다. 하지만 예제에서는 Cart 개체를 저장해야 하므로 session state data로의 접근을 제공하는 ISession interface의 확장 method를 정의하여 Cart개체를 json으로 또는 그 반대로 변환할 것입니다. 이를 위해 SessionExtensions.cs file을 Classes folder에 아래와 같이 저장합니다.

using System.Text.Json;
using Microsoft.AspNetCore.Http;

namespace CompuMallStore.Classes;

public static class SessionExtensions
{
    public static void SetJson(this ISession session, string key, object value)
    {
        session.SetString(key, JsonSerializer.Serialize(value));
    }

    public static T? GetJson<T>(this ISession session, string key)
    {
        var sessionData = session.GetString(key);
        
        return sessionData == null ? default(T) : JsonSerializer.Deserialize<T>(sessionData);
    }
}


위에서 정의한 method를 사용하면 개체를 JSON 형식으로 직렬화하여 이를 쉽게 저장하고 다시 본래 개체로 복원할 수 있습니다.

 

● Razor Page 완성

 

Cart Razor Page는 사용자가 장바구니 추가(Add To Cart) button을 click 하게 되면 browser가 보내는 HTTP요청을 수신하게 되고 여기서 form data를 통해 database로부터 product개체를 가져온 뒤 사용자의 cart를 update 할 것입니다. 이때 cart는 session data로 저장됩니다. 이런 기능을 구현하기 위해 우선 Cart.cshtml.cs를 아래와 같이 변경합니다. (Visual Studio라면 해당 file을 자동으로 생성하지만 그렇지 않은 경우 동일한 file을 수동으로 추가해 줍니다.)

using CompuMallStore.Models;
using Microsoft.AspNetCore.Mvc.RazorPages;
using CompuMallStore.Classes;
using Microsoft.AspNetCore.Mvc;

namespace CompuMallStore.Pages;

public class CartModel : PageModel
{
    private IStoreRepository repository;

    public CartModel(IStoreRepository repo)
    {
        repository = repo;
    }

    public Cart? Cart { get; set; }

    public string ReturnUrl { get; set; } = "/";

    public void OnGet(string returnUrl)
    {
        ReturnUrl = returnUrl ?? "/";
            
        Cart = HttpContext.Session.GetJson<Cart>("cart") ?? new Cart();
    }

    public IActionResult OnPost(long productId, string returnUrl)
    {
        Product? product = repository.Products.FirstOrDefault(p => p.ProductID == productId);
        
        if (product != null) {
            Cart = HttpContext.Session.GetJson<Cart>("cart") ?? new Cart();
            Cart.AddItem(product, 1);
            HttpContext.Session.SetJson("cart", Cart);
        }

        return RedirectToPage(new { returnUrl = returnUrl });
    }
}

 

위 예제는 Razor Page와 관련된 class로 page model class라고도 하며 여러 유형의 HTTP요청에 호출되는 처리 method를 정의하여 view를 render 하기 전 상태를 update 할 수 있도록 합니다. 예제에서는 page model class의 이름이 CartModel이며 HTTP Post요청에 대응하는 OnPost 처리 method를 정의하고 있습니다. 해당 method는 database에서 Product를 확인하고 session data에서 사용자의 cart를 확인하며 Product를 사용해 content를 update 합니다. 그런 뒤 변경된 Cart를 저장하고 뒤이어 browser가 동일한 Razor Page로 redirect를 수행하면서 GET요청을 수행하게 됩니다.(이 동작은 POST요청을 반복하지 않게 함으로써 reloading이 되는 것을 방지합니다.)

 

GET요청은 OnGet 처리 method에 의해 처리되어 ReturnUrl과 Cart속성에 값을 설정되고 나면 이후 page의 Razor content section을 render 해야 하므로 아래와 같이 Cart.cshtml file를 변경합니다.

 

@page
@model CartModel

<h4>Cart Page</h4>
<table>
    <thead>
        <tr>
            <th>Quantity</th>
            <th>Item</th>
            <th>Price</th>
            <th>Subtotal</th>
        </tr>
    </thead>
    <tbody>
    @foreach (var line in Model.Cart?.CartItems ?? Enumerable.Empty<CartItem>()) {
        <tr>
            <td>@line.Quantity</td>
            <td>@line.Product.Name</td>
            <td>
                @line.Product.Price.ToString("c")
            </td>
            <td>
                @((line.Quantity * line.Product.Price).ToString("c"))
            </td>
        </tr>
    }
    </tbody>
    <tfoot>
        <tr>
            <td colspan="3">Total:</td>
            <td>
                @Model.Cart?.ComputeTotalValue().ToString("c")
            </td>
        </tr>
    </tfoot>
</table>

 <div>
    <a href="@Model.ReturnUrl">Continue shopping</a>
 </div>

 

Razor Page안에서는 content, Razor 표현식 그리고 code를 단일 file안에서 같이 사용할 수 있으나 Razor page에 대한 단위 test를 사용할 것이므로 예제에서는 cs와 cshtml file을 분리하고 있습니다.

 

예제에서의 HTML content에 포함된 표현식은 view model 개체로서 CartModel을 사용하고 있으며 ReturnUrl과 Cart속성에 할당된 값이 표현식 안에서 어떻게 사용될 수 있는지를 보여주고 있습니다. Razor Page에 의해 생성된 content는 사용자의 Cart에 추가된 Product에 대한 상세를 나타내고 있으며 cart에 product를 추가한 지점으로 되돌아갈 수 있는 link 역시 제공하고 있습니다.

 

처리 method는 HTML form의 input요소(ProductSummary.cshtml)와 일치하는 이름의 매개변수를 사용하고 있으며 이를 통해 개발자가 form을 직접적으로 사용하지 않고도 ASP.NET Core는 들어오는 form POST 변수를 매개변수에 자동적으로 연결하게 됨으로써 매개변수의 값을 그대로 사용할 수 있습니다. 이러한 동작을 model binding이라고 합니다.

Razor Page는 처음에는 약간 이상하다고 느껴질 수도 있습니다. 특히 ASP.NET Core에서 제공되는 MVC Framework를 경험해 본 경우라면 더욱 그렇습니다. 하지만 Razor Page는 MVC Framework를 상호보완하는 역할을 하는 것으로 실제 MVC Framework만의 복잡함을 필요로 하지 않으면서 독립적인 기능을 수행하기에 적합하기 때문에 Controller 및 View와 함께 사용되는 경우가 많습니다. Razor page에 관해서는 추후에 자세히 알아볼 것입니다.

 

Project를 실행하면 아래와 같이 Cart에 item을 추가하기 위한 button을 가진 Product가 표시되며

 

여기서 특정 item에 대한 'Add to Cart' link을 click 하게 되면 해당 item이 cart에 추가되고 Cart의 요약화면이 표시될 것입니다.

 

여기서 다시 'Continue Shopping' link를 click 하면 Cart를 추가할 때의 화면으로 되돌아간다는 것을 확인할 수 있습니다.

단위 TEST
Razor Page Test에는 page model class에 필요한 context 개체를 생성하기 위한 많은 양의 mocking을 필요로 합니다. CartModel class에서 정의된 OnGet method의 동작을 test 하기 위해 Test Project에 Pages라는 folder를 생성한 뒤 여기에  CartTest.cs이름의 class file을 아래와 같이 추가합니다.
using CompuMallStore.Models;
using Moq;
using Microsoft.AspNetCore.Http;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;

namespace CompuMallStore.Pages;

public class CartTest
{
    [Fact]
    public void CheckCart()
    {
        // Arrange
        Product p1 = new Product { ProductID = 1, Name = "P1" };
        Product p2 = new Product { ProductID = 2, Name = "P2" };

        Mock<IStoreRepository> mockRepo = new Mock<IStoreRepository>();
        mockRepo.Setup(m => m.Products).Returns((new Product[] { p1, p2 }).AsQueryable<Product>());

        Cart testCart = new Cart();
        testCart.AddItem(p1, 2);
        testCart.AddItem(p2, 1);

        Mock<ISession> mockSession = new Mock<ISession>();
        byte[] data = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(testCart));
        mockSession.Setup(c => c.TryGetValue(It.IsAny<string>(), out data!));
        Mock<HttpContext> mockContext = new Mock<HttpContext>();
        mockContext.SetupGet(c => c.Session).Returns(mockSession.Object);

        // Action
        CartModel cartModel = new CartModel(mockRepo.Object) {
            PageContext = new PageContext(new ActionContext {
                HttpContext = mockContext.Object,
                    RouteData = new RouteData(),
                    ActionDescriptor = new PageActionDescriptor()
                })
        };
        
        cartModel.OnGet("testUrl");

        //Assert
        Assert.Equal(2, cartModel.Cart?.CartItems.Count());
        Assert.Equal("testUrl", cartModel.ReturnUrl);
    }
}
예제에서의 test는 ISession interface를 mocking 하고 있는데 이는 page model class가 JSON으로 표현된 Cart개체를 가져오는 데 사용되는 확장 method를 사용하기 위한 것입니다. ISession interface는 byte array만 저장하고 가져올 수 있으며 확장 method를 통해 문자열의 역직렬화를 수행합니다.
page model class에서의 OnPost method test는 ISession interface로 전달된 byte array를 포착하고 이것을 역직렬화하여 예상된 content를 포함하고 있는지의 여부를 확인하는 것입니다.
728x90