Storefront SDK
구매자 API 클라이언트 — createStorefrontClient 사용법
@sayren/storefront-sdk는 스토어프론트 API의 zod 스키마와 fetch 클라이언트입니다. 응답의 data 언랩과 스키마 검증을 SDK가 합니다.
| 경로 | 용도 |
|---|---|
@sayren/storefront-sdk | API 클라이언트(createStorefrontClient), 세션, 스키마 |
@sayren/storefront-sdk/auth | 서버에서 쓰는 구매자 로그인(구매자 인증) |
@sayren/storefront-sdk/payments | 브라우저 결제 모듈(결제 연동) |
@sayren/storefront-sdk/analytics | 방문 분석 수집(방문 분석) |
준비
npm install @sayren/storefront-sdk- 상점 코드: 설정 › 상점 정보의 기본 정보에 있습니다. 카탈로그만 보려면 데모 상점 코드
rk6dzy3b49k0도 됩니다.
클라이언트 생성
import { createStorefrontClient } from "@sayren/storefront-sdk";
const sdk = createStorefrontClient({
baseUrl: "https://api.sayren.io/storefront/v1",
storeCode: "{상점 코드}", // X-Store-Code. api.sayren.io이면 필수
cartToken: () => localStorage.getItem("cartToken"), // 비회원 장바구니 X-Cart-Token
onCartToken: (token) => localStorage.setItem("cartToken", token), // 서버가 새 토큰을 줬을 때
});| 옵션 | 타입 | 설명 |
|---|---|---|
baseUrl | string | API 베이스 URL (필수) |
storeCode | TokenSource | X-Store-Code 헤더(상점 지정) |
accessToken · auth.accessToken | TokenSource · string | 회원 Authorization: Bearer |
session | StorefrontSession | Bearer·401 갱신·만료 정리를 자동 배선 |
cartToken · onCartToken | TokenSource · (token) => void | 비회원 장바구니 토큰과 새 토큰 수신 콜백 |
visitorId · sessionId | TokenSource | 방문 분석 식별자. 담기·결제·구매를 방문과 잇습니다 |
fetch | typeof fetch | 커스텀 fetch |
TokenSource가 함수면 요청마다 다시 읽습니다. 토큰 옵션이 겹치면 accessToken → auth.accessToken → session 순입니다.
인증 — 세션
브라우저 앱은 createStorefrontSession으로 로그인·가입·토큰 저장·자동 갱신을 맡깁니다. 저장 매체는 SessionStorage 어댑터(get·set·remove)로 정합니다.
토큰을 서버 쿠키에 두는 SSR 앱은 구매자 인증의 createStorefrontAuth를 쓰십시오.
import { createStorefrontClient, createStorefrontSession } from "@sayren/storefront-sdk";
const session = createStorefrontSession({
baseUrl: "https://api.sayren.io/storefront/v1",
storeCode: "{상점 코드}",
storage: {
get: (key) => localStorage.getItem(key),
set: (key, value) => localStorage.setItem(key, value),
remove: (key) => localStorage.removeItem(key),
},
});
await session.login({ email: "buyer@example.com", password: "{비밀번호}" });
const sdk = createStorefrontClient({ baseUrl: "https://api.sayren.io/storefront/v1", storeCode: "{상점 코드}", session });| 멤버 | 설명 |
|---|---|
login(body) · signup(body) | 로그인·가입 후 토큰 쌍 저장. cartToken을 실으면 비회원 장바구니를 합칩니다 |
refresh() | 토큰 갱신, 성공 여부 반환. 동시 호출은 한 번으로 모읍니다 |
accessToken · refreshToken | 저장된 토큰 |
userinfo() | 회원 정보 또는 null |
clear() | 저장 키 삭제 + 로그아웃 요청 |
subscribe(cb) | 인증 상태 변경 구독, 해제 함수 반환 |
클라이언트에 session을 넘기면 401에서 refresh()를 한 번 시도하고 실패하면 세션을 정리합니다.
세션 없이 직접 호출
sdk.auth를 직접 부르면 토큰 저장·갱신은 앱 몫입니다.
const tokens = await sdk.auth.login({ email: "buyer@example.com", password: "{비밀번호}" });
// { accessToken, refreshToken, expiresIn } — expiresIn은 초
const rotated = await sdk.auth.refresh(tokens.refreshToken); // rotation — 새 쌍을 받고 이전 토큰은 폐기
await sdk.auth.logout();
// signup({ email, password, name, phone?, agreements: { terms: true, privacy: true, marketing }, cartToken? })도 같은 토큰 쌍을 준다카탈로그 — 인증 불필요
const categories = await sdk.catalog.listCategories();
// sort: recommend · latest · priceAsc · priceDesc …
const page = await sdk.catalog.searchProducts({ keyword: "티셔츠", page: 1, size: 20, sort: "priceAsc" });
const product = await sdk.catalog.getProduct("prod_001");
// product.optionGroups: [{ groupId, name, values: [{ valueId, name }] }]
// product.variants: [{ variantId, valueIds, name: "화이트 / M", additionalPrice, soldOut }]
await sdk.catalog.listProductReviews("prod_001", { sort: "helpful" });
// 옵션별 리뷰 — 상세의 variants[].variantId. 리뷰 항목의 variantId와 같은 값입니다
await sdk.catalog.listProductReviews("prod_001", { variantId: product.variants[0].variantId, hasImage: true });
await sdk.catalog.listProductInquiries("prod_001", { page: 1 });
// category: PRODUCT(기본) · DELIVERY · RETURN · EXCHANGE · ETC
await sdk.catalog.createInquiry("prod_001", { content: "언제 발송되나요?", secret: true, category: "DELIVERY" });컬렉션(기획전·이벤트 상품 묶음)은 주소(slug)로 읽습니다. 노출 중인 컬렉션만 보이고, 상품은 판매 중만 상품 검색과 같은 카드로 옵니다.
const { contents: collections } = await sdk.catalog.listCollections();
const collection = await sdk.catalog.getCollection("chuseok"); // 숨김·기간 밖은 404 COLLECTION_NOT_FOUND
// sort: manual(셀러가 정한 순서) · recommend · latest · priceAsc … 생략하면 collection.productSort
const items = await sdk.catalog.listCollectionProducts("chuseok", { page: 1, size: 20 });자세한 규칙은 컬렉션에 있습니다.
리뷰 목록은 rating(평점)·hasImage(사진 리뷰)·variantId(옵션 조합)로 거르고, 함께 주면 모두 만족하는 리뷰만 옵니다.
리뷰 항목의 variantId는 리뷰를 쓴 구매자가 산 조합이라 「레드 / M」 같은 옵션별 필터를 만들 때 이 값을 씁니다. 주문 정보를 찾지 못한 리뷰는 null입니다.
summary(평균 평점·평점 분포)는 필터와 무관하게 상품 전체 기준입니다.
정렬 sort는 latest(기본, 최신 순)·ratingDesc(평점 높은 순)·ratingAsc(평점 낮은 순)·helpful(랭킹순)입니다.
랭킹순은 사진 있는 리뷰를 먼저 보이고, 그다음 평점 높은 순, 평점이 같으면 최신 순입니다. 도움돼요 수는 반영하지 않습니다.
가격 표시
상품 카드·상세의 가격 필드는 네 개입니다. 구매자가 내는 한 개 가격은 discountedPrice ?? salePrice입니다.
| 필드 | 뜻 |
|---|---|
salePrice | 판매가. 즉시할인의 기준 가격입니다 |
discountedPrice | 즉시할인 적용가. 할인이 없으면 null입니다 |
discountRate | 판매가 대비 즉시할인율(%) |
originalPrice | 정가(할인 전 표시 가격). 비교용 표시 값이고 결제 금액에 쓰지 않습니다. 없으면 null입니다 |
「정가 취소선 + 할인율 + 판매가」로 보이려면 정가가 구매자 가격보다 클 때만 정가를 취소선으로 두고 할인율을 정가 기준으로 계산하십시오.
정가가 없으면 즉시할인이 있을 때 salePrice를 취소선으로 둡니다.
const price = product.discountedPrice ?? product.salePrice;
const compareAt =
product.originalPrice !== null && product.originalPrice > price
? product.originalPrice
: product.discountedPrice !== null
? product.salePrice
: null;
const rate = compareAt ? Math.round((1 - price / compareAt) * 100) : null;구매자가 축마다 값을 고르면 valueIds가 일치하는 조합 하나가 정해집니다. 그 variantId를 장바구니·바로구매의 optionId로 보냅니다.
optionGroups가 비었으면 옵션 없는 상품이라 optionId를 생략합니다. 재고 수치는 내려오지 않으니 구매 가능 여부는 soldOut으로 판단하십시오.
상점 공개 설정
const store = await sdk.store.get();
// { storeCode, name, logoUrl, customerCenterPhone, businessHours, guestCheckout, loginMethods,
// checkoutAvailable, checkoutMode, checkoutUnavailableReason,
// businessName, representativeName, businessNumber, mailOrderSalesNumber, businessAddress,
// customerCenterEmail, privacyOfficerName, privacyOfficerEmail }사업자 정보(상호·대표자명·사업자등록번호·통신판매업 신고번호·사업장 주소·고객센터 이메일·개인정보 보호책임자)는 푸터 표기용입니다.
셀러가 정하지 않은 항목은 null이니 그 항목은 표시하지 마십시오. 상호(businessName)는 상점명(name)과 다를 수 있습니다.
businessNumber는 숫자 10자리이고 표시할 때 123-45-67890으로 끊으면 됩니다. 값은 관리 API PATCH /v1/store로 정합니다.
checkoutAvailable이 false면 지금 주문을 받지 않습니다(결제 시작이 409 PAYMENT_NOT_CONFIGURED). 장바구니 담기·바로 구매·주문 버튼과
헤더의 장바구니를 숨기고 「지금은 온라인 주문을 받지 않습니다」를 보이십시오. customerCenterPhone이 있으면 문의 전화를 함께 보입니다.
checkoutMode가 TEST면 테스트 결제라 돈이 오가지 않습니다. 사유와 조건은 결제를 받을 수 없는 상태에 있습니다.
if (!store.checkoutAvailable) {
return <p>지금은 온라인 주문을 받지 않습니다</p>;
}guestCheckout이 false면 회원만 주문합니다(회원만 주문받는 상점).
loginMethods는 켜진 로그인 방법입니다(로그인 방법 확인).
장바구니 — 비회원 토큰 자동 처리
비회원이 처음 담으면 서버가 X-Cart-Token을 발급하고 SDK가 onCartToken으로 넘깁니다. 이후 요청에는 저장된 토큰이 실립니다.
const cart = await sdk.cart.addItem({
productId: "prod_001",
optionId: product.variants[0].variantId, // 옵션 없는 상품은 생략
quantity: 2,
});
await sdk.cart.get();
await sdk.cart.updateItem(cart.items[0].cartItemId, { quantity: 3 });
await sdk.cart.removeItem(cart.items[0].cartItemId);- 비회원 토큰은 마지막 활동 후 30일이 지나거나 로그인으로 합쳐지면 새 토큰으로 바뀝니다.
onCartToken값은 항상 저장하십시오. - 옵션 상품에
optionId가 없으면400 OPTION_REQUIRED, 없는 조합은404 OPTION_NOT_FOUND, 재고 초과는409 INSUFFICIENT_STOCK입니다.
체크아웃
주문서 생성 → 결제 옵션 선택 → 결제창 → 결과 순서입니다. 결제는 브라우저 모듈 @sayren/storefront-sdk/payments가 하고 PG 의존성은 없습니다.
import { createPayments } from "@sayren/storefront-sdk/payments";
const payments = createPayments({ client: sdk, returnUrl: `${location.origin}/checkout/return` });
// 1. 주문서 — 결제 옵션(PG·결제수단·간편결제사) 목록이 같이 온다
const checkout = await sdk.checkout.create({ cartItemIds: [cart.items[0].cartItemId] });
// 2. 결제 시작 — 팝업이면 결과가 오고, 리다이렉트면 페이지를 떠난다
const result = await payments.start(checkout.checkoutId, {
option: checkout.paymentOptions[0],
shippingAddress,
}); // result.status: COMPLETED · CANCELED · FAILED · PROCESSING · INVALID
// 3. 복귀 화면 — 리다이렉트로 돌아온 구매자
const returned = await payments.result();| 메서드 | 하는 일 |
|---|---|
start(checkoutId, options) | 결제를 시작하고 결제창을 엽니다 |
retry(paymentId, options) | 같은 결제를 다른 옵션으로 다시 시도합니다 |
result(url?) | 복귀 화면에서 결과를 읽습니다. 기본은 현재 주소 |
getStatus(paymentId) | 결제 상태 조회. PROCESSING이면 확정될 때까지 부릅니다 |
prepareWindow() · open(start, options) | 결제 시작을 서버에서 하는 SSR 앱용. 클릭 시점에 팝업을 먼저 엽니다 |
완성 예제와 배송비 미리보기(sdk.checkout.quoteDelivery)·현금영수증은 결제 연동에 있습니다.
checkout.testPayment가 true면 테스트 결제이니 구매자에게 알려 주십시오.
sdk.checkout.startPayment·sdk.payments.retry·sdk.payments.getStatus는 결제 모듈을 쓸 수 없을 때만 직접 부릅니다(SDK 없이 결제 연동).
주문/마이페이지
const orders = await sdk.myOrders.list({ status: "DELIVERED" });
// 주문마다 testPayment. payment.method는 실제 결제수단(간편결제는 EASY_PAY)
const order = await sdk.myOrders.get("order_001");
// 비회원 주문 조회 (주문 조회 비밀번호는 주문할 때 적은 6자 이상 값)
await sdk.myOrders.getGuestOrder("order_001", { phone: "01012345678", orderPassword: "guest1234" });
// 배송 조회 / 구매 확정
const delivery = await sdk.myOrders.getDelivery("oi_001");
// trackingStage: ACCEPTED·IN_TRANSIT·OUT_FOR_DELIVERY·DELIVERED(조회 전 null)
// trackingEvents: 추적 이력(오래된 순, status는 택배사 원문), trackingCheckedAt: 마지막 조회 시각
await sdk.myOrders.decidePurchase("oi_001");
// 클레임 (CANCEL · RETURN · EXCHANGE)
await sdk.myClaims.create("oi_001", { type: "RETURN", reason: "CHANGE_OF_MIND", reasonDetail: "사이즈가 맞지 않아요" });
await sdk.myClaims.list({ status: "REQUESTED" });
await sdk.myClaims.withdraw("claim_001");비회원 주문 조회는 주문이 없을 때와 연락처·비밀번호가 틀렸을 때 모두 404 ORDER_NOT_FOUND입니다. 어느 쪽인지 알려 주지 않으니
「입력한 내용을 확인하십시오」로 안내하십시오. 비밀번호 대입을 막기 위해 주문번호마다 10분에 5번까지 확인하고, 5번째가 틀리면
30분 동안 429 TOO_MANY_REQUESTS입니다. 주문번호마다 하루 15번이 상한이고 상점 전체의 조회에도 분당 한도가 있습니다. 이때는 맞는 비밀번호로도 조회되지 않으니 남은 시간(error.details.retryAfterSeconds,
초)을 안내하십시오. 맞게 조회하면 횟수가 초기화됩니다.
try {
await sdk.myOrders.getGuestOrder(orderId, { phone, orderPassword });
} catch (error) {
if (error instanceof ApiError && error.status === 429) {
const minutes = Math.ceil(Number(error.details?.retryAfterSeconds ?? 60) / 60);
showMessage(`조회 시도가 너무 많습니다. ${minutes}분 뒤에 다시 시도해 주십시오`);
}
}추적 단계는 늘어날 수 있으니 모르는 값은 원문을 보여 주십시오. 이력은 30분마다 갱신되고, 택배사가 배송완료를 알리면 status가 DELIVERED로 바뀝니다.
회원 · 리뷰 · 문의
await sdk.member.me();
await sdk.member.updateMe({ name: "홍길동" });
// 배송지 listAddresses·createAddress·updateAddress·deleteAddress, 찜 listWishlist·addWishlist·removeWishlist
await sdk.reviews.listWritable();
await sdk.reviews.create("oi_001", { rating: 5, content: "배송도 빠르고 품질도 좋았습니다" }); // 10자 이상
// 리뷰 사진 — 자리를 받아 파일을 PUT하고 url을 images에 넣습니다(최대 5장)
const ticket = await sdk.reviews.createImageUpload({ filename: file.name, size: file.size });
await fetch(ticket.uploadUrl, { method: "PUT", body: file });
await sdk.reviews.create("oi_002", { rating: 5, content: "사진처럼 색감이 예쁩니다", images: [ticket.url] });
// 내가 쓴 리뷰 — 최신순. 항목의 reviewId로 수정·삭제합니다
const mine = await sdk.reviews.listMine({ page: 1, size: 20 });
// 항목마다 productId·productName·thumbnailUrl·optionName·rating·content·images·sellerReply·blinded·editable·editableUntil·createdAt
if (mine.contents[0]?.editable) {
await sdk.reviews.update(mine.contents[0].reviewId, { rating: 4 });
}
await sdk.inquiries.listMine({ answered: false });
// 항목마다 productId·productName·thumbnailUrl·category — 상품 상세로 돌아가는 링크를 만들 수 있습니다테스트 결제 주문은 작성 가능 리뷰에서 빠지고, 작성하면 409 REVIEW_NOT_ALLOWED_FOR_TEST_ORDER입니다.
리뷰 사진 자리(createImageUpload)는 구매자 로그인이 필요하고, 형식·크기는 상품 이미지와 같습니다(jpg·jpeg·png·webp·avif·gif, 10MB).
형식이 다르면 400 UNSUPPORTED_FILE_TYPE, 크기를 넘으면 413 FILE_TOO_LARGE, 짧은 시간에 많이 받으면 429 TOO_MANY_REQUESTS입니다.
uploadUrl은 1시간 유효하니 사진을 고른 뒤에 받으십시오.
리뷰 수정은 작성 후 30일까지이고(editable·editableUntil), 지나면 409 PERIOD_EXPIRED입니다. 판매자가 가린 리뷰는 blinded가 true이고
상품 리뷰 목록에는 보이지 않습니다.
동의 조회·변경(member.consents·updateConsents)은 동의와 재동의에 있습니다.
에러 처리 — ApiError
HTTP 에러는 ApiError로 던져집니다. status·code·message·traceId·details·docs(해결 문서 주소)가 표준 응답의 error와 같습니다.
import { ApiError } from "@sayren/storefront-sdk";
try {
await sdk.cart.addItem({ productId: "prod_x", quantity: 1 });
} catch (error) {
if (error instanceof ApiError && error.code === "INSUFFICIENT_STOCK") showToast("재고가 부족합니다");
}