결제 플로우
Hosted Checkout — 주문서 생성, 결제 팝업, postMessage 수신
shop24 결제는 Hosted Checkout 방식입니다. 결제창은 플랫폼이 호스팅하는 별도 페이지(팝업)가
담당하므로, 쇼핑몰 프론트엔드는 PG SDK를 전혀 알 필요 없이 API가 내려주는 popupUrl을
팝업으로 열기만 하면 됩니다.
전체 흐름
[쇼핑몰 프론트엔드] [shop24 API] [결제 팝업] [PG (Toss)]
1. POST /checkout → 가격·재고 확정 검증, 세션(30분)
2. POST /checkout/{id}/payment→ 결제 세션 생성(pending)
← { paymentId, pgParams: { popupUrl, … } }
3. window.open(빈 팝업 먼저)
→ popupUrl 로드 4. 결제 세션 조회
5. 카드 인증 → 인증 완료
6. 결제 승인 요청
← 금액 재검증 → PG 승인
주문 생성(PAID)·재고 차감
7. opener.postMessage(결과)
8. message 수신 (origin·source 검증) → 완료 페이지로 이동1. 주문서 생성
장바구니 아이템 또는 바로구매 아이템으로 체크아웃 세션(30분 유효)을 만듭니다. 이 시점의 가격/재고가 확정 검증됩니다.
// 장바구니에서
const session = await sdk.checkout.create({ cartItemIds: ["ci_1", "ci_2"] });
// 또는 바로구매 (cartItemIds와 directItem 중 하나만)
const direct = await sdk.checkout.create({
directItem: { productId: "prod_001", optionId: "opt_001", quantity: 1 },
});
// session: { checkoutId, items[], amounts: { productAmount, deliveryFee, totalAmount }, expiresAt }2. 결제 요청 → 팝업 열기
핵심 규칙: 팝업은 사용자 클릭 콜스택에서 동기적으로 먼저 열어야 브라우저 팝업 차단을 피할 수 있습니다. URL 없이 빈 팝업을 먼저 열고, API 응답을 받은 뒤 결제 페이지로 이동시키세요.
async function onPayClick(values: CheckoutFormValues) {
// 1) 사용자 클릭 콜스택에서 동기적으로 빈 팝업을 연다 (차단 회피)
const popup = window.open("", "shop24-payment", "width=480,height=720");
// 2) 배송지/결제수단 확정 → 결제 세션 생성
const params = await sdk.checkout.requestPayment(session.checkoutId, {
shippingAddress: values.shippingAddress,
paymentMethod: values.paymentMethod, // "CARD" | "BANK_TRANSFER" | ...
guest: isGuest ? values.guest : undefined, // 비회원: 이름/연락처/주문 비밀번호
});
// 3) 열려 있는 빈 팝업을 결제 페이지로 이동
const popupUrl = params.pgParams.popupUrl as string;
if (popup) {
popup.location.href = popupUrl;
} else {
showOpenPaymentButton(popupUrl); // 차단됨 → "결제창 열기" 버튼 노출
}
}3. postMessage 수신
결제 팝업은 결제가 끝나면 window.opener.postMessage로 결과를 보내고 스스로 닫힙니다.
부모 창은 반드시 event.origin(popupUrl의 origin)과 event.source(직접 연 창)를 모두 검증한
뒤에만 메시지를 처리하세요. 결과 메시지 없이 창이 닫히면(사용자 이탈) 폴링으로 감지해
"다시 시도" 안내를 띄우는 것을 권장합니다.
/** 팝업 → 부모 페이로드 */
interface PaymentCompletedMessage {
type: "payment:completed";
paymentId: string;
orderId: string;
}
interface PaymentFailedMessage {
type: "payment:failed";
paymentId?: string;
code?: string;
message?: string;
}
const popupOrigin = new URL(popupUrl).origin;
window.addEventListener("message", (event) => {
if (event.origin !== popupOrigin || event.source !== popup) return; // 필수 검증
if (event.data?.type === "payment:completed") goToComplete(event.data.orderId);
if (event.data?.type === "payment:failed") showError(event.data.message);
});팝업 쪽도 사전에 허용된 부모 origin으로만 발신합니다 — 와일드카드(*) 수신에 의존하지 마세요.
결제 API 규칙
결제 관련 API가 보장하는 동작입니다.
- 금액 재검증 — 결제 승인 시 세션 금액과 요청 금액이 일치해야 승인됩니다.
불일치 시
400 AMOUNT_MISMATCH. - 상태는 단방향 — 결제 세션은
pending → completed | failed | expired로만 전이합니다. 이미 처리된 세션 재요청은409 ALREADY_PROCESSED. - 승인은 멱등 — 같은
paymentId + pgToken재호출은 저장된 결과를 그대로 반환합니다. 다른 pgToken이면409 ALREADY_COMPLETED_WITH_DIFFERENT_KEY. - 재고 경합 — 승인 중 재고가 소진되면
409 STOCK_DEPLETED와 함께 결제가 자동 취소됩니다. - 세션 만료 — 체크아웃/결제 세션은 30분 뒤 만료(
410 SESSION_EXPIRED)됩니다.