Ecommerce SDK
마켓, 상품 수정, 주문과 storefront 캐시 참조.
완결된 작업은 Ecommerce 가이드에서 시작하세요. 이 페이지는 공개 Ecommerce SDK를 설명합니다. import와 peer는 SDK 기본을 참고하세요.
상품 목록 검색
commerce.product.listingPage({ market: 'kr', search: '셔츠' })는 검색어 앞뒤 공백을 제거하고 상품 title 또는 slug를 검색합니다.
Cart 병합 접근
carts.merge는 아래 입력을 모두 동일한 404/not_found 응답으로 거부하며 Cart와 Checkout 데이터를 그대로 유지합니다.
| 입력 | 응답 |
|---|---|
알 수 없거나 틀린 guestCartToken | 404/not_found |
| 다른 고객의 Cart | 404/not_found |
| 다른 워크스페이스의 Cart | 404/not_found |
브라우저 고객 인증과 명시적인 customerToken을 쓰는 서버 호출에 모두 적용됩니다. reason으로 분기하고, 지원 요청 시 requestId를 사용하세요.
고객 기본 Cart
carts.create는 항상 새 Cart를 만들므로 고객 한 명이 활성 Cart를 여러 개 가질 수 있습니다. carts.mine은 createdAt이 가장 늦은 활성 Cart를 돌려주고, carts.merge는 게스트 Cart를 그 Cart에 병합합니다. createdAt이 같으면 id가 더 큰 Cart를 선택합니다.
| 병합 입력 | 결과 |
|---|---|
| 고객이 이미 소유한 활성 Cart(더 오래된 Cart 포함) | resolution: 'unchanged', 데이터 변경 없음 |
| 선택된 Cart와 마켓이 다른 게스트 Cart | 409 cart_market_mismatch. 게스트 Cart와 같은 마켓의 오래된 Cart로 대신 병합하지 않습니다 |
Checkout 배송지
commerce.checkouts.create()는 요청에 shippingAddress가 있으면 그 값을, 없으면 Cart에 저장된 배송지를 사용합니다. 항목 중 하나라도 requiresShipping !== false이면 recipientName, phone, postalCode, address, detailAddress가 앞뒤 공백을 뺀 뒤에도 비어 있지 않아야 합니다. 국가 코드만으로는 부족합니다.
| 요청 | 결과 |
|---|---|
| 배송 정보가 없거나 비어 있음 | Cart 준비나 예약 전에 HTTP 400 shipping_address_required를 반환합니다. |
| 배송이 없는 항목만 있음 | 배송지를 생략할 수 있고 배송비는 0입니다. |
| 요청 스키마에 맞지 않는 입력 | validation_failed를 반환합니다. |
| 같은 멱등 키에 나중에 주소만 추가 | 요청 본문이 바뀌었으므로 409 idempotency_conflict를 반환합니다. |
shipping_address_required를 받았다면 유효한 주소와 새 idempotencyKey로 Checkout을 새로 시작하세요. SDK의 커머스 오류 유니온에 이 reason이 있으며, 이를 모르는 이전 SDK는 unknown_commerce_error의 rawReason으로 보존합니다.
기존 품목의 기본 가격 수정
공개 SDK 0.67.0에는 아래 서버 도우미가 있습니다. 가이드 예제가 쓰는 0.65.1에는 없으므로 0.65.1 앱에 이 호출을 복사하지 마세요.
현재 품목 값을 확인하기 전에 서버 전용 server.commerce.product.graphRevision({ productId, locale })로 기준 revision을 받은 다음, updateVariantPrices({ productId, locale, graphRevision, updates: [{ id, price }] })를 호출합니다. 기준 조회, 값 확인, 저장, 이후 조회에는 같은 활성 콘텐츠 언어(ko 또는 en)를 쓰세요. revision 응답에는 { productId, graphRevision }만 있으며 비공개 no-store 응답입니다.
- 기존 품목 1–250개를 수정하고 서버가 돌려준 변경 건수와
productProjectionStale을 그대로 반환합니다.true이면 가격은 저장됐지만 목록 데이터가 아직 갱신되지 않았을 수 있습니다. price는 기본 통화의 숫자 금액이며, 통화 정밀도는 Console이 검증합니다.- 재고, 삭제, 비교 가격, 마켓별 가격, 옵션처럼 지원하지 않는 필드는 요청 전에 거부합니다.
409 graph_conflict를 받으면 값을 다시 확인하세요. SDK는 revision을 자동으로 갱신하거나, PATCH를 재시도하거나, 전체 upsert로 바꾸지 않습니다. 충돌 정보는 오류의details.body에 남고apiReason과requestId도 유지됩니다.- 읽기 전용 키는 기준 조회만 할 수 있고 저장할 수 없습니다. 전체 그래프 upsert는 별도 작업입니다.
조회부터 수정, 확인까지의 전체 예시는 SDK README의 Commerce Product 절에 있습니다.
주문 수정 호환성
주문은 한 번에 한 건씩 수정합니다. Admin Panel 일괄 편집과 일반 server.collections.from('orders').updateMany(...)는 주문을 바꾸기 전에 HTTP 400(errors[0].data.code: order_bulk_update_unsupported)으로 거부됩니다.
- 조건 없는 수정에는
update(orderId, data)를 쓰세요. - 조건부 수정은 where 조건 전체를 raw
PATCH /api/orders의 쿼리에 두고limit=1을 지정하며, 바꿀 필드는 본문으로 보냅니다. Local API에서는 숫자limit: 1, 또는 조건 전체를 하나의 UUID로 제한하는 엄격한id.equals를 씁니다. OR 조건의 한쪽에만 ID가 있으면 부족합니다. SDKupdateMany에는 limit 옵션이 없어 주문의 조건부 수정 경로로 쓸 수 없습니다.
조건이 맞지 않으면 docs: [], errors: []를 반환합니다. hook 실패가 수집되면 HTTP 500(errors[0].data.code: order_update_failed)과 현지화된 메시지를 반환하고, 지원되는 단건 작업은 롤백됩니다. 권한과 워크스페이스 검사는 그대로 적용됩니다. 조건부 수정을 조건 없는 ID 수정으로 바꾸거나 결제 매입(capture)을 자동으로 재시도하지 마세요. 주문별로 따로 수정하면 선택 전체의 원자적 성공은 보장되지 않습니다.
Console Local API로 주문을 수정할 때도 활성 트랜잭션이 필요합니다. 활성 외부 트랜잭션 없이 disableTransaction: true를 지정하면 ID 지정 수정과 where 수정 모두 저장 전에 HTTP 400(errors[0].data.code: order_transaction_required)으로 거부됩니다. 옵션을 지워 Payload가 트랜잭션을 시작하게 하거나 실제 활성 외부 트랜잭션을 넘기세요. 요청의 트랜잭션 ID만으로는 부족합니다.
배송 준비 순서
server.commerce.orders.createFulfillment에는 in_progress 상태의 배송 작업이 필요합니다. 결제만 완료해도 열린 배송 작업이 자동으로 준비되지는 않습니다.
- 결제된 주문에
server.commerce.orders.prepareFulfillmentOrder({ orderNumber })를 호출해 열린 배송 작업을in_progress로 바꾸세요. - 준비가 성공하면 같은
orderNumber로server.commerce.orders.createFulfillment({ orderNumber, ...shipment })를 호출하세요.fulfillmentOrderId를 지정한다면 해당 주문에 속한 진행 중 배송 작업의 ID여야 합니다.
준비에는 택배사나 운송장 정보가 필요하지 않습니다. createFulfillment는 준비를 자동으로 수행하지 않으며, 진행 중 배송 작업이 없으면 HTTP 400 not_fulfillable을 반환합니다. 오류 안내에 따라 prepareFulfillmentOrder를 호출한 뒤 배송을 생성하세요.
배송 변경 응답
BREAKING (0.x minor): createFulfillment와 updateFulfillment의 반환 타입이 원본 Fulfillment 문서에서 FulfillmentMutationResponse로 바뀝니다. 배송 상태·운송장·시각은 계속 제공합니다.
tenant, order, fulfillmentOrder는 확장된 문서 대신 ID를 담습니다. 연결이 없는 레거시 배송은 fulfillmentOrder: null을 반환하므로 작업을 조회하기 전에 null을 확인하세요. 응답에는 items, adminNotes, metadata, method, deletedAt이 없습니다.
shipment.items?.docs 등 문서 전용 필드를 읽던 코드는 권한 있는 서버에서 server.collections.from('fulfillments').findById(receipt.id, { joins: { items: { limit: 100 } } })를 호출하도록 바꾸세요. 관계 문서의 상세는 반환된 ID로 해당 collection에서 조회합니다. 내부 method 값과 확장된 워크스페이스 내부 문서에 대한 의존성은 제거하세요. 서버 collection 조회에도 기존 필드 접근 권한이 적용됩니다.
승인된 반품 환불 완료
서버 클라이언트의 server.commerce.orders.returnWithRefund에
existingReturnId와 일치하는 orderNumber를 넘겨 승인된 반품을 완료합니다.
필수 returnItems와 양수 refundAmount도 유지하세요. 서버는 저장된 승인
내역의 항목·금액·수수료·사유를 사용합니다. refundAmount는 반품 배송비와
재입고 수수료 차감 후 상품 환불액이며, 최초 배송비 환불은 별도로 더해집니다.
응답을 받지 못했다면 같은 요청과 idempotencyKey로 재시도하세요.
응답은 제한된 반품 영수증과 refundSeq, 결제사 환불이 있는 경우 제한된
환불 Transaction을 포함합니다. 결제사 거래가 없는 완료는 transaction: null,
refundSeq: null을 반환합니다. notificationWarning은 커밋 후 문제를 나타내며
환불은 완료된 상태입니다. 전체 Return이나 Transaction 문서가 필요하면 별도로
조회하세요. 영수증 필드 목록은 SDK README에 있습니다.
마켓 컨텍스트
스토어프론트는 가격과 통화에 영향을 주는 호출에 마켓을 넘깁니다. markets 네임스페이스로 마켓을 조회하고 기본 마켓을 정하세요.
import { createClient } from '@01.software/sdk'
type Client = ReturnType<typeof createClient>
export async function createMarketCart(client: Client) {
const markets = await client.markets.list()
const primary = await client.markets.default()
if (!primary) return { kind: 'no-market' as const, markets }
const cart = await client.commerce.carts.create({ market: primary.handle })
return { kind: 'created' as const, cart }
}클라이언트에 안전한 필드(id, handle, name, countryCode, targetCountries, currency, isPrimary, isActive)만 반환합니다. 환율과 가격 조정값은 서버에만 있습니다.
Storefront Cache Resources
스토어프론트 조회는 프레임워크와 무관한 리소스 이름을 씁니다. 그래서 어댑터는 같은 무효화 방식을 Next.js 캐시 태그, CDN 태그, 다른 캐시 계층에 연결할 수 있고, 핵심 SDK에는 프레임워크 API가 들어가지 않습니다. 여기서 스토어프론트는 상품 페이지만이 아니라 웹사이트, 앱, 문서 사이트, 쇼핑몰처럼 워크스페이스의 공개 콘텐츠를 읽는 모든 프런트엔드를 말합니다.
import { storefrontCacheResources } from '@01.software/sdk/storefront-cache'
export function productTags(
tenantId: string,
productSlug: string,
publishableKeyScope = 'default',
) {
const cacheScope = { tenantId, publishableKeyScope }
return {
listingTags: [storefrontCacheResources.productListing(cacheScope)],
detailTags: [
storefrontCacheResources.productDetail(cacheScope, { slug: productSlug }),
],
}
}리소스 이름의 형식은 다음과 같습니다.
storefront:v1:tenant:<tenantId>:key:<publishableKeyScope>:resource:<collection>:list
storefront:v1:tenant:<tenantId>:key:<publishableKeyScope>:resource:<collection>:detail:<id|slug>:<identity>현재 SDK의 공개 도우미는 상품 조회용뿐입니다. 나머지 행은 01.software가 만들 어댑터를 위해 이름만 예약해 둔 것이며, 아직 공개 도우미가 없습니다.
| 조회 대상 | 리소스 계열 | 공개 SDK 도우미 |
|---|---|---|
| 상품 목록 / 상품 상세 | products | productListing(scope), productDetail(scope, { id }) 또는 { slug } |
| 링크 | links | 예약됨, 공개 도우미 없음 |
| 문서 | documents | 예약됨, 공개 도우미 없음 |
| 갤러리 아이템 | gallery-items | 예약됨, 공개 도우미 없음 |
| 플레이리스트 | playlists | 예약됨, 공개 도우미 없음 |
| 트랙 | tracks | 예약됨, 공개 도우미 없음 |
| 미디어/이미지 | images | 예약됨, 공개 도우미 없음 |
| 배송 정책 | shipping-policies | 예약됨, 공개 도우미 없음 |
목록 페이지, 검색 페이지, 여러 문서를 합쳐 읽는 조회에는 목록 리소스를 씁니다. 상세 리소스는 어댑터가 캐시 태그에 쓴 공개 식별자를 이미 알고 있을 때만 씁니다. slug 기반 상품 페이지는 상세를 가져오기 전에 { slug }를 쓸 수 있으므로, 상품 ID를 알아내려고 원시 컬렉션을 먼저 읽을 필요가 없습니다.
publishableKeyScope에는 안정적이고 짧으며 비밀이 아닌 키 id나 지문을 넣습니다. 워크스페이스의 모든 Publishable Key가 같은 공개 콘텐츠를 볼 때만 기본 범위를 쓰세요. 키별 범위로 태그한 항목은 웹훅에서도 같은 범위로 재검증해야 합니다. 워크스페이스 전체 변경은 영향을 받는 모든 키 범위를 재검증합니다.
미리보기, 초안, 고객 토큰, 서버 자격 증명으로 한 조회는 공유 스토어프론트 캐시에 넣지 마세요. 오류, 권한 실패, { found: false } 결과는 어댑터에 짧은 TTL의 별도 부재 캐시 정책이 없다면 no-store로 처리합니다.
Next.js 매핑
Next.js 어댑터에서는 이 이름을 next.tags와 함께 캐시된 fetch에 넘깁니다. 현재 revalidateTag API에는 두 번째 인자가 필요하며, stale-while-revalidate 동작에는 "max"를 씁니다. 태그는 응답이 Data Cache에 저장될 때만 재검증에 참여하므로 SSG/ISR 조회는 캐시를 명시하고, 미리보기·초안 같은 동적 조회는 no-store로 두세요. Next.js 캐시 태그는 대소문자를 구분하고 256자 이하여야 하며, fetch 하나에 최대 128개까지 붙일 수 있습니다. 워크스페이스와 키 범위는 짧고 비밀이 아닌 값으로 두세요.
공개 HTTP 작업은 Publishable Key를 쓰는 GET /api/products/detail/catalog입니다. /api/products/detail은 서버 자격 증명이 필요한 전체 응답입니다. HTTP 성공 본문은 ProductDetailCatalog이고 제목은 catalog.product.title에 있으며, SDK는 이를 구분된 결과 타입으로 감쌉니다. 카탈로그 변형에는 stock, reservedStock이 없습니다. 생성된 OpenAPI 계약에 이 method와 경로, Publishable Key 인증, ProductDetailCatalog 성공 스키마가 있습니다. 오류와 부재 결과는 공유 성공 캐시에 넣지 마세요. 아래 어댑터는 부재 캐시 정책을 두거나, 최신 부재 여부가 필요하면 캐시를 건너뛰어야 합니다.
import type {} from 'next'
import type { ProductDetailCatalog } from '@01.software/sdk'
import { storefrontCacheResources } from '@01.software/sdk/storefront-cache'
export async function fetchProductDetail(productSlug: string) {
const scope = {
tenantId: process.env.APP_TENANT_ID!,
publishableKeyScope: process.env.STOREFRONT_CACHE_KEY_SCOPE ?? 'default',
}
const tags = [
storefrontCacheResources.productDetail(scope, { slug: productSlug }),
]
const params = new URLSearchParams({ slug: productSlug })
const response = await fetch(
`${process.env.SOFTWARE_API_URL || 'https://api.01.software'}/api/products/detail/catalog?${params}`,
{
cache: 'force-cache',
headers: {
'X-Publishable-Key': process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
},
next: { tags },
},
)
if (!response.ok) {
const parsed: unknown = await response.json().catch(() => null)
const body = (parsed && typeof parsed === 'object' ? parsed : {}) as {
code?: string
reason?: string
}
const reason = body.reason ?? body.code
if (
response.status === 404 &&
(reason === 'not_found' || reason === 'not_published')
) {
return { found: false as const, reason }
}
// Feature denial, route/deployment 404s and other failures stay errors.
const error = new Error('Product catalog request failed')
throw Object.assign(error, {
status: response.status,
reason,
requestId: response.headers.get('x-request-id'),
retryAfter: response.headers.get('retry-after'),
})
}
// HTTP returns the catalog DTO directly, unlike the SDK's { found, product } wrapper.
const catalog = (await response.json()) as ProductDetailCatalog
return { found: true as const, catalog }
}이 예제의 error.retryAfter는 Retry-After 헤더 원문(string | null, HTTP 날짜 포함)을 그대로 두고, SDK 오류의 retryAfter는 초 단위 숫자입니다.
웹훅 라우트 핸들러는 원시 컬렉션을 읽지 않고, 앱이 만든 무효화 데이터에서 같은 리소스 이름을 다시 만들어 재검증합니다. 이 도우미는 웹훅의 수신기로 서명을 검증하고, 그 데이터를 앱이 쓰는 상품 식별자와 공개 캐시 범위로 바꾼 뒤에만 호출하세요. 검증하지 않은 요청 JSON을 받지 않습니다.
import { revalidateTag } from 'next/cache'
import { storefrontCacheResources } from '@01.software/sdk/storefront-cache'
type ProductInvalidation = {
collection: 'products'
tenantId: string
publishableKeyScopes?: string[]
productId?: string
currentSlug?: string
previousSlug?: string
invalidateListing?: boolean
}
export function revalidateProduct(invalidation: ProductInvalidation) {
const scopes = (invalidation.publishableKeyScopes ?? ['default']).map(
(publishableKeyScope: string) => ({
tenantId: invalidation.tenantId,
publishableKeyScope,
}),
)
if (invalidation.collection === 'products') {
for (const scope of scopes) {
if (invalidation.invalidateListing) {
revalidateTag(storefrontCacheResources.productListing(scope), 'max')
}
if (invalidation.productId) {
revalidateTag(
storefrontCacheResources.productDetail(scope, {
id: invalidation.productId,
}),
'max',
)
}
for (const slug of [
invalidation.currentSlug,
invalidation.previousSlug,
]) {
if (!slug) continue
revalidateTag(
storefrontCacheResources.productDetail(scope, { slug }),
'max',
)
}
}
}
return { ok: true }
}무효화 데이터에는 무효화할 모든 태그의 공개 식별자가 있어야 합니다. slug가 바뀌면 이전 slug도 넣으세요. 앱이 아직 이전 slug를 추적하지 못한다면 productListing(scope)를 넓은 대체 수단으로 쓰고, 더 넓은 무효화를 감수할 때만 상품 상세 페이지에도 목록 리소스 태그를 붙이세요.