-
Event Driven Architecture 적용해보기Architecture 2025. 1. 12. 17:36
최근에 회사에서 이벤트 기반 아키텍처를 손을 봐야 하는 경우가 생겼습니다.
그래서 여러 잘된 곳을 참고하여 아래와 같은 도식표를 그리고 적용해 봤습니다.
Event-Driven Architecture Event-Driven Architecture의 발행 실패된 이벤트 재발행 로직 위 도식표는 발행을 보장하기 위한 Transaction Outbox Pattern을 이용했습니다.
흐름
- 어떤 기능에 요청이 들어와서 실행을 하고 이벤트를 방출 한다.
- 이때, 이벤트를 방출하는 Listener는 @TransactionEventListner(phase = Transaction.AFTER_COMMIT) 애노테이션을 갖고 Listen하고 있다. 그러므로 해당 기능이 실패하면 이벤트가 발행하지 않음을 보장할 수 있다.
- @TransactionEventListner(phase = Transaction.AFTER_COMMIT) 애노테이션으로 Listen한 이벤트는 내부로 이벤트를 방출한다.
- 방출 할때에는 기존의 ApplicationEventPublisher를 사용하지 않고, 실패시, 발행을 한 번더 할 수 있는 retry기능을 구현한 publisher가 방출한다.
- 내부로 방출된 이벤트는 @async, @EventListener 로 listen하고 있으며, 여기서 알맞은 로직을 실행함
- 로직을 실행 후에 해당 이벤트에 대하여 발행완료 이벤트를 발행하여, EventRecordListener가 이를 listen하여 발행완료를 EventRecord에 기록을 함.
- 모든 이벤트는 EventRecordListener는 해당 이벤트를 listen하고 eventRecord에 기록을 한다.
- 모든 이벤트는 Event라는 추상클래스를 상속하였고, 그렇기에 EventRecordListener는 추상클래스인 Event를 listen하고 있음.
- 또한, @Transactional 애노테이션을 갖고 listen하고 있어서 기능이 실패하면(commit되지 않으면) listen도 하지 않음.
- Listener에서 로직을 실행하다가 Error가 발생하면, DeadLetter로 실패됨이 기록됨.
처음에 위 도식표를 그리고 적용하면서 다음과 같은 생각을 하고 의문을 갖었었습니다.
Spring의 ApplicationPublisher는 Application Layer에 있어도 될까?
Transaction Outbox Pattern을 제대로 이해했을까?
재발행을 Event Record에서 읽고 실행을 해야 할까?
Event를 나눠도 될까?Spring의 ApplicationPublisher는 Application Layer에 있어도 될까?
이 Event-Driven Architecture를 구현하면서 처음 들었던 생각이 위와 같은 질문이었습니다. 왜냐하면, 저 기능은 특정 Framework의 기술에 의존적이라고 생각이 들었기 때문입니다. 만약, 그런 일은 없겠지만, Port and Adapter Architecture를 메인으로 사용하고 있는데, 해당 프로젝트의 Core모듈을 다른 쪽 (Nestjs, 다른 부분..)으로 이식을 한다고 했을 때, 문제가 생길 것이라 판단이 들었어서 위와 같은 생각을 갖게 되었습니다. 하지만, 저는 Port and Adapter Architecture는 위와 같은 기술적인 의존성을 Port로 사용하고 구현체는 외부에 두는 방식으로 하는 근본적인 개념을 잊고 있었나 봅니다. ApplicationPublisher는 인터페이스로 충분히 Port의 역할을 하고 있음을 뒤늦게 알아 버렸습니다..😅 (이 또한, 완전히 제외시키려면, Publisher를 Interface로 만들고, Core모듈 밖에서 ApplicationPublisher를 사용하면 완전히 의존성을 끊어버릴 수 있습니다.)
Transaction Outbox Pattern을 제대로 이해했을까?
저 도식표를 그리면서 무언가가 머릿속에 정리가 제대로 되지 않았던 느낌이 들었습니다. 왜냐하면, EventRecord와 DeadLetter의 역할이 계속 헷갈리기 시작했기 때문입니다. 이런 생각을 계속 갖고 있었던 기억이 있습니다 "Event Listener에서 Event를 Listen을 하고 해당 로직을 실행하고 EventRecord에 publish를 기록하면 굳이 DeadLetter가 필요할까?"가 은연중에 계속 머릿속에서 생각이 났습니다. 그래서 "아 나는 Transaction Outbox Pattern을 제대로 이해하지 못했구나"라고 깨닫게 되었습니다. 다시 해당 Pattern을 찾아보았습니다.
Transaction Outbox Pattern에서는 다음과 같은 문제점을 해결하는 패턴이라고 얘기합니다.
"How to atomically update the database and send messages to a message broker?"맞습니다. 어떤 기능이 데이터베이스에 업데이트되는 것과 메시지 브로커에 메시지를 전달하는걸 원자적으로 해주는 패턴입니다. 그렇기에 DeadLetter는 Listener에서 Event를 Listen을 했지만, 로직에서 실패한 내용만 기록하면 되는 것입니다. 이를 모니터링하여 재발행만 해주면 되고, EventRecord는 발행여부만 기록해 주면 된다고 판단이 되었습니다.
재발행을 Event Record에서 읽고 실행을 해야 할까?
설계한 구조는 내부 이벤트에 대해서는 1:1 구조로 구현되도록 의도하였습니다. 그래서 Transaction Outbox Pattern의 전통적인 방법에서 확장되어 내부 이벤트 발행도 EventRecord로 발행여부를 판단하도록 설계가 되어있습니다. DeadLetter에 기록된 실패 원인과 어떤 Event가 실패되었는지 모니터링 후, 해당 이벤트를 재발행하도록 실행하면, EventRecord에서 해당 이벤트를 다시 읽어 재발행되도록 하였습니다.
Event를 나눠도 될까?
위에서 설명한 "설계한 구조는 내부 이벤트에 대해서는 이벤트와 리스너가 1:1 구조로 구현되도록 의도하였다"에 대한 설명입니다.
우선 내부 이벤트 발행에 대해서 Kafka나 다른 메시지 큐를 사용하지 않았습니다. (이유는 아직 내부 이벤트에 대해서는 spring event로 충분하다고 느껴졌고, 발행이 되었는지 여부를 EventRecord로 확인할 수 있다고 판단하였습니다.) 그렇기에 어떤 기능이 이벤트를 방출한다고 하면, TransactionEventListener 애노테이션이 달린 Listener에서 내부 이벤트, 외부 이벤트를 방출하게 됩니다. (외부이벤트는 Kafka를 이용해 타 서비스에게 브로드캐스팅합니다.)
그리고, 만약 하나의 이벤트가 여러 Listener에서 Listen을 하고 있다고 가정하면, 특정 Listener가 로직에 실패하여 DeadLetter에 들어갔을 때, 이벤트 재발행을 하면, 실패한 Listener만 Listen을 하고 싶었습니다.
후기
당시에 도식표를 작성하였을 때, 괜찮다고 생각을 했지만, 블로그를 작성하면서, 여러 깨달음으로 해당 도식표의 흐름에 오류가 있다고 생각이 들었습니다.
내부 서비스에서의 이벤트와 리스너가 1:1 구조
회사의 개발자와 같이 고민을 했었는데, 저도 이 부분에 대해서 다시 한번 생각을 하게 되었습니다. 1:1 구조라면 상당히 결합력이 있다고 판단이 들었습니다. 결합력을 느슨하게 해 주는 걸 목적으로 사용하였는데, 강한 결합력이라니.. 말이 안 되는 구조라고 생각이 들었습니다.
이 부분은 다음과 같이 개선하여 적용하려고 합니다.
현재는 아래와 같은 추상 클래스를 모든 이벤트들이 상속을 받아 사용하고 있고, EventRecordListener가 해당 추상 클래스를 listen 하여 모든 이벤트에 대해 EventRecord에 기록하도록 구현되어 있습니다.
public abstract class Event { private final String evnetId; ... }
그리고 내부 이벤트는 아래와 같은 해당 publisher를 통해 발행하도록 되어있습니다.
public interface EventPublisher { void publish(Event event); }
아래 코드처럼 위 구현체에서 발행을 하고 발행이 완료되면, 바로 eventRecord에서 publish를 했다고 기록을 하면, Listener에서 publish를 하지 않아도 되고, 이벤트와 리스너가 1:1이 아닐 수 있게 됩니다. (Listener가 로직을 끝낸 후 발행완료 처리를 하기에 여러 이벤트에서 구독을 하게 되면, 여러 리스너가 발행완료 처리를 하게 됨.)
public class EventPublisherImpl implements EventPublisher { ... private final ApplicationEventPublisher publisher; private final EventProcessor eventProcessor; public void publish(Event event) { try { publisher.publishEvent(event); // 이벤트를 발행을 하고 난 후 발행완료처리를 한다. eventProcessor.publish(event); } catch (Exception e) { // 이벤트가 발행이 제대로 되지 않으면, 발행 실패했다고 EventRecord에 기록을 한다. eventProcessor.fail(event); } }
참고한 자료
https://techblog.woowahan.com/7835/
https://github.com/AkashNeil/spring-boot-event-driven-microservices
https://github.com/akash-coded/spring-framework/discussions/168
https://javadzone.com/mastering-spring-boot-events-5-best-practices/
https://medium.com/@greg.shiny82/트랜잭셔널-아웃박스-패턴의-실제-구현-사례-29cm-0f822fc23edb글
https://github.com/Sairyss/domain-driven-hexagon
https://microservices.io/patterns/data/transactional-outbox.html
- 어떤 기능에 요청이 들어와서 실행을 하고 이벤트를 방출 한다.