결제 도메인 각 파일별 기능 요약

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

1) PaymentOutboxScheduler

역할

  • Outbox 테이블을 주기적으로 스캔해 발행 대상 ID만 픽업하고, 스레드풀로 비동기 디스패치.
  • 오래된 데이터 정리, PROCESSING 방치 복구까지의 작업을 담당합니다.

핵심 상수/주기

  • 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) 호출

2) PaymentOutboxServiceImpl

역할

  • Outbox 상태 전이(PUBLISHED/FAILED/DLQ), 원자적 선점, 정리/복구를 담당하는 서비스입니다.

트랜잭션

  • markEventAsPublished/Failed/tryMarkProcessing : REQUIRES_NEW짧게 커밋
  • pickPendingIds/pickRetryableFailedIds : readOnly = true
  • cleanupOldMessages/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 타임아웃 복구

3) PaymentServiceImpl

역할

  • 결제/환불 비즈니스 트랜잭션 처리 + Outbox 이벤트 저장 + 커밋 후 Spring 이벤트 발행합니다.

트랜잭션/예외

  • 클래스 레벨 @Transactional
  • createTestKeyInPayment(), refundPayment() : noRollbackFor = PaymentException.class (비즈니스 예외는 커밋)

주요 공개 메서드

  • createTestKeyInPayment(request, userId) / 오버로드(correlationUuid)

    • 검증: validateTestKey() (토스 테스트 키)
    • 조회: getMeetingViaClient, getUserViaClient
    • 결제 Row: (이미 COMPLETED면 예외) FAILED->reopenPending(), 없으면 createPending()
    • 수행: Toss Key-in 호출 -> 성공 시 payment.complete()
    • Outbox 저장: saveOutboxEvent(..., "PAYMENT_COMPLETED",... )
    • Spring 이벤트 발행: PaymentEvents.Completed()
  • refundPayment(paymentId, userId, request) / 오버로드(correlationUuid)

    • 권한/상태 체크 -> Toss 취소 호출 -> payment.refund()
    • Outbox 저장: "PAYMENT_REFUNDED" -> Spring 이벤트 Refunded()
    • 응답 생성 후 결제 Row 삭제(재결제 허용)
  • 조회

    • 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


4) PaymentOutbox (엔티티)

역할

  • Outbox 레코드 스키마 및 상태 전이 규칙(도메인 메서드) 캡슐화합니다.

주요 필드

  • 상태: status(PENDING/PROCESSING/PUBLISHED/FAILED/DEAD_LETTERED), published, publishedAt
  • 재시도: retryCount, nextRetryAt
  • 라우팅/추적: eventType, 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 으로 같은 시퀀스가 됩니다. (즉, 실패 직전 카운트 기준으로 같게 맞추어 줬습니다.)


5) Payment (엔티티)

역할

  • 결제 레코드 및 상태 전이 -> COMPLETED/FAILED/REFUNDED) 관리.

주요 필드/제약

  • @UniqueConstraint(meeting_id, user_id) : 동일 사용자-모임 1건 제한
  • @Version : 낙관적 락
  • 상태 타임스탬프: paidAt, failedAt, refundedAt

도메인 메서드

  • createPending(userId, meetingId, amount) : 초기 결제 생성
  • complete(pgTransactionId, orderId, paidAt) : PENDING -> COMPLETED
  • fail(reason) : (COMPLETED/REFUNDED 불가) 실패 마킹
  • refund() : COMPLETED -> REFUNDED
  • reopenPending() : FAILED -> PENDING 복귀

6) PaymentEventProducer

역할

  • Outbox 단건 선점 -> MQ 발행 -> Confirm/Return 콜백에서 최종 마킹.

초기화

  • @PostConstruct setupCallbacks() :

    • Confirm 콜백: ACK -> (Return 없는 경우) Published, NACK -> Failed
    • Returns 콜백: Unroutable 처리 (Refund 키는 정책상 Published)

