[Pinit] 최종 일관성 구현 - 회원가입 플로우, 이대로 괜찮은가?
문제 상황
현재 auth 서비스와 task 서비스는 이벤트를 통해 상호작용을 조율하고 있다.
그런데, 사용자가 회원가입 후 로그인을 한 뒤 task서비스를 이용하려는데, task에 회원 정보가 존재하지 않다면 어떻게 될까?
로그인 성공 - task에 이벤트가 도착한 후
- task 서버에서 사용자 정보를 정상 처리
- 정상 플로우
로그인 성공 - task에 이벤트가 도착하기 전
- task 서버에서 auth 서버에서 처리한 사용자 정보를 아직 못받음
- 일정을 생성할 수 없음(예외 발생)
- 사용자의 지역 정보를 받아와야 하는데, member record 자체가 없음
- 별명도 존재하지 않음
문제를 깔끔하게 정리해보자.
- 현재 이 문제는 task, auth 서버 간의 문제다.
- 사용자 경험(UX) 기준에서, 별명 정보는 필수가 아니다.
- 진짜 문제는 일정 생성이다.
선택지 1: task에서 ‘자동 프로비저닝’(Read-through / Lazy create)
- task가 일정 조회 요청을 받았는데 member가 없으면
- auth(또는 user profile 서비스)로부터 최소정보를 조회하여 즉시 member를 생성 후 진행
- 장점: UX/핵심 기능 보호, 이벤트 누락에도 복원 가능
- 단점: task가 auth에 의존(런타임 결합)
선택지 2: 최종 일관성 유지 + 명확한 “준비중” 응답과 재시도
- user가 없으면 404/500 대신 503 Service unavailable / 도메인 에러코드(USER_NOT_READY) 같은 형태로 반환
- 클라이언트 또는 BFF가 짧은 재시도(backoff) 후 재호출
- 장점: 의존성 최소화, 상태가 명시적
- 단점: UX에 재시도 로직 필요, 최종 일관성이 반드시 보장되어야 한다는 전제 필요
- 트랜잭션 아웃박스 패턴 설계 필요
여기서 나는 두 가지 관점을 세웠다.
- gRPC를 이용한 추가적 런타임 의존성 결합보단, 최종 일관성 유지 + 명확한 준비중 응답이 더 나아 보인다.
- 해당 예외는 실제로 자주 발생하지는 않을 예외이다. 이벤트 처리가 그렇게까지 느려진다면, 계산복잡도적 문제가 아닌 인프라 자체가 지나치게 안좋은 문제에 가깝다.
따라서 다음과 같이 트랜잭션 아웃박스 패턴 도입을 시도한다.
- DB에 해당 이벤트 저장
- After_commit 후 비동기 트랜잭션 새로 열어서 해당 이벤트 발행 후 삭제
- 이때, AFTER_COMMIT 이기 때문에 새 트랜잭션은 커밋된 해당 이벤트를 볼 수 있다.(READ_COMMITTED 격리 수준)
- 이벤트 발행 성공 후 트랜잭션이 롤백되면, 한번 더 발행하면 된다. (수신 측의 중복 처리는 idempotent 하게 설계하면 가능하다)
- 지수적 백오프 재시도
- task 서버는 이벤트 수신 후 사용자 정보 업데이트
로직 변경 방안
현재 코드에서는 도메인 이벤트 퍼블리셔로 RabbitDomainEventPublisher가 사용되고 있었다.

실제 이벤트 발행은 다음과 같았다.

현재 로직
- 회원가입 트랜잭션 커밋
- 이벤트 발행
- AFTER_COMMIT 후 비동기 트랜잭션에서 이벤트 발행
이제 이를 위에 설계한 형식대로 변경해야 한다.
변경할 로직
- 회원가입 트랜잭션 내에서 도메인 이벤트 기록 (즉, 더이상 AFTER_COMMIT이 아님, 비동기적으로 동작하지도 않음)
- 이벤트 기록 후, 이벤트 발행을 시도하는 비동기 AFTER_COMMIT 메소드 호출 -> 애플리케이션 이벤트 발행.
- 해당 애플리케이션 이벤트 리스너에서 발행해야 하는 이벤트의 ID를 받아서, 도메인 이벤트 발행 시도
기본 Event Publisher 변경
- 도메인 이벤트 퍼블리셔의 변경

도메인 이벤트 퍼블리셔를 이제 스프링 애플리케이션 이벤트 퍼블리셔를 사용하도록 변경했다.
- 이벤트 메시지를 저장하는 리포지토리 추가.
이 이벤트를 기록하는 리포지토리는 DB 테이블 Outbox 를 사용했다.

해당 Outbox 테이블은 다형적 쿼리를 지원하기 위해 JPA의 Single Table 전략을 사용했다.
이렇게 하면, 모든 Payload 타입이 이 outbox 테이블을 상속받음으로써, 단일 테이블에 저장된다.

이제 이 본문을 저장할 repository를 추가했다.

도메인 이벤트를 애플리케이션 이벤트(Outbox)로 변환
다음과 같이 Outbox 테이블을 관리할 서비스를 추가했다.

이때의 Outbox 객체는 싱글 테이블 전략을 사용하기 때문에
- JPA 레벨에서 실제 객체 타입을 정해주고,
- 다형성을 지원한다.
이제 도메인 이벤트를 애플리케이션 이벤트로 변환하는 퍼블리셔를 작성했다.

- @EventListener 어노테이션을 사용하여, 도메인 이벤트가 발생했을 때 Outbox 레코드를 생성한다.
- 이때, 트랜잭션 컨텍스트 내에서 동작하기 위해 @Async 와 @TransactionalEventListener 없이 사용되었다.
- 도메인 이벤트가 발생한 트랜잭션이 커밋되기 전까지는 Outbox 레코드가 생성되지 않는다.
그리고 outboxService.save()는 해당 Outbox 레코드를 저장한 뒤, RabbitMQ 발행을 시도하는 비동기 AFTER_COMMIT 이벤트를 발행한다.

지수적 백오프의 사용
스프링 프레임워크 7부터는 @Retryable이 기본 기능으로 제공된다.

@EnableResilienceMethods 어노테이션을 설정하면, @Retryable을 사용할 수 있다.

그리하여 다음과 같이 AFTER_COMMIT 메소드에서 아웃박스에 저장되어있는 이벤트 발행을 트리거하고,


다음과 같이 @Retryable 어노테이션을 사용하여, 지수적 백오프 재시도를 구현했다.

멱등성의 경우, task에서 해당 이벤트를 받아 수행하는 작업 자체가 멱등하기 때문에 따로 멱등 키를 설정해주지 않았다.