첨부 문서에서 설명한 의존성 규칙을 더 구체적으로 설명하겠습니다.
핵심 규칙: "안쪽 계층은 바깥쪽 계층을 절대 알아서는 안 된다"
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