콘텐츠 대표 이미지 - ASP.NET Core로 시작하는 풀스택 개발 여정 🚀

ASP.NET Core로 시작하는 풀스택 개발 여정 🚀

MVC, Web API, Blazor까지 한 번에 마스터하기

안녕! 오늘은 정말 재미있는 이야기를 해볼까 해. 바로 ASP.NET Core를 활용한 웹 개발 이야기인데, 단순히 백엔드만 다루는 게 아니라 프론트엔드까지 모두 아우르는 풀스택 솔루션에 대해 깊이 있게 파헤쳐볼 거야. 😊

요즘 개발자들 사이에서 가장 핫한 키워드 중 하나가 바로 '풀스택'이잖아? 프론트엔드와 백엔드를 모두 다룰 수 있는 개발자의 가치는 점점 높아지고 있어. 그런데 여기서 중요한 건, 단순히 여러 기술을 '아는' 것과 실제로 '통합해서 사용할 수 있는' 것은 완전히 다른 차원이라는 거지.

ASP.NET Core는 마이크로소프트가 만든 오픈소스 크로스플랫폼 프레임워크인데, 이게 정말 대단한 게 Windows, macOS, Linux 어디서든 돌아간다는 거야. 예전 .NET Framework 시절에는 Windows에만 묶여 있었는데, 이제는 완전히 자유로워졌지. 🎉

💡 왜 ASP.NET Core인가? 성능 면에서 Node.js, Django, Spring Boot 같은 경쟁 프레임워크들과 비교했을 때 상위권에 속해. TechEmpower 벤치마크에서도 항상 높은 순위를 차지하고 있어. 특히 최신 버전으로 갈수록 성능 최적화가 미친 듯이 이루어지고 있지.
ASP.NET Core의 핵심 아키텍처 이해하기 🏗️

본격적으로 들어가기 전에 ASP.NET Core의 기본 구조부터 이해해야 해. 이게 탄탄해야 나중에 MVC든 Web API든 Blazor든 뭘 하든 막힘이 없거든.

ASP.NET Core 애플리케이션의 심장은 바로 미들웨어 파이프라인이야. 모든 HTTP 요청은 이 파이프라인을 통과하면서 처리되는데, 마치 공장의 컨베이어 벨트처럼 각 단계를 거치면서 변형되고 처리되는 거지.

Program.cs 파일을 열어보면 이런 구조를 볼 수 있어:

var builder = WebApplication.CreateBuilder(args);

// 서비스 등록
builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();

var app = builder.Build();

// 미들웨어 파이프라인 구성
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

app.Run();

이 코드가 의미하는 건 뭘까? 위에서부터 차례대로 설명해볼게. 먼저 WebApplication.CreateBuilder로 애플리케이션 빌더를 만들어. 이건 집을 지을 때 설계도를 그리는 것과 비슷해.

그 다음 Services 컬렉션에 필요한 서비스들을 등록하지. 이게 바로 의존성 주입(Dependency Injection)의 핵심이야. ASP.NET Core는 DI를 기본으로 지원하는데, 이게 정말 강력한 기능이거든. 나중에 코드 테스트할 때나 유지보수할 때 엄청난 이점을 가져다줘.

🔥 실전 팁 미들웨어의 순서가 매우 중요해! UseRouting은 UseAuthorization보다 먼저 와야 하고, UseStaticFiles는 UseRouting보다 먼저 오는 게 일반적이야. 순서를 바꾸면 예상치 못한 동작이 발생할 수 있어.

미들웨어는 각자의 역할이 명확해. UseHttpsRedirection은 HTTP 요청을 HTTPS로 리다이렉트하고, UseStaticFiles는 CSS, JavaScript, 이미지 같은 정적 파일들을 서빙하지. UseRouting은 URL을 분석해서 어떤 컨트롤러의 어떤 액션으로 보낼지 결정하고, UseAuthorization은 사용자의 권한을 체크해.

MVC 패턴으로 웹 애플리케이션 구축하기 🎨

자, 이제 본격적으로 MVC(Model-View-Controller) 패턴에 대해 이야기해보자. MVC는 소프트웨어 디자인 패턴 중 하나인데, 웹 개발에서 정말 널리 사용되고 있어.

MVC를 처음 접하는 사람들은 "왜 이렇게 복잡하게 나눠놨어?"라고 생각할 수 있는데, 사실 이게 코드를 관심사의 분리(Separation of Concerns) 원칙에 따라 깔끔하게 정리하는 방법이야.

Model 데이터와 비즈니스 로직 데이터베이스 연동 View 사용자 인터페이스 HTML, CSS 렌더링 Controller 요청 처리 및 조정 Model과 View 연결 데이터 전달 렌더링 사용자 요청 흐름 MVC 패턴의 순환

Model은 데이터와 비즈니스 로직을 담당해. 예를 들어 쇼핑몰을 만든다면 상품(Product), 주문(Order), 사용자(User) 같은 엔티티들이 모델이 되는 거지. 이들은 데이터베이스와 직접 연동되고, 데이터 검증이나 계산 같은 비즈니스 로직도 포함해.

public class Product
{
    public int Id { get; set; }
    
    [Required(ErrorMessage = "상품명은 필수입니다")]
    [StringLength(100)]
    public string Name { get; set; }
    
    [Range(0, double.MaxValue, ErrorMessage = "가격은 0 이상이어야 합니다")]
    public decimal Price { get; set; }
    
    public string Description { get; set; }
    
    public int StockQuantity { get; set; }
    
    // 비즈니스 로직
    public bool IsInStock()
    {
        return StockQuantity > 0;
    }
    
    public decimal GetDiscountedPrice(decimal discountRate)
    {
        return Price * (1 - discountRate);
    }
}

위 코드를 보면 단순히 데이터만 담는 게 아니라 데이터 검증 어노테이션도 붙어있고, 비즈니스 로직 메서드도 있지? 이게 바로 모델의 역할이야.

Controller는 사용자의 요청을 받아서 처리하는 교통정리 역할을 해. HTTP 요청이 들어오면 컨트롤러가 받아서 필요한 모델 데이터를 가져오고, 그걸 뷰에 전달하는 거지.

public class ProductController : Controller
{
    private readonly IProductService _productService;
    
    // 의존성 주입을 통한 서비스 주입
    public ProductController(IProductService productService)
    {
        _productService = productService;
    }
    
    // GET: Product/Index
    public async Task<IActionResult> Index()
    {
        var products = await _productService.GetAllProductsAsync();
        return View(products);
    }
    
    // GET: Product/Details/5
    public async Task<IActionResult> Details(int id)
    {
        var product = await _productService.GetProductByIdAsync(id);
        
        if (product == null)
        {
            return NotFound();
        }
        
        return View(product);
    }
    
    // POST: Product/Create
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Create(Product product)
    {
        if (ModelState.IsValid)
        {
            await _productService.AddProductAsync(product);
            return RedirectToAction(nameof(Index));
        }
        
        return View(product);
    }
}

컨트롤러 코드를 보면 생성자에서 IProductService를 주입받고 있어. 이게 바로 의존성 주입이야. 직접 서비스 객체를 생성하는 게 아니라 외부에서 주입받음으로써 코드의 결합도를 낮추고 테스트하기 쉽게 만드는 거지. 👍

각 액션 메서드는 특정 URL 패턴과 매핑돼. 예를 들어 Index 액션은 /Product/Index URL로 접근할 수 있고, Details 액션은 /Product/Details/5 같은 형태로 접근할 수 있어.

View는 사용자에게 보여지는 부분이야. ASP.NET Core에서는 Razor라는 뷰 엔진을 사용하는데, 이게 정말 강력해. HTML 안에 C# 코드를 자연스럽게 섞어 쓸 수 있거든.

@model IEnumerable<Product>

@{
    ViewData["Title"] = "상품 목록";
}

<h2>@ViewData["Title"]</h2>

<p>
    <a asp-action="Create" class="btn btn-primary">새 상품 등록</a>
</p>

<table class="table table-striped">
    <thead>
        <tr>
            <th>상품명</th>
            <th>가격</th>
            <th>재고</th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model)
        {
            <tr>
                <td>@item.Name</td>
                <td>@item.Price.ToString("C")</td>
                <td>
                    @if (item.IsInStock())
                    {
                        <span class="badge bg-success">재고 있음</span>
                    }
                    else
                    {
                        <span class="badge bg-danger">품절</span>
                    }
                </td>
                <td>
                    <a asp-action="Details" asp-route-id="@item.Id">상세</a> |
                    <a asp-action="Edit" asp-route-id="@item.Id">수정</a> |
                    <a asp-action="Delete" asp-route-id="@item.Id">삭제</a>
                </td>
            </tr>
        }
    </tbody>
</table>

Razor 뷰를 보면 @ 기호로 C# 코드를 시작해. @model 지시문으로 이 뷰가 어떤 타입의 모델을 받을지 선언하고, @foreach로 반복문을 돌리고, @if로 조건문을 사용할 수 있어. HTML과 C#이 완벽하게 조화를 이루고 있지? 🎵

✨ Tag Helper의 마법 asp-action, asp-controller, asp-route-id 같은 속성들을 Tag Helper라고 해. 이걸 사용하면 URL을 하드코딩하지 않고도 안전하게 링크를 생성할 수 있어. 나중에 라우팅 규칙이 바뀌어도 코드를 수정할 필요가 없지.
Web API로 RESTful 서비스 구축하기 🌐

이제 Web API 이야기를 해볼까? 요즘 웹 개발에서 API는 필수야. 모바일 앱, SPA(Single Page Application), 다른 서비스와의 연동 등 모든 곳에서 API가 사용되거든.

ASP.NET Core Web API는 RESTful 원칙을 따르는 HTTP 서비스를 만들기에 최적화되어 있어. MVC와 비슷한 구조를 가지고 있지만, View 대신 JSON이나 XML 같은 데이터 형식으로 응답을 반환하지.

API 컨트롤러를 만들 때는 ControllerBase를 상속받아. Controller 클래스가 아니라 ControllerBase인 이유는 뷰 관련 기능이 필요 없기 때문이야.

[ApiController]
[Route("api/[controller]")]
public class ProductsApiController : ControllerBase
{
    private readonly IProductService _productService;
    private readonly ILogger<ProductsApiController> _logger;
    
    public ProductsApiController(
        IProductService productService,
        ILogger<ProductsApiController> logger)
    {
        _productService = productService;
        _logger = logger;
    }
    
    // GET: api/ProductsApi
    [HttpGet]
    [ProducesResponseType(StatusCodes.Status200OK)]
    public async Task<ActionResult<IEnumerable<Product>>> GetProducts()
    {
        _logger.LogInformation("상품 목록 조회 요청");
        var products = await _productService.GetAllProductsAsync();
        return Ok(products);
    }
    
