결제 도메인 정리

김신영·2025년 8월 18일
post-thumbnail

핵심 구현 포인트

1. 원자적 선점 메커니즘

  • tryMarkProcessing()으로 단건 원자적 선점
  • UPDATE 쿼리 결과(1 또는 0)로 선점 성공 판정
  • 다중 워커 환경에서도 중복 발행 방지

2. 트랜잭션 전략

  • 메인 트랜잭션: 비즈니스 로직 + Outbox INSERT
  • REQUIRES_NEW: 상태 전이는 즉시 커밋
  • After Commit: Spring 이벤트로 발행 트리거
  • 비즈니스 예외는 커밋 (noRollbackFor)

3. 스케줄러 운영

  • PENDING: 10초마다 16건씩 처리
  • FAILED: 30초마다 재시도 (최대 3회)
  • PROCESSING: 5분 타임아웃 복구
  • 정리: 30일(PUBLISHED), 90일(DLQ)

4. 지수 백오프

  • 기본 10초 × 2^(retry_count-1)
  • 최대 300초 제한
  • next_retry_at 필드에 계산값 저장

5. 특수 정책

  • REFUND 이벤트는 unroutable이어도 PUBLISHED 처리
  • 환불 후 Payment 레코드 삭제 (재결제 허용)
  • Correlation ID로 이벤트 추적

6. 병렬 처리

  • ThreadPool (core=4, max=8)로 비동기 발행
  • CompletableFuture.runAsync()로 논블로킹
  • 워커별로 독립 처리

1. 전체 아키텍처

Payment 도메인은 Outbox 패턴으로 이벤트를 발행하고, RabbitMQ로 비동기 통신하며, 스케줄러/스레드풀로 재시도 및 정리를 수행합니다.

핵심 포인트

  • PaymentServiceImpl가 비즈니스 처리 + Outbox 저장 -> PaymentEventHandler가 AFTER_COMMIT에 PaymentEventProducer.publishOutboxEvent() 호출

  • Producer는 tryMarkProcessing()로 원자적 선점 후 RabbitMQ 발행, Confirm/Return 콜백으로 최종 상태 마킹

  • Consumer(PaymentEventConsumer)는 참가자 등록/취소/모임 삭제 이벤트를 수신해 결제/환불 메세지를 발행합니다

  • 외부 인프라: RabbitMQ Exchanges/Queues, Toss API(TossPaymentsClient)


2. 결제 성공 전체 플로우

결제 성공 시: PENDING 저장 -> 커밋 후 발행 -> Confirm ACK -> PUBLISHED로 확정.

핵심 포인트

  • PaymentServiceImpl.createTestKeyInPayment()에서 검증 -> Toss Key-in 호출 ->Payment.complete()

  • Outbox에 Wrapper JSON 저장(saveOutboxEvent) 후 커밋

  • 이벤트 핸들러가 Producer 호출 -> tryMarkProcessing()(PROCESSING) -> RabbitMQ 발행 -> Confirm ACK => markEventAsPublished()(PUBLISHED)

성공 흐름 (결제 완료 기준)

[트랜잭션] PaymentServiceImpl
  1) 결제 비즈니스 성공 -> Payment 상태 COMPLETE
  2) 이벤트 JSON을 Outbox에 저장(상태 = PENDING)
커밋됨 -> [AFTER COMMIT] Spring 이벤트 핸들러
  3) Producer.publishOutboxEvent(outboxId)
      3-1) tryMarkProcessing()로 선점(상태 = PROCESSING)
      3-2) JSON 역직렬화 -> RabbitMQ 발행
      3-3) ConfirmCallback ack = true -> markEventAsPublished() (상태 = PUBLISHED)

실패 흐름 (비즈니스 예외)

1) 결제 중 비즈니스 예외 발생 -> Payment.fail()
2) 실패 이벤트를 Outbox에 저장(PENDING)
3) 커밋 후 Producer가 동일한 방식으로 발행
4) Confirm/Return 콜백 결과에 따라 PUBLISHED/FAILED 마킹
5) FAILED면 retryCount++, nextRetryAt = 지수백오프 -> 스케줄러가 때 되면 outboxId만 갖고 producer쪽에 보내줌 -> producer에서는 다시 이벤트 발행
6) retryCount >= 3 -> markAsDeadLettered()

3. 환불 처리 플로우

권한·상태 확인 후 Toss 취소 API 호출 -> payment.refund() -> 환불 이벤트 발행 -> 레코드 삭제.

