Files
sodalive-backend-spring-boot/docs/plan-task/20260518_충전이벤트보너스지급안정화.md

15 KiB

충전 이벤트 보너스 지급 안정화 작업 계획

목적

  • 충전 완료 후 백그라운드로 지급되는 충전 이벤트 보너스가 누락되거나 중복 지급되지 않도록 한다.
  • 결제 완료 처리, 이벤트 보너스 지급, 재시도, 운영 확인이 모두 DB 기준으로 추적 가능하도록 한다.

현재 확인된 문제

  • ChargeSpringEventListener.applyChargeEvent@Async @TransactionalEventListener로 동작해 결제 완료 트랜잭션 커밋 후 별도 스레드에서 보너스를 지급한다.
  • 비동기 이벤트 처리 실패가 원 결제 API 응답에 드러나지 않아, 결제는 성공했지만 이벤트 보너스만 누락될 수 있다.
  • ChargeEventRepository.getPaymentCount(member, method, startDate, endDate)startDate, endDate를 파라미터로 받지만 실제 쿼리 조건에 사용하지 않는다.
  • Member.charge(...)는 단순 필드 증가(+=)인데 원본 충전 완료와 이벤트 지급 흐름 모두 회원 row lock 없이 실행되면 동시 충전 시 잔액 유실이 발생할 수 있다.
  • 원본 충전 건(sourceChargeId) 기준으로 이벤트 보너스 지급 여부를 고유하게 보장하는 DB 제약이 없다.

추가 캔 수 기록 여부 판단

  • charge_event_job에는 additional_can을 저장하는 것이 좋다.
  • 이유는 다음과 같다.
    • 작업 생성 시점의 이벤트 조건을 스냅샷으로 보존할 수 있다.
    • 재시도 시점에 관리자가 addPercent, 이벤트 제목, 기간을 수정해도 최초 충전 완료 당시 계산된 보너스 캔을 그대로 지급할 수 있다.
    • 운영자가 실패 작업을 확인할 때 “얼마를 지급해야 했는지”를 별도 재계산 없이 알 수 있다.
    • 재시도 worker가 이벤트 설정을 다시 읽어 계산하는 과정에서 발생할 수 있는 불일치를 줄인다.
  • 따라서 source_charge_id, member_id, charge_event_id, job_type, additional_can, method_snapshot을 함께 저장한다.

확정 운영 정책

  • worker 실행 주기는 5분으로 한다.
  • worker batch size는 30건으로 한다.
  • worker backoff는 5분, 10분, 15분 순서로 적용한다.
  • 최대 재시도 횟수는 3회로 한다.
  • 3회 재시도 후에도 실패하면 FAILED로 전환하고 자동 재시도를 중단한다.
  • DONE, PROCESSING 상태를 제외한 작업을 확인할 수 있는 관리자 API를 추가한다.
  • FAILED 작업을 다시 재시도할 수 있는 관리자 API를 추가한다.
  • worker는 활성 충전 이벤트가 없으면 charge_event_job 조회를 수행하지 않고 종료한다.
  • 충전 완료 처리(verify) 시 충전 이벤트가 있으면 charge_event_job을 항상 기록한다.
  • 원본 충전 완료 처리에서도 MemberRepository.findByIdForUpdate(...)로 회원 row를 잠근 뒤 member.charge(...)를 수행한다.
  • 충전 완료 처리(verify) 시 charge_event_job 기록 후 이벤트 보너스를 즉시 지급한다.
  • 즉시 지급 중인 작업은 PROCESSING 상태로 선점해 worker가 실행하지 않도록 한다.
  • 즉시 지급에 성공하면 DONE, 실패하면 PENDINGnext_retry_at = now + 5분으로 전환해 worker 재시도 대상으로 둔다.
  • 관리자 API는 기존 관리자 컨트롤러 관례에 맞춰 /admin/... 경로, @PreAuthorize("hasRole('ADMIN')"), ApiResponse.ok(...) 응답을 사용한다.

DB 설계

