상품

상세 블록 렌더링

상품 상세의 content 를 쇼핑몰 화면에 안전하게 그리는 방법이에요.

v1 API 상세: 상품

상품 상세의 content에는 판매자가 저장한 완성된 상세 HTML​​이 들어 있어요. 사진을 보여줄 때 images나 content_images만 읽으면 블록 본문 중간의 사진이 빠져요. content를 일반 문자열로 표시하거나 HTML 태그를 모두 제거해도 사진·표·문답·글 배치가 사라져요.

쇼핑몰은 content를 안전하게 렌더링하고, 현재 블록 CSS를 함께 적용​​해요. content_blocks를 받아 별도 편집기나 컴파일러를 다시 만드는 것은 상품 조회 화면에 필요한 작업이 아니에요.

API 필드와 우선순위

필드 공개 응답 타입 역할
images String[] 상단 대표 이미지·갤러리. 상세 본문을 대신하지 않아요.
desc String 상품 요약. 상세 본문과 별도예요.
content String 블록/HTML 편집 결과. 글·이미지·표의 표시 순서가 이미 들어 있어요.
content_type String 공개 응답에서 block, image를 확인했어요. 내부 모델 값 cot=4/2/3과 혼동하지 않아요. 렌더 우선순위를 이 필드만으로 결정하지 않아요.
content_blocks Object[] 편집 원본. 블록마다 id, type과 유형별 값이 있어요. 상세 응답에 포함될 수 있으며 목록에서는 보장하지 않아요.
content_images String[] 이미지 편집 방식의 상세 이미지. content가 없을 때 배열 순서로 표시해요.
  1. 안전하게 변환한 content에 표시할 글이나 이미지가 있으면 그대로 표시해요.
  2. content가 없거나 표시 가능한 요소가 하나도 없으면 content_images를 순서대로 표시해요.
  3. 둘 다 없으면 등록된 상품 요약이나 빈 상세 안내를 보여줘요.

content와 content_images를 무조건 합치지 않아요. 전환 전 이미지가 남아 있으면 같은 상품 사진이 중복되거나 판매자가 정한 순서가 바뀌어요. content_images=[]여도 HTML 안에 사진이 있으면 정상 상세예요.

2026-09-14 운영 상점의 13개 상품을 GET 조회만으로 확인했어요. 10개는 content_type:"block", 3개는 "image"였어요. 11개 상품의 content에 6–16장의 이미지가 있었고, 이미지 방식 한 상품에도 완성된 HTML과 과거 content_images가 함께 있었어요. 나머지 두 상품은 본문 HTML 없이 content_images에 각각 13장과 3장이 있었어요. 이 숫자는 확인한 상점의 예시이며 API의 고정 개수가 아니에요.

블록 원본과 완성 HTML

다음은 구조를 보여주는 축약 예시예요. 실제 상품 사진은 API가 반환하는 절대 HTTPS URL을 그대로 사용해요.

{
  "product_id": "67c95e64d01640bb9859c629",
  "content_type": "block",
  "content_blocks": [
    { "id": "intro", "type": "text", "html": "소재와 사용 방법을 소개해요." },
    { "id": "photo", "type": "image", "items": [{ "url": "https://example.com/product-middle.jpg", "alt": "상품의 옆면" }], "ratio": "origin", "layout": "stack" },
    { "id": "outro", "type": "text", "html": "부드러운 천으로 닦아 주세요." }
  ],
  "content": "<div class=\"bp-detail\"><div class=\"bp-text description-text\">소재와 사용 방법을 소개해요.</div><div class=\"bp-images bp-ratio-origin bp-images-stack\"><figure class=\"bp-image\"><img src=\"https://example.com/product-middle.jpg\" alt=\"상품의 옆면\" loading=\"lazy\" style=\"max-width:100%;height:auto\"></figure></div><div class=\"bp-text description-text\">부드러운 천으로 닦아 주세요.</div></div>",
  "content_images": []
}json

블록 저장 시 서버 컴파일러가 원본 배열 순서대로 HTML을 만들고 .bp-detail로 감싸요. {{상품명}}, {{판매가}}, {{배송비}} 토큰과 공용 안내 블록의 sharedId는 서버 저장/재컴파일에서 해석돼요. subscriptionPlan, policyBadges는 상품·배송 정책 문맥으로 만들어져요. 쇼핑몰에서 블록 원본만 다시 조립하면 이 값이 누락되거나 표시 내용이 달라질 수 있어요.

CSS와 이미지 자산

