TL;DR
- 이벤트는 Kafka 같은 브로커 전용이 아니다. 단일 애플리케이션 안에서도 결합도를 낮추고 후속 작업을 분리하는 데 충분히 유용하다.
- 규모가 작을 땐 직접 호출이 단순하고 빠르지만, 규모가 커지면 사건을 발행하고 소비하는 이벤트 드리븐 방식이 자연스럽게 필요해진다.
- 발행은 애플리케이션 레이어에서 담당하고, 리스너는 외부 연동은 interfaces, 내부 처리는 application 등 용도에 따라 위치를 구분할 수 있다.
- 동기/비동기 기준: 정합성이 중요하면 동기, 빠른 응답이 필요하면 AFTER_COMMIT + Async
- 이벤트를 적용하니 Facade/Handler로 경계가 드러나고, 코드가 간결해졌으며, 리팩토링과 협업이 한결 수월해졌다.
0. 이벤트에 대한 나의 오해
- “이벤트 = Kafka, RabbitMQ 같은 메시지 큐"가 아닌가?
- MSA가 아닌데 꼭 써야하나? 그냥 호출하면 안될까?
- 불필요하게 복잡해지는건 아닐까?
나는 회사에서 RabbitMQ를 쓰고 있고 이벤트를 발급하고 수신해 처리하는 로직들도 많다.
그래서 round-7에서 이벤트를 다룬다길래 당연히 Kafka, RabbitMQ를 다루는 줄 알았다.
MSA와 같이 도메인 별 기능들이 서버에 분산되어 있는 형태에서 이런 브로커가 필요하고, 이벤트도 그럴 때만 쓰는 줄로 생각했다.
닭이 먼저인지 달걀이 먼저인지, “구조가 복잡해지면 이벤트를 쓰는 것인지, 이벤트를 쓰니 복잡해지는 것인지” 잘 모르겠지만
당시 나는 이렇게 정리했다. “이벤트 드리븐 = MSA = 복잡함”
멘토링에서 이에 관한 질문을 했고 내가 정리한 내용은 아래와 같다.
스프링 ApplicationEvent는 Kafka, RabbitMQ 같은 브로커와 용도부터 다르다.
하나의 Application 내부에서도 충분히 필요하게 쓰일 수 있고,
나중에 원한다면 브로커와도 함께 공존할 수 있다.
즉, 이벤트는 “서비스 간 통합"만을 위한 것이 아니다.
동일한 애플리케이션 안에서도
- 도메인 간 결합을 줄이고,
- 트랜잭션 이후에 처리할 작업을 분리하며,
- 확장 포인트를 명시적으로 열어둔다.
는 점에서 충분히 가치가 있다.
1. 이벤트가 필요할 때
멘토님들은 늘 어려운 개념을 일상적인 비유로 쉽게 풀어주신다.
이번 라운드에서 이벤트가 왜 필요한지를 설명할 때도 마찬가지였다.
작은 가게에서는…
동네에 아주 작은 마라탕집이 있다고 해보자.
테이블이 두세 개뿐이라면 손님이 “계산이요~” 하고 부르면 직원이 바로 와서 계산해주면 된다.
직접 호출(메서드 호출)로도 충분하다. 오히려 더 빠르고 간단하다.
하지만 규모가 커지면…
그런데 3000평짜리 대형 마라탕집이라면 어떨까?
테이블이 수백 개, 손님이 동시에 여기저기서 “계산이요~” 하고 부른다.
직원이 모두 뛰어다니며 직접 처리하려 한다면 혼란스럽고 시간이 오래 걸린다.
이럴 땐 방식을 바꿔야 한다.
손님이 요청을 하면, 담당 직원이 무전기에 대고 “A테이블 계산요~” 하고 중계한다.
그리고 그 신호를 들은 계산 담당 직원이 테이블로 가서 결제를 처리한다.
- “계산이요~” → 이벤트 발행
- 무전기 방송 → 이벤트 브로커
- 방송을 듣고 계산 처리 → 이벤트 컨슈밍
규모가 작을 때는 직접 호출이 단순하고 빠르다.
하지만 규모가 커지면 직접 연결은 오히려 혼잡과 병목을 만든다.
이때는 사건을 발행하고, 중계하고, 필요한 쪽에서 소비하는 ‘이벤트 드리븐’ 방식이 자연스럽게 필요해진다.
“이벤트 드리븐은 할 수밖에 없어진다.”
- Devin
2. 이벤트를 도입하면서의 고민들
이벤트를 실제 코드에 적용하면서 여러 가지 고민이 생겼다.
“어디서 발행해야 하지?”, “리스너는 어디에 두는 게 맞을까?”, “동기/비동기 기준은 어떻게 잡을까?”
정답은 아니지만, 나름의 기준을 정리해보았다.
2-1. 이벤트는 어디서 발행해야 할까?
처음엔 도메인 서비스 안에서 바로 이벤트를 발행해도 괜찮아 보였다.
도메인 엔티티가 상태를 바꾸면서 자연스럽게 “이벤트를 발생시킨다"는 그림은 꽤 그럴듯하다.
하지만 실제 구현에서는 애플리케이션 레이어에서 발행하기로 했다.
트랜잭션 경계, 아웃박스 저장, 발행 로깅 등 “발행 행위"는 기술적 고려가 많았고, 이는 도메인보다는 애플리케이션의 책임에 더 가깝다고 느껴졌기 때문이다.
그렇다고 해서 도메인에서 이벤트를 다루는게 틀렸다고 생각하지는 않는다.
도메인이 “이벤트 객체를 쌓고 반환"하는 건 자연스럽고, “발행"만 애플리케이션이 담당하는 방식도 있을 것 같다.
2-2. Listener는 어디에 둬야할까?
리스너 위치는 더 애매했다. 그래서 리스너를 나름의 기준으로 두 종류로 나누어봤다.
- 외부 시스템과 연동하는 리스너: 분명히 interfaces에 두는 게 자연스럽다. (ex. KafkaListener, RabbitListener)
- 내부 이벤트 처리를 위한 리스너: application에 두면 “내부 유스케이스의 일부"라는 점이 더 명확하게 드러난다.
- 특히 동기적인 Event Consume은 이쪽에 해당하는 것 처럼 느껴진다.
용도별로 두 레이어에 나눠 쓴다면 복잡할 것 같기는 한데 이런 방향이 명확하다고 생각했다. 이번에 구현한 리스너들은 처음엔 내부 이벤트 처리 위주이니 application에 두었다.
@RequiredArgsConstructor
@Component
class SomeListener {
private final SomeService someService;
@EventListener
public void onEvent(SomeEvent event) {
someService.doSomething(event); // -> application service를 단순 호출한다.
}
}
작성한 코드를 곰곰히 생각해보니, 리스너가 실제로 하는 일은 “이벤트를 받아서 다른 서비스를 호출하는 것"이었다. application layer에 둔다면 application service -> application service로 호출되는 점도 썩 마음에 들지 않았다.
로직을 직접 갖지 않고 단순히 위임만 한다면, 이것은 오히려 interfaces의 역할처럼 느껴졌다. 그래서 현재는 interfaces에 위치시켰다.
2-3. 동기 vs 비동기, 그리고 트랜잭션 경계
이벤트를 동기로 처리할지 비동기로 처리할지도 중요한 고민이었다.
나는 이렇게 기준을 잡았다.
- 정합성이 반드시 보장돼야 하는 경우 → 동기(TransactionalEventListener BEFORE_COMMIT).
- 정합성이 조금 덜 필요하고, 빠른 응답이 우선인 경우 → 비동기(Async + TransactionalEventListener AFTER_COMMIT).
- 비동기로 실행되지 않는다면 이벤트 발급하는 Transaction에 이어서 처리가 되기 때문에 빠른 응답을 줄 수가 없다.
예를 들어 결제 성공 후 주문 성공 처리같은 건, 결제 로직이 끝나자마자 꼭 함께 반영돼야 하므로 동기 이벤트가 맞다.
반대로 “로그 저장, 알림 발송"처럼 서비스 자체의 성공/실패와는 직접적 관련이 없는 작업은 AFTER_COMMIT + @Async로 돌려도 충분하다.
스프링에서 EventListener
, TransactionalEventListener
, BEFORE/AFTER_COMMIT
등의 조합을 어떻게 가져갈지는 아직 완전히 기준을 세운 건 아니다.
(특히, EventListener
…)
그리고 여기서의 고민은.. TODO: 비동기에서 실패하면 어쩌지?
3. 내가 선택한 구조에서 이벤트의 발행/처리 흐름
4. 구현하면서 얻은 깨달음
4-1. 이벤트는 경계를 드러낸다.
이벤트를 직접 적용해보니 가장 크게 다가온 건, “이벤트는 경계를 드러낸다” 는 점이었다.
먼저 기능에 경계를 짓는 작업이 필요하다. 어떤 것은 “명령으로 반드시 처리해야 할 핵심 동작"이고, 어떤 것은 “사건의 결과로 이어지는 부가적인 후속 동작"이다.
이 선을 긋는 기준은 딱 떨어진 정답이 있는 게 아니라, 도메인 지식이나 개발자의 경험에서 오는 감각적인 영역이라고 생각한다.
이 감각적으로 나눈 구분이 코드에서는 Facade와 Handler로 드러난다.
- Facade: “명령"을 받아 반드시 처리해야 할 유스케이스를 실행한다.
- Handler: “사건"을 받아 부가적인 후속 동작을 실행한다.
이렇게 구분하고 나니 코드가 말해주는 게 달라졌다.
기존에는 한 클래스 안에 모든 로직이 섞여 있어 “어디까지가 핵심 로직인지” 파악하기 어려웠다.
지금은 핵심 로직은 Facade에서 마무리되고, 이후에는 publish
로 넘기며 끝난다.
덕분에 “여기까지가 본질적 책임이고, 이후는 후속 처리” 라는 경계가 명확히 드러난다.
즉, 개발자가 감각적으로 느낀 구분을 코드 구조 자체가 보여주는 셈이다.
4-2. 이벤트를 적용하니 달라진 점
코드가 간결해졌다.
Facade와 Handler가 짧아지고, 각각의 의도가 뚜렷해졌다. 읽기도 쉽다.리팩토링이 쉬워졌다.
다른 코드들을 신경 쓸 필요가 없다. “이 이벤트는 이런 규격으로 발행된다"만 보장하면, 나머지는 이벤트 리스너가 알아서 처리한다.협업에도 유리하다. (아마도…)
“이벤트를 발행한다"는 약속만 공유하면 된다.
다른 팀원은 이벤트 스펙만 보고 자기 로직을 구현할 수 있고, 서로 코드에 깊게 의존하지 않아도 된다.
6. 마무리
고민해봐야할 문제들이 더 생긴 것 같다.
이벤트 드리븐은 할 수밖에 없어진다.
라고 했지만, 그 때를 파악하는 것도 “감각적” 인걸까?
아무래도 장점도 많지만, 작은 규모에서 하려다보면 오버엔지니어링 느낌이 날 것 같기도 하다.
발제 자료의 “그래서 실무에서는 이런 고민들을 해요” 항목들과 똑같은 고민이 자연스럽게 드는 것 같다.
- 실패한 비동기 이벤트는 어쩌지?
- 재시도는 어떻게 할까?
- 중요한 처리들은 결국 메시지 브로커를 써야하지 않을까?
다음 주차에 kafka가 예고되어 있으니, round8에서 이 의문을 풀어볼 수 있기를 기대한다.
thanks to loopers..