개발자는 기록이 답이다
이벤트 메시지의 Payload, 어디까지 포함해야 할까? 본문
제가 담당한 "문의" 도메인은 레거시 시스템인 "상담" 도메인으로부터 이벤트를 수신하고, 이를 기반으로 후속 로직을 처리한다. 이번 글에서는 2024년 말에 개발한 "상태 카드 발송" 기능과 관련하여, 이벤트 메시지의 Payload 설계에 대해 고민했던 내용을 적어보고자 합니다.

📌 문제 상황
기존 레거시 "상담" 시스템에서는 ConsultationCreatedEvent (상담 생성 이벤트) 와 ConsultationChangedEvent (상담 변경 이벤트) 만을 메시지로 발행하고 있습니다. 이 "상담" 도메인은 SU(selected user) 를 생성하는 도메인으로, 다른 여러 도메인에서 참조되는 Upstream 역할을 하며, 다양한 Downstream 시스템들이 해당 이벤트를 구독하고 있습니다.
Downstream 시스템 간의 결합도를 낮추기 위해, 이벤트에는 구체적인 데이터를 포함하지 않고 이벤트 이름과 상담 식별자와 관련된 ID만 담은 Zero Payload 방식을 채택하고 있었습니다. 이를 통해 이벤트 소비자는 이벤트 발생 여부만 감지하고, 별도로 API 를 통해 이벤트 발행자를 질의하여 필요한 데이터를 얻는 방식을 사용하고 있었습니다.

{
"consultationId": 5621989,
"hospitalId": 4848,
"txDateTime": "2025-04-05T18:17:35",
"raisedAt": "2025-03-29T15:38:32"
}
Zero Payload는 우아한 형제들 기술블로그에도 적혀있는 방법으로 서비스간 결합을 느슨하게 만들 수 있는 방법입니다.
하지만 현재 구조에서 Zero Payload를 유지하면서 제가 구현해야 하는 기능을 만들 수 없었습니다. 아래 3가지가 바로 그 이유 였습니다.
1️⃣ 첫 번째 문제: 상태 변경만 식별 불가
Consultation은 "100개 이상의 컬럼"을 가진 복잡한 모델입니다. 이 때문에 서비스마다 추적하고자 하는 상담의 필드가 달라졌고, 변경이 발생할 때마다 모든 이벤트를 ConsultationChangedEvent로만 발행하는 구조가 되어버렸습니다.

그 결과, "상태만 변경되었는지"를 구분할 수 없게 되는 문제가 발생했습니다. 아래와 같은 서로 다른 변경사항이 모두 동일한 SQS로 전달되면서, "문의"도메인에서는 이 이벤트가 어떤 상태가 변경되었는지 의도를 정확히 파악할 수 없었습니다
- 상담의 방문자 이름이 변경된 경우
- 상담의 상태가 변경된 경우
- 상담의 메모가 변경된 경우
- 상담 포인트가 상환된 경우
- 기타 등등
2️⃣ 두 번째 문제: 이력 테이블 or 이벤트 소싱 미구현
Zero Payload는 서비스 간의 결합도를 낮춘다는 장점이 있지만, 오직 이벤트 발생 이후의 최종 상태(Snapshot)만 조회할 수 있다는 한계가 있습니다. 상담에서는 상태가 변경될때마다 기록하는 히스토리가 없었기 때문입니다. 즉, 변경된 상태의 최종 결과만 담고 있는 반정규화 칼럼만 있고, 전통적인 DB모델링에서 사용하는 이력을 담당하는 테이블이 없습니다. (왜 이력이 필요한가는 아래 적혀있는 3번 문제에서 나옵니다)

