콘텐츠로 이동

AI 스토어프론트 빌드 가이드

런모아 API로 정적 스토어프론트를 구현할 때 필요한 엔드포인트, 호출 순서, 응답 규칙입니다. 상품 스토어와 콘텐츠 스토어는 응답 구조가 다르므로 별도로 구현합니다.

정적 프론트엔드는 아래 값이 있어야 동작합니다.

이름예시위치
API base URLhttps://shleetest00080.runmoa.com/api/storefront/v1프론트엔드 설정
스토어프론트 키moa_pub_xxxxxxxxx프론트엔드 설정
로그인 callback URLhttps://storefront.example.com/auth/callback앱 라우트
결제 결과 URLhttps://storefront.example.com/payment-result앱 라우트

브라우저에는 moa_pub_... 형식의 스토어프론트 키만 넣습니다. 서버용 비공개 키는 정적 프론트엔드에 넣지 않습니다.

  1. GET /site로 사이트 연결과 키를 확인합니다.
  2. GET /product-tags, GET /products로 홈과 컬렉션을 만듭니다.
  3. GET /products/{productId}로 상세 페이지, 이미지, 옵션, 최소 구매 수량을 만듭니다.
  4. GET /guest-cart, POST /guest-cart, DELETE /guest-cart/{itemId}로 게스트 장바구니를 만듭니다.
  5. GET /auth/schoolmoa-client와 Runmoa 로그인 URL로 로그인 시작을 만듭니다.
  6. POST /auth/schoolmoa/callback 후 token을 저장하고 GET /me로 사용자 정보를 다시 조회합니다.
  7. 로그인 후 게스트 장바구니가 있으면 POST /cart/merge-guest로 로그인 장바구니에 병합합니다.
  8. 로그인 사용자 기준으로 POST /orders를 호출해 주문을 생성합니다.
  9. POST /payments/initialize 응답의 payment form payload를 그대로 submit합니다. 결제는 표준 NicePay 카드결제만 사용합니다.
  10. 결제 결과 URL의 query를 읽어 결과 화면을 렌더링합니다.
  11. GET /me/orders, GET /me/order-lines, GET /me/contents/all/all로 마이페이지를 만듭니다.
  12. 구매 후 콘텐츠 열기는 POST /me/content-entitlements/{lineItemId}/open, 구매확정/취소/환불/반품/교환은 POST /me/order-lines/{lineItemId}/actions, 후기는 POST /me/order-lines/{lineItemId}/review를 사용합니다.

콘텐츠 판매 스토어는 상품 스토어와 다른 응답 구조를 사용합니다.

  1. GET /content-tags 또는 GET /content-categories로 탐색 기준을 만듭니다.
  2. GET /contents?type=offline&page=1&limit=12처럼 type query로 목록을 만듭니다.
  3. 목록 응답은 contents가 아니라 classes.data에서 읽습니다.
  4. GET /contents/{contentId} 상세 응답은 class.default에서 읽습니다.
  5. 옵션 선택은 class.default.all_options[].ID를 우선 사용하고, all_options가 없으면 class.curriculums[].option_id를 사용합니다.
  6. 옵션명과 가격은 class.curriculums[].title, class.curriculums[].price, class.curriculums[].sale_price를 함께 확인합니다.
  7. offline, live는 과거 일정 옵션을 판매 가능 목록에서 제외합니다. 보통 duration_end < now 이면 마감이고, duration_end가 없으면 duration_start 기준으로 판단합니다.
  8. POST /contents/cart-previewoptions_ids를 보내 장바구니 표시용 제목, 설명, 가격을 확인할 수 있습니다.
  9. 장바구니 추가는 상품처럼 variant가 아니라 POST /guest-cart 또는 POST /cartdata: { "{optionId}": 1 } 형태로 보냅니다.
  10. 주문 생성은 POST /orders에서 old_data: [{ ID: optionId }]를 사용합니다.
  11. 결제는 POST /payments/initialize 응답의 form payload를 submit합니다.
  12. 구매 후 목록과 액션 UI는 GET /me/order-lines를 사용합니다.
  13. 구매 후 콘텐츠 열기는 POST /me/content-entitlements/{lineItemId}/open을 사용합니다. 공개 상세 화면과 구매 후 열기 화면을 같은 데이터로 처리하지 않습니다.
