운영 중인 서비스에 이벤트 기반 아키텍처 도입하기 - (1) 설계 및 아이디어

리리·2025년 8월 26일
0
post-thumbnail

들어가며

프리비는 촬영 예약 및 고객 관리 통합 플랫폼 서비스이다. 현재는 촬영 예약을 희망하는 고객이 촬영 예약/취소를 하면 서버에서 해당 요청을 처리한 뒤, 곧바로 카카오 알림톡을 발송해 사진작가에게 실시간으로 알림을 주고 있다.

하지만 현재 구조는 API 요청 처리와 알림톡 발송이 하나의 동기적 흐름에 묶여 있다는 한계가 있다. 예를 들어 알림톡 API가 순간적으로 지연되거나 실패하면, 그 영향이 고스란히 사용자 경험으로 이어진다. 예약은 정상적으로 되었음에도 응답이 늦어지거나 에러가 나는 경험을 사용자에게 주게 되는 것이다.

이에 대한 고민을 하던 중, 최근 우아한형제들 기술블로그 「회원시스템 이벤트 기반 아키텍처 구축하기」 글을 읽으며 여러가지 인사이트를 얻었다. 특히, 서비스 간 강결합을 끊는 법과 도메인 이벤트 자체를 발행하는 방식 등에서 생각해 볼 점이 많았다.

이번 글에서는 얻은 아이디어를 바탕으로, 프리비 프로젝트에 적용하고자 하는 설계 방향을 정리해 본다.


EDA(Event-Driven Architecture)를 고려하는 이유

1. 강결합 문제 해소
현재 구조는 예약 처리 로직알림톡 발송을 직접 호출하는 구조다. 이는 비즈니스 로직과 외부 API 호출이 강하게 연결되어 있음을 의미한다.

이벤트 기반 구조에서는 예약 처리 서비스는 단지 예약이 발생함/취소됨이라는 사건만 발행한다. 그 사건을 받아 알림톡을 보낼지 말지를 판단하는 건 알림 서비스의 책임이다.

이처럼 각 서비스의 역할과 책임을 분리하는 구조에서는 예약과 알림은 느슨한 결합을 갖게 된다.


2. 안정성 확보
현재 구조에서는 알림톡 API가 지연되거나 실패하면, 그 영향이 예약 API의 응답 지연이나 실패로 이어져 사용자에게 좋지 않은 경험을 주게 된다.

반면, 이벤트 기반 구조에서는 알림톡 전송과 같은 부가적인 작업을 메인 로직과 분리하여 비동기적으로 처리할 수 있다. 따라서 알림톡 시스템에 장애가 발생하더라도, 예약과 같은 핵심 기능은 빠르게 응답할 수 있어 사용자에게 항상 안정적인 서비스를 제공할 수 있게 된다.


3. 확장성

한 번 발행된 이벤트는 알림톡 외에도 여러 곳에서 소비할 수 있다.

예를 들어 reservation.created 이벤트는 알림 서비스에서는 “예약 확정 알림”을 보낼 수 있고, 통계 서비스에서는 “일별 예약 건수 증가”를 계산할 수 있다.

즉, 이벤트는 한 번만 기록하면 다양한 기능에서 재사용할 수 있는 자원이 된다.



참고한 아키텍처 아이디어 (우아한형제들 글에서)

우아한형제들 기술블로그에서는 이벤트를 크게 3단계 레이어로 나눠 설명한다.
이를 우리 서비스에 맞게 재구성하면 다음과 같다.

1. 도메인 이벤트 레이어

  • 예약/취소 트랜잭션 내부에서 ReservationCreated, ReservationCancelled 같은 이벤트를 발행
  • 이벤트는 사건 그 자체만 표현한다. “알림톡 발송” 같은 목적을 직접 담지 않는다.

2. 아웃박스(Outbox) 레이어

  • 같은 트랜잭션에서 Outbox 테이블에 이벤트를 함께 기록한다.
  • 이로써 예약은 성공했는데 이벤트는 남지 않는 dual-write를 방지한다.

3. 소비자(Consumer) 레이어

  • 스케줄러/워커가 Outbox에서 READY 상태의 이벤트를 읽어 처리한다.
  • 하나의 이벤트에는 다수의 소비자가 붙을 수 있다.

이를 적용한 구조를 시퀀스 다이어그램으로 표현해 봤다.


DB 아웃박스와 스케줄러 도입 배경

Kafka, RabbitMQ, SQS 같은 메시지 브로커를 쓰면 더 강력한 이벤트 스트리밍 환경을 만들 수 있다. 하지만 우리 프로젝트는 추가 인프라나 비용 없이 구축하는 것이 필요했다.
그래서 선택한 방법이 DB Outbox + 스케줄러 워커 패턴이다.

