본문으로 건너뛰기

리포지토리 인터페이스

모든 Repository가 Create, GetById, Update, Delete를 반복 정의해야 할까요? 도메인마다 동일한 CRUD 메서드를 복사-붙여넣기하면 코드 중복이 기하급수적으로 늘어납니다. IRepository<TAggregate, TId>는 이 문제를 해결하는 공통 인터페이스입니다. 제네릭 제약을 통해 Aggregate Root만 Repository의 대상이 되도록 컴파일 타임에 강제하며, 모든 메서드는 FinT<IO, T>를 반환하여 부수 효과와 오류 처리를 합성 가능한 형태로 다룹니다.


이 장을 완료하면 다음을 할 수 있습니다:

  1. IRepository<TAggregate, TId> 인터페이스의 13개 메서드(CRUD 8개 + Specification 5개)를 설명할 수 있습니다.
  2. FinT<IO, T> 반환 타입이 왜 Task<T>보다 합성에 유리한지 설명할 수 있습니다.
  3. 제네릭 제약(AggregateRoot<TId>, IEntityId<TId>)이 잘못된 사용을 어떻게 방지하는지 설명할 수 있습니다.
  4. 도메인 특화 Repository 인터페이스를 직접 정의할 수 있습니다.

Product, Order, Customer 도메인마다 Repository 인터페이스를 따로 정의한다고 생각해 보세요.

// Product Repository
FinT<IO, Product> Create(Product product);
FinT<IO, Product> GetById(ProductId id);
FinT<IO, Product> Update(Product product);
FinT<IO, int> Delete(ProductId id);
// Order Repository - 같은 패턴을 또 반복
FinT<IO, Order> Create(Order order);
FinT<IO, Order> GetById(OrderId id);
FinT<IO, Order> Update(Order order);
FinT<IO, int> Delete(OrderId id);
// Customer Repository - 또 반복...

Aggregate가 늘어날 때마다 동일한 시그니처를 복사합니다. 메서드 하나의 반환 타입이 바뀌면 모든 인터페이스를 수정해야 합니다. IRepository<TAggregate, TId>는 이 공통 패턴을 제네릭으로 추출하여, 한 곳에서 정의하고 모든 도메인이 재사용하게 합니다.

IRepository는 CRUD, Specification 기반 조회, Specification 기반 벌크 연산을 제공합니다. 단건과 복수 버전이 대칭을 이루고, Specification 기반 조회는 Evans의 selectSatisfying 패턴을 따릅니다.

구분메서드반환 타입
단건 생성Create(TAggregate)FinT<IO, TAggregate>
단건 조회GetById(TId)FinT<IO, TAggregate>
단건 수정Update(TAggregate)FinT<IO, TAggregate>
단건 삭제 ⚠️Delete(TId)FinT<IO, int>
복수 생성CreateRange(IReadOnlyList<TAggregate>)FinT<IO, int>
복수 조회GetByIds(IReadOnlyList<TId>)FinT<IO, Seq<TAggregate>>
복수 수정UpdateRange(IReadOnlyList<TAggregate>)FinT<IO, int>
복수 삭제 ⚠️DeleteRange(IReadOnlyList<TId>)FinT<IO, int>
Spec 존재Exists(Specification<TAggregate>)FinT<IO, bool>
Spec 건수Count(Specification<TAggregate>)FinT<IO, int>
Spec 전체 조회FindAllSatisfying(Specification<TAggregate>)FinT<IO, Seq<TAggregate>>
Spec 단건 조회FindFirstSatisfying(Specification<TAggregate>)FinT<IO, Option<TAggregate>>
Spec 삭제 ⚠️DeleteBy(Specification<TAggregate>)FinT<IO, int>

⚠️ Hard delete 메서드는 도메인 이벤트를 발행하지 않습니다. DeletedEvent가 필요한 비즈니스 삭제에는 Soft Delete 패턴을 사용하십시오:

Load (GetById/GetByIds) → aggregate.Delete(...) → Update(aggregate)/UpdateRange(aggregates)

Delete·DeleteRange·DeleteBy는 관리·마이그레이션·테스트 픽스처 정리 용도입니다.

낙관적 동시성: Update는 로드 이후 Aggregate가 변경된 경우 AdapterErrorKind.ConcurrencyConflict를 반환합니다(NotFound와 구분). RowVersion/Timestamp 컬럼 설정은 서브클래스 Model의 책임입니다.

Task<T>가 아니라 FinT<IO, T>를 반환할까요? 다음 구조를 보세요.

