From 9a9fdfe0a14b619ed6089ba949806c5c9b86dbbc Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 18 May 2026 14:18:10 +0900 Subject: [PATCH] =?UTF-8?q?docs(charge):=20=EC=B6=A9=EC=A0=84=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EB=B3=B4=EB=84=88=EC=8A=A4=20=EC=A7=80?= =?UTF-8?q?=EA=B8=89=20=EC=95=88=EC=A0=95=ED=99=94=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260518_충전이벤트보너스지급안정화.md | 170 ++++++++++++++++++ ...20260518_충전이벤트보너스지급안정화_prd.md | 107 +++++++++++ 2 files changed, 277 insertions(+) create mode 100644 docs/plan-task/20260518_충전이벤트보너스지급안정화.md create mode 100644 docs/prd/20260518_충전이벤트보너스지급안정화_prd.md diff --git a/docs/plan-task/20260518_충전이벤트보너스지급안정화.md b/docs/plan-task/20260518_충전이벤트보너스지급안정화.md new file mode 100644 index 00000000..dc41b141 --- /dev/null +++ b/docs/plan-task/20260518_충전이벤트보너스지급안정화.md @@ -0,0 +1,170 @@ +# 충전 이벤트 보너스 지급 안정화 작업 계획 + +## 목적 +- 충전 완료 후 백그라운드로 지급되는 충전 이벤트 보너스가 누락되거나 중복 지급되지 않도록 한다. +- 결제 완료 처리, 이벤트 보너스 지급, 재시도, 운영 확인이 모두 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로 검증했다. diff --git a/docs/prd/20260518_충전이벤트보너스지급안정화_prd.md b/docs/prd/20260518_충전이벤트보너스지급안정화_prd.md new file mode 100644 index 00000000..c519ab5e --- /dev/null +++ b/docs/prd/20260518_충전이벤트보너스지급안정화_prd.md @@ -0,0 +1,107 @@ +# PRD: 충전 이벤트 보너스 지급 안정화 + +## 1. Overview +- 충전 완료 후 지급되는 충전 이벤트 보너스가 누락되거나 중복 지급되지 않도록 안정화한다. +- 상세 구현 방향은 `docs/plan-task/20260518_충전이벤트보너스지급안정화.md`를 기준으로 한다. +- 이 PRD는 충전 이벤트 보너스 지급 작업의 재시도, worker, 관리자 API 운영 정책을 확정한다. + +--- + +## 2. Problem +- 현재 충전 이벤트 보너스 지급은 결제 완료 흐름과 분리된 비동기 이벤트에서 처리된다. +- 비동기 처리 실패가 발생하면 원 결제는 성공했지만 이벤트 보너스만 누락될 수 있다. +- 실패 작업을 DB에 남기고, 정해진 주기와 횟수 안에서 재시도하며, 최종 실패 건은 관리자 API로 확인/재시도할 수 있어야 한다. + +--- + +## 3. Goals +- 이벤트 보너스 지급 실패가 DB에 남고 재시도 가능해야 한다. +- 중복 결제 검증 또는 worker 재시도에도 보너스가 중복 지급되지 않아야 한다. +- 원본 충전 완료 처리에서도 회원 잔액 변경은 회원 row lock을 잡은 뒤 수행해 동시 충전 시 잔액 유실을 방지해야 한다. +- 최대 재시도 후에도 실패한 작업은 운영자가 원인과 지급 대상, 지급할 캔 수를 확인할 수 있어야 한다. +- 충전 완료 검증 시 이벤트 작업을 기록하고 즉시 지급을 시도하되, 즉시 수행 중인 작업을 worker가 중복 처리하지 않아야 한다. + +--- + +## 4. Non-Goals +- 결제 PG 검증 방식 자체를 교체하지 않는다. +- 충전 상품 가격, 이벤트 보너스율, 첫 충전 이벤트율을 변경하지 않는다. +- 관리자 화면 신규 개발은 포함하지 않는다. 단, 실패 작업 조회/재시도용 관리자 API는 포함한다. + +--- + +## 5. Core Features + +### Feature A. 충전 이벤트 보너스 지급 작업 추적 + +#### Requirements +- 충전 완료 후 이벤트 보너스 지급 대상이면 DB 작업으로 추적한다. +- 지급 대상, 원본 충전, 추가 지급 캔 수, 처리 상태, 실패 사유를 확인할 수 있어야 한다. +- 원본 충전 완료 처리(`verify`, `verifyHecto`, `appleVerify`, `payverseVerify`, `payverseWebhook`, `processGoogleIap`)는 회원 row lock 안에서 `PaymentStatus.REQUEST -> COMPLETE` 전이와 `member.charge(...)`를 수행한다. +- 충전 완료 처리(`verify`) 시 `charge_event_job`을 기록하고, 같은 흐름에서 이벤트 보너스 지급을 즉시 시도한다. +- 즉시 지급 중인 작업은 worker가 처리하지 않도록 `PROCESSING` 상태로 선점한다. +- 상세 테이블 구조와 구현 항목은 plan-task 문서를 따른다. + +#### Edge Cases +- 같은 결제 검증 요청이 여러 번 들어와도 보너스 작업은 중복 생성되지 않아야 한다. +- worker가 같은 작업을 재시도해도 보너스는 1회만 지급되어야 한다. +- 이벤트 설정이 재시도 시점에 변경되어도 최초 작업 생성 시점의 추가 캔 수를 유지해야 한다. +- 즉시 지급 실패 후 worker가 재시도할 수 있도록 작업은 `PENDING`과 `next_retry_at`으로 전환되어야 한다. + +--- + +### Feature B. 이벤트 보너스 worker 재시도 + +#### Requirements +- worker 실행 주기는 5분으로 한다. +- worker batch size는 30건으로 한다. +- worker는 활성 충전 이벤트가 없으면 `charge_event_job` 조회를 수행하지 않고 종료한다. +- worker는 `PENDING` 상태이면서 `next_retry_at IS NULL OR next_retry_at <= now`인 작업만 처리한다. +- backoff 정책은 5분, 10분, 15분 순서로 적용한다. +- 최대 재시도 횟수는 3회로 한다. +- 3회 재시도 후에도 실패하면 `FAILED`로 전환하고 자동 재시도를 중단한다. + +#### Edge Cases +- 같은 시간에 다른 scheduler/worker가 실행되어도 worker는 최대 30건만 처리한다. +- 이미 `PROCESSING`, `DONE`, `FAILED`인 작업은 자동 worker가 처리하지 않는다. +- `FAILED` 작업은 관리자 재시도 API를 통해서만 다시 재시도 대상으로 전환한다. + +--- + +### Feature C. 이벤트 작업 관리자 API + +#### Requirements +- `DONE`, `PROCESSING` 상태를 제외한 `charge_event_job` 목록을 조회할 수 있는 관리자 API를 추가한다. +- 조회 API는 재시도 대기 중인 `PENDING` 작업과 운영 확인이 필요한 `FAILED` 작업을 함께 보여준다. +- `FAILED` 상태의 단일 작업을 재시도 대상으로 되돌리는 관리자 API를 추가한다. +- 재시도 API는 대상 작업을 `PENDING`으로 변경하고 `next_retry_at`을 즉시 실행 가능하도록 갱신한다. +- 재시도 API는 이미 `DONE` 또는 `PROCESSING`인 작업을 변경하지 않아야 한다. + +#### Edge Cases +- 관리자가 같은 실패 작업 재시도 API를 여러 번 호출해도 중복 지급되지 않아야 한다. +- 이미 성공 처리된 작업은 재시도 API에서 거부되어야 한다. +- 즉시 지급 중인 `PROCESSING` 작업과 지급 완료된 `DONE` 작업은 관리자 조회 목록에 노출하지 않는다. + +--- + +## 7. Technical Constraints +- DB는 MySQL을 기준으로 한다. +- 날짜/시간 컬럼은 `TIMESTAMP`를 사용한다. +- boolean 컬럼이 필요해질 경우 `TINYINT(1)`을 사용한다. +- 각 DB 컬럼에는 `COMMENT`를 작성한다. +- 구현 계획과 DDL은 `docs/plan-task/20260518_충전이벤트보너스지급안정화.md`를 기준으로 관리한다. + +--- + +## 8. Metrics +- 결제 완료 후 이벤트 보너스 지급 작업 생성 성공률 +- 이벤트 보너스 지급 성공률 +- 재시도 후 성공한 작업 수 +- 최종 `FAILED` 상태로 남은 작업 수 +- 중복 지급 방지 위반 건수 +- 관리자 API로 재시도된 작업 수 + +--- + +## 9. Related Documents +- `docs/plan-task/20260518_충전이벤트보너스지급안정화.md`