본문은 [도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지]를 읽고 간단하게 정리한 글입니다. 필요에 따라 생략/수정된 부분이 있을 수 있으며, 내용이 추후 변경될 수 있습니다.
3.1 애그리거트
- 특정 시스템(온라인 쇼핑몰 시스템)을 개발할 때 [그림3.1]과 같이 상위 수준 개념을 이용해서 전체 모델을 정리하면 전반적인 관계를 이해하는 데 도움이 된다
- [그림3.1]의 상위 수준 모델을 개별 객체 단위로 다시 그려보면 [그림3.2]와 같다
- 상위 모델에 대한 이해 없이 [그림3.2]만 보고 상위 수준에서 개념을 파악하는 것은 더 오랜 시간이 걸린다
- 도메인 객체 모델이 복잡해지면 개별 구성요소 위주로 모델을 이해하게 되고 전반적인 구조나 큰 수준에서 도메인 간의 관계를 파악하기 어려워진다
- 주요 도메인 요소 간의 관계를 파악하기 어렵다는 것은 코드를 변경하고 확장하는 것이 어려워진다는 것을 의미한다
- 상위 수준에서 모델이 어떻게 엮여 있는지 알아야 전체 모델을 망가뜨리지 않으면서 추가 요구사항을 모델에 반영할 수 있다
- 애그리거트를 사용하면 상위 수준 모델을 조망할 수 있다
- 애그리거트는 관련된 객체를 하나의 군으로 묶어 주는 것이다(2장 참고)
- [그림 3.3]은 [그림3.2]의 모델을 애그리거트 단위로 묶어서 다시 표현한 것이다
- 동일한 모델이지만 애그리거트를 사용함으로써 모델 간의 관계를 개별 모델 수준과 상위 수준에서 모두 이해할 수 있다
- 애그리거트는 모델을 이해하는 데 도움을 줄 뿐만 아니라 일관성을 관리하는 기준도 된다
- 모델을 보다 잘 이해할 수 있고 애그리거트 단위로 일관성을 관리하므로 애그리거트는 복잡한 도메인을 단순한 구조로 만들어준다
- 복잡도가 낮아지는 만큼 도메인 기능을 확장하고 변경하는 것도 쉬워진다
- 애그리거트는 관련된 모델을 하나로 모았기 때문에 한 애그리거트에 속한 객체는 대체로 유사하거나 동일한 라이프 사이클을 지닌다
- [그림 3.3]에서 보는 것처럼 애그리거트는 경계를 갖는다
- 한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않는다
- 애그리거트는 독립된 객체 군이며 각 애그리거트는 자기 자신을 관리할 뿐 다른 애그리거트를 관리하지 않는다
- 경계를 설정할 때 기본이 되는 것은 도메인 규칙과 요구사항이다
- 도메인 규칙에 따라 함께 생성되는 구성요소는 한 애그리거트에 속할 가능성이 높다
- 'A가 B를 갖는다'로 설계할 수 있는 요구사항이 있다면 A와 B를 한 애그리거트로 묶어서 생각하기 쉽지만 실제론 그렇지 않다([그림3.4] 참고)
- 처음 도메인 모델을 만들기 시작하면 큰 애그리거트로 보이는 것들이 많지만, 도메인에 대한 경험이 생기고 도메인 규칙을 제대로 이해할수록 애그리거트의 실제 크기는 줄어든다
- (필자의 경험) 다수의 애그리거트가 한 개의 엔티티 객체만 갖는 경우가 많았으며, 두 개 이상의 엔티티로 구성되는 애그리거트는 드물었다
3.2 애그리거트 루트
- 애그리거트는 여러 객체로 구성되므로 한 객체만 상태가 정상이면 안 된다. 즉, 도메인 규칙을 지키려면 애그리거트에 속한 모든 객체가 정상 상태를 가져야 한다
- 예를 들어 구매할 상품의 주문 개수(OrderLine.quantity)를 바꾸면 주문 총 금액(Order.totalAmounts)도 변경되어야 한다
- 이는 "주문 총 금액 = 개별 상품의 주문 개수 X 가격의 합"이라는 도메인 규칙을 준수하고 데이터 일관성을 지키기 위함이다
- 루트 엔티티
- 애그리거트에 속한 모든 객체가 일관된 상태를 유지하기 위해서 애그리거트 전체를 관리할 주체
- 애그리거트 루트 엔티티는 애그리거트의 대표 엔티티
- 에그리거트에 속한 객체는 애그리거트 루트 엔티티에 직접 또는 간접적으로 속한다
- [그림 3.5]에서 Order가 루트 엔티티에 해당한다
1) 도메인 규칙과 일관성
- 애그리거트 루트의 핵심 역할은 애그리거트의 일관성이 깨지지 않도록 하는 것이다
- 이를 위해 애그리거트 루트는 애그리거트가 제공해야 할 도메인 기능을 구현한다
- 예를 들어 주문 애그리거트는 배송지 변경, 상품 변경과 같은 기능을 제공하고, 애그리거트 루트인 Order가 이 기능을 구현한 메서드를 제공한다
- 이때, 에그리거트 루트가 제공하는 메서드는 도메인 규칙에 따라 애그리거트에 속한 객체의 일관성이 깨지지 않도록 구현한다
- 이를 위해선 애그리거트 외부에서 애그리거트에 속한 객체를 직접 변경하면 안 되는데, 이는 애그리거트 루트가 강제하는 규칙을 적용할 수 없어 모델의 일관성을 깨는 원인이 된다
- 일관성을 지키기 위해 상태 확인 로직을 응용 서비스에 구현할 수도 있지만, 이는 동일한 검사 로직을 여러 응용 서비스에서 중복으로 구현할 가능성이 높아져 유지 보수에 도움이 되지 않는다
- 불필요한 중복을 피하고 애그리거트 루트를 통해서만 도메인 로직을 구현하게 만들려면 도메인 모델에 대해 다음의 두 가지를 습관적으로 적용해야 한다
- 1) 단순히 필드를 변경하는 set 메서드를 public으로 만들지 않는다
- 공개 set 메서드는 도메인의 의미나 의도를 표현하지 못하고 도메인 로직을 도메인 객체가 아닌 응용 영역이나 표현 영역으로 분산시킨다
- 도메인 로직이 한 곳에 응집되지 않으므로 코드를 유지 보수할 때도 분석하고 수정하는 데 더 많은 시간이 필요하다
- 공개 set 메서드 대신 의미가 드러나는 메서드를 사용하자(cancel, changePassword 등)
- 2) 밸류 타입은 불변으로 구현한다
- 공개 set 메서드를 만들지 않는 것의 연장선
- 밸류 객체의 값을 변경할 수 없으면 애그리거트 외부에서 밸류 객체의 상태를 변경할 수 없다
- 애그리거트 외부에서 내부 상태를 함부로 바꾸지 못하므로 애그리거트의 일관성이 깨질 가능성이 줄어든다
- 1) 단순히 필드를 변경하는 set 메서드를 public으로 만들지 않는다
2) 애그리거트 루트의 기능 구현
- 애그리거트 루트는 애그리거트 내부의 다른 객체를 조합해서 기능을 완성한다
- 애그리거트 루트는 구성요소에게 기능 실행을 위임하기도 한다
- 팀 표준이나 구현 기술의 제약으로 밸류 타입을 불변으로 구현할 수 없다면 밸류 타입의 변경 기능을 패키지나 protected 한정해서 외부에서 실행할 수 없도록 제한하는 방법도 있다
- 보통 한 애그리거트에 속하는 모델은 한 패키지에 속하므로 패키지나 protected 범위를 사용하면 애그리거트 외부에서 상태 변경 기능을 실행하는 것을 막을 수 있다
3) 트랜잭션 범위
- 트랜잭션 범위는 작을수록 좋다
- 더 많은 테이블을 수정하면 할수록 잠금 대상이 더 많아진다
- 이는 동시에 처리할 수 있는 트랜잭션 개수가 줄어든다는 것을 의미하고 결국 전체적인 성능(처리량)을 떨어뜨린다
- 한 트랜잭션에서는 한 개의 애그리거트만 수정해야 한다
- 한 트랜잭션에서 두 개 이상의 애그리거트를 수정하면 트랜잭션 충돌이 발생할 가능성이 더 높아진다(처리량 저하)
- 한 트랜잭션에서 한 애그리거트만 수정한다는 것은 애그리거트에서 다른 애그리거트를 변경하지 않는 것을 의미한다
- 한 애그리거트에서 다른 애그리거트를 수정하면 결과적으로 두 개의 애그리거트를 한 트랜잭션에서 수정하게 된다
- 애그리거트는 최대한 서로 독립적이어야 하는데, 한 애그리거트가 다른 애그리거트의 기능에 의존하기 시작하면 애그리거트 간 결합도가 높아진다
- 결합도가 높아지면 높아질수록 향후 수정 비용이 증가하므로 애그리거트에서 다른 애그리거트의 상태를 변경하지 말아야 한다
- 만약 부득이하게 한 트랜잭션으로 두 개 이상의 애그리거트를 수정해야 한다면 애그리거트에서 다른 애그리거트를 직접 수정하지말고 응용 서비스에서 두 애그리거트를 수정하도록 구현한다
- 도메인 이벤트를 사용하면 한 트랜잭션에서 한 개의 애그리거트를 수정하면서도 동기나 비동기로 다른 애그리거트의 상태를 변경하는 코드를 작성할 수 있다 (10장 참고)
- 한 트랜잭션에서 한 개의 애그리거트를 변경하는 것을 권장하지만, 다음 경우에는 한 트랜잭션에서 두 개 이상의 애그리거트를 변경하는 것을 고려할 수 있다
- 팀 표준: 팀이나 조직의 표준에 따라 사용자 유스케이스와 관련된 응용 서비스의 기능을 한 트랜잭션으로 실행해야 하는 경우가 있다
- 기술 제약: 기술적으로 이벤트 방식을 도울 수 없는 경우 한 트랜잭션에서 다수의 애그리거트를 수정해서 일관성을 처리해야 한다
3.3 리포지터리와 애그리거트
- 애그리거트는 개념상 완전한 한 개의 도메인 모델을 표현하므로 객체의 영속성을 처리하는 리포지터리는 애그리거트 단위로 존재한다
- 예를 들어 Order와 OrderLine을 물리적으로 각각 별도의 DB 테이블에 저장한다고 해서 Order와 OrderLine을 위한 리포지터리를 각각 만들지 않는다
- Order가 애그리거트 루트고 OrderLine은 애그리거트에 속하는 구성요소이므로 Order를 위한 리포지터리만 존재한다
- 어떤 기술을 이용해서 리포지터리를 구현하느냐에 따라 애그리거트의 구현도 영향을 받는다
- JPA를 사용하면 데이터베이스 관계형 모델에 객체 도메인 모델을 맞춰야 할 때도 있다
- 애그리거트는 개념적으로 하나이므로 리포지터리는 애그리거트 전체를 저장소에 영속화해야한다
- 저장할 때 애그리거트 루트 및 애그리거트에 속한 모든 구성요소에 매핑된 테이블에 데이터를 저장해야 한다
- 조회할 때 리포지터리 메서드는 완전한 애그리거트를 제공해야 한다
- 리포지터리가 완전한 애그리거트를 제공하지 않으면 필드나 값이 올바르지 않아 NullPointerException 등의 문제가 발생할 수 있다
- 애그리거트를 영속화할 저장소로 무엇을 사용하든지 간에 애그리거트의 상태가 변경되면 모든 변경을 원자적으로 저장소에 반영해야 한다
- RDBMS를 사용하면 트랜잭션을 이용해서 애그리거트의 변경이 저장소에 반영되는 것을 보장할 수 있다
- 몽고DB를 사용하면 한 개 애그리거트를 한 개의 도큐먼트에 저장함으로써 한 애그리거트의 변경을 손실 없이 저장소에 반영할 수 있다
3.4 ID를 이용한 애그리거트 참조
- 한 객체가 다른 객체를 참조하는 것처럼 애그리거트도 다른 애그리거트를 참조한다
- 애그리거트 관리 주체는 애그리거트 루트이므로 애그리거트에서 다른 애그리거트를 참조한다는 것은 다른 애그리거트의 루트를 참조한다는 것과 같다
- 애그리거트 간의 참조는 필드를 통해 쉽게 구현할 수 있다 ([그림 3.6] 참고)
- 필드를 이용해서 다른 애그리거트를 직접 참조하는 것은 개발자에게 구현의 편리함을 제공한다
- JPA는 @ManyToOne, @OneToOne과 같은 애너테이션을 이용해서 연관된 객체를 로딩하는 기능을 제공하고 있으므로 필드를 이용해 다른 애그리거트를 쉽게 참조할 수 있다
- ORM 기술 덕에 애그리거트 루트에 대한 참조를 쉽게 구현할 수 있고 필드 (또는 get 메서드)를 이용한 애그리거트 참조를 사용하면 다른 애그리거트의 데이터를 쉽게 조회할 수 있다
- 하지만 필드를 이용한 애그리거트 참조는 다음 문제를 야기할 수 있다
- 편한 탐색 오용
- 성능에 대한 고민
- 확장 어려움
1) 편한 탐색 오용
- 애그리거트를 직접 참조할 때 발생할 수 있는 가장 큰 문제는 편리함을 오용할 수 있다는 것이다
- 한 애그리거트 내부에서 다른 애그리거트 객체에 접근할 수 있으면 다른 애그리거트의 상태를 쉽게 변경할 수 있다
- 이는 구현을 편리하게 만들고, 결국 구현의 편리함 때문에 다른 애그리거트를 수정하고자 하는 유혹에 빠지기 쉽다
- 앞서 말했던 것처럼 한 애그리거트에서 다른 애그리거트의 상태를 변경하는 것은 애그리거트 간의 의존 결합도를 높여서 결과적으로 애그리거트의 변경을 어렵게 만든다
2) 성능에 대한 고민
- 두 번째 문제는 애그리거트를 직접 참조하면 성능과 관련된 여러가지 고민을 해야 한다는 것이다
- 예를 들어 JPA를 사용하면 참조한 객체를 지연 로딩과 즉시 로딩의 두 가지 방식으로 로딩할 수 있다
- 두 로딩 방식 중 무엇을 사용할지는 애그리거트의 어떤 기능을 사용하느냐에 따라 달라진다
- 결국 다양한 경우의 수를 고려해야 한다
3) 확장 어려움
- 세 번째 문제는 확장이다
- 처음엔 단일 DBMS로 서비스를 제공하는 것이 가능하지만 사용자가 늘고 트래픽이 증가하면 부하를 분산하기 위해 하위 도메인별로 시스템을 분리하기 시작하는데, 이 과정에서 하위 도메인마다 서로 다른 DBMS를 사용하거나 다른 종류의 데이터 저장소를 사용하게 될 수 있다
- 이는 더 이상 다른 애그리거트 루트를 참조하기 위해 JPA와 같은 단일 기술을 사용할 수 없음을 의미한다
- 이런 세 가지 문제를 완화할 때 사용할 수 있는 것이 바로 ID를 이용해서 다른 애그리거트를 참조하는 것이다
- ID 참조를 사용하면 모든 객체가 참조로 연결되지 않고 한 애그리거트에 속한 객체들만 참조로 연결된다
- ID 참조 방식의 장점
- 1) 애그리거트의 경계를 명확히 하고 애그리거트 간 물리적인 연결을 제거하기 때문에 모델의 복잡도를 낮춰준다
- 2) 애그리거트 간의 의존을 제거하므로 응집도를 높여주는 효과가 있다
- 3) 구현 복잡도가 낮아진다
- 4) 한 애그리거트에서 다른 애그리거트를 수정하는 문제를 근본적으로 방지할 수 있다
- 외부 애그리거트를 직접 참조하지 않기 때문에 애초에 한 애그리거트에서 다른 애그리거의 상태를 변경할 수 없다
- 5) 애그리거트별로 다른 구현 기술을 사용하는 것도 가능해진다
- 중요한 데이터인 주문 애그리거트는 RDBMS에 저장하고 조회 성능이 중요한 상품 애그리거트는 NoSQL에 저장할 수 있다
- 또한 각 도메인을 별도 프로세스로 서비스하도록 구현할 수도 있다
ID를 이용한 참조와 조회 성능
- 다른 애그리거트를 ID로 참조하면 참조하는 여러 애그리거트를 읽을 때 조회 속도가 문제될 수 있다
- 쉽게 생각해서 N+1 문제가 발생한다고 보면 된다
- 한 DBMS에 데이터가 있다면 조인을 이용해서 한 번에 모든 데이터를 가져올 수 있겠지만, 그렇지 않을 수 있다
- ID 참조 방식을 사용하면서 N+1 조회와 같은 문제가 발생하지 않도록 하려면 조회 전용 쿼리를 사용하면 된다
- 예를 들어 데이터 조회를 위한 별도의 DAO를 만들고 DAO의 조회 메서드에서 조인을 이용해 한 번의 쿼리로 필요한 데이터를 로딩하면 된다
- 애그리거트마다 서로 다른 저장소를 사용하면 한 번의 쿼리로 관련 애그리거트를 조회할 수 없다
- 이때는 조회 성능을 높이기 위해 캐시를 적용하거나 조회 전용 저장소를 따로 구성한다
- 이 방법은 코드가 복잡해지는 단점이 있지만 시스템의 처리량을 높일 수 있다
- 이는 한 대의 DB 장비로 대응할 수 없는 수준의 트래픽이 발생하는 경우 필수로 선택해야 하는 방법이다
- JPA에서 조회 전용 쿼리를 실행하는 방법은 5장에서 설명한다
- 11장에서는 CQRS에 대해 다루는데, 이 장에서는 명령 모델과 조회 전용 모델을 분리해서 구현하는 패턴에 대해 살펴본다
- 이때는 조회 성능을 높이기 위해 캐시를 적용하거나 조회 전용 저장소를 따로 구성한다
3.5 애그리거트 간 집합 연관
- 이 절에서는 애그리거트 간 1-N과 M-N 연관에 대해 살펴본다
- 이 두 연관은 컬렉션을 이용한 연관이다
- 개념적으로 존재하는 애그리거트 간의 1-N 연관을 실제 구현에 반영하는 것이 요구사항을 충족하는 것과는 상관없을 때가 있다
- 1의 입장에서 N에 해당하는 객체를 모두 가져오면 성능적인 문제가 일어날 수 있다
- 반대로 N의 입장에서 1에 해당하는 객체를 가져오는 것은 수월하다
- M-N 연관은 개념적으로 양쪽 애그리거트에 컬렉션으로 연관을 만든다
- 앞선 1-N 연관처럼 M-N 연관도 실제 요구사항을 고려하여 M-N 연관을 구현에 포함시킬지를 결정해야 한다
- 개념적으로는 양방향 M-N 연관이 존재하더라도 실제 구현에서는 요구사항을 고려하여 단방향 M-N 연관만 적용할 수도 있다
- RDBMS를 이용해서 M-N 연관을 구하려면 조인 테이블을 사용한다
3.6 애그리거트를 팩토리로 사용하기
- 응용 서비스에서 애그리거트가 갖고 있는 데이터를 이용해서 다른 애그리거트를 생성하는 경우 중요한 도메인 로직 처리가 응용 서비스에 노출될 수 있다
- 이런 경우 애그리거트에 팩토리 메서드를 구현하는 것을 고려해보자
- (도메인 기능을 넣기 위한 별도의 도메인 서비스나 팩토리 클래스를 만드는 것도 가능하다)
- 애그리거트를 팩토리로 사용할 때의 장점
- 1) 응용 서비스에 도메인 로직이 노출되지 않는다
- 따라서 도메인 로직을 변경해도 도메인 영역만 변경하면 되고, 응용 서비스는 영향을 받지 않는다
- 2) 도메인의 응집도가 높아진다
- 1) 응용 서비스에 도메인 로직이 노출되지 않는다
- 다른 애그리거트를 생성하기 위해 많은 정보가 필요하다면 다른 팩토리에 위임하는 방법도 있다
- 다른 팩토리에 위임하더라도 도메인 로직은 한 곳에 계속 위치한다
'책 > 도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지' 카테고리의 다른 글
[도메인 주도 개발 시작하기] 6장: 응용 서비스와 표현 영역 (0) | 2022.10.14 |
---|---|
[도메인 주도 개발 시작하기] 5장: 스프링 데이터 JPA를 이용한 조회 기능 (0) | 2022.10.07 |
[도메인 주도 개발 시작하기] 4장: 리포지터리와 모델 구현 (0) | 2022.10.03 |
[도메인 주도 개발 시작하기] 2장: 아키텍처 개요 (0) | 2022.09.16 |
[도메인 주도 개발 시작하기] 1장: 도메인 주도 개발 시작하기 (0) | 2022.09.07 |