    // GET: api/ProductsApi/5
    [HttpGet("{id}")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult<Product>> GetProduct(int id)
    {
        var product = await _productService.GetProductByIdAsync(id);
        
        if (product == null)
        {
            _logger.LogWarning("상품 ID {ProductId}를 찾을 수 없습니다", id);
            return NotFound(new { message = $"상품 ID {id}를 찾을 수 없습니다" });
        }
        
        return Ok(product);
    }
    
    // POST: api/ProductsApi
    [HttpPost]
    [ProducesResponseType(StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<ActionResult<Product>> CreateProduct(Product product)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }
        
        var createdProduct = await _productService.AddProductAsync(product);
        
        return CreatedAtAction(
            nameof(GetProduct),
            new { id = createdProduct.Id },
            createdProduct);
    }
    
    // PUT: api/ProductsApi/5
    [HttpPut("{id}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> UpdateProduct(int id, Product product)
    {
        if (id != product.Id)
        {
            return BadRequest(new { message = "ID가 일치하지 않습니다" });
        }
        
        var existingProduct = await _productService.GetProductByIdAsync(id);
        if (existingProduct == null)
        {
            return NotFound();
        }
        
        await _productService.UpdateProductAsync(product);
        
        return NoContent();
    }
    
    // DELETE: api/ProductsApi/5
    [HttpDelete("{id}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> DeleteProduct(int id)
    {
        var product = await _productService.GetProductByIdAsync(id);
        if (product == null)
        {
            return NotFound();
        }
        
        await _productService.DeleteProductAsync(id);
        
        return NoContent();
    }
}

위 코드를 보면 여러 가지 중요한 포인트들이 있어. 먼저 [ApiController] 어트리뷰트를 주목해봐. 이게 붙으면 자동으로 모델 검증을 해주고, 바인딩 소스 추론도 해주고, 문제 세부 정보 응답도 자동으로 생성해줘. 정말 편리하지? 😎

[Route("api/[controller]")]는 라우팅 템플릿을 정의하는 거야. [controller] 부분은 컨트롤러 이름에서 'Controller'를 뺀 부분으로 자동 치환돼. 그래서 이 경우 'api/ProductsApi'가 되는 거지.

각 액션 메서드에는 HTTP 동사 어트리뷰트가 붙어있어. [HttpGet], [HttpPost], [HttpPut], [HttpDelete] 같은 거 말이야. 이게 RESTful API의 핵심이야:

🎯 RESTful HTTP 메서드 GET: 리소스 조회 (읽기 전용, 멱등성)
POST: 새 리소스 생성
PUT: 기존 리소스 전체 수정 (멱등성)
PATCH: 기존 리소스 부분 수정
DELETE: 리소스 삭제 (멱등성)

멱등성(Idempotent)이란 같은 요청을 여러 번 해도 결과가 동일하다는 의미야.

ProducesResponseType 어트리뷰트는 API 문서화에 도움을 줘. Swagger 같은 도구를 사용할 때 이 정보를 바탕으로 자동으로 문서를 생성해주거든. 어떤 상태 코드가 반환될 수 있는지 명시하는 거지.

응답 타입도 중요해. ActionResult<T>를 사용하면 타입 안정성을 유지하면서도 다양한 HTTP 응답을 반환할 수 있어. Ok(), NotFound(), BadRequest(), CreatedAtAction() 같은 헬퍼 메서드들이 적절한 상태 코드와 함께 응답을 생성해주지.

API 버전 관리도 실무에서 정말 중요한 주제야. API는 한 번 배포하면 클라이언트들이 사용하기 시작하는데, 나중에 변경사항이 생기면 어떻게 해야 할까? 기존 클라이언트를 망가뜨리지 않으면서 새로운 기능을 추가해야 하잖아.

// NuGet 패키지: Microsoft.AspNetCore.Mvc.Versioning

// Program.cs에서 설정
builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;
    options.ApiVersionReader = ApiVersionReader.Combine(
        new UrlSegmentApiVersionReader(),
        new HeaderApiVersionReader("X-Api-Version"));
});

// 버전 1.0 컨트롤러
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsV1Controller : ControllerBase
{
    [HttpGet]
    public IActionResult GetProducts()
    {
        // 버전 1.0 로직
        return Ok(new { version = "1.0", data = "..." });
    }
}

// 버전 2.0 컨트롤러
[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsV2Controller : ControllerBase
{
    [HttpGet]
    public IActionResult GetProducts()
    {
        // 버전 2.0 로직 (개선된 기능)
        return Ok(new { version = "2.0", data = "...", metadata = "..." });
    }
}

이렇게 하면 /api/v1/products와 /api/v2/products를 동시에 운영할 수 있어. 기존 클라이언트는 v1을 계속 사용하고, 새로운 클라이언트는 v2를 사용하는 거지. 점진적인 마이그레이션이 가능해져! 🚀

인증과 권한 부여도 빼놓을 수 없는 주제야. API는 보통 공개되어 있기 때문에 보안이 정말 중요해. ASP.NET Core는 JWT(JSON Web Token) 기반 인증을 기본으로 지원해.

// Program.cs에서 JWT 인증 설정
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))
        };
    });

// 컨트롤러에서 사용
[ApiController]
[Route("api/[controller]")]
[Authorize] // 이 컨트롤러의 모든 액션은 인증 필요
public class SecureProductsController : ControllerBase
{
    [HttpGet]
    [AllowAnonymous] // 이 액션만 인증 없이 접근 가능
    public IActionResult GetPublicProducts()
    {
        return Ok("공개 상품 목록");
    }
    
    [HttpPost]
    [Authorize(Roles = "Admin")] // Admin 역할을 가진 사용자만 접근 가능
    public IActionResult CreateProduct(Product product)
    {
        return Ok("상품 생성됨");
    }
    
    [HttpGet("my-products")]
    public IActionResult GetMyProducts()
    {
        // 현재 사용자 정보 가져오기
        var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        var userName = User.Identity.Name;
        
        return Ok($"사용자 {userName}의 상품 목록");
    }
}

JWT 토큰은 클라이언트가 로그인할 때 발급받아서 이후 모든 요청의 Authorization 헤더에 포함시켜 보내. 서버는 이 토큰을 검증해서 사용자를 식별하고 권한을 확인하는 거지. 상태를 서버에 저장하지 않아도 되니까 확장성이 좋아! 💪

Blazor로 풀스택 개발의 새 지평 열기 ⚡

자, 이제 정말 흥미진진한 부분이 나왔어. 바로 Blazor야! Blazor는 C#으로 인터랙티브한 웹 UI를 만들 수 있게 해주는 프레임워크인데, 이게 정말 혁명적이야. JavaScript 없이도 풍부한 클라이언트 사이드 애플리케이션을 만들 수 있거든! 🎉

"잠깐, C#으로 프론트엔드를? 브라우저는 JavaScript만 이해하는데?" 라고 생각할 수 있어. 맞아, 그래서 Blazor는 두 가지 호스팅 모델을 제공해:

Blazor 호스팅 모델 Blazor Server 서버 브라우저 SignalR 장점 빠른 초기 로딩 작은 다운로드 크기 서버 리소스 활용 단점 서버 연결 필수 지연 시간 존재 Blazor WebAssembly 브라우저 WebAssembly 장점 오프라인 작동 서버 부하 없음 네이티브 성능 단점 큰 초기 다운로드

Blazor Server는 UI 상태와 로직이 서버에서 실행돼. 브라우저와 서버는 SignalR을 통해 실시간으로 연결되어 있고, 사용자가 버튼을 클릭하면 그 이벤트가 서버로 전송되고, 서버에서 처리한 후 UI 변경사항만 다시 브라우저로 보내는 거야. 마치 원격 데스크톱처럼 작동하는 셈이지.

Blazor WebAssembly는 완전히 다른 접근이야. .NET 런타임 자체가 WebAssembly로 컴파일되어 브라우저에서 실행돼. 그래서 C# 코드가 브라우저 안에서 직접 돌아가는 거지! 이건 정말 놀라운 기술이야. 🤯

🎯 어떤 모델을 선택해야 할까? Blazor Server: 인트라넷 애플리케이션, 빠른 초기 로딩이 중요한 경우, 서버 리소스가 충분한 경우
Blazor WebAssembly: 오프라인 지원이 필요한 경우, 서버 부하를 줄이고 싶은 경우, PWA를 만들고 싶은 경우
Blazor Hybrid: 데스크톱이나 모바일 네이티브 앱을 만들고 싶은 경우 (MAUI와 함께)

Blazor 컴포넌트를 만들어볼까? Blazor는 컴포넌트 기반 아키텍처를 사용해. React나 Vue를 써본 적 있다면 익숙할 거야.

@* Counter.razor *@
@page "/counter"

<PageTitle>카운터</PageTitle>

<h1>카운터 예제</h1>

<p role="status">현재 카운트: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">클릭하세요</button>

@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
    }
}

이게 Blazor 컴포넌트의 기본 구조야. 위쪽은 HTML과 Razor 문법이 섞인 마크업이고, 아래 @code 블록에는 C# 코드가 들어가. @onclick 같은 이벤트 핸들러를 보면 JavaScript 없이도 이벤트를 처리할 수 있다는 게 신기하지? 😊

더 복잡한 예제를 볼까? 실제 프로젝트에서 사용할 만한 상품 목록 컴포넌트를 만들어보자:

@* ProductList.razor *@
@page "/products"
@inject HttpClient Http
@inject NavigationManager Navigation

<PageTitle>상품 목록</PageTitle>

<h1>상품 관리</h1>

@if (products == null)
{
    <div class="spinner-border text-primary" role="status">
        <span class="visually-hidden">로딩 중...</span>
    </div>
}
else if (!products.Any())
{
    <div class="alert alert-info">
        등록된 상품이 없습니다.
    </div>
}
else
{
    <div class="row">
        @foreach (var product in products)
        {
            <div class="col-md-4 mb-4">
                <div class="card h-100">
                    <div class="card-body">
                        <h5 class="card-title">@product.Name</h5>
                        <p class="card-text">@product.Description</p>
                        <p class="text-primary fw-bold">@product.Price.ToString("C")</p>
                        
                        @if (product.IsInStock())
                        {
                            <span class="badge bg-success">재고 있음</span>
                        }
                        else
                        {
                            <span class="badge bg-danger">품절</span>
                        }
                    </div>
                    <div class="card-footer">
                        <button class="btn btn-sm btn-primary" 
                                @onclick="() => ViewDetails(product.Id)">
                            상세보기
                        </button>
                        <button class="btn btn-sm btn-outline-secondary" 
                                @onclick="() => EditProduct(product.Id)">
                            수정
                        </button>
                    </div>
                </div>
            </div>
        }
    </div>
}

@code {
    private List<Product>? products;

    protected override async Task OnInitializedAsync()
    {
        // 컴포넌트가 초기화될 때 데이터 로드
        products = await Http.GetFromJsonAsync<List<Product>>("api/products");
    }

    private void ViewDetails(int id)
    {
        Navigation.NavigateTo($"/products/{id}");
    }

    private void EditProduct(int id)
    {
        Navigation.NavigateTo($"/products/edit/{id}");
    }
}

이 코드에서 주목할 점들이 많아. 먼저 @inject 지시문으로 의존성을 주입받고 있어. HttpClient로 API를 호출하고, NavigationManager로 페이지 이동을 처리하지.