핵심 포인트

  • refundPayment()에서 UNAUTHORIZED_REFUND, REFUND_NOT_ALLOWED 방어

  • TOSS일 때 cancelPayment() 호출 후 Payment.refund()

  • Outbox에 PAYMENT_REFUNDED 저장 -> 발행(성공/실패 처리 동일)

  • 응답 반환 뒤 결제 레코드 삭제(재결제 허용)

환불 흐름

1) 권한/상태 체크 -> Toss 취소 API -> payment.refund()
2) 환불 이벤트 Outbox 저장
3) 커밋 후 발행(위와 동일)
4) 응답 DTO 반환 후 결제 레코드 삭제(재결제 가능하도록)

등록 이벤트 수신 시 결제 생성·승인 후 ‘결제완료’ 이벤트를 발행합니다. 비즈니스 예외는 ACK

핵심 포인트

  • 큐: PAYMENT_PARTICIPANT_REGISTER(DLX 세팅)

  • handleParticipantRegister()가 타입·필수 필드 검사 -> createTestKeyInPayment() 호출

  • 성공/비즈니스 예외는 직접 ACK, 데이터 오류는 즉시 DLQ, 시스템 오류는 컨테이너 3번 재시도 후 DLQ
    (비즈니스 예외는 consumer측에서는 ack처리 후 service에서 PaymentException을 catch 한 후에 fail이벤트를 날립니다.
    이 fail 이벤트는 meeting이 consume하고 있어 모임 참가시에 currentParticipant++처리한 것에 대해 복구 해줍니다.)

취소 이벤트에서 환불 필요 여부 체크 후 완료 결제가 있으면 환불 처리, 없으면 ACK.

핵심 포인트

  • 큐: PAYMENT_PARTICIPANT_CANCEL

  • refundRequired가 false면 ACK 후 종료

  • 완료 결제 조회(findByMeetingIdAndUserIdAndStatus(COMPLETED)) 후 refundPayment() 호출

  • 예외 정책: 비즈니스 예외 ACK / 데이터 오류 즉시 DLQ / 시스템 오류 재시도 (비즈니스 예외에 대해선 consumer에서는 ack 처리 후 서비스 측에서 paymentexception을 catch해서 환불 실패 기록을 합니다.)

모임 삭제 시 해당 모임의 완료 결제 전체 환불. 일부 실패는 기록 후 나머지 성공을 유지합니다.

핵심 포인트

  • 큐: PAYMENT_MEETING_DELETED

  • findByMeetingIdAndStatus(COMPLETED)로 일괄 조회, 루프 돌며 refundPayment()

  • 부분 실패도 최종 ACK(실패건은 recordRefundFailure로 기록/후처리)


4. 스케쥴러 흐름

PENDING(10초), FAILED 재시도(30초), 정리(매일 03:00), PROCESSING 복구(10분) + 스레드풀 병렬 발행.

핵심 포인트

  • publishPendingMessages()(10s) / retryFailedMessages()(30s, retry_count < 3 & next_retry_at 지남)

  • cleanupOldMessages() : PUBLISHED 30일, DLQ 90일 삭제

  • recoverStuckProcessingEvents() : PROCESSING 5분 이상이면 FAILED 전환(+백오프)

  • 실행 스레드풀: core=4, max=8, queue=64, 종료 시 graceful shutdown으로 유실 방지

스케쥴러에서는 id(payment_outbox.id)만을 pickup하고, 그 id를 PaymentEventProducer.publishOutboxEvent(outboxId)에 넘깁니다Producer가 tryMarkProcessing(outboxId)로 선점합니다. 선점 로직을 producer의 단일 진입점으로 통일해 중복 발행을 차단하고 경쟁 조건과 트랜잭션 경계를 명확하게 할 수 있도록 했습니다.

스케줄러 & Executor (병렬 처리)

  • pickPendingIds(BATCH_SIZE) / pickRetryableFailedIdsID만 뽑음(락/선점 없음).
  • 각 ID를 스레드풀에서 publishOutboxEvent에 전달.
    중복은? -> db 선점 쿼리가 막아줌.
  • 보조 작업:
    • cleanupOldMessages : PUBLISHED 30일 이상, DLQ 90일 이상 삭제
    • recoverStuckProcessingEvents : PROCESSING 상태가 너무 오래되면 FAILED로 바꿔 줌 + 다음 재시각 설정

간단 요약

PENDING 저장 -> (커밋 후) 선점 -> 발행 -> 콜백 반환 후 최종마킹 -> 실패면 백오프 재시도 -> 한계 넘으면 DLQ -> 스케줄러가 남은 일감 계속 producer에 보내 줌


5. outbox 상태전이 정리

