장바구니와 결제 시작
장바구니는 게스트와 로그인 사용자를 분리해서 처리합니다. 결제는 /payments/initialize로 시작합니다.
게스트 장바구니
섹션 제목: “게스트 장바구니”| 메서드 | 경로 | 설명 | 사용자 토큰 |
|---|---|---|---|
| GET | /guest-cart | 게스트 장바구니 조회 | 필요 없음 |
| POST | /guest-cart | 게스트 장바구니 추가 | 필요 없음 |
| PUT | /guest-cart/quantity | 게스트 상품 수량 변경 | 필요 없음 |
| DELETE | /guest-cart/{itemId} | 게스트 장바구니 항목 삭제 | 필요 없음 |
| DELETE | /guest-cart | 게스트 장바구니 비우기 | 필요 없음 |
상품을 게스트 장바구니에 담을 때는 상품 옵션 variant ID를 key로 사용합니다. 콘텐츠를 담을 때는 상품 variant가 아니라 콘텐츠 옵션 option_id를 key로 사용합니다.
브라우저에서 게스트 장바구니를 호출할 때는 device cookie가 유지되도록 credentials: 'include'를 사용합니다.
await fetch(`${API_BASE}/guest-cart`, { method: 'POST', credentials: 'include', headers: { 'X-Runmoa-Site-Key': STOREFRONT_KEY, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ data: { 88421: { product_id: 12001, quantity: 1, buyer_inputs: { 341: '홍길동', }, }, }, }),});POST /api/storefront/v1/guest-cartX-Runmoa-Site-Key: moa_pub_xxxxxxxxxContent-Type: application/jsonAccept: application/json
{ "data": { "88421": { "product_id": 12001, "quantity": 1, "buyer_inputs": { "341": "홍길동" } } }}buyer_inputs는 상품 상세의 buyer_input_options에서 받은 항목 ID를 key로 사용합니다. 값은 문자열로 보내며, 배열 형태를 써야 한다면 { "input_option_id": 341, "value": "홍길동" }도 사용할 수 있습니다. 서버는 활성 입력 항목만 저장하고, 필수 여부와 max_length를 다시 검증합니다.
장바구니 조회 응답은 화면의 장바구니 숫자와 drawer 목록에 바로 사용할 수 있습니다.
{ "count": 1, "items": [ { "id": 88421, "item_id": 88421, "item_type": "product", "product_id": 12001, "variant_id": 88421, "cart_quantity": 1, "name": "러닝 재킷", "price": 49000 } ]}콘텐츠 장바구니 항목은 선택한 옵션 정보가 아래처럼 내려올 수 있습니다.
{ "count": 1, "items": [ { "ID": 55102, "title": "인스타그램 콘텐츠 캘린더 실전 가이드", "cart_quantity": 1, "class": { "ID": 55102, "class_id": 6401, "type": "digital_content", "title": "인스타그램 콘텐츠 캘린더 기획 서비스", "price": "40000.0000", "sale_price": "25000.0000" } } ]}콘텐츠 장바구니 화면의 항목 ID는 option_id, item_id, id, ID 순서로 확인합니다. 가격은 sale_price, price, class.sale_price, class.price 순서로 확인하면 됩니다. 콘텐츠는 수량 구매가 아니므로 cart_quantity가 내려와도 주문 금액과 화면 합계는 1회 구매 기준으로 계산합니다.
장바구니 항목 정규화 예시:
function toNumber(value, fallback = 0) { const number = Number(value); return Number.isFinite(number) ? number : fallback;}
function firstPresent(...values) { return values.find((value) => value !== null && value !== undefined && value !== '');}
function isProductCartItem(item) { return item.item_type === 'product' || Boolean(item.product_id || item.variant_id);}
function cartDeleteTarget(item) { if (isProductCartItem(item)) { return { itemId: toNumber(firstPresent(item.item_id, item.variant_id, item.id), null), itemType: 'product', }; }
return { itemId: toNumber(firstPresent(item.option_id, item.item_id, item.id, item.ID), null), itemType: 'content', };}
function cartUnitPrice(item) { return toNumber( firstPresent( item.sale_price, item.price, item.class?.sale_price, item.class?.price, ), );}
function cartLineTotal(item) { const quantity = isProductCartItem(item) ? Math.max(1, toNumber(firstPresent(item.cart_quantity, item.quantity), 1)) : 1;
return cartUnitPrice(item) * quantity;}게스트 장바구니에서 상품 항목을 삭제할 때는 삭제 대상 타입을 함께 보냅니다.
DELETE /api/storefront/v1/guest-cart/88421X-Runmoa-Site-Key: moa_pub_xxxxxxxxxContent-Type: application/jsonAccept: application/json
{ "item_type": "product"}수량이 최소 구매 수량보다 낮으면 서버가 검증 오류를 반환할 수 있습니다.
{ "message": "최소 구매 수량을 확인해 주세요.", "min_quantity": 2}이 경우 프론트엔드는 상품 상세 응답의 sourcing_info.min_quantity 또는 오류 응답의 min_quantity를 사용해 사용자에게 올바른 수량을 안내합니다.
콘텐츠와 오프라인 클래스 장바구니 추가
섹션 제목: “콘텐츠와 오프라인 클래스 장바구니 추가”offline, live, digital_content 콘텐츠는 상품처럼 product_id를 보내지 않습니다. 콘텐츠 상세 응답에서 class.default.all_options[].ID를 우선 확인하고, all_options가 없으면 class.curriculums[].option_id 값을 key로 사용하면 됩니다.
await fetch(`${API_BASE}/guest-cart`, { method: 'POST', credentials: 'include', headers: { 'X-Runmoa-Site-Key': STOREFRONT_KEY, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ data: { 55102: 1, }, }),});POST /api/storefront/v1/guest-cartX-Runmoa-Site-Key: moa_pub_xxxxxxxxxContent-Type: application/jsonAccept: application/json
{ "data": { "55102": 1 }}여기서 55102는 콘텐츠 ID가 아니라 선택한 옵션 ID 입니다. 오프라인 클래스의 회차, 일정, 플랜처럼 사용자가 실제로 고른 항목을 넣어야 합니다.
콘텐츠는 수량을 선택하지 않습니다. 같은 옵션을 이미 장바구니에 담았다면 다시 POST /guest-cart 또는 POST /cart를 호출하지 말고 기존 장바구니 항목을 보여주세요. 상품 수량 변경 API는 콘텐츠에 사용하지 않습니다.
콘텐츠 항목 삭제 URL의 {itemId}는 콘텐츠 ID가 아니라 선택한 옵션 ID입니다. 장바구니 조회 응답에 option_id가 있으면 그 값을 사용하고, 없으면 item_id, id, ID 순서로 확인합니다.
콘텐츠 항목 삭제는 item_type을 생략해도 기본값이 content입니다.
DELETE /api/storefront/v1/guest-cart/55102X-Runmoa-Site-Key: moa_pub_xxxxxxxxxAccept: application/json명시적으로 보내고 싶다면 아래처럼 보낼 수 있습니다.
{ "item_type": "content"}로그인 장바구니
섹션 제목: “로그인 장바구니”| 메서드 | 경로 | 설명 | 사용자 토큰 |
|---|---|---|---|
| GET | /cart | 로그인 장바구니 조회 | 필수 |
| POST | /cart | 로그인 장바구니 추가 | 필수 |
| PUT | /cart/quantity | 로그인 상품 수량 변경 | 필수 |
| DELETE | /cart/{itemId} | 로그인 장바구니 항목 삭제 | 필수 |
| DELETE | /cart | 로그인 장바구니 비우기 | 필수 |
| POST | /cart/merge-guest | 게스트 장바구니를 로그인 장바구니로 병합 | 필수 |
로그인 상태에서는 장바구니 조회, 추가, 삭제, 결제 준비 모두 /cart 계열을 사용합니다. 게스트 장바구니는 로그인 callback 이후 POST /cart/merge-guest로 한 번 병합한 뒤 다시 GET /cart로 조회합니다.
주문과 결제
섹션 제목: “주문과 결제”| 메서드 | 경로 | 설명 | 사용자 토큰 |
|---|---|---|---|
| GET | /checkout/metadata | 결제 화면 메타데이터 조회 | 필요 없음 |
| POST | /orders | 주문 생성 | 필수 |
| POST | /payments/initialize | 결제 초기화 | 필수 |
주문 생성 요청
섹션 제목: “주문 생성 요청”POST /orders는 로그인 사용자 토큰이 필요합니다. 상품 주문은 new_data, 콘텐츠 주문은 old_data를 사용합니다. 정적 스토어프론트는 상품 상세/장바구니 응답에서 받은 상품, 옵션, 가격 정보를 그대로 주문 요청에 넣어야 합니다.
total_price는 프론트엔드의 상품 소계 또는 화면 표시용 추정값입니다. 배송비와 구매대행 배송 정책 등 최종 주문 금액은 서버가 주문 생성 시 다시 계산하고, 결제는 응답으로 받은 주문 번호 기준으로 진행합니다.
{ "new_data": [ { "id": 88421, "product_id": 12001, "variant": { "id": 88421 }, "price": [ { "base_price": 59000, "sale_price": 49000, "is_on_sale": 1, "currency": "KRW" } ] } ], "old_data": [], "quantities": { "88421": 1 }, "total_price": 49000, "order_memo": "", "receiver": { "receiver_name": "홍길동", "phone": "01012345678", "address": "서울시 강남구 테헤란로 123", "address_detailed": "101호", "postal_code": "06234", "pcc_number": "", "region": { "country": "KR" } }}응답은 결제 초기화에 사용할 주문 번호와 결제 금액을 반환합니다.
{ "order": { "ID": 12837, "price": 49000 }}주문 생성 검증 오류 예시:
{ "message": "주문 정보를 확인해 주세요.", "errors": { "receiver.receiver_name": ["받는 분 이름을 입력해야 합니다."], "receiver.phone": ["연락처를 입력해야 합니다."], "new_data.0.variant.id": ["선택한 옵션을 확인할 수 없습니다."] }}상품 주문 payload를 만들 때는 상품 상세/장바구니 응답에서 받은 값을 그대로 사용합니다. 특히 new_data[].id와 quantities의 key는 선택한 variant id이고, product_id는 상품 id입니다. variant 객체 안에 product_id가 없을 수 있으므로 상품 id를 별도로 보관해야 합니다.
콘텐츠 주문 payload를 만들 때는 old_data 안에 콘텐츠 option_id 기준 데이터가 들어갑니다. 즉 오프라인 클래스/라이브 클래스는 콘텐츠 ID가 아니라 사용자가 고른 옵션 ID를 기준으로 주문이 생성됩니다.
가장 안전한 최소 payload는 아래처럼 old_data[].ID = option_id만 넣는 방식입니다.
{ "new_data": [], "old_data": [ { "ID": 55102 } ], "quantities": {}, "total_price": 99000, "order_memo": "", "receiver": { "receiver_name": "홍길동", "phone": "01012345678", "address": "서울시 성동구 성수이로 100", "address_detailed": "5층", "postal_code": "04798", "pcc_number": "", "region": { "country": "KR" } }}정리하면 콘텐츠 결제는 아래 순서입니다.
GET /contents/{contentId}에서class.default.all_options를 먼저 읽습니다.all_options가 없으면class.curriculums[].option_id를 읽습니다.- 사용자가 고른 옵션의
option_id를 저장합니다. - 필요하면
POST /contents/cart-preview로 제목/설명/가격을 확인합니다. - 장바구니에는
POST /guest-cart또는POST /cart로option_id를 담습니다. - 주문 생성 시
old_data: [{ ID: option_id }]로 보냅니다. - 결제는 상품과 동일하게
POST /payments/initialize응답의 form payload를 submit합니다.
결제 초기화 요청
섹션 제목: “결제 초기화 요청”POST /payments/initialize는 로그인 사용자 토큰이 필요합니다. 주문을 생성한 사용자만 해당 주문의 결제를 시작할 수 있습니다.
const payment = await fetch(`${API_BASE}/payments/initialize`, { method: 'POST', headers: { 'X-Runmoa-Site-Key': STOREFRONT_KEY, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ order_id: 12837, redirect_url: `${window.location.origin}/payment-result`, pay_method: 'CARD', }),}).then((response) => response.json());스토어프론트 문서 기준 결제는 표준 NicePay 카드결제만 지원합니다. 결제 초기화 응답은 그 카드결제 흐름에 필요한 form payload를 반환하며, 외부 프론트엔드는 이 값을 그대로 <form method="POST">로 submit하면 됩니다.
{ "order_id": 12837, "amount": 49000, "currency": "KRW", "payment_method": "CARD", "payment": { "type": "form_post", "method": "POST", "action": "https://your-site.runmoa.com/api/pay", "fields": { "nonce": "encrypted-order-nonce", "order_id": "12837", "price": "49000", "goods_name": "러닝 재킷", "buyer_name": "홍길동", "buyer_tel": "01012345678", "buyer_mail": "buyer@example.com", "pay_method": "CARD", "redirect_url": "https://storefront.example.com/payment-result" } }, "redirect_url": "https://storefront.example.com/payment-result"}function submitPayment(payment) { const form = document.createElement('form'); form.method = payment.payment.method; form.action = payment.payment.action;
Object.entries(payment.payment.fields).forEach(([name, value]) => { const input = document.createElement('input'); input.type = 'hidden'; input.name = name; input.value = String(value); form.appendChild(input); });
document.body.appendChild(form); form.submit();}payment.action은 결제창을 열기 위한 런모아 서버 경로입니다. 외부 구현은 표준 NicePay 카드결제만 대상으로 하고, 응답에 포함된 form payload를 그대로 submit합니다.
결제 완료 후 런모아 서버는 가능한 경우 초기화 요청의 redirect_url로 사용자를 돌려보냅니다. 성공 시 보통 아래 query가 붙습니다.
https://storefront.example.com/payment-result?pay_success=true&order_id=12837실패나 취소는 pay_success=false, order_id, error query가 붙을 수 있습니다. 외부 프론트엔드는 이 query를 읽어서 결제 결과 화면을 렌더링합니다.
결제 초기화 실패 예시:
{ "message": "결제를 시작할 수 없습니다.", "errors": { "order_id": ["결제 가능한 주문을 찾을 수 없습니다."] }}결제 결과 페이지는 최소한 아래 query를 처리합니다.
const params = new URLSearchParams(window.location.search);const orderId = params.get('order_id');const success = params.get('pay_success') === 'true';const error = params.get('error');
if (success) { // 주문 완료 화면을 렌더링합니다.} else { // 실패/취소 문구와 재시도 버튼을 렌더링합니다.}