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-db | MariaDB (스키마 nestpay) | Up (healthy) |
nestpay-was | 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). 등록·로그인·거래 인증 모두 이 검사를 지남. |
통과 |
| E7 | 취소 권한 재편(2026-07-27) 회원 차단·매장 취소 |
회원 취소 API 는 존재하지 않아야 하고(404), 매장은 본 매장 확정 결제만 전액 취소. | ① 회원 토큰 POST /app/me/payments/10/cancel → 404(입구 제거). ② 매장2 토큰 POST /store/payments/10/cancel → 500원 취소 성공: txn#16 CANCEL/QR_CANCEL initiator=MERCHANT/2, 역분개 DR 매장 485 · DR 수수료 15 · CR 회원 500, 원거래 CANCELED. ③ 같은 건 재취소 → alreadyCanceled(멱등). ④ 매장1 토큰으로 매장2 결제 취소 시도 → "본 매장의 결제가 아닙니다" 거부. |
통과 |
| E8 | 관리자 취소(환불) 사유 필수·감사기록 |
관리자는 어느 매장 결제든 취소 가능하되 사유 필수, 감사 장부에 기록. | POST /admin/transactions/11/cancel(사유 포함) → 300원 취소 성공: txn#17 CANCEL/QR_CANCEL initiator=ADMIN/1. audit_logs에 PAYMENT_CANCEL(사유·IP 포함) 기록 확인. 사유 없이 요청 → "취소 사유를 입력해 주세요" 400 거부. |
통과 |
| E9 | PG 주문 결제 내부취소 차단 웹훅 정합 보호 |
PG 주문(pg_orders.txn_id 연결)에 붙은 결제는 매장앱·관리자에서 취소 불가 — PG 환불 API 로만. |
결제 txn#4 에 PG 주문 연결 후 매장·관리자 취소 시도 → 둘 다 "온라인(PG) 주문 결제입니다. 매장 시스템에서 PG 환불 API로 환불해야 합니다" 거부, 원거래 CONFIRMED 유지. (매장 잔액 부족 시 거절 가드도 별도 확인: "매장 정산 잔액이 부족해 지금은 취소할 수 없습니다") |
통과 |
※ E7~E9는 취소 권한 재편(회원 금지 → 매장·관리자만) 반영 후 2026-07-27 실측. 이후 원장 합계는 취소 2건(500원·300원)이 더해져 ΣDR=ΣCR=212,600원으로 여전히 완전 균형(회원카드 w9=62,000 · 매장 w12=3,550, DB 재현 일치).
| 시나리오 | 파일 | 핵심 |
|---|---|---|
| E1 결제 | service/AppPaymentService.java | L192~240 수수료 Fees.calc·스냅샷 저장·DR/CR/FEE 분개 |
| E2 취소 | service/AppCancelService.java | reverse() 공용 역분개(매장 DR·수수료 DR·회원 CR)·잔액복원·markCanceled — 취소 주체는 매장(storeCancel)·관리자(adminCancel)·PG(refundPgPayment) 3종(회원 취소는 2026-07-27 정책으로 제거) |
| 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절 상세). | 확인 |
| 패스키 — 로그인 수단 변경 보호 실기기 | 실제 iPhone(iOS 26.6)에서 확인. PIN 없이 등록·삭제 시도 → 거부("거래 비밀번호 인증이 필요합니다"). 정상 PIN 인증표로만 성공.
접근 로그 증거: pin/verify 400(틀림) → pin/verify 200(맞힘) → passkeys/2/delete 200.
등록·로그인·거래인증·삭제 전 경로 200, 관리자 대리 삭제 시 감사기록 USER_PASSKEY_DELETE 확인. |
확인 |
| 패스키 거래 인증 실기기 | QR 카메라 스캔 → qr/verify → passkeys/tx/start·finish → payments 전부 200.
결제 2,000원 원장: 회원 카드 −2,000(잔액 95,500) / 매장 +1,940(8,730) / 수수료 시스템 +60. 차변=대변 일치. |
확인 |
| FDS 이상거래 탐지 8종 | 8종 전부 실제 경보 생성까지 확인(임계값을 일시 하향해 유발 후 원복). PASSTHROUGH(충전 후 즉시 이체) / GIFT_CONCENTRATION(선물 집중) / MULTI_ACCOUNT_DEVICE(한 기기 여러 계정) / ENUMERATION(IP 열거 — PIN 실패 4회) / SELF_PAYMENT(자기 매장 결제 5,000원) / CANCEL_ABUSE(취소 5건·35,000원) / POST_CHANGE_WITHDRAW(계좌 변경 40~43분 뒤 출금 3건) / NIGHT_LARGE(0시대 출금 3건, KST). 동작은 경보(ALERT) 로 거래를 막지 않습니다. | 확인 |
| 거절 시도 기록 | reject_logs 에 PIN 실패·잠김, QR 무효, 없는 회원 조회를 기록(실측 확인). 고객 문의 답변 근거이자 ENUMERATION 탐지의 재료. 별도 트랜잭션이라 본 거래 롤백에도 기록이 남습니다. |
확인 |
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건이라 원인이 별도 기록되지 않았습니다. 이 건의 원인은 사후 단정 불가이나, 거절 기록 자체는 이후 보강되어 해소했습니다 — RejectLogService 가 PIN 실패·잠김, QR 무효, 없는 회원 조회 시점에 사유를 남기며, 본 거래가 롤백되어도 기록이 사라지지 않도록 별도 트랜잭션으로 씁니다.
| 구분 | 현재 한계 | 후속 과제 |
|---|---|---|
| 백엔드 테스트 | 단위/통합 테스트 클래스 0개(src/test 부재). 커버리지 자동 측정 불가. | 서비스별 JUnit5 단위테스트 + MyBatis 통합테스트 도입(멱등·역분개·수수료 계산 우선). |
| 앱 테스트 범위 | Flutter 4건은 스모크 수준(버튼·환영화면). 거래 흐름 위젯/골든 테스트 부재. | 결제·취소·출금 화면 위젯 테스트 및 상태 전이 테스트 확대. |
| 외부연동 실증 | 펌뱅킹 실이체·본인인증·은행 실명조회는 계약 전 스텁(항상 성공). 실제 지급 미실증. | 연동 계약 후 샌드박스→운영 순으로 실이체 E2E 재검증. |
| 성능/동시성 | 부하·동시성 성능 수치 미측정(로컬 단건 E2E 위주). | 착수 시 요구사항정의서에 확정할 성능 목표치 기준 부하/동시성 테스트 수행. |
| 관측성 해소 | 거절 사유가 reject_logs 에 전혀 기록되지 않아 "왜 막혔는지" 근거가 없었음. | 완료 — RejectLogService 신설(별도 트랜잭션·기록 실패가 본 흐름을 막지 않음). PIN 실패·잠김, QR 무효, 없는 회원 조회 3지점 기록. FDS 열거(ENUMERATION) 탐지의 재료로도 쓰입니다. |
총평: 앱 자동화 테스트 4건 전부 통과, 핵심 거래 9개 시나리오(승인·취소·출금·정산·멱등·재사용거부 + 취소 권한 재편 E7~E9: 회원차단·매장취소·관리자취소·PG차단)를 실데이터로 검증했으며 원장은 완전 균형(최종 ΣDR=ΣCR=212,600)·지갑 잔액 재현 일치를 확인했습니다. 백엔드 단위테스트 부재와 외부연동 스텁은 위 후속 과제로 관리합니다.
NestPay 산출물 · (주)페이네스트 · 작성일 2026-07-27 · 실제 코드/DB 기준 · 외부연동(펌뱅킹·본인인증·은행 실명조회)은 계약 전 스텁 상태임을 명시