Ari.dev

figure cannot be a descendant of p — MDX 이미지 hydration 에러를 rehype로 해결

단독 줄 이미지를 remark가 <p>로 감싸며 생긴 hydration 오류를, 자체 rehype 플러그인으로 블록/인라인을 구분해 해결한 과정.

4분 읽기MDX

빌드는 통과했는데 글 자리에 에러 화면이 떴다. 콘솔에는 이 한 줄이 찍혀 있었다.

In HTML, <figure> cannot be a descendant of <p>.
This will cause a hydration error.

원인은 내 컴포넌트가 아니라, 마크다운을 HTML로 바꾸는 과정에서 생긴 태그 중첩이었다. 정적 HTML을 grep해도 안 잡히고 브라우저 hydration 단계에서만 드러나는 종류라, "빌드 통과 = 완료"라는 착각을 정확히 파고들었다.

증상: 빌드는 멀쩡한데 본문 대신 에러 화면

블로그 본문에서 이미지가 들어간 글을 열면 error.tsx 바운더리가 떠서 글이 통째로 안 보였다. 그런데 이상한 건 이거였다.

  • npm run build는 통과했다. 타입 에러도 lint 에러도 없었다.
  • 빌드 산출물 HTML을 grep해도 문제를 못 찾았다. 서버가 뱉은 마크업 자체는 겉보기에 멀쩡했다.
  • 문제는 오직 브라우저에서 React가 hydration할 때만 터졌다.

즉 서버 렌더 결과와 클라이언트가 기대한 트리가 어긋나는 hydration 불일치였고, 그 신호가 콘솔의 cannot be a descendant of 경고였다.

원인: remark의 문단 래핑과 HTML 명세 충돌

파이프라인을 먼저 한 줄씩 짚어두면 흐름을 따라오기 쉽다.

  • remark: 마크다운 텍스트를 파싱하는 단계.
  • rehype: 그 결과를 HTML 트리로 다루며 변형하는 단계.
  • hast: rehype가 만지는 HTML 추상 트리(HTML AST). 노드에 tagName, properties, children이 달려 있다.

문제의 출발점은 remark다. 마크다운에서 이미지가 단독 줄에 있으면 remark는 그걸 문단으로 보고 <p>로 감싼다.

![경계 다이어그램](/images/sample-diagram.png "캡션")

이 한 줄은 hast에서 대략 이렇게 된다. p 안에 img 하나가 들어간 모양이다.

<p><img src="/images/sample-diagram.png" alt="경계 다이어그램" /></p>

한편 내 이미지 컴포넌트 MdxImage는 캡션을 붙이려고 <figure><figcaption>을 렌더한다.

<figure className="not-prose my-6">
  <Image ... />
  <figcaption>캡션</figcaption>
</figure>

여기서 충돌이 난다. HTML 명세상 <p>문단 콘텐츠만 담을 수 있고 <figure> 같은 블록 요소를 자식으로 가질 수 없다. 그래서 브라우저는 파싱할 때 <figure>를 만나는 순간 열려 있던 <p>를 강제로 닫아버린다. 결과적으로 브라우저가 실제로 만든 DOM 트리는 React가 서버에서 기대한 트리 (<p> 안에 <figure>)와 달라지고, 이 어긋남이 hydration 에러로 표면화된다.

정리하면 범인은 세 요소의 조합이다. remark가 이미지를 <p>로 감싸고, 컴포넌트가 그 안에서 <figure>를 렌더하고, HTML 명세가 그 중첩을 허용하지 않는다. 셋 다 각자로는 정상 동작이다.

해결: 자체 rehype 플러그인으로 블록/인라인을 구분

방향은 단순하다. <figure>를 렌더하기 전에, 그 이미지를 감싼 불필요한 <p>를 hast 단계에서 벗겨내면 된다. 이미지 하나만 든 문단은 문단일 이유가 없으니 <p>를 해제하고 img를 그 자리로 승격시킨다.

왜 rehype-unwrap-images를 그대로 쓰지 않았나

문단 해제만 놓고 보면 rehype-unwrap-images라는 기성 패키지가 있다. 이미지만 든 문단에서 <p>를 벗겨준다. 그런데 우리 경우엔 이걸로 부족했고, 그 이유가 이 글의 핵심이다.

MdxImage는 렌더 시점에 자기 부모가 <p>였는지 알 수 없다. 컴포넌트는 자기 props만 받을 뿐, hast 트리에서 어디에 놓여 있었는지는 모른다. 그런데 이미지는 두 가지로 쓰인다.

  • 블록 이미지: 단독 줄 이미지. <figure>+<figcaption>으로 캡션까지 붙여야 한다.
  • 인라인 이미지: 문장 안에 텍스트와 섞여 들어간 이미지. 이건 <figure>를 쓰면 안 된다. 텍스트가 든 <p> 안에서 <figure>가 되면 똑같은 중첩 위반이 그대로 재발한다.

rehype-unwrap-images는 문단을 해제해줄 뿐, 이 이미지가 블록이었는지 인라인 이었는지 표시를 남기지 않는다. 그래서 MdxImage는 여전히 둘을 구분할 수단이 없고, 결국 "항상 <figure>로 렌더" 같은 선택을 강요당한다. 그 순간 인라인 이미지에서 문제가 되돌아온다.

