figure cannot be a descendant of p — MDX 이미지 hydration 에러를 rehype로 해결
단독 줄 이미지를 remark가 <p>로 감싸며 생긴 hydration 오류를, 자체 rehype 플러그인으로 블록/인라인을 구분해 해결한 과정.
빌드는 통과했는데 글 자리에 에러 화면이 떴다. 콘솔에는 이 한 줄이 찍혀 있었다.
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>로 감싼다.
이 한 줄은 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 하나만 감싼
경우)도 같은 규칙으로 취급한다.
// 공백만 있는 텍스트 노드는 무시하고 의미 있는 자식만 센다.
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);
}핵심은 blockImageOf의 kids.length !== 1 가드다. 자식이 정확히 하나일 때만
블록으로 보고, 텍스트가 하나라도 섞이면 인라인으로 남겨둔다. 블록에는
data-block 마커가 붙고, 인라인에는 안 붙는다. 이 한 비트가 컴포넌트의 판단
근거가 된다.
컴포넌트 쪽은 그 마커만 읽으면 된다.
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를 건드리고 노드 정체성을 유지하므로 순서에 민감하지 않다.
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할이 이런 문맥 전달이면 직접 심는 편이 나을 수 있다.