의인화(Anthropomorphism)란?
소프트웨어 객체를 능동적이고 자율적인 존재로 설계하는 원칙.
객체를 마치 사람처럼 직접 행동하는 “자아를 가진 존재”로 다루는 것을 의미한다.
- Rebecca Wirfs-Brock -
도메인 이벤트, 상태 머신, 이벤트 소싱, CQRS, 그리고 DDD(도메인 주도 설계)는 서로 유기적으로 결합되어 응집도 높은 설계를 만든다. 본 글에서는 강아지 키우기 예제를 통해 이러한 개념들을 이해해보고, 실제 구현 시 고려해야 할 디테일과 도전 과제들을 이야기하도록 하겠다.
예제 - 강아지 키우기
우리가 설계해야 할 소프트웨어 모델은, 단순 CRUD 를 넘어 좀 더 능동적으로 스스로 행동하는 객체를 만들어야 할 수 있다. 지금부터 “강아지” 라는 객체를 만들어, 소프트웨어로서 “강아지”를 설계해보도록 하겠다.
강아지 상태 모델
한번 강아지가 가지고 있을 “상태” 들을 유추해보자.
- 상태(State)
- 밥 먹는 중
- 자는 중
- 산책 중
- 볼일 보는 중
- 만족함
이러한 상태들은 강아지 객체가 가질 수 있는 행동 상태를 의미하며, 각각의 상태는 내부적으로 특정 행동(액션)을 수행한다.
워크플로우 예시
그렇다면, 각 상태에선 어떠한 작업들이 일어날까? 예를 들어, “산책” 이라는 동작을 워크플로우로 정의해보자.
- 산책 요청부터 완료까지의 흐름
- 산책 요청 받음: 외부에서 요청이 들어오면 강아지 객체는 산책 준비 상태로 전이한다.
- 산책 중: 실제로 산책하는 동안 강아지는 산책 상태에 머무르며, 도중에 특정 이벤트(예: 장애물 감지)를 처리할 수 있다.
- 귀가 중: 산책이 종료되면 강아지는 귀가 상태로 전이하며, 집에 도착할 때까지의 행동을 관리한다.
- 집 도착: 집에 도착하면 다시 초기 상태(혹은 ‘만족함’ 상태)로 돌아간다.
스프링 StateMachine 적용
주의) 아래 내용은 중요도에 따라 나열한 것이기 때문에, 한번에 이해하기 어려울 수 있다. 뒤쪽에 등장한 개념이 앞부분의 개념을 설명하는 경우가 있기 때문에, 두번에 걸쳐 반복해 읽어가며 개념을 완벽히 이해하길 바란다.
지금부터, Spring Statemachine 에 나온 개념들을 통해, State Machine 의 설계에는 어떠한 구성요소들이 있는지 살펴보자.
- 구성 요소
- State: 강아지의 현재 상태.
- Event: 상태 전이를 유발하는 외부 혹은 내부 이벤트(예: 산책 요청, 집 도착).
- Action: 상태 전이 시 실행되는 비즈니스 로직(예: 알림 전송, 로깅).
- Guard: 특정 전이가 일어나기 위한 조건 검증.
- Transition: 특정 상태에서, 다른 상태로 변화하는 것.
각 구성요소에 대하여 좀 더 자세히 알아보자.
상태(State)
상태는 다음과 같은 3가지 요소로 구분할 수 있다.
Simple State
- 별다른 하위 구조가 없고, Entry(진입), 체류(Do), 종료(Exit) 동작 등만 있는 상태이다.
아무것도 안하고, 대기만 하고 있는 상황도 단순 상태라고 할 수 있다.
Composite State
- 내부에 또 다른 상태 기계나, 여러 하위 상태를 포함하는 상태.
- 상태 기계를 계층적으로 설계할 때 사용.
간단한 예제를 들어 설명해보겠다.
우리는 위에서 “산책 중” 이라는 상태 내의 워크플로우를 예시로 들며, 각 작업에 어떠한 일이 일어나는지를 나열해보았다.
만약, 산책 중의 워크플로우를 하나의 State Machine으로 두면, “산책 중” 이 과연 단순한 상태라고 할 수 있을까?
UML State 다이어그램에선, 이를 “복합 상태”라고 한다.
Pseudo State
- 상태 자체라기보다, 상태 전이 지점에서 임시로 사용하는 개념적인 노드를 의미한다.
해당 내용에 대해선, 아래 Guard 에서 좀 더 자세히 다루도록 하겠다.
이벤트(Event)
도메인 내에서 의미 있는 상태 변화나 중요한 일이 발생했음을 나타내는 객체를 의미한다.
이벤트 기록
도메인 객체(예: 애그리게이트) 내에서 중요한 상태 변화가 발생하면, 해당 변화에 대해 이벤트를 생성하고 기록한다. 이 과정은 나중에 그 이벤트를 기반으로 추가적인 처리를 하거나, 이벤트 소싱 같은 방식으로 변경 이력을 저장할 때 사용된다. 주로 도메인 레이어 내에서 구현하게 된다.
이벤트 발행
기록된 이벤트는 시스템 내의 다른 컴포넌트에게 전달되기 위해 발행된다. 이벤트 발행은 보통 애플리케이션 레이어에서 이벤트 버스나 메시징 시스템을 통해 이루어지며, 이를 통해 도메인 이벤트가 외부의 관심 있는 시스템 또는 서비스에 전달된다.
이벤트 구독
이벤트에 관심이 있는 서비스나 컴포넌트는 해당 이벤트를 구독한다.
구독자는 애플리케이션 레이어에서 해당 이벤트를 구독한 뒤, 이벤트가 발행되면 이를 수신하고, 그 이벤트에 맞는 비즈니스 로직이나 후속 작업(예: 다른 시스템과의 연동, 데이터 업데이트 등)을 수행한다.
액션(Action)
특정 상태에서, 주어진 행동을 처리하는 방식을 의미한다.
Action에는 세가지 주요 액션이 있다.
- entry
- 해당 상태로 진입할 때 한 번 수행할 동작을 의미한다.
- do
- 해당 상태에 머무르는 동안 계속 반복 혹은 특정 이벤트마다 수행할 동작을 의미한다.
- 단순하게 do라고 되어있지만, 아래와 같이 다양한 동작들을 설계할 수 있다.
- 다양한 이벤트를 현재 상태에 따라 적절하게 처리
- “시간을 관리해주는 State Machine”에 의해 정해진 시간마다 주기적으로 수행하는 동작
- exit
- 해당 상태에서 벗어날 때 한 번 수행할 동작을 의미한다.
상태 기계에서는 모든 동작을 위 Action으로 표현한다.
예를 들어, “배꼽시계가 울림”이라는 이벤트가 발생한 경우를 상상해보자.
- 강아지 State Machine은 “배꼽시계가 울림” 이라는 이벤트를 받는다.
- “만족함” 상태의 적절한 Do Action을 수행한다.
- 이 과정 내에서, “배고픔” 상태로 Transition하는 메소드를 호출한다.
- 만족함 상태의 Exit Action을 발생시킨다.
- 배고픔 상태의 Entry Action을 발생시킨다.
- “만족함→배고픔” 으로 상태가 전이된다.
위와 같은 과정을 통해, 우리는 강아지의 상태를 만족함→배고픔으로 바꿀 수 있는 것이다.
cf. 시뮬레이션 도구에서는, 자주 사용되는 Action들을 쉽게 설정할 수 있도록, 다음과 같이 Action들을 미리 정의하여 사용하기 쉽도록 한다.
Guard
”산책”이라는 개념을 생각해보자.
우리가 키우는 강아지는 “산책”을 혼자 나가지 못한다. 반드시 주인과의 협의가 있어야, 주인과 함께 산책을 나갈 수 있는 것이다.
따라서 강아지는 “산책 중” 이라는 상태로 넘어가기 전, 반드시 주인과의 “소통”을 통해, 산책을 나갈 것인지 협의해야 한다.
이런 Guard 역할을 하는 State를 Pseudo State라고 하며, 다이아몬드 모양(위 그림의 Guard)으로 표현한다.
전이 (Transition)
Transition에 대한 이해를 높이기 위해서, 한가지 예시를 통해 Transition을 다음과 같은 두가지로 나누어 보겠다.
예시) 강아지가 배고픈 상황
External Transition
- 외부 이벤트에 의한 상태 전이.
강아지의 두뇌와 위장이 별개의 State Machine을 갖고 있다고 상상해보자.
만약 강아지가 위장에서 “배고픔” 이라는 이벤트를 발행한다면, 두뇌는 “배고픔”이라는 이벤트를 받아, “밥달라고 조르기” 라는 상태로 전이할 것이다. 이를 External Transition이라고 한다.
Internal Transition
- 내부 조건에 의한 상태 전이.
내부적으로 발생하는 이벤트에 의해, 스스로 자신의 상태를 바꾸는 것을 의미한다.
만약 강아지가 자체적으로 단일 State Machine 을 갖고 있다고 상상해보자.
강아지가 스스로 “만족함→배고픔”으로 상태를 전이하는 데에는, 별다른 외부 이벤트가 필요하지 않을 것이다.
이와 같은, State Machine 자체에서 스스로 Transition하는 것을 Internal Transition이라고 한다.
cf. 시뮬레이션 영역에서는 각 모델들이 자체적으로 “시계”를 갖기 때문에, Internal Transition 이라는 개념을 이용해, 스스로 자신의 상태를 바꾸는 모델을 설계하기도 한다.
이벤트 소싱과 도메인 이벤트
그렇다면, 이 이벤트는 어떤 이유때문에 자주 쓰이는걸까?
그 이유는 “책임 주도 설계” 개념에서 찾을 수 있다.
예시로 “대화를 한다” 라는 행위를 구현해보자.
단순히 구현한다면 dog.send(cat, message)
라는 메소드를 구현하게 될 것이다.
위 코드는 dog가 cat을 참조하여 호출해야 하고, 이는 “수신자”에게 “메시지”를 전달한다. 라는 방식으로 구현되게 된다.
과연 이게 유일한 구현 방법일까?
아래 코드를 살펴보도록 하자.
dog.meet(park)
// 강아지는 공원에 도착하기로 했다.cat.meet(park)
// 고양이는 공원에 도착하기로 했다.dog.send(message)
// 강아지가 message라고 말한다. 이는 공원에 있는 모두에게 전달된다.
처음 소개한 코드보다 좀 길지만, 강아지가 고양이를 직접적으로 알지 않아도 되고, park라는 공간 개념을 통해 둘 사이의 결합을 느슨하게 만들어주는 새로운 도메인으로 설계한 것이다.
이렇게, 정말 실제와 유사하게 구현한다면, 적절한 책임을 부여하고 각 객체 간의 상호작용으로 현실의 변경사항을 그대로 받아들일 수 있는 구현이 가능해지는 것이다.
우리는 위 내용을 통해, 이벤트로 상태를 제어할 수 있음을 학습했다.
그렇다면, 현재 상태를 표현하는 방법이 "현재 상태 저장" 뿐일까?
아니다.
지금부터 또다른 상태를 제어할 수 있는 방법인, 도메인 이벤트 및 Event Sourcing 에 대해 소개하도록 하겠다.
이벤트 소싱(Event Sourcing)
이벤트 소싱은 객체의 현재 상태를 단순히 저장하는 대신, 상태 변화의 이벤트 기록을 모두 저장하여 필요할 때 전체 이벤트 로그를 재생(replay)함으로써 현재 상태를 재구성하는 기법이다.
- 장점
- 감사 추적(Audit Trail): 모든 상태 변화 기록이 남기 때문에, 문제 발생 시 원인을 추적하기 용이하다.
- 상태 복원: 특정 시점으로 돌아가거나, 새로운 상태 계산 로직을 적용할 때 이벤트 기록을 기반으로 재구성이 가능하다.
- 고려사항
- 이벤트 순서 보장: 분산 시스템에서 여러 이벤트가 동시에 발생할 경우 순서 보장이 필수적이다.
- 순서 보장 메커니즘: 단순한 시간순 정렬뿐 아니라, 물리적 시간, 논리적 시간, 혹은 벡터 클록 같은 메커니즘을 도입하여 순서를 보장할 수 있어야 한다.
- 위조 가능성: 송신자가 타임스탬프를 조작하는 등의 위조 가능성에 대비한 보안 메커니즘이 필요하다.
도메인 이벤트(Domain Event)
도메인 이벤트는 도메인 내에서 발생하는 중요한 사건을 캡슐화하여 기록 및 전달하는 방식이다.
- 예시: 강아지 상태 변화(밥 먹기 완료, 산책 시작 등)를 이벤트로 기록하고, 이를 기반으로 다른 시스템 컴포넌트가 반응할 수 있도록 한다.
- 실시간 처리와 후처리: 이벤트 발행 후, 다른 시스템이나 모듈이 해당 이벤트를 구독(subscribe)하여 추가 작업(알림 전송, 통계 집계 등)을 수행할 수 있다.
그렇다면, 이에 대한 단점은 없는가?
지금까지 이야기를 들어보면, 도메인 이벤트를 이용한 구현은 “우아”하고 “아름다운” 설계처럼 느껴진다.
하지만 최고의 설계는, “변경에 유연한 것”뿐만 아니라, “마감을 지킨” 설계라는 것을 명심하자.
도메인 이벤트를 이용한 객체 간의 상호작용은, 분명히 트레이드오프가 있다.
- 장점
- 책임 분리가 명확해짐
- 단점
- 도메인 서비스에 비해, 구현해야 할 객체들이 지나치게 많아질 수 있음.
예시로, 필자는 다음과 같은 상황에서 도메인 이벤트를 도입해왔다.
- 협업 중, 서로 맡은 도메인이 달라, 상호 간의 통신을 위해 이벤트를 정의해두고 그에 대한 반환값을 명시할 경우.
- 내가 담당하지 않은 도메인에 존재해야 하는, 내가 사용할 메소드 명을 내가 짓는것보단, 이벤트만 정의해두고 상대방에게 처리를 부탁한 뒤, 발행해줬으면 하는 이벤트를 정의하는 것이 훨씬 협업에 용이할 것이다.
- 이 경우는, “계약에 의한 설계” 개념과 묶어 생각하면 좀 더 이해하기 쉬울 것이다.
DDD와의 결합: 팩토리
그럼 이 상태 패턴을 통한 상태 객체를 어떻게 관리해야 하는가?
DDD(도메인 주도 설계)는 복잡한 도메인을 모델링할 때 객체들 간의 책임과 경계를 명확히 하는 것을 강조한다.
따라서,
- Repository 개념을 이용해 컨텍스트 객체를 조회하고,
- 팩토리에서 도메인 엔티티에 State Enum을 붙여
- DB에 row 형태로 구성되어 있는 데이터를 State Machine 형태로 가져올 수 있게 되는 것이다.
참고: Repository와 Factory의 차이
Repository: 영속성 저장소에서 객체를 "찾아올 때" 사용하는 객체.
Factory: 객체를 "생성할 때" 사용하는 객체. 리포지토리가 Factory 를 이용해 객체를 "재구성"할 수 있다.
둘의 미묘한 관점의 차이를 이해하길 바란다.
CQRS: 커맨드와 쿼리 분리
CQRS(Command Query Responsibility Segregation)는 읽기와 쓰기 모델을 분리하여 시스템의 복잡도를 낮추고 성능을 최적화하는 패턴이다.
이는 도메인에 “최종 일관성 보장”이 허용되는 경우 유용하며, 극적인 성능 향상을 이끌어낼 수 있지만, 몇가지 단점이 존재한다.
- 커맨드(Command) 모델
- 상태 변경 요청(커맨드)을 처리하며, 변경 결과를 이벤트로 기록한다.
- 이벤트 기록 후 주기적으로 이벤트를 재생하여 상태를 업데이트하거나, 다른 시스템에 변경 사항을 알린다.
- 쿼리(Query) 모델
- 사용자 조회 요청을 처리하며, 커맨드 모델로부터 전달받은 변경 사항을 반영한다.
- 조회 전용 모델로 구성되어, 데이터 일관성이 일시적으로 깨질 수 있으나 전체 시스템의 응답성은 향상된다.
물론, 이 또한 트레이드오프가 존재한다.
- 트레이드오프
- 함께 사용하는 경우 - CQRS 미적용: 구현 단순화와 데이터 일관성 보장이 가능하지만, 확장성이 떨어질 수 있다.
- 분리한 경우 - CQRS 적용: 조회 모델이 커맨드 모델의 복잡도에 영향받지 않아 성능 이점이 있으나, 최종 일관성이 보장되기까지 일정 시간이 필요하며 “이벤트 순서 보장” 등의 추가 구현이 필요하다.
이벤트의 순서 보장, 어려운건가?
위 설명을 들어보면, 대충 구현할 수 있을 것 같은데? 라는 생각만 들 것이다.
하지만, 이벤트의 순서 보장은 어렵다.
예시: A와 B에서 서로 다른 두 이벤트가 동시에 들어온다면?
- 어떤게 더 먼저 도착한 것이지?
- 단순 시간 순?
- 네트워크때문에 늦게 왔을 뿐, 더 먼저 발생한 이벤트라면?
- 송신자가 발생 시각을 조정해서, 더 먼저 발생한 이벤트인 것처럼 위조한다면?
- 순서 보장 로직의 구현 난이도는 높다.
자세한 내용은 데이터 중심 애플리케이션 설계의 8장을 참고하길 바란다.
복합 상태와 응용 문제
실제 시스템에서는 한 객체가 동시에 여러 상태나 이벤트에 반응해야 하는 경우가 빈번하다.
예를 들어, 산책 중에 밥을 먹는 상황은 단일 상태 머신으로는 관리하기 어려울 수 있다.
- 문제점
- 동시에 발생하는 이벤트(산책, 밥 먹기)가 충돌하거나, 순서를 어떻게 보장할지가 관건이다.
- 단일 상태 패턴으로 모든 상태를 관리하려 하면, 상태 전이가 복잡해지고 유지보수가 어려워질 수 있다.
- 해결 전략
- 복합 상태 머신: 별도의 상태 머신으로 각각의 시나리오(산책, 식사)를 독립적으로 관리한 후, 상호작용을 조율하는 방법을 고려한다.
- 서브 도메인 분리: 도메인을 세분화하여, 강아지의 산책 로직과 식사 로직을 별도의 객체 혹은 모듈로 분리하고, 필요 시 이벤트로 상호 연동하는 방식으로 복잡도를 줄일 수 있다.
- 다른 해결책도 충분히 가능하다. 직접 생각해보는 것도 추천한다. 댓글로 추가적인 설계를 남겨주면 재밌게 분석해보도록 하겠다.
- 구현상의 고려
- 각 서브 시스템 간의 이벤트 순서와 의존성을 철저히 관리하여, 전체 시스템의 일관성과 응답성을 확보해야 한다.
- 분산 환경에서의 동시성 문제와 네트워크 지연 등을 고려한 설계가 필수적이다.
출처
- UML의 패턴과 적용
- Spring State Machine docs
- 우아한 기술블로그: Spring StateMachine 도입기
'Article > 도메인 주도 설계 이해하기' 카테고리의 다른 글
도메인 이벤트 vs. 애플리케이션 이벤트. 그리고 이벤트의 설계 방법 (0) | 2025.04.28 |
---|---|
DDD - JDBC에서 Child Entity를 생성/수정/삭제하는 방법 (0) | 2025.04.27 |
도메인 서비스 - DDD의 적용, JPA 엔티티와의 비교 (0) | 2025.04.03 |
DDD 트릴레마 - 도메인 모델 완전성 vs 도메인 모델 순수성 (0) | 2025.04.01 |
객체지향 기초 - SOLID 원칙 (0) | 2025.03.07 |