첨부 문서에서 설명한 의존성 규칙을 더 구체적으로 설명하겠습니다.
핵심 규칙: "안쪽 계층은 바깥쪽 계층을 절대 알아서는 안 된다"
Domain (가장 안쪽)
  ↑
Application (Domain만 의존)
  ↑
Infrastructure (Application + Domain 의존)
  ↑
WebApi/Presentation (모든 계층 의존 가능)
왜 이렇게 해야 하나요?
첨부 문서에서 사용한 martinothamar/Mediator는 MediatR의 고성능 대안입니다.
주요 차이점:
| 특징 | MediatR | martinothamar/Mediator | 
|---|---|---|
| 구현 방식 | 런타임 리플렉션 | Source Generators (컴파일 타임) | 
| 성능 | 일반적인 성능 | 매우 빠름 (리플렉션 오버헤드 없음) | 
| .NET 버전 | .NET Standard 2.0+ | .NET 6+ (최신 기능 활용) | 
Mediator 패턴의 장점:
// Mediator 없이 (결합도 높음)
public class TodoController
{
    private readonly TodoService _service;
    private readonly EmailService _emailService;
    private readonly LogService _logService;
    // 생성자가 비대해집니다 (Fat Constructor)
    public TodoController(TodoService service, EmailService email, LogService log)
    {
        _service = service;
        _emailService = email;
        _logService = log;
    }
}
// Mediator 사용 (결합도 낮음)
public class TodoController
{
    private readonly ISender _mediator; // 단 하나의 의존성!
    public TodoController(ISender mediator)
    {
        _mediator = mediator;
    }
}
첨부 문서에서 설명한 CQRS를 더 구체적으로 보겠습니다.
// Command: 데이터를 변경하고 결과(Id)를 반환
public sealed record CreateTodoItemCommand(string Title) : IRequest<Guid>;
// Command Handler: 실제 비즈니스 로직 실행
internal sealed class CreateTodoItemCommandHandler 
    : IRequestHandler<CreateTodoItemCommand, Guid>
{
    private readonly ITodoItemRepository _repository;
    public CreateTodoItemCommandHandler(ITodoItemRepository repository)
    {
        _repository = repository;
    }
    public async Task<Guid> Handle(
        CreateTodoItemCommand request, 
        CancellationToken cancellationToken)
    {
        // 1. 비즈니스 규칙 검증
        if (string.IsNullOrWhiteSpace(request.Title))
            throw new ArgumentException("제목은 필수입니다");
        // 2. Domain 엔티티 생성
        var todoItem = new TodoItem
        {
            Id = Guid.NewGuid(),
            Title = request.Title,
            IsDone = false
        };
        // 3. Repository를 통해 저장
        await _repository.AddAsync(todoItem, cancellationToken);
        // 4. 생성된 ID 반환
        return todoItem.Id;
    }
}
// Query: 데이터를 조회하고 DTO 반환
public sealed record GetTodoItemQuery(Guid Id) : IRequest<TodoItemDto>;
// Query Handler: 데이터 조회 및 변환
internal sealed class GetTodoItemQueryHandler 
    : IRequestHandler<GetTodoItemQuery, TodoItemDto>
{
    private readonly ITodoItemRepository _repository;
    public GetTodoItemQueryHandler(ITodoItemRepository repository)
    {
        _repository = repository;
    }
    public async Task<TodoItemDto> Handle(
        GetTodoItemQuery request, 
        CancellationToken cancellationToken)
    {
        // 1. Repository에서 데이터 조회
        var todoItem = await _repository.GetByIdAsync(
            request.Id, 
            cancellationToken);
        if (todoItem is null)
            throw new NotFoundException("할 일을 찾을 수 없습니다");
        // 2. Domain 엔티티를 DTO로 변환
        return new TodoItemDto
        {
            Id = todoItem.Id,
            Title = todoItem.Title,
            IsDone = todoItem.IsDone
        };
    }
}
CQRS의 장점:
첨부 문서의 흐름을 더 상세하게 표현하겠습니다.
사용자 → [POST /api/todo-items { "title": "장보기" }]
   ↓
[TodoItemsController]
   ↓ (1) HTTP Request → Command 변환
var command = new CreateTodoItemCommand("장보기");
   ↓
   ↓ (2) Mediator에 전송
await mediator.Send(command);
   ↓
[Mediator 라이브러리]
   ↓ (3) 적절한 Handler 자동 탐색 (Source Generator로 컴파일 타임에 생성됨)
   ↓
[CreateTodoItemCommandHandler]
   ↓ (4) 비즈니스 로직 실행
var todoItem = new TodoItem { Id = Guid.NewGuid(), Title = "장보기" };
   ↓
   ↓ (5) Repository 인터페이스 호출
await _repository.AddAsync(todoItem);
   ↓
[Infrastructure - TodoItemRepository]
   ↓ (6) 실제 DB 저장 (Entity Framework Core)
