본문으로 건너뛰기
Leo Jung
뒤로 가기

[DDD Intro #14] Repository: DAO가 아니라 도메인 컬렉션처럼 다루기

페이지 수정

DDD에서 Repository는 자주 오해되는 패턴이다. 많은 코드에서 Repository는 사실상 DAO처럼 사용된다. 데이터베이스에서 데이터를 조회하고, 특정 컬럼을 수정하고, 필요한 쿼리를 실행하는 객체가 Repository라는 이름을 갖는다.

물론 Repository도 저장소와 관련이 있다. 하지만 DDD에서 Repository의 목적은 단순히 SQL을 감추는 것이 아니다. Repository는 도메인 모델이 저장 기술에 끌려가지 않도록, Aggregate의 생명주기를 다루는 컬렉션 같은 추상화를 제공한다.

이 차이는 작아 보이지만 중요하다. Repository를 어떻게 이해하느냐에 따라 도메인 모델이 행위를 중심으로 유지될 수도 있고, 다시 데이터 수정 중심으로 돌아갈 수도 있다.

DAO와 Repository는 관심사가 다르다

DAO는 데이터 접근 객체다. 테이블에서 데이터를 읽고 쓰는 기술적 관심사가 중심이다.

orderDao.updateStatus(orderId, "CANCELED");
orderDao.findOrderItems(orderId);
orderDao.insertOrderHistory(orderId, status);

이런 코드는 데이터베이스 조작을 잘 표현한다. 하지만 도메인 행위는 약하게 드러난다. “주문을 취소한다”는 말은 보이지 않고, “상태 컬럼을 CANCELED로 업데이트한다”는 사실만 보인다.

DDD에서 Repository는 다른 관점을 갖는다.

Order order = orderRepository.findById(orderId);
order.cancel(reason, now);
orderRepository.save(order);

여기서 핵심 행위는 order.cancel()이다. Repository는 주문을 꺼내고 다시 저장할 뿐이다. 도메인 규칙은 Repository가 아니라 Aggregate 안에 있다.

Repository는 데이터베이스 조작의 중심이 아니라 도메인 객체를 다시 만나는 입구다.

Aggregate Root 단위로 다룬다

DDD에서 Repository는 보통 Aggregate Root 단위로 둔다. Aggregate 내부의 모든 객체를 각각 Repository로 다루지 않는다.

예를 들어 Subscription Aggregate 안에 SubscriptionPeriod, PlanSnapshot, RenewalHistory 같은 객체가 있다고 해보자. 이 객체들이 Aggregate 내부의 구성요소라면, 각각에 대해 Repository를 만들 필요는 없다.

SubscriptionRepository

이 하나가 Aggregate Root인 Subscription의 생명주기를 다룬다.

중요한 것은 저장 단위가 아니라 일관성의 단위다. Aggregate Root는 외부에서 접근하는 진입점이고, Repository는 그 Aggregate Root를 조회하고 저장한다.

Subscription subscription = subscriptionRepository.findById(subscriptionId);
subscription.renew(paymentResult, now);
subscriptionRepository.save(subscription);

외부 코드는 Aggregate 내부의 객체를 직접 저장하거나 수정하지 않는다. Aggregate Root를 통해서만 변경한다. 이렇게 해야 Aggregate가 자신의 불변식을 지킬 수 있다.

Repository가 도메인 행위를 우회하면 위험하다

Repository를 DAO처럼 사용하기 시작하면 도메인 모델은 쉽게 빈약해진다.

subscriptionRepository.updateStatus(subscriptionId, SubscriptionStatus.CANCELED);
subscriptionRepository.updateExpiredAt(subscriptionId, now);
subscriptionRepository.insertCancelHistory(subscriptionId, reason);

이 코드는 빠르고 효율적으로 보일 수 있다. 하지만 중요한 문제가 있다. 구독이 해지 가능한 상태인지, 해지 시 어떤 이벤트가 발생해야 하는지, 무료 체험 중 해지와 유료 구독 해지가 어떻게 다른지 같은 도메인 규칙을 우회한다.

도메인 객체가 존재하는 이유는 단순히 데이터를 담기 위해서가 아니다. 자신의 규칙을 지키기 위해서다. Repository가 직접 상태를 바꾸기 시작하면, 도메인 객체는 더 이상 규칙을 지키는 중심이 아니다.

좋은 흐름은 다음에 가깝다.

Subscription subscription = subscriptionRepository.findById(subscriptionId);
subscription.cancelByMember(reason, now);
subscriptionRepository.save(subscription);

이 구조에서는 상태 변경이 도메인 객체의 행위를 통해 일어난다. Repository는 행위를 우회하지 않는다.

컬렉션처럼 보인다는 것

에릭 에반스가 말한 Repository의 중요한 감각 중 하나는 컬렉션처럼 보인다는 것이다. 메모리 안에 있는 객체 컬렉션에서 필요한 객체를 찾고, 추가하고, 제거하는 것처럼 다룬다는 뜻이다.

Subscription subscription = subscriptions.findById(subscriptionId);
subscriptions.save(subscription);

이 표현은 데이터베이스 테이블보다 도메인 객체의 집합에 가깝다. 물론 내부 구현은 SQL일 수도 있고, JPA일 수도 있고, Document DB일 수도 있다. 하지만 도메인 계층은 그 사실을 몰라도 된다.

Repository 인터페이스는 도메인에 필요한 언어로 정의되어야 한다.

public interface SubscriptionRepository {
    Optional<Subscription> findById(SubscriptionId id);
    void save(Subscription subscription);
}

필요하다면 도메인적 의미가 있는 조회 메서드를 둘 수 있다.

Optional<Subscription> findActiveByMemberId(MemberId memberId);

하지만 Repository가 화면 조회 요구를 모두 떠안기 시작하면 다시 혼란이 생긴다. 복잡한 검색, 목록 화면, 통계, 리포트는 도메인 Repository보다 별도의 Query 모델이나 조회 전용 Repository가 더 적절할 수 있다.

Command와 Query를 구분하기

Aggregate Repository는 변경을 위한 모델을 가져오는 데 집중하는 편이 좋다. Aggregate는 불변식을 지키고 상태를 변경하기 위한 모델이지, 모든 조회 화면을 만족시키기 위한 모델이 아니다.

예를 들어 관리자 화면에서 구독 목록을 보여줘야 한다고 하자. 필요한 정보는 다음과 같을 수 있다.

회원 이메일
요금제 이름
구독 상태
최근 결제일
다음 갱신일
고객 등급
미납 여부

이 정보를 보여주기 위해 Subscription Aggregate를 전부 로딩하고, 관련 회원과 결제 정보를 따라가며 조합하는 것은 좋은 선택이 아닐 수 있다. 이 경우 조회 전용 쿼리를 사용하는 편이 낫다.

List<SubscriptionListItem> items = subscriptionQueryService.search(condition);

반면 구독을 해지하거나 요금제를 변경하는 유스케이스에서는 Aggregate를 조회해서 행위를 수행해야 한다.

Subscription subscription = subscriptionRepository.findById(id);
subscription.changePlan(targetPlan, policy, now);
subscriptionRepository.save(subscription);

조회와 변경의 관심사를 구분하면 Repository가 지나치게 커지는 것을 막을 수 있다.

Repository 인터페이스는 어디에 두어야 할까

많은 DDD 구현에서 Repository 인터페이스는 도메인 계층에 두고, 구현체는 인프라 계층에 둔다.

domain
  Subscription
  SubscriptionRepository

infrastructure
  JpaSubscriptionRepository

이 구조의 목적은 도메인 모델이 저장 기술에 직접 의존하지 않게 하는 것이다. 도메인 계층은 SubscriptionRepository라는 추상화만 알고, 실제 구현이 JPA인지 MyBatis인지 알 필요가 없다.

다만 이 원칙을 기계적으로 적용할 필요는 없다. 프로젝트 규모가 작고 저장 기술이 단순하다면 더 단순한 구조로 시작할 수 있다. 중요한 것은 Repository의 의존성 방향이 도메인 모델을 오염시키지 않는지 보는 것이다.

JPA를 사용한다면 Spring Data Repository를 그대로 도메인 Repository로 사용할 수도 있다. 하지만 그 인터페이스가 기술적 쿼리 메서드로 가득 차고, 도메인 행위를 우회하는 업데이트 메서드를 제공하기 시작하면 조심해야 한다.

삭제도 도메인 행위일 수 있다

Repository에는 보통 delete 메서드가 있다. 하지만 도메인에서 삭제가 실제로 무엇을 의미하는지 생각해야 한다.

구독을 삭제한다는 것은 물리적으로 DB 행을 지우는 것인가? 아니면 해지하는 것인가? 운영자가 잘못 만든 테스트 구독을 제거하는 것인가? 개인정보 보관 기간이 지나 익명화하는 것인가?

도메인에서는 단순 삭제보다 상태 변화가 더 자연스러운 경우가 많다.

subscription.cancelByMember(reason, now);
subscription.expire(now);
subscription.terminateByAdmin(reason, now);

물리적 삭제는 기술적 조치일 수 있다. 도메인 행위를 repository.delete(subscription)로 표현하기 전에, 그 삭제가 도메인에서 어떤 의미인지 물어야 한다.

마치며

Repository는 DAO가 아니다. Repository는 도메인 객체를 저장소에서 꺼내고 다시 저장하기 위한 도메인 친화적 추상화다.

Repository가 도메인 행위를 우회해 직접 상태를 바꾸기 시작하면, Aggregate는 자신의 규칙을 지킬 기회를 잃는다. 반대로 Repository가 Aggregate Root를 중심으로 객체의 생명주기를 다루면, 도메인 행위는 도메인 객체 안에 남을 수 있다.

중요한 질문은 “어떤 쿼리를 만들 것인가?”가 아니라 “도메인 객체가 어떻게 다시 살아나고, 어떤 행위를 수행한 뒤, 어떻게 저장될 것인가?”다.

Repository는 데이터베이스를 숨기는 장식이 아니다. 도메인 모델이 저장 기술의 언어에 끌려가지 않고 자신의 언어로 살아남게 해주는 입구다.


페이지 수정
공유하기:

이전 글
[DDD Intro #15] Application Service: 유스케이스를 조율하는 계층
다음 글
[DDD Intro #13] 도메인 객체에 행위를 넣는다는 것