애그리게이트란 무엇인가?
도메인 주도 설계(DDD, Domain-Driven Design)에서 애그리게이트(Aggregate)는 관련된 도메인 객체들을 하나의 일관성 있는 경계로 묶은 객체 집합이다.
애그리게이트를 사용하는 목적은 단순히 여러 객체를 하나로 묶는 것이 아니다. 객체 간의 비즈니스 규칙을 보호하고, 데이터의 일관성을 유지하며, 외부에서 내부 객체를 임의로 변경하는 것을 방지하는 데 있다.
게임의 플레이어 인벤토리를 예로 들어보자.
플레이어는 여러 아이템을 소유하며, 아이템마다 이름과 내구도가 존재한다. 아이템을 사용하면 내구도가 감소하고, 인벤토리에 아이템을 추가할 때는 최대 슬롯 수를 초과할 수 없다.
이러한 규칙을 각각의 객체에 분산시키면 데이터의 일관성이 깨질 가능성이 커진다.
예를 들어 아이템을 추가하는 모든 코드에서 인벤토리 슬롯이 50개를 초과하는지 검사해야 한다면, 특정 코드에서 검사를 빠뜨리는 순간 잘못된 상태가 만들어질 수 있다.
DDD에서는 이러한 문제를 해결하기 위해 관련 객체를 애그리게이트로 묶고, 애그리게이트 루트를 통해 상태 변경을 통제한다.
애그리게이트의 구성 요소
이번 예제에서는 Player가 애그리게이트 루트이며, InventoryItem과 ItemId가 관련 객체로 사용된다.
| 구성 요소 | 역할 |
|---|---|
Player | 애그리게이트 루트. 인벤토리 규칙과 상태 변경을 통제한다. |
InventoryItem | 인벤토리에 포함되는 엔티티. 아이템의 내구도와 사용 행위를 관리한다. |
ItemId | 아이템을 식별하는 값 객체. 식별자 값을 하나의 도메인 타입으로 표현한다. |
애그리게이트의 경계는 객체가 단순히 서로 참조하는 범위가 아니다. 어떤 객체들이 하나의 일관성 경계 안에서 관리되어야 하는지를 정의하는 설계 개념이다.
값 객체(Value Object)
값 객체는 고유한 식별자보다 내부에 담긴 값으로 의미가 결정되는 객체다.
class ItemId {
constructor(public readonly value: string) {}
}
ItemId는 문자열 식별자를 하나의 타입으로 감싼 값 객체다.
일반 문자열을 그대로 사용하면 플레이어 ID, 아이템 ID, 주문 ID 등을 모두 동일한 string 타입으로 취급하게 된다. 반면 별도의 타입을 정의하면 도메인의 의미를 코드에 명시할 수 있다.
const itemId = new ItemId("item-001");
console.log(itemId.value);
다만 현재 구현은 값 객체의 최소 형태에 가깝다. 생성자에서 유효성을 검사하지 않으며, readonly는 프로퍼티 재할당만 막을 뿐 객체 전체를 불변으로 만드는 것은 아니다.
실제 값 객체에서는 다음과 같은 규칙을 고려할 수 있다.
- 유효하지 않은 식별자 값 거부
- 값의 동등성 비교
- 외부에서 내부 상태를 임의로 변경하지 못하도록 제한
또한 ItemId를 사용한다고 해서 TypeScript가 서로 다른 ID 타입을 자동으로 완전히 구분하는 것은 아니다. 현재처럼 문자열을 감싼 클래스는 명시적인 타입과 도메인 의미를 제공하지만, 강한 타입 구분이 필요하다면 별도의 타입 설계가 필요하다.
엔티티(Entity): InventoryItem
엔티티는 고유한 식별성을 가지며, 시간의 흐름에 따라 상태가 변경되더라도 동일한 객체로 인식되는 도메인 객체다.
class InventoryItem {
constructor(
private readonly id: ItemId,
private name: string,
private durability: number
) {}
public use(): void {
if (this.durability <= 0) {
throw new Error("이미 내구도가 다 닳은 아이템입니다.");
}
this.durability -= 10;
}
public getDurability(): number {
return this.durability;
}
}
InventoryItem은 아이템 ID를 가지고 있으며, 아이템의 내구도라는 상태를 관리한다.
아이템을 사용하면 use() 메서드를 통해 내구도가 10 감소한다.
여기서 중요한 점은 내구도를 외부에서 직접 변경하지 않는다는 것이다.
다음과 같은 구조를 생각해 보자.
item.durability -= 10;
내구도에 직접 접근할 수 있다면 아이템 사용 규칙을 거치지 않고 상태를 변경할 수 있다. 반면 use() 메서드를 통해 변경하면 아이템이 자신의 비즈니스 규칙을 직접 보호할 수 있다.
이것은 객체지향 설계의 캡슐화와도 연결된다.
상태를 보관하는 것과 상태 변경의 규칙을 소유하는 것은 다르다. 엔티티는 자신의 상태와 관련된 비즈니스 행위를 함께 관리하는 것이 바람직하다.
단, 현재 코드에서는 name과 durability가 생성자에서 검증되지 않는다. 음수 내구도를 가진 아이템이 생성되는 상황까지 막으려면 생성 시점의 유효성 검사도 추가해야 한다.
애그리게이트 루트(Aggregate Root): Player
애그리게이트 루트는 애그리게이트 외부에서 해당 애그리게이트에 접근하는 대표 객체다.
class Player {
constructor(
private readonly id: string,
private level: number,
private items: InventoryItem[]
) {}
public useItem(itemId: string): void {
const targetItem = this.items.find(
(item) => item.id.value === itemId
);
if (!targetItem) {
throw new Error("인벤토리에 해당 아이템이 없습니다.");
}
targetItem.use();
}
public addItem(item: InventoryItem): void {
if (this.items.length >= 50) {
throw new Error("인벤토리 슬롯이 가득 찼습니다.");
}
this.items.push(item);
}
public getId(): string {
return this.id;
}
}
Player는 인벤토리를 관리하는 애그리게이트 루트다.
외부에서는 InventoryItem을 직접 조작하는 대신 Player의 메서드를 호출해 아이템 사용을 요청한다.
player.useItem("item-001");
이 요청이 처리되는 과정은 다음과 같다.
Player가 인벤토리에서 해당 아이템을 찾는다.- 아이템이 존재하지 않으면 예외를 발생시킨다.
- 아이템이 존재하면
InventoryItem.use()를 호출한다. InventoryItem이 자신의 내구도 규칙을 검사하고 상태를 변경한다.
여기서 각 객체의 책임이 분리된다.
Player: 해당 아이템을 소유하고 있는지 확인하고, 인벤토리 규칙을 통제한다.InventoryItem: 아이템 사용 가능 여부를 검사하고 내구도를 변경한다.
애그리게이트 루트가 모든 비즈니스 로직을 직접 구현해야 하는 것은 아니다. 내부 엔티티에 적절한 책임을 위임하면서 애그리게이트 전체의 일관성을 보장하면 된다.
애그리게이트 루트가 불변식(Invariant)을 보호하는 방법
불변식은 도메인 객체가 항상 만족해야 하는 규칙이다.
이번 예제에서는 인벤토리 슬롯이 50개를 초과할 수 없다는 규칙이 불변식에 해당한다.
public addItem(item: InventoryItem): void {
if (this.items.length >= 50) {
throw new Error("인벤토리 슬롯이 가득 찼습니다.");
}
this.items.push(item);
}
아이템 추가를 Player.addItem()으로 통제하면 슬롯 수 제한을 한곳에서 검사할 수 있다.
반대로 외부에서 items.push(item)을 직접 호출할 수 있다면 addItem()을 우회하여 슬롯 제한을 무너뜨릴 수 있다.
따라서 애그리게이트 루트는 내부 컬렉션을 외부에 직접 노출하지 않고, 규칙을 적용하는 메서드를 통해 상태를 변경하도록 설계해야 한다.
불변식은 단순히 예외를 발생시키는 조건문이 아니다. 어떤 상태가 유효한 상태인지 정의하고, 모든 상태 변경 경로에서 그 유효성을 유지하는 규칙이다.
다만 현재 구현에서 items는 private이므로 외부 코드가 player.items에 직접 접근할 수 없다. 그렇더라도 생성자에 전달한 배열을 외부에서도 참조하고 있다면 그 배열을 통해 내부 상태가 변경될 수 있다. 생성자에서 배열을 복사하는 등의 방어가 필요하다.
왜 외부에서는 애그리게이트 루트를 통해서만 접근해야 하는가?
애그리게이트 루트를 통한 접근은 단순한 코드 스타일이 아니라 도메인 일관성을 보호하기 위한 설계 규칙이다.
예를 들어 애플리케이션 서비스가 다음과 같이 동작한다고 가정해 보자.
// 권장하지 않는 접근 방식의 예시
const item = await itemRepository.findById("item-001");
item.use();
await itemRepository.save(item);
이 코드가 반드시 잘못된 것은 아니다. 하지만 item이 Player 애그리게이트 내부에 속한다면, 아이템을 독립적으로 조회하고 변경하는 방식은 애그리게이트의 경계를 우회할 수 있다.
인벤토리 소유 여부, 플레이어 상태, 아이템 사용 제한 등이 여러 객체에 걸쳐 있다면 이러한 규칙을 일관되게 적용하기 어려워진다.
애그리게이트 루트를 통해 처리하면 다음과 같이 설계할 수 있다.
const player = await playerRepository.findById(playerId);
if (!player) {
throw new Error("플레이어를 찾을 수 없습니다.");
}
player.useItem("item-001");
await playerRepository.save(player);
이 구조에서는 애플리케이션 서비스가 플레이어를 조회하고, 도메인 행위를 호출하고, 저장을 요청한다.
각 계층의 책임은 다음과 같다.
- Application Service: 유스케이스의 실행 흐름을 조정한다.
- Repository: 애그리게이트를 조회하고 저장한다.
- Aggregate Root: 애그리게이트의 상태 변경과 규칙을 통제한다.
- Entity: 자신의 상태와 관련된 비즈니스 행위를 수행한다.
실제 구현에서는 PlayerRepository가 애그리게이트를 어떻게 저장하는지에 따라 내부 아이템의 영속화 방식도 달라질 수 있다. 핵심은 저장 기술이 아니라 도메인의 일관성 경계를 유지하는 것이다.
애그리게이트와 데이터베이스의 관계
애그리게이트가 하나의 경계를 형성한다고 해서 반드시 데이터베이스 테이블 하나로 저장해야 하는 것은 아니다.
예를 들어 다음과 같이 저장할 수 있다.
players 테이블
| player_id | level |
|---|---|
| player-001 | 10 |
inventory_items 테이블
| item_id | player_id | name | durability |
|---|---|---|---|
| item-001 | player-001 | Iron Sword | 80 |
| item-002 | player-001 | Wooden Shield | 40 |
데이터베이스에서는 플레이어와 아이템을 별도 테이블로 관리할 수 있다. 그러나 도메인 모델에서는 Player와 그 인벤토리를 하나의 애그리게이트로 취급할 수 있다.
즉, 다음 두 개념은 구분해야 한다.
- 데이터베이스의 저장 구조: 테이블, 행, 외래 키, 인덱스 등의 구성
- 도메인의 일관성 경계: 어떤 객체와 규칙을 하나의 단위로 관리할 것인지에 대한 설계
애그리게이트 루트는 데이터베이스 테이블을 대표하는 객체가 아니라 도메인 모델의 일관성 경계를 대표하는 객체다.
또한 애그리게이트를 통해 변경을 통제한다는 것만으로 데이터베이스의 동시성 문제가 자동으로 해결되지는 않는다. 여러 요청이 동시에 인벤토리를 변경할 수 있는 서버라면 트랜잭션, 낙관적 잠금, 비관적 잠금 등 적절한 동시성 제어도 검토해야 한다.
실무에서 보완해야 할 부분
현재 예제는 애그리게이트의 기본 개념을 보여주지만, 실제 게임 서버에 적용하려면 몇 가지 사항을 보완해야 한다.
아이템 생성 검증
durability가 음수이거나 유효 범위를 벗어나는 아이템이 생성되지 않도록 생성자에서 검증해야 한다.
식별자 비교
현재 코드는 ItemId를 문자열로 감싼 뒤 item.id.value와 비교한다. 실제 구현에서는 ItemId.equals() 같은 비교 메서드를 제공하면 도메인 타입의 의미를 더 명확하게 유지할 수 있다.
인벤토리 중복 및 소유권 검증
같은 아이템을 중복으로 추가하거나, 다른 플레이어가 소유한 아이템을 추가하는 상황을 막아야 한다. 아이템이 소유자를 직접 보관하는지, 인벤토리에서 소유권을 관리하는지는 도메인 모델에 따라 결정한다.
컬렉션 캡슐화
생성자에 전달된 배열을 복사하고, 외부에 내부 배열을 직접 반환하지 않아야 한다. 컬렉션을 조회하는 메서드가 필요하다면 복사본이나 읽기 전용 형태를 제공할 수 있다.
내구도 감소 규칙
현재 use()는 내구도가 1~9인 아이템도 사용하면 음수가 될 수 있다. 내구도를 0 아래로 내려가지 않게 할 것인지, 내구도 부족 시 사용 자체를 거부할 것인지 도메인 규칙을 명확히 해야 한다.
영속성과 동시성
여러 요청이 동시에 같은 플레이어의 인벤토리를 수정한다면, 저장 시점에 변경 충돌이 발생할 수 있다. 애그리게이트의 경계와 함께 트랜잭션 및 동시성 제어 전략을 설계해야 한다.
마무리
애그리게이트는 단순히 여러 객체를 묶는 구조가 아니다. 도메인 객체 간의 일관성을 유지하기 위한 경계이며, 애그리게이트 루트는 그 경계를 외부로부터 보호하는 진입점이다.
이번 예제에서 Player는 인벤토리 슬롯 제한과 아이템 사용 요청을 통제하고, InventoryItem은 자신의 내구도 규칙을 관리한다. ItemId는 아이템 식별자를 도메인 타입으로 표현한다.
핵심 원칙은 세 가지다.
- 애그리게이트는 일관성 경계를 정의한다.
- 애그리게이트 루트는 외부의 상태 변경을 통제한다.
- 각 엔티티는 자신의 책임에 해당하는 비즈니스 규칙을 수행한다.
이 원칙을 지키면 도메인 규칙이 여러 서비스와 저장소에 흩어지는 것을 줄이고, 변경 가능한 경로를 명확하게 관리할 수 있다.