PENDING -> PROCESSING(선점) -> PUBLISHED/FAILED -> (3회 초과) DEAD_LETTERED. 실패 시 지수 백오프.

핵심 포인트

  • 원자적 선점 쿼리 tryMarkProcessing()(JPA @Modifying)로 동시 중복 방지

  • markAsFailed()가 retry_count++ 및 next_retry_at(10s×2^(n-1), 최대 300s) 설정

  • Confirm ACK ⇒ markAsPublished() / NACK·예외 ⇒ markAsFailed()

  • next_retry_at가 스케줄러의 재시도 스캔 범위를 최소화


6. paymentoutbox 핵심 스키마 & 인덱스

핵심 컬럼: status, retry_count, next_retry_at, correlation_id(UNIQUE) / 핵심 인덱스 3종으로 스캔 최적화.

핵심 포인트

  • 인덱스

idx_status_next_retry(status, next_retry_at) : 재시도 스캔 최적화

idx_status_created(status, created_at) : PENDING 픽업

idx_published_at(status, published_at) : 정리 작업

  • 픽업 쿼리:

PENDING: 오래된 순 LIMIT 16

FAILED 재시도: retry_count < 3 AND next_retry_at <= NOW()

  • payload는 Wrapper(JSON) 저장(이벤트 타입/라우팅키 분리)

콜백/선점 정확성은 Payment_outbox_Id, 흐름 추적은 wrapper uuid,
운영/비즈니스 축 조회와 재처리는 aggregateId


7. 트랜잭션 전략

도메인 트랜잭션은 비즈니스 + Outbox 저장. 발행·최종마킹은 REQUIRES_NEW로 분리해 일관성과 격리를 확보.

핵심 포인트

  • 메인: PaymentServiceImpl의 트랜잭션(@Transactional)에서 Payment 상태 변경 + Outbox INSERT, 커밋 후 Spring Event 발행

  • 선점/마킹: PaymentOutboxServiceImpl의 markEventAsPublished/Failed/tryMarkProcessing()가 REQUIRES_NEW

  • 비즈니스 예외(PaymentException)는 noRollbackFor로 커밋되어 실패 이벤트를 정상 발행

  • 결과적으로 DB 커밋 <-> 메시지 발행을 분리하면서도 Outbox로 최종적 일관성 보장


8. 레빗엠큐 설정


설정 요약 — TossClient & Rabbit

핵심 포인트

  • TossPaymentsClient: 각 호출에 Idempotency-Key 적용

  • PaymentRabbitConfig: Publisher Confirm(CORRELATED) + Returns + RetryTemplate

  • PaymentEventProducer: Confirm/Return 콜백에서 published/failed 최종 마킹, correlationData.id = outboxId로 추적 단순화


9. 모임 참가 기준 최종 구현 및 흐름

1. 참가 신청 이벤트 도착
   Meeting 도메인 -> "사용자 123이 모임 456 참가" -> Payment 도메인

2. PaymentEventConsumer가 받음
   - 이벤트 타입 확인 (PARTICIPANT_REGISTER)
   - 필수 데이터 체크 (userId, meetingId)
   - PaymentService.createTestKeyInPayment() 호출

3. PaymentService 처리
   - Payment 엔티티 생성 (status=PENDING)
   - 토스 API 호출해서 결제 처리 
   - 성공하면 Payment를 COMPLETED로 변경
   - PaymentOutbox에 "결제완료" 이벤트 저장 (status=PENDING)
   - 트랜잭션 커밋

4. 즉시 발송 시도 (EventHandler)
   - 트랜잭션 커밋 직후 Spring Event 발생
   - PaymentEventProducer.publishOutboxEvent() 호출

5. Producer의 발송 프로세스
   a. tryMarkProcessing() - "내가 이거 처리할게" 선점
   b. RabbitMQ로 메시지 전송
   c. Confirm/Returns 비동기 콜백에서 최종 상태 마킹

6-A. 성공 케이스
   - RabbitMQ: "받았음 ACK"
   - Confirm 콜백: markAsPublished()
   - Outbox status -> PUBLISHED

6-B. 실패 케이스
   - RabbitMQ: "못받았음 NACK" 또는 네트워크 오류
   - Confirm 콜백: markAsFailed()
   - Outbox status -> FAILED
   - next_retry_at = 10초 후로 설정(지수 백오프)

7. 스케줄러가 재시도
   - 30초마다 FAILED 이벤트 스캔
   - next_retry_at이 지난 것만 선택
   - 다시 발송 시도 (최대 3번)

8. 3번 실패시
   - status -> DEAD_LETTERED (DLQ)
   - 수동 처리 필요

0개의 댓글