AI 스토어프론트 빌드 가이드
런모아 API로 정적 스토어프론트를 구현할 때 필요한 엔드포인트, 호출 순서, 응답 규칙입니다. 상품 스토어와 콘텐츠 스토어는 응답 구조가 다르므로 별도로 구현합니다.
필요한 설정값
섹션 제목: “필요한 설정값”정적 프론트엔드는 아래 값이 있어야 동작합니다.
| 이름 | 예시 | 위치 |
|---|---|---|
| API base URL | https://shleetest00080.runmoa.com/api/storefront/v1 | 프론트엔드 설정 |
| 스토어프론트 키 | moa_pub_xxxxxxxxx | 프론트엔드 설정 |
| 로그인 callback URL | https://storefront.example.com/auth/callback | 앱 라우트 |
| 결제 결과 URL | https://storefront.example.com/payment-result | 앱 라우트 |
브라우저에는 moa_pub_... 형식의 스토어프론트 키만 넣습니다. 서버용 비공개 키는 정적 프론트엔드에 넣지 않습니다.
구현 순서
섹션 제목: “구현 순서”GET /site로 사이트 연결과 키를 확인합니다.GET /product-tags,GET /products로 홈과 컬렉션을 만듭니다.GET /products/{productId}로 상세 페이지, 이미지, 옵션, 최소 구매 수량을 만듭니다.GET /guest-cart,POST /guest-cart,DELETE /guest-cart/{itemId}로 게스트 장바구니를 만듭니다.GET /auth/schoolmoa-client와 Runmoa 로그인 URL로 로그인 시작을 만듭니다.POST /auth/schoolmoa/callback후 token을 저장하고GET /me로 사용자 정보를 다시 조회합니다.- 로그인 후 게스트 장바구니가 있으면
POST /cart/merge-guest로 로그인 장바구니에 병합합니다. - 로그인 사용자 기준으로
POST /orders를 호출해 주문을 생성합니다. POST /payments/initialize응답의paymentform payload를 그대로 submit합니다. 결제는 표준 NicePay 카드결제만 사용합니다.- 결제 결과 URL의 query를 읽어 결과 화면을 렌더링합니다.
GET /me/orders,GET /me/order-lines,GET /me/contents/all/all로 마이페이지를 만듭니다.- 구매 후 콘텐츠 열기는
POST /me/content-entitlements/{lineItemId}/open, 구매확정/취소/환불/반품/교환은POST /me/order-lines/{lineItemId}/actions, 후기는POST /me/order-lines/{lineItemId}/review를 사용합니다.
콘텐츠 storefront 전용 구현 순서
섹션 제목: “콘텐츠 storefront 전용 구현 순서”콘텐츠 판매 스토어는 상품 스토어와 다른 응답 구조를 사용합니다.
GET /content-tags또는GET /content-categories로 탐색 기준을 만듭니다.GET /contents?type=offline&page=1&limit=12처럼typequery로 목록을 만듭니다.- 목록 응답은
contents가 아니라classes.data에서 읽습니다. GET /contents/{contentId}상세 응답은class.default에서 읽습니다.- 옵션 선택은
class.default.all_options[].ID를 우선 사용하고,all_options가 없으면class.curriculums[].option_id를 사용합니다. - 옵션명과 가격은
class.curriculums[].title,class.curriculums[].price,class.curriculums[].sale_price를 함께 확인합니다. offline,live는 과거 일정 옵션을 판매 가능 목록에서 제외합니다. 보통duration_end < now이면 마감이고,duration_end가 없으면duration_start기준으로 판단합니다.POST /contents/cart-preview에options_ids를 보내 장바구니 표시용 제목, 설명, 가격을 확인할 수 있습니다.- 장바구니 추가는 상품처럼 variant가 아니라
POST /guest-cart또는POST /cart에data: { "{optionId}": 1 }형태로 보냅니다. - 주문 생성은
POST /orders에서old_data: [{ ID: optionId }]를 사용합니다. - 결제는
POST /payments/initialize응답의 form payload를 submit합니다. - 구매 후 목록과 액션 UI는
GET /me/order-lines를 사용합니다. - 구매 후 콘텐츠 열기는
POST /me/content-entitlements/{lineItemId}/open을 사용합니다. 공개 상세 화면과 구매 후 열기 화면을 같은 데이터로 처리하지 않습니다.
공통 fetch wrapper
섹션 제목: “공통 fetch wrapper”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 유지를 위해 필요합니다.
응답 envelope 처리
섹션 제목: “응답 envelope 처리”목록 응답은 아래 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 [];}값 정규화 helper
섹션 제목: “값 정규화 helper”응답 값은 숫자와 숫자 문자열이 섞일 수 있습니다. 가격, 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 } ]}End-to-end 예시: 상품 구매
섹션 제목: “End-to-end 예시: 상품 구매”아래 순서대로 상품 구매 흐름을 구현합니다.
GET /products?tag_id={tagId}&page=1&limit=24로 목록을 렌더링합니다.- 사용자가 상품 카드를 열면
GET /products/{productId}를 호출합니다. - 상세 응답에서
variants[0].id같은 선택 가능한variant.id를 고릅니다. - 비로그인 상태면
POST /guest-cart에 variant 기준 payload를 보냅니다. - 로그인 후에는
POST /cart/merge-guest후GET /cart를 사용합니다. - 결제 버튼에서는
POST /orders로 pending 주문을 만듭니다. - 이어서
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[].id와variant.id는 variant idproduct_id는 상품 id- 둘을 섞으면 주문 생성에서
422가 납니다.
End-to-end 예시: 콘텐츠 구매
섹션 제목: “End-to-end 예시: 콘텐츠 구매”콘텐츠 storefront는 상품 흐름을 그대로 재사용하면 안 됩니다.
GET /contents?type=offline&page=1&limit=12로 카드 목록을 만듭니다.- 사용자가 콘텐츠를 열면
GET /contents/{contentId}를 호출합니다. class.default.all_options[].ID를 우선 읽고, 없으면class.curriculums[].option_id를 읽습니다.- 필요하면
POST /contents/cart-preview로 선택 옵션 제목/가격을 확인합니다. - 장바구니 추가는
POST /guest-cart또는POST /cart에data: { "{optionId}": 1 }형태로 보냅니다. - 주문 생성은
POST /orders에서old_data: [{ ID: optionId }]를 사용합니다. - 결제는 상품과 동일하게
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" } }}주의:
55102는 contentId가 아니라 optionIdoffline,live는 지난 일정 옵션을 판매 가능 목록에서 제외- 구매 후 화면은 공개 상세가 아니라
GET /me/content-items/{contentId}기준으로 구현
오류 처리 cookbook
섹션 제목: “오류 처리 cookbook”외부 storefront는 상태 코드별로 아래처럼 처리하면 됩니다.
401 Unauthorized
섹션 제목: “401 Unauthorized”의미:
- 사용자 토큰 없음
- 사용자 토큰 만료
- 로그인 필요한 API를 비로그인 상태에서 호출
처리:
- 저장된 토큰 제거
- 로그인 모달 또는 로그인 페이지 열기
- 현재 경로를 저장했다가 callback 후 복귀
if (error.status === 401) { localStorage.removeItem('runmoa_user_token'); openLoginModal();}403 Forbidden
섹션 제목: “403 Forbidden”의미:
- storefront key 도메인 제한 불일치
- 게시판/댓글 권한 부족
- 구매/접근 권한이 없는 콘텐츠 또는 댓글 권한 부족
처리:
- 사용자 액션 권한 부족이면 UI를 숨기고 안내 문구 표시
- 전역 API 호출에서 발생하면 사이트 관리자에게 키/도메인 확인 요청
if (error.status === 403) { showNotice(error.payload?.message || '권한이 없습니다.');}422 ValidationError
섹션 제목: “422 ValidationError”의미:
- 주문 payload 잘못됨
- 최소 구매 수량 미달
- variant id / option id 잘못됨
- 배송지 필수값 누락
처리:
message와errors를 그대로 폼 근처에 표시- 수량 관련이면 상품 상세의
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_host와moa_pub_...키로GET /site가 성공하는지 확인합니다. - 배포 도메인이 스토어프론트 키 허용 도메인에 포함되어 있는지 확인합니다.
- 로그인 callback URL이 배포 도메인 기준으로 돌아오는지 확인합니다.
- 로그인 직후 callback 응답의
user만 믿지 말고GET /me를 다시 호출합니다. - 상품 상세 응답에서
variant_id,price,min_quantity, 이미지 URL을 확인합니다. - 게스트 장바구니가 새로고침 후에도 유지되는지 확인합니다.
- 주문 생성 payload를 실제 상품 상세/장바구니 응답에서 만든 값으로 보냅니다.
- 결제 초기화 응답의 form payload가 정상 submit되는지 확인합니다.
- 결제 성공/실패 redirect query를 모두 처리합니다.
401,403,422오류를 사용자에게 이해 가능한 문구로 표시합니다.
AI 입력 예시
섹션 제목: “AI 입력 예시”https://api-docs.runmoa.ai/llms-full.txt 와 /openapi.yaml 를 기준으로 정적 React 스토어프론트를 만들어줘.반드시 /payments/initialize 응답의 payment form payload를 submit해.상품 목록 응답은 data/products/items envelope 후보를 모두 처리해.로그인 callback 후에는 /me를 다시 호출해서 사용자 정보를 표시해.