완성 HTML 안쪽 .bp-detail 바깥에 .bp-detail-view.is-responsive​​를 추가해요. CSS를 빼면 사진만 보이고 커버, 좌우 배치, 이미지 그리드, 표, 배경 면의 모양은 달라져요.

현재 구현에서 확인한 공용 소스는 bootservice/vendor의 두 파일이에요.

  • stylesheets/product-detail.css
  • stylesheets/product-detail.contract.json

commerce-front/src/vendor/stylesheets/, invoice-frontend/app/vendor/stylesheets/, 관리자 vendor가 이 자산을 사용해요. Next.js 앱에는 두 파일을 같은 스냅샷으로 src/vendor/product-detail/에 포함하고 아래 컴포넌트에서 CSS를 import해요. 별도의 이미지 리소스 묶음은 필요 없어요. 상품 사진은 API URL을 사용하고 내장 라인 아이콘은 CSS의 SVG mask에 들어 있어요. 웹폰트는 쇼핑몰의 기존 폰트 정책을 따라요.

이 구현이 사용한 vendor commit은 62b4f8ec23f3e97a9335f363b994691ecd453a81, CSS SHA-256은 a6fa3af410e4cfc05dc35e4deeef536ba81079639cb45c8c24840ae756891cbd예요. 계약 JSON은 contractVersion: "1.0.0"이지만 버전 문자열만으로 구형 스냅샷과 구별되지 않으므로 commit/hash도 기록해요.

CSS 배포 경로를 확인해요

기존 @bootpay/product-detail-css@1.0.0 소스의 dist는 현재 vendor보다 오래되어 커버와 확장 블록이 부족해요. 그 README의 CDN URL도 배포 확정 주소로 간주하지 않아요. npm/CDN 배포가 확인되기 전에는 담당자에게 현재 CSS와 계약 JSON을 받아 앱에 포함해요. 제공된 두 파일 없이 복사한 JSX만으로 관리자 미리보기와 동일한 상세를 보장할 수 없어요.

래퍼 bp-detail-view만 쓰면 좁은 화면 모양, is-pc를 추가하면 PC 모양 고정, 실제 쇼핑몰은 is-responsive​​로 768px 이상에서 PC 배치를 적용해요. 판매자 테마/색상 설정을 연결할 때는 바깥 래퍼의 bp-theme-editorial, bp-theme-mono와 --bp-detail-link, --bp-detail-radius-scale, --bp-detail-scale만 검증 후 조정해요. 내부 블록 순서나 원본 사진 URL을 변경하지 않아요. imageText.align의 left/right는 공간이 있으면 좁은 화면에서도 좌우를 유지하고 부족하면 줄바꿈해요. 사진 위·글 아래를 지정한 top은 세로 배치예요. 모든 블록이 모바일에서 무조건 세로로 바뀐다고 가정하지 않아요.

Next.js 서버에서 표시 필드만 전달하기

Node.js SDK는 서버에서만 생성해요. 아래 예제는 @bootpay/backend-js@2.13.1의 실제 product.productDetail(productId) 호출과 감싸지 않은 응답을 기준으로 해요.

// src/lib/product-detail.ts — 서버 전용
import 'server-only';
import { BootpayCommerce } from '@bootpay/backend-js';

const commerce = new BootpayCommerce({
  client_key: process.env.BOOTPAY_CLIENT_KEY!,
  secret_key: process.env.BOOTPAY_SECRET_KEY!,
  mode: 'production',
});

export async function getProductDetailBody(productId: string, projectId: string) {
  // 2.13.1의 선언은 일부 메서드에 legacy {success,data}가 남아 있어요.
  // 운영의 raw JSON을 unknown으로 받아 검사하고 stale SDK DTO에 맞춰 캐스팅하지 않아요.
  const response: unknown = await commerce.product.productDetail(productId);
  if (!response || typeof response !== 'object' || Array.isArray(response)) {
    throw new Error('상품 상세 응답을 확인할 수 없어요.');
  }
  const product = response as Record<string, unknown>;
  if (product.project_id !== projectId || product.product_id !== productId) {
    throw new Error('상품 소속을 확인할 수 없어요.');
  }
  if (typeof product.name !== 'string') throw new Error('상품명을 확인할 수 없어요.');
  return {
    name: product.name,
    description: typeof product.desc === 'string' ? product.desc : '',
    detailHtml: typeof product.content === 'string' ? product.content : '',
    detailImages: Array.isArray(product.content_images)
      ? product.content_images.filter((value: unknown): value is string => typeof value === 'string')
      : [],
  };
}ts