Outbox:
Outbox는 트랜잭션의 원자성을 활용해서 데이터베이스의 상태 변경이벤트 발행을 하나의 논리적 단위로 묶고자 하는 전략이다. 이는 예약이 정상적으로 발생/취소 되었음에도 이벤트 기록이 누락되는 문제를 방지할 수 있다.

이때 Outbox 테이블에 단순히 이벤트 메시지만 저장하지 않고, 이벤트의 상태를 관리하는 필드를 포함시키면 스케줄러 패턴과 융합해서 이벤트 처리 실패(ex: 네트워크 오류) 시에도 대응할 수 있다.

  • status (READY/SENT/FAILED):

    • 이벤트의 현재 상태를 나타낸다.
    • 처음 생성 시 READY, 처리가 완료되면 SENT, 최종적으로 실패하면 FAILED
  • retry_count:

    • 재시도 횟수를 기록하여 무한 재시도를 방지하고 실패 로직을 관리한다.
  • next_attempt_at:

    • 다음 재시도를 수행할 시점을 지정하여 지수 백오프(exponential backoff)와 같은 재시도 전략을 적용하는 데 사용한다.

스케줄러 워커:
스케줄러 워커는 Outbox 테이블에 기록된 이벤트를 읽어 후속 작업을 처리하는 역할을 담당한다.

  • 주기적인 폴링(Polling):

    • 스케줄러는 일정 주기(예: 1분)마다 Outbox 테이블에서 status가 READY인 이벤트를 가져온다.
    • 이때, 여러 워커가 동시에 처리할 경우를 대비해 낙관적 잠금(optimistic locking) 등의 기술을 사용하여 같은 이벤트를 중복 처리하지 않도록 방지할 수 있다.
  • 비동기적 처리:

    • 가져온 이벤트를 기반으로 알림톡 발송과 같은 외부 API 호출을 수행한다.
    • 이 과정은 메인 API의 요청-응답 흐름과 완전히 분리되어 비동기적으로 진행된다.
  • 성공/실패 처리:

    • 성공 시: 알림톡 발송이 성공하면, 해당 이벤트의 status를 SENT로 업데이트한다.
    • 실패 시: 네트워크 지연이나 API 오류로 발송에 실패하면, retry_count를 1 증가시키고 next_attempt_at 필드를 갱신하여 지수 백오프 전략을 적용한다. 이벤트는 다시 READY 상태로 두어 다음 주기 때 재시도를 유도한다. 만약 재시도 횟수가 특정 임계값(예: 5회)을 초과하면 status를 FAILED로 변경하여 별도 로그 분석 및 수동 처리가 가능하도록 한다.

왜 단순 이벤트 리스너로 끝내지 않았는가?

아웃박스 레이어의 필요성에 대해 설명해보고자 한다.

@TransactionalEventListener + @Async + @Retryable 등으로 리스너에서 바로 알림톡을 호출할 경우, 운영·내구성 관점에서 아래와 같은 한계가 남는다.

1. 트랜잭션과 내구성

AFTER_COMMIT으로 트랜잭션 오염은 피할 수 있지만, 커밋 직후 서버가 다운되는 등의 장애 상황에서는 이벤트가 유실될 수 있다.
Outbox는 비즈니스 변경과 이벤트 기록을 같은 트랜잭션에 남겨 유실을 방지한다.

2. 재시도·백오프의 지속성

리스너의 @Retryable은 프로세스 메모리 안에서 즉시 재시도에 가깝다. 배포 혹은 서버 재시작 시에는 상태가 사라지기 때문에 상태 추적이 어렵다는 한계가 있다.
Outbox는 retry_count/next_attempt_at/status 등을 DB에 레코드로 남기므로, 스케줄러와 함께 사용 시 지속적이고 가시적인 재시도를 제공한다.

결론적으로, 리스너는 이벤트를 기록하도록 트리거하는 곳까지만 맡기고, 실제 외부 호출은 Outbox 워커가 담당하도록 분리하는 편이 더 안전하고 운영 친화적이라 판단했다.


마무리

이번 글에서는 우아한형제들의 사례를 참고해, 프리비에 이벤트 기반 아키텍처를 적용하기 위한 설계 아이디어를 정리했다.

핵심은 “도메인 로직과 외부 작업을 분리하고, 이벤트 자체를 기록한다”는 점이다.
후속편에서는 실제로 이 설계를 코드로 옮기면서, 어떤 어려움이 있었고 어떤 선택을 했는지 등을 공유해 볼 예정이다.

0개의 댓글