OnInitializedAsync는 컴포넌트의 생명주기 메서드야. 컴포넌트가 처음 렌더링될 때 자동으로 호출돼. 여기서 비동기로 데이터를 가져오고 있어. GetFromJsonAsync 메서드는 정말 편리해. JSON을 자동으로 역직렬화해서 C# 객체로 만들어주거든.

컴포넌트 간 통신도 중요한 주제야. 부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달하거나, 자식에서 부모로 이벤트를 전달해야 할 때가 많거든.

@* ProductCard.razor - 자식 컴포넌트 *@
<div class="card @CssClass">
    <div class="card-body">
        <h5 class="card-title">@Product.Name</h5>
        <p class="card-text">@Product.Description</p>
        <p class="text-primary fw-bold">@Product.Price.ToString("C")</p>
        
        <button class="btn btn-primary" @onclick="HandleClick">
            @ButtonText
        </button>
    </div>
</div>

@code {
    [Parameter]
    public Product Product { get; set; } = null!;
    
    [Parameter]
    public string ButtonText { get; set; } = "선택";
    
    [Parameter]
    public string CssClass { get; set; } = "";
    
    [Parameter]
    public EventCallback<Product> OnProductSelected { get; set; }
    
    private async Task HandleClick()
    {
        await OnProductSelected.InvokeAsync(Product);
    }
}

@* ProductGallery.razor - 부모 컴포넌트 *@
<div class="row">
    @foreach (var product in products)
    {
        <div class="col-md-4">
            <ProductCard Product="product"
                        ButtonText="장바구니에 추가"
                        CssClass="shadow-sm"
                        OnProductSelected="HandleProductSelected" />
        </div>
    }
</div>

@if (selectedProduct != null)
{
    <div class="alert alert-success mt-3">
        @selectedProduct.Name 이(가) 선택되었습니다!
    </div>
}

@code {
    private List<Product> products = new();
    private Product? selectedProduct;
    
    private void HandleProductSelected(Product product)
    {
        selectedProduct = product;
        // 여기서 장바구니에 추가하는 로직 실행
    }
}

[Parameter] 어트리뷰트로 표시된 프로퍼티들은 부모 컴포넌트에서 값을 전달받을 수 있어. EventCallback은 자식에서 부모로 이벤트를 전달하는 타입 안전한 방법이야. JavaScript의 콜백 함수와 비슷하지만 더 강력하지! 💪

폼 처리와 유효성 검증도 Blazor에서 정말 잘 되어 있어. EditForm 컴포넌트와 데이터 어노테이션을 사용하면 복잡한 폼도 쉽게 만들 수 있어.

@* ProductForm.razor *@
@page "/products/create"
@inject HttpClient Http
@inject NavigationManager Navigation

<h2>새 상품 등록</h2>

<EditForm Model="@product" OnValidSubmit="@HandleValidSubmit">
    <DataAnnotationsValidator />
    <ValidationSummary class="alert alert-danger" />
    
    <div class="mb-3">
        <label class="form-label">상품명</label>
        <InputText class="form-control" @bind-Value="product.Name" />
        <ValidationMessage For="@(() => product.Name)" />
    </div>
    
    <div class="mb-3">
        <label class="form-label">가격</label>
        <InputNumber class="form-control" @bind-Value="product.Price" />
        <ValidationMessage For="@(() => product.Price)" />
    </div>
    
    <div class="mb-3">
        <label class="form-label">설명</label>
        <InputTextArea class="form-control" @bind-Value="product.Description" rows="4" />
        <ValidationMessage For="@(() => product.Description)" />
    </div>
    
    <div class="mb-3">
        <label class="form-label">재고 수량</label>
        <InputNumber class="form-control" @bind-Value="product.StockQuantity" />
        <ValidationMessage For="@(() => product.StockQuantity)" />
    </div>
    
    <div class="mb-3">
        <label class="form-check-label">
            <InputCheckbox class="form-check-input" @bind-Value="product.IsActive" />
            활성화
        </label>
    </div>
    
    <button type="submit" class="btn btn-primary" disabled="@isSubmitting">
        @if (isSubmitting)
        {
            <span class="spinner-border spinner-border-sm me-2"></span>
        }
        저장
    </button>
    <button type="button" class="btn btn-secondary" @onclick="Cancel">취소</button>
</EditForm>

@code {
    private Product product = new();
    private bool isSubmitting = false;
    
    private async Task HandleValidSubmit()
    {
        isSubmitting = true;
        
        try
        {
            var response = await Http.PostAsJsonAsync("api/products", product);
            
            if (response.IsSuccessStatusCode)
            {
                Navigation.NavigateTo("/products");
            }
            else
            {
                // 에러 처리
            }
        }
        finally
        {
            isSubmitting = false;
        }
    }
    
    private void Cancel()
    {
        Navigation.NavigateTo("/products");
    }
}

EditForm은 정말 강력해. Model에 바인딩된 객체의 데이터 어노테이션을 자동으로 검증해주고, ValidationSummary로 모든 에러를 한 번에 보여줄 수도 있고, ValidationMessage로 각 필드별 에러를 표시할 수도 있어.

OnValidSubmit 이벤트는 모든 유효성 검증을 통과했을 때만 발생해. 그래서 HandleValidSubmit 메서드에서는 유효성 검증에 대해 걱정할 필요 없이 비즈니스 로직에만 집중할 수 있지. 😊

상태 관리는 복잡한 애플리케이션에서 정말 중요해. 여러 컴포넌트가 같은 데이터를 공유해야 할 때가 많거든. Blazor에서는 여러 가지 방법으로 상태를 관리할 수 있어.

// AppState.cs - 상태 관리 서비스
public class AppState
{
    private List<Product> _cart = new();
    
    public IReadOnlyList<Product> Cart => _cart.AsReadOnly();
    
    public event Action? OnChange;
    
    public void AddToCart(Product product)
    {
        _cart.Add(product);
        NotifyStateChanged();
    }
    
    public void RemoveFromCart(Product product)
    {
        _cart.Remove(product);
        NotifyStateChanged();
    }
    
    public void ClearCart()
    {
        _cart.Clear();
        NotifyStateChanged();
    }
    
    public decimal GetTotalPrice()
    {
        return _cart.Sum(p => p.Price);
    }
    
    private void NotifyStateChanged() => OnChange?.Invoke();
}

// Program.cs에서 등록
builder.Services.AddScoped<AppState>();

// 컴포넌트에서 사용
@inject AppState AppState
@implements IDisposable

<div class="cart-summary">
    <h4>장바구니</h4>
    <p>상품 수: @AppState.Cart.Count</p>
    <p>총액: @AppState.GetTotalPrice().ToString("C")</p>
</div>

@code {
    protected override void OnInitialized()
    {
        AppState.OnChange += StateHasChanged;
    }
    
    public void Dispose()
    {
        AppState.OnChange -= StateHasChanged;
    }
}

이 패턴은 정말 유용해. AppState 서비스를 Scoped로 등록하면 같은 사용자 세션 내에서 모든 컴포넌트가 같은 인스턴스를 공유하게 돼. 그리고 OnChange 이벤트를 통해 상태가 변경되면 구독하고 있는 모든 컴포넌트가 자동으로 다시 렌더링되지. 🎯

💡 Blazor의 렌더링 최적화 Blazor는 가상 DOM 개념을 사용해. 상태가 변경되면 전체 페이지를 다시 그리는 게 아니라 변경된 부분만 효율적으로 업데이트해. 그래서 성능이 정말 좋아. ShouldRender 메서드를 오버라이드하면 불필요한 렌더링을 더 세밀하게 제어할 수도 있어.
Entity Framework Core로 데이터 액세스 구현하기 💾

풀스택 개발에서 데이터베이스는 빼놓을 수 없지. ASP.NET Core 생태계에서는 Entity Framework Core(EF Core)가 사실상 표준 ORM이야. ORM은 Object-Relational Mapping의 약자로, 객체 지향 프로그래밍과 관계형 데이터베이스 사이의 간극을 메워주는 기술이지.

EF Core를 사용하면 SQL을 직접 작성하지 않고도 C# 코드로 데이터베이스를 다룰 수 있어. 물론 필요하면 원시 SQL도 사용할 수 있고! 😊

// ApplicationDbContext.cs
public class ApplicationDbContext : DbContext
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }
    
    public DbSet<Product> Products { get; set; }
    public DbSet<Category> Categories { get; set; }
    public DbSet<Order> Orders { get; set; }
    public DbSet<OrderItem> OrderItems { get; set; }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        
        // Product 엔티티 설정
        modelBuilder.Entity<Product>(entity =>
        {
            entity.HasKey(e => e.Id);
            
            entity.Property(e => e.Name)
                .IsRequired()
                .HasMaxLength(100);
            
            entity.Property(e => e.Price)
                .HasColumnType("decimal(18,2)");
            
            entity.HasOne(e => e.Category)
                .WithMany(c => c.Products)
                .HasForeignKey(e => e.CategoryId)
                .OnDelete(DeleteBehavior.Restrict);
            
            entity.HasIndex(e => e.Name);
        });
        
        // Order와 OrderItem의 다대다 관계 설정
        modelBuilder.Entity<OrderItem>(entity =>
        {
            entity.HasKey(e => new { e.OrderId, e.ProductId });
            
            entity.HasOne(e => e.Order)
                .WithMany(o => o.OrderItems)
                .HasForeignKey(e => e.OrderId);
            
            entity.HasOne(e => e.Product)
                .WithMany()
                .HasForeignKey(e => e.ProductId);
        });
        
        // 초기 데이터 시딩
        modelBuilder.Entity<Category>().HasData(
            new Category { Id = 1, Name = "전자제품" },
            new Category { Id = 2, Name = "의류" },
            new Category { Id = 3, Name = "식품" }
        );
    }
}

DbContext는 데이터베이스와의 세션을 나타내는 클래스야. DbSet 프로퍼티들은 각각 데이터베이스 테이블에 대응돼. OnModelCreating 메서드에서는 엔티티 간의 관계, 제약조건, 인덱스 등을 세밀하게 설정할 수 있어.

Program.cs에서 DbContext를 등록하는 방법:

// Program.cs
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("DefaultConnection"),
        sqlOptions => sqlOptions.EnableRetryOnFailure(
            maxRetryCount: 5,
            maxRetryDelay: TimeSpan.FromSeconds(30),
            errorNumbersToAdd: null)));

// 개발 환경에서는 상세한 에러 표시
if (builder.Environment.IsDevelopment())
{
    builder.Services.AddDbContext<ApplicationDbContext>(options =>
        options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))
               .EnableSensitiveDataLogging()
               .EnableDetailedErrors());
}

이제 실제로 데이터를 다루는 서비스를 만들어보자. Repository 패턴을 사용하면 데이터 액세스 로직을 깔끔하게 분리할 수 있어.

// IProductRepository.cs
public interface IProductRepository
{
    Task<IEnumerable<Product>> GetAllAsync();
    Task<Product?> GetByIdAsync(int id);
    Task<IEnumerable<Product>> GetByCategoryAsync(int categoryId);
    Task<Product> AddAsync(Product product);
    Task UpdateAsync(Product product);
    Task DeleteAsync(int id);
    Task<bool> ExistsAsync(int id);
}

