DDD Memo share
1. 도메인 모델 시작
p7. 아키텍쳐 구성
- 사용자 인터페이스 또는 표현 (UI, Presentation)
- 응용 (Application)
- 도메인 (Domain)
- 인프라스트럭처 (Infrastructure)
p11. 도메인을 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것이다.
p17. 엔티티와 밸류
- 엔티티의 가장 큰 특징은 식별자를 갖는다는 점.
- 밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용한다 (예: “받는 사람”이란 밸류는 이름과 전화번호 속성으로 구성됨).
- 밸류 객체의 데이터를 변경할 때는 기존 데이터를 변경하기보다는 변경한 데이터를 갖는 새로운 밸류 객체를 생성한다(Immutable).
- 엔티티 타입의 두 객체가 같은지 비교할 때 주로 식별자를 사용한다면, 두 밸류 객체가 같은지 비교할 때는 모든 속성이 같은지 비교해야 한다.
p32. 도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 생성 시점에 필요한 것을 전달해 주어야 한다. 즉, 생성자를 통해 필요한 데이터를 모두 받아야 한다.
2. 아키텍처 개요
p39. 주문 도메인의 경우 ‘배송지 변경’, ‘결제 완료’, ‘주문 총액 계산’과 같은 핵심 로직을 도메인 모델에서 구현한다.
p51. DIP(Dependency Inversion Principle)를 적용할 때 하위 기능을 추상화한 인터페이스는 고수준 모듈 관점에서 도출한다 (예: 인프라에 대한 인터페이스는 도메인 영역에 존재).
p54. 도메인의 주요 구성 요소
- 엔티티(Entity): 고유의 식별자를 갖는 객체로 자신의 라이프사이클을 갖는다. 주문, 회원, 상품과 같이 도메인의 고유한 개념을 표현한다. 도메인 모델의 데이터를 포함하며 해당 데이터와 관련된 기능을 함께 제공한다.
- 밸류(Value): 고유의 식별자를 갖지 않는 객체로 주로 개념적으로 하나인 도메인 객체의 속성을 표현할 때 사용된다. 배송지 주소를 표현하기 위한 주소나 구매 금액을 위한 금액과 같은 타입이 밸류 타입이다. 엔티티의 속성으로 사용될 뿐만아니라 다른 밸류 타입의 속성으로도 사용될 수 있다.
- 애그리거트(Aggregate): 관련된 엔티티와 밸류 객체를 개념적으로 하나로 묶은 것이다. 예를 들어, 주문과 관련된 Order 엔티티, OrderLine 밸류, Orderer 밸류 객체를 주문 애그리커트로 묶을 수 있다.
- 리포지터리(Repository): 도메인 모델의 영속성을 처리한다. 예를 들어,DBMS 테이블에서 엔티티 객체를 로딩하거나 저장하는 기능을 제공한다.
- 도메인 서비스(Domain Service): 특정 엔티티에 속하지 않은 도메인 로직을 제공한다. ‘할인 금액 계산’은 상품, 쿠폰, 회원 등급, 구매 금액 등 다양한 조건을 이용해서 구현하게 되는데, 이렇게 도메인 로직이 여러 엔티티와 밸류를 필요로 할 경우 도메인 서비스에서 로직을 구현한다.
p56. 도메인 모델의 엔티티는 단순히 데이터를 담고 있는 데이터 구조라기보다는 데이터와 함께 기능을 제공하는 객체이다. 도메인 관점에서 기능을 구현하고 기능 구현을 캡슐화해서 데이터가 임의로 변경되는 것을 막는다.
p59. 애그리거트는 군집에 속한 객체들을 관리하는 루트 엔티티를 갖는다.
p61. 주문 애그리거트는 Order를 통하지 않고 ShippingInfo를 변경할 수 있는 방법을 제공하지 않는다.
3. 애그리거트
p74. 애그리거트는 독립된 객체 군이며, 각 애그리거트는 자기 자신을 관리할 뿐 다른 애그리거트를 관리하지 않는다. ‘A가 B를 갖는다’로 해석할 수 있는 요구사항이 있다고 하더라도 이것이 반드시 A와 B가 한 애그리거트에 속한다는 것을 의미하는 것은 아니다(예: 상품과 리뷰).
p76. 애그리거트에 속한 모든 객체가 일관된 상태를 유지하려면 애그리거트 전체를 관리할 주체가 필요한데 이 책임을 지는 것이 바로 애그리거트의 루트 엔티티다.
p82. 트랜잭션 범위는 작을 수록 좋다.
p83. 만약 부득이하게 한 트랜잭션으로 두 개 이상의 애그리거트를 수정해야 한다면, 애그리거트에서 다른 애그리거트를 직접 수정하지 말고, 응용 서비스에서 두 애그리거트를 수정하도록 구현해야 한다. 도메인 이벤트를 사용하면 한 트랜잭션에서 한 개의 애그리거트를 수정하면서도 동기나 비동기로 다른 애그리거트의 상태를 변경하는 코드를 작성할 수 있다.
p85. 리포지터리는 애그리거트 단위로 존재한다. Order 애그리거트와 관련된 테이블이 세 개라면 리포지터리를 통해서 Order 애그리거트를 저장할 때 애그리거트 루트와 매핑되는 테이블뿐만 아니라 애그리거트에 속한 모든 구성요소를 위한 테이블에 데이터를 저장해야 한다.
p101. 애그리거트가 갖고 있는 데이터를 이용해서 다른 애그리거트를 생성해야 한다면 애그리거트에 팩토리 메서드를 구현하는 것을 고려해보자. Product의 경우 제품을 생성한 Store의 식별자를 필요로한다. 즉, Store의 데이터를 이용해서 Product를 생성한다. 게다가 Product를 생성할 수 있는 조건을 판단할 때 Store 상태를 이용한다. 따라서 Store에 Product를 생성하는 팩토리 메서드를 추가하면 Product를 생성할 때 필요한 데이터의 일부를 직접 제공하면서 동시에 중요한 도메인 로직을 함께 구현할 수 있게 된다.
4. 리포지터리와 모델 구현
p114. 엔티티가 객체로서 제 역할을 하려면 외부에 set 메서드 대신 의도가 잘 드러나는 기능을 제공해야 한다. 상태 변경을 위한 setState()
메서드 보다 주문 취소를 위한 cancel()
메서드가 도메인을 더 잘 표현하고, setShippingInfo()
메서드보다 배송지를 변경한다는 의미를 갖는 changeShippingInfo()
가 도메인을 더 잘 표현한다.
p134. 애그리거트 루트를 로딩하면 루트에 속한 모든 객체가 완전한 상태여야 한다.
// product는 완전한 하나여야 한다.
Product product = productRepository.findById(id);
p137. 애그리거트가 완전해야 하는 이유는 두 가지 정도로 생각해 볼 수 있다. 첫 번째 이유는 상태를 변경하는 기능을 실행할 때 애그리거트 상태가 완전해야 하고, 두 번째 이유는 표현 영역에서 애그리거트의 상태 정보를 보여줄 때 필요하기 때문이다.
p138. 애그리거트 내의 모든 연관을 즉시 로딩으로 설정할 필요는 없다. 지연 로딩은 동작 방식이 항상 동일하기 때문에 즉시 로딩처럼 경우의 수를 따질 필요가 없는 장점이 있다. 물론, 지연 로딩은 즉시 로딩보다 쿼리 실행 횟수가 많아질 가능성이 더 높다. 따라서, 무조건 즉시 로딩이나 지연로딩으로만 설정하기보다는 애그리거트에 맞게 즉시 로딩과 지연로딩을 선택해야 한다.
p142. 자동 증가 컬럼은 DB의 insert 쿼리를 실행해야 식별자가 생성되므로 도메인 객체를 리포지터리에 저장할 때 식별자가 생성된다. 이 이야기는 도메인 객체를 생성하는 시점에는 식별자를 알 수 없고 도메인 객체를 저장한 뒤에 식별자를 구할 수 있음을 의미한다.
5. 리포지터리의 조회 기능
p144. 스펙(Specification)은 애그리거트가 특정 조건을 충족하는지 여부를 검사한다.
6. 응용 서비스와 표현 영역
p170. 응용 서비스의 메서드가 요구하는 파라미터와 표현 영역이 사용자로부터 전달받은 데이터는 형식이 일치하지 않기 때문에 표현 영역은 응용 서비스가 요구하는 형식으로 사용자 요청을 변환한다.
p171. 사용자와의 상호작용은 표현 영역이 처리하기 때문에 응용 서비스는 표현 영역에 의존하지 않는다. 응용 용역은 사용자가 웹 브라우저를 사용하는지, REST API를 호출하는지, TCP 소켓을 사용하는지 여부를 알 필요가 없다. 단지, 응용 영역은 기능 실행에 필요한 입력값을 전달받고 실행 결과만 리턴하면 될 뿐이다.
p171. 응용 서비스는 주로 도메인 객체 간의 흐름을 제어하기 때문에 다음과 같이 단순한 형태를 갖는다. 응용 서비스가 이것보다 복잡하다면 응용 서비스에 도메인 로직의 일부를 구현하고 있을 가능성이 높다.
public Result doSomeFunc(SomeReq req) {
// 1. 리포지터리에서 애그리거트를 구한다.
SomeAgg aaa = someAggRepository.findByid(req.getId());
checkNull(agg);
// 2. 애그리거트의 도메인 기능을 실행한다.
agg.doFunc(req.getValue());
// 3. 결과를 리턴한다.
return createSuccessResult(agg);
}
public Result doSomeCreation(CreateSomeReq req) {
// 1. 데이터 중복 등 데이터가 유효한지 검사한다.
checkValid(req);
// 2. 애그리거트를 생성한다.
SomeAgg newAgg = createSome(req);
// 3. 리포지터리에 애그리거트를 저장한다.
someAggRepository.save(newAgg);
// 4. 결과를 리턴한다.
return createSuccessResult(newAgg);
}
p172. 도메인 객체 간의 실행 흐름을 제어하는 것과 더불어 응용 서비스의 주된 역할 중 하나는 트랜잭션 처리이다. 응용 서비스는 도메인의 상태 변경을 트랜잭션으로 처리해야 한다.
p174. 도메인 로직을 도메인 영역과 응용 서비스에 분산해서 구현하면 코드 품질에 문제가 발생한다. 첫 번째 문제는 코드의 응집성이 떨어진다는 것이다. 도메인 데이터와 그 데이터를 조작하는 도메인 로직이 한 영역에 위치하지 않고 서로 다른 영역에 위치한다는 것은 도메인 로직을 파악하기 위해 여러 영역을 분석해야 한다는 것을 뜻한다. 두 번째 문제는 여러 응용 서비스에서 동일한 도메인 로직을 구현할 가능성이 높아진다는 것이다. 예를 들어, 비정상적인 계정 정지를 막기 위해 암호를 확인한다고 해보자. 이 경우 계정 정지 기능을 구현하는 응용 서비스는 다음과 같이 암호를 확인하는 코드를 구현해야 한다.
p181. 인터페이스가 명확하게 필요하기 전까지는 응용 서비스에 대한 인터페이스를 작성하는 것이 좋은 설계라고 볼 수 없다.
p189. 이벤트를 사용하면 코드가 다소 복잡해지는 대신 도메인 간의 의존성이나 외부 시스템에 대한 의존을 낮춰주는 장점을 얻을 수 있다. 또한 시스템을 확장하는 데에 이벤트가 핵심 역할을 수행하게 된다.
p197. 응용 서비스를 실행하는 주체가 표현 영역이면 응용 서비스는 논리적 오류 위주로 값을 검증해도 문제가 없지만, 응용 서비스를 실행하는 주체가 다양하면 응용 서비스에서 반드시 파라미터로 전달받은 값이 올바른지 검사해야 한다.
p202. 응용 서비스가 사용자 요청 기능을 실행하는데 별다른 기여를 하지 못한다면 굳이 서비스를 만들지 않아도 된다고 생각한다(예: 컨트롤러->데이터 조회)
7. 도메인 서비스
p209. 특정 기능이 응용 서비스인지 도메인 서비스인지 감을 잡기 어려울 때는 해당 로직이 애그리거트의 상태를 변경하거나 애그리거트의 상태 값을 계산하는 지 검사해 보면 된다.
8. 애그리거트 트랜잭션 관리
p217. 선점 잠금(Pessimistic Lock) 기능을 사용할 때 잠금 순서에 따른 교착 상태(Deadlock)가 발생하지 않도록 주의해야 한다. 이런 문제가 발생하지 않도록 하려면 잠금을 구할 때 최대 대기 시간을 지정해야 한다.
p219. 비선점 잠금(Optimistic Lock) 방식은 잠금을 해서 동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식이다.
UPDATE aggtable
SET version = version + 1, colx = ?, coly = ?
WHERE aggid = ? AND version = 현재버전;
p221. 비선점 잠금을 위한 쿼리를 실행할 때 쿼리 실행 결과로 수정된 행의 개수가 0이면 이미 누군가 앞서 데이터를 수정한 것이다.
p227. 루트 엔티티의 값이 바뀌지 않았더라도 애그리거트의 구성요소 중 일부 값이 바뀌면 논리적으로 그 애그리거트는 바뀐 것이다(강제 버전 증가).
9. 도메인 모델과 BOUNDED CONTEXT
p249. 이때 카탈로그 컨텍스트와 추천 컨텍스트의 도메인 모델(Product
)은 서로 다르다. 카탈로그는 제품을 중심으로 도메인 모델을 구현하지만, 추천은 추천 연산을 위한 모델을 구한다.
p255. 이런 마이크로서비스의 특징은 BOUNDED CONTEXT와 잘 어울린다. 각 BOUNDED CONTEXT는 모델의 경계를 형성하는데, BOUNDED CONTEXT를 마이크로서비스로 구현하면 자연스럽게 컨텍스트별로 모델이 분리된다. 코드로 치면 마이크로서비스마다 프로젝트를 생성하므로 BOUNDED CONTEXT마다 프로젝트를 만들게 된다 이는 코드 수준에서 모델을 분리해서 두 BOUNDED CONTEXT의 모델이 섞이지 않도록 해 준다. 별도 프로세스로 개발한 BOUNDED CONTEXT는 독립적으로 배포하고 모니터링하고 확장하게 되는데 이 역시 마이크로서비스의 특징이다.
p258. 두 BOUNDED CONTEXT가 같은 모델을 공유하는 경우도 있다. 예를 들어 운영자를 위한 주문 관리 도구를 개발하는 팀과 고객을 위한 주문 서비스를 개발하는 팀이 다르다고 가정하자. 이 경우, 두 팀은 주문을 표현하는 모델을 공유함으로써 주문과 관련된 중복 개발을 막을 수 있다. 이렇게 두 팀이 공유하는 모델을 공유 커널(SHARED KERNEL)이라고 부른다.
p259. 컨텍스트 맵
10. 이벤트
p264. Order는 주문을 표현하는 도메인 객체인데 결제 도메인의 환불 관련 로직이 뒤섞이게 된다. 이는 환불 기능에 바뀌면 Order도 영향을 받게 된다는 것을 의미한다.
p266. 모메인 모델에서 이벤트 주체는 엔티티, 밸류, 도메인 서비스와 같은 도메인 객체다. 이벤트 핸들러는 이벤트 생성 주체가 발생한 이벤트에 반응한다. 이벤트 생성 주체와 이벤트 핸들러를 연결해 주는 것이 이벤트 디스패처이다.
p266. 이벤트의 구성
- 이벤트 종류: 클래스 이름으로 이벤트 종류를 표현
- 이벤트 발생 시각
- 추가 데이터: 주문번호, 신규 배송지 정보 등 이벤트와 관련된 정보
p269. 이벤트는 크게 두가지 용도로 쓰인다. 첫 번째 용도는 트리거이다. 이벤트의 두 번째 용도는 서로 다른 시스템 간의 데이터 동기화이다.
p284. 생각해 볼만한 것은 외부의 환불 서비스 실행에 실패했다고 해서 반드시 트랜잭션을 롤백해야 하는 지에 대한 문제이다. 일단 구매 취소 자체는 처리하고 환불만 재처리하거나 수동으로 처리할 수도 있다.
p296. 이벤트는 과거에 벌어진 사건이므로 데이터가 변경되지 않는다. 이런 이유로 EventStore
인터페이스는 새로운 이벤트를 추가하는 기능과 조회하는 기능만 제공하고 기존 이벤트 데이터를 수정하는 기능은 제공하지 않는다.
p307. 이벤트 적용시 추가 고려사항
- 이벤트 소스의 추가
- 포워더에서 전송 실패를 얼마나 허용할 것이냐
- 이벤트 손실
- 이벤트 순서
- 이벤트 재처리
11. CQRS (Command Query Responsibility Segregation)
p312. 시스템이 제공하는 기능은 크게 두 가지로 나누어 생각해 볼 수 있다. 하나는 상태를 변경하는 기능이다. 또 다른 하나는 사용자 입장에서 상태 정보를 조회하는 기능이다.
p314. 단순히 데이터를 읽어와 조회하는 기능은 응용 로직이 복잡하지 않기 때문에 컨트롤러에서 바로 DAO를 실행해도 무방하다
p317. 메모리에 캐시하는 데이터는 DB에 보관된 데이터를 그대로 저장하기보다는 화면에 맞는 모양으로 변환한 데이터를 캐시할 때 성능에 더 유리하다.
├── application
│ └── service
├── common
│ ├── events
│ ├── exceptions
│ └── model
├── domain
│ ├── model
│ ├── repository
│ └── service
├── infra
│ ├── dao
│ └── external
└── ui
└── controller