콘텐츠 렌더러 우회

콘텐츠 렌더러 우회

이 사례의 핵심은 “렌더러를 잘 사용하자"가 아니라
renderer와 schema를 거치지 않으면 콘텐츠를 화면에 올릴 수 없게 만드는 것이다.

개요

registry까지 통제해도
UI는 여전히 구조를 무너뜨릴 수 있다.

그 이유는 화면에 무엇을 그릴지 정한 뒤에도
콘텐츠 자체를 어떤 형식으로 넣을지는 여전히 열려 있기 때문이다.

대표적으로 다음과 같은 우회가 남아 있다.

  • raw HTML 문자열 직접 삽입
  • AST를 거치지 않고 임의 JSX 생성
  • schema에 없는 노드를 직접 렌더링
  • sanitizer나 parser를 건너뛰고 콘텐츠를 바로 출력

즉 문제는 콘텐츠를 표시하는 것 자체가 아니라
콘텐츠가 renderer와 schema 밖에서 화면으로 새는 것이다.

Glif에서는 다음을 목표로 한다.

  • 콘텐츠는 parser → schema → renderer 경로를 통해서만 화면에 간다
  • raw HTML, unknown node, ad-hoc JSX는 허용되지 않는다
  • 위반은 코드 리뷰가 아니라 정적 분석 또는 런타임에서 실패한다

경로 한눈에 보기

D2 diagram
핵심 차이는 "콘텐츠가 화면에 나오느냐"가 아니라, 그 콘텐츠가 parser, schema, renderer를 거친 같은 의미 경로를 통과했느냐에 있다.

이 문서의 질문

이 문서는 하네스 개요에서 정리한 다섯 공통 패턴 중 direct path bypass에 가장 가깝다. 핵심 질문은 다음과 같다.

콘텐츠가 parser, schema, renderer를 거친 같은 의미 경로로만 화면에 도달하는가

문제

다음과 같은 코드가 반복적으로 생성된다.

