No description
Find a file
NillPoe a01b51fe83
feat: 현금영수증 문자 전송 제거
- PaySync API에서 POST /v2/cash-receipts/{id}/send-sms가 없어져 /api/cash-receipts/{id}/send-sms 엔드포인트, 서비스 메서드, SendCashReceiptSmsRequest 삭제
- 테스트와 README에서 문자 전송 제거 (240009 테스트는 이메일 전송으로 변경)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:01:55 +09:00
gradle/wrapper Initial 2026-03-04 10:36:01 +09:00
src feat: 현금영수증 문자 전송 제거 2026-10-04 19:01:55 +09:00
.gitattributes Initial 2026-03-04 10:36:01 +09:00
.gitignore Initial 2026-03-04 10:36:01 +09:00
build.gradle feat: PaySync v2 API로 이전 2026-10-04 06:09:25 +09:00
gradlew Initial 2026-03-04 10:36:01 +09:00
gradlew.bat Initial 2026-03-04 10:36:01 +09:00
README.md feat: 현금영수증 문자 전송 제거 2026-10-04 19:01:55 +09:00
settings.gradle refactor: 웹훅 검증을 위해 헤더 구조 단순화 2026-03-04 12:50:25 +09:00

PaySync 샘플 백엔드 (Java + Spring Boot)

PaySync REST API(v2)를 호출하고 PaySync 웹훅(v2)을 받는 가맹점 백엔드 예제입니다. Spring Boot 4의 RestClient로 PaySync API를 호출하고, 웹훅 서명 검증에는 Standard Webhooks 공식 Java 라이브러리를 사용합니다.

API 명세는 PaySync API 문서를 참고하세요.

요구사항

  • JDK 21 이상 (Gradle 툴체인 languageVersion = 21)
  • PaySync API 키, 웹훅 서명 키(whsec_...)

프로젝트 구조

src/main/java/kr/paysync/sample/backend/
├── DemoApplication.java
├── paysync/        PaySync 설정(PaySyncProperties), RestClient 설정, 공통 응답 모델, 예외 처리
├── invoice/        주문        (controller / service / dto / model)
├── cashreceipt/    현금영수증  (controller / service / dto / model)
├── transaction/    입금 내역   (controller / service / model)
├── bankaccount/    입금이 들어온 계좌 모델
└── webhook/        웹훅 수신, 서명 검증, 이벤트 모델

RestClientConfig가 base URL과 Authorization: Bearer <API 키> 헤더를 붙인 RestClient를 만듭니다. PaySync v2는 성공하면 항상 HTTP 200과 "code": "10001"을 응답하며(주문 생성 포함), 각 서비스는 이 값을 확인합니다.

PaySync 응답 종류 형식
단건 { "code", "data" }
목록 { "code", "items", "totalItems" }
데이터 없음 { "code" }

API 응답의 시각은 Unix 초(정수)이고, 웹훅 페이로드의 시각만 ISO 8601 문자열입니다. PaySync가 값을 추가할 수 있는 필드(은행, 매칭 방식, 매칭 실패 사유 등)는 enum 대신 문자열로 받고, 모르는 필드는 무시합니다.

설정

src/main/resources/application.yaml의 paysync 항목을 채우거나, 환경 변수로 설정합니다. (Spring Boot의 relaxed binding으로 paysync.api-key ↔ PAYSYNC_API_KEY가 연결됩니다.)

환경 변수 필수 기본값 설명
PAYSYNC_BASE_URL https://api.paysync.kr PaySync API 주소
PAYSYNC_API_KEY ✔ PaySync API 키 (sk_live_...)
PAYSYNC_WEBHOOK_SIGNING_SECRET ✔ 웹훅 서명 키 (whsec_...)
SERVER_PORT 8080 샘플 서버 포트

API 키와 서명 키는 application.yaml에 적어 커밋하지 말고 환경 변수로 넘기세요.

# macOS / Linux
export PAYSYNC_API_KEY=발급받은_API_키
export PAYSYNC_WEBHOOK_SIGNING_SECRET=whsec_...
# Windows PowerShell
$env:PAYSYNC_API_KEY = "발급받은_API_키"
$env:PAYSYNC_WEBHOOK_SIGNING_SECRET = "whsec_..."

실행

./gradlew bootRun      # Windows: gradlew.bat bootRun

테스트

./gradlew test         # Windows: gradlew.bat test

PaySync API는 MockRestServiceServer로, 샘플 서버는 MockMvc로 호출하므로 API 키 없이 실행됩니다.

엔드포인트

