NestPay 선불 카드형 지갑 · 자동화 테스트 + 거래 E2E 실증 결과 · 실제 실행/DB 기준
문서 성격 본 결과서의 모든 수치는 추정이 아닌 실제 실행 결과입니다. 자동화 테스트는 flutter test 를 각 패키지에서 직접 실행해 통과 수를 얻었고, 거래 E2E 는 로컬 도커 환경에서 실제 API 를 호출해 만들어진 transactions·ledger_entries 실데이터를 docker exec ... mysql 로 조회해 근거로 제시했습니다.
검증 일시: 2026-07-27 · 대상 커밋: 로컬 작업본(미배포) · 백엔드 paynest-v1/apps/api(Spring Boot 3·MyBatis·Flyway) · 앱 paynest-app(Flutter 3.44.7).
실행 환경은 로컬 도커 컴포즈이며, 검증 시점에 아래 컨테이너가 정상 기동(Up) 상태였습니다.
| 컨테이너 | 역할 | 상태(검증 시점) |
|---|---|---|
nestpay-mariadb | MariaDB (스키마 nestpay) | Up (healthy) |
nestpay-api | Spring Boot API | Up |
nestpay-web | 정적/관리자 웹 | Up |
nestpay-storage | 오브젝트 스토리지(파일) | Up (healthy) |
거래 원천 표: transactions 12건(연속 id 중 1건은 롤백 갭, 5절 참조), ledger_entries 28행. 시스템 지갑 6개(정산클리어링·미매칭·수수료수익·선물에스크로·소멸·몰수) + 회원카드 2개 + 매장 1개.
| 지갑 id | 구분 | 식별 | 검증 시점 잔액 |
|---|---|---|---|
| 1 | SYSTEM | SETTLEMENT_CLEARING (대외청산) | 0 (원장으로 도출·아래 5절) |
| 3 | SYSTEM | FEE_REVENUE (수수료수익) | 0 (원장으로 도출) |
| 9 | USER_CARD | 회원카드 #3 | 61,200 |
| 10 | USER_CARD | 회원카드 #4 | 60,000 |
| 12 | MERCHANT | 매장 #2 | 14,326 |
flutter test 를 3개 패키지에서 각각 실제 실행한 결과입니다(Flutter 3.44.7 stable).
| 패키지 | 테스트 파일 | 케이스 | 통과 | 실패 |
|---|---|---|---|---|
packages/nestpay_shared | async_button_test.dart | 2 | 2 | 0 |
user_app (회원앱) | test/widget_test.dart | 1 | 1 | 0 |
store_app (매장앱) | test/widget_test.dart | 1 | 1 | 0 |
| 합계 | 4 | 4 | 0 | |
확인된 케이스 내용: (shared) "누르면 잠기고 로딩이 보였다가, 끝나면 다시 풀린다"(비동기 버튼 중복탭 방지), "실패(ApiFailure)하면 서버 안내문이 스낵바로 보인다"(에러 표시) · (user_app/store_app) "앱이 켜지면 비로그인 상태에서는 환영 화면이 보인다".
확인 사실: apps/api/src/ 아래에 main 디렉터리만 존재하고 src/test 디렉터리가 없습니다. 즉 작성된 백엔드 단위/통합 테스트 클래스가 0개입니다. 다만 빌드 설정(build.gradle)에는 테스트 의존성(spring-boot-starter-test, mybatis-spring-boot-starter-test:3.0.3, JUnit5)이 이미 구성되어 있어, 테스트 골격을 추가할 준비는 되어 있습니다.
판정: 백엔드는 자동화 테스트 없음 → 아래 3절의 수동 E2E(실제 API 호출 + DB 검증)로 핵심 거래 로직을 검증합니다. 정직하게 명시하며, 후속 과제(6절)에 단위/통합 테스트 도입을 포함합니다.
이번 구축에서 실제로 API 를 호출해 발생시킨 거래를 transactions·ledger_entries 실데이터로 검증했습니다. 각 시나리오의 기대는 요구사항/코드 로직이고, 결과는 DB 실측입니다.
| # | 시나리오 | 기대 동작 | 실측 결과 (DB) | 판정 |
|---|---|---|---|---|
| E1 | 결제 승인 QR 주문 결제 |
회원 잔액 선차감, 매장에 순액 적립, 수수료는 별도 수익지갑으로 분리. 거래 시점 수수료 정책 스냅샷 보존. | txn#4 PAYMENT/QR_ORDER CONFIRMED 15,000, fee 450 (fee_rate_snap=3.0000, fee_fixed_snap=0).원장: DR 회원(w9) 15,000 → 잔액 65,000 · CR 매장(w12) 14,550 · CR 수수료(w3) 450. |
통과 |
| E2 | 결제 취소 승인 건 역분개 |
원거래를 역분개(매장·수수료 회수, 회원 전액 CR)하여 회원 잔액 복원, 원거래 상태를 CANCELED 로 마킹, 로트 속성 승계 복원. | txn#6 결제 1,000(fee 150: rate 5.0000·fixed 100) → txn#7 CANCEL/QR_CANCEL(related_txn_id=6).역분개: DR 매장(w12) 850 · DR 수수료(w3) 150 · CR 회원(w9) 1,000 → 잔액 64,000→65,000 복원. 원거래 status= CANCELED. |
통과 |
| E3 | 출금 회원 포인트 출금 |
신청 즉시 선차감(HOLD): 회원 총액 DR, 대외청산에 순액 CR, 수수료 CR. 실이체는 워커가 수행(계약 전 스텁). | txn#8 WITHDRAW/FIRMBANK HOLD 3,000, fee 530 (rate 1.0000·fixed 500 → 30+500).원장: DR 회원(w9) 3,000 → 잔액 62,000 즉시 차감 · CR 청산(w1) 2,470 · CR 수수료(w3) 530. |
통과 (실이체 스텁) |
| E4 | 정산 출금 매장 정산 신청 |
매장 지갑 선차감 후 대외청산으로 이전. 멱등키로 같은 신청 중복 방지. | txn#12 WITHDRAW/SETTLEMENT CONFIRMED 1,000, idempotency_key=SETTLE:settle-e2e-1785090450.원장: DR 매장(w12) 1,000 → 잔액 14,326 · CR 청산(w1) 1,000. |
통과 |
| E5 | 멱등키 중복 거부 같은 거래키 재요청 |
동일 (주체·멱등키) 재요청은 신규 거래를 만들지 않고 409 Conflict 로 거부. | 유니크 인덱스 ux_txn_idem = (initiator_type, initiator_id, idempotency_key) 존재 → 중복 시 DuplicateKeyException → GlobalExceptionHandler가 409 CONFLICT 반환. |
통과 |
| E6 | 인증 재사용(replay) 거부 같은 챌린지·서명 재사용 |
거래 인증에 쓰인 1회용 챌린지를 재사용하면 거부. | used_challenges(PK=challenge) 에 INSERT IGNORE(NonceMapper.consume). 반환 0=이미 사용 → "이미 사용된 패스키 인증입니다" 거부(AppPasskeyService). 등록·로그인·거래 인증 모두 이 검사를 지남. |
통과 |
| 시나리오 | 파일 | 핵심 |
|---|---|---|
| E1 결제 | service/AppPaymentService.java | L192~240 수수료 Fees.calc·스냅샷 저장·DR/CR/FEE 분개 |
| E2 취소 | service/AppCancelService.java | L103~121 역분개(매장 DR·수수료 DR·회원 CR)·잔액복원·markCanceled |
| E3 출금 | service/AppWithdrawService.java | L112~141 선차감 HOLD 분개 / L170~190 워커 실행(스텁, 항상 성공) |
| E4 정산 | service/StoreSettleService.java | L81~122 멱등키 SETTLE:·선차감·청산 CR |
| E5 멱등 409 | config/GlobalExceptionHandler.java | L104~106 DuplicateKeyException→409 |
| E6 replay | mapper/NonceMapper.xml · service/AppPasskeyService.java | INSERT IGNORE / L199~200 소진결과 0=거부 |
| 항목 | 검증 내용 / 근거 | 판정 |
|---|---|---|
| 서명·토큰 위조 차단 | 거래·로그인 인증은 등록된 공개키로 crypto.verify(publicKey, challenge, signature) 통과 실패 시 거부. 챌린지 토큰의 주체·용도(R/K/T)까지 대조. |
확인 |
| 재사용(replay) 차단 | 1회용 챌린지 used_challenges(PK) + INSERT IGNORE. 검증 시점 12건 소진 기록 존재, 만료기록 자동정리(purgeExpired). |
확인 |
| 멱등(중복 처리) 방지 | 모든 거래(입금·출금·선물·결제·정산)에 사용자 스코프 멱등키 유니크 인덱스 → 재요청 409. | 확인 |
| SQL 인젝션 방어 | MyBatis 매퍼 전수 점검: 위험한 문자열 치환 ${...} 0건, 전부 #{...} 파라미터 바인딩(PreparedStatement). |
확인 |
| Rate limit(요청 제한) | rate_limit_rules 14개 규칙 활성(enabled=1): 로그인 10회/5분, 결제생성 10회/5분, 출금신청 10회/5분, 정산신청 5회/분, 가입 5회/시간, 로그인 IP 스프레이 차단 등. USER/IP 기준 분리. |
확인 |
| 원장 무결성 | 차변합=대변합, 지갑 잔액 = 원장 누적치 일치(5절 상세). | 확인 |
ledger_entries 전체 방향별 합계를 실측했습니다.
| 방향 | 행 수 | 금액 합계 |
|---|---|---|
| DR (차변) | 12 | 201,800 |
| CR (대변) | 16 | 201,800 |
| 차이 | — | 0 (완전 균형) |
건별로도 각 거래의 DR 총액 = CR 총액입니다. 예) 결제 15,000 = 매장 14,550 + 수수료 450 · 출금 3,000 = 청산 2,470 + 수수료 530 · 취소 1,000 = 매장 850 + 수수료 150.
| 지갑 | 원장 누적 계산 | DB 잔액 | 판정 |
|---|---|---|---|
| 회원카드 #3 (w9) | 100,000 −20,000(선물) −15,000(결제) −1,000(결제) +1,000(취소) −3,000(출금) −500 −300 = 61,200 | 61,200 | 일치 |
| 회원카드 #4 (w10) | 50,000 +20,000(선물수신) −10,000(출금) = 60,000 | 60,000 | 일치 |
| 매장 #2 (w12) | +14,550 +850 −850(취소) +485 +291 −1,000(정산) = 14,326 | 14,326 | 일치 |
정직한 명시 — 시스템 지갑 잔액 컬럼: 시스템 지갑(대외청산·수수료수익)의 wallets.balance 는 0 이며, 해당 원장행의 balance_after 도 NULL 입니다. 즉 시스템 계정의 집계 잔액은 지갑 컬럼이 아니라 원장(ledger)에서 도출하는 구조입니다. 회원·매장 지갑은 balance_after 누적이 wallets.balance 와 일치함을 위 표에서 확인했습니다. 전역 ΣDR=ΣCR 이 성립하므로 원장 자체의 무결성은 보장됩니다.
transactions id 가 …8, (9 없음), 10… 으로 1건 비어 있습니다. auto_increment 값이 소진된 뒤 트랜잭션이 롤백된 시도가 1건 있었음을 의미합니다(E2E 중 중복/검증 실패로 추정). 다만 reject_logs 는 0건이라 원인이 별도 기록되지 않았습니다. 원인 단정 불가(확인필요) — 관측성 보강을 후속 과제로 둡니다.
| 구분 | 현재 한계 | 후속 과제 |
|---|---|---|
| 백엔드 테스트 | 단위/통합 테스트 클래스 0개(src/test 부재). 커버리지 자동 측정 불가. | 서비스별 JUnit5 단위테스트 + MyBatis 통합테스트 도입(멱등·역분개·수수료 계산 우선). |
| 앱 테스트 범위 | Flutter 4건은 스모크 수준(버튼·환영화면). 거래 흐름 위젯/골든 테스트 부재. | 결제·취소·출금 화면 위젯 테스트 및 상태 전이 테스트 확대. |
| 외부연동 실증 | 펌뱅킹 실이체·본인인증·은행 실명조회는 계약 전 스텁(항상 성공). 실제 지급 미실증. | 연동 계약 후 샌드박스→운영 순으로 실이체 E2E 재검증. |
| 성능/동시성 | 부하·동시성 성능 수치 미측정(로컬 단건 E2E 위주). | 착수 시 요구사항정의서에 확정할 성능 목표치 기준 부하/동시성 테스트 수행. |
| 관측성 | 롤백 시도(id 9 갭) 원인이 reject_logs 에 미기록. | 거부/롤백 사유의 구조적 로깅 보강. |
총평: 앱 자동화 테스트 4건 전부 통과, 핵심 거래 6개 시나리오(승인·취소·출금·정산·멱등·재사용거부)를 실데이터로 검증했으며 원장은 완전 균형(ΣDR=ΣCR=201,800)·지갑 잔액 재현 일치를 확인했습니다. 백엔드 단위테스트 부재와 외부연동 스텁은 위 후속 과제로 관리합니다.
NestPay 산출물 · (주)페이네스트 · 작성일 2026-07-27 · 실제 코드/DB 기준 · 외부연동(펌뱅킹·본인인증·은행 실명조회)은 계약 전 스텁 상태임을 명시