Repository와 Service

Repository

정의

Repository는 보편 언어로 표현된 Aggregate에 대한 조회 접근이다.

Evans가 이 패턴을 도입한 문제는 두 가지다. 첫째, 단지 무언가를 찾기 위한 탐색 가능한 연관관계가 늘어나면 모델이 흐려진다. 성숙한 모델에서 쿼리는 종종 도메인 개념을 표현한다. 둘째, 그러나 쿼리 자체가 문제를 일으킨다.

대부분의 DB 접근 인프라를 적용하는 순전한 기술적 복잡도가 클라이언트 코드를 빠르게 압도하고, 그것이 개발자로 하여금 도메인 계층을 멍청하게 만들도록 이끌어 모델을 무의미하게 만든다.

제약 없는 쿼리는 객체에서 특정 필드만 빼내 캡슐화를 위반하거나, Aggregate 내부에서 몇 개의 특정 객체만 인스턴스화하여 Aggregate Root를 무방비 상태로 만들고 이 객체들이 도메인 모델의 규칙을 강제하는 것을 불가능하게 만든다. 도메인 로직이 쿼리와 애플리케이션 계층 코드로 옮겨가고, Entity와 Value Object는 단순한 데이터 컨테이너가 된다.

처방

전역 접근이 필요한 각 Aggregate 유형마다, 그 Aggregate Root 타입의 모든 객체가 메모리상의 컬렉션에 있는 듯한 착각을 제공하는 서비스를 만들어라. 잘 알려진 전역 인터페이스를 통해 접근을 설정하라.

객체를 추가·제거하는 메서드를 제공하여 데이터 저장소의 실제 삽입·삭제를 캡슐화하라. 도메인 전문가에게 유의미한 기준으로 객체를 선택하는 메서드를 제공하라.

실제로 직접 접근이 필요한 Aggregate Root에만 Repository를 제공하라. 애플리케이션 로직은 모델에 집중하게 하고 객체 저장과 접근은 모두 Repository에 위임하라.

Aggregate Root당 하나

마지막 문장이 실무에서 가장 자주 어겨진다.

O  ShowSeatRepository, ReservationRepository, PaymentRepository, ShowRepository
X  ReservationSeatRepository   ← ReservationSeat은 Aggregate Root가 아니다

ReservationSeatReservation Aggregate의 내부 구성원이라면 밖에서 직접 꺼내는 순간 Aggregate 경계가 뚫린다. Evans가 위에서 지적한 “Aggregate Root를 무방비 상태로 만드는” 상황이 정확히 이것이다.

Spring Data JPA에서는 인터페이스 한 줄이면 Repository가 생기기 때문에 무심코 만들게 되는데, 만드는 순간 Root를 거치지 않고 내부 구성원을 바꾸는 코드가 생길 자리가 열린다. 그러면 Root가 강제하던 불변식(예: 예약 총액이 좌석 금액 합과 같아야 한다)이 조용히 깨진다. 좌석은 3석인데 총액은 2석 값인 데이터가 생겨도 아무도 모른다.

구현 중에 이런 Repository가 필요해 보이면 원인은 대개 둘 중 하나다.

  • Aggregate 경계가 틀렸다. 그 부분이 실제로는 독립 Aggregate여야 한다
  • 조회 전용 쿼리가 필요하다. 도메인 모델이 아니라 조회 모델(CQRS)에서 풀어야 한다

후자의 경우 Vernon도 같은 결론을 낸다. 식별자 참조만 쓰면 UI 뷰 조립에 여러 Repository가 필요해지고, 쿼리 오버헤드가 성능 문제를 일으키면 CQRS 도입을 고려할 가치가 있다.


Factory

정의

온전하고 내부적으로 일관된 Aggregate, 또는 큰 Value Object의 생성이 복잡해지거나 내부 구조를 너무 많이 드러낼 때 Factory가 캡슐화를 제공한다.