// ProductRepository.cs
public class ProductRepository : IProductRepository
{
    private readonly ApplicationDbContext _context;
    private readonly ILogger<ProductRepository> _logger;
    
    public ProductRepository(
        ApplicationDbContext context,
        ILogger<ProductRepository> logger)
    {
        _context = context;
        _logger = logger;
    }
    
    public async Task<IEnumerable<Product>> GetAllAsync()
    {
        return await _context.Products
            .Include(p => p.Category)
            .AsNoTracking()
            .ToListAsync();
    }
    
    public async Task<Product?> GetByIdAsync(int id)
    {
        return await _context.Products
            .Include(p => p.Category)
            .FirstOrDefaultAsync(p => p.Id == id);
    }
    
    public async Task<IEnumerable<Product>> GetByCategoryAsync(int categoryId)
    {
        return await _context.Products
            .Where(p => p.CategoryId == categoryId)
            .Include(p => p.Category)
            .AsNoTracking()
            .ToListAsync();
    }
    
    public async Task<Product> AddAsync(Product product)
    {
        _context.Products.Add(product);
        await _context.SaveChangesAsync();
        
        _logger.LogInformation("상품 추가됨: {ProductId} - {ProductName}", 
            product.Id, product.Name);
        
        return product;
    }
    
    public async Task UpdateAsync(Product product)
    {
        _context.Entry(product).State = EntityState.Modified;
        
        try
        {
            await _context.SaveChangesAsync();
            _logger.LogInformation("상품 수정됨: {ProductId}", product.Id);
        }
        catch (DbUpdateConcurrencyException ex)
        {
            _logger.LogError(ex, "동시성 충돌 발생: {ProductId}", product.Id);
            throw;
        }
    }
    
    public async Task DeleteAsync(int id)
    {
        var product = await _context.Products.FindAsync(id);
        if (product != null)
        {
            _context.Products.Remove(product);
            await _context.SaveChangesAsync();
            
            _logger.LogInformation("상품 삭제됨: {ProductId}", id);
        }
    }
    
    public async Task<bool> ExistsAsync(int id)
    {
        return await _context.Products.AnyAsync(p => p.Id == id);
    }
}

여기서 중요한 포인트들이 몇 가지 있어. Include 메서드는 관련 엔티티를 함께 로드하는 즉시 로딩(Eager Loading)이야. 이걸 안 쓰면 나중에 Category에 접근할 때 추가 쿼리가 발생하는데, 이를 N+1 문제라고 해.

AsNoTracking은 읽기 전용 쿼리에서 성능을 향상시켜. EF Core는 기본적으로 조회한 엔티티를 추적(tracking)하는데, 수정할 계획이 없다면 추적을 끄는 게 더 효율적이지.

🔥 EF Core 성능 최적화 팁 1. AsNoTracking을 읽기 전용 쿼리에 사용
2. Select로 필요한 컬럼만 조회
3. Include로 N+1 문제 방지
4. AsSplitQuery로 복잡한 조인 최적화
5. Compiled Queries로 반복 쿼리 성능 향상
6. 인덱스를 적절히 설정
7. 페이징 구현 (Skip/Take)

복잡한 쿼리 예제도 볼까?

// 고급 쿼리 예제
public async Task<PagedResult<ProductDto>> GetProductsPagedAsync(
    int pageNumber,
    int pageSize,
    string? searchTerm,
    int? categoryId,
    decimal? minPrice,
    decimal? maxPrice,
    string sortBy = "Name",
    bool ascending = true)
{
    var query = _context.Products
        .Include(p => p.Category)
        .AsQueryable();
    
    // 검색 필터
    if (!string.IsNullOrWhiteSpace(searchTerm))
    {
        query = query.Where(p => 
            p.Name.Contains(searchTerm) || 
            p.Description.Contains(searchTerm));
    }
    
    // 카테고리 필터
    if (categoryId.HasValue)
    {
        query = query.Where(p => p.CategoryId == categoryId.Value);
    }
    
    // 가격 범위 필터
    if (minPrice.HasValue)
    {
        query = query.Where(p => p.Price >= minPrice.Value);
    }
    
    if (maxPrice.HasValue)
    {
        query = query.Where(p => p.Price <= maxPrice.Value);
    }
    
    // 정렬
    query = sortBy switch
    {
        "Price" => ascending 
            ? query.OrderBy(p => p.Price) 
            : query.OrderByDescending(p => p.Price),
        "Name" => ascending 
            ? query.OrderBy(p => p.Name) 
            : query.OrderByDescending(p => p.Name),
        _ => query.OrderBy(p => p.Id)
    };
    
    // 전체 개수 조회
    var totalCount = await query.CountAsync();
    
    // 페이징 및 DTO 변환
    var items = await query
        .Skip((pageNumber - 1) * pageSize)
        .Take(pageSize)
        .Select(p => new ProductDto
        {
            Id = p.Id,
            Name = p.Name,
            Price = p.Price,
            CategoryName = p.Category.Name,
            IsInStock = p.StockQuantity > 0
        })
        .AsNoTracking()
        .ToListAsync();
    
    return new PagedResult<ProductDto>
    {
        Items = items,
        TotalCount = totalCount,
        PageNumber = pageNumber,
        PageSize = pageSize,
        TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
    };
}

이 코드는 실무에서 정말 자주 사용하는 패턴이야. 검색, 필터링, 정렬, 페이징을 모두 지원하고, DTO로 변환해서 필요한 데이터만 전송하지. Select를 사용해서 DTO로 변환하면 데이터베이스에서 필요한 컬럼만 가져오기 때문에 성능이 좋아져! 🚀

실전 프로젝트 구조와 아키텍처 패턴 🏛️

이제 실제 프로젝트를 어떻게 구조화하는지 알아보자. 작은 프로젝트라면 간단한 구조로도 충분하지만, 규모가 커지면 클린 아키텍처나 레이어드 아키텍처 같은 패턴을 적용하는 게 좋아.

레이어드 아키텍처 Presentation Layer (UI) MVC Controllers, Blazor Components, Web API Application Layer (Business Logic) Services, DTOs, Validators, Use Cases Domain Layer (Core) Entities, Interfaces, Domain Logic Infrastructure Layer (Data Access) EF Core, Repositories, External Services 의존성 방향 안쪽으로

레이어드 아키텍처의 핵심은 관심사의 분리와 의존성 역전이야. 각 레이어는 명확한 책임을 가지고, 의존성은 항상 안쪽(도메인)을 향해야 해. 이렇게 하면 비즈니스 로직이 인프라스트럭처에 의존하지 않게 되어 테스트하기 쉽고 유지보수가 편해져.

실제 프로젝트 구조를 보면 이렇게 생겼어:

MyProject/
├── MyProject.Domain/              # 도메인 레이어
│   ├── Entities/
│   │   ├── Product.cs
│   │   ├── Category.cs
│   │   └── Order.cs
│   ├── Interfaces/
│   │   ├── IProductRepository.cs
│   │   └── IOrderRepository.cs
│   └── Exceptions/
│       └── DomainException.cs
│
├── MyProject.Application/         # 애플리케이션 레이어
│   ├── Services/
│   │   ├── ProductService.cs
│   │   └── OrderService.cs
│   ├── DTOs/
│   │   ├── ProductDto.cs
│   │   └── OrderDto.cs
│   ├── Validators/
│   │   └── ProductValidator.cs
│   └── Interfaces/
│       └── IProductService.cs
│
├── MyProject.Infrastructure/      # 인프라스트럭처 레이어
│   ├── Data/
│   │   ├── ApplicationDbContext.cs
│   │   └── Migrations/
│   ├── Repositories/
│   │   ├── ProductRepository.cs
│   │   └── OrderRepository.cs
│   └── ExternalServices/
│       └── EmailService.cs
│
├── MyProject.Web/                 # 프레젠테이션 레이어 (MVC)
│   ├── Controllers/
│   ├── Views/
│   ├── wwwroot/
│   └── Program.cs
│
├── MyProject.Api/                 # Web API 프로젝트
│   ├── Controllers/
│   ├── Middleware/
│   └── Program.cs
│
└── MyProject.Blazor/              # Blazor 프로젝트
    ├── Pages/
    ├── Components/
    ├── Services/
    └── Program.cs

이런 구조를 사용하면 각 레이어를 독립적으로 개발하고 테스트할 수 있어. 예를 들어 데이터베이스를 MySQL에서 PostgreSQL로 바꾸고 싶다면? Infrastructure 레이어만 수정하면 돼. 비즈니스 로직은 전혀 건드릴 필요가 없지! 😊

의존성 주입 설정을 체계적으로 관리하는 것도 중요해. 각 레이어별로 확장 메서드를 만들어서 깔끔하게 정리할 수 있어.

// MyProject.Infrastructure/DependencyInjection.cs
public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // DbContext 등록
        services.AddDbContext<ApplicationDbContext>(options =>
            options.UseSqlServer(
                configuration.GetConnectionString("DefaultConnection")));
        
        // Repository 등록
        services.AddScoped<IProductRepository, ProductRepository>();
        services.AddScoped<IOrderRepository, OrderRepository>();
        
        // 외부 서비스 등록
        services.AddScoped<IEmailService, EmailService>();
        
        return services;
    }
}

// MyProject.Application/DependencyInjection.cs
public static class DependencyInjection
{
    public static IServiceCollection AddApplication(
        this IServiceCollection services)
    {
        // 서비스 등록
        services.AddScoped<IProductService, ProductService>();
        services.AddScoped<IOrderService, OrderService>();
        
        // FluentValidation 등록
        services.AddValidatorsFromAssemblyContaining<ProductValidator>();
        
        // AutoMapper 등록
        services.AddAutoMapper(Assembly.GetExecutingAssembly());
        
        return services;
    }
}

// Program.cs에서 사용
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);

// 나머지 설정...

이렇게 하면 Program.cs가 깔끔해지고, 각 레이어의 의존성을 한눈에 파악할 수 있어. 새로운 서비스를 추가할 때도 해당 레이어의 DependencyInjection 클래스만 수정하면 되니까 관리가 편하지! 👍

DTO(Data Transfer Object)와 AutoMapper를 활용하면 레이어 간 데이터 전송을 효율적으로 할 수 있어. 엔티티를 직접 노출하지 않고 DTO를 사용하면 보안도 좋아지고, API 버전 관리도 쉬워져.

// ProductDto.cs
public class ProductDto
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public string CategoryName { get; set; } = string.Empty;
    public bool IsInStock { get; set; }
}

public class CreateProductDto
{
    [Required(ErrorMessage = "상품명은 필수입니다")]
    [StringLength(100)]
    public string Name { get; set; } = string.Empty;
    
    [Range(0, double.MaxValue)]
    public decimal Price { get; set; }
    
    [Required]
    public int CategoryId { get; set; }
    
    public string? Description { get; set; }
    
    [Range(0, int.MaxValue)]
    public int StockQuantity { get; set; }
}

