콘텐츠로 이동

상품과 콘텐츠 조회

상품과 콘텐츠 조회 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장바구니용 콘텐츠 미리보기필요 없음

/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 /productssearch 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/12001
X-Runmoa-Site-Key: moa_pub_xxxxxxxxx
Accept: 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_typedropdown, radio, color_swatch 중 하나입니다.
  • option_value_display_settings: 옵션값별 보조 설정입니다. attribute_indexvalue_text로 옵션값을 연결하고, display_label, color_hex, image_url, sort_order를 우선 적용합니다.
  • buyer_input_options: 받는 분 성함, 배송 희망일, 문구처럼 구매자가 입력해야 하는 항목입니다. status: "active" 항목만 표시하고 is_requiredmax_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가 있으면 각 항목의 IDoption_id로 사용합니다.
  • all_options가 없으면 class.curriculums[].option_idoption_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.curriculumsclass.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-preview
X-Runmoa-Site-Key: moa_pub_xxxxxxxxx
Content-Type: application/json
Accept: 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()),
]);
  • 목록은 classes.data에서 읽습니다.
  • 상세는 class.default에서 읽습니다.
  • 옵션 선택값은 콘텐츠 ID가 아니라 all_options[].ID 또는 curriculums[].option_id 입니다.
  • 장바구니와 주문 payload에서도 같은 option ID를 사용합니다.
  • storefront 콘텐츠 목록 필터는 type을 사용합니다.

상품/콘텐츠 생성과 수정은 이 페이지의 스토어프론트 API가 아니라 서버용 비공개 API에서 처리합니다.