
tryMarkProcessing()으로 단건 원자적 선점
Payment 도메인은 Outbox 패턴으로 이벤트를 발행하고, RabbitMQ로 비동기 통신하며, 스케줄러/스레드풀로 재시도 및 정리를 수행합니다.
PaymentServiceImpl가 비즈니스 처리 + Outbox 저장 -> PaymentEventHandler가 AFTER_COMMIT에 PaymentEventProducer.publishOutboxEvent() 호출
Producer는 tryMarkProcessing()로 원자적 선점 후 RabbitMQ 발행, Confirm/Return 콜백으로 최종 상태 마킹
Consumer(PaymentEventConsumer)는 참가자 등록/취소/모임 삭제 이벤트를 수신해 결제/환불 메세지를 발행합니다
외부 인프라: RabbitMQ Exchanges/Queues, Toss API(TossPaymentsClient)

결제 성공 시: 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()

권한·상태 확인 후 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로 기록/후처리)

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의 단일 진입점으로 통일해 중복 발행을 차단하고 경쟁 조건과 트랜잭션 경계를 명확하게 할 수 있도록 했습니다.
pickPendingIds(BATCH_SIZE) / pickRetryableFailedIds로 ID만 뽑음(락/선점 없음).publishOutboxEvent에 전달.cleanupOldMessages : PUBLISHED 30일 이상, DLQ 90일 이상 삭제recoverStuckProcessingEvents : PROCESSING 상태가 너무 오래되면 FAILED로 바꿔 줌 + 다음 재시각 설정PENDING 저장 -> (커밋 후) 선점 -> 발행 -> 콜백 반환 후 최종마킹 -> 실패면 백오프 재시도 -> 한계 넘으면 DLQ -> 스케줄러가 남은 일감 계속 producer에 보내 줌

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가 스케줄러의 재시도 스캔 범위를 최소화

핵심 컬럼: 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()
콜백/선점 정확성은 Payment_outbox_Id, 흐름 추적은 wrapper uuid,
운영/비즈니스 축 조회와 재처리는 aggregateId

도메인 트랜잭션은 비즈니스 + Outbox 저장. 발행·최종마킹은 REQUIRES_NEW로 분리해 일관성과 격리를 확보.
핵심 포인트
메인: PaymentServiceImpl의 트랜잭션(@Transactional)에서 Payment 상태 변경 + Outbox INSERT, 커밋 후 Spring Event 발행
선점/마킹: PaymentOutboxServiceImpl의 markEventAsPublished/Failed/tryMarkProcessing()가 REQUIRES_NEW
비즈니스 예외(PaymentException)는 noRollbackFor로 커밋되어 실패 이벤트를 정상 발행
결과적으로 DB 커밋 <-> 메시지 발행을 분리하면서도 Outbox로 최종적 일관성 보장


설정 요약 — TossClient & Rabbit
핵심 포인트
TossPaymentsClient: 각 호출에 Idempotency-Key 적용
PaymentRabbitConfig: Publisher Confirm(CORRELATED) + Returns + RetryTemplate
PaymentEventProducer: Confirm/Return 콜백에서 published/failed 최종 마킹, correlationData.id = outboxId로 추적 단순화
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)
- 수동 처리 필요