발행

  • publishOutboxEvent(outboxId)

    1. outboxService.tryMarkProcessing(outboxId) : 원자적 선점(1/0)

    2. Outbox 조회 -> EventWrapper<?> 역직렬화

    3. RabbitTemplate.convertAndSend()

      • CorrelationData.id = outboxId
      • 메시지 메타: messageId=outboxId, headers(x-outbox-id, x-correlation-id)
    4. 콜백에서 markEventAsPublished/Failed

라우팅 실패 처리

  • handleRoutingFailure(outboxId, returned) :

    • RoutingKeys.PAYMENT_REFUNDED_KEY -> 정책상 Published
    • 그 외 -> Failed(reason=replyText)

7) PaymentRabbitConfig


역할

  • Payment 전용 Rabbit ConnectionFactory / Template / ListenerContainerFactory 설정.

Bean

  • paymentConnectionFactory(base) :

    • ConfirmType=CORRELATED, Returns=true
  • paymentRabbitTemplate(connectionFactory, messageConverter) :

    • mandatory = true, 네트워크/채널 오류용 RetryTemplate(0.5s -> ×2 -> 10s)
  • paymentListenerContainerFactory() :

    • Ack=MANUAL, defaultRequeueRejected=false
    • Prefetch=20, concurrency 3~6
    • 재시도 인터셉터: maxAttempts=3, backoff(1s->2s->4s), Recoverer=RejectAndDontRequeue

8) PaymentJpaRepository

역할

  • 결제 전용 JPA 레포.

주요 메서드

  • findByMeetingIdAndUserIdAndStatus()
  • findByMeetingIdAndUserId()
  • findByMeetingIdAndStatus()
  • searchMyPayments(userId, status, pageable) : 상태 필터 옵션

9) PaymentOutboxJpaRepository

역할

  • Outbox 전용 JPA/네이티브 레포. 픽업/선점/정리/복구 쿼리 제공.

주요 메서드/쿼리

  • 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) (네이티브) :

    • 오래된 PROCESSING -> FAILED, next_retry_at = NOW() + 10*2^retry_count sec

PROCESSING 복구 딜레이 계산

복구 쿼리에서 retryCount를 증가시키지 않고 next_retry_at = NOW() + 10 × 2^(retryCount) sec 로 딜레이를 계산합니다.

위에서 설명한 실패 콜백(markAsFailed)과 함께 보았을 때 최종 backoff 시퀀스(10->20->40) 는 동일하게 유지됩니다.


10) PaymentController

역할

  • 결제/환불/조회 REST 엔드포인트.

엔드포인트

  • POST /payments/test/keyin : 테스트 키인 결제
  • GET /payments/me : 내 결제 목록(옵션 status, pageable)
  • GET /payments/{paymentId}/pg : PG 단건 조회
  • POST /payments/{paymentId}/refund : 환불

11) OutboxExecutorConfig

역할

  • Outbox 발행용 스레드풀 정의.

설정

  • Core=4, Max=8, Queue=64, prefix=outbox-
  • @Qualifier("outboxPublisherExecutor") 로 스케줄러에서 주입받아 사용

graceful shutdown 적용

setWaitForTasksToCompleteOnShutdown(true);
을 설정해 주어 종료 시 graceful shutdown을 적용 해 배포 중 메세지 유실을 막도록 했습니다.


12) payment_outbox DDL

주요 컬럼

  • status, retry_count, next_retry_at, failure_reason, published, published_at
  • event_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) : 정리

13) PaymentEventConsumer

역할

  • Participant/Meeting 도메인에서 올라오는 등록/취소/모임삭제 이벤트를 수신해 결제 생성/환불을 트리거 합니다.
  • 비즈니스 예외는 ACK 후 드롭, 시스템 오류는 재시도 -> DLQ로 보내는 역할을 합니다.

