본문으로 건너뛰기

결제 안정성 — 중복 방지와 세션 관리

CROSS Pay 는 현재 환불 기능을 제공하지 않으므로, 중복 결제를 막고 체크아웃 세션을 올바르게 다루는 것은 운영 연동에 필수입니다. 아래 패턴과 규칙은 Getting Started 가이드에서 참조하며, 운영 전 반드시 숙지하세요.

중복 결제 방어 패턴

코드에서 다음 4가지 방어선을 함께 두면 중복 결제는 사실상 발생하지 않습니다.

패턴 1 — 주문에 idempotency_key 컬럼을 저장하고 재사용

같은 주문에 대한 결제 세션 재요청은 이전에 저장해둔 key 를 그대로 다시 사용합니다. CROSS Pay API 는 같은 key 에 대해 항상 같은 결제를 반환하므로 중복 생성이 일어나지 않습니다.

구현 예시 (스키마 + Node.js)
ALTER TABLE orders ADD COLUMN idempotency_key UUID UNIQUE;
ALTER TABLE orders ADD COLUMN checkout_id TEXT;
ALTER TABLE orders ADD COLUMN payment_status TEXT DEFAULT 'NONE';
// POST /checkout — 같은 주문이면 같은 key 재사용
async function getOrCreateCheckout(orderId) {
const order = await db.one('SELECT * FROM orders WHERE id = $1', [orderId]);

// 이미 결제가 확정된 주문이면 새 세션을 만들지 않는다
if (order.payment_status === 'PAID') {
throw new Error('order already paid');
}

// 처음이면 새 key 발급, 재호출이면 저장된 key 재사용
const idempotencyKey = order.idempotency_key ?? crypto.randomUUID();

const response = await fetch('https://api.crosspay.io/v1/payments', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MERCHANT_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ /* ... */ }),
});
const payment = await response.json();

// key 와 checkout_id 를 주문에 영구 저장 (이후 재시도는 같은 값 사용)
await db.none(
`UPDATE orders SET idempotency_key = $1, checkout_id = $2 WHERE id = $3`,
[idempotencyKey, payment.checkout_id, orderId]
);

return { checkoutId: payment.checkout_id, idempotencyKey };
}

패턴 2 — DB 행 잠금으로 동시 요청 직렬화

사용자가 결제 버튼을 빠르게 두 번 누르거나 두 탭에서 동시 진입하면 같은 주문에 대해 동시에 결제 세션 생성이 호출될 수 있습니다. SELECT ... FOR UPDATE 로 직렬화합니다.

구현 예시 (Node.js)
await db.tx(async (t) => {
// 1) 주문 row 잠금 — 같은 주문에 대한 동시 요청은 여기서 대기
const order = await t.one(
'SELECT * FROM orders WHERE id = $1 FOR UPDATE',
[orderId]
);

// 2) 이미 처리 중인 결제가 있으면 그대로 반환
if (order.idempotency_key && order.payment_status !== 'NONE') {
return { checkoutId: order.checkout_id, idempotencyKey: order.idempotency_key };
}

// 3) 잠금을 잡은 상태에서만 신규 발급
const key = crypto.randomUUID();
const payment = await createCrossPayPayment(key, order);
await t.none(
`UPDATE orders SET idempotency_key = $1, checkout_id = $2, payment_status = 'PENDING' WHERE id = $3`,
[key, payment.checkout_id, orderId]
);
return { checkoutId: payment.checkout_id, idempotencyKey: key };
});

패턴 3 — Webhook 처리 시 PAID 상태 확정 후 추가 결제 차단

Webhook 으로 payment.completed 를 받으면 주문을 PAID 로 마킹합니다. 이후 같은 주문에 대한 모든 결제 세션 생성 요청은 거부합니다.

구현 예시 (Node.js)
// /webhooks/crosspay 핸들러 (서명 검증·dedup 통과 후)
if (event.type === 'payment.completed') {
await db.tx(async (t) => {
// webhook-id dedup
const inserted = await t.oneOrNone(
`INSERT INTO webhook_log (webhook_id, payment_id, event_type)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING webhook_id`,
[req.get('webhook-id'), event.data.payment.id, event.type]
);
if (!inserted) return; // 이미 처리된 webhook

// 주문에 PAID 마킹 (낙관적 갱신: 이미 PAID 면 no-op)
await t.none(
`UPDATE orders
SET payment_status = 'PAID',
payment_id = $1,
paid_amount = $2,
paid_at = $3
WHERE id = $4 AND payment_status <> 'PAID'`,
[event.data.payment.id, event.data.payment.amount,
event.data.payment.paid_at, event.data.payment.metadata.order_id]
);
});
}