charge_event_job

  • 충전 완료 후 이벤트 보너스 지급 작업을 영속화하는 테이블이다.
  • idempotency_key에 unique key를 걸어 같은 원본 충전 건의 같은 이벤트 보너스 작업이 중복 생성되지 않도록 한다.
  • status, retry_count, next_retry_at, last_error로 실패 작업을 재시도하고 운영자가 확인할 수 있게 한다.
  • 현재 DDL에는 boolean 컬럼이 필요하지 않아 포함하지 않는다. 추후 boolean 컬럼을 추가할 경우 MySQL 기준 TINYINT(1)로 작성한다.
CREATE TABLE charge_event_job
(
    id                   BIGINT       NOT NULL AUTO_INCREMENT COMMENT 'PK',
    source_charge_id     BIGINT       NOT NULL COMMENT '이벤트 보너스 지급의 기준이 되는 원본 충전 ID(charge.id)',
    result_charge_id     BIGINT       NULL COMMENT '이벤트 보너스로 생성된 Charge ID(charge.id)',
    member_id            BIGINT       NOT NULL COMMENT '이벤트 보너스를 지급받을 회원 ID(member.id)',
    charge_event_id      BIGINT       NULL COMMENT '적용된 충전 이벤트 ID(charge_event.id), 첫 충전 이벤트처럼 별도 이벤트 row가 없으면 NULL',
    job_type             VARCHAR(30)  NOT NULL COMMENT '작업 유형(FIRST_CHARGE, ACTIVE_CHARGE_EVENT)',
    idempotency_key      VARCHAR(100) NOT NULL COMMENT '중복 작업 방지 키(예: charge-event:{sourceChargeId}:{jobType}:{chargeEventId 또는 none})',
    additional_can       INT          NOT NULL COMMENT '추가 지급할 보너스 캔 수',
    payment_gateway      VARCHAR(30)  NOT NULL COMMENT '원본 충전의 결제 게이트웨이(PG, PAYVERSE, GOOGLE_IAP, APPLE_IAP 등)',
    container            VARCHAR(10)  NOT NULL COMMENT '회원 캔 잔액 반영 대상(pg, aos, ios)',
    method_snapshot      VARCHAR(100) NOT NULL COMMENT '이벤트 보너스 Charge.payment.method에 기록할 지급 사유 스냅샷',
    status               VARCHAR(20)  NOT NULL DEFAULT 'PENDING' COMMENT '작업 상태(PENDING, PROCESSING, DONE, FAILED)',
    retry_count          INT          NOT NULL DEFAULT 0 COMMENT '재시도 횟수',
    next_retry_at        TIMESTAMP    NULL COMMENT '다음 재시도 가능 시각',
    processing_started_at TIMESTAMP   NULL COMMENT 'PROCESSING 상태로 선점한 시각',
    processed_at         TIMESTAMP    NULL COMMENT '지급 성공 처리 시각',
    last_error           TEXT         NULL COMMENT '마지막 실패 사유',
    created_at           TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
    updated_at           TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
    PRIMARY KEY (id),
    UNIQUE KEY uk_charge_event_job_idempotency_key (idempotency_key),
    KEY idx_charge_event_job_status_next_retry_at (status, next_retry_at),
    KEY idx_charge_event_job_source_charge_id (source_charge_id),
    KEY idx_charge_event_job_result_charge_id (result_charge_id),
    KEY idx_charge_event_job_member_id (member_id),
    KEY idx_charge_event_job_charge_event_id (charge_event_id),
    CONSTRAINT fk_charge_event_job_source_charge_id
        FOREIGN KEY (source_charge_id) REFERENCES charge (id),
    CONSTRAINT fk_charge_event_job_result_charge_id
        FOREIGN KEY (result_charge_id) REFERENCES charge (id),
    CONSTRAINT fk_charge_event_job_member_id
        FOREIGN KEY (member_id) REFERENCES member (id),
    CONSTRAINT fk_charge_event_job_charge_event_id
        FOREIGN KEY (charge_event_id) REFERENCES charge_event (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='충전 이벤트 보너스 지급 작업';

구현 항목

  • ChargeEventRepository.getPaymentCount(...) 쿼리 수정

    • charge.createdAt이 이벤트 startDate, endDate 사이인지 조건에 추가한다.
    • 이벤트 보너스 지급 건만 세도록 charge.status.eq(ChargeStatus.EVENT) 조건을 추가한다.
    • fetch().count() 대신 DB count 쿼리를 사용한다.
  • 결제 완료 처리 idempotency 강화

    • ChargeRepository에 원본 Charge row를 PESSIMISTIC_WRITE로 조회하는 메서드를 추가한다.
    • payverseWebhook, payverseVerify, verify, verifyHecto, appleVerify, processGoogleIap에서 PaymentStatus.REQUEST -> COMPLETE 전이일 때만 회원 잔액 반영과 이벤트 작업 생성을 수행한다.
    • 각 완료 경로는 MemberRepository.findByIdForUpdate(...)로 회원 row를 잠근 뒤 원본 충전의 member.charge(...)를 호출한다.
    • 이미 COMPLETE인 경우 원 결제와 이벤트 작업을 다시 만들지 않고 완료 응답만 반환한다.
  • 회원 잔액 업데이트 직렬화

    • 원본 충전 완료 시 MemberRepository.findByIdForUpdate(...)로 회원 row를 잠근 뒤 member.charge(...)를 호출한다.
    • 이벤트 보너스 즉시 지급 시에도 같은 방식으로 회원 row를 잠근 뒤 보너스 캔을 반영한다.
    • 이벤트 보너스 worker 재시도에서도 같은 방식으로 회원 row를 잠근 뒤 보너스 캔을 반영한다.
  • charge_event_job 기반 이벤트 보너스 작업 생성

    • 충전 완료 트랜잭션 안에서 이벤트 적용 대상인지 판단하고 charge_event_job row를 항상 생성한다.
    • 즉시 지급을 시작하는 작업은 PROCESSING으로 저장해 worker가 조회하지 않도록 한다.
    • additional_can, method_snapshot, payment_gateway, container는 작업 생성 시점 값으로 저장한다.
    • idempotency_key unique key 충돌 시 이미 생성된 작업으로 보고 중복 생성하지 않는다.
    • 즉시 지급 성공 시 DONE, processed_at, result_charge_id를 기록한다.
    • 즉시 지급 실패 시 PENDING, next_retry_at = now + 5분, last_error를 기록한다.
  • 이벤트 보너스 worker 구현

    • 기존 TranslationJobWorker@Scheduled, 상태 선점, retry/backoff 구조를 참고한다.
    • @Scheduled(fixedDelayString = "\${sodalive.charge-event-job.fixed-delay-ms:300000}") 방식으로 5분 주기를 기본값으로 둔다.
    • worker는 한 번에 최대 30건만 처리한다.
    • worker 시작 시 활성 충전 이벤트가 없으면 charge_event_job 조회 없이 즉시 종료한다.
    • PENDING 작업 중 next_retry_at IS NULL OR next_retry_at <= now인 작업을 조회한다.
    • 작업을 PROCESSING으로 선점한 뒤 별도 트랜잭션에서 보너스 Charge(status = EVENT) 생성과 member.charge(...)를 수행한다.
    • 성공하면 DONE으로 변경하고 processed_at을 기록한다.
    • 1회 재시도 실패 시 retry_count = 1, next_retry_at = now + 10분, last_error를 기록하고 PENDING으로 되돌린다.
    • 2회 재시도 실패 시 retry_count = 2, next_retry_at = now + 15분, last_error를 기록하고 PENDING으로 되돌린다.
    • 3회 재시도 실패 시 retry_count = 3, status = FAILED, last_error를 기록하고 자동 재시도를 중단한다.
  • 이벤트 작업 관리자 API 추가

    • AdminChargeEventJobControllerAdminChargeEventJobService를 추가한다.
    • 컨트롤러는 @RequestMapping("/admin/charge/event-jobs")@PreAuthorize("hasRole('ADMIN')")를 사용한다.
    • API 응답은 기존 관리자 API 관례에 맞춰 ApiResponse.ok(...)를 사용한다.
    • GET /admin/charge/event-jobsDONE, PROCESSING 상태를 제외한 작업 목록을 조회한다.
    • 조회 대상에는 재시도 대기 중인 PENDING 작업과 운영 확인이 필요한 FAILED 작업을 포함한다.
    • 응답에는 id, sourceChargeId, resultChargeId, memberId, chargeEventId, jobType, additionalCan, status, retryCount, nextRetryAt, lastError, createdAt, updatedAt을 포함한다.
    • POST /admin/charge/event-jobs/{jobId}/retryFAILED 작업을 재시도 대상으로 변경한다.
    • 재시도 API는 FAILED 상태 작업만 PENDING으로 변경하고 next_retry_at을 현재 시각으로 갱신한다.
    • 재시도 API는 DONE, PROCESSING, PENDING 상태 작업에 대해서는 상태를 변경하지 않는다.
  • 기존 @Async @TransactionalEventListener 역할 정리

    • 기존 ChargeSpringEventListener가 직접 ChargeEventService.applyChargeEvent(...)를 호출해 보너스를 지급하는 구조는 제거한다.
    • 즉시 지급은 충전 완료 처리(verify) 흐름에서 생성한 charge_event_job을 기준으로 ChargeEventJobService가 수행한다.
    • ChargeSpringEventListener는 제거하거나, 남기더라도 보너스 지급을 직접 수행하지 않는다.
    • 보너스 지급은 즉시 지급과 worker 재시도 모두 DB에 저장된 charge_event_job을 기준으로 처리한다.
  • 테스트 항목

    • 같은 source_charge_id로 이벤트 작업 생성이 중복 요청되어도 charge_event_job이 1건만 생성되는지 검증한다.
    • 충전 완료 처리에서 charge_event_job 생성 후 즉시 지급 성공 시 DONE으로 기록되는지 검증한다.
    • 즉시 지급 중인 PROCESSING 작업을 worker가 처리하지 않는지 검증한다.
    • 즉시 지급 실패 시 PENDING, next_retry_at = now + 5분으로 전환되는지 검증한다.
    • worker 실패 시 retry_count, next_retry_at, last_error가 갱신되는지 검증한다.
    • worker가 30건까지만 처리하는지 검증한다.
    • 활성 충전 이벤트가 없으면 worker가 charge_event_job 조회 없이 종료하는지 검증한다.
    • worker 재시도 성공 시 보너스 Charge(status = EVENT)와 회원 보너스 캔이 1회만 반영되는지 검증한다.
    • worker가 3회 재시도 실패 후 FAILED로 전환하고 자동 재시도를 중단하는지 검증한다.
    • 이벤트 작업 조회 관리자 API가 PENDING, FAILED 작업을 반환하고 DONE, PROCESSING 작업을 제외하는지 검증한다.
    • FAILED 재시도 관리자 API가 작업을 PENDING으로 되돌리는지 검증한다.
    • 동시 충전 요청에서 회원 잔액이 유실되지 않는지 검증한다.
    • getPaymentCount(...)가 이벤트 기간 밖의 과거 보너스 지급 건을 세지 않는지 검증한다.

검증 항목

  • ./gradlew test
  • ./gradlew ktlintCheck
  • 스테이징에서 결제 완료 후 charge_event_job 생성 확인
  • 스테이징에서 결제 완료 후 즉시 지급 성공 시 worker가 해당 작업을 중복 처리하지 않는지 확인
  • 스테이징에서 worker 실패 강제 후 재시도 성공 확인
  • 스테이징에서 3회 실패 후 FAILED 전환 및 관리자 API 재시도 확인
  • 같은 결제 검증 요청을 중복 호출해도 원 결제와 이벤트 보너스가 중복 지급되지 않는지 확인

검증 로그

  • 문서 작성 검증: 계획 문서에 수정 대상 4가지, MySQL DDL, 컬럼 COMMENT, TIMESTAMP, TINYINT(1) 규칙 반영 여부를 확인했다.
  • 구현 검증: global worktree(/Users/klaus/.config/superpowers/worktrees/sodalive/charge-event-job-stabilization)에서 charge_event_job 엔티티/리포지토리/서비스/worker/관리자 API를 추가하고, 결제 완료 경로가 ChargeEventJobService.createAndProcessImmediate(...)를 호출하도록 변경했다.
  • 테스트 검증: ./gradlew test를 실행해 전체 테스트 통과를 확인했다.
  • 린트 검증: ./gradlew ktlintCheck를 실행해 ktlint 통과를 확인했다.
  • LSP 검증: Kotlin LSP 서버가 설정되어 있지 않아 lsp_diagnostics는 실행 불가했고, 대신 Gradle 컴파일/테스트와 ktlint로 검증했다.