projectId는 인증된 서버 설정/프로젝트 범위 목록에서 얻어요. 브라우저가 보낸 값을 그대로 신뢰하지 않아요. 상세 응답 전체를 BFF에서 반환하면 디지털 지급 파일·접근 코드 등 서버 전용 데이터까지 전달될 수 있어요. 상품명·요약·완성 HTML·공개 이미지 등 허용한 표시 필드만 반환해요. content_blocks는 이 렌더 방식에 필요하지 않아요. 전체 상품 상세 API의 오류·권한 처리는 상품 상세를 따라요.

안전한 React 렌더러 예제

서버가 만든 content도 렌더 경계에서 검사해요. 특히 html의 source:"legacy"는 과거 원본 보존 경로가 있으므로 서버 저장이 모든 HTML을 완전히 정제했다고 가정하지 않아요. 정규식으로 태그를 제거한 뒤 dangerouslySetInnerHTML에 넣는 방식은 사용하지 않아요.

다음 구현은 브라우저의 분리된 <template>​​로 파싱한 뒤, 허용 태그·속성·계약 클래스만 React 요소로 새로 만들어요. 원본 DOM을 페이지에 붙이지 않으며 script, 이벤트 속성, 임의 style, iframe, form, SVG/MathML, 외부 클래스는 전달하지 않아요. 이미지는 HTTPS URL만, 링크는 HTTPS 새 창과 noopener noreferrer로 제한해요. 상대 URL은 어느 서버의 경로인지 불명확하므로 이 예제는 허용하지 않아요. 상대 이미지가 필요한 연동은 인증 쿠키가 닿지 않는 정적 호스트를 명시하고 URL을 검증하는 계약부터 정해요.

아래 파일과 product-detail-body.css, 앞에서 설명한 vendor 두 파일을 같은 경로에 넣어요.

"use client";

import { createElement, useMemo, useState, useSyncExternalStore, type ReactNode } from "react";
import contract from "@/vendor/product-detail/product-detail.contract.json";
import "@/vendor/product-detail/product-detail.css";
import "./product-detail-body.css";

type Product = {
  name: string;
  description?: string;
  detailHtml: string;
  detailText?: string;
  detailImages: string[];
};

function imageUrl(value: unknown): string | null {
  if (typeof value !== 'string') return null;
  try {
    const url = new URL(value);
    return url.protocol === 'https:' && !url.username && !url.password ? url.href : null;
  } catch { return null; }
}

const tags = new Set("p div section span br b strong i em u s a ul ol li h2 h3 h4 h5 h6 table caption thead tbody tfoot tr th td figure figcaption img blockquote hr details summary".split(" "));
const classes = new Set([...Object.keys(contract.classes), ...Object.keys(contract.legacyAliases)]);
const subscribe = () => () => {};
const browserSnapshot = () => true;
const serverSnapshot = () => false;

function DetailImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
  const [failed, setFailed] = useState(false);
  if (failed) return <span className="detail-image-unavailable" role="status">{alt ? `${alt} — ` : ""}이미지를 불러오지 못했어요.</span>;
  return <img src={src} alt={alt} className={className} loading="lazy" decoding="async" referrerPolicy="no-referrer" onError={() => setFailed(true)} />;
}

/**
 * Parse into an inert template, then rebuild permitted HTML as React elements.
 * Never attach the parsed DOM or spread its attributes onto a live element.
 * Server HTML is not assumed safe: events, arbitrary CSS, embeds, forms, SVG,
 * scripts, relative URLs and unsupported tags/attributes never reach the page.
 */
