No description
Find a file
NillPoe 74da144e14
feat: 현금영수증 문자 전송 제거
PaySync API에서 POST /v2/cash-receipts/{id}/send-sms가 제거되어
샘플의 문자 전송 라우트, 서비스, 요청 모델, 테스트, README 설명을 지웁니다.
Popbill 에러 data 전달 테스트는 이메일 전송으로 옮깁니다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:01:42 +09:00
cmd/server feat: PaySync v2 API로 이전 2026-10-04 06:04:26 +09:00
internal feat: 현금영수증 문자 전송 제거 2026-10-04 19:01:42 +09:00
.env.example feat: PaySync Go (net/http) 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
.gitattributes feat: PaySync Go (net/http) 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
.gitignore feat: PaySync Go (net/http) 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
go.mod feat: PaySync Go (net/http) 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
go.sum feat: PaySync Go (net/http) 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
README.md feat: 현금영수증 문자 전송 제거 2026-10-04 19:01:42 +09:00

PaySync 샘플 백엔드 (Go)

PaySync REST API(v2)를 호출하고 PaySync 웹훅(v2)을 받는 가맹점 백엔드 예제입니다. Go 표준 라이브러리(net/http, encoding/json, log/slog)만으로 작성했고, 웹훅 서명 검증에는 Standard Webhooks 공식 Go 라이브러리를 사용합니다.

PaySync API 문서: https://docs.paysync.kr/api-reference

요구사항

  • Go 1.27 이상 (go.mod의 go 1.27.1)
    • Go 1.21 이상이 설치되어 있다면 필요한 툴체인을 자동으로 내려받습니다.
  • PaySync API 키, 웹훅 서명 키(whsec_...)

프로젝트 구조

cmd/server/            main: 설정 읽기, 라우트 등록, 서버 실행/정상 종료
internal/config/       환경 변수 → Config
internal/paysync/      PaySync API 클라이언트 (인증 헤더, 응답 해제, APIError, 공통 Actor)
internal/httpx/        JSON 응답, 쿼리 파라미터 파싱, 에러 → HTTP 응답 매핑
internal/invoice/      주문        (model / service / handler)
internal/cashreceipt/  현금영수증  (model / service / handler)
internal/transaction/  입금 내역   (model / service / handler)
internal/webhook/      웹훅 수신, 서명 검증, 이벤트별 분기

모든 PaySync 호출은 paysync.Do / paysync.DoPage 한 곳을 거칩니다. 이곳에서 base URL과 Authorization: Bearer <API 키> 헤더를 붙이고, 응답을 풀어 성공 code("10001")인지 확인합니다. PaySync v2는 생성을 포함한 모든 성공 응답이 HTTP 200과 "10001"입니다.

PaySync 응답 형식
단건 { "code", "data" }
목록 { "code", "items", "totalItems" } (items가 최상위에 있습니다)
데이터 없음 { "code" }
에러 { "code" } 또는 { "code", "data" }

실패하면 *paysync.APIError를 반환하고, httpx.WriteError가 이를 HTTP 응답으로 바꿉니다. API 응답의 시각은 Unix 초(정수)이고, 웹훅 페이로드의 시각만 ISO 8601 문자열입니다. 응답에 모르는 필드가 있어도 무시합니다.

설정

환경 변수로 설정합니다. 목록은 .env.example에 있습니다.

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

Go는 .env 파일을 자동으로 읽지 않습니다. 셸에서 직접 설정하세요.

# macOS / Linux
export PAYSYNC_API_KEY=발급받은_API_키
export PAYSYNC_WEBHOOK_SIGNING_SECRET=whsec_...

# 또는 .env 파일을 만들어 한 번에 불러오기
cp .env.example .env   # 값 채우기
set -a; . ./.env; set +a
# Windows PowerShell
$env:PAYSYNC_API_KEY = "발급받은_API_키"
$env:PAYSYNC_WEBHOOK_SIGNING_SECRET = "whsec_..."

실행

go run ./cmd/server

Ctrl+C(또는 SIGTERM)를 받으면 처리 중인 요청을 마무리한 뒤 종료합니다.

테스트와 정적 검사는 다음과 같이 실행합니다. 테스트는 가짜 PaySync 서버(httptest)를 사용하므로 API 키가 필요 없습니다.

go test ./...
go vet ./...

Go가 설치되어 있지 않다면 Docker로 실행할 수 있습니다.

docker run --rm -v "$PWD":/src -w /src golang:1-alpine go test ./...

엔드포인트

샘플 서버 PaySync API 성공 응답
POST /api/invoices POST /v2/invoices 201 + 주문
GET /api/invoices GET /v2/invoices 200 + 목록
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 + 목록
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 + 목록
GET /api/transactions/{id} GET /v2/transactions/{id} 200 + 입금 내역
POST /api/webhooks (PaySync → 샘플 서버) 200

요청 본문

