
PaymentOutboxScheduler
역할
핵심 상수/주기
BATCH_SIZE=16 : 한 사이클 발행 건수MAX_RETRY_COUNT=3 : FAILED 재시도 한계@Scheduled(fixedDelay=10s) publishPendingMessages()@Scheduled(fixedDelay=30s) retryFailedMessages()@Scheduled(cron="0 0 3 * * *") cleanupOldMessages()@Scheduled(fixedDelay=600s) recoverStuckProcessingEvents()핵심 메서드
publishPendingMessages() : outboxService.pickPendingIds(limit)으로 PENDING ID 목록 조회 -> dispatchIdsAsync("PENDING", ids)retryFailedMessages() : pickRetryableFailedIds(maxRetry, limit) -> dispatchIdsAsync("FAILED", ids)cleanupOldMessages() : 30일 지난 PUBLISHED, 90일 지난 DLQ 정리recoverStuckProcessingEvents() : PROCESSING 상태가 오래 방치된 건을 FAILED로 전환dispatchIdsAsync(tag, ids) : 각 ID를 CompletableFuture.runAsync로 병렬 발행safePublish(tag, outboxId) : 예외 방어 로깅 후 eventProducer.publishOutboxEvent(outboxId) 호출PaymentOutboxServiceImpl
역할
트랜잭션
markEventAsPublished/Failed/tryMarkProcessing : REQUIRES_NEW 로 짧게 커밋pickPendingIds/pickRetryableFailedIds : readOnly = truecleanupOldMessages/recoverStuckProcessingEvents : 기본 트랜잭션핵심 메서드
markEventAsPublished(outboxId) : 엔티티 markAsPublished() 호출markEventAsFailed(outboxId, reason) : markAsFailed() 후 retryCount >= MAX 이면 markAsDeadLettered()tryMarkProcessing(outboxId) : 레포 tryMarkProcessing() 결과가 1이면 선점 성공pickPendingIds(limit) / pickRetryableFailedIds(maxRetry, limit)cleanupOldMessages(now) : 30일 이상 PUBLISHED, 90일 이상 DLQ 삭제recoverStuckProcessingEvents(minutes) : PROCESSING 타임아웃 복구PaymentServiceImpl
역할
트랜잭션/예외
@TransactionalcreateTestKeyInPayment(), refundPayment() : noRollbackFor = PaymentException.class (비즈니스 예외는 커밋)주요 공개 메서드
createTestKeyInPayment(request, userId) / 오버로드(correlationUuid)
validateTestKey() (토스 테스트 키)getMeetingViaClient, getUserViaClientreopenPending(), 없으면 createPending()payment.complete()saveOutboxEvent(..., "PAYMENT_COMPLETED",... )PaymentEvents.Completed()refundPayment(paymentId, userId, request) / 오버로드(correlationUuid)
payment.refund()"PAYMENT_REFUNDED" -> Spring 이벤트 Refunded()조회
getMyPayments(userId, status, pageable)getPgPayment(paymentId, userId) : 본인/키 존재/PG 조회핵심 내부 메서드
saveOutboxEvent(payment, eventType, routingKey, [refundReason], correlationUuid)
EventWrapper.of() JSON 직렬화, PaymentOutbox.create() 저장, outboxId 반환saveFailedOutboxEvent(paymentId, meetingId, userId, reason, correlationUuid)
aggregateId 안전 구성(없을 수 있습니다)유틸: validateTestKey, getMeetingViaClient, getUserViaClient, buildKeyInRequest, recordRefundFailure
PaymentOutbox (엔티티)
역할
주요 필드
status(PENDING/PROCESSING/PUBLISHED/FAILED/DEAD_LETTERED), published, publishedAtretryCount, nextRetryAteventType, aggregateId, routingKey, payload, correlationId(UUID)failureReason도메인 메서드
create(eventType, aggregateId, routingKey, payload) : 초기값(PENDING 등) 세팅markAsPublished() : 최종 성공 마킹markAsFailed(reason) : retryCount++, nextRetryAt = 10s * 2^(n-1) (최대 300s)markAsDeadLettered() : DLQ 마킹실패 콜백 경로(markAsFailed)는 retryCount를 1 올리고 10 × 2^(retryCount-1)을 써서 딜레이를 잡습니다.
PROCESSING 타임아웃 복구 경로(recoverStuckProcessingEvents)는 retryCount를 안 올리고 바로 10 × 2^(retryCount)을 씁니다.
계산식은 달라 보이지만 실제로는 10 -> 20 -> 40 으로 같은 시퀀스가 됩니다. (즉, 실패 직전 카운트 기준으로 같게 맞추어 줬습니다.)
Payment (엔티티)역할
주요 필드/제약
@UniqueConstraint(meeting_id, user_id) : 동일 사용자-모임 1건 제한@Version : 낙관적 락paidAt, failedAt, refundedAt도메인 메서드
createPending(userId, meetingId, amount) : 초기 결제 생성complete(pgTransactionId, orderId, paidAt) : PENDING -> COMPLETEDfail(reason) : (COMPLETED/REFUNDED 불가) 실패 마킹refund() : COMPLETED -> REFUNDEDreopenPending() : FAILED -> PENDING 복귀PaymentEventProducer
역할
초기화
@PostConstruct setupCallbacks() :
발행
publishOutboxEvent(outboxId)
outboxService.tryMarkProcessing(outboxId) : 원자적 선점(1/0)
Outbox 조회 -> EventWrapper<?> 역직렬화
RabbitTemplate.convertAndSend()
CorrelationData.id = outboxIdmessageId=outboxId, headers(x-outbox-id, x-correlation-id)콜백에서 markEventAsPublished/Failed
라우팅 실패 처리
handleRoutingFailure(outboxId, returned) :
RoutingKeys.PAYMENT_REFUNDED_KEY -> 정책상 PublishedPaymentRabbitConfig

