도메인?
도메인은 어떤 문제를 소프트웨어로 해결하고자 하는 영역이다. 온라인 서점을 예로 들 수 있다. 하나의 도메인은 하위 도메인으로 나눌 수 있다. 온라인 서점은 회원, 주문, 배송, 결제 등의 도메인으로 나누어진다. 각 도메인의 하위 도메인의 이름이 같다고 해서 같은 도메인이라고 착각하면 안된다. 서로 다른 하위 도메인은 같은 용어를 사용할 뿐, 전혀 다른 의미와 데이터 정보를 가지고 있을 수 있다.
요구사항을 올바르게 이해하고 도메인을 잘 설정하려면 개발자와 전문가가 함께 이야기 해야 한다. 개발자도 도메인 지식을 갖추고 있어야 도메인 전문가가 요구한 것에 가까운 제품을 만들 수 있다.
처음 도메인 모델을 구성할 때 빠지기 쉬운 함정은 도메인을 완벽하게 표현하는 단일 모델을 만드는 시도를 하는 것이다. 그러나 도메인은 보통 여러 하위 도메인으로 구분되기 때문에 한 개의 모델로 여러 하위 도메인을 모두 표현하려 시도하면 도메인 모델이 복잡하고 확장성이 좋지 않게 될 수 있다.
도메인 모델
도메인 모델은 특정 도메인을 개념적으로 표현한 것이다. 아래는 주문이라는 도메인 모델을 객체 모델로 구성한 것이다. 주문 도메인에는 주문 번호, 총 주문 금액, 배달과 결제 정보를 가진다는 것을 파악할 수 있다.
이렇게 도메인 모델을 사용하면 여러 이해관계자가 도메인을 이해하고 도메인 지식을 공유하는 데 도움이 된다. 도메인 모델은 객체 모델 뿐만 아니라, 상태 다이어그램, 그래프 등등 여러 표현 방식을 통해 도식화할 수 있다.
도메인 모델 패턴
일반적인 애플리케이션 아키텍처는 다음과 같은 네 개의 영역으로 구성된다.
- 표현: UI, 사용자의 요청을 처리하고 정보를 보여준다. (웹 브라우저 사용자, 서드파티 애플리케이션 등)
- 응용: 클라이언트가 요청한 기능을 처리한다. (업무 로직을 직접 구현하지 않고 도메인 계층을 조합함)
- 도메인: 시스템이 제공할 도메인 규칙을 구현한다.
- 인프라: DB, Message Brocker등 외부 시스템과의 연동을 처리한다.
도메인 계층은 도메인의 핵심 규칙을 구현한다. 가령 주문 도메인에서 ‘출고 전에 배송지를 변경할 수 있다’라는 규칙과 ‘주문 취소는 배송 전에만 할 수 있다’라는 규칙을 구현한 코드는 도메인 계층에 위치해야 한다.
도메인 모델 도출
도메인을 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것이다.
주문할 때 배송지 정보를 반드시 지정해야 한다. 배송지 정보는 받는 사람 이름, 전화번호 주소로 구성된다. 출고를 하면 배송지를 변경할 수 없다 등의 요구사항에서 기능을 추출할 수 있다. 출고 상태 변경하기, 배송지 정보 변경하기, 주문 취소하기, 결제 완료하기 등의 기능을 Order 도메인의 메서드로추가할 수 있다.
class Order {
private List<OrderLine> orderLines;
private Monty totalAmounts;
public void changeShipped() {...}
public void changeShippedInfo(ShippingInfo newShipping) {...}
public void cancle() {...}
public void completePayment() {...}
}
최소 한 종류 이상의 상품을 주문해야 한다. 한 상품을 한 개 이상 주문할 수 있다. 총 주문 금액은 각 상품의 가격 합을 모두 더한 금액이다 등의 요구사항에서 데이터 정보도 추출할 수 있다.
public class OrderLing {
private final Product proudct;
private final Money price;
private final int quantity;
private final Money amounts;
...
}
엔티티와 벨류
도출한 모델은 크게 엔티티(Entity)와 벨류(Value)로 구분할 수 있다. 엔티티는 식별자를 가진다. 엔티티는 객체마다 고유한 식별자를 가지며 식별자가 다르다면 같은 프로퍼티를 가진다고 해서 같은 엔티티라고 할 수 없다.
벨류 타입은 개념적으로 완전한 하나를 표현할 때 사용한다.
예를 들어 받는 사람과 주소는 개념적으로 하나여야 한다. ShppingInfo는 Receiver와 Address를 가진다. 다른 예를 들어 상품 정보는 개념적으로 하나여야 한다. OrderLine은 Product, price, quantity, amounts 등의 프로퍼티를 가질 수 있다. 벨류 타입은 모든 프로퍼티가 동일하다면 교체하여 사용할 수 있다. 벨류 객체는 내부 프로퍼티를 불변으로 가진다.
도메인 모델의 엔티티와 DB 모델의 엔티티는 다른 것이다. 도메인 모델의 엔티티는 문제 영역을 해결하기 위한 데이터와 메서드를 포함한다. DB 모델의 엔티티는 그저 애플리케이션과 DB영역의 경계선을 넘나드는 객체이다. 또, 도메인 모델의 엔티티는 두 개 이상의 데이터가 개념적으로 하나인 경우 밸류 타입을 이용하여 표현할 수 있다.
애그리거트(aggregate)
도메인이 커질수록 개발할 도메인 모델이 커지면서 많은 엔티티와 벨류가 출현한다. 점차 도메인 모델을 복잡해질 것이며 각 도메인의 상관관계와 경계가 모호해져 관리하기 어려운 문제에 봉착한다.
애그리거트는 관련 객체를 하나로 묶은 군집이다. 주문이라는 상위 개념에 배송지 정보, 주문자, 주문 목록등의 하위 개념이 포함된다. 이런 상위 개념과 하위 개념을 묶어 애그리거트라 한다.
애그리거트는 군집에 속한 객체를 관리하는 루트 엔티티를 가진다. 루트 엔티티는 애그리거트에 속해있는 엔티티와 밸류 객체를 이용해서 애그리거트가 구현해야 할 기능을 제공한다. 즉, 애그리거트에 포함되어있는 엔티티의 기능 호출은 루트 엔티티로부터 호출되어야만 한다.
리포지터리(Repository)
도메인 객체를 지속적으로 사용하려면 물리적인 저장소에 영속화 시켜야 한다. 이를 추상화한 모델인 리포지터리이다. 리포지터리는 애그리거트 단위로 도메인 객체를 저장하고 조회하는 기능을 정의한다.
애그리거트(aggregate)
애그리거트는 유사한 관심사를 가지는 하나 이상의 도메인 엔티티와 밸류를 묶은 것이다. 애그리거트를 통해 도메인의 영역과 경계를 더 분명하게하고 이는 전체 도메인 모델의 이해와 관리를 돕는다. 200개가 넘는 도메인 엔티티와 밸류를 날 것 그대로 바라본다면 이해하기 너무 어려울 것이다. 하지만 애그리거트를 통해 상위 개념으로 바라본다면 이해가 쉬울 것이다.
한 애그리거트에 속한 객체는 유사하거나 동일한 라이프 사이클을 가진다. 애그리거트는 경계를 가진다. 한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않는다. 요구사항에서 함께 변경되는 빈도가 높은 객체는 한 애그리거트에 속할 가능성이 높다.
애그리거트 루트
애그리거트로 묶인 도메인 엔티티와 밸류를 외부에서 각자 변경하는 것을 허용한다면 비즈니스 룰을 어길 가능성이 있다. 예를 들어 주문은 최소 상품 수와 최소 주문 금액이 존재하는데, 상품 엔티티를 변경해서 최소 주문 금액을 만족하지 않는(비즈니스 룰을 어기는) 주문 엔티티를 만들 수도 있다는 것이다.
애그리거트에 속한 모든 객체가 일관된 상태를 유지하려면 애그리거트 전체를 관리할 주체가 필요하다. 이를 책임지는 것이 애그리거트 루트이며, 애그리거트에 속한 객체는 애그리거트 루트 엔티티에 직접적 혹은 간접적으로 속하게 된다.
애그리거트 루트의 핵심 역할을 애그리거트의 일관성이 깨지지 않도록 하는 것이다. 애그리거트에 속한 각 엔티티의 일관성을 유지하면서 도메인 로직을 수행하려면 애그리거트 루트 엔티티에 정의된 메서드로 각 엔티티를 간접적으로 변경해야 한다.
애그리거트와 트랜잭션
한 트랜잭션에서는 한 애그리거트만 수정해야 한다. 한 트랜잭션에서 두 개 이상의 애그리거트를 수정하면 트랜잭션 충돌이 발생할 가능성이 높아 진다. 즉 DB 레벨에서 락을 거는 범위가 넓어지게 되고 동시 처리에 있어 오버헤드가 발생할 가능성이 높아진다는 것이다. 이는 자연스럽게 처리량을 낮춘다. 한 트랜잭션에서 한 애그리거트만 수정한다는 것은 애그리거트에서 다른 애그리거트를 변경하지 않는다는 것을 의미한다.
부득이하게 한 트랜잭션으로 두 개 이상의 애그리거트를 수정해야 한다면 애그리거트에서 다른 애그리거트를 수정하는 방식이 아닌 응용 서비스에서 두 애그리거트를 수정하는 방식으로 구현한다.
애그리거트와 리포지토리
애그리거트는 개념상 완전한 한 개의 도메인 모델을 표현한다. 따라서 객체의 영속성을 처리하는 리포지토리는 애그리거트 단위로 존재한다. 예를 들어 주문 애그리거트의 루트 엔티티인 Order와 주문 상품에 해당 하는 엔티티인 OrderLine은 각각의 리포지토리를 생성하지 않고 Order에 해당하는 리포지토리만 정의한다.
리포지토리는 애그리거트 전체를 DB에 영속화하고 애그리거트 단위로 읽는다. 영속화 시 애그리거트와 관련된 테이블이 여러개라면 애그리거트 루트를 포함한 모든 매핑된 테이블에 데이터를 저장한다.
애그리거트와 ID 참조
애그리거트는 다른 애그리거트를 참조할 수 있다. 애그리거트 간 참조는 필드를 통해 쉽게 구현할 수 있다. JPA의 @OneToOne, @ManyToOne 등의 어노테이션으로 연관된 객체를 로딩하기에도 편리하다. 하지만 이러한 방식의 참조는 편한 탐색 오용, 성능에 대한 고민, 확장 어려움의 문제를 야기할 수 있다.
먼저 애그리거트 간 객체 참조를 가지게 된다면, 다른 애그리거트를 변경할 가능성이 생겨버린다. 또한 각 객체를 Lazy 혹은 Eager 전략으로 로딩해야 하기에 선택 사항이 하나 더 생긴다.
내가 생각했을 때의 가장 큰 문제는 확장성이 떨어진다는 것이다. 트래픽이 증가하면서 부하분산을 위해 각 도메인마다 다른 DBMS를 사용할 수 있다. 이러한 경우 각 애그리거트가 객체 참조 구조로 이루어진 경우 문제가 발생한다. 떠한 모놀리식 애플리케이션의 덩치가 커지면서 MSA 아키텍처로 마이그레이션해야 할 수도 있다. 각 도메인 별로 서비스와 로컬 DB를 구성한다. MSA 아키텍처에서 서비스를 넘나드는 객체 참조는 불가능하므로 객체 참조는 확장성이 떨어진다. 이를 애그리거트 ID를 통해 참조하는 것으로 해결한다.
애그리거트를 ID를 통해 참조하면 여러 애그리거트를 한번에 조회해야할 시 N+1 문제가 발생할 수 있다. 예를 들어 회원 과 회원의 주문 목록을 불러오는 쿼리 기능이 존재한다면 회원 애그리거트를 먼저 조회하고, 회원 애그리거트 ID에 해당하는 주문 애그리거트를 쿼리해야 한다. ID 방식을 사용하면서 N+1 문제를 해결하기 위해서 쿼리를 위한 별도의 DAO를 생성하면 된다. CQRS 패턴의 미니버전이라고 생각하면 된다. 쿼리 전용 DAO 에서는 조인을 통해 연관된 애그리거트를 불러온다.
결론
복잡한 비즈니스 룰과 도메인을 개발에 녹여내면 각 이해관계자들이 쉽게 이해할 수 있고, 전체 구조를 쉽고 빠르게 파악할 수 있다. 요구사항에서 도메인 엔티티와 밸류, 그리고 각 하위 엔티티를 분류하고 이를 하나의 집합인 애그리거트로 묶는다. 애그리거트는 애그리거트 루트를 두고 루트를 통해서만 애그리거트를 변경할 수 있게 하여 해당 애그리거트의 정책을 유지한다. 애그리거트는 애그리거트 단위로 영속화 되며, 애그리거트 간의 참조는 ID값을 통한다.
Reference
도메인주도 개발 시작하기:DDD 핵심 개념 정리부터 구현까지(최범균, 2022)
'CS' 카테고리의 다른 글
DDD - 외부 시스템 호출 분리와 이벤트 (0) | 2024.04.06 |
---|---|
DDD - 애그리거트 트랜잭션 관리와 동시성 처리 (0) | 2024.04.05 |
Blocking vs Non-Blocking I/O (0) | 2024.03.26 |
IPC: 메시지 포맷과 설계 순서 (3) | 2024.03.19 |
비동기 메시징 통신: Message vs Event (0) | 2024.03.17 |