샘플 서버는 필수 값이 있는지와 JSON 타입만 확인합니다. 길이와 형식은 PaySync가 검사하고 code로 알려 줍니다. 값이 없는 선택 필드는 PaySync로 보내지 않습니다.

  • 주문 생성: customer{name (필수), email, phoneNumber}, amount (필수), bankAccountIds, expiresAt (Unix 초), expireAfterDays (1~365), cashReceipt{type, identifier}, metadata, sendGuideEmail
  • 주문 수정 (PATCH): customer.name, customer.email, customer.phoneNumber, amount, expiresAt, extendDays, bankAccountIds, cashReceipt, resendGuideEmail. 필드를 빼면 그대로 두고 null을 보내면 지우므로 (customer.email, customer.phoneNumber, expiresAt, cashReceipt), 샘플 서버는 받은 JSON 객체를 그대로 전달합니다.
  • 주문 취소: { "reason": "..." } (선택, 200자까지). 본문 없이 보내도 됩니다.
  • 현금영수증 발급: type (필수, PERSONAL/CORPORATE), identifier (selfIssued가 true가 아니면 필수), selfIssued, amount (필수), customerName, invoiceId
  • 현금영수증 취소: reason (필수, 1~100자), amount (빼면 남은 금액 전체)
  • 현금영수증 재발급: identifier, type, selfIssued
  • 현금영수증 이메일 전송: email (필수)

목록 조회 쿼리 파라미터

offset 기본값 0, limit 기본값 20(최대 100)이고, 나머지는 값이 있을 때만 PaySync로 전달합니다. 날짜는 YYYY-MM-DD(한국 시간, 그날 포함)입니다. status, source, bankAccountId는 여러 값을 쉼표로 이어 보낼 수 있습니다(status=PENDING,EXPIRED). 파라미터를 반복해 보내면 쉼표로 이어 전달합니다.

  • /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

목록 응답은 모두 { "items": [...], "totalItems": 0 } 형식입니다.

예시

# 주문 생성
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": ... } 형식입니다. data는 PaySync가 에러와 함께 보낸 값이 있을 때만 붙습니다. 예를 들어 같은 입금자명과 금액의 결제 대기 주문이 이미 있으면 기존 주문을 알려 줍니다.

{
  "code": "220010",
  "message": "주문 생성에 실패했습니다.",
  "data": { "invoiceId": "ivc_n0o1p2q3r4s5t6u7v8w9x0y1", "number": 1042 }
}
상황 상태 코드 code
잘못된 요청 본문 / 쿼리 파라미터 400 BAD_REQUEST
PaySync가 4xx와 code를 응답 PaySync의 상태 코드 (예: 409) PaySync의 code (예: "220010")
그 밖의 PaySync 실패 (5xx, JSON이 아닌 응답, 네트워크 오류, "10001"이 아닌 성공 응답) 502 PaySync의 code 또는 UPSTREAM_ERROR

코드 목록은 에러 코드 문서에 있습니다.

웹훅

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

v2 웹훅은 모든 이벤트가 같은 봉투에 담겨 옵니다. data의 시각은 ISO 8601 문자열입니다.

{
  "id": "evt_f1g2h3i4j5k6l7m8n9o0p1q2",
  "type": "invoice.paid",
  "timestamp": "2026-04-28T03:20:12Z",
  "data": { "id": "ivc_...", "status": "PAID", "paidTrigger": "AUTOMATIC_MATCHING", "...": "..." }
}
  • 검증 성공: 정상 웹훅이 수신되었습니다: <본문>과 이벤트 ID를 로그로 남기고, type별로 분기한 뒤 200을 응답합니다. 분기하는 handle 함수에 가맹점의 비즈니스 로직을 추가하세요.
    • invoice.created, invoice.updated, invoice.paid, invoice.expired, invoice.canceled, invoice.reopened: data는 주문입니다. 결제 완료 경로는 data.paidTrigger(AUTOMATIC_MATCHING, MANUAL_MATCHING, MANUAL_APPROVE, API_CALL)로 알 수 있습니다.
    • transaction.created: data는 새 입금입니다.
    • 모르는 type도 무시하고 200을 응답합니다.
  • 같은 이벤트가 재시도나 재전송으로 여러 번 올 수 있습니다. 페이로드의 id(evt_...)로 중복을 거르세요. 이벤트가 오는 순서는 보장되지 않습니다.
  • PaySync는 HTTP 200~299를 성공으로 기록합니다. 리디렉션은 따라가지 않고 실패로 기록합니다. PaySync는 응답을 10초까지 기다리므로, 오래 걸리는 작업은 200을 먼저 응답한 뒤 큐나 백그라운드 작업으로 넘기세요.
  • 검증 실패: 웹훅 검증에 실패했습니다 경고 로그를 남기고 400을 응답합니다. 검증되지 않은 본문은 로그에 남기지 않습니다.
  • 전송 시각(webhook-timestamp)이 현재와 5분 넘게 차이 나면 검증에 실패합니다.

웹훅 등록

대시보드 웹훅에서 웹훅 추가를 누르고, 받을 URL에 https://<내 서버 주소>/api/webhooks를 입력합니다. API 버전은 v2를 고르세요. 웹훅을 만들면 웹훅 목록의 보기에서 서명 키(whsec_...)를 확인할 수 있습니다. 이 값을 PAYSYNC_WEBHOOK_SIGNING_SECRET에 넣으세요.

로컬에서 테스트하기

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

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 "webhook-id: $ID" \
  -H "webhook-timestamp: $TS" \
  -H "webhook-signature: v1,$SIG" \
  --data "$BODY"

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

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