그래서 필요한 건 문단 해제 더하기 블록/인라인 마킹이다. 해제하면서 그 이미지에 data-block 마커를 심어, 나중에 MdxImage가 "나는 원래 블록이었다"를 알 수 있게 한다. 이 마킹이 없으면 컴포넌트는 눈을 가린 채 판단해야 한다.

패키지 하나 줄이는 게 목적은 아니었다. 마킹이 빠진 채로는 문제의 절반만 풀리기 때문에 어차피 후처리가 필요했고, 그러면 수동 워크 한 벌이 의존성보다 단순했을 뿐이다. 문단 해제만 필요한 프로젝트라면 기성 패키지가 더 나은 선택일 수 있다 — 우리는 블록/인라인 구분이 필수라 갈라진 것뿐이다.

플러그인 코드

규칙은 세 줄로 요약된다. (1) <p>의 의미 있는 자식이 이미지 하나뿐이면 그 <p>를 해제하고 이미지를 승격, (2) 그 이미지에 data-block 마커를 심는다, (3) 텍스트가 섞인 문단은 건드리지 않는다. 링크 이미지(<a>img 하나만 감싼 경우)도 같은 규칙으로 취급한다.

src/lib/rehype-unwrap-images.ts
// 공백만 있는 텍스트 노드는 무시하고 의미 있는 자식만 센다.
function meaningful(node) {
  return (node.children ?? []).filter(
    (c) => !(c.type === "text" && /^\s*$/.test(c.value ?? "")),
  );
}

// p의 의미 있는 자식이 img 하나뿐이거나,
// img 하나만 감싼 a 하나뿐이면 그 img를 반환한다.
function blockImageOf(p) {
  const kids = meaningful(p);
  if (kids.length !== 1) return null; // 텍스트가 섞였으면 인라인 → 건드리지 않음
  const only = kids[0];
  if (only.tagName === "img") return only;
  if (only.tagName === "a") {
    const inner = meaningful(only);
    if (inner.length === 1 && inner[0].tagName === "img") return inner[0];
  }
  return null;
}

function visit(node) {
  if (!node.children) return;
  node.children = node.children.map((child) => {
    if (child.tagName === "p") {
      const img = blockImageOf(child);
      if (img) {
        (img.properties ??= {})["data-block"] = "true"; // 블록 마킹
        return meaningful(child)[0]; // p 해제, 단일 자식을 승격
      }
    }
    return child;
  });
  node.children.forEach(visit);
}

핵심은 blockImageOfkids.length !== 1 가드다. 자식이 정확히 하나일 때만 블록으로 보고, 텍스트가 하나라도 섞이면 인라인으로 남겨둔다. 블록에는 data-block 마커가 붙고, 인라인에는 안 붙는다. 이 한 비트가 컴포넌트의 판단 근거가 된다.

컴포넌트 쪽은 그 마커만 읽으면 된다.

src/components/blog/mdx/mdx-image.tsx
export function MdxImage({ src, alt, title, "data-block": block }) {
  const isBlock = block === "true";

  // 인라인 이미지: p 안에 텍스트와 함께 있으므로 figure를 쓰면 안 된다.
  if (!isBlock) {
    return <img src={src} alt={alt} className="inline-block ..." />;
  }

  // 블록 이미지: 이제 부모 p가 벗겨졌으니 figure를 써도 안전하다.
  return (
    <Figure caption={title}>
      <Image src={src} alt={alt} ... />
    </Figure>
  );
}

플러그인은 렌더러의 rehype 체인에 등록한다. 크기 주입(rehypeImageSize)과는 서로 다른 property를 건드리고 노드 정체성을 유지하므로 순서에 민감하지 않다.

src/components/blog/mdx-renderer.tsx
rehypePlugins: [
  rehypeSlug,
  [rehypeAutolinkHeadings, { behavior: "wrap" }],
  rehypeCodeMeta,
  rehypeImageSize, // img에 width/height 주입
  rehypeUnwrapImages, // 단독 이미지의 p 해제 + data-block 마킹
],

결과

hydration 에러가 사라지고 error.tsx가 더는 뜨지 않는다. 블록 이미지는 <figure>+<figcaption>으로 캡션까지 붙어 렌더되고, 문장 안 인라인 이미지는 <figure> 없이 img로만 흐른다. 둘 다 콘솔 경고 없이 hydration을 통과한다.

배운 점: 빌드 통과는 완료가 아니다

이 버그가 무서웠던 이유는 하나다. 빌드가 초록불이었다. 타입 체크도, lint도, npm run build도 전부 통과했다. 정적 HTML을 grep해도 안 잡혔다. hydration은 브라우저에서 서버 트리와 클라이언트 트리를 맞춰볼 때 비로소 일어나는 일이라, 빌드 파이프라인의 어떤 단계도 이걸 검사하지 않는다. 화면을 만드는 작업은 dev 서버를 띄워 브라우저 콘솔에서 직접 확인하기 전까진 끝난 게 아니다.

부차적으로 하나 더. 컴포넌트는 자기 문맥을 모른다. MdxImage는 자기 부모가 <p>였는지 알 길이 없었고, 그래서 문맥 정보(블록인가 인라인인가)를 트리 변형 단계에서 마커로 실어 보내야 했다. 기성 패키지가 문제의 8할을 풀어줘도, 남은 2할이 이런 문맥 전달이면 직접 심는 편이 나을 수 있다.