// MappingProfile.cs
public class MappingProfile : Profile
{
    public MappingProfile()
    {
        CreateMap<Product, ProductDto>()
            .ForMember(dest => dest.CategoryName, 
                opt => opt.MapFrom(src => src.Category.Name))
            .ForMember(dest => dest.IsInStock, 
                opt => opt.MapFrom(src => src.StockQuantity > 0));
        
        CreateMap<CreateProductDto, Product>();
        
        CreateMap<Product, UpdateProductDto>().ReverseMap();
    }
}

// 서비스에서 사용
public class ProductService : IProductService
{
    private readonly IProductRepository _repository;
    private readonly IMapper _mapper;
    
    public ProductService(IProductRepository repository, IMapper mapper)
    {
        _repository = repository;
        _mapper = mapper;
    }
    
    public async Task<IEnumerable<ProductDto>> GetAllProductsAsync()
    {
        var products = await _repository.GetAllAsync();
        return _mapper.Map<IEnumerable<ProductDto>>(products);
    }
    
    public async Task<ProductDto> CreateProductAsync(CreateProductDto dto)
    {
        var product = _mapper.Map<Product>(dto);
        var created = await _repository.AddAsync(product);
        return _mapper.Map<ProductDto>(created);
    }
}

AutoMapper를 사용하면 반복적인 매핑 코드를 줄일 수 있어. 특히 복잡한 객체 그래프를 변환할 때 정말 유용하지. ForMember 메서드로 커스텀 매핑 로직도 쉽게 구현할 수 있고! 🎯

보안과 인증 구현하기 🔐

웹 애플리케이션에서 보안은 선택이 아니라 필수야. ASP.NET Core는 ASP.NET Core Identity라는 강력한 인증/권한 부여 시스템을 제공해. 사용자 관리, 비밀번호 해싱, 2단계 인증, 외부 로그인(Google, Facebook 등) 등을 모두 지원하지.

// ApplicationUser.cs - 사용자 엔티티 확장
public class ApplicationUser : IdentityUser
{
    public string FullName { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
    public DateTime? LastLoginAt { get; set; }
    
    // 네비게이션 프로퍼티
    public virtual ICollection<Order> Orders { get; set; } = new List<Order>();
}

// ApplicationDbContext.cs
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }
    
    public DbSet<Product> Products { get; set; }
    public DbSet<Order> Orders { get; set; }
    
    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);
        
        // Identity 테이블 이름 커스터마이징
        builder.Entity<ApplicationUser>(entity =>
        {
            entity.ToTable("Users");
        });
        
        builder.Entity<IdentityRole>(entity =>
        {
            entity.ToTable("Roles");
        });
        
        // 기본 역할 시딩
        builder.Entity<IdentityRole>().HasData(
            new IdentityRole 
            { 
                Id = "1", 
                Name = "Admin", 
                NormalizedName = "ADMIN" 
            },
            new IdentityRole 
            { 
                Id = "2", 
                Name = "User", 
                NormalizedName = "USER" 
            }
        );
    }
}

Identity를 설정하는 방법:

// Program.cs
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
    // 비밀번호 정책
    options.Password.RequireDigit = true;
    options.Password.RequireLowercase = true;
    options.Password.RequireUppercase = true;
    options.Password.RequireNonAlphanumeric = true;
    options.Password.RequiredLength = 8;
    
    // 계정 잠금 정책
    options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
    options.Lockout.MaxFailedAccessAttempts = 5;
    options.Lockout.AllowedForNewUsers = true;
    
    // 사용자 정책
    options.User.RequireUniqueEmail = true;
    
    // 로그인 정책
    options.SignIn.RequireConfirmedEmail = true;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();

// 쿠키 설정
builder.Services.ConfigureApplicationCookie(options =>
{
    options.Cookie.HttpOnly = true;
    options.ExpireTimeSpan = TimeSpan.FromDays(7);
    options.LoginPath = "/Account/Login";
    options.LogoutPath = "/Account/Logout";
    options.AccessDeniedPath = "/Account/AccessDenied";
    options.SlidingExpiration = true;
});

회원가입과 로그인 기능을 구현해보자:

// AccountController.cs
public class AccountController : Controller
{
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly SignInManager<ApplicationUser> _signInManager;
    private readonly ILogger<AccountController> _logger;
    
    public AccountController(
        UserManager<ApplicationUser> userManager,
        SignInManager<ApplicationUser> signInManager,
        ILogger<AccountController> logger)
    {
        _userManager = userManager;
        _signInManager = signInManager;
        _logger = logger;
    }
    
    [HttpGet]
    public IActionResult Register()
    {
        return View();
    }
    
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Register(RegisterViewModel model)
    {
        if (ModelState.IsValid)
        {
            var user = new ApplicationUser
            {
                UserName = model.Email,
                Email = model.Email,
                FullName = model.FullName,
                CreatedAt = DateTime.UtcNow
            };
            
            var result = await _userManager.CreateAsync(user, model.Password);
            
            if (result.Succeeded)
            {
                _logger.LogInformation("새 사용자 생성됨: {Email}", user.Email);
                
                // 기본 역할 할당
                await _userManager.AddToRoleAsync(user, "User");
                
                // 이메일 확인 토큰 생성
                var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
                var callbackUrl = Url.Action(
                    "ConfirmEmail",
                    "Account",
                    new { userId = user.Id, token = token },
                    protocol: HttpContext.Request.Scheme);
                
                // 이메일 발송 (실제로는 IEmailService 사용)
                // await _emailService.SendEmailAsync(user.Email, "이메일 확인", callbackUrl);
                
                return RedirectToAction("RegisterConfirmation");
            }
            
            foreach (var error in result.Errors)
            {
                ModelState.AddModelError(string.Empty, error.Description);
            }
        }
        
        return View(model);
    }
    
    [HttpGet]
    public IActionResult Login(string? returnUrl = null)
    {
        ViewData["ReturnUrl"] = returnUrl;
        return View();
    }
    
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Login(LoginViewModel model, string? returnUrl = null)
    {
        returnUrl ??= Url.Content("~/");
        
        if (ModelState.IsValid)
        {
            var result = await _signInManager.PasswordSignInAsync(
                model.Email,
                model.Password,
                model.RememberMe,
                lockoutOnFailure: true);
            
            if (result.Succeeded)
            {
                var user = await _userManager.FindByEmailAsync(model.Email);
                user.LastLoginAt = DateTime.UtcNow;
                await _userManager.UpdateAsync(user);
                
                _logger.LogInformation("사용자 로그인: {Email}", model.Email);
                return LocalRedirect(returnUrl);
            }
            
            if (result.RequiresTwoFactor)
            {
                return RedirectToAction("LoginWith2fa", new { returnUrl, model.RememberMe });
            }
            
            if (result.IsLockedOut)
            {
                _logger.LogWarning("계정 잠김: {Email}", model.Email);
                return RedirectToAction("Lockout");
            }
            
            ModelState.AddModelError(string.Empty, "로그인 시도가 실패했습니다.");
        }
        
        return View(model);
    }
    
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Logout()
    {
        await _signInManager.SignOutAsync();
        _logger.LogInformation("사용자 로그아웃");
        return RedirectToAction("Index", "Home");
    }
}

UserManager와 SignInManager는 Identity의 핵심 클래스야. UserManager는 사용자 생성, 수정, 삭제, 역할 관리 등을 담당하고, SignInManager는 로그인, 로그아웃, 2단계 인증 등을 처리하지.

⚠️ 보안 주의사항 1. HTTPS를 반드시 사용하세요
2. ValidateAntiForgeryToken으로 CSRF 공격 방지
3. 비밀번호는 절대 평문으로 저장하지 마세요 (Identity가 자동으로 해싱)
4. SQL Injection 방지를 위해 파라미터화된 쿼리 사용
5. 민감한 정보는 User Secrets나 Azure Key Vault에 저장
6. Rate Limiting으로 무차별 대입 공격 방지

Blazor에서 인증을 사용하는 방법도 알아보자:

@* LoginDisplay.razor *@
<AuthorizeView>
    <Authorized>
        <span class="me-2">안녕하세요, @context.User.Identity?.Name!</span>
        <a href="/Account/Manage" class="btn btn-sm btn-outline-primary">프로필</a>
        <form method="post" action="/Account/Logout" class="d-inline">
            <button type="submit" class="btn btn-sm btn-outline-danger">로그아웃</button>
        </form>
    </Authorized>
    <NotAuthorized>
        <a href="/Account/Login" class="btn btn-sm btn-primary">로그인</a>
        <a href="/Account/Register" class="btn btn-sm btn-outline-primary">회원가입</a>
    </NotAuthorized>
</AuthorizeView>

@* SecureComponent.razor *@
@page "/secure"
@attribute [Authorize]

<h3>보안 페이지</h3>

<AuthorizeView Roles="Admin">
    <Authorized>
        <p>관리자만 볼 수 있는 내용입니다.</p>
        <button class="btn btn-danger" @onclick="DeleteAllData">모든 데이터 삭제</button>
    </Authorized>
    <NotAuthorized>
        <p>관리자 권한이 필요합니다.</p>
    </NotAuthorized>
</AuthorizeView>

@code {
    [CascadingParameter]
    private Task<AuthenticationState>? authenticationStateTask { get; set; }
    
    private string? userName;
    
    protected override async Task OnInitializedAsync()
    {
        if (authenticationStateTask != null)
        {
            var authState = await authenticationStateTask;
            var user = authState.User;
            
            if (user.Identity?.IsAuthenticated == true)
            {
                userName = user.Identity.Name;
            }
        }
    }
    
    private void DeleteAllData()
    {
        // 관리자만 실행 가능한 위험한 작업
    }
}

AuthorizeView 컴포넌트는 정말 편리해. 사용자의 인증 상태에 따라 다른 UI를 보여줄 수 있거든. Roles 파라미터로 특정 역할을 가진 사용자에게만 콘텐츠를 표시할 수도 있어. 🔒

실시간 기능 구현 - SignalR 활용하기 📡

현대 웹 애플리케이션에서 실시간 기능은 거의 필수가 되었어. 채팅, 알림, 실시간 대시보드 등 다양한 곳에서 사용되지. ASP.NET Core는 SignalR이라는 강력한 실시간 통신 라이브러리를 제공해.

SignalR은 WebSocket을 우선적으로 사용하고, 지원하지 않는 환경에서는 자동으로 Server-Sent Events나 Long Polling으로 폴백해. 개발자는 이런 복잡한 부분을 신경 쓸 필요 없이 간단한 API만 사용하면 돼! 😊

// ChatHub.cs
public class ChatHub : Hub
{
    private readonly ILogger<ChatHub> _logger;
    
    public ChatHub(ILogger<ChatHub> logger)
    {
        _logger = logger;
    }
    
    public async Task SendMessage(string user, string message)
    {
        _logger.LogInformation("메시지 수신: {User} - {Message}", user, message);
        
        // 모든 클라이언트에게 메시지 전송
        await Clients.All.SendAsync("ReceiveMessage", user, message);
    }
    
