결제 플로우
Hosted Checkout — 주문서 생성, 결제 팝업, postMessage 수신, 결제 상태 확인
sayren 결제는 Hosted Checkout 방식입니다. 결제창은 플랫폼이 호스팅하는 별도 페이지(팝업)가
담당하므로, 쇼핑몰 프론트엔드는 PG SDK를 알 필요 없이 API가 내려주는 popupUrl을 팝업으로 엽니다.
어느 PG로 결제할지는 스토어의 결제 설정이 정합니다. 셀러가 연결한 PG(토스페이먼츠·포트원)와 라우팅 방식에 따라 팝업이 결제창을 띄우고, 카드 거절 뒤 다른 결제사로 다시 시도하는 과정도 팝업 안에서 끝납니다.
스토어가 샌드박스 모드면 결제는 테스트 결제가 됩니다. 결제창과 승인 흐름은 같지만 실제로 돈이 오가지 않고, 주문서·결제 요청·
결제 상태·주문 응답에 testPayment: true가 붙습니다. 이 값이 true면 실제로 돈이 오가지 않는다는 점을 구매자에게 표시하세요.
전체 흐름
[쇼핑몰 프론트엔드] [sayren API] [결제 팝업] [PG]
1. POST /checkout → 가격·재고 확정 검증, 주문서(30분)
2. POST /checkout/{id}/payment→ 결제 세션 생성(pending, 10분)
스토어 결제 설정으로 결제창 준비
← { paymentId, pgProvider, 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({
// optionId = 상품 상세의 variantId. 옵션 없는 상품은 생략 가능
directItem: { productId: "prod_001", optionId: "var_001", quantity: 1 },
});
// session: {
// checkoutId,
// items: [{ productId, productName, optionId, optionName, quantity, unitPrice, totalPrice }],
// amounts: { productAmount, deliveryFee, totalAmount },
// testPayment, // 지금 결제하면 테스트 결제인지. 최종 값은 결제 요청 응답
// expiresAt,
// }회원만 주문받는 스토어
스토어가 비회원 주문을 허용하지 않으면, 로그인하지 않은 구매자의 주문서 생성과 결제 요청은
403 GUEST_CHECKOUT_DISABLED입니다. 장바구니 담기와 이미 받은 비회원 주문의 조회·취소는 그대로 됩니다.
공개 설정의 guestCheckout으로 미리 알 수 있으니 주문 버튼을 로그인 안내로 바꿔 두세요.
const store = await sdk.store.get();
if (!store.guestCheckout && !isLoggedIn) {
// "로그인하고 주문하기" — 로그인할 때 cartToken을 보내면 담아 둔 장바구니가 합쳐집니다
}설정은 셀러 콘솔의 설정 › 스토어 정보 › 비회원 주문이나 Store API PATCH /store의
guestCheckoutEnabled로 바꿉니다. 바꾼 값은 1분 안에 적용됩니다.
2. 결제 요청 → 팝업 열기
핵심 규칙: 팝업은 사용자 클릭 콜스택에서 동기적으로 먼저 열어야 브라우저 팝업 차단을 피할 수 있습니다. URL 없이 빈 팝업을 먼저 열고, API 응답을 받은 뒤 결제 페이지로 이동시키세요.
async function onPayClick(values: CheckoutFormValues) {
// 1) 사용자 클릭 콜스택에서 동기적으로 빈 팝업을 연다 (차단 회피)
const popup = window.open("", "sayren-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;
if (popup) {
popup.location.href = popupUrl;
} else {
showOpenPaymentButton(popupUrl); // 차단됨 → "결제창 열기" 버튼 노출
}
}pgParams에서 프론트엔드가 쓰는 값은 popupUrl 하나입니다. 나머지 키는 결제 팝업이 쓰는 값이라 PG에 따라 달라지고
예고 없이 바뀔 수 있습니다. pgProvider는 첫 결제 시도의 PG이며 현재 tosspayments 또는 portone입니다.
testPayment는 이 결제가 테스트 결제인지를 알려 주는 최종 값입니다. 결제 id(paymentId)는 결제 상태 조회의 키이므로 결제가 끝날
때까지 보관하세요.
결제를 시작할 수 없을 때
스토어가 결제를 받을 수 없는 상태면 결제 요청이 실패합니다. 구매자가 잘못한 것이 아니므로 입력 오류와 구분해, 먼저 열어 둔 빈 팝업을 닫고 결제를 받을 수 없는 상태로 처리하세요.
| 응답 | 의미 |
|---|---|
409 PAYMENT_NOT_CONFIGURED | 스토어에 지금 결제를 받을 수 있는 PG가 없습니다. 결제 세션을 만들지 않습니다 |
503 PAYMENT_PROVIDER_UNAVAILABLE | 사용 중인 PG가 모두 결제창을 준비하지 못했습니다. 잠시 후 같은 주문서로 다시 요청할 수 있습니다 |
import { ApiError } from "@sayren/storefront-sdk";
try {
const params = await sdk.checkout.requestPayment(session.checkoutId, body);
// …
} catch (error) {
popup?.close();
if (
error instanceof ApiError &&
(error.code === "PAYMENT_NOT_CONFIGURED" || error.code === "PAYMENT_PROVIDER_UNAVAILABLE")
) {
onPaymentUnavailable(error.code); // 503은 잠시 후 같은 주문서로 다시 요청 가능
}
}3. postMessage 수신
결제 팝업은 결제가 끝나면 window.opener.postMessage로 결과를 보내고 스스로 닫힙니다. 카드 거절처럼 다시 결제할 수
있는 실패는 팝업 안에서 재시도를 안내하므로 부모 창에 전달되지 않고, payment:failed는 결제를 더 진행할 수 없을 때만 옵니다.
부모 창은 반드시 event.origin(popupUrl의 origin)과 event.source(직접 연 창)를 모두 검증한
뒤에만 메시지를 처리하세요.
/** 팝업 → 부모 페이로드 */
interface PaymentCompletedMessage {
type: "payment:completed";
paymentId: string;
orderId: string;
approvedAt: string | null;
}
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") onCompleted(event.data.orderId);
if (event.data?.type === "payment:failed") onFailed(event.data.code);
});팝업 쪽도 사전에 허용된 부모 origin으로만 발신합니다 — 와일드카드(*) 수신에 의존하지 마세요.
4. 결제 상태 확인
결제창이 결제사·카드사 페이지를 거치는 동안 부모 창과의 연결이 끊길 수 있습니다. 그러면 결과 메시지가 오지 않고, 팝업이 아직
열려 있는데도 부모 창에서는 popup.closed가 true로 보일 수 있습니다. 그래서 창이 닫힌 것처럼 보여도 결제가 끝났다고 판단하지
말고 GET /payments/{paymentId}로 결제 상태를 확인하세요. 인증 없이 호출할 수 있고, 응답에 개인정보는 없습니다.
const payment = await sdk.payments.getStatus(params.paymentId);
// { paymentId, status, orderId, testPayment, expiresAt }status | 의미 | 할 일 |
|---|---|---|
pending | 결제 대기. 구매자가 아직 결제창에서 결제 중일 수 있음 | 계속 확인. 결제가 끝났다고 판단하지 않음 |
processing | 승인 결과 확인 중 | 확인 중으로 표시하고 계속 확인. 새 결제를 시작하지 않음 |
completed | 결제 완료, 주문 생성 | orderId로 완료 처리 |
failed · expired | 결제가 끝나지 않음 | 실패로 처리. 새 결제를 시작할 수 있음 |
status는 문자열이고 값이 추가될 수 있습니다. 모르는 값은 processing처럼 다루세요. Storefront SDK의 PAYMENT_STATUS_VALUES가
현재 값 목록입니다. orderId는 completed일 때만 있고, testPayment는 완료된 결제면 주문의 값, 아니면 결제 요청 시점의 값입니다.
처리 규칙은 다음과 같습니다.
- 확인 시점 — 결과 메시지 없이 창이 닫힌 것처럼 보이면 2초 간격으로 확인을 시작합니다. 결과 메시지를 받았더라도 주문번호는 결제 상태 응답의 값을 우선합니다.
pending— 결제를 취소했다고 확정하지 않습니다. 확인을 계속하면서 구매자가 원하면 새 결제를 시작할 수 있게 합니다. 새 결제는 확인을 멈춘 뒤에 요청합니다. 같은 주문서는 한 번만 결제되며, 이전 결제와 새 결제가 모두 승인되면 나중에 끝난 결제를 서버가 자동으로 취소합니다.- 상한 — 확인은
expiresAt까지 합니다. 그 시각이 지나면 한 번 더 조회해 최종 상태를 정합니다. 결제 대기로 남은 결제는 이때expired로 보고됩니다. 그래도processing이면 승인 결과가 확정되지 않은 것이므로 실패로 처리하지 말고, 나중에 주문 내역에서 확인하게 합니다. 사용자가 페이지를 떠나면 확인을 멈춥니다. - 테스트 결제 —
testPayment가true면 완료 처리 때 테스트 결제임을 함께 표시합니다.
async function watchPayment(paymentId: string, signal: AbortSignal) {
let payment = await sdk.payments.getStatus(paymentId);
while (!signal.aborted && Date.now() < Date.parse(payment.expiresAt)) {
if (payment.status === "completed" || payment.status === "failed" || payment.status === "expired") break;
await new Promise((resolve) => setTimeout(resolve, 2000)); // pending·processing·모르는 값
payment = await sdk.payments.getStatus(paymentId);
}
if (signal.aborted) return;
if (payment.status !== "completed" && payment.status !== "failed") {
payment = await sdk.payments.getStatus(paymentId); // 상한에서 한 번 더 확인
}
switch (payment.status) {
case "completed":
return onCompleted(payment.orderId, payment.testPayment);
case "failed":
case "expired":
return onFailed();
default:
return onStillProcessing(); // 승인 결과 미확정. 주문 내역에서 다시 확인
}
}결제 API 규칙
결제 관련 API가 보장하는 동작입니다.
- 금액 재검증 — 승인 전에 결제 금액을 주문서 금액과 대조하고, PG가 승인한 금액도 결제 금액과 대조합니다.
PG 승인 금액이 다르면 결제를 자동 취소하고
409 PG_AMOUNT_MISMATCH를 반환합니다. - 상태는 단방향 — 결제 세션은
pending → completed | failed | expired로만 끝납니다. 카드 거절처럼 승인되지 않은 것이 확정된 실패는 세션을 끝내지 않습니다. 세션은pending으로 남고 같은 세션에서 다시 결제할 수 있습니다. 이미 끝난 세션에 승인을 요청하면409 ALREADY_PROCESSED. - 승인은 멱등 — 같은
paymentId + pgToken재호출은 저장된 결과를 그대로 반환합니다. 다른 pgToken이면409 ALREADY_COMPLETED_WITH_DIFFERENT_KEY. - 현재 시도만 승인 — 결제 세션 하나에 결제 시도가 여러 번 생길 수 있습니다(다른 결제사로 재시도).
승인 요청의
attemptId는 결제창이 돌려준 주문번호이고, 생략하면 현재 시도를 승인합니다. 지난 시도를 지정하면409 ATTEMPT_NOT_CURRENT이며, 그 시도가 이미 결제됐다면 서버가 취소합니다. - 재고 경합 — 승인 중 재고가 소진되면
409 STOCK_DEPLETED와 함께 결제가 자동 취소됩니다. - 만료 — 주문서는 30분, 결제 세션은 10분 뒤 만료됩니다. 만료 후 요청은 각각
409 CHECKOUT_EXPIRED,409 PAYMENT_EXPIRED.
결제 승인 응답
결제 팝업을 쓰면 승인은 팝업이 처리합니다. POST /payments/{paymentId}/confirm을 직접 호출한다면 응답에 따라
다음처럼 처리하세요.
| 응답 | 세션 | 처리 |
|---|---|---|
200 | completed | 주문 완료 |
402(PG 거절 코드) · 402 PAYMENT_NOT_APPROVED · 502 PG_REQUEST_FAILED · 409 LATE_APPROVAL_CANCELED | pending | 같은 세션에서 다시 결제할 수 있습니다 |
409 PAYMENT_RESULT_UNKNOWN · 409 PAYMENT_IN_PROGRESS | 처리 중 | 승인을 다시 요청하지 말고 잠시 후 주문 내역을 확인합니다 |
409 ALREADY_COMPLETED_WITH_DIFFERENT_KEY | completed | 다른 승인으로 이미 완료됐습니다. 주문 내역을 확인합니다 |
409 STOCK_DEPLETED · ALREADY_PAID · PG_AMOUNT_MISMATCH · AMOUNT_MISMATCH · CHECKOUT_EXPIRED · CHECKOUT_NOT_FOUND · PG_ENVIRONMENT_MISMATCH | failed | 승인된 결제는 자동 취소됩니다. 주문서부터 다시 시작합니다 |
409 ALREADY_PROCESSED · PAYMENT_EXPIRED · PAYMENT_SESSION_OUTDATED · ATTEMPT_NOT_CURRENT · INVALID_ATTEMPT_STATE | — | 새 결제 요청이 필요합니다 |
503 PG_CREDENTIALS_UNAVAILABLE · 503 PG_MERCHANT_MISMATCH | pending | 스토어 결제 설정 문제로 이 결제사로는 승인할 수 없습니다. 다른 결제사로 다시 결제하거나 결제를 받을 수 없는 상태로 처리합니다 |
포트원 결제에서는 결제창에서 이미 결제가 끝났을 수 있어, 스토어 결제 설정 문제로 승인하지 못한 경우에도 503 대신
409 PAYMENT_RESULT_UNKNOWN을 반환합니다. 처리 방법은 PAYMENT_RESULT_UNKNOWN과 같습니다.