쇼핑몰 결제 연동
주문 생성, 입금 안내, invoice.paid와 invoice.expired 웹훅 처리까지 쇼핑몰 무통장입금 결제 흐름을 Node.js 코드로 구현해요.
고객이 상품을 주문하면 무통장입금을 안내하고, 입금이 확인되면 주문을 결제 완료로 바꾸는 쇼핑몰을 만들어요. 코드는 Node.js와 Express, 화면은 Next.js 기준이에요. 다른 언어에서도 흐름은 같아요. 실행할 수 있는 전체 코드는 언어별 예제 코드에 있어요.
준비
빠른 시작을 따라 아래 항목을 먼저 준비하세요.
- 입금받을 계좌를 연결하고 1원 인증까지 마쳐요.
- 읽기·쓰기 권한의 API 키를 만들어서 환경변수
PAYSYNC_API_KEY에 넣어요. - API 버전 v2로 웹훅을 만들고
invoice.paid,invoice.expired이벤트를 골라요. 서명 키(whsec_...)는 환경변수PAYSYNC_WEBHOOK_SECRET에 넣어요. 로컬에서 개발한다면 ngrok으로 터널을 열고 그 URL을 등록하세요.
1단계: 주문 생성
고객이 결제 화면에 들어오면 페이싱크에 주문을 만들고, 받은 주문 ID를 쇼핑몰 주문 테이블에 저장해요.
import express from "express";
import { db } from "../db.js";
const router = express.Router();
router.post("/checkout", express.json(), async (req, res) => {
const { userId, productId, amount, customerName, email } = req.body;
const orderId = `ORDER-${Date.now()}`;
const response = await fetch("https://api.paysync.kr/v2/invoices", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAYSYNC_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
customer: { name: customerName, email }, // 고객이 이체할 때 쓸 입금자명
amount, // 입금 금액과 같아야 매칭돼요
expireAfterDays: 1, // 24시간 안에 입금이 없으면 만료
metadata: { orderId, userId, productId },
}),
});
const body = await response.json();
if (body.code === "220010") {
// 같은 입금자명과 금액의 결제 대기 주문이 이미 있어요.
return res.status(409).json({ error: "입금자명을 바꿔 다시 주문해 주세요." });
}
if (body.code !== "10001") {
return res.status(500).json({ error: "결제 준비에 실패했어요.", code: body.code });
}
const invoice = body.data;
await db.orders.insert({
id: orderId,
userId,
productId,
amount,
customerName,
paysyncInvoiceId: invoice.id,
paysyncGuideUrl: invoice.guideUrl,
status: "PENDING",
expiresAt: new Date(invoice.expiresAt * 1000), // Unix 초
});
res.json({ orderId, redirectTo: `/checkout/${orderId}` });
});
export default router;
customer.name에는 고객이 실제로 이체할 때 쓸 이름을 넣으세요. 은행 알림의 입금자명과 이 값이 같아야 페이싱크가 매칭해요. 이름은 1~16자이고 공백을 넣을 수 없어요. 은행은 긴 입금자명을 잘라서 알림을 보내니, 7자보다 긴 이름을 쓴다면 이름 앞자리 매칭을 켜 두세요.
metadata.orderId에 쇼핑몰 주문 ID를 넣어 두면 웹훅을 받았을 때 어느 주문인지 바로 찾을 수 있어요.
같은 이름과 금액의 주문이 겹칠 때
같은 입금자명과 금액으로 결제 대기 중인 주문이 이미 있으면 새 주문 생성은 409, 220010으로 실패해요. 입금 한 건이 어느 주문의 것인지 구분할 수 없기 때문이에요. 고객이 같은 금액으로 두 번 주문할 수 있는 쇼핑몰이라면 아래 중 하나를 고르세요.
- 이름 뒤에 번호를 붙여 입금자명을 다르게 만들어요. 예:
홍길동1,홍길동2 - 계좌를 여러 개 연결하고 주문마다 받을 계좌를 나눠요. 다중 계좌 라우팅을 참고하세요.
응답의 data.invoiceId로 겹친 주문을 찾을 수 있어요. 같은 고객이 결제 화면을 다시 연 경우라면 새 주문 대신 그 주문을 다시 보여 줘도 돼요. 자세한 규칙은 주문 충돌 규칙을 참고하세요.
2단계: 입금 안내
고객에게 어느 계좌로, 얼마를, 어떤 이름으로 보낼지 알려 줘요. 셋 중 하나라도 틀리면 매칭되지 않아요. 페이싱크가 만든 입금 안내 페이지를 쓰거나, 쇼핑몰에 직접 화면을 만들어요.
입금 안내 페이지
주문 응답의 guideUrl은 페이싱크가 만든 구매자용 입금 안내 페이지예요. 고객을 이 주소로 보내면 별도 화면을 만들지 않아도 돼요.
res.redirect(order.paysyncGuideUrl); // https://paysync.kr/pay/...
페이지에 나오는 내용과 열 수 있는 기간은 구매자 안내를 참고하세요. 입금 안내 메일을 켜면 이 페이지 링크를 구매자 이메일로도 보내요.
직접 만드는 화면
쇼핑몰 디자인에 맞춰 직접 만든다면 복사 버튼을 달고 입금자명을 눈에 띄게 표시하세요.
export default async function CheckoutPage({ params }) {
const order = await db.orders.find(params.orderId);
const account = await db.bankAccounts.findActive(); // 페이싱크에 연결한 입금 계좌
return (
<div className="checkout">
<h1>입금 안내</h1>
<dl>
<dt>은행</dt>
<dd>{account.bankName}</dd>
<dt>계좌번호</dt>
<dd>
{account.number}
<CopyButton value={account.number} />
</dd>
<dt>예금주</dt>
<dd>{account.holder}</dd>
<dt>입금자명</dt>
<dd className="highlight">
{order.customerName}
<small>이 이름으로 이체해 주세요.</small>
</dd>
<dt>입금 금액</dt>
<dd>{order.amount.toLocaleString()}원</dd>
</dl>
<PaymentStatus orderId={order.id} />
</div>
);
}
3단계: 웹훅 수신
페이싱크가 입금을 매칭하면 등록한 URL로 invoice.paid 이벤트를 보내요. 서버는 서명 검증, 중복 확인, 주문 처리, 200 응답 순서로 처리해요.
import crypto from "node:crypto";
import express from "express";
import { db } from "../db.js";
const router = express.Router();
// 서명은 원본 바이트로 검증해요.
router.post(
"/paysync/webhook",
express.raw({ type: () => true }),
async (req, res) => {
const id = req.header("webhook-id");
const timestamp = req.header("webhook-timestamp");
const signatureHeader = req.header("webhook-signature");
if (!id || !timestamp || !signatureHeader) return res.sendStatus(401);
// 5분보다 오래된 요청은 거부해요.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(401).send("expired");
}
const body = req.body.toString("utf8");
const key = Buffer.from(process.env.PAYSYNC_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
const provided = Buffer.from(signatureHeader.split(",")[1] ?? "", "base64");
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(body);
// 재시도로 같은 이벤트가 다시 올 수 있어요. 이벤트 ID로 한 번만 처리해요.
if (await db.webhookEvents.exists(event.id)) return res.sendStatus(200);
if (event.type === "invoice.paid") await handleInvoicePaid(event.data);
if (event.type === "invoice.expired") await handleInvoiceExpired(event.data);
await db.webhookEvents.insert(event.id);
res.sendStatus(200);
}
);
async function handleInvoicePaid(invoice) {
const orderId = invoice.metadata.orderId;
if (!orderId) return;
const order = await db.orders.find(orderId);
if (!order || order.status === "PAID") return;
await db.orders.update(orderId, { status: "PAID", paidAt: new Date(invoice.paidAt) });
// 재고 차감, 메일 발송은 응답을 늦추지 않도록 큐로 넘겨요.
await queue.add("order-paid", { orderId });
}
export default router;
페이싱크는 2xx 응답을 성공으로 봐요. 10초 안에 응답하지 않거나 다른 상태 코드를 돌려주면 30초부터 16분 간격으로 최대 6번 더 보내요.
4단계: 결제 상태 확인
웹훅으로 주문이 결제 완료되면 고객 화면도 바뀌어야 해요. 쇼핑몰 서버의 주문 상태를 몇 초마다 확인하는 방법이 가장 간단해요.
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
export function PaymentStatus({ orderId }) {
const router = useRouter();
const [status, setStatus] = useState("PENDING");
useEffect(() => {
const check = async () => {
const res = await fetch(`/api/orders/${orderId}`);
const order = await res.json();
setStatus(order.status);
if (order.status === "PAID") router.push(`/checkout/${orderId}/done`);
};
check();
const timer = setInterval(check, 3000);
return () => clearInterval(timer);
}, [orderId, router]);
if (status === "EXPIRED") return <p>주문이 만료됐어요. 다시 주문해 주세요.</p>;
return <p>입금을 기다리고 있어요.</p>;
}
폴링 대신 Server-Sent Events나 WebSocket을 쓰면 웹훅 핸들러에서 고객 화면으로 바로 알릴 수 있어요.
5단계: 만료 처리
expireAfterDays: 1로 만든 주문은 24시간이 지나면 페이싱크가 EXPIRED로 바꾸고 invoice.expired 웹훅을 보내요. 웹훅 핸들러에서 쇼핑몰 주문도 만료로 바꿔요.
async function handleInvoiceExpired(invoice) {
const orderId = invoice.metadata.orderId;
if (!orderId) return;
const order = await db.orders.find(orderId);
if (!order || order.status !== "PENDING") return;
await db.orders.update(orderId, { status: "EXPIRED" });
await restoreStock(order.productId);
}
고객이 만료 뒤에 입금하면 그 입금은 자동으로 매칭되지 않아요. 대시보드 입금 내역에서 추천 주문으로 찾아 직접 매칭하면 invoice.paid가 와요. 이미 만료 처리한 쇼핑몰 주문이라면 다시 결제 완료로 바꿀지 정책을 정해 두세요.
자주 겪는 문제
입금했는데 매칭되지 않아요
대시보드 입금 내역에서 그 입금을 열면 매칭하지 못한 이유가 나와요. API로는 입금 단건 조회의 failureReason으로 확인해요. 이유별 해결 방법은 매칭되지 않은 입금을 참고하세요.
웹훅이 오지 않아요
- 대시보드 웹훅의 전송 기록에서 전송 시도가 있었는지, 응답 상태가 어땠는지 확인해요.
- 웹훅을 만들 때 받을 이벤트에
invoice.paid를 골랐는지, 웹훅이 켜져 있는지 확인해요. - 서버가 10초 안에
2xx를 돌려줬는지 확인해요. - 서명 검증에서
401을 돌려줬다면 원본 바디를 쓰는지, 서명 키 환경변수가 맞는지 확인해요.
같은 주문의 웹훅을 두 번 받았어요
응답이 늦거나 실패하면 페이싱크가 다시 보내요. 전송 기록에서 재전송을 눌러도 같은 이벤트가 다시 가요. 예제 코드처럼 이벤트 ID(event.id)로 이미 처리한 이벤트는 건너뛰세요.