    public async Task SendMessageToGroup(string groupName, string user, string message)
    {
        // 특정 그룹에만 메시지 전송
        await Clients.Group(groupName).SendAsync("ReceiveMessage", user, message);
    }
    
    public async Task JoinGroup(string groupName)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
        
        await Clients.Group(groupName).SendAsync(
            "SystemMessage",
            $"{Context.User?.Identity?.Name}님이 {groupName} 그룹에 참여했습니다.");
    }
    
    public async Task LeaveGroup(string groupName)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
        
        await Clients.Group(groupName).SendAsync(
            "SystemMessage",
            $"{Context.User?.Identity?.Name}님이 {groupName} 그룹에서 나갔습니다.");
    }
    
    public override async Task OnConnectedAsync()
    {
        _logger.LogInformation("클라이언트 연결: {ConnectionId}", Context.ConnectionId);
        await base.OnConnectedAsync();
    }
    
    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        _logger.LogInformation("클라이언트 연결 해제: {ConnectionId}", Context.ConnectionId);
        await base.OnDisconnectedAsync(exception);
    }
}

// Program.cs에서 SignalR 설정
builder.Services.AddSignalR(options =>
{
    options.EnableDetailedErrors = true;
    options.KeepAliveInterval = TimeSpan.FromSeconds(15);
    options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
});

var app = builder.Build();

// 엔드포인트 매핑
app.MapHub<ChatHub>("/chathub");

Hub는 SignalR의 핵심 클래스야. 클라이언트에서 호출할 수 있는 메서드들을 정의하고, Clients 프로퍼티를 통해 연결된 클라이언트들에게 메시지를 보낼 수 있어.

Blazor에서 SignalR을 사용하는 방법:

@* Chat.razor *@
@page "/chat"
@using Microsoft.AspNetCore.SignalR.Client
@inject NavigationManager Navigation
@implements IAsyncDisposable

<div class="container mt-4">
    <div class="row">
        <div class="col-md-8 mx-auto">
            <div class="card">
                <div class="card-header bg-primary text-white">
                    <h4>실시간 채팅</h4>
                </div>
                <div class="card-body" style="height: 400px; overflow-y: auto;" @ref="messageContainer">
                    @foreach (var msg in messages)
                    {
                        <div class="mb-2 @(msg.IsSystemMessage ? "text-muted fst-italic" : "")">
                            @if (!msg.IsSystemMessage)
                            {
                                <strong>@msg.User:</strong>
                            }
                            <span>@msg.Text</span>
                            <small class="text-muted">@msg.Timestamp.ToString("HH:mm:ss")</small>
                        </div>
                    }
                </div>
                <div class="card-footer">
                    <div class="input-group">
                        <input type="text" 
                               class="form-control" 
                               @bind="messageInput"
                               @bind:event="oninput"
                               @onkeypress="HandleKeyPress"
                               placeholder="메시지를 입력하세요..."
                               disabled="@(!IsConnected)" />
                        <button class="btn btn-primary" 
                                @onclick="Send" 
                                disabled="@(!IsConnected || string.IsNullOrWhiteSpace(messageInput))">
                            전송
                        </button>
                    </div>
                    <small class="text-muted">
                        상태: @(IsConnected ? "연결됨 ✅" : "연결 안 됨 ❌")
                    </small>
                </div>
            </div>
        </div>
    </div>
</div>

@code {
    private HubConnection? hubConnection;
    private List<ChatMessage> messages = new();
    private string? messageInput;
    private string? userName = "익명";
    private ElementReference messageContainer;
    
    public bool IsConnected => hubConnection?.State == HubConnectionState.Connected;
    
    protected override async Task OnInitializedAsync()
    {
        // SignalR 연결 설정
        hubConnection = new HubConnectionBuilder()
            .WithUrl(Navigation.ToAbsoluteUri("/chathub"))
            .WithAutomaticReconnect()
            .Build();
        
        // 메시지 수신 핸들러
        hubConnection.On<string, string>("ReceiveMessage", (user, message) =>
        {
            messages.Add(new ChatMessage
            {
                User = user,
                Text = message,
                Timestamp = DateTime.Now
            });
            
            InvokeAsync(StateHasChanged);
        });
        
        // 시스템 메시지 수신 핸들러
        hubConnection.On<string>("SystemMessage", (message) =>
        {
            messages.Add(new ChatMessage
            {
                Text = message,
                Timestamp = DateTime.Now,
                IsSystemMessage = true
            });
            
            InvokeAsync(StateHasChanged);
        });
        
        // 연결 시작
        await hubConnection.StartAsync();
    }
    
    private async Task Send()
    {
        if (hubConnection is not null && !string.IsNullOrWhiteSpace(messageInput))
        {
            await hubConnection.SendAsync("SendMessage", userName, messageInput);
            messageInput = string.Empty;
        }
    }
    
    private async Task HandleKeyPress(KeyboardEventArgs e)
    {
        if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(messageInput))
        {
            await Send();
        }
    }
    
    public async ValueTask DisposeAsync()
    {
        if (hubConnection is not null)
        {
            await hubConnection.DisposeAsync();
        }
    }
    
    private class ChatMessage
    {
        public string User { get; set; } = string.Empty;
        public string Text { get; set; } = string.Empty;
        public DateTime Timestamp { get; set; }
        public bool IsSystemMessage { get; set; }
    }
}

HubConnectionBuilder로 연결을 만들고, On 메서드로 서버에서 오는 메시지를 구독해. SendAsync로 서버의 Hub 메서드를 호출할 수 있어. WithAutomaticReconnect를 사용하면 연결이 끊어졌을 때 자동으로 재연결을 시도하지! 🔄

✨ SignalR 활용 아이디어 1. 실시간 채팅: 고객 지원, 팀 협업
2. 알림 시스템: 새 메시지, 주문 상태 변경
3. 실시간 대시보드: 주식 시세, 서버 모니터링
4. 협업 도구: 공동 문서 편집, 화이트보드
5. 게임: 멀티플레이어 게임, 실시간 퀴즈
6. IoT: 센서 데이터 실시간 표시
성능 최적화와 캐싱 전략 ⚡

애플리케이션이 커지면서 성능 최적화는 점점 중요해져. 특히 데이터베이스 쿼리가 많아지면 응답 시간이 느려질 수 있어. 이럴 때 캐싱이 정말 효과적이야!

ASP.NET Core는 여러 가지 캐싱 옵션을 제공해:

// Program.cs에서 캐싱 설정
// 1. 메모리 캐시
builder.Services.AddMemoryCache();

// 2. 분산 캐시 (Redis)
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "MyApp_";
});

// 3. 응답 캐싱
builder.Services.AddResponseCaching();

var app = builder.Build();

// 응답 캐싱 미들웨어
app.UseResponseCaching();

// 캐싱 서비스 사용 예제
public class ProductService : IProductService
{
    private readonly IProductRepository _repository;
    private readonly IMemoryCache _cache;
    private readonly ILogger<ProductService> _logger;
    
    public ProductService(
        IProductRepository repository,
        IMemoryCache cache,
        ILogger<ProductService> logger)
    {
        _repository = repository;
        _cache = cache;
        _logger = logger;
    }
    
    public async Task<IEnumerable<ProductDto>> GetAllProductsAsync()
    {
        const string cacheKey = "all_products";
        
        // 캐시에서 먼저 확인
        if (_cache.TryGetValue(cacheKey, out IEnumerable<ProductDto>? cachedProducts))
        {
            _logger.LogInformation("캐시에서 상품 목록 반환");
            return cachedProducts!;
        }
        
        // 캐시에 없으면 데이터베이스에서 조회
        _logger.LogInformation("데이터베이스에서 상품 목록 조회");
        var products = await _repository.GetAllAsync();
        var productDtos = _mapper.Map<IEnumerable<ProductDto>>(products);
        
        // 캐시에 저장 (10분 동안)
        var cacheOptions = new MemoryCacheEntryOptions()
            .SetSlidingExpiration(TimeSpan.FromMinutes(10))
            .SetAbsoluteExpiration(TimeSpan.FromHours(1))
            .SetPriority(CacheItemPriority.Normal)
            .RegisterPostEvictionCallback((key, value, reason, state) =>
            {
                _logger.LogInformation("캐시 항목 제거됨: {Key}, 이유: {Reason}", key, reason);
            });
        
        _cache.Set(cacheKey, productDtos, cacheOptions);
        
        return productDtos;
    }
    
    public async Task<ProductDto> UpdateProductAsync(int id, UpdateProductDto dto)
    {
        var product = _mapper.Map<Product>(dto);
        await _repository.UpdateAsync(product);
        
        // 캐시 무효화
        _cache.Remove("all_products");
        _cache.Remove($"product_{id}");
        
        return _mapper.Map<ProductDto>(product);
    }
}

메모리 캐시는 단일 서버 환경에서 빠르고 간단해. 하지만 여러 서버로 확장하면 각 서버마다 독립적인 캐시를 가지게 되어 일관성 문제가 생길 수 있어. 이럴 때는 분산 캐시(Redis, SQL Server)를 사용하는 게 좋아.

응답 캐싱은 HTTP 응답 전체를 캐싱해. API 컨트롤러에서 사용하면 효과적이야:

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    [ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any)]
    public async Task<ActionResult<IEnumerable<ProductDto>>> GetProducts()
    {
        // 이 응답은 60초 동안 캐싱됨
        var products = await _productService.GetAllProductsAsync();
        return Ok(products);
    }
    
    [HttpGet("{id}")]
    [ResponseCache(Duration = 300, VaryByQueryKeys = new[] { "id" })]
    public async Task<ActionResult<ProductDto>> GetProduct(int id)
    {
        // ID별로 다른 캐시, 5분 동안 유지
        var product = await _productService.GetProductByIdAsync(id);
        
        if (product == null)
        {
            return NotFound();
        }
        
        return Ok(product);
    }
}

데이터베이스 쿼리 최적화도 중요해. EF Core에서 자주 하는 실수들을 피하는 방법:

// ❌ 나쁜 예: N+1 문제
public async Task<List<ProductDto>> GetProductsBad()
{
    var products = await _context.Products.ToListAsync();
    
    // 각 상품마다 카테고리를 조회하는 추가 쿼리 발생!
    return products.Select(p => new ProductDto
    {
        Id = p.Id,
        Name = p.Name,
        CategoryName = p.Category.Name // 여기서 추가 쿼리!
    }).ToList();
}

// ✅ 좋은 예: Include로 한 번에 조회
public async Task<List<ProductDto>> GetProductsGood()
{
    return await _context.Products
        .Include(p => p.Category)
        .Select(p => new ProductDto
        {
            Id = p.Id,
            Name = p.Name,
            CategoryName = p.Category.Name
        })
        .AsNoTracking()
        .ToListAsync();
}

// ✅ 더 좋은 예: 필요한 컬럼만 조회
public async Task<List<ProductDto>> GetProductsBest()
{
    return await _context.Products
        .Select(p => new ProductDto
        {
            Id = p.Id,
            Name = p.Name,
            Price = p.Price,
            CategoryName = p.Category.Name
        })
        .AsNoTracking()
        .ToListAsync();
}

