16 KiB
결제 완료 응답 확장 PRD
문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현·테스트·독립 리뷰 완료, 미배포 |
| 작성일 | 2026-11-03 (KST) |
| 최종 수정일 | 2026-11-03 (KST) |
| 대상 제품 | Sodalive 결제 완료 응답과 최초 유료 충전 판정 |
| 작성자·결정권자 | 제품 요청자 |
| 관련 API Contract | 이 문서 §API 계약 (별도 contract 파일 없음) |
| 관련 구현 계획 | plan-task.md |
| 관련 review | 없음 |
1. 개요와 문제
서버 서비스는 결제 완료 정보와 최초 충전 여부를 이미 계산하지만, 다섯 완료 경로의 HTTP 응답은 ApiResponse.data에 Unit을 반환한다. 최초 여부도 응답 시점의 전체 완료 행 개수에 기대므로 충전·환불·동시 완료의 의미를 안정적으로 보존하지 못한다. 이 작업은 완료 응답 계약을 전달하고, 최초 유료 충전 사실을 한 번 확정해 재시도 후에도 유지한다.
2. 목표 및 제외
목표
- 다섯 결제 완료 경로에서 동일한
ChargeCompleteResponse를 반환한다. - 소유 회원만 해당 충전을 확인할 수 있게 하며, 회원 잠금 안에서 최초 유료 충전을 한 번 판정한다.
- 최초 여부와 IAP 여부를 저장된 결제 정보로 응답하고 기존 금액·통화 원천과 추적 호출 조건·위치를 유지한다. 추적의 FIRST/REPEAT 분류는 저장된 최초 판정값을 따른다.
제외
- 충전 생성 endpoint와 webhook의 HTTP 응답 모양 변경.
- 클라이언트 앱 변경, AppsFlyer 또는 Firebase SDK/server 연동, 이벤트 중복 제거, 첫 충전 보너스 복구.
- Apple/Google/PG 외부 가격 조회, 실제 청구 금액 재검증·가격 원천 변경, 새 transaction ID·timestamp 필드.
- Google
purchaseToken중복 방지/idempotency 수정, 범용 결제 보안 refactor, 그 밖의 상태·결제 경로 수정. - 운영 SQL 실행/배포 및 운영 조치. 승인된 애플리케이션 코드·테스트·SQL 파일 작성은 완료했으며 운영 적용은 포함하지 않는다.
3. 확정 요구사항과 추적
| ID | 상태 | 요구사항 및 수용 기준 | 계획 연결 |
|---|---|---|---|
| PAY-001 | 확정 | POST /charge/verify, /charge/verify/hecto, /charge/payverse/verify, /charge/apple/verify, /charge/google가 ApiResponse.data에 ChargeCompleteResponse를 반환한다. 생성 응답과 Payverse webhook 응답은 바뀌지 않는다. |
P1-T3, P1-T4 |
| PAY-002 | 확정 | charge.member.id와 인증 회원 ID가 다르면 완료 shortcut, provider 호출, 결제/회원 잔액 변경보다 먼저 can.charge.invalid_payment_info로 거부한다. processGoogleIap도 포함한다. Payverse webhook 서명 검증과 IP 인증은 유지한다. |
P1-T1, P1-T4 |
| PAY-003 | 확정 | Charge.isFirstCharged: Boolean? = null을 nullable로 저장한다. 요청에서 완료로 처음 전환될 때 회원 잠금 아래 한 번만 판정한다. 같은 회원의 다른 charge 중 다음 두 쌍만 유료 이력이다: (ChargeStatus.CHARGE, PaymentStatus.COMPLETE), (ChargeStatus.CANCEL, PaymentStatus.RETURN). 현재 charge는 제외하고 모든 PG/PAYVERSE/APPLE_IAP/GOOGLE_IAP를 포함한다. 결과 true는 다른 유료 이력이 없음을 뜻한다. 날짜와 ID 순서로 첫 충전을 정하지 않는다. 구현 승인 후 nullable 컬럼 추가 및 COMMENT를 포함한 SQL 파일을 작성하되, 과거 행은 NULL로 두고 backfill하지 않는다. Boolean?의 실제 MySQL 컬럼 타입 호환성은 rollout 전에 확인한다. |
P1-T2, P1-T4 |
| PAY-004 | 확정 | 완료 응답에 isIap: Boolean을 추가한다. GOOGLE_IAP와 APPLE_IAP이면 true, PG와 PAYVERSE이면 false다. 단말 OS와 무관하게 저장된 gateway만 사용한다. price, currencyCode의 타입·값 원천은 유지한다. |
P1-T3, P1-T4 |
| PAY-005 | 확정 | 소유권, 최초 판정·동시성·재시도·환불·webhook, 다섯 HTTP 응답 및 기존 추적 효과를 focused TDD와 통합 테스트로 검증한다. 모든 요구사항은 P1-GATE의 증거와 연결한다. | P1-T1~P1-T4, P1-GATE |
4. API 계약
공통 성공 envelope
ApiResponse의 기존 필드와 의미를 유지한다. 요청 DTO, 인증 방식, 기존 오류 의미와 HTTP status 정책은 변경하지 않는다.
{
"success": true,
"message": null,
"data": {
"price": 9.99,
"currencyCode": "USD",
"isFirstCharged": true,
"isIap": false
},
"errorProperty": null
}
data.price는 기존 BigDecimal의 JSON number 직렬화다. currencyCode는 기존 locale에서 가져오는 통화 문자열이다. isFirstCharged와 isIap은 non-null JSON boolean이다.
완료 경로
| Method / path | 완료 결과 | 변경 여부 |
|---|---|---|
POST /charge/verify |
ApiResponse<ChargeCompleteResponse> |
data만 Unit에서 완료 응답으로 변경 |
POST /charge/verify/hecto |
ApiResponse<ChargeCompleteResponse> |
동일 |
POST /charge/payverse/verify |
ApiResponse<ChargeCompleteResponse> |
동일 |
POST /charge/apple/verify |
ApiResponse<ChargeCompleteResponse> |
동일 |
POST /charge/google |
ApiResponse<ChargeCompleteResponse> |
동일 |
나머지 외부 필드, 요청 DTO, 인증 및 오류 계약은 현재와 같다. POST /charge, /charge/payverse, /charge/apple 생성 응답과 /charge/payverse/webhook의 응답은 그대로 둔다. 새 status code를 도입하지 않는다.
최초 판정 의미와 데이터 호환성
- 최초 fact는 최초
REQUEST -> COMPLETE전환 시Charge.isFirstCharged에 true 또는 false로 고정한다. 재시도, 이후 충전 및 환불은 기존 non-null 값을 덮어쓰지 않는다. - 과거 완료 charge의 null은 backfill하지 않는다. 응답에서는
charge.isFirstCharged == true인 경우에만 true를 내고, null은 false로 직렬화한다. 이 false는 “최초가 아님이 확인됨”이 아니라 “최초임이 확인되지 않음”을 뜻한다. - 최초 판정 이력 조회는 현재 처리 중인 charge를 제외한 동일 회원의 다른 유료 이력만 찾는다.
EVENT,COUPON,ADMIN,ADS등은 결제 gateway/상태가 유료처럼 보여도 제외한다.REFUND_CHARGE도 포함하지 않는다. - Payverse webhook 완료 writer도 동일한 최초 fact 판정을 수행한다. webhook은 charge 잠금 다음 회원 잠금을 획득하고, 회원 잠금 안에서 이력 판정과 상태·잔액 전환을 수행한다. 기존 charge -> member 잠금 순서를 유지한다.
- 스키마 변경은
charge.is_first_chargednullable boolean 의미의 단일 컬럼만 추가하고 컬럼COMMENT를 포함한 SQL 파일을 작성한다. 기존 행 값은 null로 두며 backfill하지 않는다.Boolean?의 실제 MySQL 컬럼 타입 호환성은 rollout 전에 확인한다. - 구현 시 컬럼 물리 타입은 Hibernate 5.6의 MySQL 기본 Boolean 매핑에 맞춘
BIT(1) NULL을 사용한다. 여섯 완료 writer의 이력 조회는READ_COMMITTED와 기존 회원 잠금을 조합한다. 이는 MySQL의 과거 snapshot 재사용 방지를 위한 국소 변경이며 운영 DB 실행 검증을 뜻하지 않는다. - 애플리케이션보다 스키마를 먼저 배포해야 한다. 구버전 writer와 신버전 writer가 동시에 동작하는 rolling deployment 중에는 구버전 writer가 최초 fact를 저장하지 않을 수 있으므로, writer 혼재 구간의 데이터 정확성을 보장한다고 간주하지 않는다. 배포 순서와 구 writer drain을 별도로 조정한다. 구체 운영 조치와 DDL 실행은 이 문서 작성 범위가 아니다.
5. 결제 금액 및 외부 의존
기존 price는 기록된 결제 metadata이지 검증된 실제 청구 가격의 보증이 아니다. Apple 생성 요청에서 price가 없으면 현재 경로는 0을 기록하고, locale이 없으면 응답 currency는 기존처럼 KRW로 fallback한다. 이 동작과 BigDecimal 타입을 바꾸지 않는다. 정확한 분석 금액은 확인된 원본 구매 또는 검증된 결제 데이터에서 가져와야 하며 이 작업은 그 원천을 결정하거나 조회하지 않는다.
AppsFlyer는 분석에 필요한 DATA를 논의한 맥락일 뿐이다. 서버가 AppsFlyer/Firebase로 이벤트를 보내거나 클라이언트 SDK·클라이언트를 변경하는 작업은 아니다. 클라이언트 호환성과 정확한 분석 금액 원천, 분석 이벤트 dedup은 외부에서 검증되지 않았고 구현 완료를 전제하지 않는다. 결제 완료 재호출이 partner tracking eligibility와 함께 기존 tracking 호출을 다시 수행할 수 있으므로 FIRST_PAYMENT 이벤트가 반복 기록될 수 있다. 새 dedup은 보장하지 않는다.
6. 구현 근거와 파일 경계
구현 전 코드는 ChargeService에서 완료 DTO를 만들고 ChargeController에서 그 결과를 tracking에 사용한 뒤 ApiResponse.ok(Unit)을 반환했다. 기존 ChargeRepository.isFirstCharged(memberId)는 CHARGE/COMPLETE 행 개수를 세었고 webhook은 COMPLETE 변경 후 member 잠금을 획득했다. 구현 후에는 소유권 검사, 저장형 최초 판정과 회원 잠금 안의 완료 처리, 확장 DTO 반환으로 변경했다.
주요 production 경로:
src/main/kotlin/kr/co/vividnext/sodalive/can/charge/Charge.ktsrc/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.ktsrc/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.ktsrc/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.ktsrc/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeCompleteResponse.ktsrc/main/kotlin/kr/co/vividnext/sodalive/can/payment/Payment.kt(PaymentStatus는 이 파일 안에 선언됨)src/main/kotlin/kr/co/vividnext/sodalive/can/payment/PaymentGateway.ktsrc/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeStatus.ktsrc/main/kotlin/kr/co/vividnext/sodalive/common/ApiResponse.ktsrc/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.ktsrc/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.ktsrc/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundService.kt
현재 확인한 관련 테스트 근거는 src/test/kotlin/kr/co/vividnext/sodalive/can/CanControllerIntegrationTest.kt, src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceTest.kt, src/test/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundServiceTest.kt다. 새 repository 통합 테스트와 charge service/controller 테스트의 정확한 경로는 plan에 적는다. 결제 생성·이벤트·관리자 환불 경로는 최초 판정의 범위와 무관한 기존 동작을 회귀 검증한다.
7. 수용 기준
- PAY-001: 실제 HTTP 직렬화에서 다섯 완료 endpoint의
data가 네 필드를 포함하고isFirstCharged와isIap은 JSON boolean이다. price9.99의 number 직렬화와 기존 currency 값이 보존된다. 생성 응답과 webhook shape는 변화가 없다. - PAY-002: 타 회원 소유의 pending 및 완료 charge 모두에서 같은 오류 키로 거부된다. provider 호출, 결제 상태, 잔액 및 event 처리 side effect가 없다. 본인 소유 완료 재호출은 저장된 최초 판정값을 반환하고 상태·잔액을 중복 변경하지 않는다.
- PAY-003: PG/PAYVERSE/APPLE_IAP/GOOGLE_IAP 완료 이력,
CANCEL + RETURN, legacy null, 현재 건 제외,EVENT + COMPLETE, replay, webhook-first, rollback을 검증했다. 기존 유료 이력 없는 회원의 동시 완료는 정확히 하나만 true, 이력이 있으면 둘 다 false이며 날짜/ID 순서에 의존하지 않는다.20261103_charge_first_charged_ddl.sql은 nullable 단일 BIT(1) 컬럼과 COMMENT만 포함하고 backfill하지 않는다. H2 및 정적 Hibernate 매핑 검증이며 운영 MySQL 적용·검증은 하지 않았다. - PAY-004: 네 gateway에 대해
isIap값이 각각 false/false/true/true이고 OS에 영향받지 않는다. 기존 price 및 currency sourcing, null -> false 응답 규칙을 유지한다. - PAY-005: 기존 charge coupon test와 관리자 환불 test를 포함한 직접 영향 회귀를 통과하고 모든 변경 요구가 P1-GATE에 추적된다.
완료 근거: 전체 339 suites / 3,112 tests, failures=0, errors=0, skipped=0. test build ktlintCheck exit 0 및 독립 리뷰 PASS. 실제 HTTP 56회 증거와 환경 한계는 plan-task.md의 최종 Gate 기록에 있다. 운영 배포 승인을 뜻하지 않는다.
8. 미검증 항목과 Decision Log
| ID | 상태 | 항목 | 처리 또는 재개 조건 |
|---|---|---|---|
| EXT-001 | 외부 의존 | 클라이언트가 새 data 응답을 허용하는지 |
클라이언트 owner가 호환성을 확인한다. 이 문서 작성의 차단 조건은 아니다. |
| EXT-002 | 외부 의존 | 분석에 사용할 정확한 실제 결제 금액 원천 및 이벤트 dedup | 분석/클라이언트 담당자가 데이터 원천과 dedup 요구를 별도 결정한다. 완료 사실로 간주하지 않는다. |
| OPS-001 | 배포 전 조건 | nullable 컬럼의 실제 MySQL 타입 호환성, schema-first 순서, 구 writer drain | SQL 파일 작성·정적 검토는 P1-T2에 포함한다. 운영/DB 담당자의 실제 DB 적용·운영 검증과 앱 배포는 별도 승인 대상이다. |
| EXT-003 | 범위 밖 관찰 | ChargeTempService.verify는 별도 PG 완료 writer이며 이번 대상 밖이다. |
운영 사용 여부를 확인해야 한다. 이번 동시성 검증은 지정된 다섯 API와 Payverse 웹훅 사이에 한정하며 temp 경로 동시 실행까지 보장하지 않는다. |
| 날짜 | ID | 상태 | 결정 | 근거 및 영향 |
|---|---|---|---|
| 2026-11-03 | DEC-001 | 확정 | 다섯 완료 경로만 응답 DTO를 노출하고 생성 및 webhook 응답은 유지한다. | PAY-001, P1-T3 |
| 2026-11-03 | DEC-002 | 확정 | 최초 여부는 nullable charge-level fact이며 이력 판정 후 재계산하지 않는다. legacy null은 보수적으로 응답 false로 매핑한다. | PAY-003, P1-T2 |
| 2026-11-03 | DEC-003 | 확정 | IAP 판정은 저장된 gateway만 사용하며 price/currency 의미를 확장하지 않는다. 분석 연동은 제외한다. | PAY-004, P1-T3 |
| 2026-11-03 | DEC-004 | 확정 | 이번 승인 범위는 새 요구·계획 문서 작성뿐이다. 코드·DDL·테스트 실행은 구현 승인을 기다린다. | 모든 요구사항, P1-T1 |
| 2026-11-03 | DEC-005 | 확정 | DEC-004의 문서 전용 승인 범위는 유지한다. 구현을 별도 승인한 뒤 nullable 컬럼 추가와 COMMENT를 담은 SQL 파일을 작성한다. SQL 적용·배포·운영 DB 확인은 별도 운영 승인 대상이며 현재 수행하지 않는다. | PAY-003, P1-T2, OPS-001 |
| 2026-11-03 | DEC-006 | 정정, 사용자 구현 승인 | “문서대로 진행” 및 ULW 요청에 따라 P1-T1부터 구현·격리 테스트·전체 회귀·HTTP QA를 수행한다. SQL 파일 작성은 포함하며 운영 DB 적용·실결제·배포·커밋·푸시는 별도 승인 경계를 유지한다. | PAY-001~PAY-005, P1-GATE |
| 2026-11-03 | DEC-007 | 구현 세부 결정 | 동시 판정의 최신 이력 가시성 확보를 위해 여섯 완료 writer만 READ_COMMITTED로 설정하고, Hibernate MySQL dialect에 맞춰 DDL을 BIT(1)로 작성한다. 추가 temp writer는 범위 밖 관찰로 기록하며 임의 확장하지 않는다. | PAY-003, OPS-001, EXT-003 |
9. 계획 추적
| 요구사항 | 자동 검증 예정 | 계획 Goal |
|---|---|---|
| PAY-001 | ChargeControllerIntegrationTest의 다섯 실제 직렬화 assertion |
P1-T3, P1-T4, P1-GATE |
| PAY-002 | ChargeServiceIntegrationTest 소유권 및 부수효과 assertions |
P1-T1, P1-T4, P1-GATE |
| PAY-003 | ChargeRepositoryTest, ChargeServiceIntegrationTest 이력·동시성·재시도 assertions 및 SQL 산출물의 정적·매핑 정합성 검토 |
P1-T2, P1-T4, P1-GATE |
| PAY-004 | ChargeControllerIntegrationTest gateway mapping 및 price/currency assertions |
P1-T3, P1-T4, P1-GATE |
| PAY-005 | focused, charge/event/admin 영향 회귀 | P1-T1~P1-T4, P1-GATE |