객체 생성은 그 자체로 주요 연산일 수 있지만, 복잡한 조립 연산은 생성되는 객체의 책임에 맞지 않는다. 이런 책임을 결합하면 이해하기 어려운 어색한 설계가 나온다. 클라이언트가 직접 생성하게 하면 클라이언트 설계가 흐려지고, 조립되는 객체나 Aggregate의 캡슐화가 위반되며, 클라이언트가 생성 객체의 구현에 과도하게 결합된다.

처방

복잡한 객체와 Aggregate의 인스턴스 생성 책임을 별도의 객체로 이전하라. 그 객체는 도메인 모델 안에서 아무 책임을 갖지 않을 수도 있지만 여전히 도메인 설계의 일부다.

복잡한 조립 전체를 캡슐화하고 클라이언트가 인스턴스화되는 객체의 구체 클래스를 참조하지 않아도 되는 인터페이스를 제공하라.

Aggregate 전체를 한 조각으로 생성하며 그 불변식을 강제하라. 복잡한 Value Object도 한 조각으로 생성하라. 필요하다면 빌더로 요소를 조립한 뒤에.

Vernon의 사례에서는 Aggregate를 쪼갠 결과 Root의 커맨드 메서드가 자연스럽게 Factory 역할을 하게 되었다. 큰 Aggregate 시절에는 컬렉션에 원소를 추가하는 void 메서드였던 것이, 분리 후에는 새 Aggregate 인스턴스를 만들어 반환하는 메서드로 바뀐다.

// 큰 Aggregate: 컬렉션에 추가하는 커맨드
public void planBacklogItem(String aSummary, ...) { ... }

// 분리 후: 새 Aggregate를 만들어 반환하는 Factory
public BacklogItem planBacklogItem(String aSummary, ...) { ... }

Factory 패턴을 처음부터 별도 클래스로 도입할 필요는 없다. Root의 정적 메서드나 커맨드 메서드로 시작해서, 조립이 실제로 복잡해질 때 분리하는 편이 낫다.


Service

정의

Evans의 문장으로 시작하는 것이 가장 명확하다.

때로는 그것이 사물(thing)이 아니다.

도메인의 어떤 개념은 객체로 모델링하기가 자연스럽지 않다. 필요한 도메인 기능을 Entity나 Value의 책임으로 강제하면 모델 기반 객체의 정의를 왜곡하거나, 의미 없는 인공 객체를 추가하게 된다.

처방은 다음과 같다.

도메인의 유의미한 프로세스나 변환이 Entity나 Value Object의 자연스러운 책임이 아닐 때, 독립적인 인터페이스로 선언된 연산을 Service로 모델에 추가하라.

Service 계약, 즉 그 Service와의 상호작용에 관한 단정 집합을 정의하라. 이 단정을 특정 Bounded Context의 보편 언어로 진술하라. Service에 이름을 주고, 그 이름도 보편 언어의 일부가 된다.

Domain Service와 Application Service

DDD 용어에서 Service는 Entity나 Value Object에 자연스럽게 속하지 않는 로직을 구현하는 무상태 객체다. 두 종류로 나뉜다.

무엇을 하는가무엇을 모르는가
Domain Service여러 Entity나 Aggregate에 걸친 비즈니스 규칙을 캡슐화한다트랜잭션, DB, 프레임워크
Application Service유스케이스를 조율한다. Domain Service와 Repository 호출을 조율하고, 트랜잭션을 관리하며, 인증이나 알림 같은 관심사를 처리한다비즈니스 규칙 (담지 않는다)

Microsoft 가이드의 예를 빌리면, 드론 배송 시스템의 Scheduler는 Domain Service다. 스케줄링 로직이 드론 가용성·배송 시간대·경로 최적화에 관한 비즈니스 규칙을 포함하고, 그것이 어느 단일 Entity에도 속하지 않기 때문이다. 반면 배송 요청을 받아 Scheduler를 호출하고 결과를 반환하는 API 엔드포인트는 Application Service다.

용어 주의: DDD의 Service는 마이크로서비스와 직접 관련이 없다. 같은 단어가 다른 것을 가리키는 전형적인 경우다.