function renderDetailHtml(html: string, productName: string): ReactNode[] {
  if (!html || html.length > 1_000_000) return [];
  const template = document.createElement("template");
  template.innerHTML = html;
  let visited = 0;
  let meaningful = false;
  function walk(node: Node, key: string, depth: number): ReactNode {
    if (++visited > 12000 || depth > 64) throw new Error("Detail content is too complex");
    if (node.nodeType === Node.TEXT_NODE) {
      if (node.textContent?.trim()) meaningful = true;
      return node.textContent;
    }
    if (node.nodeType !== Node.ELEMENT_NODE) return null;
    const element = node as Element;
    const tag = element.localName;
    if (element.namespaceURI !== "http://www.w3.org/1999/xhtml" || !tags.has(tag)) return null;
    // Compiled meter dots use this one state class outside the bp-* contract.
    const meterDot = tag === "i" && element.parentElement?.classList.contains("bp-meter__scale");
    const className = (element.getAttribute("class") || "").split(/\s+/).filter(name => classes.has(name) || (meterDot && name === "is-on")).join(" ");
    if (tag === "img") {
      const src = imageUrl(element.getAttribute("src"));
      if (src) meaningful = true;
      return src ? <DetailImage key={key} src={src} alt={element.getAttribute("alt") || `${productName} 상세 이미지`} className={className} /> : null;
    }
    const props: Record<string, unknown> = { key, ...(className ? { className } : {}) };
    if (element.hasAttribute("title")) props.title = element.getAttribute("title");
    if (element.getAttribute("aria-hidden") === "true") props["aria-hidden"] = true;
    if (element.hasAttribute("aria-label")) props["aria-label"] = element.getAttribute("aria-label");
    if (element.getAttribute("role") === "img") props.role = "img";
    if (tag === "a") {
      const href = imageUrl(element.getAttribute("href"));
      if (href) Object.assign(props, { href, target: "_blank", rel: "noopener noreferrer" });
    }
    if (tag === "th" || tag === "td") {
      for (const [attribute, property] of [["colspan", "colSpan"], ["rowspan", "rowSpan"]]) {
        const value = Number(element.getAttribute(attribute));
        if (Number.isInteger(value) && value > 0 && value <= 100) props[property] = value;
      }
      const scope = element.getAttribute("scope");
      if (scope && ["row", "col", "rowgroup", "colgroup"].includes(scope)) props.scope = scope;
    }
    if (tag === "details" && element.hasAttribute("open")) props.open = true;
    if (tag === "br" || tag === "hr") return createElement(tag, props);
    const children = Array.from(node.childNodes).map((child, index) => walk(child, `${key}.${index}`, depth + 1));
    return createElement(tag, props, ...children);
  }
  try {
    const content = Array.from(template.content.childNodes).map((node, index) => walk(node, `${index}`, 0));
    return meaningful ? content : [];
  } catch {
    return [];
  }
}

export function ProductDetailBody({ product }: { product: Product }) {
  const browserReady = useSyncExternalStore(subscribe, browserSnapshot, serverSnapshot);
  const content = useMemo(() => browserReady ? renderDetailHtml(product.detailHtml || "", product.name) : [], [browserReady, product.detailHtml, product.name]);
  if (!browserReady) return <p className="detail-body-status" role="status">상세 설명을 불러오고 있어요.</p>;
  // Compiled content wins even when a legacy content_type still says "image".
  // Appending content_images would duplicate or reorder the merchant's layout.
  return <div className="bp-detail-view is-responsive storefront-detail-body">
    {content.length > 0 ? content : product.detailImages.length > 0 ? <div className="bp-detail"><div className="bp-images bp-ratio-origin bp-images-stack">{product.detailImages.filter(src => imageUrl(src)).map((src, index) => <figure className="bp-image" key={`${src}-${index}`}><DetailImage src={src} alt={`${product.name} 상세 이미지 ${index + 1}`} /></figure>)}</div></div> : <p className="detail-body-status">{product.detailText || product.description || "상세 설명이 아직 등록되지 않았어요. 상품 정보는 판매자에게 문의해 주세요."}</p>}
  </div>;
}
tsx

detailHtml을 통째로 텍스트로 바꾸지 않으므로 본문 중간 이미지를 같은 위치에 유지해요. React가 글자·속성 값을 escape하며, 이미지가 실패하면 해당 자리에 안내를 보여줘요. 브라우저 DOM을 쓰므로 이 예제는 클라이언트 컴포넌트예요. SSR 본문이 필요한 서비스는 검증된 서버 DOM 파서/정제기를 별도로 선택하고 동일한 태그·속성·클래스·URL 계약으로 검사해요. 브라우저 파서를 서버에서 그대로 호출하지 않아요.