샘플 서버 PaySync API 성공 응답
POST /api/invoices POST /v2/invoices 201 + 주문
GET /api/invoices GET /v2/invoices 200 + { items, totalItems }
GET /api/invoices/{id} GET /v2/invoices/{id} 200 + 주문 (history 포함)
PATCH /api/invoices/{id} PATCH /v2/invoices/{id} 200 + 주문
POST /api/invoices/{id}/mark-paid POST /v2/invoices/{id}/mark-paid 200 + 주문
POST /api/invoices/{id}/cancel POST /v2/invoices/{id}/cancel 200 + 주문
POST /api/cash-receipts POST /v2/cash-receipts 201 + 현금영수증
GET /api/cash-receipts GET /v2/cash-receipts 200 + { items, totalItems }
GET /api/cash-receipts/{id} GET /v2/cash-receipts/{id} 200 + 현금영수증
POST /api/cash-receipts/{id}/cancel POST /v2/cash-receipts/{id}/cancel 200 + 취소 영수증
POST /api/cash-receipts/{id}/reissue POST /v2/cash-receipts/{id}/reissue 201 + 새 현금영수증
GET /api/cash-receipts/{id}/urls GET /v2/cash-receipts/{id}/urls 200 + { view, print, pdf }
POST /api/cash-receipts/{id}/send-email POST /v2/cash-receipts/{id}/send-email 204
GET /api/transactions GET /v2/transactions 200 + { items, totalItems }
GET /api/transactions/{id} GET /v2/transactions/{id} 200 + 입금
POST /api/webhooks (PaySync → 샘플 서버) 200

목록 조회 쿼리 파라미터 (offset 기본값 0, limit 기본값 20, 나머지는 값이 있을 때만 PaySync로 전달)

  • /api/invoices: offset, limit, search, status, due, source, createdBy, dateFrom, dateTo
  • /api/transactions: offset, limit, search, bankAccountId, status, matchMethod, dateFrom, dateTo
  • /api/cash-receipts: offset, limit, search, status, type, method, kind, invoiceId, dateFrom, dateTo

dateFrom, dateTo는 YYYY-MM-DD 형식의 한국 시간 날짜이며 두 날짜 모두 포함합니다. status, source, bankAccountId는 쉼표로 여러 값을 이어 보낼 수 있습니다 (예: status=PENDING,EXPIRED).

요청 본문

  • 주문 생성: customer.name(필수), customer.email, customer.phoneNumber, amount(필수), bankAccountIds, expiresAt(Unix 초), expireAfterDays(1~365), cashReceipt.type, cashReceipt.identifier, metadata, sendGuideEmail. 값이 없는 선택 필드는 PaySync로 보내지 않습니다.
  • 주문 수정: 받은 JSON 객체를 그대로 전달합니다. 보낸 필드만 바뀌고, null을 보내면 값을 지웁니다 (customer.email, customer.phoneNumber, expiresAt, cashReceipt). 바꿀 수 있는 필드는 customer.name, customer.email, customer.phoneNumber, amount, expiresAt, extendDays, bankAccountIds, cashReceipt, resendGuideEmail입니다.
  • 주문 취소: reason(선택, 200자까지). 본문 없이 호출해도 됩니다.
  • 현금영수증 발급: type(필수, PERSONAL/CORPORATE), identifier(selfIssued가 true가 아니면 필수), selfIssued, amount(필수), customerName, invoiceId.
  • 현금영수증 취소: reason(필수, 1~100자), amount(빼면 남은 금액 전체).
  • 현금영수증 재발급: identifier, type, selfIssued.
  • 이메일 전송: email(필수).

샘플 서버는 필수 필드와 JSON 타입만 확인합니다. 길이와 형식은 PaySync가 검사하고 오류 code로 알려 줍니다.

예시

curl -X POST http://localhost:8080/api/invoices \
  -H 'Content-Type: application/json' \
  -d '{"customer":{"name":"홍길동"},"amount":50000,"expireAfterDays":1,"metadata":{"orderId":"ORDER-0001"}}'

curl 'http://localhost:8080/api/invoices?status=PENDING&limit=10'

# 입금 기한을 3일 늘리고 구매자 이메일을 지웁니다.
curl -X PATCH http://localhost:8080/api/invoices/ivc_... \
  -H 'Content-Type: application/json' \
  -d '{"extendDays":3,"customer":{"email":null}}'

curl -X POST http://localhost:8080/api/invoices/ivc_.../cancel \
  -H 'Content-Type: application/json' \
  -d '{"reason":"고객 요청으로 주문 취소"}'

에러 응답

에러는 { "code": "...", "message": "...", "data": ... } 형식이며, paysync/exception/PaySyncExceptionHandler.java에서 한곳에 모아 처리합니다. data는 PaySync 오류 응답에 data가 있을 때만 들어 있습니다.

상황 상태 코드 code
잘못된 요청 본문 / 쿼리 파라미터, 필수 필드 누락 400 BAD_REQUEST
PaySync가 4xx와 code를 응답 PaySync의 상태 코드 (예: 404, 409, 422) PaySync의 code (예: 20014)
그 밖의 PaySync 실패 (5xx, JSON이 아닌 본문, 연결 실패, 10001이 아닌 성공 응답) 502 PaySync의 code 또는 UPSTREAM_ERROR

같은 입금자명과 금액의 결제 대기 주문이 이미 있으면 기존 주문 ID가 함께 옵니다.

{
  "code": "220010",
  "message": "PaySync API 요청이 거부되었습니다.",
  "data": { "invoiceId": "ivc_n0o1p2q3r4s5t6u7v8w9x0y1", "number": 1042 }
}

code 목록은 에러 코드 문서를 참고하세요.

