처음 도메인 모델을 만들 떄 빠지기 쉬운 함정은 도메인을 완벽하게 표현하려는 단일 모델을 만드는 시도이다.
예를들면, 상품이라는 모델을 살펴보자.
상품 이름은 실제 같지만 실제로 의미하는 것이 다르다.
카탈로그에서의 상품은 상품 정보가 위주이다 (상품 이미지, 상품명, 상품가격, 옵션 목록, 상세 설명)
재고 관리에서의 상품은 실존하는 개별 객체를 추적하기 위한 목적으로 상품을 사용한다.
즉, 카탈로그에서 물리적으로 한 개인 상품이 재고 관리에서는 여러 개 존재할 수 있다.
논리적으로 같은 존재처럼 보이지만 하위 도메인에 따라 다른 용어를 사용하는 경우도 있다.
이렇게 하위 도메인마다 같은 용어라도 의미가 다르고,
같은 대상이라도 지칭하는 용어가 다를 수 있기 때문에
한 개로의 모델로 모든 하위 도메인을 표현하려 해서는 안된다. (표현 할 수도 없다.)
올바른 도메인 모델을 개발하려면 하위 도메인마다 모델을 만들어야 한다.
각 모델은 명시적으로 구분되는 경계를 가져서 섞이지 않도록 해야 한다.
여러 하위 도메인의 모델이 섞이기 시작하면 모델의 의미가 약해질 뿐만 아니라 여러 도메인의 모델이 서로 얽혀 있기 떄문에 각 하위 도메인별로 다르게 발전하는 요구사항을 모델에 반영하기 어려워진다.
모델은 특정한 컨텍스트(문맥)하에서 완전한 의미를 갖는다.
같은 제품이라도 카탈로그 컨텍스트와 재고 컨텍스트에서 의미가 서로 다르다. 이렇게 구분되는 경계를 갖는 컨텍스트를 DDD에서는 BOUNDED CONTEXT라고 부른다.
Bounded context 는 모델의 경계를 결정하며 한 개의 Bounded context는 논리적으로 한 개의 모델을 갖는다.
Bounded Context 는 용어를 기준으로 구분한다.
Bounded Context 는 실제로 사용자에게 기능을 제공하는 물리적 시스템으로, 도메인 모델은 이 Bounded Context 안에서 도메인을 구현한다.
하위 도메인과 Bounded context가 1:1 관계를 가지면 좋겠지만 현실적으로는 그렇지 않을 때가 많다. 기업의 팀 조직 구조에 따라 결정되기도 한다.
주문 하위 도메인이라도 주문/결제 금액 계산 팀으로 나뉜다면 Bounded Context 또한 둘로 존재하게 된다.
카탈로그와 재고 관리가 아직 명화하게 구분 되지 않은 경우 두 하위 도메인을 한 Bounded Context 에서 구현하기도 한다.
규모가 작은 기업이라면 전체 시스템을 즉, 여러 하위 도메인을 한개의 Bounded Context 에서 구현할 때도 있다.
다만 이럴 경우 하위 도메인들이 뒤섞이지 않도록 주의해야 한다.
여러 하위 도메인들을 하나의 단일 모델로 만들게 된다면, 결과적으로 도메인 모델이 개별 하위 도메인을 제대로 반영하지 못하게 되면서 하위 도메인 별 기능 확장이 어렵게 되고 이는 서비스의 경쟁력을 떨어뜨리는 원인이 된다.
한 개의 Bounded Context에서 여러 하위 도메인을 포함하더라도 하위 도메인마다 구분되는 패키지를 갖도록 구현해야 하위 도메인을 위한 모델이 서로 뒤섞이지 않아서 하위 도메인마다 Bounded Context 를 갖는 효과를 낼 수 있다.
Bounded Context 도메인 모델을 구분하는 경계가 되기 때문에 같은 사용자를 지칭한다 하더라도 Bounded Context가 다르다면 갖는 모델도 달라진다.
회원의 Member는 애거리거트 루트이지만 주문의 Orderer는 밸류가 되고 카탈로그의 Product는 상품이 속할 Category와 연관을 갖지만 재고의 Product는 카탈로그의 Category와 연관을 맺지 않는다.
Bounded Context는 도메인 모델만 포함하는 것 뿐만이 아니라 도메인 기능을 사용자에게
제공하는 데 필요한 표현 영역, 응용 서비스, 인프라 영역 등을 모두 포함한다.
도메인 모델의 데이터 구조가 바뀌면 DB테이블 스키마도 함께 변경해야 하므로 해당 테이블도 Bounded Context에 포함된다.
모든 Bounded Context를 반드시 도메인주도(DDD) 구조로 개발할 필요는 없다. 복잡한 로직이 없다면 DAO 와 데이터 중심의 밸류 객체(VO) 만을 이용한 CRUD 방식으로 구현해도 된다.
서비스-DAO 구조를 사용하면 도메인 기능이 서비스에 흩어지게 되지만, 도메인 기능 자체가 단순하면 서비스-DAO 로 구성된 CRUD 방식을 사용해도 유지보수에 문제가 되지 않는다.
각 Bounded Context 는 도메인 구조에 알맞는 아키텍처를 사용한다.
한 Bounded Context에서 두 방식을 혼합하여 사용할 수도 있는데 대표적인 예가 CQRS 패턴이다.
CQRS(Command Query Responsibility Segregation) 패턴 : 상태를 변경하는 명령 기능과 내용을 조회하는 쿼리 기능을 위한 모델을 구분하는 패턴이다.
CQRS 패턴을 단일 Bounded Context 에 적용하면 아래와 같이 상태 변경과 관련된 기능은 도메인 모델 기반으로 구현하고 조회 기능은 서비스-DAO 를 이용해서 구현할 수 있다.
Bounded Context 는 서로 다른 구현 기술을 사용할 수 있다.
Bounded Context 는 반드시 사용자에게 보여지는 UI를 가져야 하는 것은 아니다.
위와 같이 UI를 처리하는 서버를 두고 UI 서버에서 Bounded Context와 통신해서 사용자 요청을 처리하는 방법도 있다.
이 구조에서 UI 서버는 각 Bounded Context를 위한 파사드(Facede) 역할을 수행한다.
UI 서버는 카탈로그와 리뷰 Bounded Context로부터 필요한 정보를 읽어와 조합한 뒤 브라우저에 응답을 제공한다.
서로 다른 두 팀이 관련된 Bounded Context 를 개발하면 자연스럽게 두 Bounded Context 간의 통합이 발생하게 된다.
(ex. 카탈로그 컨텍스트와 추천 컨텍스트 간의 통합 예시)
사용자가 카탈로그 Bounded Context에 추천 제품 목록을 요청하면 카탈로그 Bounded Context는 추천 Bounded Context로부터 추천 정보를 읽어와 추천 제품 목록을 제공하는 방식이라 가정한다.
이때 카탈로그 컨텍스트와 추천 컨텍스트의 도메인 모델은 서로 다르다. 카탈로그는 제품을 중심으로 도메인 모델을 구현하지만 추천은 추천 연산을 위한 모델을 구현한다.
카탈로그 컨텍스트에서 추천 컨텍스트로 추천 데이터를 받아오지만, 카탈로그 시스템에서는 추천의 도메인 모델을 사용하기 보다는 카탈로그 도메인 모델을 사용해서 추천 상품을 표현해야한다.
즉, 카탈로그 모델을 기반으로 하는 도메인 서비스를 이용해 상품 추천기능을 표현해야한다.
두 Bounded Context를 직접적으로 통합하는 방식으로 주로 두 Bounded Context 간에 REST API 호출하는 방식이 있다.
// 상품 추천 기능을 표현하는 카탈로그 도메인 서비스
public interface ProductRecommendationService {
public List<Product> getRecommendationsOf(ProductId id);
}
위 예시 처럼 카탈로그 도메인 모델을 기반으로 하는 도메인 서비스를 이용하여 상품 추천 기능을 표현해야 한다.
도메인 서비스를 구현한 클래스는 infraStructure 영역에 위치한다.
위 그림처럼 외부 추천 시스템으로 rest api를 호출하여 받아온 데이터는 추천 컨텍스트 기반 모델 형태이기 때문에 아래 코드와 같이 카탈로그 도메인 형태의 모델로 변환이 필요하다.
public class RecSystemClient implements ProductRecommendationService {
private ProductRepository productRepository;
@Override
public List<Product> getRecommendationOf(ProductId id) {
List<RecommendationItem> items = getRecItems(id.getValue());
return toProducts(items);
}
private List<RecommendationItem> getRecItems(String itemId) {
// externalRecClient는 외부 추천 시스템을 위한 클라이언트라고 가정
return externalRecClient.getRecs(itemId);
}
private List<Product> toProducts(List<RecommendationItem> items) {
return items.stream()
.map(item -> toProductId(item.getItemId()))
.map(prodId -> productRepository.findById(prodId))
.collect(toList());
}
private ProductId toProductId(String itemId) {
return new ProductId(itemId);
}
//...
}
RecSystemClient는 외부 시스템 모델이 내 도메인 모델을 침범하지 않도록 만들어주는 안티 코럽션 계층(Anticorruption Layer) 역할을 한다.
위 처리가 복잡하다면 별도 변환 처리만을 위한 클래스를 구현하여 사용하는 구조도 가능하다.
대표적인 간접 통합방식으로는 메시지 큐를 활용하는 것이다.
이 방식도 마찬가지로 두 BOUNDED CONTEXT 간 사용할 메시지의 데이터 구조를 협의해야 한다. 보통 큐를 누가 제공하는가에 따라 데이터 구조가 결정된다.
카탈로그 시스템에서 큐를 제공한다면 큐에 담기는 내용은 카탈로그 도메인을 따른다. 카탈로그 도메인은 메시징 큐에 카탈로그와 관련된 메시지를 저장하게 되고, 다른 Bounded Context는 이 큐로부터 필요한 메시지를 수신하는 방식을 사용한다.
(좀더 이해해야될 부분)
Bounded Context 는 어떤식으로든 연결되기 때문에 두 Bounded Context는 다양한 방식으로 관계를 맺는다.
의존 관계에 따라 상류/하류 컴포넌트로 구분한다. 하류 컴포넌트는 상류 컴포넌트가 제공하는 데이터와 기능에 의존하게 된다.
상류/하류 컴포넌트는 일반적으로 REST API 형태로 호출하는 관계를 맺고 있기 때문에 연관되고 있는 API 변경 작업이 필요할 떄 마다 서로 공유하고 일정을 협의해서 결정해야한다.
공개 호스트 서비스란, 상류 팀의 고객인 하류 팀이 다수 존재할 때 상류팀에서 하류 팀의 요구사항을 수용할 수 있는 API 를 만들고 이를 서비스 형태로 공개하여 일관성을 유지할 수 있게 하는 서비스를 말한다.
서비스를 제공할 하류 컴포넌트가 많다면 각 하류 팀의 요구사항을 수용해서 이를 단일 서비스로 공개한다.
상류 컴포넌트의 서비스는 상류 Bounded Context의 도메인 모델을 따른다.
따라서 하류 컴포넌트는 상류 서비스의 모델이 자신의 도메인 모델에 영향을 주지 않도록 보호해 주는 완충 지대를 만들어야하고, 앞에 나왔던 RecSystemClient 처럼 내 모델이 깨지는 것을 막아 주는 안티코럽션 계층(Anticorruption Layer)을 활용할 수 있다.
안티코럽션 계층에서 두 Bounded Context 간의 모델 변환을 처리해 주기 떄문에 다른 Bounded Context의 모델에 영향을 받이 않고 내 도메인 모델을 유지할 수 있다.
따라서, 밀접한 관계를 유지해야하며,
두 팀이 밀접한 관계를 형성할 수 없다면 공유 커널을 활용하지 않는 것이 좋다.
(공유 커널 이용의 장점보다 공유 커널로 인해 개발이 지연되고 정체되는 문제가 더 커지게 된다)
독립 방식은 두 BOUNDED CONTEXT 간의 통합이 수동으로 이뤄진다.
(판매 정보를 ERP 시스템에 직접 입력)
다만, 규모가 커질 수록 수동 통합에는 한계가 있으므로 그 떄는 두 BOUNDED CONTEXT 간에 통합이 필요하다.
나무가 아닌 숲을 보기 위한 전체 비즈니스를 조망하는 지도
시스템의 전체 구조를 보여준다.
컨텍스트 맵은 저네 시스템의 이해 수준을 보여준다. 즉, 시스템을 더 잘 이해하거나 시간이 지나면서 컨텍스트 간 관계가 바뀌면 컨텍스트 맵도 함꼐 바뀐다.
Bounded Context..?