01.software 문서

Ecommerce SDK

마켓, 상품 수정, 주문과 storefront 캐시 참조.

Ecommerce SDK

완결된 작업은 Ecommerce 가이드에서 시작하세요. 이 참조는 공개 SDK 경계를 설명합니다. import와 peer는 SDK 기본을 확인하세요.

기존 품목의 기본 가격 수정

아래 도우미는 SDK 0.66.0 이상이 필요하며, 이 페이지의 예제를 검증한 공개 0.65.1 배포본에는 없습니다. 0.65.1 consumer에 이 호출을 복사하지 마세요. 저장소 소스에 존재하는 것과 패키지 게시는 별개입니다.

해당 버전 조건을 충족하면 서버 전용 server.commerce.product.graphRevision({ productId, locale })로 현재 품목 값을 확인하기 전에 기준 revision을 조회한 다음, updateVariantPrices({ productId, locale, graphRevision, updates: [{ id, price }] })를 호출합니다. 기준 조회, 값 확인, 저장, 독립 재조회에 같은 활성 콘텐츠 locale (ko 또는 en)을 사용하세요. Revision 응답은 { productId, graphRevision }만 포함하며 private/no-store입니다.

도우미는 기존 품목 1–250행을 PATCH하고 서버의 변경 건수와 productProjectionStale를 그대로 반환합니다. true는 가격 저장은 완료됐지만 목록 표시용 데이터가 아직 갱신되지 않았을 수 있다는 뜻입니다. 가격은 기존 상점 통화의 숫자 금액이며 통화 정밀도는 Console이 검증합니다. 재고, 삭제, 비교 가격, 시장별 가격, 옵션 등 지원하지 않는 필드는 요청 전에 거절합니다. 409 graph_conflict에서는 값을 다시 확인해야 하며 revision 자동 갱신, PATCH 재시도, 전체 upsert 전환은 없습니다. 기존 충돌 정보는 SDK 오류의 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의 query에 유지하고 limit=1을 지정하며, 변경할 필드는 body로 보냅니다. Local API는 숫자 limit: 1 또는 전체 조건을 단일 UUID로 제한하는 엄격한 id.equals를 사용합니다. OR의 한 가지에만 ID가 있으면 충분하지 않습니다. SDK updateMany는 limit 옵션이 없어 주문 CAS 경로로 사용할 수 없습니다.

조건 불일치는 docs: [], errors: []로 반환됩니다. 수집된 hook 실패는 HTTP 500(errors[0].data.code: order_update_failed)과 현지화된 메시지로 전파되며, 지원되는 단건 작업은 rollback됩니다. 권한과 tenant 검사는 그대로 적용됩니다. 조건부 수정을 조건 없는 ID 수정으로 대체하거나 결제 capture를 자동 재시도하지 마세요. 주문별 개별 수정은 전체 선택 항목의 원자적 성공을 보장하지 않습니다.

Console Local API의 주문 수정에도 활성 트랜잭션이 필요합니다. 활성 외부 트랜잭션 없이 disableTransaction: true를 지정하면 top-ID와 where 수정 모두 저장 전에 HTTP 400(errors[0].data.code: order_transaction_required) 으로 거절됩니다. 옵션을 제거해 Payload가 트랜잭션을 시작하게 하거나 실제 활성 외부 트랜잭션을 전달하세요. 요청의 transaction ID만으로는 충분하지 않습니다.

마켓 컨텍스트

스토어프론트는 가격·통화에 영향을 주는 호출에 마켓을 전달합니다. 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

Shaped storefront 읽기는 framework-neutral resource name을 사용합니다. 따라서 adapter는 같은 invalidation contract를 Next.js cache tag, CDN tag, 다른 cache layer로 매핑할 수 있고, core SDK API에는 framework API를 넣지 않습니다. 여기서 storefront는 ecommerce 상품 페이지만이 아니라 website, app, docs site, commerce frontend처럼 public tenant-facing read를 제공하는 프론트엔드를 뜻합니다.

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 }),
    ],
  }
}

Resource name은 다음 shape를 사용합니다.

storefront:v1:tenant:<tenantId>:key:<publishableKeyScope>:resource:<collection>:list
storefront:v1:tenant:<tenantId>:key:<publishableKeyScope>:resource:<collection>:detail:<id|slug>:<identity>

SDK가 지금 공개하는 helper는 product shaped read만입니다. 다른 행은 해당 shaped read를 소유한 first-party adapter가 생길 때 따를 resource-family grammar를 예약합니다. Shaped adapter surface가 생기기 전에는 public helper를 추가하지 않습니다.

