이벤트를 통한 시스템 결합도 낮추기
서론
여행 기록 관리 플랫폼 ‘여기가’ 프로젝트를 진행하며, 비밀번호 초기화 이메일 전송 기능 개발을 담당하게 되었다. 기능 개발에는 그리 오랜 시간이 소요되지는 않았지만, 구현을 완료한 뒤 에러 상황을 겪게 되며 한 가지 의문점이 들게 되었다.
해당 에러는 SPF/DKIM 관련 에러였으나, 필자가 궁금증을 가지게 된 부분은 사용자에겐 정상 발송 응답이 되지만, 실제 이메일은 발송되지 않는 상황이다. 이메일 발송 부분은 @Async 어노테이션을 통한 비동기로 동작하도록 설정하였기에 해당 쓰레드에서 에러가 발생하더라도 이는 이메일 초기화 요청 핵심 비즈니스 로직이 동작하는 쓰레드에 영향을 미치지 못한다.
해당 기능은 3분 동안 이메일 초기화 링크가 유효하며, 3분 이내 비밀번호 초기화 재요청 시 해당 요청은 거절된다. 즉, 이메일 발송이 실패한 경우에 사용자는 3분 간 아무것도 하지 못하는 상태가 되어버리는 문제가 발생하는 것이다.
필자는 이러한 문제를 해결하기 위하여 비밀번호 초기화 이메일 발송이 보장되어야 한다는 생각을 가지게 되었고, 이에 대한 해결법을 찾아보게 되었다.
결론적으로 ‘트랜잭션 아웃박스 패턴’이 해결법이라는 것을 알게 되었다. 트랜잭션 아웃박스 패턴을 통해 최소한 1번의 실행은 보장하는 ALO(At-Least Once)가 가능하다. 트랜잭션 아웃박스 패턴에 대한 더 자세한 내용과 적용은 차후 포스팅에서 다뤄보고자 한다.
이번 포스팅에서 중점적으로 다루고자 하는 내용은 ‘이벤트’이다.
트랜잭션 아웃박스 패턴에 대해 찾아보면, 대부분의 자료에서 이벤트 기반 아키텍처와 연관되어 있음을 알 수 있다. 처음에는 “왜 트랜잭션 아웃박스 패턴이 이벤트 기반 아키텍처와 연관되어있지?”라는 생각을 했었지만, 실제적으로는 “이벤트 기반 아키텍처에서 트랜잭션 아웃박스 패턴이 필요하다”는 것을 깨닫게 되었다.
즉, 트랜잭션 아웃박스 패턴에 대해 알아보기 전에 ‘이벤트 기반 아키텍처’에 대한 이해가 필요하다.
이벤트 기반 아키텍처
이벤트 기반 아키텍처에 대한 설명은 우아한형제들 2020 우아콘 김영한님의 ‘마이크로서비스 여행기’ 발표에서 이해할 수 있었다.
해당 발표는 마이크로서비스를 중점으로한 내용이지만, 마이크로서비스가 동작하기 위해서 이벤트 기반 아키텍처가 도입되어야하는 이유를 알게 해주었다.
메인 데이터베이스(루비)에 의존하던 레거시 시스템을 마이크로서비스로 전환하게 되는 과정에서 시스템 간 결합도를 최대한 분리하게 되는 과정을 엿볼 수 있었다.
특정 도메인을 메인으로 관리하는 하나의 시스템에 조회나 검색 등 다른 시스템들이 의존하게 된다. 직접 API 호출을 통하여 데이터를 조회할 시, 연쇄적인 장애 전파 문제 및 사용자 트래픽이 전파되어 시스템 부하를 높이는 문제가 발생하게 된다. 따라서, 각 시스템이 역할에 맞는 데이터를 최적화하여 가지고 있을 수 있는 데이터베이스를 별도로 가지게 된다. CQRS에 기반하여 조회(Query) 시스템과 명령(Command) 시스템으로 구분되게 된다.
결국, 조회 시스템의 경우 특정 도메인 시스템이 가지고 있는 테이블에서 자신이 사용자에게 제공할 최적화된 값들만 가지고 있어야 성능적인 면에서 유리하다. 즉, 특정 도메인의 일부 필요한 데이터를 가지고와서 해당 조회 시스템의 데이터베이스에 저장하게 된다.
이 과정에서 데이터의 변경이 생기면, 해당 데이터를 참조하는 조회 시스템으로 변경 내용을 전파하기 위해서 가장 적절한 방식이 ‘이벤트 기반 아키텍처’이다.
데이터 갱신 이벤트를 발행하고, 이를 이벤트 브로커(AWS SNS/SQS)를 통해 다른 시스템에 전파를 하게 된다. 해당 이벤트를 수신하게 되는 조회 시스템에서는 최신의 데이터를 받아오기 위해 이 시점에서 조회 API를 호출해 데이터를 갱신한다.
해당 방식은 Zero-Payload 방식을 사용하며, 최소한의 필요 데이터(ex. ID)만 전달해 데이터 정합성을 유지하는 방법이라 한다.
여기서 이벤트를 사용하여 전달하는 것이 장애 분리, 확장성, 재시도, 전송 보장 등 다양한 방면에서 이점을 얻을 수 있게 된다.
장애 분리
예를 들어 시스템 A와 B가 있다고 가정하면 다음과 같은 상황이 생길 수 있다.
- A 시스템의 핵심 비즈니스 로직 → B 시스템 API 직접 호출
- B 시스템에서 에러 발생
- A 시스템에도 에러가 전파되어 해당 트랜잭션이 실패
위와 같이 장애가 전파되어 주관심사인 핵심 비즈니스 로직도 실패하는 상황이 생길 수가 있다.
이벤트 기반 아키텍처를 사용하게 된다면, 핵심 비즈니스 로직에서는 이벤트만 발행하기 때문에 차후 이벤트를 활용하게 되는 타 시스템의 장애에 영향을 받지 않게 된다.
이는, 마이크로서비스 아키텍처에서 타 시스템 간 장애 전파가 아닌 모놀리식 단일 모듈에서 객체 간 장애 전파 상황과도 비슷할 것이다.
@Async도 장애 분리일까?
앞서, 비밀번호 초기화 메일 발송 과정에서 이메일 발송 로직 내 오류가 발생할 경우, 사용자에게 정상적인 응답이 가는 이유가 이메일 발송 로직이 @Async를 통한 비동기로 동작하기 때문이라 설명하였다.
이메일 발송까지도 핵심 로직이긴 하지만, @Async를 사용한 부가 로직에서 에러가 발생한 경우 핵심 비즈니스 로직에서 이 에러가 전파되지 않는다는 상황만 두고보면 이는 “@Async를 사용한 경우에도 장애 분리가 된다고 볼 수 있나?”라는 의문이 들었다.
결론적으로는 표면상 장애가 전파되지 않는다는 점은 맞긴하지만, 장애를 분리했다고 보기는 어렵다.
왜냐하면, 이는 장애가 적절히 대응된 것이 아니라 별도의 쓰레드에서 동작하기 때문에 장애 인식 시점이 늦어지는 결과만 초래할 뿐이기 때문이다. 이는 필자의 개인적인 생각이라 정확한 해석은 아닐 수 있지만, 적어도 장애 분리가 @Async의 장점은 아니라고 말할 수 있을 것 같다.
이벤트
앞선 내용에서 어느정도 이벤트에 대한 내용을 이해할 수 있었지만 해당 섹션에서 장점을 한 번 더 정리해보고자 한다.
이벤트 기반 아키텍처에 관한 내용을 찾아보며 “그래서 이벤트가 왜 좋은건데?”라는 질문에 대한 답을 스스로 이해할 수 있었다.
필자가 생각하는 이벤트를 사용함에 있어 주는 장점은 ‘느슨한 결합도 형성’과 ‘핵심 관심사와 부가 관심사의 분리’라고 생각한다. 앞서 보았던 ‘장애 격리’도 느슨한 결합도로 인해 생기는 이점이라 할 수 있다.
// AuthService
private final MemberService memberService;
private final MemberSignUpSnsSender memberSignUpSnsSender;
private final MemberSignUpEmailSender memberSignUpEmailSender;
private final MemberSignUpLogger memberSignUpLogger;
public void signUp(SignUpDto dto) {
// 핵심 로직 (사용자 등록)
Member member = memberService.create(dto.toUserEntity());
// 부가 로직
memberSignUpSnsSender.send(member); // SNS 발송
memberSignUpEmailSender.send(member); // Email 발송
memberSignUpLogger.execute(member); // 로그 기록
// ...
}
회원가입 기능을 예로 들면, 위 코드에서는 핵심 로직(사용자 등록)과 부가 로직이 같은 메서드 내에 위치한 것을 알 수 있다.
우선, 해당 메서드 내 부가 로직이 함께하게 되어 코드의 응집도가 떨어진다는 문제점을 눈으로 확인할 수 있다. 해당 부가 로직들을 AuthService 내에서 실행해야하기 때문에 해당 의존성도 멤버 변수로 선언해야한다.
의존성이 많아 클래스 자체가 비대해지며, AuthService 내 회원가입 메서드에서만 사용되는 부가 로직을 위해 테스트 시에 불필요한 Mock 객체를 다수 스터빙해야한다는 문제도 예상이 된다.
또한, 부가 로직이 추가적으로 증가할 경우 AuthService 내 추가적인 의존성이 필요하며, 테스트 코드 재작성이 필요하다. 부가 로직 중 단 하나의 로직에서 장애가 발생할 경우 핵심 및 모든 부가 로직이 실패하게 되어 장애가 전파된다는 문제점도 존재한다.
// AuthService
private final MemberService memberSerivce;
private final EventPublisher eventPublisher;
public void signUp(SignUpDto dto) {
// 핵심 로직 (사용자 등록)
Member member = memberService.create(dto.toUserEntity());
// 부가 로직 (이벤트 발행으로 통일)
eventPublisher.publish(MemberSignUpEvent.from(member));
}
이벤트를 적용한다면 핵심 비즈니스 로직의 코드가 간결해지며 응집도가 높아진다.
AuthService가 의존하는 객체의 수도 줄게되어 멤버 회원가입과 연관된 부가 기능과의 강결합도를 해소할 수 있게 된다.
회원가입 이후 추가적으로 실행해야할 부가 로직이 추가된 경우에도 AuthService 내 코드는 수정할 필요가 없으며, 이벤트를 소비하는 리스너를 추가 정의하여 사용하기만 하면 된다. 즉, 확장에 용이해진 것이다.
Spring Event의 경우에는 트랜잭션의 범위를 제어할 수 있긴 하지만, 이벤트를 소비하는 부가 로직에서 적절한 예외 처리를 수행하는 등의 방법으로 장애 격리도 가능하다.
Spring Event
이벤트 기반 아키텍처에서는 이벤트 발행자/소비자와 이벤트를 중개하는 이벤트 버스(브로커)가 필요하다.
Kafka, RabbitMQ, AWS SNS/SQS 등 다양한 메시지 브로커들이 이벤트 버스(브로커) 역할을 수행한다. 이와 유사하게 Spring 내에 Spring Event가 존재한다.
앞선 메시지 브로커들은 큐의 상태를 GUI로 확인하거나 명시적으로 큐의 상태를 확인하고 제어할 수 있지만, Spring Event의 경우에는 스프링 내부적으로 애플리케이션 레이어에서 이벤트를 제어하기 때문에 비슷하지만 다른 느낌을 받기도 한다.
그렇다면 Spring Event를 사용하는 이유는 무엇일까?
이는 '느슨한 결합'과 '트랜잭션 범위 제어'를 통한 비관심사를 효율적으로 처리하기 위함에 있다고 생각한다. 즉, 이벤트 기반 아키텍처를 사용하는 이유와 거의 동일한 것이다.
시스템 간이 아닌, 애플리케이션 레이어에서도 주 관심사와 부가 관심사를 분리하여 느슨한 결합의 장점을 가져갈 수 있게 된다.
그리고, Spring Event의 @EventListener를 확장한 @TransactionalEventListener 어노테이션에서는 트랜잭션 범위 제어에 관한 옵션을 설정할 수 있다.
TransactionPhase.BEFORE_COMMITTransactionPhase.AFTER_COMPLETIONTransactionPhase.AFTER_COMMITTransactionPhase.AFTER_ROLLBACK
트랜잭션이 커밋되기 전/후, 트랙잭션 커밋 여부와 상관없이, 롤백되고 난 후 등 다양한 범위의 트랜잭션 제어가 가능하기 때문에 비관심사를 효율적으로 처리할 수 있다.
트랜잭션 아웃박스 패턴의 경우 해당 트랙잭션에서 발생한 이벤트를 기록하는 것이 중요하다. 따라서, 트랜잭션이 커밋되기 이전에 해당 이벤트를 기록하여야 한다. 이후 트랜잭션 커밋이 완료된 시점에 기록된 이벤트를 활용한 추가 로직을 실행하도록하여 트랜잭션 범위에 따른 핵심/부가 관심사의 효율적인 제어가 가능하다.