Application Service에 도메인 규칙을 넣지 않는다

Fowler가 Anemic Domain Model 글에서 인용한 Evans의 Application Layer 정의가 이 경계를 가장 분명히 그린다.

Application Layer는 소프트웨어가 해야 할 일을 정의하고 표현력 있는 도메인 객체들이 문제를 풀도록 지시한다. 이 계층은 얇게 유지된다. 비즈니스 규칙이나 지식을 담지 않고 작업을 조율하여 아래 계층의 도메인 객체 협력에 위임할 뿐이다. 비즈니스 상황을 반영하는 상태를 갖지 않지만, 사용자나 프로그램의 작업 진행 상태는 가질 수 있다.

if (seat.status == HELD) throw ... 같은 판단이 Application Service에 있으면 그 규칙은 Entity 안에 있어야 하는 것이다. Application Service는 누구를 어떤 순서로 부르는가만 담당한다.

// Application Service - 조율만 한다
@Transactional
fun holdSeats(showId: ShowId, seatIds: List<ShowSeatId>, userId: UserId): Reservation {
    val show = showQueryService.findOnSale(showId)          // 조회
    val seats = showSeatRepository.findAllById(seatIds)      // 조회
    val reservation = Reservation.create(show, seats, userId)
    seats.forEach { it.hold(reservation.id, clock.now().plus(HOLD_DURATION)) }  // 규칙은 Entity 안
    return reservationRepository.save(reservation)
}

Fowler의 진단은 다음과 같다.

일반적으로 Service에서 발견되는 행위가 많을수록 도메인 모델의 이점을 스스로 강탈하고 있을 가능성이 높다. 모든 로직이 Service에 있다면 눈뜨고 도둑질당한 것이다.

Domain Service도 남용 대상이다. 대부분의 규칙은 ShowSeat.hold(), Reservation.confirm() 같은 Aggregate 메서드로 들어가는 것이 맞고, Domain Service는 정말로 여러 Aggregate에 걸치는 규칙만 담는다.


Module

Evans는 모듈도 모델의 일부로 다룬다. 모두가 모듈을 쓰지만 그것을 모델의 온전한 구성 요소로 취급하는 사람은 드물다. 코드는 기술 아키텍처의 측면부터 개발자 업무 분담까지 온갖 범주로 쪼개진다. 리팩터링을 많이 하는 개발자조차 프로젝트 초기에 구상한 모듈 구조에 만족하는 경향이 있다.

결합도와 응집도에 대한 설명은 연관과 상호작용의 분포로 기계적으로 판단되는 기술적 지표처럼 들리게 만든다. 그러나 모듈로 나뉘는 것은 코드만이 아니라 개념이다. 사람이 한 번에 생각할 수 있는 것에는 한계가 있고(낮은 결합도), 응집되지 않은 아이디어 조각은 뒤죽박죽인 아이디어 수프만큼 이해하기 어렵다(높은 응집도).

처방은 다음과 같다.

시스템의 이야기를 들려주고 응집된 개념 집합을 담는 모듈을 골라라. 모듈 이름이 보편 언어의 일부가 되게 하라. 모듈은 모델의 일부이며 그 이름은 도메인에 대한 통찰을 반영해야 한다.

이렇게 하면 보통 모듈 간 낮은 결합도가 따라오지만, 그렇지 않다면 개념을 분리할 수 있게 모델을 바꿀 방법을 찾거나, 요소들을 유의미하게 묶어줄 놓친 개념을 찾아라.

이 패턴이 “DDD를 한다 = domain/application/infrastructure 폴더를 만든다"라는 오해를 정면으로 반박한다. 그런 이름은 기술 아키텍처의 층위이고 도메인에 대한 통찰을 반영하지 않는다.

한편 계층 의존 규칙 자체는 폴더로 강제되지 않는다. domaininfrastructure를 참조하지 못하게 하려면 ArchUnit 같은 도구로 테스트에서 검증해야 한다.


관련 개념


참고 자료