웹훅

POST /api/webhooks는 webhook-id, webhook-timestamp, webhook-signature 헤더와 파싱하기 전의 원본 본문(@RequestBody String)으로 서명을 검증합니다 (webhook/service/WebhookServiceImpl.java).

v2 웹훅은 모든 이벤트가 같은 봉투에 담겨 옵니다.

{
  "id": "evt_f1g2h3i4j5k6l7m8n9o0p1q2",
  "type": "invoice.paid",
  "timestamp": "2026-04-28T03:20:12Z",
  "data": { "id": "ivc_...", "paidTrigger": "AUTOMATIC_MATCHING", "...": "..." }
}
이벤트 data 샘플의 처리
invoice.created 주문 주문이 생성되었습니다: ivc_... (API) 로그
invoice.updated 주문 주문이 수정되었습니다: ivc_... 로그
invoice.paid 주문 주문이 결제 완료되었습니다: ivc_... (AUTOMATIC_MATCHING) 로그
invoice.expired 주문 주문이 만료되었습니다: ivc_... 로그
invoice.canceled 주문 주문이 취소되었습니다: ivc_... (취소 사유) 로그
invoice.reopened 주문 결제 매칭이 해제되어 주문이 다시 열렸습니다: ivc_... (PENDING) 로그
transaction.created 입금 새 입금이 들어왔습니다: trx_... (홍길동 50000원) 로그
그 밖의 이벤트 무시하고 200 응답
  • invoice.paid의 결제 완료 경로는 data.paidTrigger(AUTOMATIC_MATCHING, MANUAL_MATCHING, MANUAL_APPROVE, API_CALL)로 구분합니다.
  • 웹훅의 주문 페이로드는 API 주문 객체와 비슷하지만 시각이 ISO 8601 문자열이고, createdBy, paidBy, canceledBy, cafe24, guideUrl, guideEmailSentAt, history가 없습니다.

응답 규칙:

  • 검증 성공: 정상 웹훅이 수신되었습니다: <이벤트 ID> (<이벤트 종류>)를 로그로 남기고, 이벤트별로 처리한 뒤 200을 응답합니다. dispatch 메서드에 가맹점의 비즈니스 로직을 추가하세요.
    • PaySync는 HTTP 200~299를 성공으로 기록합니다. 그 밖의 상태 코드와 리디렉션은 실패로 기록하고 다시 보냅니다. PaySync는 응답을 10초까지 기다리므로, 오래 걸리는 작업은 200을 먼저 응답한 뒤 큐나 백그라운드 작업으로 넘기세요.
    • 재시도와 재전송 때문에 같은 이벤트를 여러 번 받을 수 있습니다. 페이로드의 id(evt_...)로 이미 처리한 이벤트인지 확인하세요. 이벤트가 오는 순서는 보장되지 않습니다.
  • 검증 실패: 웹훅 검증에 실패했습니다 경고 로그를 남기고 400을 응답합니다. 검증되지 않은 본문은 로그에 남기지 않습니다.
  • 전송 시각(webhook-timestamp)이 현재와 5분 넘게 차이 나면 검증에 실패합니다.

웹훅 등록

대시보드 웹훅에서 웹훅 추가를 눌러 받을 URL(https://<공개 주소>/api/webhooks)을 입력하고, API 버전은 v2를 고르세요. 웹훅을 만든 뒤 웹훅 목록의 보기에서 서명 키(whsec_...)를 확인해 PAYSYNC_WEBHOOK_SIGNING_SECRET에 넣습니다.

PaySync가 보내는 실제 웹훅을 받으려면 공개 주소가 필요합니다 (localhost는 등록할 수 없습니다). cloudflared 나 ngrok 같은 터널로 로컬 서버를 노출하고 그 주소를 등록하세요. 등록한 뒤 웹훅 목록의 행 메뉴에서 테스트 이벤트 보내기로 서명 검증까지 확인할 수 있습니다.

로컬에서 테스트하기

직접 서명한 요청을 보내 확인할 수 있습니다. 서버와 같은 서명 키를 사용하세요.

SECRET="$PAYSYNC_WEBHOOK_SIGNING_SECRET"
ID=whm_local_test
TS=$(date +%s)
BODY='{"id":"evt_local","type":"invoice.paid","timestamp":"2026-04-28T03:20:12Z","data":{"id":"ivc_test","paidTrigger":"API_CALL"}}'

KEY_HEX=$(printf '%s' "${SECRET#whsec_}" | base64 -d | od -An -tx1 | tr -d ' \n')
SIG=$(printf '%s.%s.%s' "$ID" "$TS" "$BODY" \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" -binary | base64)

curl -i -X POST http://localhost:8080/api/webhooks \
  -H 'Content-Type: application/json' \
  -H "webhook-id: $ID" \
  -H "webhook-timestamp: $TS" \
  -H "webhook-signature: v1,$SIG" \
  --data "$BODY"

200이 오고 주문이 결제 완료되었습니다: ivc_test (API_CALL) 로그가 남으면 성공입니다. webhook-signature 값을 바꿔 보내면 400이 옵니다.