그렇다면 왜 처음부터 상태 이력을 저장하지 않았을까요?
추측해보면, 상담 테이블이 100개가 넘는 칼럼을 가지고 있기 때문에, 모든 필드의 변경 내역을 추적하는 히스토리 테이블을 설계하는 데 현실적인 어려웠을 것 같습니다. 또한, 이력 테이블을 하나로 통합할지, 아니면 유즈케이스별로 분리할지에 대한 고민도 당시에는 해결되지 않았던 것으로 보입니다.
결과적으로, 수신 시스템인 문의 도메인에서는 이벤트 수신 후 consultation을 조회해도 변경 전 상태를 알 수 없기 때문에, 아래와 같은 정보가 누락되며, 상태 변경 카드 발송 기능을 구현하는 데 있어 큰 제약이 있었습니다.
- 누가
- 어떤 상태에서 (from)
- 어떤 상태로 (to)
- 변경했는지
제가 담당한 "상태 카드 발송" 기능에서 필요한 시나리오는 다음과 같습니다,
- 고객이 예약을 접수한 경우 - 예약 접수 카드 (발화자 : 고객)
- 병원이 예약을 확정한 경우 - 예약 확정 카드 (발화자 : 병원)
- 고객이 예약을 변경한 경우 - 예약 변경 카드 (발화자 : 고객)
- 병원이 예약을 변경한 경우 - 예약 변경 카드 (발화자 : 병원)
- 병원이 예약을 취소한 경우 - 예약 취소 카드 (발화자 : 고객)
- 고객이 예약을 취소한 경우 - 예약 취소 카드 (발화자 : 병원)


3️⃣ 세 번째 문제: 복잡한 상태 체계
상담 상태는 사용자 유형에 따라 파트너(병원) 입장에서는 13단계, 고객 입장에서는 4단계로 나누어져 있었습니다. 아래는 파트너 어드민에서 병원이 예약 접수한 고객들의 상담 이력을 관리하는 화면 예시입니다.


각 상태는 1단계 → 2단계 → 3단계로 단순히 흐르지 않고, 자유롭게 상호 이동이 가능한 구조였기 때문에, 이 상태들의 변화를 일관되게 관리할 수 있는 체계가 필요했습니다.
📌 해결 방안
이슈를 해결하기 위해 Zero Payload 방식에서 벗어나, 이벤트 메시지에 필요한 도메인 정보를 명시적으로 포함하는 Fat Payload 설계 방식을 도입했습니다.
- 이벤트 메시지에 stateTransition(상태 전이), stateTransitionActor(전이 주체), prevState(이전 상태) 등의 필드를 추가해,
- 수신 도메인에서 상태 변화의 맥락을 파악할 수 있도록 개선했습니다.
- 상태 전이 정보를 활용한 예약 상태 카드 발송 기능 구현이 가능해졌습니다.
{
"consultationId": 5621989,
"hospitalId": 4848,
"txDateTime": "2025-04-05T18:17:35",
"raisedAt": "2025-03-29T15:38:32",
"stateTransition": "CONSULTATION_CANCELED",
"stateTransitionActor": "MEMBER",
"prevState": "FIXED"
}
『도메인 주도 설계 첫걸음 (Domain Modeling Made Functional)』 5장 - 모델 간 통신 (Interfacing between bounded contexts)에서 이벤트 메시지에 필요한 도메인 컨텍스트 정보를 충분히 담아야 한다고 말합니다. 원문 내용은 아래와 같습니다.
"When a bounded context emits an event, it should carry enough information for the other context to understand what happened without querying back."
→ 이벤트가 발행될 때, 다른 컨텍스트가 그것을 추론 없이 이해할 수 있도록 충분한 정보를 담아야 한다.
- stateTransition → "무슨 일이 일어났는지"
- prevState, stateTransitionActor → "어떤 상태에서", "누가", 어떻게 변화시켰는지"
[1] 기존 구조 (Zero Payload)
────────────────────────────────────────────
[상담 도메인] [수신 도메인]
상태 변경 발생 ─┬─> SNS/SQS 발행 (payload: {}) ──┬──> 상태 조회 API 호출
│ │
│ ▼
└────────→ 단순 Snapshot 수신 → 추론 기반 처리
(ex. 현재 상태만 존재)
문제: 이전 상태 정보 없음 → 추론 필요 → 기능 구현 제약 발생
────────────────────────────────────────────
[2] 개선 구조 (Fat Payload)
────────────────────────────────────────────
[상담 도메인] [수신 도메인]
상태 변경 발생 ─┬─> SNS/SQS 발행 (Fat Payload) ──┬──> 즉시 이벤트 처리
│ │
│ ▼
└────────→ 상태 전이 정보 포함 → 맥락(Context) 이해 가능
{
"stateTransition": "CONSULTATION_CANCELED",
"prevState": "FIXED",
"stateTransitionActor": "MEMBER"
}
결과: 추론 없이 독립적 처리 가능 → 도메인 간 맥락 전이 ↓, 처리 효율 ↑
────────────────────────────────────────────
Fat Payload를 설계하면서 가장 고민되는 지점은 "이 필드를 추가하면 Downstream과 강결합이 되는 건 아닐까?" 하는 부분이었습니다. Fat Payload와 Thin Payload는 각각 언제 사용할 수 있는 것 일까요?
📌 메세지와 메세지 채널의 종류
『마이크로서비스 패턴』과 『도메인 주도 설계 첫걸음』을 읽고 정리한 내용은 아래와 같습니다.
이벤트는 메세지이지만 메세지가 반드시 이벤트는 아닙니다. 메세지에는 2가지 유형이 있습니다.
메시지와 이벤트는 다릅니다. 이벤트(Event)는 메시지의 한 종류이지만, 모든 메시지가 이벤트인 것은 아닙니다.
메시지는 크게 두 가지 유형으로 나눌 수 있습니다
1️⃣ 커맨드(Command)
"특정 동작을 수행해라"라는 명령
- 특정 동작을 수행하라는 지시를 담은 메시지입니다.
- 수신자는 해당 명령을 반드시 실행해야 하며, 요청자와 수신자 간에 강한 의존 관계가 존재합니다.
- 제로 페이로드 가능
예시:
- CreateOrderCommand → "주문을 생성해라"
2️⃣ 이벤트(Event)
"무엇이 일어났다"는 사실
- 이미 발생한 도메인 내 사건을 알리는 메시지입니다.
- 수신자는 이 이벤트에 반드시 반응하지 않아도 되며, 느슨한 결합(loose coupling) 을 유지할 수 있습니다.
- 제로 페이로드 불가능
예시:
- OrderCreatedEvent → "주문이 생성되었다"
커맨드와 이벤트 모두 메시지 기반 비동기 통신에 사용될 수 있지만, 역할과 관계성은 분명히 다릅니다. 커맨드는 동사 형태로 지시를 내리는 구조 (Create, Cancel, Update 등)이고, 이벤트는 과거형 또는 수동태로, 어떤 일이 이미 발생했는지를 기술 (Created, Cancelled, Updated 등)합니다.
이벤트는 수신자가 다양한 방식으로 처리할 수 있도록, 충분한 정보를 포함하는 것이 중요합니다. 즉, 발행자는 수신자를 배려해 설계해야 하며, 이벤트를 통해 "무슨 일이, 언제, 누가, 어떤 상태로" 일어났는지를 최대한 명확히 전달해야 합니다.
메세지 채널은 2가지 종류가 있습니다.
1️⃣ point-to-point 채널

