DDD의 aggregate는 일관성 관리를 위해 매우 중요한 개념이지만, 덩치가 커질수록 성능과 확장성 측면에서 문제가 발생하게 된다. Composition 기반 aggregate의 문제점에 대해 자세히 살펴보고, 효율적인 aggregate를 설계하기 위한 방법들을 알아보자.
Aggregate의 문제점과 바람직한 설계 방법 - DDD aggregate diet
Composition 기반 aggregate의 문제점
Composition에 의한 성능과 확장성 문제
이전 글에서 aggregate에 대해 간단히 살펴보았다. 설명에 따르면 aggregate는 단일 진입점 역할을 하는 aggregate root라는 객체 하위에 일관성 경계로 묶인 여러 part object들이 composition 관계로 포함되어 있어야 하는 것처럼 보인다. (UML 기준으로 엄밀히 따지면 aggregation 관계이지만 설명의 편의를 위해 이 글에서는 composition으로 표현하겠다.) 쉽게 말해서, aggregate root 객체의 멤버 변수에 part object들의 객체 참조를 직접 가지고 있는 형태로 구현되는 것이다.
class PartObject:
pass
class AggregateRoot:
def __init__(self) -> None:
self.part_objects: List[PartObject] = []
그런데, 이러한 구조가 과연 바람직할까? 결론부터 말하자면, aggregate root가 part object들의 객체 참조를 직접 들고 있는 것은 성능과 확장성 측면에서 바람직하지 않다. 오랜 기간 app을 서비스하면 aggregate에 속하는 part object의 수가 점점 증가하기 마련이다. 덩치가 커진 aggregate는 시간 및 메모리 효율성 측면에서 불리하다. 또한, 하나의 트랜잭션에서 다수의 객체에 대한 일관성을 유지해줘야 하기 때문에 여러 사용자가 동시에 요청을 하는 환경에서는 동시성 문제가 발생할 수 있다. 따라서, 병렬처리나 분산처리 측면에서도 불리하다.
이러한 성능과 확장성 문제의 근본적인 원인은 aggregate repository로부터 aggregate root 객체를 불러올 때, 의존적인 part object들도 모두 꺼내서 메모리에 적재하기 때문이다. 이에 대해 python의 sqlalchemy같은 ORM 도구들이 제공하는 지연 로딩(lazy loading) 기능을 사용하면 되는 것 아니냐고 반문할 수도 있다. 물론 어느정도 속도와 메모리 문제를 완화시킬 수는 있다. 그런데, 복잡한 관계로 맺어진 aggregate에서는 문제가 되는 시점을 늦출 뿐, 여전히 part object에 대한 참조가 필요할 때마다 모든 객체들을 불러와야 한다는 사실에는 변함이 없다.
Python 예시: 바람직하지 않은 aggregate 설계
예시를 통해 좀 더 명확하게 이해해보자. AggregateRoot
라는 객체에 PartObject
객체들이 composition 관계로 포함되어 있다. AggregateRoot
는 상태가 activated
인 PartObject
들의 value
의 평균을 계산해서 activated_avg
에 저장하고 있다. 이 일관성 규칙은 aggregate 내에서 항상 유지되어야 한다.
from enum import Enum, auto
from typing import List
from dataclasses import dataclass
class State(Enum):
activated = auto()
deactivated = auto()
@dataclass
class PartObject:
id: int
state: State
value: int
@dataclass
class AggregateRoot:
id: int
part_objects: List[PartOjbect] = []
activated_avg: float = 0.0
위 코드에서 AggregateRoot
에 새로운 PartObject
를 추가하는 로직을 구현한다면 어떻게 해야 할까? PartObject
객체를 내부 part_objects
라는 리스트에 추가함과 동시에 일관성 유지를 위해 activated_avg
를 다시 계산해 주어야 한다. Part object 하나를 추가하는 매우 간단한 작업임에도 aggregate에 속한 모든 part object들의 참조가 필요하다.
class AggregateRoot:
...
def add_part_object(self, part_object: PartObject) -> None:
self.part_objects.append(part_object)
total = 0
for obj in self.part_objects:
if obj.state == State.activated:
total += obj.value
self.activated_total = total / len(self.part_objects)
물론 위 예제에서는 모든 part object들을 불러오지 않고 평균을 계산하도록 로직을 최적화할 수 있다. 쉽게 이해할 수 있도록 최대한 간단한 예시를 든 것이라고 양해해주길 바란다. 실제 다양한 비즈니스 로직에서는 part object들의 참조가 필요하다고 생각되는 경우를 쉽게 만나볼 수 있다. 그렇게 생각했기 때문에 코드에 composition 관계가 다수 포함되어 있는 것 아니겠는가?
AggregateRoot
객체를 AggregateRepository
라는 repository로부터 꺼낼 때에도 해당 AggregateRoot
에 포함된 모든 PartObject
들의 참조를 DB로부터 얻어야 한다. Python의 sqlalchemy를 사용한다면 대략 다음과 같은 형태의 코드가 탄생할 것이다. Foreign key나 relationship 등은 미리 잘 설정되어 있다고 가정하겠다.
class AggregateRepository(Repository):
...
def read(self, id: int) -> AggregateRoot:
query = (
select(AggregateRoot)
.where(AggregateRoot.id == id)
.options(joinedload(AggregateRoot.part_objects))
)
result = self.session.execute(query)
return result.scalars().unique().one_or_none()
정리하자면, composition을 활용한 aggregate는 단순한 read와 update 작업을 수행할 때마다 모든 의존적인 part object들의 참조를 얻기 위해 매번 DB와 메모리 사이에 데이터 교환을 해야 하는 구조라는 것을 알 수 있다. 이때의 오버헤드는 당연히 포함된 part object의 수가 많아질수록 점점 커진다. 실제 비즈니스 상황에서 하나의 aggregate가 얼마나 커질지 가늠이 쉽게 안된다면 심각한 리스크라고 할 수 있다. ORM 도구의 lazy loading같은 임시방편보다는 근본적인 설계의 변화가 필요하다.
성능과 확장성 향상을 위한 aggregate 설계 방법
Aggregate 가볍게 다이어트 시켜서 해결하기
앞서 살펴본 성능과 확장성 문제를 해결하기 위한 바람직한 설계 방향은 허무하게도 aggregate를 다시 단일 entity로 만드는 것이다. DDD 커뮤니티에서는 되도록이면 aggregate를 작게 유지하는 것을 권장하기 때문에, 거대한 aggregate를 쪼개서 작은 entity들로 만들어야 한다고 주장한다. 가장 이상적인 것은 aggregate root라는 단일 entity만으로 aggregate가 이루어질 때까지 쪼개는 것이다.
애써 일관성이라는 어려운 주제를 꺼내면서 entity들을 aggregate라는 그룹으로 묶어놨는데 다시 쪼개라니 당황스러울 것이다. 실제로 Niclas Hedhman이라는 사람은 금융 관련 프로젝트에서 대대적인 리팩토링을 진행했을 때, 약 70%의 aggregate를 단일 entity로 표현할 수 있었다고 주장한다. 나머지 30%도 2 ~ 3개 정도의 entity만으로 구성할 수 있었다고 한다. 그렇다면 애초에 DDD에서 aggregate라는 개념은 무시해도 될 정도로 무의미한 것일까?
비즈니스 로직에서 일관성 문제는 여전히 매우 중요하다. 사용자가 app에 다양한 요청을 날렸을 때, app 상태의 일관성이 보장되지 않는다면 뒤죽박죽 엉망인 결과들이 나타날 것이다. Aggregate를 단일 entity로 만들자는 주장은 일관성이 중요하다는 사실을 부정하려는 것이 아니다. 단지 트랜잭션적 일관성(transactional consistency)을 지키기 위해 굳이 여러 entity를 사용해야 하는지에 대한 의문이다. 그렇다면 어떻게 설계를 해야 하는 것일까?
Aggregate를 단일 entity로 만드는 과정
- 의존적인 entity들을 값 객체(value object)로 대체하기
- 결과적 일관성을 적극적으로 활용하기
- Attribute에 part object 객체의 참조 대신 식별자를 포함하기
대부분의 의존적인 part object들은 값 객체로 대체 가능하다. 쉽게 말해서, aggregate root의 멤버 변수 중 객체의 참조를 들고 있는 것을 단순한 기본 타입으로 만드는 것이라고 이해하면 된다. DB 관점에서 생각해보면, 자체적인 ID를 가지고 별도의 DB 테이블에서 관리되던 part object 정보에서 ID를 제거하고 aggregate root를 저장하는 테이블의 column에 모두 편입시켜버리는 것과 유사하다.
class Entity: # entities 테이블에 따로 저장됨
id: int # primary key
name: str
description: str
aggregate_root_id: int # foreign key
class ValueObject: # aggregate_roots 테이블에 함께 저장됨
# id: int
name: str
description: str
# aggregate_root_id: int
class AggregateRoot:
id: int
# info: Entity
info: ValueObject
Aggregate root에 반드시 1:1로 하나만 존재하는 part object들의 경우 이렇게 변경하는 것이 유용하다. 일관성 유지를 위해 part object의 값을 변경하는 역할과 책임이 aggregate root 쪽으로 넘어가기 때문에 부담이 커지는 것 아니냐는 의문이 들 수도 있다. 그런데, 애초에 aggregate root 자체가 사용자에게 단일 진입점을 제공하고 part object의 일관성 유지를 총괄하는 역할과 책임을 가지고 있다. 따라서, 역할과 책임이 지나치게 늘어난다고 보기는 어렵다.
정리하자면, 여러 entity로 정의해둔 클래스들과 일관성 경계에 의문을 품고 최대한 단일 진입점을 제외한 클래스들을 value object로 대체하려고 노력해야 한다. 이렇게 entity 대신 value object들로 이루어진 aggregate root를 설계하면 다음과 같은 이점이 있다.
- 성능 향상: entity는 별도의 저장소(e.g. DB 테이블)에서 상태 관리가 되어야 하지만, value object는 aggregate root와 함께 직렬화될 수 있기 때문에 상대적으로 가볍다. Value object들로만 이루어진 aggregate root는 저장소에서 불러올 때 SQL JOIN같은 무거운 연산이 필요없다.
- 테스트 용이성 향상: value object는 자체적인 상태가 존재하지 않고, 불변성이라는 특징 때문에 unit test를 통해 정확성을 확인하기가 쉽다. 상대적으로 entity에 비해 value object들을 대상으로 비교 연산을 하기가 쉽다고 이해하면 된다.
모든 aggregate들이 단일 entity와 value object들로 표현이 되면 좋겠지만, 앞서 언급했듯이 약 30% 정도는 2개 이상의 entity들이 필요한 경우가 있다. 특히 aggregate root가 다수의 part object들을 collection으로 포함하고 있는 경우에는 part object들을 value object로 변경하더라도 여전히 메모리 문제가 남아있다. 이외에도 아무리 고민을 해봐도 part object를 entity로 유지할 수 밖에 없다고 판단되는 경우가 있을 수도 있다.
리팩토링이 불가능해보이는 경우에도 여전히 aggregate를 단일 entity로 만드려는 노력을 멈추면 안된다. 이 경우에는 트랜잭션적 일관성은 포기하더라도 결과적 일관성(eventual consistency)만 만족되면 충분한지 검토해봐야 한다. 만약 결과적 일관성만으로 충분하다고 판단된다면, 의존적인 entity들을 별도의 aggregate root로 쪼개고 의존하는 쪽에서 객체의 참조 대신 ID 값을 들고 있도록 만들면 된다.
예를 들어, 앞선 python 코드 예제에서 AggregateRoot
에 새로운 PartObject
가 추가되었을 때, AggregateRoot
의 activated_avg
값이 반드시 즉각적으로 업데이트되어 UI에 표시되어야 할까? 단 1초의 지연도 허용되지 않는 것일까? Stakeholder들과 논의해보면 대부분의 경우 어느정도의 지연이 허용된다는 것을 알 수 있을 것이다. UI의 값 최신화 지연에 따른 불편함은 경고 메시지나 알람 등 다양한 방법을 통해 충분히 완화시킬 수 있다.
Event driven architecture(EDA)를 적용하면 쪼개진 aggregate 사이의 결과적 일관성이 유지되도록 구현할 수 있다. Aggregate root A에서 변화가 발생했을 때, 결과적 일관성 경계에 있는 다른 aggregate root B의 ID를 담은 도메인 이벤트를 발생시키면 된다. 발생된 도메인 이벤트는 적절한 handler가 받아서 해당하는 aggregate root B의 메서드를 호출한다. 이때, 일관성 보장을 위해서 재시도 로직을 적용하는 것을 잊으면 안된다.
다른 aggregate root의 객체 참조 대신 ID를 가지도록 하는 것의 동기는 entity를 value object로 대체하는 것과 유사하다. 가볍게 만들기 위함이다. 추가적으로, 하나의 transaction에서 2개 이상의 aggregate를 변경하려는 유혹을 애초에 차단하기 위함도 있다. 하나의 transaction에서는 하나의 aggregate만 변경되어야 한다는 규칙을 준수하기 위해 되도록이면 entity 대신 ID를 사용하도록 하자.
CQRS를 통해 복잡한 aggregate 조회 최적화하기
Aggregate를 다이어트하는 것과 별개로 aggregate 데이터 조회 측면에서 성능과 확장성을 끌어올리려면 CQRS를 적용하면 된다. 이 글에서 이미 다룬 적이 있기 때문에 자세한 내용은 생략하겠다. 간단하게 구현하려면 aggregate repository에서 특정 비즈니스 로직을 위해 최적화된 DB query를 사용하는 메서드를 제공하면 된다.
예를 들어, 앞에서 살펴본 python 예시에서 상태가 deactivated
인 ParObject
들의 value
의 합을 구하려면 어떻게 해야 할까? CQRS를 적용하지 않는다면 AggregateRoot
객체를 DB로부터 불러올 때, 포함된 모든 PartObject
들을 메모리에 적재한 뒤 for문을 돌면서 계산하는 로직을 구현해야 한다. 그런데, CQRS를 적용한다면 AggregateRepository
에서 다음과 같은 특수한 메서드를 제공할 수 있다.
class AggregateRepository(Repository):
...
def get_deactivated_avg(self, id: int) -> float:
query = """
SELECT AVG(value)
FROM part_objects
WHERE aggregate_root_id = {id} AND state = deactivated;
""".format(id=id)
...
return query_result
이렇게 구현하면 필요한 data에만 선별적으로 접근할 수 있고, DB 수준의 쿼리 최적화도 가능하기 때문에 효율성이 향상된다. 다만, 코드 레벨에서 특정 DB의 SQL문을 사용하는 것이 눈살을 찌푸리게 만든다거나, repository같은 인프라적인 요소에 비즈니스 로직이 노출된다는 우려가 생길 수 있다. 그런데, CQRS 소개글에서도 언급했듯이 복잡한 entity 구조를 read-only 작업에서도 그대로 사용하는 대신 CQRS를 적용했을 때의 이점이 명확하기 때문에 조금 관대해질 필요가 있다. 코드가 좀 못생겼더라도 괜찮다!
이상적인 aggregate 설계 방법 요약
하나의 aggregate 안에 aggregate root라는 하나의 entity만 존재하도록 리팩토링한다.
- 1단계: aggregate root에 포함된 entity들을 최대한 value object로 대체해본다.
- 2단계: value object로 치환이 안되는 경우, 결과적 일관성을 적용하여 entity들을 별도의 aggregate로 분리시킨다. 이때, 다른 aggregate의 객체 참조 대신 ID를 포함시킨다.
- 3단계: 위 단계를 모두 적용할 수 없는 경우, entity가 여러 개인 aggregate로 유지한다.
추가적으로 CQRS를 적용하여 read-only 작업에 대해 최적화를 진행한다.
More Posts
Validation 코드는 어디에 작성해야 할까? - The 3 types of validation logics
개발자들은 다양한 validation 코드들을 작성하는데 많은 시간을 소비한다. 이곳저곳에 덕지덕지 붙어있는 validation 코드들을 바라보면, 과연 이 코드들이 여기에 있어도 되는 것인지 의문이 생긴다. 다양한 종류의 validation 코드들을 어디에 작성해야 하는지 정리해보자.
Domain model에서 repository를 직접 사용해도 될까? - 도메인 모델의 영속성 무지
Repository는 인프라적인 요소에 가깝다고 할 수 있다. 그런데, 이 repository를 도메인 모델에서 직접 사용해도 괜찮을까? DDD와 관련된 여러 참고 문서들에서는 이에 대해 통일되지 않은 견해를 보이고 있다. 이 글을 통해 여러 견해들을 한꺼번에 모아서 생각해보자.
Aggregate란 무엇인가 도대체! - DDD aggregate의 기초 쉽게 이해하기
DDD에서는 aggregate라는 용어가 자주 등장한다. DDD의 다양한 개념들을 이해하기 위해서는 aggregate에 대한 이해가 선행되어야 하는데, 보통 DDD 자료들에서는 aggregate에 대한 설명이 중후반부에 등장한다. DDD 초보자도 쉽게 이해할 수 있도록 aggregate를 겉핥기해보자.
Comments