// 이후 /checkout 호출은 패턴 1 의 첫 가드에서 차단됨:
// if (order.payment_status === 'PAID') throw new Error('order already paid');

패턴 4 — 클라이언트 버튼 disable + 응답 캐싱

서버 방어가 핵심이지만, UX 개선과 race 추가 방어로 클라이언트에서도 버튼을 잠그고 응답을 캐시합니다.

구현 예시 (React)
function CheckoutButton({ orderId }: { orderId: string }) {
const [busy, setBusy] = useState(false);
const cachedRef = useRef<{ checkoutId: string; idempotencyKey: string } | null>(null);

async function handlePay() {
if (busy) return; // 중복 클릭 차단
setBusy(true);
try {
// 같은 주문 재시도면 캐시된 값을 그대로 사용
const session = cachedRef.current ?? await (
await fetch('/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderId }),
})
).json();
cachedRef.current = session;

const crossPay = await loadCrossPay();
await crossPay.checkout(session);
} finally {
setBusy(false);
}
}

return <button onClick={handlePay} disabled={busy}>결제</button>;
}
4가지 패턴이 함께 막는 시나리오
  • 더블 클릭 / 두 탭 동시 진입 → 패턴 2 (행 잠금) 가 차단
  • 네트워크 일시 단절 후 재시도 → 패턴 1 (key 재사용) 으로 같은 결제 반환
  • 결제 완료 후 잘못된 재시도 → 패턴 3 (PAID 가드) 가 거부
  • UI 레벨 race / 중복 클릭 → 패턴 4 (버튼 lock) 가 차단

체크아웃 세션 관리

위 패턴은 모두 체크아웃 세션을 어떻게 다루느냐로 귀결됩니다. 세션의 식별자·라이프사이클·재사용 규칙을 정리합니다.

핵심 식별자 3가지

식별자발급 주체범위용도
Idempotency-Key가맹점 서버가 생성 (UUID)가맹점 주문 1건같은 주문의 재요청을 dedupe. 주문 row 에 저장해 영구 재사용
checkout_id (cs_...)CROSS Pay API 가 발급결제 세션 1건SDK 가 결제창을 열 때 사용. 가맹점 서버에도 저장
payment_id (pay_...)CROSS Pay API 가 발급결제 1건webhook / GET /v1/payments/:id 조회 키
idempotency_keycheckout_id 매핑

같은 Idempotency-KeyPOST /v1/payments 를 여러 번 호출하면 항상 같은 checkout_id / payment_id 를 받습니다. 가맹점 서버는 주문 row 에 이 3개 값을 한 번 저장해두면 됩니다.

세션 라이프사이클

상태가맹점이 해야 할 것
PENDING같은 checkoutId/idempotencyKey 로 결제창을 여러 번 열어도 안전. 사용자가 창을 닫았다 다시 시도 가능. PENDING 세션은 결제 세션 취소가 가능합니다.
PROCESSING결제가 처리 중인 상태입니다. 취소할 수 없으며, webhook 으로 전달되는 terminal 결과를 기다리세요.
PAID / FAILED / EXPIRED / CANCELLED세션 폐기. 같은 Idempotency-Key 를 다른 주문에 재사용 금지. 같은 주문에 새 결제가 필요하면 새 key 로 새 세션 생성
세션 취소 가능 시점

세션 취소는 PENDING 상태일 때만 가능합니다. PROCESSING 으로 넘어가면 더 이상 취소할 수 없으니, PAID / FAILED 결과를 기다린 뒤 그에 맞춰 처리하세요.

TTL 만료 후의 재결제

EXPIRED 된 세션은 다시 열 수 없습니다. 사용자가 장바구니로 돌아와 재결제하려 할 때는 반드시 새 Idempotency-KeyPOST /v1/payments 를 호출하세요. 주문 row 의 idempotency_key 컬럼도 초기화해야 합니다.

세션 재진입 (사용자가 결제창을 닫고 돌아온 경우)

가장 흔한 케이스입니다. 사용자가 결제창을 닫고 다시 "결제" 버튼을 누른 경우, 서버는 새 세션을 만들지 말고 기존 세션을 그대로 재사용합니다.