하나의 메시지를 여러 수신자 중 오직 한 명에게만 전달합니다. 요청-응답 또는 명령 기반의 일대일 상호작용에 적합하며, 일반적으로 커맨드를 처리할 때 사용됩니다.
2️⃣ 발행-구독(publish-subscribe)

하나의 메시지를 해당 채널을 구독하고 있는 모든 수신자에게 전달합니다. 이벤트 기반의 일대다 상호작용에 적합하며, 시스템 간 느슨한 결합을 가능하게 합니다.
이처럼 메시지의 성격(Command vs Event)에 따라 적절한 메시지 채널(Point-to-Point vs Publish-Subscribe)을 선택하는 것이 중요합니다.
기존 상담 도메인에서는 “수행을 요구하는 커맨드 유형의 메세지”를 발행하면서도, 메시지 채널은 pub-sub 구조를 사용하고 있어 의도한 커맨드의 특성과 메시지 전달 방식 간에 불일치가 발생하고 있습니다.
- 커맨드 메시지(Command)는 명확한 수신자 1명에게 전달되어야 하며, 이는 point-to-point 채널에 적합합니다.
- 반면, publish-subscribe 구조는 복수의 수신자에게 메시지가 브로드캐스트되므로, 커맨드 메시지를 사용하는 데에는 부적절한 방식입니다.
이와 같은 설계 불일치로 인해, 특정 도메인만 메시지를 소비해야 하는 요구사항에서 의도치 않은 메시지 소비자 존재로 인해 기능 구현에 어려움이 생겼으며, 메시지 수신의 책임과 실행 보장을 설계상에서 명확히 하지 못해 시스템의 예측 가능성과 유지보수성 저하로 이어졌습니다.
따라서 메시지의 의미(Command vs Event)에 따라, 적절한 메시지 채널(Point-to-Point vs Pub-Sub)을 선택하는 것이 중요하며, 이 원칙을 지키지 않을 경우 의도한 시스템 동작과 실제 동작 간 괴리가 발생할 수 있습니다.