# 충전 이벤트 보너스 지급 안정화 작업 계획 ## 목적 - 충전 완료 후 백그라운드로 지급되는 충전 이벤트 보너스가 누락되거나 중복 지급되지 않도록 한다. - 결제 완료 처리, 이벤트 보너스 지급, 재시도, 운영 확인이 모두 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`, 실패하면 `PENDING`과 `next_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)`로 작성한다. ```sql 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='충전 이벤트 보너스 지급 작업'; ``` ## 구현 항목 - [x] `ChargeEventRepository.getPaymentCount(...)` 쿼리 수정 - [x] `charge.createdAt`이 이벤트 `startDate`, `endDate` 사이인지 조건에 추가한다. - [x] 이벤트 보너스 지급 건만 세도록 `charge.status.eq(ChargeStatus.EVENT)` 조건을 추가한다. - [x] `fetch().count()` 대신 DB count 쿼리를 사용한다. - [x] 결제 완료 처리 idempotency 강화 - [x] `ChargeRepository`에 원본 `Charge` row를 `PESSIMISTIC_WRITE`로 조회하는 메서드를 추가한다. - [x] `payverseWebhook`, `payverseVerify`, `verify`, `verifyHecto`, `appleVerify`, `processGoogleIap`에서 `PaymentStatus.REQUEST -> COMPLETE` 전이일 때만 회원 잔액 반영과 이벤트 작업 생성을 수행한다. - [x] 각 완료 경로는 `MemberRepository.findByIdForUpdate(...)`로 회원 row를 잠근 뒤 원본 충전의 `member.charge(...)`를 호출한다. - [x] 이미 `COMPLETE`인 경우 원 결제와 이벤트 작업을 다시 만들지 않고 완료 응답만 반환한다. - [x] 회원 잔액 업데이트 직렬화 - [x] 원본 충전 완료 시 `MemberRepository.findByIdForUpdate(...)`로 회원 row를 잠근 뒤 `member.charge(...)`를 호출한다. - [x] 이벤트 보너스 즉시 지급 시에도 같은 방식으로 회원 row를 잠근 뒤 보너스 캔을 반영한다. - [x] 이벤트 보너스 worker 재시도에서도 같은 방식으로 회원 row를 잠근 뒤 보너스 캔을 반영한다. - [x] `charge_event_job` 기반 이벤트 보너스 작업 생성 - [x] 충전 완료 트랜잭션 안에서 이벤트 적용 대상인지 판단하고 `charge_event_job` row를 항상 생성한다. - [x] 즉시 지급을 시작하는 작업은 `PROCESSING`으로 저장해 worker가 조회하지 않도록 한다. - [x] `additional_can`, `method_snapshot`, `payment_gateway`, `container`는 작업 생성 시점 값으로 저장한다. - [x] `idempotency_key` unique key 충돌 시 이미 생성된 작업으로 보고 중복 생성하지 않는다. - [x] 즉시 지급 성공 시 `DONE`, `processed_at`, `result_charge_id`를 기록한다. - [x] 즉시 지급 실패 시 `PENDING`, `next_retry_at = now + 5분`, `last_error`를 기록한다. - [x] 이벤트 보너스 worker 구현 - [x] 기존 `TranslationJobWorker`의 `@Scheduled`, 상태 선점, retry/backoff 구조를 참고한다. - [x] `@Scheduled(fixedDelayString = "\${sodalive.charge-event-job.fixed-delay-ms:300000}")` 방식으로 5분 주기를 기본값으로 둔다. - [x] worker는 한 번에 최대 30건만 처리한다. - [x] worker 시작 시 활성 충전 이벤트가 없으면 `charge_event_job` 조회 없이 즉시 종료한다. - [x] `PENDING` 작업 중 `next_retry_at IS NULL OR next_retry_at <= now`인 작업을 조회한다. - [x] 작업을 `PROCESSING`으로 선점한 뒤 별도 트랜잭션에서 보너스 `Charge(status = EVENT)` 생성과 `member.charge(...)`를 수행한다. - [x] 성공하면 `DONE`으로 변경하고 `processed_at`을 기록한다. - [x] 1회 재시도 실패 시 `retry_count = 1`, `next_retry_at = now + 10분`, `last_error`를 기록하고 `PENDING`으로 되돌린다. - [x] 2회 재시도 실패 시 `retry_count = 2`, `next_retry_at = now + 15분`, `last_error`를 기록하고 `PENDING`으로 되돌린다. - [x] 3회 재시도 실패 시 `retry_count = 3`, `status = FAILED`, `last_error`를 기록하고 자동 재시도를 중단한다. - [x] 이벤트 작업 관리자 API 추가 - [x] `AdminChargeEventJobController`와 `AdminChargeEventJobService`를 추가한다. - [x] 컨트롤러는 `@RequestMapping("/admin/charge/event-jobs")`와 `@PreAuthorize("hasRole('ADMIN')")`를 사용한다. - [x] API 응답은 기존 관리자 API 관례에 맞춰 `ApiResponse.ok(...)`를 사용한다. - [x] `GET /admin/charge/event-jobs`로 `DONE`, `PROCESSING` 상태를 제외한 작업 목록을 조회한다. - [x] 조회 대상에는 재시도 대기 중인 `PENDING` 작업과 운영 확인이 필요한 `FAILED` 작업을 포함한다. - [x] 응답에는 `id`, `sourceChargeId`, `resultChargeId`, `memberId`, `chargeEventId`, `jobType`, `additionalCan`, `status`, `retryCount`, `nextRetryAt`, `lastError`, `createdAt`, `updatedAt`을 포함한다. - [x] `POST /admin/charge/event-jobs/{jobId}/retry`로 `FAILED` 작업을 재시도 대상으로 변경한다. - [x] 재시도 API는 `FAILED` 상태 작업만 `PENDING`으로 변경하고 `next_retry_at`을 현재 시각으로 갱신한다. - [x] 재시도 API는 `DONE`, `PROCESSING`, `PENDING` 상태 작업에 대해서는 상태를 변경하지 않는다. - [x] 기존 `@Async @TransactionalEventListener` 역할 정리 - [x] 기존 `ChargeSpringEventListener`가 직접 `ChargeEventService.applyChargeEvent(...)`를 호출해 보너스를 지급하는 구조는 제거한다. - [x] 즉시 지급은 충전 완료 처리(`verify`) 흐름에서 생성한 `charge_event_job`을 기준으로 `ChargeEventJobService`가 수행한다. - [x] `ChargeSpringEventListener`는 제거하거나, 남기더라도 보너스 지급을 직접 수행하지 않는다. - [x] 보너스 지급은 즉시 지급과 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(...)`가 이벤트 기간 밖의 과거 보너스 지급 건을 세지 않는지 검증한다. ## 검증 항목 - [x] `./gradlew test` - [x] `./gradlew ktlintCheck` - [ ] 스테이징에서 결제 완료 후 `charge_event_job` 생성 확인 - [ ] 스테이징에서 결제 완료 후 즉시 지급 성공 시 worker가 해당 작업을 중복 처리하지 않는지 확인 - [ ] 스테이징에서 worker 실패 강제 후 재시도 성공 확인 - [ ] 스테이징에서 3회 실패 후 `FAILED` 전환 및 관리자 API 재시도 확인 - [ ] 같은 결제 검증 요청을 중복 호출해도 원 결제와 이벤트 보너스가 중복 지급되지 않는지 확인 ## 검증 로그 - [ ] 문서 작성 검증: 계획 문서에 수정 대상 4가지, MySQL DDL, 컬럼 COMMENT, `TIMESTAMP`, `TINYINT(1)` 규칙 반영 여부를 확인했다. - [x] 구현 검증: global worktree(`/Users/klaus/.config/superpowers/worktrees/sodalive/charge-event-job-stabilization`)에서 `charge_event_job` 엔티티/리포지토리/서비스/worker/관리자 API를 추가하고, 결제 완료 경로가 `ChargeEventJobService.createAndProcessImmediate(...)`를 호출하도록 변경했다. - [x] 테스트 검증: `./gradlew test`를 실행해 전체 테스트 통과를 확인했다. - [x] 린트 검증: `./gradlew ktlintCheck`를 실행해 ktlint 통과를 확인했다. - [x] LSP 검증: Kotlin LSP 서버가 설정되어 있지 않아 `lsp_diagnostics`는 실행 불가했고, 대신 Gradle 컴파일/테스트와 ktlint로 검증했다.