next-mdx-remote에서 컴포넌트 props가 사라질 때
JSX 표현식으로 넘긴 props가 조용히 사라지는 이유와, blockJS를 끄지 않고 해결한 과정.
결론부터. MDX 컴포넌트에 넘긴 width={640} 같은 표현식 props가 렌더링에서
사라진 건 버그가 아니라 next-mdx-remote의 보안 기능이었다. blockJS 옵션이
신뢰할 수 없는 MDX의 임의 JS 실행을 막으려고 표현식을 제거한다. 문제는 경고도
에러도 없이 조용히 제거한다는 점이고, 이걸 발견한 건 순전히 dev 환경에서
next/image가 우연히 throw해준 덕분이었다.
문제: dev에서만 터지는 이미지
MDX 본문에 이런 이미지 컴포넌트를 썼다.
<Image
src="/images/diagram.png"
width={640}
height={360}
caption="경계 다이어그램"
/>dev 서버에서 이 페이지를 열면 next/image가 던졌다.
Error: Image with src "/images/diagram.png" is missing required "width" property.이상한 건 두 가지였다.
caption="경계 다이어그램"문자열 속성은 멀쩡히 렌더됐다. 사라진 건width,height뿐이었다.npm run build(prod)는 통과했다. 정적 HTML에는 이미지가 크기 없이 들어갔고, 브라우저에서 레이아웃이 밀리며 CLS가 깨졌다. dev에서 throw가 없었다면 그대로 배포까지 갔을 문제다.
오진: "마크다운 HTML 파싱 모드" 의심
처음엔 next-mdx-remote가 마크다운 안의 HTML을 특정 모드로만 파싱해서 속성을
흘리는 거라 의심했다. 파서가 raw HTML을 다룰 때 허용 속성을 제한하는 경우가
있으니, 그럴듯한 가설이었다.
이 가설을 깬 단서가 앞의 비대칭이었다. 파싱 모드가 속성을 통째로 버린다면
caption도 같이 사라져야 한다. 그런데 caption="..." 문자열은 살아남고
width={640} 표현식만 사라졌다. "속성"이 문제가 아니라 "표현식"이 문제였다는
뜻이다. 문자열 리터럴과 표현식을 가르는 무언가가 중간에 있었다.
원인: blockJS의 표현식 제거
next-mdx-remote에는 blockJS 옵션이 있고 기본값이 true다. 켜져 있으면
내부적으로 removeJavaScriptExpressions가 MDX AST를 훑으며
mdxJsxAttributeValueExpression(표현식으로 된 JSX 속성)과 본문의 표현식
노드를 제거한다.
- 문자열 리터럴 속성(
caption="...")과 불리언 속성은 값이 AST에 리터럴로 박혀 있어 보존된다. - 표현식 속성(
width={640})과 본문 표현식({someVar})은 JS 평가가 필요한 노드라 제거된다.
목적은 보안이다. next-mdx-remote는 신뢰할 수 없는 사용자 MDX를 서버에서
렌더하는 시나리오를 상정한다. 표현식을 그대로 두면 콘텐츠 작성자가 임의 JS를
실행할 수 있으므로, 기본적으로 그 경로를 차단한다. width가 사라진 건 파서
버그가 아니라 설계된 방어선이었다.
검증: 격리해서 확인
가설을 사실로 굳히려고 두 가지만 격리해서 확인했다.
실험 1 - 본문 표현식. 컴포넌트 속성이 아니라 본문에 {1 + 1}을 넣어봤다.
렌더 결과에 2가 아니라 빈 값이 나왔다. 속성만이 아니라 표현식 전반이
사라진다는 뜻이다.
실험 2 - 플러그인 제거. remark/rehype 플러그인을 전부 뺀 최소 설정에서도
같은 현상이 재현됐다. 우리가 끼워둔 플러그인의 부작용이 아니라
next-mdx-remote 코어(blockJS) 동작임이 확정됐다.
나머지는 결과만 정리하면 이렇다. 문자열/불리언 속성은 전부 보존, 표현식
속성과 본문 표현식은 전부 유실. blockJS를 false로 두면 표현식이 다시
살아나는 것도 확인했다(끄지는 않았다. 아래 트레이드오프 참조).
해결: 표현식 대신 rehype로 빌드타임 주입
표현식을 못 쓴다면, 표현식으로 넘기려던 값을 다른 경로로 넣으면 된다. 이미지 크기는 파일에서 빌드타임에 읽어 rehype 플러그인이 노드 속성으로 심는다. rehype가 세팅한 property는 표현식이 아니라 이미 값이 박힌 속성이라 blockJS 필터에 걸리지 않고 컴포넌트 props로 전달된다.
그리고 커스텀 <Image> 대신 마크다운 표준 이미지 문법으로 통일했다.
크기 주입 플러그인의 요지는 이렇다.
function visit(node) {
if (node.type === "element" && node.tagName === "img") {
const props = (node.properties ??= {});
const info = inspectImage(props.src); // 빌드타임에 파일 크기 조회
if (info.status === "ok") {
props.width = info.width;
props.height = info.height;
}
}
node.children?.forEach(visit);
}렌더러에서 blockJS는 명시적으로 켜둔 채 둔다.
<MDXRemote
source={source}
components={mdxComponents}
options={{
blockJS: true, // 기본값이지만 명시 - 표현식 차단은 의도된 동작
mdxOptions: { rehypePlugins: [rehypeImageSize /* ... */] },
}}
/>작성자가 실수로 표현식을 다시 쓰면 어떻게 될까. 조용히 사라지면 똑같이 당한다. 그래서 빌드타임 린트로 MDX 원문에서 표현식 속성, 본문 표현식, import/export를 미리 잡아 빌드를 실패시킨다. "조용한 삭제"를 "시끄러운 실패"로 바꾼 것이다.
rehype 주입은 빌드타임에 알 수 있는 값만 넣을 수 있다. 런타임이나 사용자 입력에 따라 달라지는 값은 이 경로로 못 넣는다. 그런 게 필요하면 애초에 정적 MDX가 맞는 그릇인지부터 다시 봐야 한다.
트레이드오프
이 해결이 공짜는 아니다.
- MDX 본문에서 표현식을 아예 못 쓴다. 계산값이나 동적 props가 필요하면 전부 rehype 플러그인으로 빌드타임에 주입해야 한다.
blockJS: false로 끄면 표현식이 살아나지만, 그 순간 콘텐츠의 임의 JS 실행을 허용하는 셈이다. 글을 지금은 나 혼자 쓰더라도 굳이 방어선을 내릴 이유가 없어 끄지 않았다. 대신 표현식이 필요한 소수 케이스(이미지 크기, 코드 메타)를 플러그인으로 흡수했다.- 표준 마크다운 문법으로 통일한 건 부수 효과로 이득이었다. 표현식 함정이 원천적으로 안 생긴다.
물론 blockJS를 끄는 게 더 나은 상황도 있다. 신뢰할 수 있는 저자만 MDX를
쓰고 표현식이 자주 필요하면 끄는 편이 단순하다. 우리는 표현식 수요가 적어
플러그인 주입이 더 맞았을 뿐, 어느 쪽이 항상 옳다는 얘기는 아니다.
배운 점: 에러 안 나는 실패가 가장 위험하다
가장 무서웠던 건 CLS가 아니라 "조용함"이었다. blockJS는 표현식을 제거하면서
경고도 에러도 남기지 않는다. dev의 next/image가 width 없다고 throw해주지
않았다면, 이 문제는 프로덕션에서 레이아웃이 밀리는 형태로만 드러났을 것이다.
원인이 "속성이 사라진다"인 걸 한참 뒤에야 알았을 거고.
정리하면 이렇다.
- 에러로 터지는 실패는 이미 절반은 고친 셈이다. 진짜 위험한 건 값이 조용히 비는 실패다.
- dev와 prod의 동작 차이는 버그의 단서가 되기도 한다. 여기선 dev 전용 검증이 우연히 조기 경보로 작동했다.
- 라이브러리의 "이상한 동작"은 대개 누군가의 의도된 방어선이다. 우회(끄기)보다 그 의도를 이해하고 설계로 맞추는 편이 낫다. 그래서 blockJS는 켜둔 채, 조용한 삭제를 빌드타임 린트로 시끄럽게 바꿨다.
옵션의 정확한 동작은 next-mdx-remote README에서 확인할 수 있다.