DDD에서는 aggregate라는 용어가 자주 등장한다. DDD의 다양한 개념들을 이해하기 위해서는 aggregate에 대한 이해가 선행되어야 하는데, 보통 DDD 자료들에서는 aggregate에 대한 설명이 중후반부에 등장한다. DDD 초보자도 쉽게 이해할 수 있도록 aggregate를 겉핥기해보자.
Aggregate란 무엇인가 도대체! - DDD aggregate의 기초 쉽게 이해하기
Aggregate와 일관성
Aggregate는 "일관성 경계"다. 시스템이 어떤 동작을 한 번 수행할 때 한꺼번에 변경되어야 하는, 즉, 함께 일관성을 유지해야 하는 entity들의 집합이라고 할 수 있다. 왜 경계라는 표현을 사용할까? 도메인 모델링을 진행했다면, 머릿속에 둥둥 떠다니던 여러가지 비즈니스 용어들이 다양한 entity와 value object(VO)들로 정의되어 있을 것이다. 이 객체들 사이의 관계를 머릿속에 그려보면 마치 그물망처럼 복잡한 형상이 떠오를 것이다.
이때, 특정 비즈니스 로직이 실행될 때 서로 강하게 영향을 주는 것들을 위주로 묶어보면 조금 더 객체들 사이의 관계가 정리되는 기분이다. 그룹으로 묶기 위해 경계선을 그릴 때는 함께 변경되어야 하는 entity들을 묶는다는 규칙으로 진행하면 된다. 말그대로 "일관성 경계"를 쳐주는 것이다. 이런 과정을 통해 탄생한 entity의 그룹이 바로 aggregate다. VO는 상태를 유지하지 않기 때문에 우선 entity의 attribute 정도로 생각하고 넘어가자. Entity를 중심으로 설명을 이어가겠다.
좀 더 깊이 이해하기 위해서는 일관성의 종류에 대해 짚고 넘어가야 한다. DDD에서 일관성이라는 개념을 언급할 때는 다음과 같이 두 가지로 구분하여 생각한다.
- 트랜잭션적 일관성(transactional consistency)
- 결과적 일관성(eventual consistency)
트랜잭션적 일관성은 쉽게 말해서 하나의 트랜잭션 안에서 유지되는 일관성을 의미한다. 결과적 일관성은 서로 다른 트랜잭션 사이에 시간 지연이 있더라도 최종적으로 일관성이 달성되는 것을 뜻한다. 결과적 일관성이 트랜잭션적 일관성보다 좀 더 느슨한 개념이다. Aggregate에서 말하는 일관성은 이 둘 중에서 트랜잭션 일관성에 해당한다. 트랜잭션을 분리해서 결과적 일관성만 달성되어도 충분한 동작을 모델링할 때는 별도의 aggregate로 나누어서 설계한다.
"1 transaction, 1 aggregate!"
Aggregate를 도입할 때의 이상적인 목표는 "하나의 트랜잭션에서 반드시 하나의 aggregate만 업데이트하는 것"이다. App에서 사용자가 use case를 실행시키면 트랜잭션이 시작되고 다양한 domain entity들이 변경되겠지만, 한꺼번에 변경되는 entity들은 모두 하나의 aggregate에 속해있어야 한다는 것이다. 왜냐고? Aggregate를 도입한 이유를 생각해보길 바란다! Aggregate는 트랜잭션적 일관성을 모델링한 것이다. 만약, 하나의 트랜잭션 안에서 여러 aggregate가 수정되고 있다면, aggregate 설계 자체에 문제가 있음을 암시하는 것이다.
Aggregate root: 비즈니스 로직의 단일 진입점
Entity들을 그룹으로 묶으면 entity들 사이의 복잡한 관계가 조금이나마 정리가 된다. 그런데, 여전히 비즈니스 로직을 실행할 때 어디서부터 시작해야 하는지 파악하기 어렵다는 문제가 있다. 따라서, 사용자의 비즈니스 로직 실행 요청을 받아줄 "단일 진입점"이 필요하다.
Entity들을 일관성 경계로 묶어준 뒤에, 어떤 entity가 그룹을 대표할 것인지 결정해야 한다. 만약 마땅한 entity가 없다면 새로운 entity를 정의하면 된다. 이렇게 aggregate의 대표가 된 entity를 aggregate root라고 부른다. Aggregate root는 단일 진입점의 역할을 하기 때문에 aggregate 내에 포함된 다른 entity들의 참조 또는 식별자들을 포함한다. 자신에게 포함된 entity들을 활용하여 일관성을 해치지 않도록 유의하며 비즈니스 로직을 수행한다.
Vaughn Vernon의 <Implementing Domain-Driven Design>에서는 이 단일 진입점 개념을 설명하기 위해 "데메테르의 법칙(Law of Demeter)"과 "Tell, Don't Ask(TDA)"를 소개한다. 이 두 가지 개념은 모두 정보 은닉을 강조한다. 간단히 말해서 "멍청한 사용자와 뛰어난 도구"를 추구하는 것이다. 이를 aggregate에 적용해보면, aggregate를 사용하는 입장인 application service가 aggregate에 대해 너무 많은 것을 알고 있으면 안된다는 것이다.
좀 더 자세히 논의해보자. Application service는 use case를 실행할 때, aggregate root에 해당하는 객체에게 실제 비즈니스 로직을 위임한다. Aggregate root의 내부에 존재하는 파트 객체들을 직접 조작해서는 안된다. 다시 말해서, aggregate가 직접 내부에 포함된 파트 객체들에게 비즈니스 로직을 수행하도록 명령하고, 그 결과만을 application service에 내어주어야 한다는 것이다. Application service가 aggregate의 파트 객체의 참조를 직접 얻어온 뒤에 파트 객체의 메서드를 직접 실행해서는 안된다. 이 설명을 python 의사코드로 만들어서 이해해보자.
class PartObject:
def do_part_job(self) -> int:
return 1
class AggregateRoot:
def __init__(self) -> None:
self.total: int = 0
self.part_objects: List[PartObject] = []
def do_part_job(self) -> int:
self.total = sum([p.do_part.job() for p in self.part_objects])
return self.total
위와 같이 aggregate root와 그에 속한 파트 객체가 정의되어 있다고 가정해보자. 이때, do_part_job
이라는 use case를 실행하는 application service는 다음과 같이 구현되어야 한다. Application service가 하나의 트랜잭션 안에서 aggregate root의 메서드를 단순 호출하여 모든 비즈니스 로직을 위임하는 것을 확인할 수 있다.
class ApplicationService:
def do_part_job(self, id: int) -> int: # Good!
total = 0
with self.session() as session:
aggregate_root = self.repository().read(id=id)
total = aggregate_root.do_part_job()
return total
잘못된 예시도 살펴보자. Application service가 aggregate root에 대해 너무 많은 것을 알게 되어 "똑똑한 사용자와 멍청한 도구" 관계로 역전된다면 어떻게 될까?
class PartObject:
def do_part_job(self) -> int:
return 1
class AggregateRoot:
def __init__(self) -> None:
self.total = 0
self.part_objects: List[PartObject] = part_objects
def get_part_objects(self) -> List[PartObject]:
return self.part_objects
class ApplicationService:
def do_part_job(self, id: int) -> int: # Bad!
total = 0
with self.session() as session:
aggregate_root = self.repository().read(id=id)
part_objects = aggregate_root.get_part_objects()
total = sum([p.do_part.job() for p in part_objects])
aggregate_root.total = total
return total
차이가 느껴지는가? Application service에 비즈니스 로직이 노출되었다. AggregateRoot
를 사용하는 모든 클라이언트들에게 do_part_job
이라는 use case를 실행할 때마다 복잡한 과정을 항상 기억하고 올바르게 수행해야 하는 책임이 생겨버렸다. 객체 사이의 일관성 유지를 위해 너무 많은 것을 알아야 하는 것이다. 그리고 AggregateRoot
는 그 어떤 의미있는 동작도 가지지 못한 무기력한 도메인 모델(anemic domain model)이 되어버렸다.
이러한 설계 오류를 최대한 회피하기 위해 파트 객체의 비즈니스 로직을 수행하는 메서드에 protected 접근제어자를 붙이는 것이 권장된다. 해당 메서드에 aggregate root 객체만 접근 가능하도록 클래스를 위치시키고, application service같은 외부 클라이언트에서는 접근하지 못하도록 막는 것이다. 하지만, python 진영에서는 "우리를 애 취급하지 말라!"는 문화가 형성되어 있다. 조금이라도 개발자들이 무언가를 못하게 만드는 제약이 생기는 것을 참지 못한다. 따라서, python에는 protected 접근제어자가 없다. 동료들이 application service에서 파트 객체의 메서드를 직접 호출하지 못하도록 두 눈에 불을 켜고 지켜봐야 한다!
Aggregate repository로 영속성 관리하기
Aggregate는 트랜잭션적 일관성 경계 안에 있는 객체들의 집합이고, 동시에 비즈니스 로직의 단일 진입점을 제공하는 객체이기도 하다. 그렇다면 이 aggregate라는 것은 DB에서 어떤 식으로 관리되어야 할까? Aggregate를 조립해야 하는 책임을 누가 져야 하는지에 대해 고민해보면 답을 알 수 있다. 결론부터 얘기하자면, aggregate repository가 조립의 책임을 지면 된다.
"1 aggregate, 1 repository!"
앞서 살펴본 데메테르의 법칙과 TDA에 의해, 사용자 입장에서는 aggregate root랑만 소통할 수 있다면 비즈니스 로직을 실행하는데 전혀 문제가 없어야 한다. 따라서, repository에서는 aggregate root 객체만 꺼내주면 된다. Aggregate에 속한 파트 객체들을 일일이 제공할 필요는 없다. 이는 repository가 entity와 1:1 대응되는 것이 아니라, aggregate root와 1:1 대응된다는 것을 시사한다. 예를 들어, 위 예시에서 AggregateRoot
객체를 관리하는 AggregateRepository
만 필요할 뿐, PartObjectRepository
는 따로 정의할 필요없다.
class AggregateRepository:
def read(self, id: int) -> AggregateRoot:
part_objects = self.get_part_objects_from_db(id=id)
aggregate_root = self.get_aggregate_root_from_db(id=id)
aggregate_root.part_objects = part_objects
return aggregate_root
Aggregate root의 수만큼만 repository를 정의하여 유지하자. 클라이언트인 application service가 aggregate root의 식별자를 통해 객체를 요구하면, aggregate repository가 필요한 파트 객체들을 꺼내어 조립한 뒤 반환해주면 된다. Python의 sqlalchemy같은 ORM 도구를 사용한다면, relationship
과 joinedload
등을 활용하여 이를 쉽게 구현할 수 있다. Sqlalchemy의 사용 방법은 글의 범위를 벗어나므로 다른 글에서 자세히 다루도록 하겠다.
내용 간단 요약
- Aggregate는 여러 entity(+ VO)들을 트랜잭션적 일관성 경계로 묶은 것이다.
- 비즈니스 로직의 단일 진입점을 제공하는 객체를 aggregate root라고 한다.
- Repository는 entity가 아니라 aggregate마다 하나씩 정의해주자.
주의사항 및 예고편
이 글에서는 aggregate에 대해 아주 기초적이고 표면적인 내용만 다루었다. DDD의 다른 복잡한 개념들을 접하기 전 알아야 하는 최소한의 내용 정도로 받아들이면 된다. 그런데, 사실 aggregate는 DDD에서 가장 핵심적이면서도 어려운 주제 중 하나이기 때문에 이 글에서 다룬 내용들만으로는 충분하지 않다. 추가적으로 어려운 내용들을 살펴보아야 한다.
예를 들어, aggregate root가 포함하는 entity들의 객체 참조를 직접 들고 있어야 할까? 아니면 식별자들만 가지고 있는 것으로 충분할까? 답을 어떻게 내리느냐에 따라 영속성과 관련된 설계 자체가 바뀌게 된다. 그리고 객체의 관계가 복잡할수록 aggregate에 대한 단순 읽기 작업의 성능이 저하된다. 이러한 문제는 어떻게 해결할 수 있을까? 또 만약 다양한 클라이언트가 동시에 하나의 aggregate를 변경하려고 할 때는 어떻게 처리해야 할까? Aggregate는 일관성을 보장해주어야 하는 책임이 있기 때문에 동시성에 대해서도 특별히 신경을 써야 한다.
이렇게 복잡한 내용들을 한꺼번에 다루면 글의 분량이 지나치게 많아지고 지루해질 것이 분명하다. Aggregate의 파트 객체들을 설계하는 방법, CQRS로 읽기 성능을 최적화하는 방법, 동시성 경계로 설계하는 방법 등에 대해서는 다른 글에서 자세히 소개하도록 하겠다.
More Posts
Validation 코드는 어디에 작성해야 할까? - The 3 types of validation logics
개발자들은 다양한 validation 코드들을 작성하는데 많은 시간을 소비한다. 이곳저곳에 덕지덕지 붙어있는 validation 코드들을 바라보면, 과연 이 코드들이 여기에 있어도 되는 것인지 의문이 생긴다. 다양한 종류의 validation 코드들을 어디에 작성해야 하는지 정리해보자.
Domain model에서 repository를 직접 사용해도 될까? - 도메인 모델의 영속성 무지
Repository는 인프라적인 요소에 가깝다고 할 수 있다. 그런데, 이 repository를 도메인 모델에서 직접 사용해도 괜찮을까? DDD와 관련된 여러 참고 문서들에서는 이에 대해 통일되지 않은 견해를 보이고 있다. 이 글을 통해 여러 견해들을 한꺼번에 모아서 생각해보자.
Aggregate의 문제점과 바람직한 설계 방법 - DDD aggregate diet
DDD의 aggregate는 일관성 관리를 위해 매우 중요한 개념이지만, 덩치가 커질수록 성능과 확장성 측면에서 문제가 발생하게 된다. Composition 기반 aggregate의 문제점에 대해 자세히 살펴보고, 효율적인 aggregate를 설계하기 위한 방법들을 알아보자.
Comments