컨슈머 공통 정책

  • 컨테이너 설정(paymentListenerContainerFactory) 기준

    • Ack=MANUAL, defaultRequeueRejected=false -> 의도적 예외는 즉시 DLQ(재처리 금지).
    • Retry(1s->2s->4s, 3회): 런타임 예외만 재시도 후 실패 시 DLQ.
    • Prefetch=20, concurrency=3~6: 병렬 처리/처리량 확보.
  • 예외 매핑

    • PaymentException(도메인/비즈니스): ACK (재처리 불필요, 내부 Outbox로 실패 이벤트 발행).
    • AmqpRejectAndDontRequeueException: 즉시 DLQ (NULL, 타입오류, 역직렬화 실패, 필수 필드 누락).
    • 그 외(일시적/시스템): 런타임 throw -> 컨테이너 재시도 후 DLQ.

수신 큐/바인딩

  • 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<?> . . .)

    1. NULL 체크 -> null이면 ACK 후 드롭.

    2. 타입 검증: MEETING_PARTICIPANT_REGISTER 아니면 즉시 DLQ.

    3. 역직렬화: 실패 시 즉시 DLQ.

    4. 필수 필드(meetingId, userId) 체크: 누락 시 즉시 DLQ.

    5. 결제 생성 플로우: paymentService.createTestKeyInPayment(request, userId, corrUuid)

      • 내부에서 테스트키 검증, 외부 조회, PENDING 재사용/FAILED -> PENDING 복귀, Toss Key-in 호출.
      • 성공 시 Outbox "PAYMENT_COMPLETED" 저장 + Spring 이벤트 AFTER_COMMIT 발행.
    6. 성공 ACK, PaymentException은 컨슈머 측에서 ACK service에서 catch 후 * Outbox "PAYMENT_FAILED" 저장 + Spring 이벤트 AFTER_COMMIT 발행. 그 외 오류는 재시도 -> DLQ.

  • handleParticipantCancel(EventWrapper<?> . . .)

    1. NULL -> ACK.

    2. 타입 검증: MEETING_PARTICIPANT_CANCEL 아니면 즉시 DLQ.

    3. 역직렬화 실패 -> 즉시 DLQ.

    4. 필드 체크(meetingId, userId) 누락 -> 즉시 DLQ.

    5. refundRequired=falseACK (무료/환불 불필요).

    6. 완료 결제 조회: 없으면 ACK.

    7. 환불 처리: paymentService.refundPayment(paymentId, userId, reason, corrUuid)

      • PG 취소 호출(Toss) -> 엔티티 refund() -> Outbox "PAYMENT_REFUNDED" 저장 -> Spring 이벤트 발행 -> 레코드 삭제(재결제 허용).
    8. 성공 ACK, PaymentExceptionACK 후 서비스에서 catch후 recordRefundFailure()로 기록, 그 외 오류는 재시도 -> DLQ.

  • handleMeetingDeleted(EventWrapper<?> . . . )

    1. NULL -> ACK.

    2. 타입 검증: MEETING_DELETE 아니면 즉시 DLQ.

    3. 역직렬화 실패/meetingId 누락 -> 즉시 DLQ.

    4. 해당 모임의 COMPLETED 결제 목록 조회. 없으면 ACK.

    5. 각 결제에 대해 개별 환불 시도(try-catch):

      • 성공: refundPayment() 호출(상동).
      • 실패: 로그/기록(recordRefundFailure)계속 진행(부분 실패 허용).
    6. 부분 실패가 있어도 최종 ACK(실패건은 별도 후처리).

보조 메서드

  • safeAck(Channel ch, long tag): ACK 자체 실패를 방어.
  • recordRefundFailure(): 환불 실패 로그 기록

멱등성/추적

  • Correlation UUID: 수신 EventWrapper.uuId()결제/환불 성공,실패 Outbox 이벤트에 그대로 전파 -> 전/후 이벤트 상관관계 추적 용이.

  • 중복 관리:

    • 결제: (meeting_id, user_id) 유니크 제약 + 상태 기반 로직(PENDING 재사용/ALREADY_PAID 차단).
    • 환불: COMPLETED만 환불 허용, 완료 후 레코드 삭제로 재참가/재결제 허용.

14) PaymentListenerContainerFactory

역할

  • 컨슈머 공통 처리 정책(ACK/Retry/DLQ/Prefetch/동시성)을 정의합니다.

핵심 설정

  • 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

0개의 댓글