상품과 콘텐츠 조회
상품과 콘텐츠 조회 API는 사용자 토큰 없이 호출할 수 있습니다. 접근 권한 확인이나 개인화된 보유 상품/콘텐츠 조회는 사용자 토큰이 필요합니다.
공개 조회
섹션 제목: “공개 조회”| 메서드 | 경로 | 설명 | 사용자 토큰 |
|---|---|---|---|
| GET | /product-categories | 상품에서 사용 중인 카테고리 조회 | 필요 없음 |
| GET | /product-categories/search | 상품 카테고리 검색 | 필요 없음 |
| GET | /product-tags | 노출 상품에서 사용 중인 태그 조회 | 필요 없음 |
| GET | /products | 상품 목록 조회 | 필요 없음 |
| GET | /products/{productId} | 상품 상세 조회 | 필요 없음 |
| GET | /content-categories | 콘텐츠에서 사용 중인 카테고리 조회 | 필요 없음 |
| GET | /content-categories/search | 콘텐츠 카테고리 검색 | 필요 없음 |
| GET | /content-tags | 노출 콘텐츠에서 사용 중인 태그 조회 | 필요 없음 |
| GET | /contents | 콘텐츠 목록 조회 | 필요 없음 |
| GET | /contents/{contentId} | 콘텐츠 상세 조회 | 필요 없음 |
| GET | /contents/{contentId}/summary | 콘텐츠 요약 조회 | 필요 없음 |
| GET | /contents/{contentId}/description | 콘텐츠 설명 조회 | 필요 없음 |
| POST | /contents/bulk | 여러 콘텐츠 조회 | 필요 없음 |
| POST | /contents/cart-preview | 장바구니용 콘텐츠 미리보기 | 필요 없음 |
목록 필터 Query
섹션 제목: “목록 필터 Query”/products와 /contents는 외부 스토어프론트에서 쓰기 쉬운 이름의 필터를 지원합니다.
| 이름 | 대상 | 설명 |
|---|---|---|
page | 상품, 콘텐츠 | 페이지 번호 |
limit | 상품, 콘텐츠 | 페이지당 개수 |
search | 상품, 콘텐츠 | 이름 또는 제목 검색 |
category_id | 상품, 콘텐츠 | 단일 카테고리 ID |
category_ids | 상품, 콘텐츠 | 쉼표 문자열 또는 배열. 여러 카테고리 중 하나에 속한 항목 조회 |
tag_id | 상품, 콘텐츠 | 단일 태그 ID |
tag_ids | 상품, 콘텐츠 | 쉼표 문자열 또는 배열. 여러 태그 중 하나가 붙은 항목 조회 |
orderby | 상품, 콘텐츠 | 정렬 방식. 최신순 목록은 newest를 사용 |
type | 콘텐츠 | vod, live, offline, digital_content |
카테고리와 태그는 먼저 /product-categories, /product-tags, /content-categories, /content-tags로 조회한 뒤 목록 필터에 넣습니다.
콘텐츠 목록의 storefront query name은 content_type이 아니라 type입니다.
사용자 기준 조회
섹션 제목: “사용자 기준 조회”| 메서드 | 경로 | 설명 | 사용자 토큰 |
|---|---|---|---|
| GET | /me/contents/{type}/{status} | 내 기준 콘텐츠 목록. 처음에는 /me/contents/all/all로 시작할 수 있습니다. | 필수 |
상품 검색 예시
섹션 제목: “상품 검색 예시”상품 검색, 검색 결과 페이지, 상단 검색창은 모두 GET /products의 search query를 사용합니다. 브라우저에서는 서버용 비공개 상품 API가 아니라 이 스토어프론트 경로를 호출합니다.
const query = '러닝';
const products = await fetch( `${API_BASE}/products?search=${encodeURIComponent(query)}&page=1&limit=24`, { headers: { 'X-Runmoa-Site-Key': STOREFRONT_KEY, Accept: 'application/json', }, },).then((response) => response.json());응답은 페이지네이션 형태입니다. 보통 products.data를 상품 카드 목록으로 렌더링하고, products.total 또는 products.meta.total이 있으면 검색 결과 개수로 표시합니다.
대표 상품 목록 응답:
{ "products": { "data": [ { "ID": 12001, "id": 12001, "name": "러닝 재킷", "thumbnail_link": "https://cdn.example.com/products/running-jacket.jpg", "main_image": { "image_url": "https://cdn.example.com/products/running-jacket-main.jpg" }, "price": [ { "base_price": 59000, "sale_price": 49000, "price": 49000, "currency": "KRW", "is_on_sale": 1, "variant_id": 88421 } ], "variants": [ { "id": 88421, "product_id": 12001, "label": "Black / M", "quantity": 12 } ], "sourcing_info": { "min_quantity": 1, "unit_quantity": 1, "shipping_fee": 3000, "chargeable_shipping_fee": 3000, "shipping_pay_label": "선불" } } ], "current_page": 1, "total": 1 }}상품 응답에서 자주 쓰는 필드
섹션 제목: “상품 응답에서 자주 쓰는 필드”상품 카드와 상세 화면은 응답 구조가 사이트 설정과 상품 종류에 따라 달라질 수 있습니다. 아래 필드를 우선순위대로 확인합니다.
| 목적 | 확인할 필드 |
|---|---|
| 대표 이미지 | thumbnail_link, main_image.image_url, mainImage.image_url, details.main_image.image_url, details.images |
| 추가 이미지 | additional_images, sub_images, details.additional_images, details.sub_images |
| 옵션 | variants, details.variants, attributes, details.attributes, option_display_settings, option_value_display_settings |
| 구매자 입력 항목 | buyer_input_options, details.buyer_input_options |
| 최소 구매 수량 | sourcing_info.min_quantity, details.sourcing_info.min_quantity |
| 묶음 구매 수량 | sourcing_info.unit_quantity, details.sourcing_info.unit_quantity |
| 배송비 | shippingDetails, shipping_details, details.shippingDetails, details.shipping_details, sourcing_info.shipping_fee, sourcing_info.chargeable_shipping_fee |
배송비 표시는 상품별 응답 값을 사용자에게 안내하는 용도입니다. 실제 주문 금액과 배송비는 주문 생성 시 서버가 다시 계산합니다.
상품 카드 정규화 예시:
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 productCard(product) { const price = Array.isArray(product.price) ? product.price[0] : product.price; const variant = Array.isArray(product.variants) ? product.variants[0] : null;
return { id: toNumber(firstPresent(product.ID, product.id), null), title: firstPresent(product.name, product.title, product.translation?.name), imageUrl: firstPresent( product.thumbnail_link, product.main_image?.image_url, product.mainImage?.image_url, Array.isArray(product.img) ? product.img[0] : null, ), price: toNumber(firstPresent(price?.sale_price, price?.price, price?.base_price)), variantId: toNumber(firstPresent(variant?.id, price?.variant_id), null), minimumQuantity: Math.max(1, toNumber(product.sourcing_info?.min_quantity, 1)), };}상품 상세 응답 예시
섹션 제목: “상품 상세 응답 예시”상품 상세 페이지는 목록 응답보다 더 많은 이미지, 설명, 옵션 정보를 받을 수 있습니다. 아래 예시는 대표 구조이며 사이트 설정에 따라 일부 필드는 없을 수 있습니다.
GET /api/storefront/v1/products/12001X-Runmoa-Site-Key: moa_pub_xxxxxxxxxAccept: application/json{ "product": { "product": { "ID": 12001, "id": 12001, "name": "러닝 재킷" }, "translation": { "name": "러닝 재킷", "description": "<p>가벼운 트레이닝 재킷입니다.</p>" }, "main_image": { "image_url": "https://cdn.example.com/products/running-jacket-main.jpg" }, "additional_images": [ "https://cdn.example.com/products/running-jacket-side.jpg", "https://cdn.example.com/products/running-jacket-back.jpg" ], "price": [ { "base_price": 59000, "sale_price": 49000, "price": 49000, "currency": "KRW", "is_on_sale": 1, "variant_id": 88421 } ], "variants": [ { "id": 88421, "product_id": 12001, "label": "Black / M", "is_active": true, "attributesArray": [ { "name": "Color", "value": "Black" }, { "name": "Size", "value": "M" } ], "quantity": 12 }, { "id": 88422, "product_id": 12001, "label": "Black / L", "quantity": 8 } ], "sourcing_info": { "min_quantity": 1, "unit_quantity": 1, "shipping_fee": 3000, "chargeable_shipping_fee": 3000, "shipping_pay_label": "선불" } }}장바구니와 주문 생성에서는 선택한 옵션의 variant.id를 key로 사용합니다. 상품 variant에는 product_id가 없을 수 있으므로, 프론트엔드는 상세 상품의 product.ID 또는 product.id를 함께 보관해야 합니다.
상품 옵션 표시와 구매자 입력
섹션 제목: “상품 옵션 표시와 구매자 입력”상품 상세의 product 객체에는 기본 옵션 조합 외에도 아래 필드가 내려올 수 있습니다.
option_display_settings: 옵션별 표시 설정입니다.attribute_index는 0부터 시작하는 옵션 위치이고,display_type은dropdown,radio,color_swatch중 하나입니다.option_value_display_settings: 옵션값별 보조 설정입니다.attribute_index와value_text로 옵션값을 연결하고,display_label,color_hex,image_url,sort_order를 우선 적용합니다.buyer_input_options: 받는 분 성함, 배송 희망일, 문구처럼 구매자가 입력해야 하는 항목입니다.status: "active"항목만 표시하고is_required및max_length를 클라이언트에서도 먼저 검증합니다.
옵션 선택 UI는 options 배열 순서대로 렌더링합니다. 각 단계에서 선택 가능한 variant를 좁히고, variants[].is_active === false인 조합은 선택할 수 없게 표시합니다. 재고 제한 여부와 최종 구매 가능 여부는 장바구니 요청 시 서버가 다시 검증합니다. 옵션값의 실제 비교값은 variant의 attributesArray[].value를 사용합니다.
buyer_input_options가 있으면 장바구니 요청에 해당 항목의 ID를 key로 넣습니다. 서버가 활성 항목, 필수 여부, 글자 수를 다시 검증합니다.
{ "data": { "88421": { "product_id": 12001, "quantity": 1, "buyer_inputs": { "341": "홍길동", "342": "2026-07-20" } } }}콘텐츠 상세에서 옵션 선택
섹션 제목: “콘텐츠 상세에서 옵션 선택”콘텐츠는 상품처럼 variant.id를 고르는 구조가 아닙니다. offline, live, digital_content 타입은 콘텐츠 옵션 option_id 를 골라서 장바구니에 담아야 합니다.
특히 storefront 콘텐츠 응답은 상품 응답과 envelope가 다릅니다.
- 목록:
classes.data - 상세:
class.default - 옵션 선택용 목록:
class.default.all_options - 옵션 fallback:
class.curriculums[].option_id - 커리큘럼/회차 정보:
class.curriculums - 오프라인/라이브 일정 요약:
class.default.option_details
콘텐츠 화면은 이 구조를 기준으로 구현합니다. product, products.data, variants 같은 상품용 경로를 콘텐츠에 재사용하면 안 됩니다.
중요한 점은 사이트마다 구매 가능한 콘텐츠 옵션이 두 군데 중 한 곳에 들어올 수 있다는 것입니다.
class.default.all_options가 있으면 각 항목의ID를option_id로 사용합니다.all_options가 없으면class.curriculums[].option_id를option_id로 사용합니다.- 옵션명과 가격은
class.curriculums[].title,class.curriculums[].price,class.curriculums[].sale_price를 함께 확인합니다. class.default.option_details는 일정, 다운로드 기간 같은 보조 정보입니다.offline,live타입은 지난 일정 옵션을 판매 가능 목록에서 제외해야 합니다. 보통duration_end < now이면 마감 처리하고,duration_end가 없으면duration_start기준으로 판단합니다.
콘텐츠 목록 예시
섹션 제목: “콘텐츠 목록 예시”콘텐츠 스토어는 먼저 GET /contents로 카드 목록을 만들고, 필터는 type, tag_id, category_id, search를 사용합니다.
const contents = await fetch( `${API_BASE}/contents?type=offline&tag_id=8&page=1&limit=12`, { headers: { 'X-Runmoa-Site-Key': STOREFRONT_KEY, Accept: 'application/json', }, },).then((response) => response.json());
const cards = contents.classes?.data ?? [];대표 목록 응답:
{ "classes": { "data": [ { "ID": 6401, "type": "offline", "title": "브랜드 포지셔닝 워크숍", "thumbnail_link": "https://cdn.example.com/contents/brand-positioning-thumb.jpg", "img": [ "https://cdn.example.com/contents/brand-positioning-main.jpg" ], "options": { "ID": 55102, "class_id": 6401, "price": 120000, "sale_price": 99000, "is_on_sale": 1 }, "all_options": [ { "ID": 55102, "class_id": 6401, "price": 120000, "sale_price": 99000, "is_on_sale": 1 } ], "review_count": 3, "average_rating": 4.7 } ], "current_page": 1, "total": 1 }}콘텐츠 카드에서는 아래 순서로 필드를 확인합니다.
| 목적 | 확인할 필드 |
|---|---|
| 제목 | title, name |
| 대표 이미지 | thumbnail_link, img[0] |
| 대표 가격 | options.sale_price, options.price |
| 타입 | type |
| 평점/리뷰수 | average_rating, review_count |
콘텐츠 카드 정규화 예시:
function contentCard(item) { const option = item.options ?? item.all_options?.[0] ?? {};
return { id: toNumber(firstPresent(item.ID, item.id), null), title: firstPresent(item.title, item.name), type: item.type, imageUrl: firstPresent( item.thumbnail_link, Array.isArray(item.img) ? item.img[0] : null, item.main_image?.image_url, ), price: toNumber(firstPresent(option.sale_price, option.price)), optionId: toNumber(firstPresent(option.ID, option.option_id), null), };}콘텐츠 상세 예시
섹션 제목: “콘텐츠 상세 예시”const payload = await fetch(`${API_BASE}/contents/6401`, { headers: { 'X-Runmoa-Site-Key': STOREFRONT_KEY, Accept: 'application/json', },}).then((response) => response.json());
const detail = payload.class?.default;const curriculums = payload.class?.curriculums ?? [];const optionList = detail?.all_options?.length ? detail.all_options.map((option) => ({ optionId: option.ID, title: option.title ?? null, price: option.sale_price || option.price || null, })) : curriculums.map((curriculum) => ({ optionId: curriculum.option_id, title: curriculum.title, price: curriculum.sale_price || curriculum.price || null, }));대표 상세 응답:
{ "class": { "default": { "ID": 6401, "type": "offline", "title": "브랜드 포지셔닝 워크숍", "description": "<p>브랜드 핵심 메시지를 정리하는 오프라인 워크숍입니다.</p>", "img": [ "https://cdn.example.com/contents/brand-positioning-main.jpg" ], "additional_images": [ "https://cdn.example.com/contents/brand-positioning-detail-1.jpg" ], "duration_start": "2026-07-12 14:00:00", "duration_end": "2026-07-12 18:00:00", "option_details": [ { "title": "7월 12일 토요일 14:00", "duration_start": "2026-07-12 14:00:00", "duration_end": "2026-07-12 18:00:00" } ], "all_options": [ { "ID": 55102, "class_id": 6401, "price": 120000, "sale_price": 99000, "is_on_sale": 1 }, { "ID": 55103, "class_id": 6401, "price": 150000, "sale_price": 129000, "is_on_sale": 1 } ] }, "curriculums": [ { "ID": 55102, "title": "7월 12일 토요일 14:00", "description": "서울 오프라인 진행", "price": 99000, "sale_price": 99000 } ], "chapters": [], "exchangeRates": { "KRW": 1 } }}스토어프론트는 class.default.all_options를 우선 사용하고, 이 값이 없으면 class.curriculums[].option_id를 구매 선택값으로 사용합니다. 옵션명, 가격, 일정 설명은 class.curriculums와 class.default.option_details를 함께 붙여서 보여주면 됩니다.
즉, 이 콘텐츠를 장바구니에 담을 때 선택값은 55102 또는 55103 같은 옵션 ID 입니다.
구매 가능한 옵션 목록은 아래처럼 만듭니다.
function purchasableContentOptions(detailPayload, now = new Date()) { const detail = detailPayload.class?.default ?? {}; const curriculums = detailPayload.class?.curriculums ?? []; const byOptionId = new Map(curriculums.map((item) => [String(item.option_id ?? item.ID), item])); const sourceOptions = detail.all_options?.length ? detail.all_options : curriculums;
return sourceOptions .map((option) => { const optionId = option.ID ?? option.option_id; const curriculum = byOptionId.get(String(optionId)) ?? {}; const endAt = option.duration_end ?? curriculum.duration_end ?? detail.duration_end; const startAt = option.duration_start ?? curriculum.duration_start ?? detail.duration_start; const compareAt = endAt ?? startAt; const isPast = compareAt ? new Date(compareAt).getTime() < now.getTime() : false;
return { optionId: toNumber(optionId, null), title: firstPresent(option.title, curriculum.title, detail.title), price: toNumber(firstPresent(option.sale_price, option.price, curriculum.sale_price, curriculum.price)), startsAt: startAt ?? null, endsAt: endAt ?? null, isPurchasable: !isPast, }; }) .filter((option) => option.optionId && option.isPurchasable);}콘텐츠 장바구니 미리보기
섹션 제목: “콘텐츠 장바구니 미리보기”콘텐츠 상세에서 옵션을 고른 뒤 장바구니/구매 버튼 직전에 미리보기 데이터를 만들고 싶다면 POST /contents/cart-preview를 사용합니다. 이 경로도 상품 variant가 아니라 콘텐츠 option_id 배열을 받습니다.
POST /api/storefront/v1/contents/cart-previewX-Runmoa-Site-Key: moa_pub_xxxxxxxxxContent-Type: application/jsonAccept: application/json
{ "options_ids": [55102]}여러 오프라인 클래스나 라이브 세션을 한 번에 확인할 때는 options_ids에 여러 값을 넣으면 됩니다.
이 응답은 장바구니 drawer에 넣기 전, 선택한 옵션의 제목/설명/가격을 최종 확인하는 용도로 쓰면 됩니다.
태그 상품 목록 예시
섹션 제목: “태그 상품 목록 예시”태그별 컬렉션 페이지는 먼저 /product-tags로 태그 ID를 찾은 뒤 tag_id 또는 tag_ids로 /products를 필터링합니다.
/product-tags 응답은 보통 아래처럼 tags 배열을 반환합니다.
{ "tags": [ { "id": 17, "name": "상의", "slug": "tops", "target_type": "product" } ]}const products = await fetch(`${API_BASE}/products?page=1&tag_id=17`, { headers: { 'X-Runmoa-Site-Key': STOREFRONT_KEY, Accept: 'application/json', },}).then((response) => response.json());카테고리/태그 조회 예시
섹션 제목: “카테고리/태그 조회 예시”const [categories, tags] = await Promise.all([ fetch(`${API_BASE}/product-categories`, { headers: { 'X-Runmoa-Site-Key': STOREFRONT_KEY, Accept: 'application/json' }, }).then((response) => response.json()), fetch(`${API_BASE}/product-tags`, { headers: { 'X-Runmoa-Site-Key': STOREFRONT_KEY, Accept: 'application/json' }, }).then((response) => response.json()),]);콘텐츠 storefront 구현 규칙
섹션 제목: “콘텐츠 storefront 구현 규칙”- 목록은
classes.data에서 읽습니다. - 상세는
class.default에서 읽습니다. - 옵션 선택값은 콘텐츠 ID가 아니라
all_options[].ID또는curriculums[].option_id입니다. - 장바구니와 주문 payload에서도 같은 option ID를 사용합니다.
- storefront 콘텐츠 목록 필터는
type을 사용합니다.
상품/콘텐츠 생성과 수정은 이 페이지의 스토어프론트 API가 아니라 서버용 비공개 API에서 처리합니다.