역할
Bean
paymentConnectionFactory(base) :
paymentRabbitTemplate(connectionFactory, messageConverter) :
mandatory = true, 네트워크/채널 오류용 RetryTemplate(0.5s -> ×2 -> 10s)paymentListenerContainerFactory() :
defaultRequeueRejected=falsemaxAttempts=3, backoff(1s->2s->4s), Recoverer=RejectAndDontRequeuePaymentJpaRepository역할
주요 메서드
findByMeetingIdAndUserIdAndStatus()findByMeetingIdAndUserId()findByMeetingIdAndStatus()searchMyPayments(userId, status, pageable) : 상태 필터 옵션PaymentOutboxJpaRepository역할
주요 메서드/쿼리
pickPendingIds(limit) : PENDING -> created_at ASC + LIMIT
pickRetryableFailedIds(maxRetry, limit) : FAILED & retry_count < :maxRetry & next_retry_at <= NOW()
tryMarkProcessing(id, now) (@Modifying JPQL) :
UPDATE PaymentOutbox o
SET o.status='PROCESSING', o.updatedAt=:now
WHERE o.id=:id
AND o.status IN ('PENDING','FAILED')
AND (o.status='PENDING' OR o.nextRetryAt <= :now)
-> 결과 1/0으로 원자적 선점 판정
deletePublishedBefore(threshold) / deleteDlqMessagesBefore(threshold)
recoverStuckProcessingEvents(minutes) (네이티브) :
next_retry_at = NOW() + 10*2^retry_count secPROCESSING 복구 딜레이 계산
복구 쿼리에서 retryCount를 증가시키지 않고 next_retry_at = NOW() + 10 × 2^(retryCount) sec 로 딜레이를 계산합니다.
위에서 설명한 실패 콜백(markAsFailed)과 함께 보았을 때 최종 backoff 시퀀스(10->20->40) 는 동일하게 유지됩니다.
PaymentController역할
엔드포인트
POST /payments/test/keyin : 테스트 키인 결제GET /payments/me : 내 결제 목록(옵션 status, pageable)GET /payments/{paymentId}/pg : PG 단건 조회POST /payments/{paymentId}/refund : 환불OutboxExecutorConfig역할
설정
outbox-@Qualifier("outboxPublisherExecutor") 로 스케줄러에서 주입받아 사용graceful shutdown 적용
setWaitForTasksToCompleteOnShutdown(true);
을 설정해 주어 종료 시 graceful shutdown을 적용 해 배포 중 메세지 유실을 막도록 했습니다.
payment_outbox DDL주요 컬럼
status, retry_count, next_retry_at, failure_reason, published, published_atevent_type, aggregate_id, routing_key, payload, correlation_id(UNIQUE)인덱스
idx_status_next_retry (status, next_retry_at) : 재시도 스캔idx_status_created (status, created_at) : PENDING 픽업idx_published_at (status, published_at) : 정리PaymentEventConsumer
역할
컨슈머 공통 정책
컨테이너 설정(paymentListenerContainerFactory) 기준
예외 매핑
PaymentException(도메인/비즈니스): ACK (재처리 불필요, 내부 Outbox로 실패 이벤트 발행).AmqpRejectAndDontRequeueException: 즉시 DLQ (NULL, 타입오류, 역직렬화 실패, 필수 필드 누락).수신 큐/바인딩
PAYMENT_PARTICIPANT_REGISTER <- participant.registered (Topic, momo.participant.events)PAYMENT_PARTICIPANT_CANCEL <- participant.canceled.refund (Topic, momo.participant.events)PAYMENT_MEETING_DELETED <- meeting.deleted (Topic, meeting.exchange)DLX_PAYMENT로 DLQ 바인딩되어 즉시/최종 실패 시 PAYMENT_DLQ로 이동.핵심 핸들러
handleParticipantRegister(EventWrapper<?> . . .)
NULL 체크 -> null이면 ACK 후 드롭.
타입 검증: MEETING_PARTICIPANT_REGISTER 아니면 즉시 DLQ.
역직렬화: 실패 시 즉시 DLQ.
필수 필드(meetingId, userId) 체크: 누락 시 즉시 DLQ.
결제 생성 플로우: paymentService.createTestKeyInPayment(request, userId, corrUuid)
"PAYMENT_COMPLETED" 저장 + Spring 이벤트 AFTER_COMMIT 발행.성공 ACK, PaymentException은 컨슈머 측에서 ACK service에서 catch 후 * Outbox "PAYMENT_FAILED" 저장 + Spring 이벤트 AFTER_COMMIT 발행. 그 외 오류는 재시도 -> DLQ.
handleParticipantCancel(EventWrapper<?> . . .)
NULL -> ACK.
타입 검증: MEETING_PARTICIPANT_CANCEL 아니면 즉시 DLQ.
역직렬화 실패 -> 즉시 DLQ.
필드 체크(meetingId, userId) 누락 -> 즉시 DLQ.
refundRequired=false면 ACK (무료/환불 불필요).
완료 결제 조회: 없으면 ACK.
환불 처리: paymentService.refundPayment(paymentId, userId, reason, corrUuid)
refund() -> Outbox "PAYMENT_REFUNDED" 저장 -> Spring 이벤트 발행 -> 레코드 삭제(재결제 허용).성공 ACK, PaymentException은 ACK 후 서비스에서 catch후 recordRefundFailure()로 기록, 그 외 오류는 재시도 -> DLQ.
handleMeetingDeleted(EventWrapper<?> . . . )
NULL -> ACK.
타입 검증: MEETING_DELETE 아니면 즉시 DLQ.
역직렬화 실패/meetingId 누락 -> 즉시 DLQ.
해당 모임의 COMPLETED 결제 목록 조회. 없으면 ACK.
각 결제에 대해 개별 환불 시도(try-catch):
refundPayment() 호출(상동).recordRefundFailure) 및 계속 진행(부분 실패 허용).부분 실패가 있어도 최종 ACK(실패건은 별도 후처리).
보조 메서드
safeAck(Channel ch, long tag): ACK 자체 실패를 방어.recordRefundFailure(): 환불 실패 로그 기록멱등성/추적
Correlation UUID: 수신 EventWrapper.uuId()를 결제/환불 성공,실패 Outbox 이벤트에 그대로 전파 -> 전/후 이벤트 상관관계 추적 용이.
중복 관리:
(meeting_id, user_id) 유니크 제약 + 상태 기반 로직(PENDING 재사용/ALREADY_PAID 차단).PaymentListenerContainerFactory역할
핵심 설정
MANUAL ACK / defaultRequeueRejected=false / Retry(1s->2s->4s, 3회, Recoverer=RejectAndDontRequeue)prefetch=20, concurrentConsumers=3, max=6 흐름 연결
PaymentEventConsumer의 예외 전략과 결합되어 비즈니스/시스템 오류를 명확히 나누고, DLQ로 보냅니다.컨슈머 재시도 대상 정리
컨테이너의 재시도는 리스너(@RabbitListener) 메서드가 던진 예외에만 적용됩니다.
현재 구현에서는 비즈니스 예외는 컨슈머 측에서 ACK처리,
데이터 오류는 즉시 DLQ로 보내며,
시스템 예외는 runtimeException으로 재시도 대상입니다.
PaymentException(비즈니스): ACK 후 종료 -> 재시도 없음 (서비스에서 paymentexception으로 catch 후 fail 이벤트를 발행합니다)
AmqpRejectAndDontRequeueException(데이터 오류/영구 실패): 즉시 DLQ -> 재시도 없음
그 외 RuntimeException(일시적/시스템 오류): 컨테이너가 재시도 3회(1s->2s->4s) -> 실패 시 DLQ