const API_BASE = 'https://your-site.runmoa.com/api/storefront/v1';
const STOREFRONT_KEY = 'moa_pub_xxxxxxxxx';
async function storefrontFetch(path, options = {}) {
const token = localStorage.getItem('runmoa_user_token');
const headers = {
'X-Runmoa-Site-Key': STOREFRONT_KEY,
Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
};
const response = await fetch(`${API_BASE}${path}`, {
...options,
credentials: 'include',
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.message || payload?.error || `Runmoa API ${response.status}`;
throw Object.assign(new Error(message), {
status: response.status,
payload,
});
}
return payload;
}

credentials: 'include'는 게스트 장바구니 device cookie 유지를 위해 필요합니다.

목록 응답은 아래 key 중 하나에 배열이 들어올 수 있습니다.

function extractList(payload, keys = ['data', 'products', 'contents', 'orders', 'items', 'classes']) {
if (Array.isArray(payload)) return payload;
for (const key of keys) {
if (Array.isArray(payload?.[key])) return payload[key];
if (Array.isArray(payload?.[key]?.data)) return payload[key].data;
}
return [];
}

응답 값은 숫자와 숫자 문자열이 섞일 수 있습니다. 가격, ID, 이미지 URL은 화면에 넣기 전에 한 번 정규화합니다.

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 itemId(item) {
return toNumber(firstPresent(item.option_id, item.item_id, item.id, item.ID), null);
}
function itemPrice(item) {
return toNumber(
firstPresent(
item.sale_price,
item.price,
item.class?.sale_price,
item.class?.price,
item.options?.sale_price,
item.options?.price,
),
);
}
function imageUrl(item) {
return firstPresent(
item.thumbnail_link,
item.image_url,
item.main_image?.image_url,
item.mainImage?.image_url,
Array.isArray(item.img) ? item.img[0] : null,
Array.isArray(item.images) ? item.images[0] : null,
);
}
function cartLineTotal(item) {
const isProduct = item.item_type === 'product' || item.product_id || item.variant_id;
const quantity = isProduct ? Math.max(1, toNumber(item.cart_quantity ?? item.quantity, 1)) : 1;
return itemPrice(item) * quantity;
}

콘텐츠 장바구니 항목은 cart_quantity가 있어도 1회 구매 기준으로 계산합니다. 상품만 수량 변경 UI와 수량 변경 API를 사용합니다.

구현에 바로 쓰는 최소 응답 shape

섹션 제목: “구현에 바로 쓰는 최소 응답 shape”

아래 shape를 기준으로 카드, 상세, 장바구니, 마이페이지 화면을 구현합니다.

{
"ID": 6551,
"id": 6551,
"site_name": "shleetest00080",
"name": "shleetest00080",
"host": "shleetest00080.runmoa.com",
"logo": null
}
{
"user": {
"ID": 123,
"id": 123,
"name": "홍길동",
"nickname": "gildong",
"email": "buyer@example.com",
"phone": "01012345678",
"user_phone": "01012345678"
}
}
{
"products": {
"data": [
{
"ID": 12001,
"name": "러닝 재킷",
"thumbnail_link": "https://cdn.example.com/products/running-jacket.jpg",
"price": [
{
"price": 49000,
"sale_price": 49000,
"currency": "KRW",
"variant_id": 88421
}
],
"variants": [
{
"id": 88421,
"product_id": 12001,
"label": "Black / M",
"quantity": 12
}
]
}
]
}
}
{
"product": {
"product": {
"ID": 12001,
"name": "러닝 재킷"
},
"translation": {
"name": "러닝 재킷",
"description": "<p>가벼운 트레이닝 재킷입니다.</p>"
},
"main_image": {
"image_url": "https://cdn.example.com/products/running-jacket-main.jpg"
},
"variants": [
{
"id": 88421,
"product_id": 12001,
"label": "Black / M"
}
],
"sourcing_info": {
"min_quantity": 1,
"unit_quantity": 1
}
}
}
{
"classes": {
"data": [
{
"ID": 6401,
"type": "offline",
"title": "브랜드 포지셔닝 워크숍",
"thumbnail_link": "https://cdn.example.com/contents/brand-positioning-thumb.jpg",
"all_options": [
{
"ID": 55102,
"class_id": 6401,
"price": 120000,
"sale_price": 99000
}
]
}
]
}
}
{
"class": {
"default": {
"ID": 6401,
"type": "offline",
"title": "브랜드 포지셔닝 워크숍",
"description": "<p>브랜드 핵심 메시지를 정리하는 오프라인 워크숍입니다.</p>",
"all_options": [
{
"ID": 55102,
"class_id": 6401,
"price": 120000,
"sale_price": 99000
}
]
},
"curriculums": [
{
"ID": 55102,
"option_id": 55102,
"title": "7월 12일 토요일 14:00",
"price": 99000,
"sale_price": 99000
}
]
}
}

콘텐츠 구매 선택값은 contentId가 아니라 all_options[].ID 또는 curriculums[].option_id 입니다.

상품과 콘텐츠가 같은 items 배열에 섞일 수 있습니다.

{
"count": 2,
"items": [
{
"id": 88421,
"item_id": 88421,
"item_type": "product",
"product_id": 12001,
"variant_id": 88421,
"cart_quantity": 2,
"name": "러닝 재킷",
"price": 49000,
"image_url": "https://cdn.example.com/products/running-jacket.jpg"
},
{
"ID": 55102,
"item_type": "content",
"title": "인스타그램 콘텐츠 캘린더 기획 서비스",
"cart_quantity": 1,
"class": {
"ID": 55102,
"class_id": 6401,
"type": "digital_content",
"title": "인스타그램 콘텐츠 캘린더 기획 서비스",
"price": "40000.0000",
"sale_price": "25000.0000"
}
}
]
}

상품 삭제 ID는 보통 item_id 또는 variant_id입니다. 콘텐츠 삭제 ID는 option_id, item_id, id, ID 순서로 찾습니다.

{
"boards": {
"data": [
{
"ID": 13522,
"title": "문의 게시판",
"post_cnt": 1,
"type": "qna",
"write": "1",
"comment_write": "1",
"comment_mode": "flat",
"posting_scope": "member"
},
{
"ID": 13521,
"title": "공지사항",
"post_cnt": 1,
"type": "default",
"write": "0",
"comment_write": "0",
"comment_mode": "disabled",
"posting_scope": "admin_only"
}
]
}
}
{
"board_contents": {
"data": [
{
"ID": 1184,
"board_id": 13522,
"title": "첫 문의",
"writer_name": "런모아",
"created_at": "2026-06-24T03:23:37.000000Z",
"comment_cnt": 0
}
],
"comment_mode": "flat",
"board_type": "qna"
}
}
{
"board_content": {
"ID": 1184,
"board_id": 13522,
"title": "첫 문의",
"content": "test",
"writer_name": "런모아"
},
"comment_mode": "flat",
"can_read_comment": false,
"can_write_comment": false
}

이 경우 댓글 목록을 바로 호출하지 말고 로그인 또는 권한 안내를 우선 보여줍니다.

마이페이지의 상품/콘텐츠 구매 후 화면은 line_item_id를 기준으로 동작합니다.

{
"line_items": [
{
"line_item_id": "product:77",
"kind": "product",
"order_id": 12837,
"product_id": 12001,
"variant_id": 88421,
"title": "러닝 재킷",
"subtitle": "Black / M",
"quantity": 1,
"price": 49000,
"shipping_price": 3000,
"status": "delivered",
"actions": [
{ "type": "return_request", "enabled": true, "label": "반품요청", "method": "POST" },
{ "type": "confirm_purchase", "enabled": true, "label": "구매확정", "method": "POST" }
],
"review": { "exists": false, "review_id": null }
},
{
"line_item_id": "content:99031",
"kind": "content",
"content_type": "vod",
"order_id": 12838,
"content_id": 6401,
"option_id": 55102,
"title": "브랜드 포지셔닝 워크숍",
"price": 99000,
"quantity": 1,
"status": "paid",
"serve": { "mode": "internal_player" },
"actions": [
{ "type": "open", "enabled": true, "label": "콘텐츠 열기", "method": "POST" },
{ "type": "refund_request", "enabled": true, "label": "환불 요청", "method": "POST" }
],
"review": { "exists": false, "review_id": null }
}
],
"total": 2
}

버튼은 actions[].enabled === true인 항목만 렌더링합니다. open 버튼은 POST /me/content-entitlements/{lineItemId}/open을 호출하고, 구매확정/취소/환불/반품/교환 버튼은 POST /me/order-lines/{lineItemId}/actions를 호출합니다. 후기 작성은 POST /me/order-lines/{lineItemId}/review를 사용합니다.

페이지최소 엔드포인트
GET /site, GET /products?page=1&limit=24, GET /product-tags
태그 컬렉션GET /product-tags, GET /products?tag_id={tagId}
검색GET /products?search={query}
상품 상세GET /products/{productId}
콘텐츠 목록GET /content-tags 또는 GET /content-categories, GET /contents?type={type}
콘텐츠 상세GET /contents/{contentId}, POST /contents/cart-preview
장바구니GET /guest-cart, POST /guest-cart, DELETE /guest-cart/{itemId}, GET /cart, POST /cart, DELETE /cart/{itemId}, POST /cart/merge-guest
로그인GET /auth/schoolmoa-client, POST /auth/schoolmoa/callback, GET /me
결제POST /orders, POST /payments/initialize
마이페이지GET /me, GET /me/orders, GET /me/orders/{orderId}, GET /me/orders/{orderId}/details, GET /me/contents/all/all, GET /me/content-items/{contentId}
  • 상품 목록: products.data
  • 콘텐츠 목록: classes.data
  • 상품 상세: product
  • 콘텐츠 상세: class.default
  • 상품 옵션 선택값: variant.id
  • 콘텐츠 옵션 선택값: class.default.all_options[].ID 또는 class.curriculums[].option_id

콘텐츠 주문은 content_id가 아니라 option_id를 사용합니다.

게시판 댓글 작성은 post_id가 아니라 content_id를 사용합니다. 게시글 상세 응답에서 can_read_comment, can_write_comment, comment_mode를 먼저 확인하고, 댓글 목록은 GET /posts/{postId}/comments로 별도 조회합니다.

{
"old_data": [
{
"ID": 55102
}
]
}

아래 순서대로 상품 구매 흐름을 구현합니다.

  1. GET /products?tag_id={tagId}&page=1&limit=24로 목록을 렌더링합니다.
  2. 사용자가 상품 카드를 열면 GET /products/{productId}를 호출합니다.
  3. 상세 응답에서 variants[0].id 같은 선택 가능한 variant.id를 고릅니다.
  4. 비로그인 상태면 POST /guest-cart에 variant 기준 payload를 보냅니다.
  5. 로그인 후에는 POST /cart/merge-guestGET /cart를 사용합니다.
  6. 결제 버튼에서는 POST /orders로 pending 주문을 만듭니다.
  7. 이어서 POST /payments/initialize를 호출하고 payment.fields를 그대로 submit합니다.

대표 게스트 장바구니 payload:

{
"data": {
"88421": {
"product_id": 12001,
"quantity": 1
}
}
}

대표 주문 생성 payload:

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

주의:

  • new_data[].idvariant.idvariant id
  • product_id상품 id
  • 둘을 섞으면 주문 생성에서 422가 납니다.

콘텐츠 storefront는 상품 흐름을 그대로 재사용하면 안 됩니다.

  1. GET /contents?type=offline&page=1&limit=12로 카드 목록을 만듭니다.
  2. 사용자가 콘텐츠를 열면 GET /contents/{contentId}를 호출합니다.
  3. class.default.all_options[].ID를 우선 읽고, 없으면 class.curriculums[].option_id를 읽습니다.
  4. 필요하면 POST /contents/cart-preview로 선택 옵션 제목/가격을 확인합니다.
  5. 장바구니 추가는 POST /guest-cart 또는 POST /cartdata: { "{optionId}": 1 } 형태로 보냅니다.
  6. 주문 생성은 POST /orders에서 old_data: [{ ID: optionId }]를 사용합니다.
  7. 결제는 상품과 동일하게 POST /payments/initialize 응답을 submit합니다.

대표 cart preview payload:

{
"options_ids": [55102]
}

대표 장바구니 payload:

{
"data": {
"55102": 1
}
}

대표 주문 생성 payload:

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

주의:

  • 55102contentId가 아니라 optionId
  • offline, live는 지난 일정 옵션을 판매 가능 목록에서 제외
  • 구매 후 화면은 공개 상세가 아니라 GET /me/content-items/{contentId} 기준으로 구현

외부 storefront는 상태 코드별로 아래처럼 처리하면 됩니다.

의미:

  • 사용자 토큰 없음
  • 사용자 토큰 만료
  • 로그인 필요한 API를 비로그인 상태에서 호출

처리:

  • 저장된 토큰 제거
  • 로그인 모달 또는 로그인 페이지 열기
  • 현재 경로를 저장했다가 callback 후 복귀
if (error.status === 401) {
localStorage.removeItem('runmoa_user_token');
openLoginModal();
}

의미:

  • storefront key 도메인 제한 불일치
  • 게시판/댓글 권한 부족
  • 구매/접근 권한이 없는 콘텐츠 또는 댓글 권한 부족

처리:

  • 사용자 액션 권한 부족이면 UI를 숨기고 안내 문구 표시
  • 전역 API 호출에서 발생하면 사이트 관리자에게 키/도메인 확인 요청
if (error.status === 403) {
showNotice(error.payload?.message || '권한이 없습니다.');
}

의미:

  • 주문 payload 잘못됨
  • 최소 구매 수량 미달
  • variant id / option id 잘못됨
  • 배송지 필수값 누락

처리:

  • messageerrors를 그대로 폼 근처에 표시
  • 수량 관련이면 상품 상세의 min_quantity로 교정
  • 옵션 관련이면 상세 응답을 다시 읽고 사용자가 다시 선택하게 함
if (error.status === 422) {
const fieldErrors = error.payload?.errors ?? {};
renderFormErrors(fieldErrors);
}
  • posting_scope: admin_only 또는 write: "0"이면 글쓰기 버튼을 숨깁니다.
  • comment_mode: "disabled"면 댓글 섹션을 비활성화합니다.
  • can_read_comment: false면 댓글 목록 대신 로그인/권한 안내를 표시합니다.
  • can_write_comment: false면 댓글 입력폼을 숨깁니다.
상태의미프론트엔드 처리
400요청 값이 잘못됨API의 message, errors를 화면에 표시
401사용자 토큰 없음 또는 만료local token 제거 후 로그인 유도
403키, 도메인, 권한 문제사이트 관리자에게 키/허용 도메인 확인 요청
404상품, 주문, 콘텐츠 없음not found 화면 표시
422수량, 주소, 주문 payload 검증 실패입력 폼 또는 장바구니 항목 수정 유도

대표 오류 응답:

{
"message": "요청 값이 올바르지 않습니다.",
"errors": {
"receiver.phone": ["연락처를 입력해야 합니다."]
}
}
{
"message": "이 API 키는 현재 도메인에서 사용할 수 없습니다."
}

외부 프론트엔드는 표준 NicePay 카드결제만 대상으로 POST /payments/initialize로 시작하고, 응답의 payment 객체를 그대로 form submit합니다.

function submitRunmoaPayment(paymentResponse) {
const payment = paymentResponse.payment;
if (payment?.type !== 'form_post') {
throw new Error('지원하지 않는 결제 응답입니다.');
}
const form = document.createElement('form');
form.method = payment.method;
form.action = payment.action;
Object.entries(payment.fields).forEach(([name, value]) => {
if (value === null || value === undefined) return;
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = String(value);
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
  • 실제 site_hostmoa_pub_... 키로 GET /site가 성공하는지 확인합니다.
  • 배포 도메인이 스토어프론트 키 허용 도메인에 포함되어 있는지 확인합니다.
  • 로그인 callback URL이 배포 도메인 기준으로 돌아오는지 확인합니다.
  • 로그인 직후 callback 응답의 user만 믿지 말고 GET /me를 다시 호출합니다.
  • 상품 상세 응답에서 variant_id, price, min_quantity, 이미지 URL을 확인합니다.
  • 게스트 장바구니가 새로고침 후에도 유지되는지 확인합니다.
  • 주문 생성 payload를 실제 상품 상세/장바구니 응답에서 만든 값으로 보냅니다.
  • 결제 초기화 응답의 form payload가 정상 submit되는지 확인합니다.
  • 결제 성공/실패 redirect query를 모두 처리합니다.
  • 401, 403, 422 오류를 사용자에게 이해 가능한 문구로 표시합니다.
https://api-docs.runmoa.ai/llms-full.txt 와 /openapi.yaml 를 기준으로 정적 React 스토어프론트를 만들어줘.
반드시 /payments/initialize 응답의 payment form payload를 submit해.
상품 목록 응답은 data/products/items envelope 후보를 모두 처리해.
로그인 callback 후에는 /me를 다시 호출해서 사용자 정보를 표시해.