읽기 surfaceResource familyPublic SDK helper
product listing / product detailproductsproductListing(scope), productDetail(scope, { id }) 또는 { slug }
linkslinks예약된 family; 아직 public helper 없음
documentsdocuments예약된 family; 아직 public helper 없음
gallery itemsgallery-items예약된 family; 아직 public helper 없음
playlistsplaylists예약된 family; 아직 public helper 없음
trackstracks예약된 family; 아직 public helper 없음
media/imagesimages예약된 family; 아직 public helper 없음
shipping policiesshipping-policies예약된 family; 아직 public helper 없음

List page, search page, 여러 document를 조합하는 shaped read에는 list resource를 사용합니다. Detail resource는 adapter가 cache tag에 사용한 public identity를 이미 가지고 있을 때만 사용합니다. Slug 기반 product page는 shaped detail 응답을 fetch하기 전에 { slug }를 사용할 수 있으므로 product ID를 알아내기 위한 raw collection pre-read가 필요하지 않습니다.

publishableKeyScope는 안정적이고 짧은 non-secret key id 또는 fingerprint를 넣습니다. Tenant의 모든 publishable key가 같은 public cache view를 볼 때만 기본 scope를 사용합니다. Cache entry를 key-specific scope로 tag했다면 webhook revalidation도 같은 scope를 revalidate해야 합니다. Tenant-wide mutation은 영향을 받는 모든 public key scope를 revalidate합니다.

Preview, draft, customer-token, server-credential 읽기는 shared storefront cache 밖에 둡니다. Error response, permission failure, { found: false } 읽기는 adapter가 짧은 TTL의 별도 negative-cache 정책을 소유하지 않는 한 no-store로 처리합니다.

Next.js 매핑

Next.js adapter에서는 이 이름들을 next.tags가 있는 cached fetch에 전달합니다. 현재 Next.js revalidateTag API는 두 번째 인자가 필요하므로 stale-while-revalidate 동작에는 "max"를 사용합니다. Tag는 response가 Data Cache에 저장될 때만 revalidation에 참여하므로 SSG/ISR read는 caching을 명시하고 preview, draft, 기타 dynamic read는 no-store로 둡니다. Next.js cache tag는 대소문자를 구분하고, 256자 이하여야 하며, fetch 하나에는 최대 128개 tag를 붙일 수 있습니다. Tenant/key scope는 짧고 non-secret이어야 합니다.

공개 HTTP operation은 Publishable Key를 쓰는 GET /api/products/detail/catalog이며 /api/products/detail은 서버 credential이 필요한 full 응답입니다. HTTP 성공 body는 ProductDetailCatalog이고 제목은 catalog.product.title에 있습니다. SDK는 이를 discriminated result로 감쌉니다. Catalog variant에는 stock, reservedStock이 없습니다. 생성된 OpenAPI 계약에 이 method/path, Publishable Key 인증, ProductDetailCatalog 성공 schema가 포함됩니다. 오류와 부재 결과는 shared 성공 cache에 넣지 말고, cache adapter가 negative-cache 정책을 제공하거나 최신 부재가 필요하면 cache를 우회해야 합니다.

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}/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 }
}

이 fence의 error.retryAfter는 Retry-After 헤더 원문(string | null, HTTP-date 포함)을 보존하며, SDK 오류의 retryAfter는 초 단위로 파싱한 number입니다.

Webhook route handler는 raw collection read를 실행하지 말고 앱이 소유한 invalidation payload에서 같은 resource name을 다시 만들어 revalidate합니다. 이 예시는 route가 platform webhook 데이터를 앱이 사용하는 product identity와 public cache scope로 정규화했다고 가정합니다.

먼저 Webhooks의 signed receiver로 인증하고 app-owned payload를 정규화한 뒤 아래 helper를 호출하세요. 인증되지 않은 request JSON을 직접 받는 route가 아닙니다.

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 }
}

Invalidation payload가 adapter에서 raw collection read를 수행할 필요는 없지만, 무효화하려는 tag에 필요한 public identity는 payload가 제공해야 합니다. Slug가 바뀌는 경우 이전 slug도 포함합니다. 앱이 아직 이전 slug를 추적할 수 없다면 productListing(scope)를 coarse fallback으로 사용하고, 더 넓은 invalidation을 의도적으로 받아들일 때만 product detail page에도 listing resource를 함께 붙입니다.

다음 액션

목차