본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성하였습니다.
강의 요약
오늘 강의에서는 여태까지의 서비스들을 모두 도커 컴포즈로 묶어 실행시키고 현재 서비스간 로직에서 이벤트 구조로 변경하기 위해 먼저 이벤트를 설계하는 내용이었다. 저번 글에 적었던 것처럼 주문 -> 결제 -> 배송로직은 이벤트라기보다는 커맨드 형식으로 설계하는 느낌을 받고 있엇는데 강의에서도 살짝 언급은 되었지만 명확하진 않았다. 따라서 분산 시스템에서 메시지 타입을 분류하는 기준과 좋은 이벤트 설계를 위해 알아보자.
메시지 타입 분류
Azure 메시징 시스템(Azure Service Bus, Azure Event Hubs 등)의 수석 아키텍트인 Clemens Vasters는 분산 메시징 유형을 분류하는 방식을 제안했는데, 그는 분산 메시징을 두 가지 범주로 분류한다.

- Intents는 제어권을 이전하는 메시지로, Commands(특정 행동 요청)와 Queries(정보 조회 요청)가 포함된다.
- Facts는 이미 발생한 사실을 나타내며, Events(상태 변화 알림)와 Documents(정보 전달)가 이에 해당한다.
이 분류가 중요한 이유는 각 메시지 타입에 따라 설계 방식과 책임 소재가 달라지기 때문이다. Command는 수신자가 특정 동작을 수행해야 하는 의무를 지니지만, Event는 발행자가 단순히 사실을 알리는 것으로 수신자의 반응을 기대하지 않는다.
EDA의 안티 패턴: Passive-Aggressive Event
EDA를 도입하면서 흔히 저지르는 실수 중 하나가 모든 통신을 이벤트로 만들려는 시도인데, 이 과정에서 발생하는 대표적인 안티 패턴이 바로 "Passive-Aggressive Event"이다.
Passive-Aggressive Event란
Martin Fowler는 이 패턴을 "passive-aggressive command"로 명명했다. 발행자가 수신자에게 특정 행동을 기대하면서도 이벤트 형태로 메시지를 포장하는 경우를 말한다.
좋은 이벤트의 특성은 다음과 같다.
- 과거에 발생한 사실을 나타낸다.
- 발행자는 누가 구독하는지 알지도 않고 관심도 없다.
- 수신자의 반응을 기대하지 않는다
Passive-Aggressive Event는 이 세 번째 특성을 위반한다. 겉으로는 이벤트처럼 보이지만, 실제로는 특정 수신자가 특정 동작을 수행하기를 암묵적으로 기대한다.
구체적인 예시 비교
결제가 접수되면 확인 이메일을 발송하고 상태를 변경하는 시나리오를 살펴보자.
이벤트 기반 설계
[Payment Service] --PaymentReceived--> [Email Service]
<--PaymentConfirmed--
- Payment Service는 "PaymentReceived" 이벤트를 발행하고
- Email Service는 이를 구독하여 이메일을 발송한 뒤 "PaymentConfirmed" 이벤트를 발행하거나 Payment Service를 직접 호출하여 상태를 변경한다.
커맨드 기반 설계
[Payment Service] --SendConfirmationEmail--> [Email Service]
<--Success/Failure Response--
- Payment Service가 "SendConfirmationEmail" 커맨드를 전송하고, Email Service는 처리 결과를 응답한다.
- Payment Service는 응답을 받아 상태를 변경한다.
왜 커맨드가 더 적절한가
첫 번째 설계에서 Email Service는 결제 도메인의 비즈니스 로직을 알게 된다. "결제가 접수되면 확인 이메일을 보내야 하고, 이메일이 발송되면 결제 상태를 변경해야 한다"는 로직이 Payment Service가 아닌 Email Service에 위치하게 되는 것이다.
| 구분 | 이벤트 방식 | 커맨드 방식 |
| 비즈니스 로직 위치 | Email Service에 분산 | Payment Service에 집중 |
| Email Service 역할 | 결제 로직 인지 필요 | 이메일 발송만 담당 |
| 재사용성 | 결제 도메인에 종속 | 범용적으로 활용 가능 |
| 책임 소재 | 불명확 | 명확 |
- 이런 패턴이 여러 서비스에 반복되면 "분산 모놀리스(Distributed Monolith)"가 된다.
- 마이크로서비스의 단점(네트워크 복잡성, 운영 부담)과 모놀리스의 단점(강한 결합)을 동시에 갖게 되는 최악의 상황이다.
올바른 선택 기준
메시지 타입 선택은 다음 기준으로 판단할 수 있다.
이벤트를 선택해야 할 때
- 발생한 사실을 여러 관심 있는 서비스에 알릴 때
- 수신자가 누구인지 발행자가 알 필요 없을 때
- 수신자의 반응 여부가 발행자의 다음 동작에 영향을 주지 않을 때
커맨드를 선택해야 할 때
- 특정 서비스가 특정 동작을 수행해야 할 때
- 수행 결과에 따라 다음 동작이 결정될 때
- 명확한 요청-응답 흐름이 필요할 때
커맨드의 기술적 구현
커맨드와 이벤트는 개념적 차이뿐 아니라 구현에 사용하는 도구도 달라진다. 핵심은 전달 방식의 차이다.
1:1 전달 vs 브로드캐스트
- 커맨드: 특정 대상에게 작업을 시키는 것 → 1:1 전달 (Targeted Delivery)
- 이벤트: 사실을 알리는 것 → N:N 브로드캐스트 (Broadcast)
이벤트는 "누가 듣든 상관없이" 발행하므로 SNS, Kafka 같은 Pub/Sub 방식이 적합하다. 반면 커맨드는 "특정 컴포넌트 하나가 처리해야" 하므로 1:1로 전달되는 방식이 필요하다.
비동기 커맨드: 메시지 큐 (SQS, RabbitMQ)
- 커맨드를 비동기로 처리할 때는 메시지 큐가 적합하다.
| 특성 | 설명 |
| 1:1 전달 | 특정 서비스의 큐에 직접 메시지를 넣는다 |
| 작업 보장 | 수신자가 처리할 때까지 메시지를 보관한다 |
| 로드 밸런싱 | 여러 워커가 하나의 큐를 바라보며 작업을 분배한다 |
앞서 다룬 결제-이메일 예시를 메시지 큐로 구현하면 다음과 같다.
[올바른 구현: 메시지 큐 사용]
1. 이메일 서비스 앞단에 EmailSendingQueue(SQS) 배치
2. 결제 서비스가 SendConfirmationEmail 메시지를 큐에 직접 삽입
3. 이메일 서비스는 큐에서 메시지를 꺼내 작업만 수행
→ 이메일 서비스는 "왜" 보내는지 알 필요 없음
→ 결제 서비스는 "언젠가는 처리된다"는 보장을 받음
- 메시지 큐를 사용한다는 것은 "할 일을 쪽지에 적어 담당자의 서류함(Queue)에 넣어두는 것"과 같다.
- 담당자가 자리에 없어도 서류함에 쪽지는 남아 있으니 작업이 유실되지 않는다.
동기 커맨드: REST / gRPC
- 커맨드라고 해서 반드시 메시지 큐만 써야 하는 것은 아니다.
- 즉시 결과값(성공/실패)이 필요한 경우 동기식 통신이 더 적합하다.
[동기 커맨드가 적합한 경우]
- 결과값을 받아야 다음 로직을 진행할 수 있을 때
- 실패 시 즉시 롤백이나 보상 처리가 필요할 때
- 요청-응답 패턴이 자연스러운 경우
- 단, REST/gRPC는 수신자가 죽어 있으면 요청 자체가 실패한다.
- 담당자에게 직접 전화를 거는 것과 같아서, 전화를 안 받으면 일을 시킬 수 없다. 따라서 큐를 쓸 때보다 결합도가 높아진다.
비동기 커맨드인데 '응답'이 꼭 필요하다면?
커맨드 후 이벤트(Command followed by Event) 패턴
- 가장 권장되는 비동기 처리 방식이다. '요청'은 큐로 보내고, '응답'은 이벤트로 받는 것이다.
- 이를 "원인과 결과(Cause and Effect)" 또는 "시스템 변경의 전과 후(Before and After)"라고 설명할 수 있다.
- 논리적으로는 요청-응답 패턴이지만, 구현은 완전히 비동기적으로 이루어진다.
작동 흐름
- 송신자(주문 서비스): ProcessPayment라는 커맨드를 결제 서비스의 큐에 넣는다.
- 수신자(결제 서비스): 큐에서 메시지를 꺼내 결제를 처리한다.
- 결과 통보: 처리가 완료되면 결제 서비스는 PaymentProcessed(성공) 또는 PaymentFailed(실패)라는 이벤트를 이벤트 버스에 발행한다.
- 확인: 처음에 커맨드를 보냈던 주문 서비스는 이 결과 이벤트를 구독하고 있다가, 이벤트가 도착하면 그때 비로소 "아, 결제가 끝났구나"라고 인지하고 후속 로직을 수행한다.
왜 이것이 중요한가: 캡슐화와 변경 비용
David Parnas가 1970년대부터 강조한 정보 은닉(Information Hiding) 원칙의 관점에서 이 문제를 살펴보면 더 명확해진다.
이벤트 방식에서 Email Service는 "PaymentReceived 이벤트가 오면 확인 이메일을 보내고, PaymentConfirmed 이벤트를 발행해야 한다"는 결제 도메인의 비즈니스 로직을 알게 된다. 이는 Payment Service의 캡슐화를 깨뜨리는 것이다. 결제 프로세스가 변경되면(예: 확인 이메일 발송 시점 변경, 추가 검증 단계 삽입) Email Service의 코드도 함께 수정해야 한다.
변경 비용의 증가
결제 프로세스 변경 시 영향 범위:
[이벤트 방식]
Payment Service 수정 + Email Service 수정 + 연관 서비스 N개 수정
[커맨드 방식]
Payment Service 수정
- Email Service가 결제 도메인에 종속되면 재사용성도 떨어진다.
- User Account Service에서 환영 이메일을 보내려면? Bank Account Service에서 알림 이메일을 보내려면?
- Email Service가 각 도메인의 비즈니스 로직을 모두 이해해야 한다면, 결국 모든 도메인의 로직이 Email Service에 축적된다.
Distributed Monolith의 형성
이런 패턴이 시스템 전반에 반복되면 "분산 모놀리스"가 형성된다.
Distributed Monolith = 분산 시스템의 단점 + 모놀리스의 단점
분산 시스템의 단점:
- 네트워크 지연과 장애
- 분산 트랜잭션의 복잡성
- 운영 및 모니터링 부담
모놀리스의 단점:
- 강한 결합으로 인한 변경 전파
- 독립적 배포 불가
- 단일 장애 지점
- 서비스를 물리적으로 분리했지만 논리적으로는 강하게 결합되어 있어, 하나의 변경이 여러 서비스에 연쇄적으로 영향을 미친다.
- 마이크로서비스의 이점(독립적 개발, 배포, 확장)은 사라지고 복잡성만 남는다.
SOLID와 DDD 관점에서의 해석
단일 책임 원칙(SRP)의 관점에서 Email Service는 "이메일을 발송한다"는 하나의 책임만 가져야 한다. "결제가 완료되면 확인 이메일을 보내고 상태를 변경한다"는 결제 도메인의 책임이며, 이는 Payment Service에 있어야 한다.
DDD의 Bounded Context 관점에서도 마찬가지다. 결제 컨텍스트의 비즈니스 규칙은 결제 컨텍스트 내부에 캡슐화되어야 한다. Email Service는 메시징 컨텍스트로서, 결제 컨텍스트가 무엇을 요청하든 이메일 발송이라는 자신의 역할만 수행하면 된다.
명확한 경계와 책임 분리는 조직의 흐름을 개선하고 인지 부하를 줄인다. 각 팀은 자신의 서비스가 어떤 계약(Contract)을 제공하는지만 이해하면 되고, 다른 서비스의 내부 로직을 알 필요가 없다.
결합도에 대한 오해
"이벤트는 느슨한 결합, 커맨드는 강한 결합"이라는 공식은 널리 퍼져 있지만 오해의 소지가 있다.
결합도를 결정하는 것은 메시지 타입이 아니라 캡슐화다.
캡슐화는 시스템이 최소한의 인터페이스만 노출하면서 통합할 수 있게 해준다. 위 예시에서 커맨드를 사용하면 Payment Service는 이메일 발송에 필요한 최소한의 정보(수신자, 제목, 내용)만 전달한다. Email Service는 이메일을 왜 보내야 하는지 알 필요 없이, 요청받은 이메일을 발송하는 역할만 수행한다.
[커맨드 방식의 인터페이스]
SendConfirmationEmail {
to: "customer@example.com"
subject: "결제 확인"
body: "결제가 완료되었습니다."
}
→ Email Service는 이것이 결제 확인인지, 회원가입 환영인지,
비밀번호 재설정인지 알 필요도 없고 관심도 없다.
각 서비스는 자신의 책임 범위 내에서만 동작하므로 실제 결합도는 오히려 낮아진다. 이벤트라는 형식을 사용하면서 암묵적인 기대와 숨겨진 의존성을 만드는 것보다, 커맨드로 명시적인 계약을 맺는 것이 더 낮은 결합도를 달성할 수 있다.
그렇다면 항상 커맨드를 선호해야 하는가?
절대 그렇지 않다. 이벤트를 발행하고 다운스트림 컨슈머가 반응하든 말든 상관없는 상황이라면, 이벤트가 더 적절하다.
예를 들어 "주문이 완료되었다"는 이벤트는 재고 서비스, 배송 서비스, 분석 서비스, 추천 서비스 등 여러 서비스가 각자의 목적에 맞게 구독하고 처리할 수 있다. 주문 서비스는 누가 이 이벤트를 구독하는지 알 필요 없고, 새로운 서비스가 추가되어도 코드 변경이 필요 없다.
핵심은 숨겨진 의존성과 백채널을 통해 의도치 않게 결합도를 높이지 않도록 주의하는 것이다.
EDA에 대한 흔한 오해들
Passive-Aggressive Event 패턴을 이해하면서 함께 짚어볼 만한 EDA의 오해들이 있다.
EDA는 Event Sourcing과 다르다
Event Sourcing은 서비스 내부에서 상태를 저장하는 방식이다. 현재 상태 대신 상태 변경 이벤트를 저장하고, 이벤트를 재생하여 상태를 복원한다.
EDA는 서비스 간 통신 방식이다. 서비스가 상태 변경을 이벤트로 발행하고, 다른 서비스가 구독하여 처리한다.
두 패턴은 상호 보완적이지만 독립적으로 사용할 수 있다. Event Sourcing 없이 EDA를 구현할 수 있고, EDA 없이 단일 서비스 내에서 Event Sourcing을 적용할 수 있다.
Kafka 사용이 곧 EDA는 아니다
Kafka는 로그 기반 메시지 브로커이며, 전송하는 메시지가 이벤트인지 커맨드인지는 사용자의 설계에 달려 있다. Kafka를 사용하면서도 모든 메시지를 커맨드로 설계할 수 있고, Kafka 없이도 HTTP Feeds 같은 방식으로 EDA를 구현할 수 있다.
시스템을 이벤트 기반으로 만드는 것은 도구가 아니라 메시지의 의미(semantics)와 시스템 전체의 흐름이다.
네이밍만으로는 이벤트가 되지 않는다
메시지 이름을 "OrderCreated", "PaymentProcessed"처럼 과거형으로 짓는다고 해서 자동으로 좋은 이벤트가 되지는 않는다.
만약 각 이벤트가 정확히 하나의 수신자를 가지고, 그 수신자가 다시 이벤트를 발행하여 원래 발행자에게 응답하는 구조라면, 이는 이벤트로 위장한 요청-응답 패턴이다. 시스템 전체의 메시지 흐름을 보고 판단해야 한다.
참고 출처
- https://javierholguera.com/2025/04/15/the-passive-aggressive-event/
- https://www.reactivesystems.eu/2024/09/30/five-common-misconceptions-about-eda.html
- https://martinfowler.com/articles/201701-event-driven.html
- https://www.youtube.com/watch?v=3SQWHptOA0Q
- https://www.youtube.com/watch?v=STKCRSUsyP0
- https://medium.com/@esaddag/event-driven-architecture-modern-applications-and-best-practices-e4cb26d40db6
- https://medium.com/@seetharamugn/the-complete-guide-to-event-driven-architecture-b25226594227
- https://medium.com/swlh/event-notification-vs-event-carried-state-transfer-2e4fdf8f6662
















































