결제 연동
결제 옵션을 고르고 결제창을 거쳐 주문 완료까지 연결하기. 현금영수증 신청과 배송비 확정 포함
구매자가 결제수단을 고르고 결제창에서 결제한 뒤 주문 완료 화면으로 돌아오게 합니다. MCP 템플릿에는 이 흐름이 이미 있습니다.
스토어프론트는 주문서의 결제 옵션(PG × 결제수단) 중 하나로 결제를 시작하고, 응답의 결제 주소(payUrl)를 팝업이나 현재 탭으로 엽니다.
sayren 결제 화면이 PG 결제창과 승인을 맡고, 스토어프론트는 결과만 받습니다.
5분 요약
스토어프론트는 정보만 넘깁니다. 고른 상품·옵션·수량, 쿠폰, 사용할 적립금 금액, 결제수단, 배송지를 보내면 결제 금액, 적립금 실제 사용액, 결제가 필요한지는 서버와 SDK가 판단합니다. 화면은 응답 값을 그대로 보여 주고 금액을 직접 계산하지 않습니다.
import { createStorefrontClient } from "@sayren/storefront-sdk";
import { createPayments } from "@sayren/storefront-sdk/payments";
// 1) 클라이언트 — 회원이면 accessToken을 넘깁니다(적립금은 회원만 사용합니다)
const sdk = createStorefrontClient({
baseUrl: "https://api.sayren.io/storefront/v1",
storeCode: "{상점 코드}",
accessToken: () => session.accessToken,
});
const payments = createPayments({ client: sdk, returnUrl: `${location.origin}/checkout/return` });
// 2) 장바구니 담기
const cart = await sdk.cart.addItem({ productId: "prod_001", optionId: "var_001", quantity: 1 });
// 3) 주문서 — 결제 옵션(paymentOptions)과 적립금 안내(points)가 같이 옵니다
const checkout = await sdk.checkout.create({ cartItemIds: cart.items.map((item) => item.cartItemId) });
// 4) (쿠폰·적립금을 쓰면) 금액 미리보기 — 결제 금액과 결제 필요 여부는 서버가 정합니다
const coupons = [{ code: "WELCOME10" }];
const pointAmount = 3000;
const pricing = await sdk.checkout.pricing(checkout.checkoutId, { coupons, pointAmount });
showAmounts(pricing.amounts); // totalAmount·pointAmount를 그대로 표시
// 5) 결제 시작 — 클릭 핸들러에서 바로 부릅니다
async function onPayClick(option, shippingAddress) {
const result = await payments.start(checkout.checkoutId, {
option: pricing.paymentRequired ? option : undefined, // false면(적립금 전액) 결제수단을 보내지 않습니다
shippingAddress,
coupons,
pointAmount,
});
settle(result);
}
// 모바일·리다이렉트 결제는 복귀 화면(/checkout/return)에서 같은 결과를 읽습니다
settle(await payments.result());
// 6) 결과 분기는 하나입니다 — 결제창 결제와 적립금 전액 결제가 같은 결과를 돌려줍니다
function settle(result) {
if (result.status === "COMPLETED") return location.replace(`/orders/${result.orderId}`);
if (result.status === "PROCESSING") return waitForResult(result.paymentId);
return showRetry(result); // CANCELED·FAILED·INVALID
}- 쿠폰·적립금을 쓰지 않으면 4단계를 건너뛰고
option·shippingAddress만 넘깁니다. pricing.paymentRequired가false면 결제수단 영역을 숨기고option을 생략합니다.payments.start는 결제창 없이COMPLETED를 돌려줍니다.- 배송이 필요 없는 주문(주문서
requiresShipping이false)은shippingAddress를 생략합니다. - 상세 규칙은 아래 단계별 설명과 쿠폰·적립금을 보십시오.
준비
- Storefront SDK 클라이언트(
sdk)와 장바구니 - 설정 › 결제(소유자만)에서
사용중인 PG 1개 이상(결제 설정) - (선택) 설정 › 결제의 결제 도메인. 비우면 모든 https 복귀 주소를 받고, 등록하면 그 도메인으로 제한합니다
전체 흐름
- 주문서 생성 —
sdk.checkout.create(). 가격·재고를 검증한 30분짜리 주문서와 결제 옵션 목록(paymentOptions)이 옵니다. - 결제 시작 — 구매자가 옵션을 고르면
payments.start(). 10분짜리 결제가 만들어지고 결제창이 열립니다. - 결제 — 구매자가 결제창에서 결제하고 sayren이 PG 승인을 합니다.
- 결과 받기 — 팝업이면
start()가 결과를 돌려주고, 리다이렉트면 복귀 주소(returnUrl)에서payments.result()로 같은 결과를 읽습니다. - 실패하면 다른 옵션으로 — 거절·취소면
payments.retry()로 같은 결제를 다른 옵션으로 다시 합니다.
팝업과 리다이렉트
mode | 동작 | start()·retry() 반환 |
|---|---|---|
auto (기본) | 모바일은 리다이렉트, PC는 팝업. 팝업이 차단되면 리다이렉트 | 팝업일 때만 결과 |
popup | 팝업에서 결제하고 결과를 메시지로 받음 | 결제 결과 |
redirect | 현재 탭이 결제 화면으로 이동 | 끝나지 않음(페이지를 떠남) |
모바일은 앱 전환으로 팝업 연결이 끊기므로 리다이렉트를 씁니다. 팝업 차단·연결 끊김에 대비해 복귀 화면은 항상 만들어 두십시오.
샌드박스 모드 상점의 결제는 테스트 결제입니다. 돈이 오가지 않고 주문서·결제·주문 응답에 testPayment: true가 붙으니 구매자에게 표시하십시오.
완성 예제
import { createStorefrontClient, paymentOptionKey } from "@sayren/storefront-sdk";
import { createPayments } from "@sayren/storefront-sdk/payments";
const sdk = createStorefrontClient({ baseUrl: "https://api.sayren.io/storefront/v1", storeCode: "{상점 코드}" });
const payments = createPayments({ client: sdk, returnUrl: `${location.origin}/checkout/return` });
// 1) 주문서 — 결제 옵션 목록이 같이 온다
const checkout = await sdk.checkout.create({ cartItemIds: ["ci_1"] });
renderOptions(checkout.paymentOptions); // 라디오 value = paymentOptionKey(option)
// 2) 결제 시작 — 클릭 핸들러에서 바로 부른다
async function onPayClick(option, shippingAddress, guest) {
settle(await payments.start(checkout.checkoutId, { option, shippingAddress, guest }));
}
// 4) 복귀 화면(/checkout/return)
settle(await payments.result());
function settle(result) {
switch (result.status) {
case "COMPLETED":
return location.replace(`/orders/${result.orderId}`);
case "CANCELED": // 구매자가 결제창을 닫음
case "FAILED": // 카드 거절 등. result.code·result.message
return showRetry(result.paymentId); // 5) payments.retry(result.paymentId, { option })
case "PROCESSING": // 결과 확인 중 — 실패로 단정하지 않는다
return waitForResult(result.paymentId);
case "INVALID": // 결제 정보가 없는 주소
return location.replace("/cart");
}
}완료 기준: 테스트 결제 뒤 완료 화면으로 이동하고, 상점 관리 › 판매 › 주문에 테스트 결제 배지가 붙은 주문이 보입니다.
1. 주문서와 결제 옵션
const checkout = await sdk.checkout.create({ cartItemIds: ["ci_1", "ci_2"] });
// 바로구매: { directItem: { productId: "prod_001", optionId: "var_001", quantity: 1 } }
// checkout.paymentOptions — PG 우선순위순
// [
// { pg: "tosspayments", method: "CARD", label: "신용·체크카드", pgName: "토스페이먼츠" },
// { pg: "tosspayments", method: "EASY_PAY", provider: "NAVERPAY", label: "네이버페이", pgName: "토스페이먼츠" },
// { pg: "portone", method: "EASY_PAY", provider: "KAKAOPAY", label: "카카오페이", pgName: "포트원" },
// ]| 필드 | 의미 |
|---|---|
pg | tosspayments · portone |
method | CARD · EASY_PAY · BANK_TRANSFER · MOBILE · VIRTUAL_ACCOUNT(아직 목록에 나오지 않음) |
provider | EASY_PAY일 때만. NAVERPAY · KAKAOPAY · TOSSPAY · PAYCO — 그 간편결제 결제창을 바로 엽니다 |
label · pgName | 화면 표시 이름 |
셀러가 켠 PG·결제수단만 담기고, 비어 있으면 지금 결제할 수 없습니다. paymentOptionKey(option)은 tosspayments:EASY_PAY:NAVERPAY 같은 키를 만듭니다.
SDK가 모르는 새 결제수단은 목록에서 빠집니다(SDK를 올리면 보입니다).
옵션 직접 쓰기
목록 항목 대신 옵션을 직접 써도 됩니다. TypeScript에서 provider는 method: "EASY_PAY"일 때만 허용됩니다.
await payments.start(checkoutId, { option: { pg: "tosspayments", method: "EASY_PAY", provider: "NAVERPAY" }, shippingAddress });셀러가 켜지 않은 조합이면 409 PAYMENT_OPTION_UNAVAILABLE이고 error.details.paymentOptions에 고를 수 있는 목록이 옵니다.
회원만 주문받는 상점
비회원 주문을 막은 상점에서 로그인하지 않은 구매자의 주문서 생성·결제 시작은 403 GUEST_CHECKOUT_DISABLED입니다.
공개 설정의 guestCheckout으로 미리 알고 주문 버튼을 로그인 안내로 바꾸십시오.
const store = await sdk.store.get();
if (!store.guestCheckout && !isLoggedIn) {
// "로그인하고 주문하기" — 로그인할 때 cartToken을 보내면 장바구니가 합쳐진다
}배송지를 받으면 배송비가 바뀝니다
주문서는 배송지를 모르고 만들어서 제주·도서산간 추가 배송비가 빠져 있습니다(checkout.delivery.zipCode가 null).
구매자가 배송지를 입력하면 다시 계산해 보여 주십시오.
const quote = await sdk.checkout.quoteDelivery(checkout.checkoutId, { zipCode: "63000" });
// quote.amounts.totalAmount — 이 배송지로 결제할 금액
// quote.delivery.remoteSurcharge · remoteAreaLabel — 추가 배송비와 지역 이름주문서를 바꾸지 않는 읽기 계산입니다. 최종 금액은 결제 시작이 보낸 배송지로 확정되고 결제 시작 응답의 amounts에 있습니다.
미리보기를 건너뛰면 결제창 금액이 화면보다 커질 수 있습니다. 규칙은 배송비와 출고지·반품지에 있습니다.
배송이 없는 주문
배송 없는 상품(MANUAL)만 담긴 주문서는 requiresShipping이 false입니다. 배송지 입력을 숨기고 결제 시작에 shippingAddress를 보내지 않아도 됩니다.
배송이 필요한 주문에 배송지가 없으면 400 SHIPPING_ADDRESS_REQUIRED입니다.
2. 결제 시작
const result = await payments.start(checkout.checkoutId, {
option,
shippingAddress: { receiverName, phone, zipCode, address1, address2, deliveryMemo },
guest: isGuest ? { name, phone, email, orderPassword } : undefined,
// shippingAddress — checkout.requiresShipping이 false면 생략
// returnUrl — 기본은 createPayments에 준 값, mode — 기본 "auto"
});start()는 POST /checkout/{checkoutId}/payment로 결제를 만들고 결제창을 엽니다. 결제 시작 API가 실패하면 미리 연 팝업을 닫고 에러를 던집니다.
회원이 적립금을 사용하면 pointAmount를 함께 보냅니다. 먼저 금액 미리보기(api.checkout.pricing)로 실제 사용액을 확인하십시오. 확정 금액은 응답의 amounts(pointAmount·totalAmount)입니다. 적립금
스토어프론트는 정보만 넘깁니다. 상품·옵션·수량, 쿠폰, 사용할 적립금 금액을 보내고 결제 금액과 결제가 필요한지는 서버와 SDK가 판단합니다.
금액 미리보기의 paymentRequired가 false면(적립금이 결제 금액 전체를 덮음) 결제수단 영역을 숨기고 option 없이 start()를 부르십시오.
결제창 없이 바로 COMPLETED가 돌아옵니다. 결과 분기는 같습니다.
const pricing = await api.checkout.pricing(checkout.checkoutId, { coupons, pointAmount });
const result = await payments.start(checkout.checkoutId, {
option: pricing.paymentRequired ? option : undefined,
shippingAddress,
coupons,
pointAmount,
});팝업은 클릭 콜스택 안에서 열어야 차단되지 않습니다. 클릭 핸들러에서 바로 start()를 부르십시오.
결제 시작을 서버에서 하는 경우
회원 토큰을 서버에만 두는 SSR 앱은 클릭 시점에 팝업을 먼저 열고, 서버 응답이 오면 그 창으로 결제 화면을 띄웁니다.
const paymentWindow = payments.prepareWindow(); // 클릭 핸들러에서. paymentWindow.mode로 실제 방식(popup·redirect)을 안다
try {
const start = await startPaymentOnServer({ checkoutId, option, shippingAddress }); // 서버 함수
settle(await payments.open(start, { window: paymentWindow }));
} catch (error) {
paymentWindow.close(); // 결제 시작 실패 — 빈 팝업을 닫는다
throw error;
}복귀 주소와 결제 도메인
returnUrl은 결제를 마친 구매자가 돌아올 https 절대 주소입니다. 팝업 결제에서도 결과 메시지를 보낼 곳의 기준입니다.
- 셀러가 설정 › 결제의 결제 도메인을 비워 두면 모든
https주소를 받습니다 - 등록돼 있으면 목록 밖 주소는
400 RETURN_URL_NOT_ALLOWED입니다 - 테스트 결제는
localhost·127.0.0.1을 항상 허용합니다
결제를 시작할 수 없을 때
| 응답 | 의미 | 할 일 |
|---|---|---|
409 PAYMENT_NOT_CONFIGURED | 지금 결제를 받을 PG가 없음 | 결제 준비 중으로 안내 |
409 PAYMENT_OPTION_UNAVAILABLE | 쓸 수 없는 결제 옵션 | details.paymentOptions로 다시 고르게 함 |
503 PAYMENT_PROVIDER_UNAVAILABLE | 고른 PG의 결제창을 준비하지 못함 | 다른 옵션으로 다시 시도 |
400 RETURN_URL_NOT_ALLOWED | https가 아니거나 결제 도메인 밖의 복귀 주소 | 주소를 고치거나 셀러가 도메인 등록 |
400 SHIPPING_ADDRESS_REQUIRED | 배송이 필요한 주문에 배송지 없음 | 배송지를 받아 다시 시작 |
409 CHECKOUT_STALE | 주문서를 만든 뒤 상품 유형이 바뀜 | 주문서를 다시 만듦 |
다른 PG로 자동으로 넘어가지 않습니다. PG는 항상 스토어프론트가 옵션으로 정합니다.
3. 현금영수증
계좌이체(BANK_TRANSFER)·가상계좌(VIRTUAL_ACCOUNT)는 결제 시작에 cashReceipt를 실어 신청합니다. 승인되면 sayren이 그 PG로 발급합니다.
await payments.start(checkout.checkoutId, {
option: { pg: "tosspayments", method: "BANK_TRANSFER" },
shippingAddress,
cashReceipt: { type: "INCOME_DEDUCTION", identityNumber: "010-1234-5678" },
});type | 용도 | identityNumber |
|---|---|---|
INCOME_DEDUCTION | 소득공제(개인) | 휴대폰 번호(01X + 7 |
EXPENSE_PROOF | 지출증빙(사업자) | 사업자등록번호(10자리) 또는 현금영수증카드 번호(13~19자리) |
- 주민등록번호는 받지 않습니다(
400 VALIDATION_FAILED, 필드 코드RESIDENT_NUMBER_NOT_ALLOWED). 용도에 맞지 않는 번호도400 VALIDATION_FAILED입니다. - 하이픈은 있어도 됩니다.
cashReceiptRequestSchema로 미리 검사하고cashReceiptAvailable(option.method)로 입력란 표시를 정합니다. - 다른 결제수단에 보내면
400 CASH_RECEIPT_NOT_AVAILABLE입니다. 최종 승인 수단이 계좌이체·가상계좌가 아니면 발급하지 않습니다. retry에cashReceipt를 보내면 신청을 바꾸고, 생략하면 처음 신청을 유지합니다.- 환불하면 환불 금액만큼 취소됩니다. 발급이 늦거나 실패해도 결제·주문은 그대로이고 셀러가 주문 상세에서 다시 발급합니다.
- 식별 번호는 암호화해 저장하고 관리 API·상점 플랫폼에는 가린 번호(
010****5678)만 보입니다.
4. 결과 받기
결과는 PaymentResult 하나입니다. 팝업이면 start()·retry()·open()이 돌려주고, 리다이렉트면 복귀 화면의 payments.result()가 줍니다.
두 경로 모두 결제 상태 조회로 판정하므로 결과가 같습니다.
result.status | 의미 | 할 일 |
|---|---|---|
COMPLETED | 결제 완료, 주문 생성(result.orderId) | 완료 화면으로 이동 |
CANCELED | 구매자가 결제창을 닫음 | 다른 옵션으로 다시 결제 |
FAILED | 카드 거절 등(result.code·result.message) | 다른 옵션으로 다시 결제 |
PROCESSING | 승인 결과 확인 중 | 확정될 때까지 조회. 다시 결제하지 않음 |
INVALID | 복귀 주소에 결제 정보가 없음 | 장바구니·주문서로 안내 |
복귀 화면(/checkout/return)은 await payments.result() 한 줄이면 됩니다.
- 새 탭으로 돌아와도 됩니다 — 결제 id가 복귀 주소에 실려 있어 저장한 값 없이 결과를 읽습니다
- 여러 번 불러도 안전합니다 — 뒤로 가기·새로고침에도 같은 결과입니다
- 브라우저에서 부릅니다 — 서버 렌더에서 부르지 마십시오
결과 확인 중일 때
PROCESSING을 실패로 안내하면 구매자가 다시 결제해 이중 결제가 날 수 있습니다.
payments.getStatus(paymentId)로 확정될 때까지 확인하십시오.
async function waitForResult(paymentId: string) {
for (let i = 0; i < 20; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 2000));
const status = await payments.getStatus(paymentId);
if (status.result === "COMPLETED") return location.replace(`/orders/${status.orderId}`);
if (status.result === "FAILED" || status.result === "EXPIRED") return showRetry(paymentId);
}
showNotice("결제 결과를 확인하고 있습니다. 잠시 뒤 주문 내역에서 확인해 주십시오.");
}상태 조회(GET /payments/{paymentId})는 인증 없이 결제 id로 합니다. 응답 필드(result·status·orderId·lastFailure·testPayment·expiresAt)는
결과 확정에 있습니다. result·status는 값이 늘 수 있으니 모르는 값은 PROCESSING처럼 다루고 계속 조회하십시오.
5. 다른 옵션으로 다시 시도
const result = await payments.retry(paymentId, { option: { pg: "portone", method: "CARD" } });같은 결제(같은 주문서·배송지)에 새 시도를 열고 결제창을 엽니다(POST /payments/{paymentId}/attempts). 반환과 mode는 start()와 같고,
returnUrl을 빼면 처음 값을 씁니다. 승인·확인 중이면 409 PAYMENT_IN_PROGRESS, 결제가 만료(10분)됐으면 주문서부터 다시 합니다.
FAILED 결과에 optionUnavailable: true가 있으면 상점의 PG 설정 문제처럼 구매자가 고칠 수 없는 실패입니다. 방금 고른 옵션을 목록에서
빼거나 고르지 못하게 하고, message(「이 결제수단은 지금 사용할 수 없습니다. 다른 결제수단을 선택하십시오」)를 보여 줍니다.
SDK 없이 연동하기
결제 시작 응답의 payUrl을 열고, 팝업 메시지나 복귀 주소의 sayrenPaymentId로 GET /payments/{paymentId}를 조회하면 됩니다.
메시지와 복귀 쿼리는 신호일 뿐이고 판정은 상태 조회로 합니다. 전체 규칙과 예제는 SDK 없이 결제 연동에 있습니다.
결제 시작 응답의 status가 completed면 결제창 없이 결제가 끝난 것입니다(적립금으로 결제 금액 전체를 낸 주문, 준비 중). 이때 payUrl은 null이고
orderId가 주문 번호입니다. 결제창을 열지 말고 주문 완료 화면으로 보내십시오. SDK의 payments.start·open은 이 경우를 직접 처리합니다.
모바일과 웹뷰
- 모바일 웹은 리다이렉트로 결제합니다. 간편결제·앱카드 앱 전환은 결제 화면이 처리합니다
- 자체 앱 웹뷰라면 PG 앱 스킴(
intent://,kakaotalk://,supertoss://등)을 외부 앱으로 넘기도록 설정하십시오 - 구매자가 결제 앱에서 돌아오지 않아도 서버가 확인합니다. 미승인 결제는 만료되고, 결제창에서 매입된 결제는 서버가 확인해 취소합니다
CSP를 쓰는 스토어프론트
PG 스크립트는 결제 화면이 불러옵니다. 스토어프론트 CSP에 PG 도메인을 넣을 필요가 없고, 새 창·페이지 이동으로 여니 frame-src도 필요 없습니다.
결제 API가 보장하는 동작
- 금액 재검증 — 승인 전 결제 금액을 주문서와, PG 승인 금액을 결제 금액과 대조합니다. 다르면 자동 취소합니다.
- 상태는 되돌아가지 않음 — 결제는
completed·failed·expired중 하나로 끝납니다. 확정된 거절은 결제를 끝내지 않고 재시도하게 합니다. - 같은 주문서는 한 번만 결제 — 두 결제가 모두 승인되면 나중 것을 자동 취소합니다.
- 승인은 한 곳에서 — PG 승인은 sayren이 하고 같은 결제로 승인이 두 번 나가지 않습니다.
- 재고 경합 — 승인 중 재고가 소진되면 결제를 자동 취소합니다.
- 만료 — 주문서 30분, 결제 10분입니다. 만료 후에는
409 CHECKOUT_EXPIRED,409 PAYMENT_EXPIRED입니다.
관련 API 레퍼런스
주문서 생성
POST /checkout
결제 시작
POST /checkout/{checkoutId}/payment
결제 재시도
POST /payments/{paymentId}/attempts
결제 상태 조회
GET /payments/{paymentId}
SDK 없이 연동
payUrl과 결제 상태 API를 직접 호출