async function resumeOrCreateCheckout(orderId) {
const order = await db.one('SELECT * FROM orders WHERE id = $1', [orderId]);

// 1) terminal 상태면 새 결제 사이클 시작
const terminal = ['PAID', 'FAILED', 'EXPIRED', 'CANCELLED'];
if (terminal.includes(order.payment_status)) {
if (order.payment_status === 'PAID') throw new Error('already paid');
// PAID 이외의 terminal — 재시도 가능. 컬럼 초기화 후 새 key 발급
await db.none(
`UPDATE orders SET idempotency_key = NULL, checkout_id = NULL, payment_status = 'NONE' WHERE id = $1`,
[orderId]
);
return createNewCheckout(orderId);
}

// 2) PENDING 이면 기존 세션 재사용 (CROSS Pay 가 같은 결과 반환)
if (order.idempotency_key && order.checkout_id) {
// optional: 안전을 위해 CROSS Pay 측 상태도 확인
const live = await fetch(
`https://api.crosspay.io/v1/payments/${order.payment_id}`,
{ headers: { Authorization: `Bearer ${process.env.MERCHANT_API_KEY}` } }
).then(r => r.json());

if (live.status === 'PENDING') {
return { checkoutId: order.checkout_id, idempotencyKey: order.idempotency_key };
}
// CROSS Pay 측 상태가 변했는데 webhook 이 아직 안 옴 → DB 동기화 후 재평가
await syncOrderStatus(orderId, live);
return resumeOrCreateCheckout(orderId);
}

return createNewCheckout(orderId);
}

Webhook 누락 대비 — 폴백 폴링

Webhook 은 at-least-once 이지만 네트워크 장애 등으로 도착이 늦어질 수 있습니다. 결제 후 일정 시간 내 webhook 이 오지 않으면 서버 조회로 보강하세요.

// 결제창이 닫힌 직후 호출하는 보강 잡 (큐로 위임 권장)
async function reconcilePayment(paymentId, orderId) {
for (let i = 0; i < 6; i++) { // 최대 6회 * 5초 = 30초
const order = await db.one('SELECT payment_status FROM orders WHERE id = $1', [orderId]);
if (order.payment_status !== 'PENDING') return; // webhook 이 먼저 도착

const live = await fetch(
`https://api.crosspay.io/v1/payments/${paymentId}`,
{ headers: { Authorization: `Bearer ${process.env.MERCHANT_API_KEY}` } }
).then(r => r.json());

if (live.status !== 'PENDING') {
await syncOrderStatus(orderId, live); // webhook 핸들러와 동일한 트랜잭션 로직 재사용
return;
}
await sleep(5_000);
}
}
폴링은 보강이지 1차 수단이 아닙니다

주문 확정의 기본은 webhook 수신입니다. 폴링은 webhook 이 늦거나 누락된 케이스를 잡기 위한 2차 안전망으로만 사용하세요. webhook 핸들러와 폴링 잡이 같은 상태 갱신 함수(syncOrderStatus) 를 호출하도록 하면 둘 중 어느 쪽이 먼저 와도 결과가 같아집니다.

세션 관련 자주 묻는 질문

질문
같은 checkoutId 로 결제창을 여러 번 열어도 되나요?, PENDING 상태인 동안에는 안전합니다. SDK 결과만 새로 받습니다.
결제 세션을 취소할 수 있나요?PENDING 상태일 때만 가능합니다. PROCESSING 으로 넘어가면 취소할 수 없으니 terminal 결과를 기다리세요.
Idempotency-Key 의 TTL 은 얼마인가요?세션의 TTL 과 동일합니다. 세션이 EXPIRED 되면 같은 key 로 새 결제를 만들 수 없습니다.
같은 Idempotency-Key 로 금액을 바꿔 다시 호출하면?거부됩니다. 다른 주문/금액이면 반드시 새 key 를 발급하세요.
결제 완료 후 같은 주문을 또 결제하면?서버 가드(패턴 3) 가 없으면 중복 결제가 발생합니다. 환불이 불가하므로 반드시 PAID 체크를 두세요.
사용자가 결제창을 닫고 30분 뒤에 돌아왔다면?TTL 내면 기존 세션 재사용, TTL 초과(EXPIRED) 면 새 세션 발급. 위 resumeOrCreateCheckout 패턴 참고.