← 문서 목록

시스템 구성도 · 애플리케이션 아키텍처 · 주요 업무 흐름도

NestPay 선불 지갑 플랫폼 · 실제 코드/DB(nestpay, Flyway V1~V70 적용) 기준 재확인

문서 목적 — 본 문서는 NestPay 서버(paynest-v1)의 배포 구성, 애플리케이션 계층 구조, 그리고 돈이 오가는 주요 업무의 처리 순서와 원장 복식분개(DR/CR/FEE)를 실제 소스 코드와 운영 DB 스키마로 재확인하여 정리한 설계 산출물입니다.

근거: apps/api/docker-compose.yml, apps/api/nginx/*, apps/api/src/main/java/kr/nestpay/api/**, application.yml, build.gradle, Flyway 마이그레이션(V1~V70) 및 운영 DB(nestpay) 조회. 다이어그램은 외부 스크립트 없이 텍스트로 그렸습니다.

목차

1. 기술 스택 요약

구분채택 기술버전 / 근거
언어 · 런타임Java17 (build.gradle toolchain JavaLanguageVersion.of(17) — 발주사 확정)
프레임워크Spring Boot3.3.5 (web · validation · actuator)
SQL 매핑MyBatismybatis-spring-boot-starter 3.0.3 (SQL 을 XML 매퍼에 직접 작성)
스키마 관리Flywayflyway-core + flyway-mysql, 부팅 시 V1~V70 자동 적용(빈 서버에 70건 전부 성공 확인)
데이터베이스MariaDB10.3 (utf8mb4 · event-scheduler ON — V35 파티션 수명관리)
파일 보관WAS 공유 폴더별도 라이브러리 없음— 오브젝트 스토리지 사용 불가로 확정(2026-08-11), 자바 표준 파일 API 사용
API 문서Springdoc(Swagger)4개 그룹(app/store/admin/pg), 운영은 내부 스펙 SWAGGER_ENABLED=false
리버스 프록시nginx1.27-alpine — server_name(서브도메인) 기반 신뢰 경계 분리

2. 시스템 구성도

운영은 L4 → WEB(공개망) → WAS(폐쇄망) → DB 구조입니다(발주사 확정 2026-08-11). WEB·WAS 모두 동일 구성 2대씩이며 무상태(stateless)입니다. 두 WAS 가 하나의 공유 DB하나의 공유 파일 폴더를 바라보므로, 요청이 어느 쪽으로 가도 결과가 같습니다. WEB 의 nginx 하나가 3개 호스트(npwp-api·npwp-pg·npwp-admin)를 server_name 으로 동시에 서비스합니다(서버를 주소별로 나누지 않음).

                    [ 인터넷 / 외부 매장 서버 / 발주사 서버 ]
                                       │
                              ┌────────┴────────┐
                              │  L4 로드밸런서   │   /health 로 각 WEB 생사 확인
                              └────────┬────────┘
              ┌────────────────────────┴────────────────────────┐
              │                 공개망 (DMZ)                     │
    ┌─────────┴──────────┐                          ┌───────────┴────────┐
    │   WEB #1           │ ······· 동일 ··········· │   WEB #2           │
    │  ┌──────────────┐  │                          │  ┌──────────────┐  │
    │  │ nginx 1.27   │  │                          │  │ nginx 1.27   │  │
    │  │ server_name  │  │  주소(npwp-api·pg·admin) │  │ server_name  │  │
    │  │ 가상호스트   │  │  로 갈라 줌              │  │ 가상호스트   │  │
    │  │ + 정적 파일  │  │  관리자 화면·문서은      │  │ + 정적 파일  │  │
    │  └──────┬───────┘  │  여기서 직접 응답        │  └──────┬───────┘  │
    └─────────┼──────────┘                          └─────────┼──────────┘
              │                 폐쇄망 (외부에서 직접 접속 불가)          │
    ┌─────────┴──────────┐                          ┌─────────┴──────────┐
    │   WAS #1 (무상태)  │ ······· 동일 ··········· │   WAS #2 (무상태)  │
    │  ┌──────────────┐  │                          │  ┌──────────────┐  │
    │  │ Spring Boot  │  │                          │  │ Spring Boot  │  │
    │  │ API :8080    │  │                          │  │ API :8080    │  │
    │  │ (@Scheduled  │  │                          │  │ (@Scheduled  │  │
    │  │  워커 포함)  │  │                          │  │  워커 포함)  │  │
    │  └──────┬───────┘  │                          │  └──────┬───────┘  │
    │  ┌──────┴───────┐  │                          │         │          │
    │  │ 파일 공유폴더│◀─┼───── 마운트(WAS #2가 함께 바라봄) ──┘          │
    │  │ STORAGE_DIR  │  │                          │                    │
    │  └──────────────┘  │                          │                    │
    └─────────┬──────────┘                          └─────────┬──────────┘
              └────────────────────┬─────────────────────────┘
                          ┌────────┴────────┐
                          │  MariaDB 10.3   │   원장·지갑·거래 (1대, 공유)
                          └─────────────────┘

  ※ 파일 실물: 오브젝트 스토리지를 쓸 수 없어(발주사 확정 2026-08-11), WAS #1 의 폴더를
     WAS #2 가 함께 마운트합니다. 폴더가 연결돼 있지 않으면 서버가 아예 켜지지 않습니다.

  ※ 워커 동시성: 서버 2대가 같은 @Scheduled 를 돌려도, 각 작업은 claim_token(원자적 UPDATE) /
     FOR UPDATE / 상태 가드 쿼리로 "한 건은 한 번만" 처리되도록 DB 에서 보호합니다(중복 처리 방지).

2.1 호스트(서브도메인) 3종 — nginx server_name 분기

하나의 nginx 가 서브도메인별로 "허용 경로만" 열어 신뢰 경계를 나눕니다. 나머지 경로는 모두 404 로 막아, 관리자 API·내부 Swagger 스펙이 공개(api/pg) 호스트로 새지 않게 합니다.

호스트운영 도메인테스트 도메인개발 포트허용 경로 (conf.d)보호 방식
wwwnpwp-www.nestpay.co.krnpwp-twww.nestpay.co.kr6443회사·서비스 소개 정적 사이트($uri.html 미러)공개(인증 없음)
appnpwp-api.nestpay.co.krnpwp-tapi.nestpay.co.kr8443/app · /store · /health + 내부 Swagger(app/store/admin, 사내 IP)토큰 + 사내 IP(문서)
adminnpwp-admin.nestpay.co.krnpwp-tadmin.nestpay.co.kr9443관리자 정적 SPA + /api/* → 백엔드 /admin화이트 IP + 관리자 토큰(내부 전용)
pgnpwp-pg.nestpay.co.krnpwp-tpg.nestpay.co.kr7443/pg/** + 외부 공개 Swagger(pg 그룹만)HMAC 서명 + 매장 화이트 IP

· 운영(443): 하나의 서버가 위 4개 서브도메인(www/app/admin/pg)을 server_name 으로 동시 서비스, 알 수 없는 도메인(직접 IP 접근 등)은 default_server 로 404. · 테스트 서버는 같은 도메인에 앞글자 t 를 붙입니다(twww/tapp/tadmin/tpg). 설정 파일은 한 벌(nginx/templates/nginx.conf.template)이고 환경변수 NP_HOST_PREFIX(운영="" · 테스트="t")로 갈립니다. · 앱 API 가 api. 가 아니라 app. 인 이유: api.nestpay.co.kr·tapi.nestpay.co.kr 는 발주사의 기존 결제 시스템(CyrexPay)이 이미 사용 중이라 사용할 수 없습니다. · 도메인 맨 앞을 뺀 nestpay.co.kr 접속은 www 로 301 이동. · 개발(로컬): DNS 가 없어 포트(6443/8443/9443/7443)로 같은 입구에 접속. · MariaDB(3307) 와 WAS 는 인터넷에 노출하지 않습니다(폐쇄망).

2.2 컨테이너 구성(로컬 docker compose)

서비스이미지역할기동 조건
dbmariadb:10.3스키마·기본데이터(Flyway) 저장healthcheck(mysqladmin ping)
storageminio (2024-10)파일·이미지 실물healthcheck(mc ready)
api멀티스테이지 빌드Spring Boot API + 워커db · storage service_healthy 후 기동
webnginx:1.27-alpine443/6443/7443/8443/9443 입구api 기동 후

3. 애플리케이션 아키텍처

표준 계층형 구조입니다. Controller(입출력·검증) → Service(업무 판단·트랜잭션 경계) → Mapper(MyBatis XML SQL) → MariaDB. 여러 흐름이 공유하는 계산·조회는 util 패키지의 순수 함수로 뽑아 중복을 제거했습니다(수수료·로트·지급시각 등).

  ┌──────────────────────────────────────────────────────────────────────────┐
  │  Controller  (kr.nestpay.api.controller)                                    │
  │   App*(회원앱)  Store*(매장앱)  Admin*(관리자)  Pg*(외부PG)  BankWebhook    │
  │   · 요청 DTO 검증(jakarta.validation) · ApiResponse 표준 응답            │
  └───────────────────────────────┬────────────────────────────────────────────┘
                                   │  (권한·토큰은 아래 보안 필터에서 이미 판별)
  ┌───────────────────────────────┴────────────────────────────────────────────┐
  │  Service  (@Transactional 경계 = 한 업무 = 한 트랜잭션)                      │
  │   AppPayment · AppWithdraw · AppGift · AppDeposit · StoreSettle · PgPayment  │
  │   ┌──────────────── 공용 유틸(util) — 중복 제거 ────────────────┐           │
  │   │ Fees(수수료)  Lots(로트 소비)  LedgerLookups(시스템지갑·만료) │           │
  │   │ PayoutSchedules(지급시각)  PushPayloads(알림)  Masks(마스킹)  │           │
  │   │ Hashes(SHA-256)  Pagination  BankVerify  BankMaintenance      │           │
  │   └──────────────────────────────────────────────────────────────┘           │
  └───────────────────────────────┬────────────────────────────────────────────┘
  ┌───────────────────────────────┴────────────────────────────────────────────┐
  │  Mapper  (MyBatis · resources/mapper/*.xml)  — 원장/지갑/거래 단일 원장 SQL  │
  └───────────────────────────────┬────────────────────────────────────────────┘
  ┌───────────────────────────────┴────────────────────────────────────────────┐
  │  MariaDB 10.3  —  wallets · lots · transactions · ledger_entries · …         │
  └──────────────────────────────────────────────────────────────────────────────┘

  [외부 연동은 인터페이스로 격리 — 계약 전 스텁 / 계약 후 실연동 교체]
    BankVerifier   ← StubBankVerifier   (실명조회·1원인증)
    IdentityVerifier ← StubIdentityVerifier (본인인증 PASS 등)

3.1 공용 유틸(중복 제거) — 실제 함수

유틸역할계산/규약(코드 원문)
Fees.calc수수료 계산(전 흐름 공통)floor(금액 × rate% ÷ 100) + fixed_amount. 정책 없으면 0. (과거 충전만 floorDiv 로 달랐던 버그를 통일)
Lots.consume유상 포인트 로트 소비(배분·차감)DR 분개 1줄에 대해 오래된 로트부터 insertLotAllocation(배분 기록=취소 복원 근거) + decreaseLotRemaining
LedgerLookups시스템지갑 조회 · 로트 만료개월systemWallet(code), expiryMonths()
PayoutSchedules.from출금·정산 지급 예정 시각delay_days ≤ 0 → 즉시(now), 아니면 오늘+delay_days 일의 execute_time 시각
PushPayloads.of알림 payload(JSON) 생성kind·principalType·principalId·ntype·title·body·deeplink
Masks / Hashes이름 마스킹 · SHA-256 지문전화·카드번호는 sha256Hex 지문으로 조회(원문 미저장)

4. 보안 경계 — nginx → IP → HMAC → 토큰 (3중 필터)

가장 바깥은 nginx(호스트·경로 화이트리스트)이고, 그 안에서 Spring 서블릿 필터가 순서대로 검사합니다. 값싼 검사(IP)를 먼저, 비용이 큰 검사(서명·토큰)를 뒤에 둡니다. 필터 등록·URL 패턴은 SecurityFilterConfig + application.yml 에서 확인.

  요청 ─► [ nginx ]  host(server_name)+경로 화이트리스트 (그 외 404)
            │
            ├─ /admin/*  ─► ① IpWhitelistFilter (order 1)  등록 IP 만 통과(없으면 bootstrap-allow)
            │               ─► ③ AdminAuthFilter (order 3)  관리자 토큰 + 관리자별 개인 허용 IP
            │
            ├─ /pg/*     ─► ② HmacAuthFilter (order 2)  X-Client-Id/Api-Key/Timestamp/Signature
            │                   · 시각 오차 300초 · client_id APPROVED · 키 해시대조(교체유예)
            │                   · HMAC-SHA256(method\npath\nts\nbody) 시간차 없는 비교
            │                   · 서명 통과 후 그 매장의 승인 화이트IP 인지 추가 확인(V7)
            │
            ├─ /store/*  ─► ④ StoreAuthFilter (order 4)  매장 출입증(토큰)
            │
            ├─ /app/me/* ─► ⑤ UserAuthFilter (order 5)  회원 출입증(토큰)
            │               (/app 의 가입·로그인·공개콘텐츠는 무인증)
            │
            └─ /webhooks/bank/*  ─► X-Internal-Key(=INTERNAL_API_KEY) 상수시간 비교 (은행/PG 전용)

  · 진짜 사용자 IP 판별: ClientIpResolver 가 신뢰 프록시(nginx)에서 온 X-Forwarded-For 만 신뢰.
  · 내부 Swagger 스펙(app/store/admin): docsIpWhitelistFilter(order 1)로 사내 IP 에서만 열람.
필터order대상 경로검사 내용
IpWhitelistFilter1/admin/*DB 등록 IP(없으면 bootstrap-allow)
docsIpWhitelistFilter1/v3/api-docs/{app,store,admin}내부 문서 스펙 사내 IP 제한
HmacAuthFilter2/pg/*HMAC 서명 + 매장 화이트 IP
AdminAuthFilter3/admin/*관리자 토큰 + 관리자별 IP
StoreAuthFilter4/store/*매장 토큰
UserAuthFilter5/app/me/*회원 토큰

5. 부팅 안전장치 · 백그라운드 워커

5.1 부팅 안전장치(fail-fast) — 개발 기본값으로 운영 오픈 차단

가드실행 시점차단 조건 (app.env ≠ dev 일 때)
SecretsGuard
(EnvironmentPostProcessor)
DB 연결·Flyway 이전(최초기)Swagger 미차단 또는 개발 기본값 잔존: APP_CRYPTO_KEY · ADMIN_TOKEN_SECRET · INTERNAL_API_KEY · DB_PASSWORD
StubGuard
(ApplicationRunner)
컨텍스트 기동 직후외부 연동이 스텁 그대로: StubBankVerifier(실명조회·1원인증) · StubIdentityVerifier(본인인증)

· 위 두 클래스가 실제 소스에 존재하는 부팅 가드입니다(config/SecretsGuard, config/StubGuard). 조건 미충족 시 IllegalStateException 으로 서버 기동을 중단합니다. 외부 연동(펌뱅킹·본인인증·은행 실명조회)은 계약 전 스텁 상태이며, 실연동 구현으로 교체해야만 운영 기동이 됩니다.

5.2 백그라운드 워커(@Scheduled) — 2대 동시 실행 안전

워커주기역할동시성 보호
DepositNoticeWorker10초입금 통지 처리(적립/미매칭)FOR UPDATE 클레임 + 상태 가드(guardNoticeMatched/Unmatched)
WebhookSender20초PG 매장서버로 결제완료 웹훅 발송webhook_claim_token(V34)
OutboxWorker30초알림함 적재 · 푸시 캠페인 · 고아 회수claim_token 원자적 UPDATE(V11) — MariaDB 10.3 엔 SKIP LOCKED 없음
PgExpirySweeper30초만료된 PG 주문·QR 정리상태 전이 가드
WithdrawWorker60초예정 출금·정산 이체 제출(페이솔루스 출금요청 API → PENDING, 완료는 출금통지 웹훅)claimWithdrawal(HOLD→PENDING) 원자 전이
DepositRequestExpiryWorker5분입금통지 미도착 충전신청 만료(PENDING→EXPIRED)조건부 벌크 UPDATE(멱등 — 상태 전이만이라 claim 불필요)
RateLimitService1시간레이트리밋 낡은 카운터 청소
ReconcileWorker1시간금액 무결성 대사(지갑잔액=원장순액·전역ΣDR=ΣCR·거래별 균형·수수료 기입) + 일별 집계·스냅샷integrity_runs.run_key UNIQUE + INSERT IGNORE 원자 클레임(V38)

금액 무결성 상시 검증(대사)ReconcileWorker가 매시간 네 규칙(①비시스템 지갑 balance == ΣCR-ΣDR ②전역 ΣDR==ΣCR ③거래별 ΣDR==ΣCR ④수수료 거래별 fee_amount == FEE_REVENUE 기입액)을 대조해 어긋난 값만 integrity_findings에 남기고, 하루 한 번 전날 daily_summaries(충전/결제/출금/정산/선물/수수료 합계)·daily_wallet_snapshots를 채운다. 관리자 #/integrity 화면에서 실행 이력·발견을 보고 조사중/해결/오탐으로 처리하며 [지금 검사]로 즉시 실행도 가능하다. 검사 결과는 영구 저장된다(integrity_check_runs 실행 이력 + integrity_findings 발견 + 일별 집계·스냅샷).

매장 매출 무결성 — 매장별로 "매장 지갑 잔액 == 받은 결제(순, 수수료 제외) − 취소 환불 − 정산 지급"을 대조한다(GET /store/integrity=매장앱 자기조회, GET /admin/integrity/merchant/{id}=관리자 매장상세). 매장앱 매출 화면과 관리자 매장 상세에 정상/점검 필요 배지 + 구성 내역으로 표시된다. 전역 대사가 이미 매장 지갑을 포함하므로, 이 매장별 뷰는 같은 원장을 매장 관점으로 풀어 보여 주는 것이다.

6. 원장(복식부기) 공통 규약

모든 자금 이동은 transactions 1건 + ledger_entries 여러 줄로 기록되는 단일 원장(single ledger) 구조입니다. 각 줄은 direction(DR 차변 / CR 대변) · amount · balance_after 를 가지며, 한 거래의 DR 합 = CR 합(균형)입니다. 수수료는 항상 별도 CR 줄로 시스템 수수료 지갑에 적립합니다.

시스템 지갑(wallets.system_code)용도
SETTLEMENT_CLEARING대외 청산(충전 유입·출금/정산 유출의 상대 계정)
FEE_REVENUE플랫폼 수수료 수익
SEIZED압류 보관 — 사고 조사 중인 돈이 머무는 곳(아직 주인이 정해지지 않음)
SEIZE_BURNED압류 소각 — 회원에게 돌아가지 않기로 확정된 돈
UNMATCHED주인 못 찾은 입금 보관(관리자 수동 매칭/반환)
EXPIRED / FORFEITED만료·소멸 포인트 귀속(운영 DB 확인)

· 운영 DB 조회 결과 시스템 지갑 7종 확인: SETTLEMENT_CLEARING · UNMATCHED · FEE_REVENUE · EXPIRED · FORFEITED · SEIZED · SEIZE_BURNED. · 회원 지갑에서 돈이 나갈 때는 Lots.consume 로 유상 로트를 소비하고 배분을 남겨 취소·실패 시 정확히 복원합니다.

7. 업무 흐름 ① 회원 QR 결제 AppPaymentService

QR 은 서버가 QR 번호를 AES-GCM 으로 잠근 문자열(NPQR1.+암호문)입니다. 위조 불가·매장정보 은닉. QR 종류 3가지를 하나의 결제 로직으로 처리합니다.

QR 종류금액유효시간결제 subtype
STORE_STATIC(고정 스티커)회원이 입력무제한QR_STORE
ORDER(1회용, 금액 지정)QR 금액 고정기본 5분QR_ORDER
ORDER(1회용, 금액 미설정)회원이 입력기본 5분QR_ORDER
처리 순서(전부 한 트랜잭션)
 회원앱                          API (AppPaymentService.pay)                    DB
   │  스캔 QR + 금액 + pinPass  ─────►
   │                            0) requirePinPass  (PIN 인증 직후 발급된 1회용 표 검증)
   │                            1) loadQr  복호화·존재·활성·만료·매장정상 검증(5.1)
   │                            2) ORDER형이면 burnOrderQr  ← QR 선(先)소각(동시 결제 차단)
   │                            3) 회원지갑·매장지갑 번호순 잠금(데드락 방지)  ─► FOR UPDATE
   │                            4) 수수료 스냅샷(Fees.calc) + 부가세 분리(TAXED)
   │                               취소가능기한(cancelable_until) 계산(3.5, 기록용 스냅샷)
   │                               ※ 취소 권한: 매장·관리자만(회원 불가, 2026-07-27 정책) — 기한 강제 없음
   │                            5) 로트 잠금(무상 우선) — 가용 합 < 금액이면 결제 불가
   │                            6) transactions INSERT (멱등키 = PAY:{pinSig})
   │                            7) ledger_entries 분개(아래 표)
   │                            8) Lots.consume  회원 DR 줄에 로트 배분·차감(0.4)
   │                            9) 지갑 잔액 갱신 + 양쪽 알림(outbox) + PG주문 연결
   │  ◄─ 결제완료(txnId·잔액)
분개 줄지갑DR/CR금액
회원 차감회원 지갑DR결제금액(payAmount)
매장 적립매장 지갑CR결제금액 − 수수료 (순액)
수수료 수익(수수료>0일 때만)FEE_REVENUECR수수료(feeAmount)

· 멱등: 멱등키 PAY:{pinSig}ux_txn_idem 이 막아 같은 인증표로 두 번 결제 불가. · 수수료는 매장 부담(MERCHANT_PAYMENT 정책 스냅샷). · PG 오픈 API 주문이면 pgOrderService.onQrPaid 로 주문 PAID 연결 + 매장서버 웹훅 예약(돈 이동은 위 단일 원장에만 기록).

8. 업무 흐름 ② 출금 AppWithdrawService + WithdrawWorker

출금은 신청 즉시 지갑에서 선차감(HOLD) 하고, 예정 시각이 되면 워커가 페이솔루스 출금요청 API로 제출하며, 이후 출금통지 웹훅으로 완료(또는 거절)가 확정됩니다. 출금은 유상(DEPOSIT) 로트만 가능(무상 적립금 출금 불가 — 0.5). 은행 점검시간에는 신청을 막습니다(BankMaintenanceGuard).

8.1 신청 = 선차감 HOLD (한 트랜잭션)
 회원앱  ─ 계좌·금액·pinPass ─►  request()
   · bankGuard.ensureOpen()  (은행 점검시간 차단)
   · requirePinPass · 계좌 존재 · 계좌변경 24h 냉각(cooldown) 확인
   · 지갑 잠금 · 유상로트 가용합 ≥ 금액 확인
   · 수수료(USER_WITHDRAW) → 순액 = 금액 − 수수료 (순액 ≤ 0 이면 거부)
   · 예정시각 = PayoutSchedules.from(정책)
   · transactions INSERT (status HOLD, 멱등키 WDR:{pinSig})
   · 분개 + Lots.consume(유상만) + 지갑 갱신 + 알림
단계분개 줄지갑DR/CR금액
신청(HOLD)회원 차감회원 지갑DR출금액(amount)
청산 대기SETTLEMENT_CLEARINGCR순액(netAmount)
수수료(>0)FEE_REVENUECR수수료
8.2~8.3 실행(WithdrawWorker, 60초) · 8.5 실패 복원
 WithdrawWorker.tick (60s)
   │ scanDue()  예정시각 지난 HOLD 목록(최대 100, 짧은 TX)
   └─ 건별 executeOne() [REQUIRES_NEW]
        · claimWithdrawal  HOLD→PENDING (0건이면 타 서버가 이미 집음 → skip)
        · ★페이솔루스 출금요청 API 제출(PaySolusPayoutApiClient.submit, 계약 전 스텁)
          → 접수번호 bankTranRef = "PNST-{txnId}", 상태 PENDING 유지(동기 확정 안 함)

 출금통지 웹훅 ─ X-Internal-Key ─► /webhooks/bank/withdraw-notice  (완료/거절 확정)
   · SUCCESS → confirmWithdrawal → CONFIRMED + "출금 완료" 알림
   · FAIL(거절) → 신청취소 · 선차감 복원(아래 실패 복원)
   (웹훅 미도착 등 결과불명은 PENDING 으로 남아 리컨실러(8.4)가 정리)

 실패 시 restoreFailed() [REQUIRES_NEW] — 역분개 + 로트 승계 복원
   · failWithdrawal (상태 전이 가드)
   · 역분개(복원 입금 거래, 멱등키 WDRBACK:{txnId}):
분개 줄(실패 복원)지갑DR/CR금액
청산 회수SETTLEMENT_CLEARINGDR순액
수수료 회수(>0)FEE_REVENUEDR수수료
회원 환급회원 지갑CR출금액(총액)

· 실패 복원 시 restoreLots원래 쓰던 로트의 유형·만료일을 그대로 승계(유효기간 손실 방지 — 8.5). · 멱등키 WDR:{pinSig} 로 이중 신청 차단.

9. 업무 흐름 ③ 선물 AppGiftService

보낼 수 있는 것은 유상 포인트뿐(무상 적립금 선물 불가 — 0.5). 발신인이 수수료 부담(USER_GIFT). 모든 실행은 pinPass 필수. 받는 사람에게는 받은 날부터 새 유효기간의 유상 로트가 생깁니다(insertReceiveLot).

9.1 선물(7.2) — 휴대폰 번호·카드번호·QR 로 즉시 전달

 sendDirect()  · 수신자 조회(전화 or 카드번호 지문) · 양쪽 지갑 번호순 잠금
   · 수수료 → 순액(≤0 거부) · 유상로트 가용 확인 · transactions(GIFT_DIRECT, CONFIRMED)
분개 줄지갑DR/CR금액
발신 차감발신 지갑DR선물액(총액)
수신 적립수신 지갑CR순액 → 수신자 새 로트 생성
수수료(>0)FEE_REVENUECR수수료

10. 업무 흐름 ④ 정산 StoreSettleService · AdminSettlementService

10.1 매장 정산 출금(8.1 매장판) — 매장 지갑은 로트 없음

매장이 쌓인 매장 지갑 잔액을 자기 계좌로 빼는 기능. 매장 지갑은 로트가 없어 회원 출금보다 단순합니다(잔액에서 바로 차감). 기본 정책은 즉시 정산(delay 0). 실행·복원은 회원 출금과 같은 WithdrawWorker 가 처리(같은 규약).

 매장앱 ─ 금액 + idempotencyKey ─► request()
   · 정산계좌·잔액 확인 · 수수료(MERCHANT_PAYOUT) → 순액(≤0 거부)
   · 예정시각(PayoutSchedules) · transactions(멱등키 SETTLE:{앱제공키})
   · 분개(아래) + 지갑 갱신 + 알림   → 이후 WithdrawWorker 가 이체 실행
분개 줄지갑DR/CR금액
매장 차감매장 지갑DR정산액(총액)
청산 대기SETTLEMENT_CLEARINGCR순액
수수료(>0)FEE_REVENUECR수수료

주의 앱이 idempotencyKey 를 주면 ux_txn_idem 이 재시도 중복을 막지만, 구버전 앱이 안 주면 새 값이 생성되어 중복 차단이 되지 않습니다(코드 주석 명시).

10.2 관리자 월 마감(재계산) — 멱등

 AdminSettlementService.closeMonth(yyyymm)
   · 월 형식·미래월 검증 → 그 달 [시작, 다음달 시작) 범위로 월 정산서 통째 재계산(17.14)
   · 같은 달을 몇 번 눌러도 값이 다시 계산되어 안전(멱등 — 18장 규약) · 감사로그 기록

· 월 마감은 집계·리포트 성격이라 원장 분개를 만들지 않습니다(자금 이동 없음). 자금 이동은 10.1 정산 출금에서만 발생.

11. 업무 흐름 ⑤ 입금 충전 AppDepositService · DepositNoticeWorker

회원이 앱에서 충전 신청을 하면 페이솔루스 입금요청 API로 신청건(deposit_requests)이 만들어지고, 그 신청에만 유효한 입금코드(NC+8자리)가 발급됩니다. 회원이 신청한 금액 그대로 그 코드를 은행 이체 입금자명에 적어 보내면, 은행/PG 입금통지가 웹훅으로 들어오고, 워커가 코드로 신청건을 찾아 신청 금액과 정확히 일치할 때만 적립하며 그 외(무신청·금액 불일치)는 미매칭 보관합니다. 페이솔루스 입금요청 API는 계약 전 스텁입니다.

· 충전 규칙: 무신청 충전 불가 · 회원당 진행 중 신청은 1건(새 신청 시 이전 PENDING 자동취소) · 신청 금액과 정확 일치할 때만 적립 · 입금통지 미도착 시 만료 워커가 PENDING→EXPIRED 처리(단 만료·취소 후 늦게 입금돼도 코드+금액이 맞으면 적립 — 환불이 어려움) · 충전(1회/1일/월)·보유 한도는 신청 시점에 검사.

4.1 통지 수신(웹훅, 멱등) → 4.2b 워커 클레임(10초) → 4.3 적립 / 4.4 미매칭
 은행/PG ─ X-Internal-Key ─► BankWebhookController /webhooks/bank/deposit-notice
   · 내부키 상수시간 비교 · receiveNotice():
       dedupKey = SHA256(source:bankTranRef)   ← 펌뱅킹(거래식별자 있음)
                = SHA256(source:rawText)        ← SMS(원문 지문)
     INSERT 시 DuplicateKeyException(1062) → 이미 받은 통지면 조용히 멱등 처리(INSERT IGNORE 금지)

 DepositNoticeWorker.tick (10s)  claimNewNotices(FOR UPDATE, 최대 100)
   └─ 건별 processNotice() [REQUIRES_NEW]
        1) 신청건 매칭: 입금코드 → deposit_requests(진행 중) → 회원 → 충전 대상 카드(대표 우선), 신청 금액 정확 일치 확인
        2) 충전 강제 규약: 회원이 "인증계좌(실명+1원)" 보유해야만 자동 적립
        3) 재발행 카드면 새 카드로 갈아탐(REISSUED → reissued_to)
분기DRCR후속
4.3 확정(신청건·금액 일치, 인증계좌 있음)SETTLEMENT_CLEARING 총액회원 지갑 순액 · FEE_REVENUE 수수료(>0)유상 로트 생성(DEPOSIT 만료개월) · 지갑 갱신 · 충전완료 알림
4.4 미매칭(무신청·금액 불일치·인증계좌 없음)SETTLEMENT_CLEARING 금액UNMATCHED 금액미매칭 큐 적재 → 관리자 수동 매칭/반환

· 멱등키 DEP:{source}:{dedupKey} 로 같은 통지의 이중 적립을 차단(DuplicateKeyException → TX 롤백). · 워커는 통지별 독립 TX 라 한 건 실패가 다른 건에 영향 없음(실패 통지는 NEW 로 남아 다음 바퀴 재시도).

12. 업무 흐름 ⑥ 포인트 압류 SeizureService · AdminSeizureService

12.1 묶기 → 반환 또는 소각

사고(도용·분쟁·수사 협조)가 나면 그 돈이 빠져나가지 못하게 묶습니다. "쓸 수 있는 금액"을 따로 계산하지 않고 돈을 실제로 옮깁니다 — 돈 쓰는 곳(결제·출금·선물·정산)마다 검사를 넣는 방식은 한 곳만 빠뜨려도 묶은 돈이 새어 나가기 때문입니다.

 seize()   지갑 → SEIZED 로 옮김. 잔액이 줄어 어디서도 쓸 수 없음
   └ 대상 DR 금액 / SEIZED CR 금액  (+회원은 만료 임박 로트부터 차감)   [seizures.status HELD]
 refund()  조사 결과 정상 → 되돌림
   └ SEIZED DR 금액 / 대상 CR 금액  (+원래 유효기간을 이어받은 새 로트 = SEIZE_RESTORE)
 burn()    사기 확정 → 소각(회원에게 돌아가지 않음)
   └ SEIZED DR 금액 / SEIZE_BURNED CR 금액   (시스템 지갑끼리라 잔액 체인 없음)
이벤트DRCR로트
묶기대상 지갑SEIZED만료 임박 순 차감(매장은 로트 없음)
반환SEIZED대상 지갑원 유효기간 승계 복원(SEIZE_RESTORE)
소각SEIZEDSEIZE_BURNED없음(이미 차감됨)

· 거래는 ADJUST 계열 — SEIZE · SEIZE_RETURN · SEIZE_BURN. · 전부 아니면 전무(부분 반환·부분 소각 없음) — 나눠 처리해야 하면 압류를 나눠서 겁니다. · 앱에는 묶인 금액만 표시하고 관리자가 적은 사유는 내부용입니다(GET /app/me/seizure·/store/seizure). · 중복 처리 방지: lockOne 으로 잠근 뒤 HELD 인 것만 끝냅니다.

NestPay 산출물 · (주)페이네스트 · 작성일 2026-07-27 · 실제 코드/DB 기준 · 외부연동(펌뱅킹·본인인증·은행 실명조회)은 계약 전 스텁 상태임을 명시