Ecommerce
Catalog 선택부터 비회원 카드 구매와 동일 주문 확인까지 완료합니다.
준비
대상 환경에서 ecommerce를 켜고 상품, 변형(variant), 옵션, 마켓 배정을 공개하세요. 카탈로그는 Publishable Key만으로 조회합니다. Secret Key와 권한이 필요한 주문·재고 작업은 서버에 둡니다. 고객 가입은 Customers에서 다룹니다.
예제는 Next.js App Router 앱에서 요청마다 실행됩니다. 확인한 버전과 구성은 빌드 옵션에 있습니다.
카탈로그에서 선택까지 구현
상품 목록 페이지를 복사한 다음 상품 상세 페이지와 오류 경계를 복사하세요. /products에서 카드와 색상 견본을 차례로 누른 뒤 새로고침해도 같은 상품과 변형이 선택돼 있어야 합니다. 두 레시피에는 공개 SDK 패키지만 필요합니다.
정상·빈 결과·실패
다음 경우를 확인하세요. 라우트 params는 비동기이므로 await합니다.
| 경우 | 기대 결과 |
|---|---|
| 빈 카탈로그 | 성공한 결과입니다. 빈 상태 화면을 보여 줍니다. |
| 공개 상품 한 개 | 카드와 상세 페이지가 표시됩니다. |
URL로 직접 연 unlisted 상품 | 상세 페이지가 표시됩니다. |
| 없거나 공개되지 않은 상품 | 표준 404를 반환합니다. |
| 새로고침 후 선택, 더는 없는 선택 | 안정 ID 기반 선택이 유지되고, 사라진 선택도 처리합니다. |
| 키·기능·워크스페이스 오류 | 오류를 보여 줍니다. 원시 컬렉션 조회로 우회하지 않습니다. |
| 네트워크·서버 실패 | 재시도를 제공합니다. 재고 0이나 상품 0으로 보여 주지 않습니다. |
공개 상품 데이터에는 원시 재고 수량이 없습니다. 카탈로그의 구매 가능 여부는 최신이 아닐 수 있고, 재고 확인은 재고를 예약하지 않습니다. Checkout과 주문 생성 때 재고와 판매 가능 여부를 다시 확인합니다. 결제에는 다음 절의 설정이 필요합니다.
비회원 구매 완결
이 예제는 PortOne Standard V2 연결(standard_v2, 토스페이먼츠 V2 CARD 채널)로 배송이 없는 상품 하나를 KRW 양수 금액으로 카드 결제합니다. 다음을 준비하세요.
- 고객 그룹 제한이나 고객별 구매 한도가 없는 비회원 구매 가능 상품, 활성 변형, KRW 마켓의 유효한 가격
- Ecommerce 기능
- Payments에서 설정한 활성 PortOne 계정. 준비 상태와 웹훅 설정이 최신이어야 하고, Standard V2 채널 키와 워크스페이스 CORS 설정에 등록된 origin의 redirect URL이 필요합니다.
PortOne Hosted V2(hosted_v2)는 체크아웃 프로필에 연결한 V2 해외결제 채널만 받으므로, 국내 PG 채널만 있는 상점은 사용할 수 없습니다. 이 예제에서는 다루지 않습니다. 다른 통화나 결제 수단, 배송, 할인, 적립금, 행사 입장권은 각각 별도로 통합을 검토해야 합니다.
구매는 앱이 이미 쓰는 내구성 있는 구매·세션 저장소 위에 구현합니다. 아래 lib/purchase.ts를 복사하고 그 저장소에 맞게 PurchasePort를 구현하세요. 이것은 앱 코드이며 SDK helper나 새 저장 엔진이 아닙니다. 메모리 어댑터는 테스트에서만 쓰세요. 저장소는 다음을 지켜야 합니다.
- 구매마다 기록 하나. 앱이 구매 의도를 확인하면
PurchaseRecord하나를 원자적으로 만듭니다.authorityId는 검증한 앱 세션에서 가져오고,purchaseId는 불투명한 값으로 둡니다. 바뀌지 않는intent에는 상세 레시피의slug,?를 뺀 표준selectionQuery, 마켓 handle, 연락용 이메일, 수량(예: 두 개)을 담습니다. 이메일은 연락용 기록일 뿐 고객 인증이 아닙니다. 다시 제출하거나 다른 탭에서 열어도 대기 중인 작업을 포함한 같은 기록을 돌려줘야 합니다. - 기록의 소유와 직렬화. 기록을 돌려주기 전에 소유권을 인증하고, 같은 구매에 대한 모든 탭과 워커를 직렬화합니다. 외부 호출 전에는 각 작업의 키와 정확한 요청 본문을 함께 원자적으로 확보합니다.
save는 분리된 스냅숏을 커밋한 뒤에 완료되며, 잠금이 만료된 이전 소유자의 저장은 거부합니다. 기존 대기·결과 항목은 그대로 두고, 재시도에서 새 키를 만들지 않습니다. 확보나 저장에 실패하면 외부 작업을 멈추세요. 같은 식별자에 바뀐 입력을 쓰지 마세요. - 토큰은 비공개로. 권한 토큰과 결제 action은 저장할 때와 전송할 때 모두 비공개로 다룹니다. 다음 단계로 넘어가거나 이동하기 전에 Cart 토큰과 id, Checkout 토큰과 id, PaymentSession id를 저장하고, 새 요청이 오거나 중단된 뒤에도 같은 기록을 다시 엽니다. 토큰과 기록을 URL에 넣지 말고, 이 값들과 PortOne 요청 값을 로그, 오류 보고, 분석 도구, 추적 리디렉션에 남기지 마세요. 계정의 redirect URL은 고정값이라 구매 id가 담기지 않습니다. 돌아올 때 PortOne이 붙이는 쿼리는 무시하고 검증한 세션으로 기록을 찾으세요. 해당 세션이 요청한 화면 정보만 비공개
no-store응답으로 돌려주고, 구매 동작에도 기존 same-origin·CSRF 보호를 적용하세요.
이 모듈은 앱의 신뢰할 수 있는 요청 계층에서 비회원 전용 Publishable Key 클라이언트로 실행합니다. 매 호출에 검증한 세션의 authorityId를 넘기세요. 구매 id만으로는 아무 권한도 생기지 않습니다. customer: { persist: false }는 브라우저에 저장된 고객 정보를 읽지 않게 합니다. 비회원을 대신하려고 Secret Key나 PAT를 추가하지 마세요. 고객에 묶인 자원에는 해당 고객의 권한도 필요하므로 이 비회원 흐름에서는 쓰지 않습니다.
// lib/purchase.ts — app-owned workflow; no server credential or PSP SDK.
import {
createClient,
createProductSelectionCodec,
resolveProductSelection,
} from '@01.software/sdk'
type Commerce = ReturnType<typeof createClient>['commerce']
type Cart = Awaited<ReturnType<Commerce['carts']['retrieve']>>
type Checkout = Awaited<ReturnType<Commerce['checkouts']['retrieve']>>
type Payment = Awaited<
ReturnType<Commerce['paymentSessions']['retrievePortOne']>
>
type StandardAction = Extract<
NonNullable<Payment['nextAction']>,
{ type: 'portone_sdk' }
>
// Exactly the PortOne Browser SDK V2 requestPayment() fields.
export type PortOnePaymentRequest = Pick<
StandardAction,
| 'storeId'
| 'channelKey'
| 'paymentId'
| 'orderName'
| 'totalAmount'
| 'currency'
| 'payMethod'
| 'redirectUrl'
>
type Saved<Request, Result> = { request: Request; result?: Result }
export type PurchaseRecord = {
authorityId: string
purchaseId: string
// Immutable after the app atomically creates this session-bound record.
intent: {
slug: string
selectionQuery: string
market: string
email: string
quantity: number
}
cartCreationStarted?: true
cart?: { cartToken: string; snapshot: Cart }
add?: Saved<
Parameters<Commerce['carts']['addItem']>[0],
Awaited<ReturnType<Commerce['carts']['addItem']>>
>
update?: Saved<
Parameters<Commerce['carts']['updateItem']>[0],
Awaited<ReturnType<Commerce['carts']['updateItem']>>
>
checkout?: Saved<
Parameters<Commerce['checkouts']['create']>[0],
Awaited<ReturnType<Commerce['checkouts']['create']>>
>
payment?: Saved<
Parameters<Commerce['paymentSessions']['createPortOne']>[0],
Payment
>
}
// Implement using the EXISTING app's durable purchase/session store.
// exclusive authenticates authorityId, checks record ownership, and serializes
// all tabs/workers for this purchase. save commits a detached durable snapshot
// before resolving; it is fenced against expired ownership and older revisions.
// A failed save/claim aborts work. Do not hold an uncommitted DB transaction
// across upstream I/O. Never expire/delete an unresolved payment's identity.
export interface PurchasePort {
exclusive<T>(
authorityId: string,
purchaseId: string,
work: (
record: PurchaseRecord,
save: (next: PurchaseRecord) => Promise<void>,
) => Promise<T>,
): Promise<T>
}
export type PurchaseView =
| { kind: 'portone_sdk'; request: PortOnePaymentRequest }
| { kind: 'complete'; order: { id: string; orderNumber: string } }
| { kind: 'order_readback'; order: { id: string; orderNumber: string } }
| { kind: 'needs_attention' }
export function purchaseWorkflow(
config: { publishableKey: string; apiUrl?: string },
store: PurchasePort,
) {
// Dedicated guest client: do not inherit a signed-in customer's browser state.
const { commerce } = createClient({ ...config, customer: { persist: false } })
const run = <T>(
authorityId: string,
purchaseId: string,
work: (
r: PurchaseRecord,
save: (next: PurchaseRecord) => Promise<void>,
) => Promise<T>,
) =>
store.exclusive(authorityId, purchaseId, async (r, save) => {
if (r.authorityId !== authorityId || r.purchaseId !== purchaseId)
throw new Error('Purchase authority mismatch')
return work(r, save)
})
const rememberCart = (r: PurchaseRecord, snapshot: Cart) => {
if (
r.cart &&
(r.cart.snapshot.id !== snapshot.id ||
snapshot.revision < r.cart.snapshot.revision)
) {
throw new Error('Refuse mismatched or older Cart snapshot')
}
if (!r.cart) throw new Error('Missing Cart capability')
r.cart.snapshot = snapshot
}
const requirePaidPath = (checkout: Checkout) => {
if (
checkout.status !== 'open' ||
checkout.amounts.totalMinor <= 0 ||
checkout.amounts.currency !== 'KRW' ||
checkout.items.length === 0 ||
checkout.items.some((item) => item.requiresShipping)
) {
throw new Error('Outside configured nonshipping positive KRW path')
}
}
const observe = async (r: PurchaseRecord): Promise<PurchaseView> => {
const created = r.checkout?.result
if (!created) throw new Error('Resume the stored Checkout request first')
if (!r.payment) {
const checkout = await commerce.checkouts.retrieve({
checkoutToken: created.checkoutToken,
})
if (checkout.id !== created.checkout.id)
throw new Error('Checkout identity mismatch')
// Already placed is order readback, not proof about an unknown PSP attempt.
return checkout.status === 'placed'
? { kind: 'order_readback', order: checkout.order }
: { kind: 'needs_attention' }
}
if (!r.payment.result)
throw new Error('Resume the stored PaymentSession request first')
const payment = await commerce.paymentSessions.retrievePortOne({
paymentSessionId: r.payment.result.id,
checkoutToken: created.checkoutToken,
})
const placedCheckout = await commerce.checkouts.retrieve({
checkoutToken: created.checkoutToken,
})
if (
payment.id !== r.payment.result.id ||
placedCheckout.id !== created.checkout.id
) {
throw new Error('Purchase identity mismatch')
}
if (
payment.providerStatus === 'captured' &&
payment.finalizationStatus === 'committed' &&
payment.reconciliation.status === 'none' &&
placedCheckout.status === 'placed' &&
payment.order.id === placedCheckout.order.id
) {
return { kind: 'complete', order: placedCheckout.order }
}
if (
placedCheckout.status === 'open' &&
payment.reconciliation.status === 'none' &&
payment.presentationStatus === 'actionable' &&
payment.nextAction?.type === 'portone_sdk'
) {
// Non-cancelable: retry only this exact request, never a sibling session.
const action = payment.nextAction
return {
kind: 'portone_sdk',
request: {
storeId: action.storeId,
channelKey: action.channelKey,
paymentId: action.paymentId,
orderName: action.orderName,
totalAmount: action.totalAmount,
currency: action.currency,
payMethod: action.payMethod,
redirectUrl: action.redirectUrl,
},
}
}
return { kind: 'needs_attention' }
}
return {
async prepareCart(authorityId: string, purchaseId: string): Promise<Cart> {
return run(authorityId, purchaseId, async (r, save) => {
if (r.checkout || r.payment)
throw new Error('Resume existing purchase; do not edit its Cart')
if (!Number.isSafeInteger(r.intent.quantity) || r.intent.quantity < 1)
throw new Error('Invalid quantity')
if (!r.cart) {
// carts.create has NO idempotency key. A lost result needs app support;
// never replay this pending create as though it had the later contract.
if (r.cartCreationStarted)
throw new Error('Cart creation outcome unavailable; retain record')
r.cartCreationStarted = true
await save(r)
const created = await commerce.carts.create({
market: r.intent.market,
})
r.cart = { cartToken: created.cartToken, snapshot: created.cart }
await save(r)
}
if (!r.add) {
const result = await commerce.product.detailCatalog({
slug: r.intent.slug,
})
if (!result.found) throw new Error('Product unavailable')
const codec = createProductSelectionCodec(result.product, {
emit: 'canonical-id',
})
const selection = resolveProductSelection(
result.product,
codec.parse(r.intent.selectionQuery),
)
if (!selection.selectedVariant)
throw new Error('Choose a current variant')
r.add = {
request: {
cartToken: r.cart.cartToken,
expectedRevision: r.cart.snapshot.revision,
idempotencyKey: crypto.randomUUID(),
productId: String(result.product.product.id),
variantId: String(selection.selectedVariant.id),
quantity: 1,
},
}
await save(r) // Atomic claim fixes key AND body before upstream.
}
if (!r.add.result) {
const result = await commerce.carts.addItem(r.add.request)
rememberCart(r, result.cart)
r.add.result = result
await save(r)
}
if (!r.update) {
const line = r.cart.snapshot.items.find(
(item) => item.variant.id === r.add!.request.variantId,
)
if (!line) throw new Error('Selected Cart line unavailable')
r.update = {
request: {
cartToken: r.cart.cartToken,
expectedRevision: r.cart.snapshot.revision,
idempotencyKey: crypto.randomUUID(),
cartItemId: line.id,
quantity: r.intent.quantity,
},
}
await save(r)
}
if (!r.update.result) {
const result = await commerce.carts.updateItem(r.update.request)
rememberCart(r, result.cart)
r.update.result = result
await save(r)
}
const latest = await commerce.carts.retrieve({
cartToken: r.cart.cartToken,
})
rememberCart(r, latest)
await save(r)
if (
latest.items.length === 0 ||
latest.items.some((item) => item.requiresShipping)
) {
throw new Error('Review Cart; this path requires nonshipping items')
}
return latest // Display server totals; no client-side Money arithmetic.
})
},
async startPayment(
authorityId: string,
purchaseId: string,
): Promise<PurchaseView> {
return run(authorityId, purchaseId, async (r, save) => {
if (!r.cart || !r.update?.result)
throw new Error('Prepare and review Cart first')
if (!r.checkout) {
r.checkout = {
request: {
cartToken: r.cart.cartToken,
customer: { email: r.intent.email },
idempotencyKey: crypto.randomUUID(),
},
}
await save(r)
}
if (!r.checkout.result) {
r.checkout.result = await commerce.checkouts.create(
r.checkout.request,
)
await save(r) // Persist Checkout id/token before any provider handoff.
}
if (!r.payment) {
const checkout = await commerce.checkouts.retrieve({
checkoutToken: r.checkout.result.checkoutToken,
})
if (checkout.id !== r.checkout.result.checkout.id)
throw new Error('Checkout identity mismatch')
if (checkout.status === 'placed')
return { kind: 'order_readback', order: checkout.order }
requirePaidPath(checkout)
if (checkout.paymentState.activePaymentSession)
return { kind: 'needs_attention' }
r.payment = {
request: {
checkoutToken: r.checkout.result.checkoutToken,
idempotencyKey: crypto.randomUUID(),
provider: 'portone',
providerOptions: {
profile: 'standard_v2',
paymentMethod: 'CARD',
},
},
}
await save(r)
}
if (!r.payment.result) {
r.payment.result = await commerce.paymentSessions.createPortOne(
r.payment.request,
)
await save(r) // Persist PaymentSession id before the browser action.
}
return observe(r)
})
},
async resume(
authorityId: string,
purchaseId: string,
): Promise<PurchaseView> {
return run(authorityId, purchaseId, (r) => observe(r))
},
}
}실행과 같은 구매 재개
- 구매자가 상품과 변형을 고르면 세션에 묶인 기록을 만들거나 불러옵니다. Publishable Key, API URL, 직접 구현한
PurchasePort로purchaseWorkflow를 만들고, 구매 동작에서prepareCart(authorityId, purchaseId)를 호출하세요. 최신 카탈로그 상세로 선택을 다시 확인한 뒤 Cart를 만들고, 항목 하나를 추가하고, 의도한 수량으로 바꾸고, 최신 Cart를 조회합니다. 반환된 서버 합계와add.result,update.result의 경고를 보여 주세요. 카탈로그 가격으로 청구액을 계산하지 마세요. 구매자는 이 Cart를 확인한 뒤 진행합니다. - 구매자가 확인하면 같은 기록으로
startPayment를 호출합니다. Checkout은 서버 합계를 고정하고 구매 조건, 재고, 마켓을 다시 확인합니다. Checkout이 이미placed이면 결과는order_readback입니다. PSP 세션을 시작하지 말고 정리된(sanitized) 주문 참조만 보여 주세요. 합계가 0이거나 양수 합계를 적립금으로 모두 결제한 경우에 생길 수 있으므로 "무료"라고 표시하지 마세요. 이 예제의 유료 결제 경로는 열린 상태이고 양수 금액이며 배송이 없는 KRW Checkout만 다룹니다. 결제 ID와 금액은 서버가 정하고, 앱은 Standard 프로필과 결제 수단만 보냅니다. portone_sdk화면 정보에는 PortOne Browser SDK V2에 넘길 정확한 요청이 담겨 있습니다. 구매자의 브라우저에서@portone/browser-sdk/v2의PortOne.requestPayment(view.request)에 그대로 넘기세요. 결제 ID, 금액, redirect URL을 바꾸지 마세요. 모바일 브라우저는 계정의 redirect URL로 돌아오므로 그 페이지가 원래 구매 흐름에 속해야 합니다. 데스크톱 브라우저는 이동 없이 promise가 끝날 수 있습니다. 이 결제 요청은 취소할 수 없습니다. 재시도할 때는 같은view.request를 다시 보내고, 두 번째 PaymentSession을 만들지 마세요. 결제 후 구매자가 구매 상태 확인을 누를 수 있도록 구매 페이지를 유지하세요.- 그 동작은 같은 검증된 세션과 구매 id로
resume을 호출하며, URL에 토큰을 넣지 않습니다. 저장한 Checkout 토큰으로 저장된 PaymentSession을 조회하고, 그 Checkout도 따로 조회합니다. 이 PortOne 결제가 완료됐다고 볼 수 있는 결과는complete뿐입니다. 결제가 captured·committed이고 reconciliation이none이며, 동시에 Checkout이 같은order.id로 placed 상태여야 합니다. 이 조회 결과의order.id와orderNumber를 보여 주세요. 비회원 주문 상세·목록 API는 없습니다. 리디렉션, 쿼리 문자열,requestPayment()결과, 브라우저의 성공 메시지는 결제 완료의 근거가 아닙니다.
쓰기 응답이나 그 뒤의 저장이 유실됐다면 기록을 다시 여세요. prepareCart는 대기 중인 추가·변경을, startPayment는 대기 중인 Checkout·PaymentSession 생성을 저장된 요청 그대로 이어서 실행합니다. 저장된 PaymentSession은 항상 조회만 하고 다시 만들지 않습니다. resume은 생성 결과가 없으면 거부하므로 잃어버린 id를 "결제 없음"으로 오해하지 않습니다. carts.create에는 멱등 키가 없습니다. 토큰이나 결과를 잃었다면 저장된 대기 표시가 자동 재생성을 막습니다. 기록을 보존하고 앱 지원 절차로 해결하세요. Cart 생성을 멱등 요청처럼 재시도하지 마세요.
불확실한 결과 구분
| 상황 | 대응 |
|---|---|
| 빈 Cart, 상품·재고·마켓을 쓸 수 없음, 잘못된 선택, 기능·키·워크스페이스 거부 | 오류 유형과 요청 id를 유지하고 구매 의도를 바로잡습니다. 거부를 빈 결과·무료·결제 완료로 바꾸지 말고, 원시 컬렉션으로 우회하지 마세요. |
cart_revision_conflict | 최신 Cart를 읽고 구매자에게 변경을 다시 확인받습니다. 거부된 요청은 새 변경이 아닙니다. 이전 결과가 정리된 뒤 따로 확인한 변경만 새 작업 기록과 키를 받습니다. 이 예제는 요청을 고쳐 쓰지 않고 멈춥니다. |
응답 유실, idempotency_in_progress, 일시적 실패 | 원래 키와 본문을 유지하고 SDK 재시도 정책을 따릅니다. 읽기와 쓰기의 재시도 규칙은 다릅니다. 같은 키로 다시 보내는 것은 새 변경이 아닙니다. |
unknown, 처리 중, captured지만 대기 중, 결과 미반영, committed이지만 reconciliation 필요, 다른 주문 | "대기 중" 또는 "확인 필요"를 표시하고, 같은 대상을 다시 조회하거나 지원팀에 문의합니다. 새 키·세션·결제사·청구를 만들지 마세요. committed만으로 reconciliation이 해소되지 않습니다. |
| Checkout 만료·이탈, 조회 실패, 브라우저 종료 | 연결된 모든 식별자를 보존하고 공식 상태를 확인하거나 지원팀에 문의합니다. 이 중 어느 것도 결제가 매입(capture)되지 않았다는 뜻이 아니며, 매입이 늦게 반영될 수 있습니다. 결제를 다시 시작하려고 기록을 지우거나 보존 기간을 우회하지 마세요. |
이 흐름은 더 오래된 Cart revision이 저장된 Cart를 덮어쓰기 전에 거부합니다. PaymentSession이 생긴 뒤에는 그 Cart를 고치지 않으며, Checkout이 placed라는 사실만으로 그 세션의 불확실한 결과가 해소됐다고 보지 않습니다. 예외는 그대로 전달하세요. 권한이 담긴 요청이나 오류 본문은 드러내지 말고, 읽기 재시도나 미확인 작업 상태를 보여 주세요.
통합 확인
직접 구현한 저장소에서 동시 탭, 각 외부 쓰기 직전의 저장 실패, 응답 유실 뒤 정확히 같은 키와 본문으로 재개하는 경우를 시험하세요. 공개 SDK와 통제된 전송 계층으로 다음을 확인합니다.
- Standard action 전달 뒤 captured·committed·reconciliation 없음, 그리고 같은 주문의 Checkout을 따로 조회한 결과가 이어지는지
- reconciliation이 필요한 committed, 다른 주문, 더 낮은 Cart revision, 조회 실패를 모두 거부하는지
unknown이후 시작·상태 확인을 반복해도 새 결제가 생기지 않는지- 빈 카탈로그, 판매 가능 여부·재고·마켓 변경, 오래된 revision, 기능·키·워크스페이스 거부를 처리하는지
통제된 전송 계층은 앱의 동작을 시험할 뿐, 결제사 정산이나 실제 워크스페이스를 확인하지 않습니다. 실제 흐름은 배포 뒤에 확인하세요. 운영 책임은 커머스 운영에서 다룹니다.