await _context.TodoItems.AddAsync(todoItem);
await _context.SaveChangesAsync();
   ↓
   ↓ (7) 생성된 ID 반환
return todoItem.Id;
   ↓
[TodoItemsController]
   ↓ (8) HTTP 201 Created 응답
return CreatedAtAction(nameof(GetTodoItem), new { id = todoItemId });
   ↓
사용자 ← [201 Created, Location: /api/todo-items/{guid}]
// Domain/Entities/TodoItem.cs
namespace Domain.Entities;
/// <summary>
/// 할 일 엔티티 (순수한 비즈니스 모델)
/// </summary>
public sealed class TodoItem
{
    public Guid Id { get; set; }
    public required string Title { get; set; }
    public bool IsDone { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    // 비즈니스 규칙 메서드
    public void MarkAsCompleted()
    {
        if (IsDone)
            throw new InvalidOperationException("이미 완료된 할 일입니다");
        IsDone = true;
    }
}
특징:
using 문에 Microsoft.EntityFrameworkCore 같은 기술적 라이브러리가 없음
// Application/Common/Interfaces/ITodoItemRepository.cs
namespace Application.Common.Interfaces;
/// <summary>
/// Repository 인터페이스 (구현은 Infrastructure에서)
/// </summary>
public interface ITodoItemRepository
{
    Task<TodoItem?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
    Task AddAsync(TodoItem todoItem, CancellationToken cancellationToken);
    Task UpdateAsync(TodoItem todoItem, CancellationToken cancellationToken);
    Task DeleteAsync(Guid id, CancellationToken cancellationToken);
}
// Application/TodoItems/Commands/CreateTodoItem/CreateTodoItemCommand.cs
using Mediator;
namespace Application.TodoItems.Commands.CreateTodoItem;
/// <summary>
/// 할 일 생성 명령
/// </summary>
public sealed record CreateTodoItemCommand(string Title) : IRequest<Guid>;
Application 계층의 DI 설정:
// Application/DependencyInjection.cs
using Microsoft.Extensions.DependencyInjection;
using Mediator;
namespace Application;
public static class DependencyInjection
{
    public static IServiceCollection AddApplication(
        this IServiceCollection services)
    {
        // Mediator 라이브러리 등록
        services.AddMediator(options =>
        {
            options.ServiceLifetime = ServiceLifetime.Scoped;
        });
        return services;
    }
}
// Infrastructure/Persistence/AppDbContext.cs
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace Infrastructure.Persistence;
public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) 
        : base(options)
    {
    }
    public DbSet<TodoItem> TodoItems => Set<TodoItem>();
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Entity 설정
        modelBuilder.Entity<TodoItem>(entity =>
        {
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Title)
                .IsRequired()
                .HasMaxLength(200);
        });
    }
}
// Infrastructure/Persistence/TodoItemRepository.cs
using Application.Common.Interfaces;
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace Infrastructure.Persistence;
/// <summary>
/// ITodoItemRepository 인터페이스의 실제 구현
/// </summary>
public class TodoItemRepository : ITodoItemRepository
{
    private readonly AppDbContext _context;
    public TodoItemRepository(AppDbContext context)
    {
        _context = context;
    }
    public async Task<TodoItem?> GetByIdAsync(
        Guid id, 
        CancellationToken cancellationToken)
    {
        return await _context.TodoItems
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
    }
    public async Task AddAsync(
        TodoItem todoItem, 
        CancellationToken cancellationToken)
    {
        await _context.TodoItems.AddAsync(todoItem, cancellationToken);
        await _context.SaveChangesAsync(cancellationToken);
    }
    public async Task UpdateAsync(
        TodoItem todoItem, 
        CancellationToken cancellationToken)
    {
        _context.TodoItems.Update(todoItem);
        await _context.SaveChangesAsync(cancellationToken);
    }
    public async Task DeleteAsync(
        Guid id, 
        CancellationToken cancellationToken)
    {
        var todoItem = await GetByIdAsync(id, cancellationToken);
        if (todoItem is not null)
        {
            _context.TodoItems.Remove(todoItem);
            await _context.SaveChangesAsync(cancellationToken);
        }
    }
}
Infrastructure DI 설정:
// Infrastructure/DependencyInjection.cs
using Application.Common.Interfaces;
using Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Infrastructure;
public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // DbContext 등록
        services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(
                configuration.GetConnectionString("DefaultConnection")));
        // Repository 등록
        services.AddScoped<ITodoItemRepository, TodoItemRepository>();
        return services;
    }
}
// WebApi/Controllers/TodoItemsController.cs
using Application.TodoItems.Commands.CreateTodoItem;
using Application.TodoItems.Queries.GetTodoItem;
using Mediator;
using Microsoft.AspNetCore.Mvc;
namespace WebApi.Controllers;
[ApiController]
[Route("api/todo-items")]
public class TodoItemsController : ControllerBase
{
    private readonly ISender _mediator;
    public TodoItemsController(ISender mediator)
    {
        _mediator = mediator;
    }
    /// <summary>
    /// 새로운 할 일 생성
    /// </summary>
    /// <param name="request">생성할 할 일 정보</param>
    /// <returns>생성된 할 일의 ID</returns>
    [HttpPost]
    [ProducesResponseType(StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> CreateTodoItem(
        [FromBody] CreateTodoItemRequest request)
    {
        // 1. API 요청을 Command로 변환
        var command = new CreateTodoItemCommand(request.Title);
        // 2. Mediator에 전달 (Handler가 자동 실행됨)
        var todoItemId = await _mediator.Send(command);
        // 3. 201 Created 응답 반환
        return CreatedAtAction(
            nameof(GetTodoItem), 
            new { id = todoItemId }, 
            new { id = todoItemId });
    }
    /// <summary>
    /// 할 일 상세 조회
    /// </summary>
    /// <param name="id">할 일 ID</param>
    /// <returns>할 일 상세 정보</returns>
    [HttpGet("{id:guid}")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetTodoItem(Guid id)
    {
        // 1. Query 생성
        var query = new GetTodoItemQuery(id);
        // 2. Mediator에 전달
        var todoItem = await _mediator.Send(query);
        // 3. 200 OK 응답 반환
        return Ok(todoItem);
    }
}
// API Request DTO
public record CreateTodoItemRequest(string Title);
Program.cs 설정:
// WebApi/Program.cs
using Application;
using Infrastructure;
var builder = WebApplication.CreateBuilder(args);
// 계층별 서비스 등록
builder.Services.AddControllers();
builder.Services.AddApplication();  // Application 계층
builder.Services.AddInfrastructure(builder.Configuration);  // Infrastructure 계층
// Swagger 설정
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Middleware 설정
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
단순한 방식 (레거시):
// 모든 로직이 Controller에 섞여 있음
public class TodoController : ControllerBase
{
    private readonly SqlConnection _connection;
    [HttpPost]
    public IActionResult Create(string title)
    {
        // DB 연결, 비즈니스 로직, 예외 처리가 모두 섞여 있음
        var sql = $"INSERT INTO Todos (Title) VALUES ('{title}')"; // SQL Injection 위험!
        _connection.Execute(sql);
        return Ok();
    }
}
Clean Architecture 방식:
// Controller: 단순히 요청 전달만
// Handler: 비즈니스 로직만
// Repository: DB 접근만
// 각각의 책임이 명확하고 테스트하기 쉬움
// ❌ 나쁜 예: N+1 문제
public async Task<List<TodoItemDto>> GetAllTodos()
{
    var todos = await _repository.GetAllAsync();
    foreach (var todo in todos)
    {
        // 각 todo마다 별도 쿼리 실행 (느림!)
        todo.Category = await _categoryRepository.GetByIdAsync(todo.CategoryId);
    }
    return todos;
}
// ✅ 좋은 예: Include 사용
public async Task<List<TodoItemDto>> GetAllTodos()
{
    return await _context.TodoItems
        .Include(t => t.Category)  // 한 번의 쿼리로 조회
        .Select(t => new TodoItemDto
        {
            Id = t.Id,
            Title = t.Title,
            CategoryName = t.Category.Name
        })
        .ToListAsync();
}
// Application/Common/Exceptions/NotFoundException.cs
public class NotFoundException : Exception
{
    public NotFoundException(string message) : base(message)
    {
    }
}
// WebApi에서 전역 예외 처리
public class GlobalExceptionHandler : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext context,
        Exception exception,
        CancellationToken cancellationToken)
    {
        var response = exception switch
        {
            NotFoundException => (StatusCodes.Status404NotFound, "리소스를 찾을 수 없습니다"),
            ValidationException => (StatusCodes.Status400BadRequest, "유효하지 않은 요청입니다"),
            _ => (StatusCodes.Status500InternalServerError, "서버 오류가 발생했습니다")
        };
        context.Response.StatusCode = response.Item1;
        await context.Response.WriteAsJsonAsync(new { error = response.Item2 });
        return true;
    }
}
https://github.com/martinothamar/Mediator/tree/main/samples/apps/ASPNET_Core_CleanArchitecture
Mediator/samples/apps/ASPNET_Core_CleanArchitecture at main · martinothamar/Mediator
A high performance implementation of Mediator pattern in .NET using source generators. - martinothamar/Mediator
github.com
| AutoMapper · MediatR — 상용화(라이선스 변경) 이슈 정리 (0) | 2025.09.26 | 
|---|---|
| ASP.NET Core 8 : AWS SSO 를 활용한 S3 연동 구현하기 (1) | 2025.08.27 | 
| ASP.NET Core MVC : [FromBody], [FromForm], [FromQuery] 사용해 보기 (1) | 2025.08.26 | 
| ASP.NET CORE : 웹 개발을 위해 꼭 알아야하는 보안 대응 (DOMPurify) (3) | 2025.08.19 | 
| ASPNETCORE : .gitignore 파일 (1) | 2025.06.10 |