.storefront-detail-body {
  min-width: 0;
  text-align: left;
  overflow-wrap: anywhere;
  --bp-detail-link: #565f42;
}
.storefront-detail-body img { display: block; max-width: 100%; height: auto; }
.storefront-detail-body figure { margin: 0; }
.storefront-detail-body table { max-width: 100%; }
.detail-body-status { color: #73766e; line-height: 1.9; white-space: pre-line; }
.detail-image-unavailable { display: block; padding: 30px 16px; color: #73766e; text-align: center; }
css

넓은 상세 영역에 컴포넌트를 배치해요. 상단 상품 안내 아코디언에는 요약을, 전체 상세에는 사진·표가 들어 있는 본문을 배치하면 긴 내용을 좁은 구매 패널 안에 넣지 않아도 돼요.

<section aria-labelledby="product-detail-heading">
  <h2 id="product-detail-heading">상품 상세 설명</h2>
  <ProductDetailBody product={detail} />
</section>tsx

블록 유형별 표시 계약

현재 서버 컴파일러가 지원하는 유형은 18개예요. 아래는 읽기/표시 계약​​이며 V1 생성·수정 API가 모든 편집 원본의 쓰기를 지원한다는 의미는 아니에요. 각 유형의 세부 필드 전체를 새 렌더러로 재구현하기보다 완성 HTML과 공용 CSS를 사용해요.

type 편집 원본의 주요 값 완성 HTML·표시 포인트
heading text, html, level, size, eyebrow h2/h3.bp-section-title, 제목 크기·위 라벨
text html, emphasis .bp-text, 줄바꿈·강조·인용·인라인 글자 크기
image items[{url,alt,caption}], ratio, layout .bp-images, figure/img/figcaption, stack/grid2/grid3와 원본 순서
imageText image, html, align, ratio, imageWidth, verticalAlign .bp-image-text, 사진 왼쪽/오른쪽/위, 폭에 따른 줄바꿈
features items[{icon,title,text}], preset .bp-features, 특징 목록·번호/아이콘/구분선
specs rows[{label,value}], columns .bp-specs, table/tbody/tr/th/td
notice title, html, tone, sharedId .bp-notice, 공용 안내는 서버에서 해석
html raw, source .bp-html, legacy도 렌더 경계 검사 필수
subscriptionPlan source:"product" .bp-subscription-plan, 상품 회차에서 생성된 정적 안내 표
cover image, title, text, pos, dim, height, eyebrow .bp-cover, 커버 사진·명암·문구 위치
steps items[{title,text}], preset .bp-steps, 순서가 있는 안내
stats items[{value,label,unit}] .bp-stats, 핵심 수치와 단위
faq items[{q,a}] .bp-faq, 네이티브 details/summary 접기·펼치기
compare headers, rows .bp-compare, 비교 표와 표 머리글
policyBadges show .bp-policy-badges, 연결 배송 정책에서 생성된 배지
sizeGuide columns, rows[{label,values}], note .bp-size-guide, 실측 표와 오차 안내
meterGroup items[{label,level,low,high}] .bp-meters, 정적 수준 표시·접근성 라벨
fitFor good, bad .bp-fit-for, 추천 대상·유의 사항

spacing, section, sectionPad, sectionAlign, bleed 같은 공통 값은 블록 간격·면 배경·폭에 영향을 줘요. 이미지의 focus는 자르기 기준점이에요. originUrl, hint 등 편집 보조 값으로 현재 이미지 URL을 교체하거나 화면 문구를 만들지 않아요. 같은 section이 연속된 블록은 하나의 section으로 컴파일될 수 있어요.

로딩·오류·검증

상품 API를 불러오는 동안 로딩 상태를 보여주고, 404는 상품 없음, 통신 오류는 다시 시도 상태로 처리해요. 본문을 못 읽었다는 이유로 다른 상품 사진이나 가짜 설명을 채우지 않아요. 위 렌더러는 읽을 수 없는/과도하게 큰 HTML을 표시 가능한 이미지 또는 빈 상태로 전환하며, 최대 1,000,000자·12,000노드·64단계의 파싱 한도를 둬요. 이 한도는 샘플 앱의 방어 기준이며 API의 저장 상한이 아니에요.

구현 후 아래를 실제 브라우저에서 검증해요.

  • text → imageText → text → image 순서와 본문 중간 사진을 유지하는지 확인해요.
  • HTML+content_images가 함께 있을 때 중복 표시가 없는지, 이미지 방식과 빈 상세가 올바른지 확인해요.
  • 데스크톱/모바일에서 판매자가 지정한 좌우·상하 배치와 줄바꿈, 원본 비율·표·FAQ와 가로 넘침을 확인해요.
  • 이미지 로딩 실패 안내, alt, 문답 키보드 조작과 링크 정책을 확인해요.
  • script, onerror, javascript:/data: URL, 외부 CSS/클래스, iframe, 폼, SVG/MathML 입력이 실행되거나 삽입되지 않는지 확인해요.
  • CSS와 계약 JSON을 함께 갱신하고, API의 새 블록 클래스가 정제 과정에서 빠지지 않는지 확인해요.

함께 보기