재우니 개발자 블로그

1. Clean Architecture의 핵심 원칙 (Dependency Rule)

 

 

첨부 문서에서 설명한 의존성 규칙을 더 구체적으로 설명하겠습니다.

핵심 규칙: "안쪽 계층은 바깥쪽 계층을 절대 알아서는 안 된다"

Domain (가장 안쪽)
  ↑
Application (Domain만 의존)
  ↑
Infrastructure (Application + Domain 의존)
  ↑
WebApi/Presentation (모든 계층 의존 가능)

 

 

왜 이렇게 해야 하나요?

  • Domain 계층은 비즈니스 규칙만 담당하므로, 데이터베이스나 웹 프레임워크가 바뀌어도 영향을 받지 않습니다
  • 테스트 용이성: 각 계층을 독립적으로 테스트할 수 있습니다
  • 유지보수성: 한 계층의 변경이 다른 계층에 영향을 최소화합니다

2. Mediator 패턴 vs MediatR 라이브러리

 

첨부 문서에서 사용한 martinothamar/MediatorMediatR의 고성능 대안입니다.

 

 

주요 차이점:

특징 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;
    }
}

 


 

 

 

3. CQRS 패턴 심화 이해

 

첨부 문서에서 설명한 CQRS를 더 구체적으로 보겠습니다.

(1) Command (명령) - 상태 변경

// 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;
    }
}

 

 

(2) Query (조회) - 데이터 읽기

// 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의 장점:

  • 성능 최적화: 읽기와 쓰기를 독립적으로 최적화 가능
  • 확장성: 읽기 전용 DB를 별도로 구성 가능
  • 명확성: 코드의 의도가 명확하게 드러남

 


 

 

4. 실제 동작 흐름 (시퀀스 다이어그램)

첨부 문서의 흐름을 더 상세하게 표현하겠습니다.

사용자 → [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}]

 


 

 

5. 각 계층의 책임과 예제 코드

(1) Domain 계층

// 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 같은 기술적 라이브러리가 없음

 


 

 

(2) Application 계층

// 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;
    }
}

 


 

 

(3) Infrastructure 계층

// 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;
    }
}

 


 

(4) WebApi 계층

// 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();

 


🎯 핵심 포인트 정리

1. 왜 이렇게 복잡하게 나누나요?

단순한 방식 (레거시):

// 모든 로직이 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 접근만
// 각각의 책임이 명확하고 테스트하기 쉬움

 

 

2. 실무 적용 팁

(1) 작은 프로젝트에서는?

  • Domain, Application을 합쳐도 됩니다 (Core 계층으로 통합)
  • 하지만 의존성 방향 규칙은 반드시 지켜야 합니다

(2) 성능 고려사항

// ❌ 나쁜 예: 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();
}

 

 

(3) 예외 처리

// 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