// 복잡한 쿼리는 Compiled Query 사용
private static readonly Func<ApplicationDbContext, int, Task<Product?>> 
    GetProductByIdCompiled = EF.CompileAsyncQuery(
        (ApplicationDbContext context, int id) =>
            context.Products
                .Include(p => p.Category)
                .FirstOrDefault(p => p.Id == id));

public async Task<Product?> GetProductByIdOptimized(int id)
{
    return await GetProductByIdCompiled(_context, id);
}

Compiled Query는 쿼리를 미리 컴파일해서 재사용하는 거야. 같은 쿼리를 반복적으로 실행할 때 성능이 크게 향상돼! 🚀

비동기 프로그래밍도 성능에 큰 영향을 미쳐. ASP.NET Core는 비동기를 기본으로 설계되었어. 동기 메서드를 사용하면 스레드를 블로킹해서 확장성이 떨어지거든.

// ❌ 나쁜 예: 동기 메서드
public IActionResult GetProductsSync()
{
    var products = _context.Products.ToList(); // 스레드 블로킹!
    return Ok(products);
}

// ✅ 좋은 예: 비동기 메서드
public async Task<IActionResult> GetProductsAsync()
{
    var products = await _context.Products.ToListAsync();
    return Ok(products);
}

// 여러 비동기 작업을 병렬로 실행
public async Task<DashboardDto> GetDashboardDataAsync()
{
    // 순차 실행 (느림)
    // var products = await _productService.GetAllProductsAsync();
    // var orders = await _orderService.GetRecentOrdersAsync();
    // var users = await _userService.GetActiveUsersAsync();
    
    // 병렬 실행 (빠름!)
    var productsTask = _productService.GetAllProductsAsync();
    var ordersTask = _orderService.GetRecentOrdersAsync();
    var usersTask = _userService.GetActiveUsersAsync();
    
    await Task.WhenAll(productsTask, ordersTask, usersTask);
    
    return new DashboardDto
    {
        Products = await productsTask,
        Orders = await ordersTask,
        Users = await usersTask
    };
}

Task.WhenAll을 사용하면 여러 비동기 작업을 동시에 실행할 수 있어. 순차적으로 실행하는 것보다 훨씬 빠르지! 단, 작업들이 서로 독립적일 때만 사용해야 해. 😊

테스트와 디버깅 전략 🧪

좋은 개발자와 훌륭한 개발자의 차이는 테스트에 있어. 테스트 코드를 작성하면 버그를 조기에 발견하고, 리팩토링할 때 자신감을 가질 수 있어. ASP.NET Core는 테스트하기 정말 좋은 구조로 되어 있어!

단위 테스트(Unit Test)부터 시작해보자. xUnit, NUnit, MSTest 중 하나를 선택할 수 있는데, xUnit이 가장 인기가 많아.

// ProductServiceTests.cs
public class ProductServiceTests
{
    private readonly Mock<IProductRepository> _mockRepository;
    private readonly Mock<IMapper> _mockMapper;
    private readonly Mock<ILogger<ProductService>> _mockLogger;
    private readonly ProductService _service;
    
    public ProductServiceTests()
    {
        _mockRepository = new Mock<IProductRepository>();
        _mockMapper = new Mock<IMapper>();
        _mockLogger = new Mock<ILogger<ProductService>>();
        
        _service = new ProductService(
            _mockRepository.Object,
            _mockMapper.Object,
            _mockLogger.Object);
    }
    
    [Fact]
    public async Task GetAllProductsAsync_ReturnsAllProducts()
    {
        // Arrange
        var products = new List<Product>
        {
            new Product { Id = 1, Name = "상품1", Price = 10000 },
            new Product { Id = 2, Name = "상품2", Price = 20000 }
        };
        
        var productDtos = new List<ProductDto>
        {
            new ProductDto { Id = 1, Name = "상품1", Price = 10000 },
            new ProductDto { Id = 2, Name = "상품2", Price = 20000 }
        };
        
        _mockRepository
            .Setup(r => r.GetAllAsync())
            .ReturnsAsync(products);
        
        _mockMapper
            .Setup(m => m.Map<IEnumerable<ProductDto>>(products))
            .Returns(productDtos);
        
        // Act
        var result = await _service.GetAllProductsAsync();
        
        // Assert
        Assert.NotNull(result);
        Assert.Equal(2, result.Count());
        Assert.Equal("상품1", result.First().Name);
    }
    
    [Fact]
    public async Task GetProductByIdAsync_ProductNotFound_ReturnsNull()
    {
        // Arrange
        _mockRepository
            .Setup(r => r.GetByIdAsync(It.IsAny<int>()))
            .ReturnsAsync((Product?)null);
        
        // Act
        var result = await _service.GetProductByIdAsync(999);
        
        // Assert
        Assert.Null(result);
    }
    
    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    [InlineData(-100)]
    public async Task GetProductByIdAsync_InvalidId_ThrowsException(int invalidId)
    {
        // Arrange & Act & Assert
        await Assert.ThrowsAsync<ArgumentException>(
            () => _service.GetProductByIdAsync(invalidId));
    }
}

Moq 라이브러리를 사용하면 의존성을 쉽게 모킹할 수 있어. Setup 메서드로 특정 메서드가 호출될 때 어떤 값을 반환할지 정의하고, Verify로 메서드가 실제로 호출되었는지 검증할 수 있지.

통합 테스트(Integration Test)는 여러 컴포넌트가 함께 작동하는지 테스트해. ASP.NET Core는 WebApplicationFactory를 제공해서 통합 테스트를 쉽게 만들 수 있어.

// ProductsApiIntegrationTests.cs
public class ProductsApiIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly HttpClient _client;
    
    public ProductsApiIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // 테스트용 인메모리 데이터베이스 사용
                var descriptor = services.SingleOrDefault(
                    d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));
                
                if (descriptor != null)
                {
                    services.Remove(descriptor);
                }
                
                services.AddDbContext<ApplicationDbContext>(options =>
                {
                    options.UseInMemoryDatabase("TestDb");
                });
            });
        });
        
        _client = _factory.CreateClient();
    }
    
    [Fact]
    public async Task GetProducts_ReturnsSuccessStatusCode()
    {
        // Act
        var response = await _client.GetAsync("/api/products");
        
        // Assert
        response.EnsureSuccessStatusCode();
        Assert.Equal("application/json; charset=utf-8", 
            response.Content.Headers.ContentType?.ToString());
    }
    
    [Fact]
    public async Task CreateProduct_ValidData_ReturnsCreatedProduct()
    {
        // Arrange
        var newProduct = new CreateProductDto
        {
            Name = "테스트 상품",
            Price = 15000,
            CategoryId = 1,
            StockQuantity = 10
        };
        
        var content = new StringContent(
            JsonSerializer.Serialize(newProduct),
            Encoding.UTF8,
            "application/json");
        
        // Act
        var response = await _client.PostAsync("/api/products", content);
        
        // Assert
        response.EnsureSuccessStatusCode();
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        
        var responseString = await response.Content.ReadAsStringAsync();
        var createdProduct = JsonSerializer.Deserialize<ProductDto>(
            responseString,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
        
        Assert.NotNull(createdProduct);
        Assert.Equal("테스트 상품", createdProduct.Name);
    }
}

통합 테스트는 실제 HTTP 요청을 보내고 응답을 받는 전체 흐름을 테스트해. 인메모리 데이터베이스를 사용하면 실제 데이터베이스 없이도 테스트할 수 있어서 빠르고 독립적이지! 🎯

🔥 테스트 작성 팁 1. AAA 패턴 사용: Arrange(준비), Act(실행), Assert(검증)
2. 테스트 이름을 명확하게: MethodName_Scenario_ExpectedResult
3. 하나의 테스트는 하나의 것만 검증
4. 테스트는 독립적이어야 함 (순서에 의존하지 않음)
5. Given-When-Then 형식으로 생각하기
6. 경계값을 테스트하기 (0, 음수, 최대값 등)
배포와 DevOps 🚀

개발이 끝나면 이제 배포할 차례야! ASP.NET Core 애플리케이션은 다양한 환경에 배포할 수 있어. Azure, AWS, Docker, Kubernetes 등 선택지가 정말 많지.

Docker를 사용한 컨테이너화는 요즘 거의 표준이 되었어. Dockerfile을 만들어보자:

# Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["MyProject.Web/MyProject.Web.csproj", "MyProject.Web/"]
COPY ["MyProject.Application/MyProject.Application.csproj", "MyProject.Application/"]
COPY ["MyProject.Domain/MyProject.Domain.csproj", "MyProject.Domain/"]
COPY ["MyProject.Infrastructure/MyProject.Infrastructure.csproj", "MyProject.Infrastructure/"]
RUN dotnet restore "MyProject.Web/MyProject.Web.csproj"
COPY . .
WORKDIR "/src/MyProject.Web"
RUN dotnet build "MyProject.Web.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "MyProject.Web.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyProject.Web.dll"]

이 Dockerfile은 멀티 스테이지 빌드를 사용해. SDK 이미지로 빌드하고, 런타임 이미지로 실행하는 거야. 이렇게 하면 최종 이미지 크기가 훨씬 작아져!

docker-compose.yml로 여러 컨테이너를 함께 관리할 수 있어:

# docker-compose.yml
version: '3.8'

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "5000:80"
      - "5001:443"
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
      - ConnectionStrings__DefaultConnection=Server=db;Database=MyProjectDb;User=sa;Password=YourStrong@Passw0rd;
    depends_on:
      - db
      - redis
    networks:
      - myproject-network

  db:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      - ACCEPT_EULA=Y
      - SA_PASSWORD=YourStrong@Passw0rd
    ports:
      - "1433:1433"
    volumes:
      - sqldata:/var/opt/mssql
    networks:
      - myproject-network

  redis:
    image: redis:alpine
    ports:
      - "6379:6379"
    networks:
      - myproject-network

volumes:
  sqldata:

networks:
  myproject-network:
    driver: bridge

이제 docker-compose up 명령어 하나로 웹 애플리케이션, 데이터베이스, Redis를 모두 실행할 수 있어. 정말 편리하지? 😊

CI/CD 파이프라인을 구축하면 코드를 푸시할 때마다 자동으로 빌드, 테스트, 배포가 이루어져. GitHub Actions를 사용한 예제를 볼까?

# .github/workflows/deploy.yml
name: Build and Deploy

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Setup .NET
      uses: actions/setup-dotnet@v3
      with:
        dotnet-version: 8.0.x
    
    - name: Restore dependencies
      run: dotnet restore
    
    - name: Build
      run: dotnet build --no-restore --configuration Release
    
    - name: Test
      run: dotnet test --no-build --configuration Release --verbosity normal
    
    - name: Publish
      run: dotnet publish -c Release -o ./publish
    
    - name: Build Docker image
      run: docker build -t myproject:${{ github.sha }} .
    
    - name: Push to Docker Hub
      run: |
        echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
        docker tag myproject:${{ github.sha }} ${{ secrets.DOCKER_USERNAME }}/myproject:latest
        docker push ${{ secrets.DOCKER_USERNAME }}/myproject:latest
    
    - name: Deploy to Azure
      uses: azure/webapps-deploy@v2
      with:
        app-name: 'my-project-app'
        publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
        package: ./publish

