Aggregate Root는 무엇을 기준으로 정하는가
Aggregate Root는 무엇을 기준으로 정하는가
시작은 단순했다
주차타워, 셔틀, 주차구획(슬롯), 진입레인 같은 설비 도메인을 모델링하는 중이었다. 엔티티 몇 개를 정의하고 나니 자연스럽게 질문이 따라왔다.
- 어떤 엔티티를 Aggregate Root로 잡아야 하는가?
- Aggregate Root는 다른 엔티티를 리스트로 가지면 안 되는 거 아닌가?
- 주차타워가 Root라면
Shuttles[],ParkingSlots[]를 반드시 물고 있어야 하나? - 애초에 설비 속성을 정의하는 단계에서부터 Aggregate 경계를 확정해야 하는 건가?
돌이켜보면, 이 질문들 자체가 함정이었다. 설비 마스터 데이터를 정의하는 일과 Aggregate를 설계하는 일을 같은 작업으로 착각하고 있었던 것이다.
Aggregate Root는 "대표 엔티티"가 아니다
가장 먼저 바로잡아야 했던 건 정의였다. Aggregate Root는 여러 엔티티 중 가장 중요해 보이는 것을 고르는 문제가 아니라,
"업무 규칙을 보호하는 일관성 경계의 진입점"
을 찾는 문제다. 그래서 판단 순서도 엔티티 목록에서 출발하지 않는다.
1. 어떤 변경들이 함께 발생하는가?
슬롯 예약, 셔틀 작업 할당, 주차 현황 갱신, 작업 상태 변경처럼 업무 수행 시 동시에 일어나는 변경을 나열한다.
2. 반드시 함께 지켜져야 하는 규칙은 무엇인가?
예를 들면 이런 것들이다.
- 하나의 슬롯은 동시에 두 차량에 예약될 수 없다.
- 하나의 셔틀은 동시에 두 작업을 수행할 수 없다.
- 예약되지 않은 슬롯에는 입차할 수 없다.
3. 그 규칙을 누가 최종적으로 책임지는가?
규칙을 지키는 객체가 곧 Aggregate Root 후보다. 같은 도메인이라도 어디에 규칙을 두느냐에 따라 후보가 달라진다.
// 슬롯 규칙 중심
ParkingSlot.Reserve()
ParkingSlot.Release()
ParkingSlot.Occupy()
→ ParkingSlot이 Aggregate Root 후보
// 작업 중심
EntryTask.AssignSlot()
EntryTask.AssignShuttle()
→ EntryTask가 Aggregate Root 후보
// 셔틀 중심
Shuttle.AssignTask()
Shuttle.CompleteTask()
→ Shuttle이 Aggregate Root 후보
판단을 도와준 다섯 가지 질문
실전에서는 아래 질문들이 실질적인 가이드가 됐다.
외부에서 직접 식별하고 명령하는 대상인가?
"특정 구역(Zone)의 슬롯에 입차/출차 예약"이라는 시나리오라면 ParkingSlot, SlotReservation, EntryTask, ExitTask가 후보로 떠오른다.
같이 변경되어야 하는 불변조건이 있는가?
차량 입차 시 슬롯 예약과 셔틀 작업 할당이 반드시 함께 이뤄져야 한다면 ParkingTask나 EntryTask가 후보다. 다만 이게 반드시 하나의 DB 트랜잭션이어야 한다는 뜻은 아니다 — 이 부분에서 한 번 더 헷갈렸었다.
내부 엔티티를 루트 없이 수정해도 되는가?
parkingSlot.ChangeZone(...)처럼 슬롯 단독으로 수정해도 무방하다면, ParkingSlot 자체가 Aggregate Root 후보가 된다.
변경 빈도가 너무 높은가?
셔틀의 X/Y/Z 위치 정보처럼 실시간으로 계속 바뀌고 업무 규칙과는 직접 관계가 적은 데이터는, Aggregate 내부 상태로 두기보다 ShuttlePositionSnapshot, ShuttleTelemetry 같은 별도 Read Model/Telemetry로 분리하는 편이 낫다.
다른 Aggregate는 Id로만 참조 가능한가?
알람이 어떤 설비/셔틀/슬롯에서 발생했는지 표현할 때는 객체 참조가 아니라 코드 참조로 충분하다.
// 좋은 예
Alarm
└ EquipmentCode
// 지양할 예
Alarm
└ Equipment 객체 참조
내가 잘못 알고 있던 네 가지
오해 1. 엔티티를 정의하는 순간 Aggregate Root도 함께 정해야 한다
아니다. 설비 속성 정의는 대부분 마스터 데이터 모델링이다. ParkingTower, Shuttle, ParkingSlot, EntryLane, LaneSegment는 처음엔 단순 Entity 수준으로 정의해도 충분하고, Aggregate 경계는 업무 규칙이 붙는 시점에 결정하면 된다.
오해 2. Aggregate Root는 리스트를 가지면 안 된다
아니다. 개수가 작고, 함께 변경되고, Root를 통해서만 수정된다면 EntryLane이 LaneSegments[]를 갖는 것도 얼마든지 가능하다.
오해 3. Aggregate Root는 반드시 모든 하위 엔티티를 포함해야 한다
아니다. ParkingTower가 굳이 Shuttles[], ParkingSlots[]를 끌어안을 필요는 없다. ParkingTower, Shuttle, ParkingSlot을 각각 독립된 Aggregate Root로 두고 Code/Id로 연결하는 구조도 충분히 유효하다.
오해 4. 업무 흐름 전체를 하나의 Aggregate로 묶어야 한다
아니다. "입차 작업 생성 → 슬롯 예약 → 셔틀 지시 → PLC 응답 → 주차 현황 갱신"은 하나의 업무 흐름이지만, ParkingTask, ParkingSlot, ShuttleCommand, OccupancyLedger처럼 여러 Aggregate로 나뉠 수 있고, 이를 조율하는 건 Application Service의 몫이다.
결론: Entity가 먼저, Aggregate는 나중에 드러난다
지금 내가 도달한 결론은 이렇다. 설비 속성을 정의하는 단계에서는 "이게 Aggregate Root인가?"를 묻지 않는다. 대신 "어떤 정보를 관리해야 하는가?"에 집중한다.
ParkingTower
- Code
- Name
- Endpoint
Shuttle
- Code
- Name
- Type
ParkingSlot
- Code
- Zone
- Bay
- Row
- Level
- Deep
그러다 실제 업무가 정의되면, 그때 아래 세 질문으로 Aggregate Root를 확정한다.
누가 이 규칙을 책임지는가? 누가 직접 명령받는가? 무엇이 함께 변경되어야 하는가?
돌아보면 내가 헤맸던 이유는 단순했다. Aggregate Root를 먼저 정하려고 했기 때문이다. 순서를 뒤집으니 훨씬 편해졌다.
1. 설비 마스터(Entity)를 먼저 정의한다.
2. 업무 시나리오를 정리한다.
3. 변경 규칙과 일관성 경계를 찾는다.
4. 그 규칙을 책임지는 객체를 Aggregate Root로 선정한다.
Entity를 먼저 발견하고, Aggregate는 나중에 드러난다. 특히 물류/설비 도메인에서는 설비 자체보다 작업(Task)이 Aggregate Root가 되는 경우가 많다는 것도 기억해둘 만하다.