2026년 .NET Clean Architecture: CQRS, MediatR, 개발자 면접 질문 완벽 가이드
.NET에서 Clean Architecture 구현 방법을 상세히 설명합니다. CQRS, MediatR 14, 파이프라인 동작의 실용적인 코드 예제와 시니어 개발자 면접에서 자주 출제되는 아키텍처 설계 질문에 대한 답변 전략을 다룹니다.

.NET의 Clean Architecture는 비즈니스 로직을 인프라스트럭처 관심사로부터 분리하여 테스트 용이성, 유지보수성, 확장성을 향상시키는 설계 방법론입니다. CQRS(Command Query Responsibility Segregation)와 MediatR을 결합하면 엔터프라이즈 애플리케이션 구축을 위한 체계적인 접근 방식을 구현할 수 있습니다. 이 아키텍처 패턴은 시니어 .NET 개발자 면접에서 빈번하게 평가되는 핵심 역량입니다.
Clean Architecture 관련 질문은 시니어 .NET 면접의 70% 이상에서 출제됩니다. 면접관은 의존성 규칙에 대한 설명을 기대합니다: 의존성은 안쪽으로 향하며, 도메인 레이어는 외부 프레임워크에 대한 의존성을 갖지 않습니다.
Clean Architecture 레이어와 의존성 규칙
ASP.NET Core 10용 Ardalis Clean Architecture 템플릿은 코드를 4개의 동심원 레이어로 구성합니다. 각 레이어는 특정 책임과 엄격한 의존성 규칙을 가집니다.
// Core/Domain Layer - No external dependencies
// src/Core/Domain/Entities/Order.cs
namespace Core.Domain.Entities;
public class Order
{
public Guid Id { get; private set; }
public string CustomerEmail { get; private set; }
public List<OrderLine> Lines { get; private set; } = new();
public OrderStatus Status { get; private set; }
public DateTime CreatedAt { get; private set; }
// Domain logic encapsulated in the entity
public void AddLine(Product product, int quantity)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot modify a submitted order");
var existingLine = Lines.FirstOrDefault(l => l.ProductId == product.Id);
if (existingLine is not null)
{
existingLine.IncreaseQuantity(quantity);
return;
}
Lines.Add(new OrderLine(product.Id, product.Price, quantity));
}
public void Submit()
{
if (!Lines.Any())
throw new InvalidOperationException("Cannot submit an empty order");
Status = OrderStatus.Submitted;
}
}도메인 레이어에는 엔티티, 값 객체, 도메인 서비스가 포함됩니다. 데이터베이스, HTTP, 외부 프레임워크에 대해서는 전혀 알지 못합니다. 이 설계를 통해 비즈니스 규칙의 순수성이 유지되고 단위 테스트가 용이해집니다.
CQRS 패턴: 읽기와 쓰기의 분리
CQRS는 작업을 Command(상태를 변경하는 쓰기 작업)와 Query(데이터를 반환하는 읽기 작업)로 분리합니다. 이 분리를 통해 각 경로의 독립적인 확장과 최적화가 가능해집니다.
// UseCases Layer - Commands and Queries
// src/UseCases/Orders/Commands/CreateOrder/CreateOrderCommand.cs
namespace UseCases.Orders.Commands.CreateOrder;
public record CreateOrderCommand(
string CustomerEmail,
List<OrderLineDto> Lines
) : IRequest<Result<Guid>>;
public record OrderLineDto(Guid ProductId, int Quantity);Command는 최소한의 데이터를 반환합니다. 일반적으로 성공 표시자 또는 새로 생성된 ID만 반환합니다. Query는 소비자에게 최적화된 DTO를 반환합니다.
namespace UseCases.Orders.Queries.GetOrderById;
public record GetOrderByIdQuery(Guid OrderId) : IRequest<Result<OrderDetailsDto>>;
public record OrderDetailsDto(
Guid Id,
string CustomerEmail,
string Status,
decimal TotalAmount,
List<OrderLineDetailsDto> Lines
);읽기 모델과 쓰기 모델을 분리함으로써 각각의 요구사항에 최적화된 설계가 가능합니다. 예를 들어, 읽기 측에서는 비정규화된 뷰를 사용하고, 쓰기 측에서는 정규화된 도메인 모델을 유지할 수 있습니다.
MediatR 14: 파이프라인 동작과 핸들러 구현
MediatR 14.2는 CQRS를 위한 메시징 인프라를 제공합니다. 각 Command 또는 Query에는 정확히 하나의 핸들러가 있어 단일 책임 원칙을 강제합니다.
namespace UseCases.Orders.Commands.CreateOrder;
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Result<Guid>>
{
private readonly IOrderRepository _orderRepository;
private readonly IProductRepository _productRepository;
private readonly IUnitOfWork _unitOfWork;
public CreateOrderHandler(
IOrderRepository orderRepository,
IProductRepository productRepository,
IUnitOfWork unitOfWork)
{
_orderRepository = orderRepository;
_productRepository = productRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<Guid>> Handle(
CreateOrderCommand request,
CancellationToken cancellationToken)
{
// Load products to validate and get prices
var productIds = request.Lines.Select(l => l.ProductId).ToList();
var products = await _productRepository
.GetByIdsAsync(productIds, cancellationToken);
if (products.Count != productIds.Count)
return Result.NotFound("One or more products not found");
// Create order using domain logic
var order = new Order(request.CustomerEmail);
foreach (var line in request.Lines)
{
var product = products.First(p => p.Id == line.ProductId);
order.AddLine(product, line.Quantity);
}
await _orderRepository.AddAsync(order, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(order.Id);
}
}핸들러는 리포지토리 인터페이스를 통해 데이터에 접근하고, 도메인 로직을 호출하여 작업을 수행합니다. Unit of Work 패턴을 통해 여러 리포지토리 작업을 하나의 트랜잭션으로 관리할 수 있습니다.
.NET 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
파이프라인 동작을 통한 횡단 관심사 처리
MediatR의 파이프라인 동작은 로깅, 유효성 검사, 캐싱, 트랜잭션 관리를 핸들러를 오염시키지 않고 처리합니다.
namespace UseCases.Common.Behaviors;
public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (!_validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(f => f is not null)
.ToList();
if (failures.Any())
throw new ValidationException(failures);
return await next();
}
}동작은 실행 순서대로 등록됩니다. 일반적으로 유효성 검사가 먼저 실행되고, 그 다음 로깅, 그리고 트랜잭션 처리 순서입니다.
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(CreateOrderCommand).Assembly);
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
cfg.AddOpenBehavior(typeof(TransactionBehavior<,>));
});이 파이프라인 아키텍처를 통해 횡단 관심사를 모듈화하고 필요에 따라 추가하거나 제거할 수 있습니다. 면접에서는 이 설계가 AOP의 대안으로 어떻게 작동하는지 설명할 수 있어야 합니다.
인프라스트럭처 레이어: 리포지토리 구현
인프라스트럭처 레이어는 코어 레이어에서 정의된 인터페이스를 구현합니다. Entity Framework Core 리포지토리는 도메인 작업을 데이터베이스 호출로 변환합니다.
namespace Infrastructure.Data.Repositories;
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _context;
public OrderRepository(AppDbContext context)
{
_context = context;
}
public async Task<Order?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default)
{
return await _context.Orders
.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
}
public async Task AddAsync(
Order order,
CancellationToken cancellationToken = default)
{
await _context.Orders.AddAsync(order, cancellationToken);
}
public async Task<IReadOnlyList<Order>> GetByCustomerEmailAsync(
string email,
CancellationToken cancellationToken = default)
{
return await _context.Orders
.Where(o => o.CustomerEmail == email)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync(cancellationToken);
}
}리포지토리 패턴을 통해 데이터 접근 로직이 캡슐화되고, 도메인 레이어는 데이터 영속화 방법을 알 필요가 없습니다. 테스트 시 모의 리포지토리를 주입하여 데이터베이스 없이 비즈니스 로직을 테스트할 수 있습니다.
면접 질문: 시니어 개발자가 알아야 할 것들
시니어 .NET 포지션 기술 면접에서는 Clean Architecture에 관한 질문이 자주 포함됩니다. 다음은 면접관이 평가하는 패턴입니다.
Q: 도메인 레이어가 외부 프레임워크에 의존하면 안 되는 이유는 무엇입니까?
도메인 레이어에는 인프라스트럭처 변경과 관계없이 안정적이어야 하는 비즈니스 규칙이 포함됩니다. 도메인이 Entity Framework에 의존하면, Dapper나 다른 데이터베이스로 전환할 때 비즈니스 로직을 변경해야 합니다. 의존성 규칙을 통해 인프라스트럭처는 코어 동작에 영향을 주지 않고 교체할 수 있는 세부사항이 됩니다.
Q: CQRS가 과잉인 경우는 언제입니까?
CQRS는 단순한 CRUD 애플리케이션에는 불필요한 복잡성을 추가합니다. 읽기 모델과 쓰기 모델이 거의 동일하고, 별도의 확장이 필요 없으며, 팀이 소규모인 경우 더 단순한 아키텍처가 적절합니다. CQRS는 읽기 모델이 쓰기 모델과 크게 다른 경우, 이벤트 소싱이 필요한 경우, 또는 읽기와 쓰기 부하를 독립적으로 확장해야 하는 경우에 효과를 발휘합니다.
Q: 여러 집계에 걸친 트랜잭션은 어떻게 처리합니까?
// Transaction behavior wraps the entire handler execution
public class TransactionBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IUnitOfWork _unitOfWork;
public TransactionBehavior(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
// Commands that modify multiple aggregates use explicit transactions
if (request is not ICommand)
return await next();
await using var transaction = await _unitOfWork
.BeginTransactionAsync(cancellationToken);
try
{
var response = await next();
await transaction.CommitAsync(cancellationToken);
return response;
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
}여러 집계에 걸친 작업에서는 가능한 한 도메인 이벤트를 사용한 최종 일관성을 채택합니다. 집계 간 동기 트랜잭션은 설계상의 문제를 시사할 수 있습니다: 해당 집계들은 하나로 통합해야 할 수도 있습니다.
읽기 측 최적화: 분리된 쿼리 모델
성능이 중요한 경우 Query는 도메인 레이어를 완전히 우회합니다. Dapper나 원시 SQL을 사용한 직접 데이터베이스 접근으로 전체 엔티티 그래프 구체화에 따른 오버헤드를 회피할 수 있습니다.
namespace Infrastructure.Queries;
public class GetOrderByIdQueryHandler
: IRequestHandler<GetOrderByIdQuery, Result<OrderDetailsDto>>
{
private readonly IDbConnection _connection;
public GetOrderByIdQueryHandler(IDbConnection connection)
{
_connection = connection;
}
public async Task<Result<OrderDetailsDto>> Handle(
GetOrderByIdQuery request,
CancellationToken cancellationToken)
{
const string sql = """
SELECT o.Id, o.CustomerEmail, o.Status, o.CreatedAt,
SUM(ol.UnitPrice * ol.Quantity) AS TotalAmount
FROM Orders o
LEFT JOIN OrderLines ol ON ol.OrderId = o.Id
WHERE o.Id = @OrderId
GROUP BY o.Id, o.CustomerEmail, o.Status, o.CreatedAt
""";
var order = await _connection.QueryFirstOrDefaultAsync<OrderDetailsDto>(
new CommandDefinition(sql, new { request.OrderId }, cancellationToken: cancellationToken));
return order is null
? Result.NotFound($"Order {request.OrderId} not found")
: Result.Success(order);
}
}이 접근 방식을 통해 읽기 작업은 최대 성능을 달성하고, 쓰기 작업은 도메인 무결성을 유지할 수 있습니다. 면접에서는 이 설계의 이유와 트레이드오프를 설명할 수 있어야 합니다.
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
Clean Architecture 면접 준비: 핵심 요약
- 의존성 규칙은 소스 코드 의존성이 안쪽으로만 향해야 함을 규정합니다. 도메인 레이어는 데이터베이스, 프레임워크, 전달 메커니즘에 대한 지식을 갖지 않습니다.
- CQRS는 Command(상태 변경)와 Query(데이터 조회)를 분리합니다. Command는 도메인 모델과 유효성 검사를 거칩니다. Query는 성능을 위해 도메인 엔티티를 우회할 수 있습니다.
- MediatR 14는 요청을 핸들러로 라우팅합니다. 파이프라인 동작은 유효성 검사, 로깅, 트랜잭션과 같은 횡단 관심사를 조합 가능한 방식으로 처리합니다.
- 리포지토리 인터페이스는 코어 레이어에 위치합니다. 구현은 인프라스트럭처 레이어에 위치합니다. 이 역전을 통해 비즈니스 로직을 건드리지 않고 EF Core를 Dapper로 교체할 수 있습니다.
- 면접 답변에는 트레이드오프를 포함해야 합니다. Clean Architecture는 초기 복잡성을 추가하지만, 여러 개발자가 참여하는 대규모 장기 운영 애플리케이션에서는 그 가치를 발휘합니다.
- SharpSkill의 ASP.NET Core Clean Architecture 모듈에서는 Specification, Result 객체, 도메인 이벤트 등의 추가 패턴을 다룹니다.
.NET 코드의 버그를 찾을 수 있나요
실제 코드 한 조각, 숨은 버그 하나, 하루 한 번. 계정 없이 바로 도전할 수 있습니다.

작성자
Anthony Fillion-MailletSharpSkill 창업자
10년 이상 풀스택 개발을 해왔습니다. SharpSkill을 운영하며 이곳에 게시되는 모든 내용에 책임을 집니다.
2026년 9월 15일 업데이트
공유
관련 기사

ASP.NET Core에서 DbContext 수명 관리: 비동기 작업에서의 성능과 스레드 안전성
ASP.NET Core에서 DbContext 수명 관리를 마스터합니다. 범위 지정 수명과 트랜지언트 수명의 사용 시기, 비동기 작업에서 스레드 안전성을 보장하는 방법, DbContext 풀링으로 성능을 최적화하는 방법을 학습합니다.

C# 클린 아키텍처 완벽 가이드: 면접 대비 및 구현 패턴 2026
C#에서 클린 아키텍처의 설계 원칙, SOLID 원칙의 실무 적용 방법, 기술 면접에서 자주 출제되는 질문과 답변을 상세히 다룹니다.

2026년 C# LINQ 고급 가이드: 연산자, 성능 최적화, 면접 대비
고급 LINQ 연산자, 지연 실행, 성능 최적화를 상세히 다룹니다. GroupBy, SelectMany, 쿼리 최적화 및 .NET 개발자를 위한 면접 질문을 포함합니다.