위 그림은 우아한형제들 기술블로그 ‘회원시스템 이벤트기반 아키텍처 구축하기’ 글에서 소개된 이벤트 기반 아키텍처이다.
배달의 민족 시스템에서도 타 시스템에 이벤트 전달을 위해서는 AWS SNS/SQS를 사용하지만, Application 레이어 내부에서 Spring Event도 사용하는 것을 알 수 있다.

해당 포스팅에서는 Spring Event를 사용하는 이유로 분산-비동기를 다룰 수 있으며 트랙잭션 범위 제어가 가능하다는 점을 활용하여, 비관심사이지만 시스템에서 반드시 해결해야하는 부가 로직을 도메인에 영향 없이 확장과 변경이 용이한 Spring Event을 적용하여 해결하였다는 것을 확인할 수 있었다.
Transactional Outbox Pattern
비밀번호 초기화 메일 발송 기능의 전송을 보장하기 위한 방법을 찾아보던 중 가장 먼저 보았던 키워드는 Transactional Outbox Pattern이다.
트랜잭션 아웃박스 패턴은 이벤트 기반 아키텍처에서 전송을 보장하기 위해 사용되는 방법이다. 따라서, 이번 포스팅에서 이벤트에 관한 내용을 우선 작성하게 되었다. 트랜잭션 아웃박스 패턴에 대한 내용은 차후 포스팅에서 더 자세하게 다루겠지만, 이번 포스팅에서 이벤트 기반 아키텍처에서 트랜잭션 아웃박스 패턴이 언급되는 이유를 간략하게 설명하고자 한다.
이벤트 기반 로직에서 시스템 결합도를 낮추고, 장애 격리의 장점은 얻을 수 있지만 한 가지 문제가 존재한다.
바로 '이벤트 발행은 성공하였지만, 이벤트 전달에는 실패'하는 경우가 발생할 수 있다는 것이다.
이벤트가 발행된 다음, 이벤트 전달(부가 로직)에는 실패할 경우 해당 이벤트는 이미 소비된 이후 상태이기 때문에 부가 로직을 재시도할 수도 없게 된다.
Kafka, RabbitMQ와 같은 메시지브로커에서는 실패한 메시지(Dead Letter)에 대한 재시도 로직을 수행할 수 있기는 하지만, 메시지브로커 자체에서 문제가 발생한다면 메시지브로커에 전달된 이벤트 조차도 유실될 수 있다는 문제점이 존재한다.
따라서, 이벤트를 이벤트 버스로 발행하기에 앞서 이벤트를 데이터베이스에 기록하고 상태를 기록하여 실패한 이벤트를 재발행하여 최소한 한 번은 실행을 보장(ALO)할 수 있게 되는 것이다.
이것이 이벤트 기반 아키텍처에서 트랙잭션 아웃박스 패턴이 언급되고 필요한 이유이다.
다음 포스팅에서는 트랙잭션 아웃박스 패턴을 적용하여 비밀번호 초기화 메일 전송 로직이 전송 보장되도록 리팩토링하는 과정에 대한 내용을 작성할 예정이다.