Repository Pattern vs DbContext, 그리고 EF Core로 Aggregate 경계 지키기
Repository Pattern vs DbContext, 그리고 EF Core로 Aggregate 경계 지키기
질문은 단순했다
EF Core를 쓰는 프로젝트에서 한 번은 꼭 마주치는 질문이 있다. "Repository 패턴을 굳이 끼워야 하나, DbContext를 그대로 쓰면 안 되나?" 처음엔 취향 문제라고 생각했는데, 정리하다 보니 이건 취향이 아니라 이 프로젝트에서 도메인 규칙을 얼마나 강하게 지켜야 하는가에 대한 판단이었다.
DbContext를 직접 쓰는 경우
var todo = await _context.Todos.FirstAsync(x => x.Id == id);
todo.Complete();
await _context.SaveChangesAsync();장점은 명확하다.
- EF Core를 그대로 사용하므로 코드가 짧고 빠르다
- 학습 비용과 개발 속도 면에서 유리하다
단점도 그만큼 명확하다.
- EF Core에 강하게 의존한다
- 도메인 규칙이 여기저기 흩어지기 쉽다
- 테스트에서 mock이 어렵다
- Aggregate 경계가 쉽게 무너진다
context.Todos로 아무 곳에서나 접근할 수 있다는 건, 곧 아무 곳에서나 도메인 규칙을 우회할 수 있다는 뜻이기도 하다.
Repository를 두는 경우
Repository는 EF Core를 숨기고 "의도가 드러나는" API를 제공한다.
// 인터페이스 (Application / Domain)
public interface ITodoRepository
{
Task<Todo> GetById(Guid id);
Task Add(Todo todo);
}
// 구현체 (Infrastructure)
public class TodoRepository : ITodoRepository
{
private readonly AppDbContext _context;
}
// 사용
var todo = await _repo.GetById(id);
todo.Complete();
await _repo.SaveChanges();장점:
- 도메인 보호 —
repo.GetActiveTodos()처럼 의미 있는 메서드로 접근을 제한할 수 있다 - Aggregate 강제 — Root만 반환하도록 제한하고, 자식 엔티티로의 직접 접근을 차단할 수 있다
- 테스트 용이성 — 인터페이스이므로 mock이 쉽다
단점: 코드가 늘어나고, 단순 CRUD에서는 오히려 비효율적이며, EF의 일부 기능(Include, 지연 로딩 등)을 제한적으로 쓰게 된다.
어느 쪽을 써야 하나
기준을 이렇게 정리했다.
DbContext 직접 사용이 맞는 경우
- CRUD 위주의 서비스
- 스타트업, 빠른 개발이 우선인 상황
- 도메인 복잡도가 낮음
- CQRS의 Query 쪽
Repository가 맞는 경우
- 도메인 규칙이 많음
- Aggregate 경계가 중요함
- 여러 명이 함께 개발하며 규칙을 강제할 필요가 있음
- 오래 유지보수해야 하는 프로젝트
실무에서는 둘 중 하나만 고르기보다 혼합형을 가장 많이 쓴다. Command(쓰기)는 Repository를 통하고, Query(읽기)는 DbContext를 직접 쓰는 방식이다.
// Command
var todo = await repo.GetById(id);
todo.Complete();
// Query
var todos = await context.Todos
.Where(x => x.IsDone)
.ToListAsync();한 줄로 정리하면 이렇다. DbContext는 도구이고, Repository는 그 도구를 쓰는 규칙을 강제하는 레이어다.
그런데 Repository만으로는 부족하다
여기까지는 "Repository를 쓸까 말까"의 문제였다. Repository를 쓰기로 했다고 해도, EF Core 설정 자체가 Aggregate 경계를 무너뜨릴 수 있다는 걸 놓치기 쉽다. DbContext에 DbSet<T>을 어떻게 등록하느냐가 실질적인 경계선이기 때문이다.
DbSet에 무엇을 등록할 것인가
DbSet은 "이 엔티티를 EF Core가 추적·쿼리 대상으로 인식한다"는 뜻이다. 외부에서 context.엔티티 형태로 LINQ 접근을 하려면 DbSet이 필요하다.
public class AppDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
}문제는 이걸 자식 엔티티에도 그대로 열어두면 벌어진다.
// Order = Aggregate Root ✔
// OrderItem = 내부 Entity ❌ (DbSet 없음)
public DbSet<Order> Orders;
public DbSet<OrderItem> OrderItems; // ❌ DDD 깨짐OrderItems가 DbSet으로 열려 있으면 context.OrderItems로 Root를 거치지 않고 독립적으로 조회·수정할 수 있게 된다. Aggregate 경계와 트랜잭션 일관성이 그 순간 깨진다. 그래서 원칙은 단순하다.
핵심 Aggregate Root만 DbSet에 등록한다. "이 엔티티를 밖에서 바로 꺼내 쓸 것인가?"가 곧 DbSet 등록 여부의 기준이다.
물론 자식 엔티티에 DbSet을 별도로 두는 경우도 있다 — 읽기 최적화, 자식 테이블에 대한 별도 필터링·페이징이 자주 필요할 때다. 다만 이건 "예외적으로 허용하는 것"이지 기본값은 아니다.
IEntityTypeConfiguration과 DbContext의 역할 차이
세 요소의 역할을 한 문장씩으로 정리하면 헷갈리지 않는다.
Entity는 무엇을 표현하는가 EntityConfiguration은 DB에 어떻게 저장하는가 DbContext는 그 저장 작업을 어디서 관리하는가
public class TodoConfiguration : IEntityTypeConfiguration<Todo>
{
public void Configure(EntityTypeBuilder<Todo> builder)
{
builder.ToTable("Todos");
builder.HasKey(x => x.Id);
}
}IEntityTypeConfiguration<T>은 매핑 규칙을 분리하는 용도일 뿐, "이 엔티티가 DbSet에 등록되는가"와는 별개다. OnModelCreating에서 ApplyConfigurationsFromAssembly로 어셈블리 내 모든 Configuration을 적용하면, DbSet에 없더라도 EF Core는 그 엔티티를 인식한다.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}다만 이 경우 context.Set<T>()로만 접근 가능하고 context.엔티티 프로퍼티로는 접근할 수 없다 — 의도적으로 진입점을 좁혀두는 셈이다.
결론
Repository냐 DbContext냐는 "이 프로젝트가 도메인 규칙을 얼마나 강하게 지켜야 하는가"로 판단하면 되고, 실무에서는 Command는 Repository, Query는 DbContext를 쓰는 혼합형이 흔하다. 그리고 Repository를 쓰기로 했다면 그 경계는 인터페이스 설계만으로 끝나지 않는다. DbSet에 무엇을 등록하느냐가 EF Core 레벨에서 Aggregate 경계를 실제로 강제하는 지점이라는 걸 함께 기억해둘 만하다.