15 KiB
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, 실패하면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)로 작성한다.
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에 원본Chargerow를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_jobrow를 항상 생성한다. - 즉시 지급을 시작하는 작업은
PROCESSING으로 저장해 worker가 조회하지 않도록 한다. additional_can,method_snapshot,payment_gateway,container는 작업 생성 시점 값으로 저장한다.idempotency_keyunique 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 추가
AdminChargeEventJobController와AdminChargeEventJobService를 추가한다.- 컨트롤러는
@RequestMapping("/admin/charge/event-jobs")와@PreAuthorize("hasRole('ADMIN')")를 사용한다. - API 응답은 기존 관리자 API 관례에 맞춰
ApiResponse.ok(...)를 사용한다. GET /admin/charge/event-jobs로DONE,PROCESSING상태를 제외한 작업 목록을 조회한다.- 조회 대상에는 재시도 대기 중인
PENDING작업과 운영 확인이 필요한FAILED작업을 포함한다. - 응답에는
id,sourceChargeId,resultChargeId,memberId,chargeEventId,jobType,additionalCan,status,retryCount,nextRetryAt,lastError,createdAt,updatedAt을 포함한다. POST /admin/charge/event-jobs/{jobId}/retry로FAILED작업을 재시도 대상으로 변경한다.- 재시도 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로 검증했다.