Test #466
@@ -0,0 +1,314 @@
|
||||
# 결제 완료 응답 확장 구현 계획
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현·테스트·리뷰 완료, 미배포 |
|
||||
| 작성일 | 2026-11-03 (KST) |
|
||||
| 요구사항 기준 | [prd.md](prd.md) |
|
||||
| API 기준 | `prd.md` §4 API 계약 |
|
||||
| 현재 Phase | Phase 1: 결제 완료 정보의 안전한 판정과 전달 |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
| 다음 Goal | 없음; 운영 적용·클라이언트 검증은 별도 |
|
||||
|
||||
## 목표
|
||||
|
||||
다섯 인증된 결제 완료 요청이 소유권을 확인하고, 보존되는 최초 유료 충전 사실과 gateway 기반 IAP 여부를 `ApiResponse.data`로 반환한다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 구현 및 Gate 완료 | 4/4 | 없음 | 운영 DB rollout은 별도 승인 |
|
||||
|
||||
- 사용자 “문서대로 진행” 승인으로 P1-T1부터 실행한다. 완료 증거가 있는 Task만 체크하며 운영 DB 적용·실결제·배포는 하지 않는다.
|
||||
- `P1-T1 -> P1-T2 -> P1-T3 -> P1-T4 -> P1-GATE` 순서로 진행한다. 특히 `ChargeService` 변경은 한 번에 하나의 goal만 실행한다.
|
||||
- `P1-GATE`는 구현 task가 모두 끝난 뒤 read-only 최종 확인이다. 검증 명령은 구현 승인 이후 실행하며, 전체 회귀의 필요성은 프로젝트 가이드의 조건으로 판단한다.
|
||||
|
||||
## 범위와 기술 제약
|
||||
|
||||
### 포함
|
||||
|
||||
- PRD의 PAY-001~PAY-005, 기존 tracking 호출 조건·위치 유지 및 최초 판정 분류 영향 검증.
|
||||
- 신규 nullable `charge.is_first_charged` 컬럼 한 개를 사용하는 엔티티 매핑과 최초 fact 기록.
|
||||
- TDD 기반 repository, service, 실제 controller HTTP serialization 통합 검증.
|
||||
- Payverse webhook의 같은 최초 판정과 charge -> member lock 순서 보존.
|
||||
- 구현 승인 후 Task 1.2에서 컬럼 추가 SQL 파일 작성 및 정적·엔티티 매핑 정합성 검토. 실제 DB 적용은 별도 운영 승인 대상이다.
|
||||
|
||||
### 제외
|
||||
|
||||
- 운영 DDL 실행, 배포 및 운영 DB 접속. 승인된 Kotlin·테스트·SQL 파일 작성은 완료했으며 운영 적용은 포함하지 않는다.
|
||||
- 별도 contract/review 파일 생성은 계획에 포함하지 않는다. Task 1.2에는 구현 승인 후 SQL 파일 작성이 포함되며, SQL 적용·배포·운영 DB 확인은 별도 운영 승인 대상이다.
|
||||
- transaction ID, timestamp, Payment 쪽 중복 flag, `isIap` 저장 필드, 과거 최초 fact backfill.
|
||||
- AppsFlyer/Firebase 전송·SDK, client 변경, 실제 가격 조회, 분석 이벤트 dedup, first-charge bonus 복구.
|
||||
- Google purchaseToken dedup/idempotency와 포괄 결제 리팩터링.
|
||||
|
||||
### 제약
|
||||
|
||||
- Kotlin/Spring 및 현재 `Charge`, `Payment`, `ApiResponse` 계약을 따른다. 기존 요청·인증·오류 semantics를 바꾸지 않는다.
|
||||
- `PaymentStatus`는 `can/payment/Payment.kt` 내부 enum이다. `PaymentStatus.kt`는 존재하지 않는다.
|
||||
- `Charge.isFirstCharged: Boolean? = null`, DB 컬럼 이름 `is_first_charged`를 사용한다. 실제 MySQL boolean 호환 타입을 rollout 전 검증한다.
|
||||
- Task 1.2에서 `docs/20261103_결제완료응답확장/20261103_charge_first_charged_ddl.sql`을 작성한다. nullable 단일 컬럼과 컬럼 comment만 포함하고 backfill하지 않는다. 실제 MySQL 타입 호환성은 rollout 전에 확인하며, SQL 실행은 별도 운영 승인 없이는 하지 않는다. 프로젝트 migration runner를 추측하지 않는다.
|
||||
- 우선 schema를 nullable 컬럼으로 배포한 뒤 모든 구 writer가 drain된 상태에서 신 writer를 운영한다. rolling 혼재 중 과거 writer는 최초 fact를 기록하지 않는다는 한계를 확인한다. 각 배포 작업은 본 계획 범위가 아니며 별도 승인·운영 확인이 필요하다.
|
||||
- 초기 문서 전용 단계에서는 코드 명령을 실행하지 않았다. 이후 사용자 구현·ULW 승인에 따라 focused test -> 직접 영향 회귀 -> 전체 test/build/lint를 수행했다.
|
||||
|
||||
### Phase 1: 결제 완료 사실과 응답 전달
|
||||
|
||||
**Phase 결과:** 소유자만 유효한 결제를 완료할 수 있고 최초 유료 충전 사실이 저장되며, 다섯 완료 endpoint가 확정된 응답 DTO를 실제 JSON으로 노출한다.
|
||||
|
||||
**선행조건:** 이 PRD와 계획 문서 검토 완료, 구현 시작에 대한 별도 명시 승인. 배포 전 MySQL 타입·schema-first 조건은 OPS-001을 별도로 확인한다.
|
||||
|
||||
**Phase 완료 조건:** P1-T1~P1-T4 구현 및 focused/영향 회귀 증거 완료 후 P1-GATE read-only 검증 통과. 모든 체크박스와 Progress는 실제 구현·명령 실행 이후에만 갱신한다.
|
||||
|
||||
- [x] **Task 1.1: 충전 소유권 검증**
|
||||
|
||||
**Goal 실행 `P1-T1`:** 완료 shortcut 및 결제·잔액 변경 전에 요청 회원이 대상 charge의 소유자인지 검증한다.
|
||||
|
||||
- **시작 조건:** 별도 명시적 구현 승인, PRD PAY-002 검토.
|
||||
- **완료 증거:** 다른 회원의 REQUEST 및 COMPLETE charge가 `can.charge.invalid_payment_info`로 거부되고 provider·상태·잔액·event side effect가 없음을 통합 검증. 본인 경로와 coupon 회귀 확인.
|
||||
- **범위 밖:** 인증 방식, 오류 status/key 변경, webhook 인증 우회, 범용 보안 refactor.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceIntegrationTest.kt` (신규, `@SpringBootTest`, 필요한 외부 provider는 bounded test double)
|
||||
- Regression: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: 기존 verify service arguments `memberId: Long`, order/charge ID; 기존 `MemberRepository.findByIdForUpdate(memberId)`와 charge lock.
|
||||
- Produces: 소유권 위반을 기존 `SodaException(messageKey = "can.charge.invalid_payment_info")`로 일관되게 거부하는 service 동작. `verify`, `verifyHecto`, `payverseVerify`, `appleVerify`, `processGoogleIap` 포함.
|
||||
|
||||
- [x] **RED:** `shouldRejectForeignPendingChargeBeforeProviderOrMutation`, `shouldRejectForeignCompletedChargeBeforeShortcut` 통합 테스트를 추가한다. 해당 member와 다른 member 소유의 REQUEST/COMPLETE charge를 만들고 오류 key, provider 호출 0회, 상태·잔액·event 미변경을 assert한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceIntegrationTest.shouldRejectForeignPendingChargeBeforeProviderOrMutation' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceIntegrationTest.shouldRejectForeignCompletedChargeBeforeShortcut'`를 실행해 ownership assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** 다섯 service 경로에서 완료 shortcut, 외부 검증 호출, 결제·잔액 기록보다 먼저 charge owner ID와 요청 `memberId`를 비교한다. 기존 오류 key를 유지한다.
|
||||
- [x] **GREEN 확인:** 위 focused 명령과 `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceTest'`가 성공하고 side effect assertion이 모두 통과함을 확인한다.
|
||||
- [x] **REFACTOR:** 이번 검증에서 생성한 중복만 정리한다. 두 focused 테스트와 `ChargeServiceTest`를 재실행하고 실제 명령·결과를 Progress에 기록한다.
|
||||
|
||||
**P1-T1 Progress, 2026-11-03 KST: 구현·검증 및 독립 리뷰 완료**
|
||||
|
||||
- 무엇/왜: 다섯 charge 잠금 조회 직후 `charge.member?.id != memberId`를 검사해 회원 잠금, 완료 shortcut 및 provider 접근보다 먼저 기존 오류 key로 거부한다. `isFirstCharged`, DTO, webhook, 가격·스키마는 변경하지 않았다.
|
||||
- 테스트: 실제 Spring service transaction proxy와 H2 저장소를 사용한다. fixture를 별도 transaction으로 commit하고 서비스 호출 뒤 새 transaction에서 상태·결제 metadata·양 회원의 6종 잔액·charge/event job 수를 비교한다. REQUEST 5건, COMPLETE 5건, 본인 완료 replay 5건이다.
|
||||
- 네트워크 차단: OkHttp/Google은 호출 시 AssertionError를 던지는 대역이다. 직접 생성하던 Bootpay 두 곳은 동일 인자로 생성하는 `internal createBootpay` 하나만 추출해 서비스 spy에서 차단 대역을 주입했다. 실제 PG gateway REQUEST로 RED를 입증했으며 provider 및 event service 호출 0회를 GREEN에서 검증한다. 저장소와 transaction manager는 대역이 아니다.
|
||||
- 실제 실행 환경: 모든 명령 앞에 `export TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/payment-ulw.7P2uSv/implement.gayCgD NODE_COMPILE_CACHE=$TMPDIR/node-compile-cache BUN_RUNTIME_TRANSPILER_CACHE_PATH=$TMPDIR/bun-transpiler`를 실행하고 Gradle에 `--offline --console=plain`을 더했다. 아래 로그 경로는 이 TMPDIR 기준이다.
|
||||
- RED: 위 두 method focused 명령은 exit 1, 10건 중 10건 실패(`t1-red.log`). COMPLETE 5건은 SodaException 미발생, REQUEST 5건은 차단 provider 접근 및 호출 0회 assertion 위반이다. 먼저 수행한 COMPLETE-only RED도 exit 1, 5/5 실패(`t1-red-completed.log`)다. 초기 timeout 및 Mockito matcher 설정 오류는 RED 증거에서 제외했다.
|
||||
- GREEN: 동일 focused 명령은 exit 0, 10/10 성공(`t1-green-focused.log`). 초기 lint의 신규 테스트 argument 배치 3건만 수정했다.
|
||||
- 회귀 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceIntegrationTest' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceTest' --tests 'kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest' --tests 'kr.co.vividnext.sodalive.can.coupon.CanCouponServiceIntegrationTest' --offline --console=plain`은 exit 0, 27/27 성공(각 15/4/7/1, skip 0, failure/error 0; `t1-green-regression.log`, `t1-green-test-counts.txt`). 기존 테스트 파일은 수정하지 않았다.
|
||||
- 정적 검증: `./gradlew ktlintCheck --offline --console=plain` exit 0(`t1-ktlint-green.log`). focused/회귀 실행에서 Kotlin production/test compile도 성공했다. LSP 도구는 제공되지 않아 실행하지 않았다.
|
||||
- 한계/관찰: H2 + opt-in embedded Redis만 사용했으며 운영 DB·외부 결제·HTTP QA는 실행하지 않았다. 기존 coupon 통합 테스트에서도 보이는 background scheduler의 H2 `FOR UPDATE SKIP LOCKED` SQL 오류 로그와 기존 Gradle deprecation 경고는 범위 밖으로 남긴다. 테스트 결과 실패는 없다. 전체 suite/build/실제 HTTP 최종 검증은 부모의 이후 P1-GATE 범위다.
|
||||
- 인계: 상세 보고서는 위 TMPDIR의 `t1-report.md`다. Task 1.1 최종 체크와 다음 Goal 이동은 부모 리뷰 이후 처리하며 P1-T2는 시작하지 않았다.
|
||||
|
||||
- [x] **Task 1.2: 저장형 최초 유료 충전 fact**
|
||||
|
||||
**Goal 실행 `P1-T2`:** 현재 charge를 제외한 동일 회원의 다른 유료 이력을 조회해 최초 값을 REQUEST -> COMPLETE 시 한 번 저장하고 webhook에서도 같은 규칙으로 기록한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료, schema-first 배포 및 MySQL nullable 타입 확인 절차가 문서에 명시됨. 실제 schema 배포는 이 Task 승인 범위 밖이다.
|
||||
- **완료 증거:** history truth table, `flush/clear/reload`, rollback, 동시 완료 테스트와 웹훅-first 후 verify 재조회가 PRD PAY-003을 충족한다. 승인된 구현 작업에서 SQL 파일을 작성하고 정적 검사와 내용·타입 정합성 검토를 완료한다. 이는 MySQL 실행이나 런타임 스키마 증거가 아니다.
|
||||
- **범위 밖:** 기존 완료 null 값 backfill, Payment 중복 flag, timestamp/ID 순서 기반 우선권, SQL 실행·배포.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/Charge.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt`
|
||||
- Create: `docs/20261103_결제완료응답확장/20261103_charge_first_charged_ddl.sql`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepositoryTest.kt` (신규, `@DataJpaTest(properties = ["spring.cache.type=none"])`, 전역 Redis initializer 금지)
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceIntegrationTest.kt`
|
||||
- Reference only: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundService.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `P1-T1`의 ownership guard; 기존 charge lock 다음 member lock 규칙.
|
||||
- Produces: `Charge.isFirstCharged: Boolean? = null`; repository 존재 조회 `existsSuccessfulPaidCharge(memberId: Long, excludedChargeId: Long): Boolean`; 기존 완료 DTO의 `isFirstCharged`에는 `charge.isFirstCharged == true`를 매핑한다. 위 경로의 SQL 파일에는 `charge.is_first_charged` nullable 단일 컬럼 ADD와 `COMMENT`만 작성한다. 기존 조회 제거는 이 변경으로 orphan이 확인된 경우에만 하며 다른 count consumer는 보존한다.
|
||||
|
||||
**P1-T2 사전 기술 판정:**
|
||||
|
||||
- Ruling: 여섯 완료 writer(`verify`, `verifyHecto`, `payverseVerify`, `appleVerify`, `processGoogleIap`, `payverseWebhook`)에만 `READ_COMMITTED`를 적용한다. MySQL REPEATABLE READ에서 member 잠금 전 lazy payment 조회가 만든 오래된 snapshot을 이후 이력 조회가 재사용하는 위험을 없앤다. 이력 행 추가 잠금은 charge -> member 순서와 충돌할 수 있어 사용하지 않는다. 기존 controller는 외부 transaction 없이 서비스 proxy를 호출한다. 전역 isolation·생성·쿠폰·환불 경계는 변경하지 않는다.
|
||||
- Ruling: SQL 컬럼은 Hibernate 5.6.15 `MySQL8Dialect`의 기본 Boolean 매핑과 일치하는 `BIT(1) NULL`로 작성한다. 개념 예시의 `BOOLEAN`(MySQL TINYINT 별칭) 대신 매핑에 맞춘다. `@Column(name = "is_first_charged")`를 명시하고 변환기·전역 dialect 변경은 하지 않는다. 정적 dialect 근거이며 운영 MySQL 적용 증거는 아니다.
|
||||
- 범위 관찰: `can/charge/temp/ChargeTempService.verify`도 PG 완료를 기록하지만 이번 승인된 다섯 API 및 웹훅 밖이다. 그 경로의 운영 사용·동시 실행은 미확인이고 수정하지 않는다. 동시성 보장은 이번 여섯 writer 사이에 한정하며 temp 경로까지 전역 보장이라고 표현하지 않는다.
|
||||
|
||||
- [x] **RED:** repository `shouldFindOnlyOtherSameMemberPaidChargeOrReturnedCancellation`에 네 gateway, `(CHARGE, COMPLETE)`, `(CANCEL, RETURN)`, current-charge exclusion 및 `EVENT + COMPLETE` 제외 케이스를 둔다. service `shouldPersistFirstFactOnlyForFirstPaidCompletion`, `shouldPreserveLegacyNullOnReplayAndPersistFactAcrossRefund`, `shouldPersistFactWhenPayverseWebhookCompletesFirst`, `shouldSetExactlyOneFirstFactForConcurrentCharges`를 추가한다. 과거 완료 null은 재생에서도 null 유지 후 응답 false여야 한다. 동시성 test는 분리된 committed transaction, latch, timeout을 사용하고 sleep이나 mock-only lock 주장으로 대체하지 않는다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeRepositoryTest' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceIntegrationTest.shouldPersistFirstFactOnlyForFirstPaidCompletion'` 실행 결과에서 의도한 mapping/query assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** nullable `is_first_charged`를 매핑하고 다른 charge의 이력에 대한 exists 판정을 구현한다. 현재 charge를 제외하고 gateway 4종 전체에서 두 qualifying pair만 포함한다. verify writers와 Payverse webhook은 charge 잠금 후 member 잠금 내부에서 최초 판정과 최초 상태/잔액 전환을 수행하며 실패·rollback이면 marker도 함께 rollback한다. `completeResponse`와 Payverse 완료 DTO의 `isFirstCharged`는 저장값 `== true`로 매핑한다. webhook signature check와 charge -> member lock order를 유지한다. 비활성화된 오래된 isFirstCharged 조회는 실제 소비처가 없을 때만 정리한다.
|
||||
- [x] **GREEN 확인:** 위 focused 명령과 다음 history assertions를 통과시킨다: mixed gateways, legacy null, true/false 저장 후 flush/clear/reload, CANCEL+RETURN, 이벤트·쿠폰·admin·ads 제외, out-of-order 생성일, 기존 유료 이력 없는 회원의 두 동시 완료 중 정확히 한 true, 기존 유료 이력이 있으면 둘 다 false, 후속 구매·환불 후 기존 marker 불변, webhook-first replay. 실패 transaction에는 marker 없음.
|
||||
- [x] **REFACTOR:** 새 query와 first-transition 처리를 최소로 정리한다. `ChargeRepositoryTest`, `ChargeServiceIntegrationTest`의 이력·동시성 테스트와 `AdminChargeRefundServiceTest`를 재실행하고 H2로 입증된 범위와 MySQL 미검증 범위를 구분해 Progress에 기록한다.
|
||||
- [x] **SQL 파일 작성 및 정적 검토:** `docs/20261103_결제완료응답확장/20261103_charge_first_charged_ddl.sql`에는 nullable `ADD COLUMN`과 컬럼 `COMMENT`만 작성한다. `DEFAULT FALSE`, backfill, `UPDATE`, `DROP`은 포함하지 않으며 entity column name과 type 의미의 정합성을 검토한다. `Boolean?`의 실제 MySQL type은 확인 전까지 확정하지 않고, 파일 정적 검사와 별도로 사람 검토를 기록한다.
|
||||
- [x] **SQL 정적 검사:** 미래 구현 시 `GIT_MASTER=1 git diff --no-index --check -- /dev/null docs/20261103_결제완료응답확장/20261103_charge_first_charged_ddl.sql`을 실행한다. 이 검사는 공백 오류만 확인하며 SQL 실행, MySQL 타입 호환성 또는 런타임 스키마를 증명하지 않는다. 해당 DDL의 실제 적용과 운영/공유 DB 검증은 별도 운영 승인 대상이다. 격리된 테스트 DB를 사용하는 기존 통합 테스트는 승인된 구현·테스트 범위에서 수행하되 운영 검증과 구분한다.
|
||||
|
||||
**P1-T2 Progress, 2026-11-03 KST: 구현·focused 검증 및 독립 리뷰 완료**
|
||||
|
||||
- 무엇/왜: nullable `Charge.isFirstCharged`를 명시 컬럼에 매핑하고, 회원 잠금 아래 최초 REQUEST 완료 시에만 기록한다. 기존 최초 count 조회만 scalar exists로 교체했으며 다른 count 조회는 그대로다. 여섯 완료 writer에만 `READ_COMMITTED`를 적용하고 webhook도 member 잠금 뒤에 상태를 변경한다. 응답은 저장값 `== true`이며 Payverse도 기존 `completeResponse`를 재사용한다.
|
||||
- RED 실제 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeRepositoryTest' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceIntegrationTest.shouldPersistFirstFactOnlyForFirstPaidCompletion' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceIntegrationTest.shouldReplayOwnCompletedChargeWithoutMutation' --offline --console=plain`은 exit 1, 11건 실패다. 유효한 RED는 mapping 부재 1건, 성공 완료 뒤 persistent attribute 부재 4건, legacy 완료 null 응답이 false 대신 true인 5건으로 총 10건이다. PG 1건은 provider double의 BigDecimal scale 불일치로 인한 fixture 실패이므로 RED 증거에서 제외하고 DB 가격 scale에 맞췄다. query truth matrix 자체의 RED 실행을 주장하지 않는다.
|
||||
- 최종 GREEN/lint 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeRepositoryTest' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceIntegrationTest' --tests 'kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest' ktlintCheck --continue --offline --console=plain`은 exit 0, 169/169 성공(repository 110, service integration 52, refund 7; skip/failure/error 0), `ktlintCheck` 성공이다. production/test Kotlin compile도 통과했다. 중간 신규 fixture nickname 누락과 lint argument 배치 오류는 수정했으며 RED로 세지 않는다.
|
||||
- 검증 범위: repository의 105개 상태/gateway 조합, nullable 3종 flush/clear/reload, current/다른 회원 제외 및 역순 생성일을 검증했다. 실제 service proxy를 통해 다섯 성공 경로, legacy null, mixed gateway, 완료/환불 이력, 후속 구매 replay, 실제 관리자 환불, PG/Hecto/Apple RETURN 재검증의 null/true/false 보존, webhook-first와 실패 서명, 여섯 writer의 flush 뒤 rollback을 검증했다.
|
||||
- 동시성: fixture만 `TransactionTemplate`으로 commit한다. 서로 다른 두 service 호출은 각자의 실제 proxy transaction 안에서 service spy의 prelude가 실제 charge 잠금과 lazy payment read를 수행한 뒤 latch로 합류하고 real method를 실행한다. 외부 worker transaction에 service를 합류시키거나 transaction manager/repository를 mock하지 않는다. 기존 이력 없음/있음에서 true 개수 1/0, 두 완료와 잔액을 확인했다. 동일 charge 동시 replay도 한 번만 지급한다. await/get/종료에 timeout을 두고 sleep은 없다. provider 대역 및 webhook event 호출 내부의 `TransactionSynchronizationManager`로 실제 `READ_COMMITTED`도 확인했다.
|
||||
- SQL: 두 줄의 nullable `BIT(1) NULL COMMENT` ADD만 작성했다. default/backfill/update/drop은 없다. `shouldMapFirstFactAsPersistentAttribute`는 엔티티 컬럼 이름·nullable 및 로컬 Hibernate 5.6 `BooleanType.INSTANCE.sqlType()`에 대한 `MySQL8Dialect.getTypeName(...) == "bit"`를 확인했다. 위 SQL 전용 `git diff --no-index --check`와 `git diff --check`는 exit 0이다. 파일 내용의 작성자 자체 정적 검토이며 사람/독립 승인이나 운영 MySQL 타입·DDL 실행 증거는 아니다.
|
||||
- 환경/한계: 모든 Gradle 실행은 `$TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/payment-ulw.7P2uSv/first-marker.k2Y93W` 및 그 하위 Node/Bun cache, `JAVA_TOOL_OPTIONS=-Djava.io.tmpdir=$TMPDIR`를 사용했다. 실제 provider/운영 DB 접속은 없다. H2 결과는 MySQL snapshot/lock 검증이 아니며 `ChargeTempService.verify`는 승인 범위 밖으로 보존했다. 기존 H2 VALUE 테이블 DDL 경고, scheduler의 `SKIP LOCKED` SQL 오류 로그, Gradle deprecation은 테스트 실패 없이 남아 있다.
|
||||
- 인계: 위 TMPDIR의 `t2-report.md`, `t2-red.log`, `t2-green-committed-proxy.log`가 상세 증거다. LSP 도구는 없어 실행하지 않았고 compile/lint로 대체했다. 전체 suite/build, HTTP/DTO/isIap, T3 및 운영 rollout은 실행하지 않았다. 이 Task 최상위 체크와 다음 Goal 이동은 부모 리뷰 뒤 처리한다.
|
||||
|
||||
- [x] **Task 1.3: 완료 응답과 gateway 표식**
|
||||
|
||||
**Goal 실행 `P1-T3`:** 서비스 완료 DTO와 기존 tracking 결과를 다섯 HTTP 완료 응답으로 전달하고 저장된 gateway로 IAP를 표시한다.
|
||||
|
||||
- **시작 조건:** `P1-T2` 완료. 이 Task와 다음 service-integrated Task는 순차 실행한다.
|
||||
- **완료 증거:** controller 통합 HTTP serialization에서 다섯 경로의 공통 envelope·응답 필드와 생성/webhook의 불변 shape를 검증한다.
|
||||
- **범위 밖:** 새로운 요청/응답 외 필드, 실결제 연동, 가격 원천 수정, AppsFlyer/Firebase 전송과 dedup.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeCompleteResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeControllerIntegrationTest.kt` (신규, `@SpringBootTest`, 외부 provider는 bounded test double)
|
||||
- Regression: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceIntegrationTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `P1-T2`가 저장한 `Charge.isFirstCharged`와 기존 `ChargeCompleteResponse(price, currencyCode, isFirstCharged)` 작성 경로.
|
||||
- Produces: `ChargeCompleteResponse(price: BigDecimal, currencyCode: String, isFirstCharged: Boolean, isIap: Boolean)`; controller 반환의 `ApiResponse.data`가 이 response다. IAP는 `paymentGateway == GOOGLE_IAP || paymentGateway == APPLE_IAP`로만 도출한다.
|
||||
|
||||
- [x] **RED:** `shouldReturnChargeCompleteDataForAllFiveVerificationEndpoints`, `shouldSerializeCompletionFlagsAndPreservePriceAndCurrency`, `shouldKeepCreationAndWebhookResponseShapes` 실제 HTTP 통합 테스트를 추가한다. `price=9.99` JSON number와 통화 값을 정확히 비교하고 모든 gateway 값, `isFirstCharged` true/false/null, 응답 key 이름·non-null boolean 및 envelope 4 key를 검증한다. 생성 3개와 webhook은 기존 응답 shape를 고정한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeControllerIntegrationTest.shouldReturnChargeCompleteDataForAllFiveVerificationEndpoints'`를 실행해 `data`가 Unit/null인 현재 계약으로 실패하는지 확인한다.
|
||||
- [x] **GREEN:** DTO에 Boolean `isIap`을 더하고 각 완료 응답의 stored gateway에서 계산한다. 다섯 controller 경로에서 기존 service 결과를 tracking에 전달한 뒤 `ApiResponse.ok(response)`로 반환한다. creation·webhook 응답과 기존 price/currency 계산은 변경하지 않는다.
|
||||
- [x] **GREEN 확인:** 세 HTTP test를 실행해 다섯 endpoint 모두 `data.price`, `currencyCode`, `isFirstCharged`, `isIap`이 있고 boolean이 JSON boolean임을 확인한다. 생성/webhook shape가 보존된다.
|
||||
- [x] **REFACTOR:** 이번 변경으로 생긴 중복만 다듬고 controller 및 service integration focused test와 `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceTest'`를 재실행한다. partner eligibility에서 기존 tracking 호출 위치를 유지하며 legacy null은 REPEAT_PAYMENT로 처리되고, 저장된 true를 반환하는 replay는 기존처럼 FIRST_PAYMENT가 재기록될 수 있음을 확인한다. dedup이나 bonus 복원은 추가하지 않는다.
|
||||
|
||||
**P1-T3 Progress, 2026-11-03 KST: 구현·HTTP/focused 검증 및 독립 리뷰 완료**
|
||||
|
||||
- 무엇/왜: 기존 완료 DTO에 non-null `isIap: Boolean`만 추가하고 `completeResponse`에서 저장된 Google/Apple gateway 여부로 계산한다. 다섯 controller 완료 경로는 기존 tracking 호출 직후 `ApiResponse.ok(response)`를 반환한다. 금액·통화 원천, 최초 marker/소유권 로직, 생성 3개 및 webhook 구현은 변경하지 않았다.
|
||||
- HTTP 테스트: 준비된 초안을 `ChargeControllerIntegrationTest`로 추가했다. `@SpringBootTest(RANDOM_PORT)`와 `TestRestTemplate`, 실제 JWT·Redis 토큰 저장소·서비스 transaction proxy·H2 repository를 사용한다. Google은 실제 신규 REQUEST 생성부터 완료까지 실행하고 다른 네 endpoint는 저장된 완료 fixture/replay를 사용한다. 외부 PG/OkHttp/Google만 차단 대역으로 대체하며, tracking 호출 관찰용 대역과 background worker 대역은 초안대로 유지한다. 서비스 결과나 transaction/repository는 mock하지 않는다.
|
||||
- RED 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeControllerIntegrationTest.shouldReturnChargeCompleteDataForAllFiveVerificationEndpoints' --offline --console=plain`은 exit 1, 5/5 실패다. 다섯 경로 모두 HTTP 200 `data:{}`이며 `expected: <[price, currencyCode, isFirstCharged, isIap]> but was: <[]>` assertion으로 실패했다. compile·인증·fixture 설정 실패가 아닌 실제 응답 누락이다(`t3-red.log`, `t3-red-http-evidence.log`).
|
||||
- 최종 GREEN 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceIntegrationTest' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceTest' ktlintCheck --offline --console=plain`은 exit 0, 78/78 성공(HTTP 22, service integration 52, service unit 4; skip/failure/error 0), `ktlintCheck` 성공이다(`t3-verified.log`). production/test compile도 성공했으며 마지막 실행의 production compile은 UP-TO-DATE다. 중간 두 combined 실행은 테스트 78/78 통과 후 신규 테스트 줄바꿈 lint만 실패했고, 포맷 수정 후 lint-only exit 0 및 위 최종 명령 exit 0을 확인했다. 기존 로그는 보존했다.
|
||||
- 실제 계약 증거: HTTP 요청 56회에서 envelope 4 key와 완료 data 4 key, JSON number `9.9900`의 정확한 9.99 값 및 USD, non-null JSON boolean, 네 gateway의 false/false/true/true를 확인했다. 저장된 true/false/null 재조회 2회에서 상태·잔액·metadata 불변과 FIRST/REPEAT tracking 전달을 확인했다. 생성 shape는 `chargeId` 또는 `chargeId + payloadJson`이며 webhook은 `{"receiveResult":"SUCCESS"}`다. 타인 소유 REQUEST/COMPLETE, 다섯 익명 요청 401, 잘못된 webhook IP/서명 404 및 정상 서명 완료·replay·verify도 통과했다. 생성/webhook 검증은 각각 `shouldKeepCreationResponseShapes`, `shouldKeepSignedWebhookResponseShapeAndRejectInvalidIpOrSignature`로 분리했다.
|
||||
- 환경·정리: 모든 명령은 `TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/payment-ulw.7P2uSv/http-response.f24ToP`, 그 하위 Node/Bun cache 및 `JAVA_TOOL_OPTIONS=-Djava.io.tmpdir=$TMPDIR`를 사용했다. 최종 test JVM/Tomcat PID 22172(HTTP 61240), embedded Redis PID 22191(127.0.0.1:61237)은 종료 후 `ps`에서 없고 두 port의 LISTEN도 없다. 운영 DB·외부 provider 접속은 없다. LSP 미제공으로 compile/lint로 대체했으며 전체 suite/build/최종 리뷰는 명시된 부모 범위로 실행하지 않았다. H2 결과는 운영 MySQL 검증이 아니다. 기존 service-test scheduler의 H2 `SKIP LOCKED` 오류, Gradle 경고 및 HTTP context 종료 시 Lettuce thread 경고는 테스트 실패나 잔류 PID 없이 남아 있다.
|
||||
- 인계: 위 TMPDIR의 `t3-report.md`와 `t3-green-http-counts-evidence.log`에 명령·결과·HTTP 원문·정리 증거를 기록했다. P1-T3 내부 체크만 갱신하며 Task 최상위 체크, 다음 Goal 이동 및 T4/GATE는 부모 리뷰 뒤 처리한다.
|
||||
|
||||
- [x] **Task 1.4: 결제 완료 통합 회귀 잠금**
|
||||
|
||||
**Goal 실행 `P1-T4`:** 사용자 가시 계약과 writer 동시성·재시도·환불 불변식을 전체 영향 테스트 조합으로 고정한다.
|
||||
|
||||
- **시작 조건:** `P1-T3` 완료.
|
||||
- **완료 증거:** 실제 저장/HTTP 응답, webhook-first replay, 소유권 실패 부수효과 및 동시 완료 테스트가 통과하고 직접 영향 feature 회귀 명령이 결과와 함께 기록된다.
|
||||
- **범위 밖:** 테스트 편의를 위해 provider에 실요청, H2 동시성 결과를 MySQL 증명으로 주장, 미승인 배포·테스트 전체 suite.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepositoryTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceIntegrationTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeControllerIntegrationTest.kt`
|
||||
- Regression: `src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceTest.kt`
|
||||
- Regression: `src/test/kotlin/kr/co/vividnext/sodalive/admin/charge/AdminChargeRefundServiceTest.kt`
|
||||
- Regression: charge event tests under `src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/` (저장소 실제 class 이름에 맞는 기존 테스트만 선택)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: 이전 task가 추가한 repository exists 계약, persisted nullable fact, 완료 response 및 HTTP 계약.
|
||||
- Produces: `P1-GATE`에서 다시 실행할 focused test 명령 집합과 제한·외부 검증 기록. production interface 추가 없음.
|
||||
|
||||
- **TDD 예외 사유:** 앞선 Task에서 구현·RED/GREEN 검증한 동작의 회귀 보강이다. 이미 통과하는 추가 assertion을 위해 인위적인 production 실패를 만들지 않는다.
|
||||
- **대체 검증 방법:** 기존 RED/GREEN 증거와 새 회귀 assertion을 대조하고, 누락 동작을 발견한 경우 해당 실패를 기록한 뒤 범위 내 후속 회귀 수정 Goal로 처리한다.
|
||||
|
||||
- [x] **RED 대조:** PAY-001~PAY-005의 회귀 항목을 P1-T1~T3의 실패·통과 증거와 대조했다. 동시 완료, 기존 이력, 환불, 웹훅, 소유권, IAP, 생성 응답 보존이 모두 기존에 추가한 테스트에 포함된다.
|
||||
- [x] **RED 확인 예외:** 이 Task에서는 추가 누락이나 결함이 없어 새 RED를 만들지 않았다. 위 TDD 예외 규칙대로 기존 증거를 재사용하며 인위적인 production 실패는 만들지 않았다.
|
||||
- [x] **GREEN 대조:** 새 코드·테스트 추가 없이 앞선 3개 통합 테스트 클래스의 최종 상태를 검토했다. 기존 테스트 삭제·완화·skip 추가는 없다.
|
||||
- [x] **GREEN 확인:** 사용자 ULW 요청에 따라 `./gradlew test build ktlintCheck --continue --offline --console=plain` 전체 실행에 직접 영향 charge/event/refund/coupon 회귀를 함께 포함했다. 339 suites, 3,112 tests, failure/error/skipped 모두 0, exit 0이다.
|
||||
- [x] **REFACTOR 검토:** 추가 정리는 필요하지 않아 수행하지 않았다. 실제 서비스 proxy·분리된 transaction·bounded latch 동시성, H2/외부 provider 대역 한계와 실제 HTTP 증거를 아래 Gate 기록에 남겼다.
|
||||
|
||||
#### Phase 1 Gate
|
||||
|
||||
**Goal 실행 `P1-GATE`:** PRD의 요구사항, 변경 파일, 직접 영향 테스트와 배포 전 미검증 조건을 read-only로 대조해 계획 완료 여부를 판정한다.
|
||||
|
||||
- **시작 조건:** 별도 구현 승인 이후 `P1-T1`~`P1-T4`의 구현 체크박스와 각 task Progress 증거가 모두 완료.
|
||||
- **완료 증거:** 아래 검증 명령의 실제 결과, PAY 요구사항 추적표, 생성/webhook 불변 및 미검증 외부/운영 조건을 기록한다. 누락된 요구는 통과로 표시하지 않는다.
|
||||
- **범위 밖:** 이 Gate에서 직접 코드 변경, test 완화, schema/앱 배포, external analytics 연결. 범위 내 결함은 후속 회귀 수정 Goal로, 범위 밖 변경은 별도 승인 뒤 처리한다.
|
||||
- **TDD 예외 사유:** read-only final Gate이며 production/test 코드를 추가 변경하지 않는다.
|
||||
- **대체 검증 방법:** `P1-T4` 명령 결과와 PRD §9 traceability, diff scope, checkbox/Progress를 대조한다. schema type 확인은 DB 담당자가 제공한 별도 증거가 있어야만 검증 완료로 표기한다.
|
||||
|
||||
- [x] `P1-T1`~`P1-T4`의 모든 체크와 검증 결과가 문서에 실제로 누적됐다.
|
||||
- [x] PAY-001~PAY-005 각각에 test 및 명령 결과가 대응하고 최종 독립 리뷰가 승인 범위의 차단 사항 없이 통과했다.
|
||||
- [x] 구현 승인 범위, 문서·소스 변경 범위와 스키마/애플리케이션 운영 승인 상태를 기록했다. 코드·테스트·SQL 작성은 승인 범위이고 커밋·푸시·운영 DB 적용은 수행하지 않았다.
|
||||
- [x] H2 동시성 결과를 운영 MySQL 증거로 승격하지 않고, schema type·schema-first·구 writer 종료 조건을 OPS-001로 남겼다.
|
||||
|
||||
**Gate 명령 예시, 구현 승인 뒤에만 실행:**
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeRepositoryTest' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceIntegrationTest' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceTest' --tests 'kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.can.charge.event.*'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
**Expected:** focused tests 성공과 charge event 관련 직접 영향 회귀 성공. 위 조건이 전체 회귀 조건을 만족하지 않으면 전체 `test`/`build`를 관성적으로 실행하지 않고 생략 근거와 대체 검증을 기록한다. H2 test 통과는 MySQL 실제 컬럼 타입 및 운영 lock 격리를 검증하지 않는다.
|
||||
|
||||
## 데이터베이스 rollout 참고, 실행 금지
|
||||
|
||||
Task 1.2에서 [20261103_charge_first_charged_ddl.sql](20261103_charge_first_charged_ddl.sql)을 작성했다. 아래 SQL은 해당 파일의 내용이다. Hibernate dialect의 정적 매핑은 테스트했지만 실제 MySQL 환경의 적용·검증은 별도 운영 승인 대상이다. 애플리케이션 배포 전에 컬럼을 먼저 반영해야 한다.
|
||||
|
||||
```sql
|
||||
ALTER TABLE charge
|
||||
ADD COLUMN is_first_charged BIT(1) NULL COMMENT '최초 유료 충전 여부, 최초 완료 전환에서만 확정';
|
||||
```
|
||||
|
||||
NULL 과거 행은 그대로 둔다. backfill 문장은 없다. 선행 컬럼 배포 이후 신버전 writer 투입, 구 writer drain 확인 및 rollout 순서는 OPS-001에 따른 별도 운영 승인 대상이다.
|
||||
|
||||
## Progress
|
||||
|
||||
### 커밋 직전 재검증, 2026-11-03 KST
|
||||
|
||||
- 사용자 `/commit-with-validation` 요청으로 전역 공용 절차와 저장소 커밋 규칙을 확인했다.
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.*' --tests 'kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest' --offline --console=plain` exit 0, 195건 성공(failures/errors/skipped=0), 58초다. 현재 XML과 HTML 보고서는 이 focused 실행 결과이며 앞선 전체 회귀 보고서를 대체한다.
|
||||
- `./gradlew ktlintCheck --offline --console=plain` exit 0(UP-TO-DATE), `git diff --check` 및 커밋별 staged 공백 검사를 수행한다.
|
||||
- 이번 요청은 검증된 변경의 커밋이며 production/test 추가 수정이 없어 전체 `test`/`build`는 반복하지 않았다. 결제·관리자 환불 직접 영향 회귀로 확인했으며 운영 MySQL·실결제·클라이언트 호환성은 검증하지 않았다.
|
||||
|
||||
### P1-T4 / P1-GATE 최종 판정, 2026-11-03 KST
|
||||
|
||||
- 상태: 승인 범위 구현·검증·독립 리뷰 완료. 운영 배포 승인이나 실결제 검증을 뜻하지 않는다.
|
||||
- 전체 실행: `./gradlew test build ktlintCheck --continue --offline --console=plain` exit 0, `BUILD SUCCESSFUL in 13m 34s`. 첫 실행은 600초 도구 제한으로 중단됐고 실패 테스트를 보고하지 않았다. 제한을 1,800초로 늘린 재실행이 완료됐으며 최종 결과는 재실행 XML만 집계했다.
|
||||
- 집계: `build/test-results/test/TEST-*.xml` 339 suites, tests=3112, failures=0, errors=0, skipped=0. `test`는 실제 실행됐고 compile/lint/bootJar 등은 이전 성공 출력의 UP-TO-DATE 결과를 포함한다. 테스트 무력화·건너뜀은 추가하지 않았다.
|
||||
- 직접 영향 증거: 저장소 110건, 서비스 통합 52건, HTTP 통합 22건 모두 전체 suite 안에서도 통과했다. 기존 쿠폰·환불·충전 이벤트 회귀도 포함됐다. 부모가 앞서 별도로 재실행한 저장소·서비스 162건도 exit 0이었다.
|
||||
- 실제 표면 QA: 부모의 전체 실행에서 `ChargeControllerIntegrationTest`가 실제 Tomcat 62247 포트에 `TestRestTemplate`으로 56회 POST했다. 실제 JWT/Redis 인증, 서비스 proxy, H2 저장소를 통과하고 외부 provider만 대역으로 차단했다. MockMvc 또는 서비스 반환값 stub 결과가 아니다.
|
||||
- HTTP 원문/테스트 증거: `build/test-results/test/TEST-kr.co.vividnext.sodalive.can.charge.ChargeControllerIntegrationTest.xml`의 `HTTP EVIDENCE` 56개와 `build/reports/tests/test/index.html`. 아래 발췌는 실제 성공 응답이다. XML은 후속 테스트 실행으로 덮어써질 수 있으므로 핵심 결과를 이 문서에도 보존한다.
|
||||
|
||||
```text
|
||||
POST /charge/verify 200 data={"price":9.9900,"currencyCode":"USD","isFirstCharged":true,"isIap":false}
|
||||
POST /charge/verify/hecto 200 data={"price":9.9900,"currencyCode":"USD","isFirstCharged":true,"isIap":false}
|
||||
POST /charge/payverse/verify 200 data={"price":9.9900,"currencyCode":"USD","isFirstCharged":true,"isIap":false}
|
||||
POST /charge/apple/verify 200 data={"price":9.9900,"currencyCode":"USD","isFirstCharged":true,"isIap":true}
|
||||
POST /charge/google 200 data={"price":9.9900,"currencyCode":"USD","isFirstCharged":true,"isIap":true}
|
||||
POST /charge/google 200 data={"price":9.9900,"currencyCode":"USD","isFirstCharged":false,"isIap":true}
|
||||
POST /charge/payverse/webhook 200 {"receiveResult":"SUCCESS"}
|
||||
```
|
||||
|
||||
- 오류·보존 QA: 타인 소유 REQUEST/COMPLETE는 기존 success=false 및 결제 정보 오류를 반환하고 잔액/provider/event 변경이 없다. 익명 완료 요청 5종은 401, 잘못된 webhook IP/서명은 404다. 생성 응답·true/false/null 재조회·partner tracking 조건과 재호출 분류도 검증됐다.
|
||||
- 독립 리뷰: T1 및 T2 단계별 spec/quality PASS, 최종 T3+전체 변경 리뷰 PASS. 최종 리뷰는 production 5파일 diff, 신규 테스트 3개 전체, SQL 및 실제 RED/GREEN/HTTP 로그를 읽고 차단 사항 없음을 확인했다. 최종 리뷰 이후 production/test 변경은 없다.
|
||||
- 구현 세부 판정: Bootpay 생성식 2곳은 실결제 없는 테스트를 위해 기존 생성자를 그대로 호출하는 internal 함수로만 모았다. 여섯 완료 writer의 READ_COMMITTED와 BIT(1) DDL은 위 P1-T2 판정을 따른다. 추가 모듈·의존성·전역 설정은 만들지 않았다.
|
||||
- 미검증: 도구에 LSP 진단 기능이 없어 Kotlin compile·ktlint로 대신 확인했다. 운영 MySQL snapshot/DDL, 실제 provider·tracking 비동기 적재, 클라이언트 호환성/정확한 분석 가격은 미검증이다. `ChargeTempService.verify`와 구 writer 혼재까지 동시성 보장을 확대하지 않는다.
|
||||
- 기존 환경 관찰: H2 VALUE/`SKIP LOCKED` scheduler SQL 오류 로그, Gradle deprecation 및 일부 종료 경고는 기존 환경에서 보이며 테스트 failure/error는 없다. 이 작업과 무관한 설정은 수정하지 않았다.
|
||||
- QA 종료 확인: 위 XML에 Tomcat stop과 Hikari shutdown이 있으며 전체 실행 종료 후 test worker/redis-server 프로세스는 없다. 실행용 임시 로그는 핵심 증거를 이 문서에 옮긴 뒤 작업 종료 시 제거한다. 운영 DB·실결제·배포·커밋·푸시는 수행하지 않았다.
|
||||
- 공백 검사 기록 정정: 이전 단계의 `git diff --no-index --check -- /dev/null`에 기재한 exit 0은 종료 코드 확인이 부정확했다. 최종 직접 집계에서는 신규 파일 6개 모두 exit 1, 진단 출력 없음이었다. 이는 `--no-index`의 파일 차이 종료 코드이며 공백 오류 증거가 아니다. 추적 파일의 `git diff --check` 및 별도 trailing-whitespace 검색과 함께 판정하며 과거 기록을 삭제하지 않고 정정한다.
|
||||
- 소유 프로세스 정리: 작업 전용 java.io.tmpdir의 Gradle PID 14157/19040/20991과 그 자식 Kotlin PID 14225를 TERM 종료했다. `ps -p 14225,14157,19040,20991` 재조회는 header만 출력(exit 1)해 종료를 확인했다. 관련 HTTP 포트 62247/61240 및 Redis 포트 61237도 listener가 없다. 다른 workspace의 공유 Gradle 프로세스와 캐시는 건드리지 않았다.
|
||||
- 임시 경로 정리: `payment-ulw.7P2uSv` 실행 루트와 하위 task 로그·Node/Bun/Java 임시 캐시, 별도로 확인한 작업 소유 Redis 임시 경로 두 개 및 ULW notepad를 제거했다. 경로 부재를 재조회해 확인했으며, 위 단계의 TMPDIR 링크는 실행 당시 역사 기록이다. 핵심 결과는 이 문서에 보존했고 최종 원문 테스트 증거는 `build/test-results/test/`와 `build/reports/tests/test/`에 남겨 두었다.
|
||||
|
||||
### 구현 시작, 2026-11-03 KST
|
||||
|
||||
- 사용자 구현 승인: “문서대로 진행”, ULW. 기존 test 브랜치 workspace에서 시작하며 자동 worktree를 만들지 않는다.
|
||||
- 시작 상태: 기존 사용자 문서 두 개만 untracked이며 소스 변경은 없다. 공유 ChargeService 변경은 순차 진행한다.
|
||||
- 기준 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.charge.ChargeServiceTest' --tests 'kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest' --console=plain` 성공(exit 0). 기존 쿠폰·환불 동작을 유지한다.
|
||||
- 시나리오: 다섯 완료 HTTP 응답, 타인 소유 REQUEST/COMPLETE 차단, 첫 결제·환불·legacy null·웹훅·재조회, 동시 완료에서 한 번만 최초 판정, 기존 기능 회귀.
|
||||
- 검증 계획: focused RED/GREEN부터 전체 test/build/ktlint와 격리 실제 HTTP QA로 확장한다. 외부 결제 호출은 테스트 대역으로 차단한다.
|
||||
- 현재 도구에 LSP 진단 기능이 없어 Kotlin compile·ktlint로 확인하며 LSP 검증했다고 보고하지 않는다. 운영 MySQL 검증과 클라이언트 확인은 별도다.
|
||||
|
||||
### 문서 작성, 2026-11-03 KST
|
||||
|
||||
- 상태: 문서 초안 완료, 구현 대기.
|
||||
- 무엇을: 신규 PRD와 단일 Phase 계획의 기준 경로·요구 traceability·미실행 제약을 기록했다.
|
||||
- 어떻게: 요구된 프로젝트 가이드, 샘플, 실제 결제 service/controller/entity/repository, payment enum/gateway, API envelope, refund/event 경로 및 관련 테스트를 읽었다.
|
||||
- 검증: 두 문서 전체를 읽고 PAY-001~PAY-005, 응답 필드·판정 규칙·Task 의존성을 대조했다. 두 파일 각각의 `git diff --no-index --check -- /dev/null` 검사는 exit 0이다. `git status --short --untracked-files=all`에서 이 디렉터리의 신규 Markdown 두 파일만 확인했다.
|
||||
- 생략: 문서 전용 변경이므로 Gradle 테스트/build/task 및 DB 작업은 실행하지 않았다. 계획의 코드 검증 명령은 미래 구현 단계의 예시이며 실행 결과가 아니다. 구현 체크박스는 모두 미완료로 유지한다.
|
||||
- 남은 항목: 별도 구현 승인, 운영 MySQL type·rollout 확인, 클라이언트/분석 금액 원천 확인은 미완료 외부 조건이다.
|
||||
- 다음 행동: 구현 승인이 있을 때만 `P1-T1`을 시작한다.
|
||||
|
||||
### 문서 범위 수정, 2026-11-03 KST
|
||||
|
||||
- 무엇을: PRD PAY-003 및 DEC-005와 계획 Task 1.2를 일치시켜, 구현 승인 뒤 nullable 최초 충전 컬럼 SQL 파일 작성이 구현 산출물임을 명시했다. SQL 적용·배포·운영 DB 확인은 별도 운영 승인으로 유지한다.
|
||||
- 범위: 수정 파일은 이 PRD와 `plan-task.md`뿐이다. SQL 파일은 생성하지 않았고 코드·테스트는 변경하지 않았다.
|
||||
- 검증 상태: 이번 수정에서는 테스트·빌드·DB 작업을 실행하지 않았다. 구현 체크박스는 모두 미완료이며 이 기록은 PASS 또는 선행 검증 결과를 추가하지 않는다.
|
||||
- 후속 문서 검증: PRD와 계획의 SQL 작성 범위·산출물 경로·승인 경계를 대조했다. 두 Markdown의 `git diff --no-index --check -- /dev/null`은 각각 exit 0이며, 실제 SQL·소스 파일 생성이나 변경은 없다. 운영 MySQL 타입 호환성과 SQL 실행은 검증하지 않았다.
|
||||
@@ -0,0 +1,154 @@
|
||||
# 결제 완료 응답 확장 PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현·테스트·독립 리뷰 완료, 미배포 |
|
||||
| 작성일 | 2026-11-03 (KST) |
|
||||
| 최종 수정일 | 2026-11-03 (KST) |
|
||||
| 대상 제품 | Sodalive 결제 완료 응답과 최초 유료 충전 판정 |
|
||||
| 작성자·결정권자 | 제품 요청자 |
|
||||
| 관련 API Contract | 이 문서 §API 계약 (별도 contract 파일 없음) |
|
||||
| 관련 구현 계획 | [plan-task.md](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 정책은 변경하지 않는다.
|
||||
|
||||
```json
|
||||
{
|
||||
"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_charged` nullable 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.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeCompleteResponse.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/can/payment/Payment.kt` (`PaymentStatus`는 이 파일 안에 선언됨)
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/can/payment/PaymentGateway.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeStatus.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/common/ApiResponse.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt`
|
||||
- `src/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. 수용 기준
|
||||
|
||||
- [x] PAY-001: 실제 HTTP 직렬화에서 다섯 완료 endpoint의 `data`가 네 필드를 포함하고 `isFirstCharged`와 `isIap`은 JSON boolean이다. price `9.99`의 number 직렬화와 기존 currency 값이 보존된다. 생성 응답과 webhook shape는 변화가 없다.
|
||||
- [x] PAY-002: 타 회원 소유의 pending 및 완료 charge 모두에서 같은 오류 키로 거부된다. provider 호출, 결제 상태, 잔액 및 event 처리 side effect가 없다. 본인 소유 완료 재호출은 저장된 최초 판정값을 반환하고 상태·잔액을 중복 변경하지 않는다.
|
||||
- [x] 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 적용·검증은 하지 않았다.
|
||||
- [x] PAY-004: 네 gateway에 대해 `isIap` 값이 각각 false/false/true/true이고 OS에 영향받지 않는다. 기존 price 및 currency sourcing, null -> false 응답 규칙을 유지한다.
|
||||
- [x] 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](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 |
|
||||
Reference in New Issue
Block a user