FinT<IO, T> = IO<Fin<T>>
= IO<Succ(T) | Fail(Error)>
  • Fin<T>은 성공(Succ) 또는 실패(Fail)를 표현하는 Result 타입입니다. 예외를 던지지 않고 실패를 값으로 다룹니다.
  • IO는 부수 효과(Side Effect)를 추적하는 모나드입니다. DB 접근 같은 부수 효과를 타입으로 명시합니다.
  • FinT는 두 모나드의 합성(Monad Transformer)입니다. 여러 Repository 호출을 | 연산자로 체이닝할 수 있습니다.

다음 제약이 잘못된 Repository 사용을 컴파일 타임에 차단합니다.

public interface IRepository<TAggregate, TId>
where TAggregate : AggregateRoot<TId> // Aggregate Root만 허용
where TId : struct, IEntityId<TId> // 값 타입 ID만 허용
  • AggregateRoot<TId> 제약: Entity나 Value Object를 직접 영속화하려고 하면 컴파일 에러가 발생합니다.
  • IEntityId<TId> 제약: Ulid 기반 식별자를 강제하여 ID 생성 전략을 통일합니다.

IRepository는 IObservablePort를 상속합니다. IObservablePortRequestCategory 속성 하나를 가지며, Observability 파이프라인이 Command/Query를 구분하여 메트릭과 로그를 수집하는 데 사용됩니다. Repository 구현체는 RequestCategory => "Command"를 반환합니다.


01-Repository-Interface/
├── RepositoryInterface/
│ ├── RepositoryInterface.csproj
│ ├── Program.cs # 콘솔 데모
│ ├── ProductId.cs # Ulid 기반 식별자
│ ├── Product.cs # Aggregate Root
│ └── IProductRepository.cs # 도메인 특화 Repository 인터페이스
├── RepositoryInterface.Tests.Unit/
│ ├── RepositoryInterface.Tests.Unit.csproj
│ ├── Using.cs
│ ├── xunit.runner.json
│ └── ProductTests.cs
└── README.md

공통 인터페이스를 정의했으니, 도메인 특화 메서드는 어떻게 추가할까요? IRepository를 상속하면 됩니다.

IProductRepository — IRepository를 확장한 도메인 특화 인터페이스:

public interface IProductRepository : IRepository<Product, ProductId>
{
FinT<IO, Product> GetByIdIncludingDeleted(ProductId id);
}

IRepository의 13개 메서드(CRUD 8개 + Specification 5개)를 그대로 상속받으면서, Product 도메인에만 필요한 GetByIdIncludingDeleted 메서드(예: Soft Delete된 레코드 조회)를 추가합니다. 새로운 도메인이 추가되어도 표준 시그니처를 다시 정의할 필요가 없습니다.


다음 테이블은 이 장에서 다룬 핵심 항목을 요약합니다.

항목설명
인터페이스IRepository<TAggregate, TId>
CRUD 메서드단건 4개 + 복수 4개 = 8개
반환 타입FinT<IO, T> (합성 가능한 모나드)
제약 조건Aggregate Root + Ulid 기반 ID
확장 방법도메인 특화 인터페이스로 상속

Q1: 왜 Repository는 Aggregate Root만 다루나요?

섹션 제목: “Q1: 왜 Repository는 Aggregate Root만 다루나요?”

A: DDD에서 Aggregate Root는 일관성 경계(Consistency Boundary)입니다. 내부 Entity는 Aggregate Root를 통해서만 접근해야 하므로, Repository도 Aggregate Root 단위로 동작합니다.

Q2: FinT<IO, T>가 Task<T>보다 나은 점은 무엇인가요?

섹션 제목: “Q2: FinT<IO, T>가 Task<T>보다 나은 점은 무엇인가요?”

A: Task<T>는 예외를 던지지만, FinT<IO, T>는 실패를 값으로 표현합니다. 이를 통해 오류 처리를 합성(compose)할 수 있고, 예외 기반 제어 흐름을 피할 수 있습니다.

Q3: IReadOnlyList를 매개변수로 쓰는 이유는 무엇인가요?

섹션 제목: “Q3: IReadOnlyList를 매개변수로 쓰는 이유는 무엇인가요?”

A: IReadOnlyList<T>는 인덱스 접근과 Count를 제공하면서도 변경을 허용하지 않아, 안전하고 유연한 컬렉션 인터페이스입니다. List<T>, 배열, Seq<T> 등 다양한 타입을 받을 수 있습니다.


공통 Repository 인터페이스를 정의했습니다. 그런데 DB 없이 이 인터페이스를 테스트하려면 어떻게 해야 할까요? 다음 장에서는 ConcurrentDictionary 기반 InMemory Repository를 구현하여, 실제 DB 연결 없이 빠르게 동작을 검증하는 방법을 살펴봅니다.

2장: InMemory Repository