이 워크플로우는 코드가 푸시되면 자동으로 빌드하고, 테스트를 실행하고, Docker 이미지를 만들어서 배포해. 테스트가 실패하면 배포가 중단되니까 안전하지! 🛡️

환경별 설정 관리도 중요해. appsettings.json을 환경별로 나눠서 관리할 수 있어:

// appsettings.json (기본 설정)
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}

// appsettings.Development.json (개발 환경)
{
  "Logging": {
    "LogLevel": {
      "Default": "Debug",
      "Microsoft.AspNetCore": "Information"
    }
  },
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=MyProjectDb_Dev;Trusted_Connection=True;"
  }
}

// appsettings.Production.json (운영 환경)
{
  "Logging": {
    "LogLevel": {
      "Default": "Warning",
      "Microsoft.AspNetCore": "Error"
    }
  },
  "ConnectionStrings": {
    "DefaultConnection": "Server=prod-server;Database=MyProjectDb;User=produser;Password=***;"
  }
}

민감한 정보(비밀번호, API 키 등)는 절대 소스 코드에 넣으면 안 돼! User Secrets(개발), 환경 변수(배포), Azure Key Vault(운영) 같은 안전한 방법을 사용해야 해.

실전 프로젝트 예제: 전자상거래 플랫폼 🛒

지금까지 배운 내용을 모두 활용해서 간단한 전자상거래 플랫폼을 만들어보자. 이 프로젝트는 MVC로 관리자 페이지를, Web API로 모바일 앱 지원을, Blazor로 고객용 쇼핑몰을 구현할 거야.

먼저 도메인 모델부터 설계해보자:

// Product.cs
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int StockQuantity { get; set; }
    public string ImageUrl { get; set; } = string.Empty;
    public int CategoryId { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }
    
    public virtual Category Category { get; set; } = null!;
    public virtual ICollection<OrderItem> OrderItems { get; set; } = new List<OrderItem>();
    public virtual ICollection<Review> Reviews { get; set; } = new List<Review>();
}

// Order.cs
public class Order
{
    public int Id { get; set; }
    public string UserId { get; set; } = string.Empty;
    public DateTime OrderDate { get; set; }
    public OrderStatus Status { get; set; }
    public decimal TotalAmount { get; set; }
    public string ShippingAddress { get; set; } = string.Empty;
    
    public virtual ApplicationUser User { get; set; } = null!;
    public virtual ICollection<OrderItem> OrderItems { get; set; } = new List<OrderItem>();
    public virtual Payment? Payment { get; set; }
}

// OrderItem.cs
public class OrderItem
{
    public int OrderId { get; set; }
    public int ProductId { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
    
    public virtual Order Order { get; set; } = null!;
    public virtual Product Product { get; set; } = null!;
}

public enum OrderStatus
{
    Pending,
    Processing,
    Shipped,
    Delivered,
    Cancelled
}

이제 Blazor로 쇼핑 카트 기능을 구현해보자:

// CartService.cs
public class CartService
{
    private List<CartItem> _items = new();
    
    public event Action? OnChange;
    
    public IReadOnlyList<CartItem> Items => _items.AsReadOnly();
    
    public int ItemCount => _items.Sum(i => i.Quantity);
    
    public decimal TotalPrice => _items.Sum(i => i.Product.Price * i.Quantity);
    
    public void AddToCart(Product product, int quantity = 1)
    {
        var existingItem = _items.FirstOrDefault(i => i.Product.Id == product.Id);
        
        if (existingItem != null)
        {
            existingItem.Quantity += quantity;
        }
        else
        {
            _items.Add(new CartItem
            {
                Product = product,
                Quantity = quantity
            });
        }
        
        NotifyStateChanged();
    }
    
    public void RemoveFromCart(int productId)
    {
        var item = _items.FirstOrDefault(i => i.Product.Id == productId);
        if (item != null)
        {
            _items.Remove(item);
            NotifyStateChanged();
        }
    }
    
    public void UpdateQuantity(int productId, int quantity)
    {
        var item = _items.FirstOrDefault(i => i.Product.Id == productId);
        if (item != null)
        {
            if (quantity <= 0)
            {
                RemoveFromCart(productId);
            }
            else
            {
                item.Quantity = quantity;
                NotifyStateChanged();
            }
        }
    }
    
    public void Clear()
    {
        _items.Clear();
        NotifyStateChanged();
    }
    
    private void NotifyStateChanged() => OnChange?.Invoke();
}

public class CartItem
{
    public Product Product { get; set; } = null!;
    public int Quantity { get; set; }
}

장바구니 UI 컴포넌트:

@* Cart.razor *@
@page "/cart"
@inject CartService CartService
@inject NavigationManager Navigation
@implements IDisposable

<h2>장바구니</h2>

@if (!CartService.Items.Any())
{
    <div class="alert alert-info">
        장바구니가 비어있습니다.
    </div>
    <button class="btn btn-primary" @onclick="GoToProducts">쇼핑 계속하기</button>
}
else
{
    <div class="row">
        <div class="col-md-8">
            @foreach (var item in CartService.Items)
            {
                <div class="card mb-3">
                    <div class="card-body">
                        <div class="row align-items-center">
                            <div class="col-md-2">
                                <img src="@item.Product.ImageUrl" class="img-fluid" alt="@item.Product.Name" />
                            </div>
                            <div class="col-md-4">
                                <h5>@item.Product.Name</h5>
                                <p class="text-muted">@item.Product.Price.ToString("C")</p>
                            </div>
                            <div class="col-md-3">
                                <div class="input-group">
                                    <button class="btn btn-outline-secondary" 
                                            @onclick="() => UpdateQuantity(item.Product.Id, item.Quantity - 1)">
                                        -
                                    </button>
                                    <input type="number" 
                                           class="form-control text-center" 
                                           value="@item.Quantity"
                                           @onchange="(e) => UpdateQuantity(item.Product.Id, int.Parse(e.Value?.ToString() ?? "1"))"
                                           min="1" />
                                    <button class="btn btn-outline-secondary" 
                                            @onclick="() => UpdateQuantity(item.Product.Id, item.Quantity + 1)">
                                        +
                                    </button>
                                </div>
                            </div>
                            <div class="col-md-2">
                                <strong>@((item.Product.Price * item.Quantity).ToString("C"))</strong>
                            </div>
                            <div class="col-md-1">
                                <button class="btn btn-danger btn-sm" 
                                        @onclick="() => RemoveItem(item.Product.Id)">
                                    삭제
                                </button>
                            </div>
                        </div>
                    </div>
                </div>
            }
        </div>
        
        <div class="col-md-4">
            <div class="card">
                <div class="card-body">
                    <h4>주문 요약</h4>
                    <hr />
                    <div class="d-flex justify-content-between mb-2">
                        <span>상품 수:</span>
                        <strong>@CartService.ItemCount개</strong>
                    </div>
                    <div class="d-flex justify-content-between mb-3">
                        <span>총액:</span>
                        <strong class="text-primary fs-4">@CartService.TotalPrice.ToString("C")</strong>
                    </div>
                    <button class="btn btn-success w-100 mb-2" @onclick="Checkout">
                        주문하기
                    </button>
                    <button class="btn btn-outline-secondary w-100" @onclick="GoToProducts">
                        쇼핑 계속하기
                    </button>
                </div>
            </div>
        </div>
    </div>
}

@code {
    protected override void OnInitialized()
    {
        CartService.OnChange += StateHasChanged;
    }
    
    private void UpdateQuantity(int productId, int quantity)
    {
        CartService.UpdateQuantity(productId, quantity);
    }
    
    private void RemoveItem(int productId)
    {
        CartService.RemoveFromCart(productId);
    }
    
    private void GoToProducts()
    {
        Navigation.NavigateTo("/products");
    }
    
    private void Checkout()
    {
        Navigation.NavigateTo("/checkout");
    }
    
    public void Dispose()
    {
        CartService.OnChange -= StateHasChanged;
    }
}

이 예제는 실제 전자상거래 사이트의 핵심 기능을 보여줘. 상태 관리, 이벤트 처리, 네비게이션 등 Blazor의 주요 기능들이 모두 들어있지! 🛒

✨ 프로젝트 확장 아이디어 1. 결제 시스템 통합 (Stripe, PayPal)
2. 상품 검색 및 필터링 (Elasticsearch)
3. 추천 시스템 (머신러닝)
4. 리뷰 및 평점 시스템
5. 위시리스트 기능
6. 쿠폰 및 할인 시스템
7. 주문 추적 기능
8. 관리자 대시보드 (매출 통계, 재고 관리)
마무리하며 🎓

와, 정말 긴 여정이었어! ASP.NET Core의 MVC, Web API, Blazor를 모두 살펴봤고, Entity Framework Core로 데이터를 다루고, SignalR로 실시간 기능을 구현하고, 보안, 테스트, 배포까지 다뤘어. 😊

이 모든 기술을 한 번에 마스터하려고 하면 부담스러울 수 있어. 하지만 걱정하지 마! 하나씩 차근차근 배워가면 돼. 작은 프로젝트부터 시작해서 점점 복잡한 기능을 추가해가는 거야.

재능넷 같은 플랫폼에서 다른 개발자들과 지식을 공유하고, 프로젝트를 함께 진행하면서 실력을 키워나가는 것도 좋은 방법이야. 혼자 공부하는 것보다 함께 성장하는 게 훨씬 재미있고 효과적이거든! 👥

ASP.NET Core는 계속 발전하고 있어. 매년 새로운 버전이 나오면서 성능이 개선되고 새로운 기능이 추가되지. 최신 트렌드를 따라가려면 공식 문서를 자주 확인하고, 커뮤니티에 참여하는 게 중요해.

💡 학습 로드맵 제안 1단계: C# 기초 문법 마스터
2단계: ASP.NET Core MVC로 간단한 CRUD 애플리케이션 만들기
3단계: Entity Framework Core로 데이터베이스 연동
4단계: Web API 구축 및 RESTful 원칙 이해
5단계: Blazor로 인터랙티브 UI 구현
6단계: 인증/권한 부여 시스템 구축
7단계: SignalR로 실시간 기능 추가
8단계: 테스트 작성 및 CI/CD 파이프라인 구축
9단계: 실제 프로젝트 배포 및 운영

마지막으로, 개발은 끊임없는 학습의 연속이야. 새로운 기술이 나오면 두려워하지 말고 도전해봐. 실패해도 괜찮아. 그 과정에서 배우는 게 가장 중요하니까! 💪

이 글이 ASP.NET Core로 풀스택 개발을 시작하는 데 도움이 되었으면 좋겠어. 궁금한 점이 있거나 더 깊이 있는 내용을 알고 싶다면 재능넷에서 다른 개발자들과 소통해봐. 함께 성장하는 즐거움을 느낄 수 있을 거야! 🚀

행복한 코딩 되길 바라! Happy Coding! 🎉

MVC Web API Blazor ASP.NET Core 풀스택 여정
댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.