315 lines
46 KiB
Markdown
315 lines
46 KiB
Markdown
# 결제 완료 응답 확장 구현 계획
|
|
|
|
| 항목 | 내용 |
|
|
|---|---|
|
|
| 상태 | 구현·테스트·리뷰 완료, 미배포 |
|
|
| 작성일 | 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 실행은 검증하지 않았다.
|