아래 글에서는
[.NET/ASP.NET Core] - [ASP.NET Core] Shopping mall project - Category탐색과 장바구니 구현
간단히 Cart기능을 구현해 보았는데 계속 이어서 고객이 주문을 완료하기 위한 처리를 추가해 보고자 합니다.
1. Service를 통한 Cart Model의 보완
이전 글에서 Cart Model을 만들어 사용자가 Cart에 Product 정보의 객체를 추가시키는 방법으로 이것이 어떻게 session 기능을 통하여 저장될 수 있는지, Cart의 전반적인 동작을 Razor Page로 구현하여 session data로서 Cart객체를 어떻게 처리할 수 있는지 등을 알아보았습니다.
현재 문제는 만약 Cart가 필요한 다른 Razor Page나 Controller가 생기면 Cart 객체를 가져오고 저장하기 위한 code를 똑같이 추가해야 한다는 것입니다. 따라서 ASP.NET Core의 핵심기능인 Service기능을 통해 Cart가 관리되는 방법을 단순화시킬 것입니다.
Service는 일반적으로 interface를 통해 실체적 기능이 어떻게 구현되어 있는지에 대한 상세함을 감추면서 Application을 형성하거나 이미 형성된 구조를 바꾸는데 필요한 수고 등을 덜어주는 등 기타 다른 많은 문제를 해결하기 위해 사용됩니다. 따라서 지금처럼 Cart class를 구조화하는데도 사용하는 것입니다.
(1) Storage-Aware Cart Class 만들기
일단 기존의 구현된 Cart class를 정리하기 위해 session state를 통해 어떻게 자기자신을 저장할지를 알고 있는 하위 class를 생성할 것입니다. 이를 위해 우선 Cart.cs를 수정하여 아래와 같이 virtual keyword를 사용하여 각 member가 override 될 수 있도록 구현합니다.
public class Cart
{
public List<CartItems> Items { get; set; } = new List<CartItems>();
public virtual void AddItem(Product product, int quantity)
{
CartItems? line = Items.Where(p => p.Product.Id == product.Id).FirstOrDefault();
if (line == null)
{
Items.Add(new CartItems
{
Product = product,
Quantity = quantity
});
}
else
line.Quantity += quantity;
}
public virtual void RemoveItem(Product product) => Items.RemoveAll(i => i.Product.Id == product.Id);
public decimal ComputeTotalValue() => Items.Sum(i => i.Product.Price * i.Quantity);
public virtual void Clear() => Items.Clear();
}
그다음 SessionCart.cs이름의 file을 Model folder에 아래와 같은 내용으로 추가합니다.
using MyWebApp.Classes;
using System.Text.Json.Serialization;
namespace MyWebApp.Models
{
public class SessionCart : Cart
{
public static Cart GetCart(IServiceProvider services)
{
ISession? session = services.GetRequiredService<IHttpContextAccessor>().HttpContext?.Session;
SessionCart cart = session?.GetJson<SessionCart>("Cart") ?? new SessionCart();
cart.Session = session;
return cart;
}
[JsonIgnore]
public ISession? Session { get; set; }
public override void AddItem(Product product, int quantity)
{
base.AddItem(product, quantity);
Session?.SetJson("Cart", this);
}
public override void RemoveItem(Product product)
{
base.RemoveItem(product);
Session?.SetJson("Cart", this);
}
public override void Clear()
{
base.Clear();
Session?.Remove("Cart");
}
}
}
SessionCart는 Cart class의 상속 class이며 AddItem(), RemoveItem(), Clear() Method를 override 하고 있습니다. 그러면서 각각의 Method안에서는 base(Cart class)에서 구현된 Method를 호출하고 있고 ISession interface의 확장 method를 통해 session의 state를 저장하고 있습니다. 여기서 static GetCart()은 SessionCart객체를 생성하고 ISession객체로의 접근을 제공함으로써 자체적인 저장이 가능하도록 되어 있습니다.
이때 ISession object객체는 HttpContext 객체로의 접근을 제공해 주는 IHttpContextAccessor service의 instance를 가져온 다음 ISession의 접근을 시도하고 있습니다. 이것은 본래 session이 일반적인 service로서 제공되지 않기 때문에 session으로의 접근시 필요할 때 사용되는 간접적인 접근방법입니다.
(2) service 등록
다음으로 Cart class의 service를 생성합니다. 이는 자기 자신을 저장하는 SessionCart객체를 통해 Cart 객체에 대한 요청을 처리하기 위한것입니다.
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession();
builder.Services.AddScoped<Cart>(sp => SessionCart.GetCart(sp));
builder.Services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
AddScoped() method는 Cart instance와 관련된 요청을 처리하기 위해 동일한 객체가 사용되어야 함을 지정합니다. 다시 말해 HTTP 요청을 처리하는 component에서 Cart를 필요로 할 때면 해당 모든 Cart가 동일한 객체를 전달받게 됨을 의미합니다.
이때 Repository에서 했던 것처럼 type mapping을 통해 AddScoped method를 제공하기보다는 예제에서 조금 다른 방법으로 lambda 식을 사용했습니다. 이 표현식은 등록된 service의 collection을 전달받아 SessionCart class의 GetCart Method로 collection을 전달하게 되고 이를 통해 Cart service의 요청은 SessionCart객체를 생성하는 것으로 처리될 것입니다. SessionCart객체는 override 된 Method에 따라 변경점이 발생하면 자신을 Session Data로 serialize 하게 됩니다.
또한 예제에서는 항상 같은 객체가 사용될 수 있도록 지정하는 AddSingleton() Method를 사용하여 Service를 추가하였습니다. 이렇게 되면 ASP.NET Core는 HttpContextAccessor class를 IHttpContextAccessor interface의 구현이 필요할 때마다 사용하게 되며 이를 통해 일정한 SessionCart class의 현재 session에 접근할 수 있게 됩니다.
(3) Cart Razor Page의 간소화
위와 같이 Service를 추가하게 됨으로써 우리는 Cart object를 사용할 때의 code를 좀 더 간소화할 수 있게 되었습니다. 따라서 Cart Razor Page의 page model class를 아래와 같이 변경합니다.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using MyWebApp.Models;
namespace MyWebApp.Pages
{
public class CartModel : PageModel
{
private IMyDBRepository _repository;
public CartModel(IMyDBRepository repository, Cart cart)
{
_repository = repository;
Cart = cart;
}
public Cart Cart { get; set; }
public string ReturnUrl { get; set; } = "/";
public void OnGet(string returnUrl)
{
ReturnUrl = returnUrl ?? "/";
}
public IActionResult OnPost(long ProductID, string returnUrl)
{
Product? product = _repository.Products.FirstOrDefault(p => p.Id == ProductID);
if (product != null)
Cart.AddItem(product, 1);
return RedirectToPage(new { returnUrl = returnUrl });
}
}
}
page model class는 생성자 method의 매개변수를 통해서 Cart object를 가져올 수 있도록 구현하고 있고 이로 인해 처리 Method에서 session과 관련된 구문을 제거할 수 있습니다. 이것은 page model class가 Cart 객체의 생성과 지속성에 대해 관여할 필요 없이 본래 자신의 역할에 좀 더 집중할 수 있게 합니다. 또한 Service가 Application전역에서 사용할 수 있게 됨으로써 Application의 모든 component에서는 같은 구현을 통해 사용자의 Cart정보를 가져올 수 있습니다.
2. Cart의 기능 개선
다음 절차를 계속 진행하기 이전에 Cart에 대해 아래 2가지의 새로운 기능을 추가함으로써 Cart에 대한 완성도를 좀 더 높여 보고자 합니다.
- Cart에 추가된 Item의 제거
- Page상단에 Cart의 요약정보 표시
(1) Cart의 Item제거
사용자가 Cart에 추가된 Item을 제거하려면 Cart Razor Page에서 삭제에 필요한 Button을 표시하여야 할 것입니다. 따라서 다음과 같이 Cart.cshtml을 수정합니다.
@page
@model CartModel
<h2>장바구니</h2>
<table>
<thead>
<tr>
<th>수량</th>
<th>제품</th>
<th>가격</th>
<th>금액</th>
<th>삭제</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model?.Cart?.Items ?? Enumerable.Empty<CartItems>())
{
<tr>
<td>@item.Quantity</td>
<td>@item.Product.Name</td>
<td>@item.Product.Price.ToString("c")</td>
<td>
@((item.Quantity * item.Product.Price).ToString("c"))
</td>
<td>
<form asp-page-handler="Remove" method="post">
<input type="hidden" name="ProductID" value="@item.Product.Id" />
<input type="hidden" name="returnUrl" value="@Model?.ReturnUrl" />
<button type="submit">삭제</button>
</form>
</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">계속 쇼핑하기</a>
</div>
추가된 button element는 post요청을 수행하는 form element로 인해 button을 누르는 순간 form내부의 hidden input element의 값을 post값으로 전송하게 될 것입니다. form에서는 이 post전송을 Remove로 하도록 지정하였으므로 Page Model Class에서는 이 값을 전달받을 새로운 Method를 생성해야 합니다.
public IActionResult OnPostRemove(long productId, string returnUrl)
{
var item = Cart.Items.First(cl => cl.Product.Id == productId).Product;
Cart.RemoveItem(item);
return RedirectToPage(new { returnUrl = returnUrl });
}
Cart.cshtml에서 form은 asp-page-handler를 통해 전송 위치를 Remove로 설정했으며 OnPostRemove()라는 새로운 method를 추가하였습니다. 이때 method의 이름은 On이라는 접두사와 함께 전송 Type에 맞는 이름을 결합하여 OnPost가 되고 그다음 지정된 이름의 Remove를 붙여 최종적으로 OnPostRemove라는 명칭이 완성될 수 있습니다.
Project를 실행하여 Cart의 Item추가와 삭제가 정상적으로 처리되는지를 확인합니다.
(2) Cart 요약 표시하기
현재 Cart의 문제점은 사용자가 Cart페이지를 봐야만 자신이 어떤 item을 추가했는지를 알 수 있고 더불어 Cart 페이지를 보려면 새로운 Item을 추가해야 한다는 것입니다. 물론 /Cart로 직접 URL을 입력할 수는 있지만 사용자에게 이런 요구를 할 수 없고 반드시 Page 간 전환이 필요하다는 점은 단점이 되기에 충분합니다.
이 문제를 해결하기 위해서 Application 전체에 걸쳐 Cart의 요약정보를 표시하고 Cart page로 이동할 수 있는 Widget을 Category 탐색 기능을 추가했던 것과 비슷한 방법으로 추가할 것입니다.
우선 Project의 Components folder에 CartSummary.cs이름으로 component를 추가합니다.
using Microsoft.AspNetCore.Mvc;
using MyWebApp.Models;
namespace MyWebApp.Components
{
public class CartSummary : ViewComponent
{
private Cart _cart;
public CartSummary(Cart cart)
{
_cart = cart;
}
public IViewComponentResult Invoke()
{
return View(_cart);
}
}
}
위 예제에서도 마찬가지로 이전에 생성한 Service의 이점을 활용하여 생성자를 통해 Cart의 객체를 가져올 수 있도록 하였으며 해당 Cart를 View Method로 전달하여 필요한 HTML을 생성할 수 있도록 하였습니다. 이제 Component를 위한 View를 생성하기 위해 Views->Shared->Components folder에 CartSummary folder를 생성하고 Default.cshtml이름의 Razor file을 추가합니다.
@model Cart
<div style="border:1px;">
@if (Model?.Items.Count() > 0)
{
<p>현재</p>
<span style="color:green;">
@Model?.Items.Sum(x => x.Quantity) 개제품 총
@Model?.ComputeTotalValue().ToString("c") 원
</span>
}
<a asp-page="/Cart" asp-route-returnurl="@ViewContext.HttpContext.Request.PathAndQuery()">장바구니</a>
</div>
예제에서는 Cart에 추가된 품목이 있는 경우 Cart에 존재하는 품목의 수와 합계금액을 표시하도록 하고 있습니다. 위와 같이 Component 및 그와 관련된 VIew를 생성하였으므로 Project의 _Layout.cshtml을 수정하여 모든 View Page에서 Cart의 요약정보를 표시할 수 있도록 합니다.
Project를 실행하여 추가한 Cart Component가 잘 작동하는지 확인합니다.
3. 주문하기
Cart에 원하는 item을 담아놓으면 이제 해당 item들을 확인하고 실제 주문을 완료할 수 있는 기능이 필요할 것입니다. 이를 위해 주문에 필요한 data model을 Models folder에 아래와 같이 생성합니다.
using Microsoft.AspNetCore.Mvc.ModelBinding;
using System.ComponentModel.DataAnnotations;
namespace MyWebApp.Models
{
public class Order
{
[BindNever]
public int OrderID { get; set; }
[BindNever]
public ICollection<CartItems> Items { get; set; } = new List<CartItems>();
[Required(ErrorMessage = "이릅이 입력되지 않았습니다.")]
public string? Name { get; set; }
[Required(ErrorMessage = "전화번호가 입력되지 않았습니다.")]
public string? Phone { get; set; }
[Required(ErrorMessage = "배송주소가 입력되지 않았습니다.")]
public string? Address { get; set; }
}
}
예제에서는 각 Property에 Model Validation을 적용하였습니다. 특히 [BindNever] Attribute는 사용자가 HTTP 요청을 통해 이들 Property의 값을 제공하지 못하도록 하는데 이는 model binding system의 기능으로 ASP.NET Core가 HTTP request값을 사용하여 해당 Property에 값을 담아두는 것을 방지합니다.
(1) 주문 완료하기
일단 Cart에 들어와 주문 상세내역을 확인하고 나면 해당 내역대로 주문할 수 있어야 합니다. 아래와 같이 Cart.chtml을 수정하여 주문이 가능한 button을 추가해줍니다.
~생략
<div>
<a href="@Model?.ReturnUrl">계속 쇼핑하기</a>
<a asp-controller="Order" asp-action="Checkout">주문하기</a>
</div>
위에서 생성한 link를 Cart에서 click 하게 되면 asp-controller와 asp-action의 tag helper설정에 따라 Order Controller의 Checkout Action Method를 호출하게 됩니다.
(2) Controller와 View 추가하기
이제 project의 Controllers folder에 OrderController.cs라는 file을 생성하고 Checkout Action Method를 정의합니다.
using Microsoft.AspNetCore.Mvc;
using MyWebApp.Models;
namespace MyWebApp.Controllers
{
public class OrderController : Controller
{
public IActionResult Checkout()
{
return View(new Order());
}
}
}
Checkout() Action Method는 View Model로서 Order객체를 생성하여 기본 View로 전달하고 있습니다. 이어서 위에서 만든 Action Method에서 Checkout부분에 Mouse 오른쪽 button을 눌러 'Add View'를 선택한 뒤 'Razor View -Empty'를 선택하고 'Add' button을 눌러 필요한 View를 생성합니다.
Name부분에 'Order.cshtml'을 지정하고 'Add'를 눌러주면 project의 Views folder에 Order라는 folder를 생성하고 그 안에 Checkout.cshtml이라는 file을 자동으로 생성할 것입니다.
Checkout.cshtml file이 생성되면 아래와 같이 해당 file을 수정합니다.
@model Order
<h2>주문서 작성</h2>
<form asp-action="Checkout" method="post">
<h1>배송정보 입력</h1>
<label>이름</label>:<input asp-for="Name" /><br />
<label>전화번호</label>:<input asp-for="Phone" /><br />
<label>주소</label>:<input asp-for="Address" /><br />
<div>
<input type="submit" value="주문하기" />
</div>
</form>
각 input element는 이전에 작성한 Order Model에 기반한 것이며 tag helper를 통해 Model의 속성과 연결하였습니다. 이때 사용된 asp-for tag helper는 input element에 대해서 type, id, name 등의 속성과 값을 Model에 기반해 자동으로 생성해 줄 것입니다.
project를 실행해 Cart에 들어가 '주문하기'를 눌러 아래와 같이 주문 작성 화면이 잘 표시되는지를 확인합니다.
(3) 주문 처리하기
위와 같은 주문서 작성 화면에서 사용자가 필요한 정보를 입력하고 '주문하기'를 누르게 되면 실제 주문정보를 저장해야 할 것이므로 사전에 필요한 준비를 진행합니다.
● Database 확장
필요한 정보는 대부분 Database를 활용하므로 project의 MyDbContext.cs을 아래와 같이 수정합니다.
public DbSet<Product> Products { get; set; }
public DbSet<Order> Orders { get; set; }
위와 같이 Orders 속성을 추가하는 것만으로 Entity Framework Core가 Order객체의 저장을 위해 database를 변경하기에는 충분합니다. 이제 Console에서 해당 Project folder로 이동해 아래 명령을 내려 실제 database에 변경사항이 적용될 수 있도록 합니다.
dotnet ef migrations add Orders |
위 명령은 Entity Framework Core가 새로운 Application Data Model을 통해 이전 Model과의 차이를 파악하고 Orders라는 새로운 Migration을 생성할 수 있도록 합니다. 이는 Project가 실행되면 InitData() Method를 통해 Migrate() Method가 호출되면서 자동으로 적용될 것입니다.
● Order Repository 생성
Order Repository는 Order 객체로의 접근을 제공하기 위한 것으로 이전에 product repository를 위해 사용했던 것과 같은 방법으로 생성하려고 합니다. 따라서 project의 Models folder에 IOrderRepository.cs file로 interface를 아래와 같이 추가합니다.
namespace MyWebApp.Models
{
public interface IOrderRepository
{
IQueryable<Order> Orders { get; }
void SaveOrder(Order order);
}
}
그리고 해당 interface의 구현 class인 OrderRepository.cs file도 Models foler에 추가합니다.
using Microsoft.EntityFrameworkCore;
namespace MyWebApp.Models
{
public class OrderRepository : IOrderRepository
{
private MyDbContext _dbContext;
public OrderRepository(MyDbContext dbContext)
{
_dbContext = dbContext;
}
public IQueryable<Order> Orders => _dbContext.Orders.Include(o => o.Items).ThenInclude(p => p.Product);
public void SaveOrder(Order order)
{
_dbContext.AttachRange(order.Items.Select(p => p.Product));
if (order.OrderID == 0)
_dbContext.Orders.Add(order);
_dbContext.SaveChanges();
}
}
}
위의 구현 class는 일련의 Order 객체를 통해 주문을 생성하거나 변경하고, 확인할 수 있는 Method를 포함하고 있습니다.
그리고 이렇게 만들어진 interface와 class를 Program.cs에서 하나의 service로 등록합니다.
builder.Services.AddScoped<IMyDBRepository, MyDBRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddRazorPages();
(4) Order Controller 보완하기
사용자의 주문을 완료하기 위해서는 위에서 생성한 OrderController에서 생성자를 추가해 주문처리에 필요한 Service객체를 전달받고 사용자가 Cart에서 '주문하기'button을 누를 때의 HTTP form POST 요청을 처리할 수 있는 Action Method를 추가해야 합니다.
using Microsoft.AspNetCore.Mvc;
using MyWebApp.Models;
namespace MyWebApp.Controllers
{
public class OrderController : Controller
{
private IOrderRepository _repository;
private Cart _cart;
public OrderController(IOrderRepository repository, Cart cart)
{
_repository = repository;
_cart = cart;
}
public IActionResult Checkout()
{
return View(new Order());
}
[HttpPost]
public IActionResult Checkout(Order order)
{
if (_cart.Items.Count() == 0)
{
ModelState.AddModelError(String.Empty, "장바구니에 주문가능한 제품이 포함되어 있지 않습니다.");
}
if (ModelState.IsValid)
{
order.Items = _cart.Items.ToArray();
_repository.SaveOrder(order);
_cart.Clear();
return RedirectToPage("/OrderComplete", new { OrderId = order.OrderID });
}
else
{
return View();
}
}
}
}
예제에서 두 번째 Checkout() Method는 HttpPost attribute를 통해 Decorate 되었으므로 POST 요청이 발생할 때 호출될 것이며 동시에 HTTP 요청에서 전달되는 값을 받기 위해 ASP.NET Core model binding기능을 사용하고 있습니다. 따라서 HTTP Post요청이 발생하면 model binding system은 Order class에 정의된 속성의 값을 찾게 됩니다.
그러나 경우에 따라 Cart 객체의 속성과 일치하는 값이 존재하지 않을 수도 있는데 '3. 주문하기'에서는 이를 방지할 목적으로 Cart class에 validation attribute를 적용하였습니다. 따라서 ASP.NET Core는 Cart class에 적용된 validation 제약 조건을 확인하고 그 결과를 ModelState 속성을 통해 제공하도록 해야 합니다. 이를 위해 예제에서는 AddModelError() Method를 통해 Cart에 주문 가능한 제품이 없는 경우 관련 오류 message를 등록하고 이후에 ModelState의 IsValid속성을 확인하여 Model에 어떠한 문제가 있는지를 파악하고 있습니다.
(5) Validation Error 표시하기
현재 예제는 사용자 data의 유효성 검증을 위해 Order class에 validation attribute를 적용하고 있습니다. 하지만 validation error message자체를 실제 사용자에게 표현하기 위해서는 약간의 추가적인 작업이 필요한데 이를 위해 아래와 같이 Views->Order folder에 있는 Checkout.cshtml을 수정하여 div tag와 함께 ASP.NET Core의 기본 tag helper인 asp-validation-summary를 추가합니다. 이 tag helper는 사용자가 입력한 data의 유효성을 확인하고 유효하지 않은 각 속성의 오류사항을 표시할 것입니다.
@model Order
<h2>주문서 작성</h2>
<div asp-validation-summary="All" style="color:red;"></div>
<form asp-action="Checkout" method="post">
<h1>배송정보 입력</h1>
<label>이름</label>:<input asp-for="Name" /><br />
<label>전화번호</label>:<input asp-for="Phone" /><br />
<label>주소</label>:<input asp-for="Address" /><br />
<div>
<input type="submit" value="주문하기" />
</div>
</form>
project를 실행하여 /order/checkout으로 이동해 아무런 값도 입력하지 않고 '주문하기'button을 눌러 아래와 같이 Model validation이 작동하는지를 확인합니다.
다만 위와 같은 방식의 validation문제점은 사용자의 data가 어떻게 해서든 server로 전성되어야만 그 결과로 error를 생성할 수 있다는 것입니다. 잘못된 data자체를 server로 전송하여 처리한다는 것은 server에게는 그만큼의 요청을 처리해야 한다는 뜻이 되므로 부담이 될 수 있는데 이 때문에 대부분의 경우에 validation은 browser의 javascript를 통해 입력값을 확인하는 client-side validation처리를 같이 동반하는 경우가 많습니다. client-side validation은 추후에 다룰 예정입니다.
(6) 완료 page 생성
사용자의 주문 완료처리를 위해 주문이 완료되었다는 page를 추가할 것입니다. Project의 Pages folder에 OrderComplete.cshtml file을 아래 내용으로 추가합니다.
@page
@model OrderCompleModel
<h3>주문해 주셔서 감사합니다.</h3>
<div>
<p>귀하의 주문번호 : @Model.OrderId</p>
<a class="btn btn-primary" asp-controller="Home" asp-action="Index">돌아가기</a>
</div>
그리고 Page Model Class에는 위에서 사용하려고 하는 OrderID 속성을 정의합니다.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace MyWebApp.Pages
{
public class OrderCompleModel : PageModel
{
public void OnGet()
{
}
[BindProperty(SupportsGet = true)]
public int OrderId { get; set; }
}
}
여기서 OrderId속성은 BindProperty attribute를 사용하고 있는데 이는 해당 속성의 값은 model binding system의 요청으로 가져올 수 있다는 것을 지정하는 것입니다.
이제 고객은 구입하고자 하는 제품을 Cart에 담고 주문처리를 완료하게 되면 아래와 같은 화면을 통해 주문이 정상적으로 완료되었음을 표시하게 될 것입니다.
주문번호는 시도한 횟수에 따라 달라질 수 있습니다.
'.NET > ASP.NET' 카테고리의 다른 글
[ASP.NET Core] Shopping mall project - 보안과 배포 (0) | 2022.09.06 |
---|---|
[ASP.NET Core] Shopping mall project - 관리자기능 (0) | 2022.09.01 |
[ASP.NET Core] Shopping mall project - Category탐색과 장바구니 구현 (0) | 2022.08.22 |
[ASP.NET Core] Shopping mall project 시작하기 (0) | 2022.08.16 |
[ASP.NET Core] 초간단 Application 만들어 보기 (0) | 2022.08.03 |