문서 / 결제 PG 연동
결제 PG 연동
서버 주문 생성부터 결제 승인·재조회·웹훅·정기결제까지 구현 흐름을 안내합니다.
결제 유형에 맞게 시작하세요
고객 프로젝트의 결제 연동 안내입니다. 국내 일반결제·정기결제·글로벌 판매를 구분하고, 조립스페이스에서 안내하는 아래 가입 경로를 사용하세요.
아래 코드는 토스페이먼츠 일반결제의 승인 핵심 예제입니다. 테스트 상점 키를 준비하고 Hono 기본 예제와 검증된 앱 세션에 연결하세요. PG 계약과 실제 결제 화면은 이 문서 배포만으로 생성되지 않습니다.
주문 금액은 서버에서 확정합니다
상품 DB에서 가격을 읽어 주문 ID·구매자·금액·상태를 먼저 저장합니다. 브라우저가 전송한 가격을 그대로 청구하지 마세요. 이 스키마는 Hono 예제의 products를 참조합니다.
CREATE TABLE payment_orders (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
product_id INTEGER NOT NULL REFERENCES products(id),
amount INTEGER NOT NULL CHECK(amount > 0),
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','paid','cancelled')),
created_at INTEGER NOT NULL
);
CREATE INDEX idx_payment_orders_user ON payment_orders(user_id,created_at,id);
CREATE INDEX idx_payment_orders_pending ON payment_orders(status,created_at,id);결제 승인 시 주문 소유자와 금액을 다시 검사합니다. 원본 paymentKey나 PG 응답은 이 테이블에 저장하지 않습니다. 상품 가격이 바뀌어도 해당 주문의 확정 금액은 유지합니다.
서버에서만 결제를 승인합니다
프로젝트 Secret에 TOSS_SECRET_KEY를 등록하고 서버에서만 참조합니다. 아래는 주문 생성·금액 검증·중복 승인·응답 손실 후 조회를 포함합니다. 토스페이먼츠 공식 연동 안내
import { Hono } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { requireSession } from '../middleware/session';
import type { AppEnv } from '../types';
type PaymentEnv = AppEnv & {
Bindings: AppEnv['Bindings'] & { TOSS_SECRET_KEY: string };
};
type Order = { id: string; user_id: string; amount: number; status: string };
export const payments = new Hono<PaymentEnv>();
payments.use('*', requireSession);
payments.use('*', bodyLimit({ maxSize: 4096 }));
// 가격은 브라우저가 보낸 금액이 아니라 DB에서 확정합니다.
payments.post('/orders', async (c) => {
const input = await c.req.json().catch(() => null);
if (!input || !Number.isSafeInteger(input.productId))
return c.json({ error: 'invalid_product' }, 400);
const product = await c.env.DB.prepare(
'SELECT id,name,price FROM products WHERE id=? AND published=1'
).bind(input.productId).first<{ id: number; name: string; price: number }>();
if (!product) return c.json({ error: 'not_found' }, 404);
const orderId = crypto.randomUUID();
await c.env.DB.prepare(
'INSERT INTO payment_orders(id,user_id,product_id,amount,created_at) VALUES(?,?,?,?,?)'
).bind(orderId, c.get('identity').user_id, product.id, product.price, Date.now()).run();
return c.json({ orderId, orderName: product.name, amount: product.price });
});
payments.post('/confirm', async (c) => {
const input = await c.req.json().catch(() => null);
if (!input || typeof input.orderId !== 'string' || input.orderId.length > 64 ||
typeof input.paymentKey !== 'string' || input.paymentKey.length > 200 ||
!input.paymentKey || !Number.isSafeInteger(input.amount))
return c.json({ error: 'invalid_payment' }, 400);
const order = await c.env.DB.prepare(
'SELECT id,user_id,amount,status FROM payment_orders WHERE id=? AND user_id=?'
).bind(input.orderId, c.get('identity').user_id).first<Order>();
if (!order) return c.json({ error: 'not_found' }, 404);
if (input.amount !== order.amount) return c.json({ error: 'amount_mismatch' }, 400);
if (order.status === 'paid') return c.json({ ok: true });
if (order.status !== 'pending') return c.json({ error: 'order_conflict' }, 409);
const headers = { Authorization: 'Basic ' + btoa(c.env.TOSS_SECRET_KEY + ':') };
try {
let result = await fetch('https://api.tosspayments.com/v1/payments/confirm', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json', 'Idempotency-Key': order.id },
body: JSON.stringify({ orderId: order.id, paymentKey: input.paymentKey, amount: order.amount }),
});
// 중복 승인·응답 손실 뒤에는 주문 번호로 PG의 실제 상태를 재확인합니다.
if (!result.ok) result = await fetch(
'https://api.tosspayments.com/v1/payments/orders/' + encodeURIComponent(order.id), { headers });
if (!result.ok) return c.json({ error: 'payment_verification_pending' }, 503);
const payment = await result.json() as { orderId: string; totalAmount: number; currency: string; status: string };
if (payment.orderId !== order.id || payment.totalAmount !== order.amount || payment.currency !== 'KRW')
return c.json({ error: 'payment_verification_failed' }, 409);
if (payment.status !== 'DONE') return c.json({ status: 'pending' }, 202);
await c.env.DB.prepare("UPDATE payment_orders SET status='paid' WHERE id=? AND status='pending'")
.bind(order.id).run();
const saved = await c.env.DB.prepare('SELECT status FROM payment_orders WHERE id=?')
.bind(order.id).first<{ status: string }>();
if (saved?.status !== 'paid') return c.json({ error: 'order_conflict' }, 409);
return c.json({ ok: true });
} catch {
// 승인 여부가 불명확합니다. 실패로 확정하거나 주문/결제를 새로 만들지 않습니다.
return c.json({ error: 'payment_verification_pending' }, 503);
}
});import { payments } from './routes/payments';
app.route('/api/payments', payments);주문 ID는 같은 결제 재시도에서 고정합니다. HTTP 성공이나 successUrl 도착만으로 결제 완료 처리하지 말고 서버가 검증한 PG 상태와 저장된 주문 상태를 확인하세요.
브라우저 결제 요청과 결과 화면
이 코드는 결제 UI를 렌더링하는 별도 브라우저 모듈입니다. 주문 생성은 로그인된 앱 세션이 있어야 성공합니다. 공개 가능한 클라이언트 키와 서버 전용 시크릿 키를 혼동하지 마세요.
// 주문서 페이지에 아래 요소와 SDK를 먼저 추가합니다.
// <div id="payment-method"></div><div id="agreement"></div>
// <button id="pay" disabled>결제하기</button>
// <script src="https://js.tosspayments.com/v2/standard"></script>
const response = await fetch('/api/payments/orders', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: 1 }),
});
if (!response.ok) throw new Error('로그인과 주문 정보를 확인하세요.');
const order = await response.json();
// 클라이언트 키만 브라우저에서 사용합니다. 시크릿 키를 넣으면 안 됩니다.
const toss = TossPayments('YOUR_TOSS_CLIENT_KEY');
const widgets = toss.widgets({ customerKey: TossPayments.ANONYMOUS });
await widgets.setAmount({ currency: 'KRW', value: order.amount });
await widgets.renderPaymentMethods({ selector: '#payment-method', variantKey: 'DEFAULT' });
await widgets.renderAgreement({ selector: '#agreement', variantKey: 'AGREEMENT' });
const button = document.querySelector('#pay');
button.disabled = false;
button.addEventListener('click', async () => {
button.disabled = true;
try {
await widgets.requestPayment({
orderId: order.orderId, orderName: order.orderName,
successUrl: location.origin + '/payments/success',
failUrl: location.origin + '/payments/fail',
});
} catch {
document.querySelector('#payment-method').insertAdjacentText('beforebegin', '결제 요청을 확인하세요.');
button.disabled = false;
}
});// /payments/success의 별도 브라우저 모듈입니다. #result 요소를 준비하세요.
const params = new URLSearchParams(location.search);
const payload = { orderId: params.get('orderId'), paymentKey: params.get('paymentKey'), amount: Number(params.get('amount')) };
history.replaceState(null, '', location.pathname); // 민감한 쿼리가 이후 링크에 남지 않도록 제거
const response = await fetch('/api/payments/confirm', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
});
const result = await response.json();
document.querySelector('#result').textContent = response.ok && result.ok
? '결제가 확인되었습니다.' : '결제 상태를 확인 중입니다. 주문 내역에서 다시 확인하세요.';/checkout, /payments/success, /payments/fail 페이지를 각각 만들고 해당 모듈과 안내 요소를 연결하세요. 실패 화면에서는 쿼리 값을 textContent로 표시하고 자동 재결제하지 않습니다. 성공·실패 페이지는 noindex·no-store·Referrer-Policy: no-referrer로 제공하며 paymentKey가 들어오는 쿼리를 로깅·분석 도구에 전달하지 마세요.
웹훅과 응답 손실 복구
일반 승인 예제만으로 입금·취소·환불 같은 비동기 변경까지 처리되는 것은 아닙니다. 웹훅 수신 시 공급자의 해당 이벤트 인증 규격을 확인하고 주문 ID만 단서로 삼아 서버 Secret으로 PG 상태를 재조회하세요. 웹훅 본문이 주장하는 금액·완료 상태를 그대로 저장하지 않습니다.
- 웹훅 ID를 고유 키로 저장하고 같은 이벤트의 재전송을 중복 처리하지 않습니다.
- PG 재조회 결과의 주문·통화·금액·최신 상태를 검증합니다.
- 허용된 상태 전이와 상품 지급 outbox를 같은 DB 트랜잭션에 기록합니다.
- 기록이 영구 저장된 뒤 2xx를 반환하고, 일시적 실패는 다시 전달받을 수 있도록 실패 상태로 응답합니다.
주기적으로 pending 주문을 제한된 수만큼 조회해 PG 상태와 대조하세요. 승인 결과를 잃어도 새 주문을 만들지 않으며, 취소·부분환불은 잔액과 상품 회수 정책까지 별도로 처리합니다.
정기결제와 글로벌 결제
정기결제는 일반결제 승인 API를 반복 호출하는 방식이 아닙니다. 고객 동의와 빌링키 발급, 청구 주기·해지·실패 재시도·중복 청구 방지를 설계하세요. 빌링키는 암호화 보관하고 주기별 고유 청구 ID를 사용합니다. 크론은 정기 실행 가이드에 따라 등록합니다.
도도 페이먼츠는 서버에서 체크아웃을 생성하고 브라우저를 결제 URL로 이동시킨 뒤, 서명 검증한 웹훅으로 구독 권한을 갱신하는 흐름을 사용합니다. Secret은 프로젝트 바인딩으로 관리하고 구독 상태를 브라우저 리다이렉트만으로 활성화하지 마세요. 구체적 이벤트·서명 방식은 도도 페이먼츠 공식 웹훅 문서를 따릅니다.
테스트 상점에서 확인할 항목
- 정상 승인, 사용자 취소, 로그인 만료, 금액 변조, 다른 사용자의 주문 접근
- 승인 버튼 중복 클릭, 승인 응답 손실, DB 기록 실패 후 재조회
- 동일 웹훅 재전송, 역순 이벤트, 가상계좌 입금 대기, 취소·부분환불
- 상품 지급 중복 방지, pending 대조 작업, 비밀 키·결제키 로그 미노출
문서 코드의 로컬 검증은 실제 PG 상점 결제 성공 검증을 대체하지 않습니다. 테스트 키로 위 흐름을 확인한 뒤 운영 키를 적용하세요.