모임 참가로직을 설계할 때, 결제 완료 시 모임 참가에 승인한다는 규칙을 정했습니다.
이에 프로젝트 초기에는 서비스 호출을 계획했습니다.
모임 참가 로직 안에서 결제 서비스 메서드를 호출하고,
내부적으로 사용한 toss api 자체는 webclient blocking 방식으로 처리하도록 했습니다.
다만, 초기에 부하 테스트를 할 때 이런 문제가 발생했습니다.
모임 테이블의 참가 인원수에만 행락을 짧게 걸어 락 자체가 길어지지는 않았지만, 결제 승인 대기로 인한 db 커넥션 점유 시간이 과다해졌고 결과적으로 커넥션 풀의 고갈로 응답이 지연됐습니다.
또한 toss api 장애 시 전체 모임 참가 기능이 마비 될 가능성이 있었습니다.
이런 문제점으로 인해 이벤트 기반 설계로 전환하는 것을 결정했습니다.
저희 팀은 이벤트 기반 설계로 전환하는 과정에 메세지 큐를 프로젝트 전체에서 사용하기로 하였습니다. 이 과정에서의 발생할 수 있는 문제점은 이렇습니다.
이에 아웃박스 패턴을 도입하기로 결정했습니다.
결제 서비스 안에서 결제 완료/실패/환불 로직을 수행
같은 트랜잭션으로 payment_outbox 레코드 (status = PENDING) 저장.
일정 주기(5~10 초)로 돌아가는 PaymentOutboxPublisher
PENDING 혹은 재시도 가능한 FAILED 행을 읽음.
RabbitTemplate.convertAndSend(… correlationData) 로 발행
브로커의 ACK/NACK 결과에 따라
markAsPublished() -> status=PUBLISHED
markAsFailed() -> status=FAILED, retryCount++
retryCount ≥ 3 -> markAsDeadLettered() (DLQ 테이블 개념)
이 구현은 예외가 발생 했을 시에만 아웃박스에 저장하는 fall back 방식이었습니다.
여기서는 3단계 재시도 전략을 사용했습니다
즉시 발행 (TransactionalEventListener)
트랜잭션 커밋 직후 시도
Publisher Confirm 3초 타임아웃
스케줄러 재시도 (10초 주기)
PENDING 상태 메시지 발행
FAILED 메시지 중 retryCount < 3 재시도
각 실패마다 retryCount++
DLQ 처리
retryCount >= 3이면 DEAD_LETTERED 상태로 변경
별도 관리자 개입 필요
트랜잭션 커밋 직후 즉시 발행을 시도하되, 실패 시 스케줄러가 재시도하는 방식을 채택했습니다.
핵심 구현 포인트:
@TransactionalEventListener(AFTER_COMMIT)로 커밋 직후 발행 시도tryMarkProcessing()으로 중복 발행 방지1. 결제 서비스 처리 (PaymentServiceImpl)
- Payment 엔티티 생성/업데이트
- PaymentOutbox 레코드 저장 (status=PENDING)
- Spring 도메인 이벤트 발행 (PaymentEvents.Completed)
- 모두 같은 트랜잭션으로 원자적 처리
2. 즉시 발행 시도 (PaymentEventHandler)
- @TransactionalEventListener(AFTER_COMMIT)
- PaymentEventProducer.publishOutboxEvent() 호출
3. Producer 발행 프로세스 (PaymentEventProducer)
a. tryMarkProcessing() - 원자적 UPDATE로 PENDING→PROCESSING 전환
(다른 인스턴스가 이미 선점했으면 중단)
b. Outbox 조회 및 EventWrapper 역직렬화
c. RabbitTemplate.convertAndSend() with CorrelationData
d. 비동기 Confirm/Return 콜백 대기
4. Publisher Confirm 콜백 처리
- ACK: markAsPublished() → status=PUBLISHED
- NACK/Return: markAsFailed() → status=FAILED, next_retry_at 설정
- 타임아웃: 스케줄러가 PROCESSING 상태 복구
5. 스케줄러 백업 처리 (PaymentOutboxScheduler)
- PENDING 처리: 10초마다 실행
- FAILED 재시도: 30초마다, next_retry_at 체크, 최대 3회
- PROCESSING 복구: 10분마다, 5분 이상 방치된 건 FAILED로 전환
- 각 메시지는 스레드풀에서 병렬 처리
6. 최종 상태
- 성공: PUBLISHED (30일 후 자동 삭제)
- 실패: DEAD_LETTERED (3회 초과, 90일 후 삭제)
-- 원자적 선점 쿼리 (tryMarkProcessing)
UPDATE payment_outbox
SET status='PROCESSING', updated_at=NOW()
WHERE id=? AND status IN ('PENDING','FAILED')
AND (status='PENDING' OR next_retry_at <= NOW())
이 쿼리가 affected rows = 1을 반환할 때만 발행을 진행하여, 여러 인스턴스가 동시에 같은 메시지를 처리하는 것을 방지합니다.
이 하이브리드 방식으로 평상시에는 즉시 발행의 낮은 지연시간을 유지하면서, 장애 상황에서는 스케줄러를 통한 안정적인 재시도를 보장합니다.
graceful shutdown 적용
또한 스케쥴러에서 사용하는 아웃 박스 실행 스레드 풀에서
setWaitForTasksToCompleteOnShutdown(true);
을 설정해 주어 종료 시 graceful shutdown을 적용 해 배포 중 메세지 유실을 막도록 했습니다.
PROCESSING 타임아웃 복구
// 10분마다 체크
// 5분 이상 PROCESSING 상태면 -> FAILED로 변경
// 서버 다운시 자동 복구