function HtmlPreview({ html }: { html: string }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

또는

function renderNode(node: any) {
  if (node.type === "paragraph") return <p>{node.text}</p>;
  return <div>{node.raw}</div>;
}

또는

const output = markdownText
  .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
  .replace(/\n/g, "<br/>");

return <div dangerouslySetInnerHTML={{ __html: output }} />;

이 방식은 빠르게 결과를 만든다.
하지만 구조적으로는 다음 문제를 만든다.

  • schema에 없는 표현이 조용히 화면에 섞임

  • sanitizer 적용 여부를 추적하기 어려움

  • editor와 preview의 표현 일관성이 깨짐

  • 같은 콘텐츠가 경로에 따라 다르게 렌더됨

  • AI가 parser와 schema를 생략한 가장 짧은 표현 경로를 계속 재생산함

즉 문제는 렌더링 속도가 아니라
콘텐츠 진입 경로가 통제되지 않는 것이다.

문제의 본질

사람과 AI는 콘텐츠 렌더링을
구조적 해석 과정이 아니라 출력 문제로 보는 경향이 있다.

기준은 단순하다.

  • 화면에 모양만 맞으면 된다

  • parser, AST, schema를 따라가는 과정은 길다

  • raw string을 바로 넣는 것이 빠르다

  • 특정 노드 하나만 처리하면 되는 상황에서는 더 유혹적이다

그래서 다음과 같은 우회가 쉽게 발생한다.

  • dangerouslySetInnerHTML 사용

  • schema 없는 임시 node type 추가

  • parser를 우회한 regex 기반 변환

  • renderer registry를 건너뛴 direct JSX 분기

즉 사람과 AI는 콘텐츠 구조를 보존하기보다
문자열을 바로 화면으로 보내는 경로를 만든다.

목표 구조

Glif에서는 콘텐츠 경로를 다음처럼 고정한다.

D2 diagram
핵심 규칙은 단순하다.
  • 콘텐츠는 먼저 schema 구조로 해석된다

  • renderer는 schema에 정의된 node만 받는다

  • raw HTML과 ad-hoc node는 화면에 직접 연결되지 않는다

하네스 적용 위치

이 문제는 네 레벨에서 동시에 막는다.

구조 레벨

콘텐츠 계층을 명확히 분리한다.

  • source text

  • parser

  • schema node

  • renderer

  • host component

그리고 각 계층이 다음 계층 외에는 직접 알지 못하게 한다.

즉 preview 컴포넌트는 raw markdown이나 raw html을 받는 것이 아니라
이미 해석된 node tree만 받는 소비자가 된다.

검증 레벨

정적 분석으로 콘텐츠 우회를 탐지한다.

  • dangerouslySetInnerHTML 금지

  • UI 레이어의 raw markdown/html string render 금지

  • renderer 외부 switch(node.type) 분기 금지

  • schema에 없는 node type literal 금지

실행 레벨

런타임에서도 schema 경계를 확인한다.

  • unknown node type 즉시 throw

  • sanitize 안 된 HTML payload 거부

  • renderer registry에 없는 node는 실패

테스트 레벨

테스트도 schema 기반 fixture로만 작성한다.

  • raw html snapshot 금지

  • regex 기반 preview helper 금지

  • parser output fixture와 renderer output을 함께 검증

실제 구현

이 문서에서 필요한 구현은 네 가지뿐이다.

  • 문자열은 parser까지만 허용한다
  • renderer 입력 타입은 schema node로 닫아 둔다
  • node 분기 권한은 registry에만 둔다
  • unknown node와 raw HTML은 조용히 복구하지 않는다
구현 예시 펼치기

콘텐츠는 먼저 schema node로 해석되어야 한다

콘텐츠 하네스의 핵심은 화면이 raw string이나 arbitrary object를 직접 받지 않는 것이다.

type DocNode =
  | { type: "paragraph"; children: InlineNode[] }
  | { type: "heading"; level: 1 | 2 | 3; children: InlineNode[] }
  | { type: "blockquote"; children: DocNode[] }
  | { type: "code_block"; language: string | null; value: string };

renderer 입력 타입을 닫아두면 schema 밖 표현은 애초에 렌더 surface로 올라오기 어렵다.

parser를 합법 진입점으로 고정한다

export interface ContentParser {
  parse(input: string): DocNode[];
}

export function buildPreviewModel(markdown: string, parser: ContentParser): DocNode[] {
  return parser.parse(markdown);
}

문자열은 parser까지만 가고, 그 다음부터는 schema node만 흐른다. 이 경계가 있어야 raw HTML이나 ad-hoc object가 의미 경로를 우회하지 못한다.

node 분기 권한은 renderer registry로 집중한다

const nodeRenderers = {
  paragraph: (node) => <p>{renderInline(node.children)}</p>,
  heading: (node) => {
    const Tag = `h${node.level}` as const;
    return <Tag>{renderInline(node.children)}</Tag>;
  },
  blockquote: (node) => <blockquote>{node.children.map(renderDocNode)}</blockquote>,
  code_block: (node) => <pre><code>{node.value}</code></pre>,
};

이 구조에서는 renderer 외부에서 node.type을 다시 분기하거나, unknown node를 임시 div로 넘기는 경로가 줄어든다.

unknown node와 raw HTML은 즉시 실패시킨다

export function renderDocNode(node: DocNode): React.ReactElement {
  const renderer = nodeRenderers[node.type];
  if (!renderer) {
    throw new Error(`Unknown node type: ${node.type}`);
  }
  return renderer(node as never);
}

그리고 dangerouslySetInnerHTML 같은 raw HTML 주입은 정적 규칙으로 금지한다. fallback HTML 삽입은 유연함이 아니라 schema 위반을 성공처럼 위장하는 우회 경로다.

sanitize 같은 예외도 parser pipeline 안에 둔다

HTML을 허용해야 하는 예외가 있더라도 renderer나 host component가 즉흥적으로 처리하지 않고 parser pipeline 안에서 구조화하는 편이 맞다.

export function parseHtmlBlock(rawHtml: string, sanitizer: HtmlSanitizer): Extract<DocNode, { type: "code_block" }> {
  const safe = sanitizer.sanitize(rawHtml);
  return { type: "code_block", language: "html", value: safe };
}

중요한 것은 예외를 허용하더라도 parser -> schema -> renderer 경로 밖으로 빼지 않는 것이다.

테스트도 문자열 직렌더 대신 schema fixture를 사용한다

const docFixture: DocNode[] = [
  { type: "heading", level: 2, children: [{ type: "text", value: "Hello" }] },
  { type: "paragraph", children: [{ type: "text", value: "World" }] },
];

테스트가 raw markdown string, regex 치환 HTML, dangerouslySetInnerHTML helper를 허용하면 실제 코드도 같은 우회 경로를 배우게 된다.

위반 예시와 실패 결과

다음 코드는 반드시 실패해야 한다.

function Preview({ html }: { html: string }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

실패 결과:

error: dangerouslySetInnerHTML is forbidden. Use parsed schema nodes and renderer registry.

또는

function Preview({ node }: { node: any }) {
  switch (node.type) {
    case "paragraph":
      return <p>{node.text}</p>;
    default:
      return <div>{node.raw}</div>;
  }
}

실패 결과:

error: UI must not branch on content node.type directly. Use renderer registry.

또는 unknown node 렌더:

Error: Unknown node type: custom_raw_html

이 순간부터 콘텐츠 렌더링 규칙은 문서가 아니라
실행 가능한 제약이 된다.

보조 강제 수단

이 케이스의 보조 강제 수단은 content meaning path를 parser와 schema 기준으로 고정하는 데 있다. schema만 두면 raw HTML 삽입이 남고, parser만 두면 arbitrary node branching이 남고, runtime guard가 없으면 unknown node가 조용히 통과한다.

  • schema union type → 콘텐츠 surface 고정
  • parser interface → 합법 진입점 고정
  • renderer registry → node 분기 권한 집중
  • ESLint → raw HTML, direct node branching 차단
  • runtime unknown-node guard → schema 위반 즉시 실패

이 장치들은 콘텐츠를 출력 문자열이 아니라 해석 가능한 의미 구조로 유지하게 만든다.

결과

하네스 적용 이후 변화는 명확하다.

  • raw HTML이나 임의 node 삽입 경로가 사라진다.
  • parser, schema, renderer를 거친 의미 경로가 다시 하나로 고정된다.
  • 지원되지 않는 콘텐츠는 조용히 흘러가지 않고 앞단에서 실패한다.

실무 적용 팁

  • raw HTML은 거의 항상 가장 짧은 우회 경로다. 한 번 열어두면 parser와 schema는 금방 장식으로 변한다.
  • unknown node fallback을 두지 않는다. 예외를 친절하게 복구할수록 schema는 점점 느슨해진다.
  • renderer는 표현 계층이지 복구 계층이 아니다. sanitize, fallback, 구조 보정을 떠안기 시작하면 콘텐츠 경계는 금방 흐려진다.
  • 테스트에서 문자열 직렌더를 허용하지 않는다. 테스트가 허용한 우회는 곧 실제 코드 생성의 합법 경로가 된다.

요약

이 케이스에서 하네스는 다음과 같이 구성된다.

  • 콘텐츠는 parser를 통해 schema node로 먼저 해석된다

  • renderer는 정의된 node union만 입력으로 받는다

  • raw HTML과 direct node branching은 정적 분석으로 차단된다

  • unknown node는 런타임에서 즉시 실패한다

  • 테스트도 schema fixture와 renderer registry를 통해서만 콘텐츠를 렌더한다

결과적으로

  • 콘텐츠 진입 경로는 parser → schema → renderer로 고정되고

  • raw string과 ad-hoc JSX라는 우회 경로는 제거되며

  • editor와 preview는 동일한 구조 위에서 표현되게 된다

“렌더러를 잘 사용하라"가 아니라
“renderer와 schema를 거치지 않으면 콘텐츠를 화면에 올릴 수 없다"가 하네스다.