NestPay · API 공통 규약과 외부기관 연계 지점 전수 정의 · 실제 코드 기준
문서 목적 — 본 문서는 NestPay API 서버(apps/api)가 대내외에 제공·소비하는 인터페이스의 규약을 정의한다. 크게 두 부분으로 구성한다.
상태 범례: 구현 코드 구현 완료 · 스텁 외부기관 계약 전 개발용 대역 · 부분 일부 구현·일부 연동 대기
서버의 모든 정상 응답은 성공·실패를 불문하고 동일한 포장지(ApiResponse<T>, record·불변)로 감싸 내려간다. 화면(회원앱·매장앱·관리자 웹)은 언제나 같은 방식으로 결과를 꺼내 쓴다.
| 필드 | 타입 | 성공 시 | 실패 시 | 설명 |
|---|---|---|---|---|
success | boolean | true | false | 성공 여부 |
data | T | 실제 내용 | null | 돌려줄 본문(잔액·목록 등) |
error | String | null | 오류 메시지 | 사람이 읽는 한글 안내문 |
meta | Object | 목록 정보(선택) | null | 전체 개수·페이지 등 부가정보 |
생성 팩토리: ApiResponse.ok(data) · ApiResponse.ok(data, meta) · ApiResponse.fail(error). (근거: apps/api/.../dto/ApiResponse.java)
// 성공
{ "success": true, "data": { "cardId": 12, "balance": 30000 }, "error": null, "meta": null }
// 목록(meta 포함)
{ "success": true, "data": [ ... ], "error": null, "meta": { "total": 137, "page": 1, "limit": 20 } }
// 실패
{ "success": false, "data": null, "error": "PIN 이 올바르지 않습니다.", "meta": null }
어디서 오류가 나든 @RestControllerAdvice 안전망(GlobalExceptionHandler)이 한곳에서 받아 정해진 HTTP 코드 + 공통 ApiResponse.fail(...) 모양으로 응답한다. 서버 내부 오류(500)는 사용자에게 일반 메시지만 보이고, 상세는 콘솔 로그와 오류 장부(app_error_logs)에 traceId로 연결해 남긴다.
| HTTP | 구분 | 발생 예외 / 트리거 | 사용자에게 내려가는 메시지(예) |
|---|---|---|---|
| 400 | 입력값 검증 | MethodArgumentNotValidException (@NotBlank/@Positive 등) | 검증 실패 항목의 안내문(필드 message) |
| 400 | 업무 규칙 위반 | ApiException.badRequest(...) | 업무별 메시지(A-3 참조) |
| 400 | 깨진/빈 JSON | HttpMessageNotReadableException | "요청 본문을 읽을 수 없습니다. 형식을 확인해 주세요." |
| 400 | 경로·쿼리 형식오류 | MethodArgumentTypeMismatchException | "요청 값의 형식이 올바르지 않습니다." |
| 401 | 인증 실패 | ApiException.unauthorized(...) | "로그인이 필요합니다." 등 |
| 403 | 권한 없음 | ApiException.forbidden(...) | "이용이 제한된 계정입니다." 등 |
| 404 | 대상 없음 | ApiException.notFound(...) | "주문을 찾을 수 없습니다." 등 |
| 404 | 없는 주소 | NoResourceFoundException | "요청하신 주소를 찾을 수 없습니다." |
| 409 | 중복(멱등 충돌) | DuplicateKeyException (유니크 제약 ux_txn_idem) | "이미 처리된 요청입니다. (중복 방지)" |
| 429 | 이용제한 | ApiException.tooManyRequests(...) (RateLimitService) | "짧은 시간에 너무 많이 시도했습니다. 잠시 후 다시 시도해 주세요." |
| 500 | 그 밖의 모든 오류 | 위에서 걸러지지 않은 Exception | "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." (+ app_error_logs 기록) |
인증 문지기(HmacAuthFilter·UserAuthFilter·StoreAuthFilter·AdminAuthFilter·IpWhitelistFilter)는 컨트롤러 앞단에서 동작하므로 위 예외 처리기를 거치지 않는다. 대신 FilterResponses.reject(...)가 동일한 공통 포맷({success:false, error:"..."})으로 401을 직접 반환한다. 즉 인증 실패의 응답 모양은 필터 단계든 컨트롤러 단계든 일치한다.
근거: apps/api/.../config/GlobalExceptionHandler.java, security/FilterResponses.java
업무 규칙 위반은 서비스 계층에서 ApiException 정적 팩토리로 던진다. 팩토리마다 HTTP 상태가 고정되어 있어 상태 매핑이 중복 없이 일관된다. (근거: config/ApiException.java 및 각 service/*)
| 팩토리 | HTTP | 의미 | 코드에 실재하는 대표 메시지(발췌) |
|---|---|---|---|
badRequest() | 400 | 입력값·업무 규칙 위반 | "PIN 은 숫자 6자리여야 합니다." · "본인 명의 계좌가 아닙니다. 본인 이름의 계좌만 등록할 수 있습니다." · "1원인증이 만료되었습니다. 다시 요청하세요." · "NESTPAY 결제 QR 이 아닙니다." · "PG 연동을 먼저 신청하세요." |
unauthorized() | 401 | 인증 실패(비번·OTP·PIN·토큰) | "PIN 이 올바르지 않습니다." · "거래 비밀번호 인증이 필요합니다." · "이미 사용된 인증입니다. 다시 인증해 주세요." · "OTP 숫자가 맞지 않습니다. …" · "등록되지 않은 패스키입니다. …" |
forbidden() | 403 | 자격 없음(로그인은 됨) | "이용이 제한된 계정입니다. 고객센터에 문의하세요." · "이 관리자에게 허용되지 않은 IP 입니다." · "본 매장의 결제가 아닙니다." · "PIN 이 잠겼습니다. 30분 후 …" |
notFound() | 404 | 대상 없음 | "주문을 찾을 수 없습니다." |
tooManyRequests() | 429 | 이용제한 초과 | "짧은 시간에 너무 많이 시도했습니다. 잠시 후 다시 시도해 주세요." |
※ 409(중복)는 팩토리가 아니라 DB 유니크 제약 위반(DuplicateKeyException)을 예외 처리기가 잡아 매핑한다. 즉 이중결제·중복출금·중복선물은 애플리케이션 판단이 아니라 DB 레벨 멱등키(ux_txn_idem)로 최종 차단된다.
NestPay는 서버 2대 이중화(L4 뒤 공유 DB) 구조이므로 세션을 서버 메모리에 두지 않는 무상태(stateless) 토큰을 쓴다. 토큰은 AdminTokenService 하나가 발급·검증을 전담한다(A/M/U/P를 포함한 모든 종류 공용).
토큰 = base64url(payload) . hex( HMAC-SHA256(서버비밀키, base64url(payload)) )
payload = type | subjectId | expiresEpoch | extra
· 내용을 한 글자라도 고치면 도장(HMAC)이 안 맞아 즉시 무효
· 서버에 저장하지 않으므로 2대 어느 서버에서도 동일 검증(무상태)
· 검증 시 도장 대조는 MessageDigest.isEqual 로 타이밍 공격 방지
· 비밀키는 운영에서 환경변수 ADMIN_TOKEN_SECRET 로 주입(기본값 사용 시 부팅 차단 — B-3)
전달 방식: 세션 토큰(A/M/U)은 Authorization: Bearer <토큰> 헤더. PIN 거래표(P)·본인인증표(I)·1원인증표(W) 등은 각 API 요청 본문 필드로 전달.
| type | 주체(subjectId) | 용도 | 검증 위치 | 비고 |
|---|---|---|---|---|
| A | 관리자 ID | 관리자 세션(로그인 완료) | AdminAuthFilter (/admin/*) | OTP까지 통과한 정식 출입증 |
| M | 매장(merchant) ID | 매장앱 세션 | StoreAuthFilter (/store/*) | 가입·로그인 경로는 무인증 |
| U | 회원(user) ID | 회원앱 세션 | UserAuthFilter (/app/me/*) | 가입·공개콘텐츠는 무인증 |
| P | 회원 ID | pinPass — 거래 인증표 | AppPinService.requirePinPass | 유효 3분, 1회용(결제·출금·선물마다 재인증) |
| O | 관리자 ID | OTP 숫자 입력 대기(임시) | AdminAuthService | 비번은 통과, OTP 남음 |
| S | 관리자 ID | OTP 최초등록 대기(임시) | AdminAuthService | extra에 등록용 씨앗(Base32) 품음 |
| I | 0(가입 전) | 본인인증 통과표(가입용) | AppAuthService | 인증사 결과를 봉인해 가입에 사용 |
| W | 회원/매장 ID | 1원인증 확인표(계좌 등록) | AppBankService·StoreBankService | 계좌·코드 지문을 묶음(원문 미포함) |
| R | 회원 ID | 패스키 등록 챌린지 | AppPasskeyService | WebAuthn 등록용 |
| K | 0(로그인 시작) | 패스키 로그인 챌린지 | AppPasskeyService | 사용자 확정 전 단계 |
| T | 회원 ID | 패스키 로그인 확정표 | AppPasskeyService | 사용자 특정 후 발급 |
간편 PIN(숫자 6자리, Argon2id 해시, 5회 오류 시 30분 잠금)을 맞히면 3분짜리 거래 인증표(P 토큰)를 발급한다. 결제·출금·선물·취소·카드열람 API는 이 표가 있어야만 실행되며(비밀번호 로그인만으로는 불가), 표의 도장(서명)은 NonceMapper.consume으로 단 한 번만 소진된다.
근거: security/AdminTokenService.java, security/{User,Store,Admin,Hmac}AuthFilter.java, service/AppPinService.java
하나의 API 서버가 4개 그룹을 제공하며, Swagger(springdoc) 문서도 그룹별로 분리 생성된다. 내부 3그룹(app/store/admin)과 외부 1그룹(pg)의 노출 통제 정책이 다르다.
| 그룹 | 경로 | 사용 주체 | 인증 | 문서(Swagger) 노출 |
|---|---|---|---|---|
| app | /app/** | 회원앱 | U 토큰(+ P/I/W 표) | api 호스트 · 사내 허용 IP 제한 |
| store | /store/** | 매장앱 | M 토큰(+ P/W 표) | api 호스트 · 사내 허용 IP 제한 |
| admin | /admin/** | 관리자 백오피스 | 화이트 IP + A 토큰 | api 호스트 · 사내 허용 IP 제한 |
| pg | /pg/** | 매장 쇼핑몰 서버(외부) | HMAC 서명 + 승인 화이트IP | pg 호스트 · 외부 공개 (매장 개발자용) |
SecurityFilterConfig · docs-ip.url-patterns): 내부 스펙 /v3/api-docs, /v3/api-docs/{app,store,admin}은 관리자와 동일한 사내 허용 IP(admin_allowed_ips + bootstrap)에서만 열람. pg 스펙(/v3/api-docs/pg)과 Swagger UI 껍데기는 제한 대상 아님.SWAGGER_ENABLED=false로 문서 자체를 꺼야 한다(미설정 시 부팅 차단 — B-3).근거: config/OpenApiConfig.java, config/SecurityFilterConfig.java, resources/application.yml
RateLimitService가 동작 키(actionKey)별 규칙(rate_limit_rules)을 읽어 고정 창(window_sec) 단위로 대상(IP 또는 USER)별 호출 수를 세고, max_count 초과 시 429를 던진다. 규칙은 관리자 화면에서 창 크기·최대 횟수·기준(IP/USER)·on/off를 수정할 수 있다.
근거: service/RateLimitService.java, mapper/RateLimitMapper.java
외부기관·외부 시스템과 맞닿는 연동 지점 전수와 현재 상태다. 돈(이체)·신원(본인확인)·계좌(실명·1원)는 계약 전이라 개발용 스텁으로 동작하며, 스텁 상태로는 운영(sandbox/live) 기동 자체가 차단된다(B-3).
| # | 연계 지점 | 방향 | 인터페이스(코드) | 상태 |
|---|---|---|---|---|
| 1 | 은행 펌뱅킹 실이체 (출금·정산) | 아웃바운드(우리→은행) | WithdrawWorker/AppWithdrawService.executeOne | 스텁 |
| 2 | 본인인증(인증사, PASS/NICE 등) | 아웃바운드 | IdentityVerifier ← StubIdentityVerifier | 스텁 |
| 3 | 은행 계좌 실명조회 | 아웃바운드 | BankVerifier.verifyHolder ← StubBankVerifier | 스텁 |
| 4 | 은행 1원(소액) 인증 | 아웃바운드 | BankVerifier.sendOneWon ← StubBankVerifier | 스텁 |
| 5 | 입금 통지 수신 웹훅 | 인바운드(은행/PG→우리) | BankWebhookController · POST /webhooks/bank/deposit-notice | 부분 |
| 6 | FCM 폰 화면 푸시 | 아웃바운드 | AdminPushService(설정) · PushDispatchService/OutboxWorker(발송) | 부분 |
| 7 | PG 오픈API (매장 연동) | 인바운드(매장→우리) | /pg/** · HmacAuthFilter | 구현 |
| 8 | 매장 웹훅 발송(결제·정산 통지) | 아웃바운드(우리→매장) | WebhookSender · X-Nestpay-Signature | 구현 |
| 9 | 국세청(NTS) 사업자 진위 | 아웃바운드 | 스키마 external_api_logs.provider 예약 | 미구현 |
외부기관 호출·응답은 external_api_logs(provider = FIRMBANK|NICE|NTS|FCM|WEBHOOK_OUT 등)에 마스킹 후 적재해 리컨실·분쟁 추적에 사용하도록 스키마가 설계되어 있다.
목적 — 회원 출금과 매장 정산 시 등록 계좌로 실제 자금을 이체한다.
인터페이스 — 출금 워커(WithdrawWorker, 1분 주기)가 예정 시각이 지난 HOLD 건을 잡아 AppWithdrawService.executeOne에서 이체를 실행. 매장 정산(StoreSettleService·PG /pg/settlements)도 동일한 출금 실행 경로를 재사용한다.
현재 상태 — 펌뱅킹 계약 전이므로 이체는 스텁: HOLD→PENDING 클레임 후 항상 성공으로 처리하고 CONFIRMED로 확정. 은행 점검시간에는 BankMaintenanceGuard.ensureOpen이 신청을 차단.
전환 지점 — AppWithdrawService.executeOne 내 "펌뱅킹 이체 연동 지점" 주석 위치에서 실제 이체를 호출하고, 실패/UNKNOWN 처리와 리컨실러를 연결(8.5 실패 복원: 역분개 + 원 로트·만료일 승계는 이미 구현).
목적 — 회원 가입·재인증 시 실명·생년월일·전화·성별과 CI/DI(연계·중복가입 확인정보)를 인증사로부터 확보. "1인 1활성계정" 규칙의 기준값.
인터페이스 — IdentityVerifier.verify(name, birthDate, phone, gender) → IdentityResult(name, birthDate, phone, gender, ci, di). 성공 시 결과를 봉인한 본인인증표(I 토큰)를 발급해 가입에 사용.
현재 상태 — StubIdentityVerifier가 동작: 같은 입력은 항상 같은 CI/DI(실서비스 성질 모사), 이름이 "실패"로 시작하면 인증 실패(400) 반환. 임의 명의로 통과 가능하므로 운영 부적합.
전환 지점 — 인증사(PASS/NICE 등) SDK·API를 호출하는 클래스로 IdentityVerifier를 구현·교체(스텁 삭제). 호출 측 코드는 무변경(인터페이스 유지). NICE는 external_api_logs.provider에 예약됨.
목적 — 회원·매장이 등록하려는 계좌의 예금주명이 본인(인증 실명)과 일치하는지 확인(실명조회)하고, 통장에 찍히는 4자리 코드로 계좌 소유를 확인(1원인증)한다. 계좌 등록 3요건의 핵심.
인터페이스 — BankVerifier.verifyHolder(bankCode, accountNo, expectedName) → HolderResult(match, holderName) · BankVerifier.sendOneWon(bankCode, accountNo) → 4자리 코드. 1원인증은 W 토큰(verifyToken)에 계좌·코드 지문을 묶어 확인(코드 원문 미저장).
현재 상태 — StubBankVerifier: 계좌번호가 9로 끝나면 불일치("김타인") 반환(불일치 흐름 시험), 그 외 일치. 1원인증은 실입금 없이 코드만 생성(dev 모드에서만 debugCode로 응답에 노출, 운영에선 미노출).
전환 지점 — 펌뱅킹/오픈뱅킹 계약 확정 시 실연동 클래스로 BankVerifier를 교체(스텁과 맞교환). 호출 측 무변경.
목적 — 은행/PG(가상계좌·펌뱅킹) 또는 SMS 파서가 입금 사실을 서버에 통지 → 적립/미매칭 처리.
인터페이스 — POST /webhooks/bank/deposit-notice. 헤더 X-Internal-Key(환경변수 INTERNAL_API_KEY와 시간차 없는 비교로 검증). 본문: source(PGVACCT|FIRMBANK|SMS), amount, depositorName, identifierValue, bankTranRef, rawText(증거).
현재 상태 — 수신 문·내부 열쇠 인증·중복 재수신 안전(멱등)·워커 처리(DepositNoticeWorker, 10초 주기, 2대 중복 처리 방지)까지 구현 완료. 다만 은행/PG의 실제 서명 규격이 아니라 내부 키 방식이므로, 계약 확정 시 해당 규격으로 인증부만 교체 필요.
전환 지점 — BankWebhookController의 내부 키 검사(주석 "★펌뱅킹/PG 계약 확정 시 그쪽 규격(서명 방식)으로 교체")를 실제 서명 검증으로 교체.
목적 — 알림함(inbox) 저장에 더해 실제 단말 화면 푸시 발송.
인터페이스(설정형) — 관리자가 AdminPushService를 통해 Firebase projectId 입력 + 서비스 계정 키(JSON) 업로드 + on/off 저장(설정값 PUSH_FCM_PROJECT_ID·PUSH_FCM_KEY_FILE_ID). 켜려면 키 파일 선행 필수. 단말 토큰은 push_tokens 테이블에 보관.
현재 상태 — 설정 UI·키 보관·알림함 적재·캠페인 발송 파이프라인(야간 광고 차단, 키셋 청크, 재시도 안전)은 구현. 단말로의 FCM 실발송은 미연동(푸시 키 발급 후 연동 예정).
전환 지점 — PushDispatchService.dispatch의 "FCM 실발송 연동 지점"(push_tokens 조회 후 발송) 및 OutboxWorker의 payload 소비부. payload는 이미 그대로 사용 가능하도록 구성.
목적 — 매장 쇼핑몰 서버가 결제 생성·조회·취소·정산요청을 호출하는 외부 공개 API.
인터페이스 — /pg/**. HmacAuthFilter가 헤더 4종(X-Client-Id, X-Api-Key, X-Timestamp, X-Signature)을 검증. 서명 = HMAC-SHA256(비밀키, "METHOD\n경로\n시각\n본문") hex 소문자. 시각 오차 300초 초과 시 거부(재사용 공격 방지). 주요 엔드포인트: POST /pg/ping(연결시험), POST /pg/payments, GET /pg/payments/{id}, POST /pg/payments/{id}/cancel, GET /pg/balance, POST /pg/settlements.
현재 상태 — 구현 완료. 비밀키는 DB에 해시만 저장(원문 미보관)하며 키 교체 유예 중에는 직전 키도 인정. 서명 통과 후 매장별 승인 화이트IP(PgCredentialMapper.selectApprovedCidrs) 검사까지 통과해야 호출 성립(V7 확정 요구).
비고 — 인증 실패는 모두 401 + 공통 포맷(A-2 필터 경로). 미승인 IP·화이트IP 미등록도 거부.
목적 — 결제·정산 등 이벤트를 매장 서버로 통지(콜백).
인터페이스 — WebhookSender(20초 주기)가 발송함(webhook_deliveries)을 훑어 매장 target_url로 POST. 헤더 X-Nestpay-Event, 서명 X-Nestpay-Signature = HMAC-SHA256(매장 webhook_secret, 본문). 매장은 이 서명으로 진위 확인.
현재 상태 — 구현 완료. 2xx면 DELIVERED, 실패 시 지수 백오프 재시도(최대 10회, 최대 60분 간격), 소진 시 EXHAUSTED + 오류 장부 기록. 2대 서버 동시 실행 시 UUID 집기 표식으로 중복 발송 방지. 웹훅 시크릿은 암호화 저장(복호화해 서명).
목적/상태 — 매장 사업자등록 진위 확인용으로 external_api_logs.provider에 NTS(국세청)가 예약되어 있으나, 현재 코드에 호출 구현은 없음(스키마 예약 단계). 필요 시 아웃바운드 연동으로 추가.
스텁·개발 기본값이 운영에 남아 사고로 이어지지 않도록, 기동 단계에서 서버를 세우는 두 개의 안전장치가 있다.
| 안전장치 | 실행 시점 | 차단 조건 (app.env ≠ dev) |
|---|---|---|
StubGuard(ApplicationRunner) | 기동 직후 | BankVerifier가 StubBankVerifier이거나 IdentityVerifier가 StubIdentityVerifier이면 예외 → 부팅 중단. (돈·신원 무검증 운영 오픈 금지) |
SecretsGuard(EnvironmentPostProcessor, DB 접속 전) | 기동 아주 초기 | 개발 기본값이 남은 경우 부팅 중단: APP_CRYPTO_KEY, ADMIN_TOKEN_SECRET, INTERNAL_API_KEY, DB_PASSWORD, STORAGE_SECRET_KEY. 또한 SWAGGER_ENABLED가 false가 아니면(내부 스펙 노출) 부팅 중단. |
근거: config/StubGuard.java, config/SecretsGuard.java, resources/application.yml(app.env=NESTPAY_ENV)
운영 전환 체크리스트(요약) — ① 은행/본인인증/실명·1원 스텁을 실연동 구현으로 교체 → ② 운영 환경변수(위 5종 + NESTPAY_ENV=sandbox|live)를 강한 값으로 주입 → ③ SWAGGER_ENABLED=false → ④ 입금통지 웹훅 인증을 은행/PG 규격 서명으로 교체 → ⑤ FCM 실발송 연동. ①②③ 미완료 시 서버가 기동 자체를 거부한다.
NestPay 산출물 · (주)페이네스트 · 작성일 2026-07-27 · 실제 코드/DB 기준 · 외부연동(펌뱅킹·본인인증·은행 실명조회)은 계약 전 스텁 상태임을 명시