← 목차3 / 7

03

HTML 껍데기 붙이기

문자열로 조립할 것인가, React 트리 안에 넣을 것인가

껍데기를 어디서 붙일 것인가

2장에서 본 응답을 다시 보면 아래와 같이 시작합니다.

<div><h1>Todo List</h1><!--$?-->...

<!DOCTYPE html>도 <html>도 <head>도 없습니다. App이 <div>부터 반환하도록 짰기 때문입니다. 브라우저가 알아서 보정해주기 때문에 화면은 나오지만 정상은 아닙니다. <title>을 넣을 자리도 없고, 나중에 클라이언트 스크립트를 주입할 곳도 없습니다. 껍데기를 붙여야 합니다. 방법이 두 가지 있습니다.

방식 1 — 문자열로 앞뒤에 붙이기

React 스트림 앞뒤에 문자열을 이어붙입니다.

header 문자열 + [React 스트림] + trailer 문자열

React 스트림을 다 모았다가 앞뒤를 붙이면 스트리밍이 죽기 때문에, 단순히 이어붙이면 안 됩니다.

먼저 온 것부터 즉시 내보내면서 앞뒤를 붙여야 합니다. 그러려면 응답 스트림을 직접 만들고 그 안에 개입해야 합니다.

const header = `<!DOCTYPE html><html><head><title>Streaming SSR</title></head><body><div id="root">`;
const trailer = `</div></body></html>`;

const responseStream = new ReadableStream({
  async start(controller) {
    // header를 즉시 내보낸다
    controller.enqueue(textEncoder.encode(header));

    // React 스트림에서 꺼내는 대로 즉시 넘긴다
    const reader = reactStream.getReader();
    try {
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        controller.enqueue(value);
      }
    } catch (error) {
      controller.error(error);
    } finally {
      // 에러가 나도 반드시 닫는다
      controller.enqueue(textEncoder.encode(trailer));
      controller.close();
    }
  },
});

개입한다는 것

2장에서는 React 스트림을 Response에 그대로 넘겼습니다.

React -> passThrough -> 스트림 -> Response
                                  (HTTP 레이어가 읽는다)

방식 1에서는 중간에 한 겹을 끼워넣습니다.

React -> passThrough -> 스트림 -> 우리 -> 새 스트림 -> Response
                                  읽고     쓴다

우리가 만든 ReadableStream은 “이 스트림을 읽는 사람에게 무엇을 줄지”를 정의합니다. enqueue한 순서가 곧 나가는 순서입니다.

개입하지 않으면 이 둘이 아예 없습니다. 2장 코드에는 read도 enqueue도 없었습니다.

스트리밍이 유지되는 이유

while 루프는 await reader.read()에서 멈춰 있습니다. 하지만 이미 enqueue한 것은 벌써 나갔습니다. 큐에 넣는 즉시 소비되기 때문입니다.

그러면 읽는 즉시 내보내지 않고 다 모았다가 한 번에 내보내면 어떻게 될까요. 다음 장에서 직접 확인해봤습니다.

방식 2 — React 트리 안에 넣기

App이 <html>부터 반환하게 합니다.

function App() {
  return (
    <html lang="ko">
      <head>
        <meta charSet="utf-8" />
        <title>Streaming SSR</title>
      </head>
      <body>
        <div id="root">
          <h1>Todo List</h1>
          <Suspense fallback={<div>loading...</div>}>
            <TodoList />
          </Suspense>
        </div>
      </body>
    </html>
  );
}

React 18부터 <html>, <head>, <body>를 렌더링할 수 있습니다. 그러면 서버 코드에서 문자열 조립이 통째로 사라집니다. 2장 형태로 돌아가면 됩니다.

DOCTYPE은 어떻게 되는가

여기서 걸리는 것이 하나 있습니다. <!DOCTYPE html>은 HTML 태그가 아니라 문서 선언입니다. React 엘리먼트로 표현할 방법이 없습니다. 그래서 방식 2에서도 DOCTYPE 한 줄은 직접 주입해야 한다고 생각했습니다. 확인해보려고 DOCTYPE 없이 먼저 돌려봤습니다.

<!DOCTYPE html><html lang="ko"><head>...

이미 있었습니다.

루트 컴포넌트가 <html>을 반환하면 React가 DOCTYPE을 넣어줍니다. 공식 문서에 명시되어 있습니다. 확실히 하려고 직접 주입하는 버전도 돌려봤습니다.

<!DOCTYPE html><!DOCTYPE html><html lang="ko">...

두 번 찍혔습니다. 앞의 것은 React가, 뒤의 것은 제가 넣은 것입니다.

DOCTYPE이 없으면 무슨 일이 생기는가

결과적으로 문제가 되지 않았지만, 확인해둘 값어치는 있습니다.

DOCTYPE이 없으면 브라우저는 표준 규칙 대신 quirks mode로 렌더링합니다. document.compatMode로 확인할 수 있습니다.

값 모드 조건
CSS1Compat standards DOCTYPE 있음
BackCompat quirks DOCTYPE 없음

quirks mode에서는 박스 모델 계산이 다릅니다.

div {
  width: 100px;
  padding: 10px;
  border: 5px solid;
}

standards에서는 130px, quirks에서는 100px입니다. padding과 border가 안쪽으로 먹힙니다.

이 모드는 표준 이전에 만들어진 사이트를 깨뜨리지 않으려고 브라우저가 유지하는 것입니다. DOCTYPE을 빠뜨리면 지금 만드는 페이지가 옛날 문서로 오인됩니다.

어느 쪽을 쓸 것인가

방식 1 (문자열) 방식 2 (트리 안)
밖에서 붙이는 것 header + trailer 없음
스트림 개입 필요 불필요
DOCTYPE 직접 작성 React 자동 주입
</html> 닫는 주체 trailer 문자열 React
<title> 변경 문자열 조립 JSX
오타 검출 불가 컴파일 타임

방식 2가 낫다고 생각했습니다.

문자열은 검증받지 못하는 데이터입니다. 방식 1의 header에 <meta charst="utf-8">이라고 써도 TypeScript는 아무 말이 없습니다. 런타임 에러도 안 나고 브라우저가 조용히 무시합니다. 한글이 깨진 것을 보고서야 알게 됩니다.

방식 2에서는 같은 오타를 내면 컴파일 단계에서 잡힙니다.

그리고 방식 1은 짝이 갈라집니다. <div id="root">를 header에서 열고 trailer에서 닫는데, 두 상수가 서로 다른 곳에 있습니다. 한쪽만 고쳐도 아무도 알려주지 않습니다.

방식 1을 만들어본 것이 의미 없지는 않다

방식 2로 결론이 났지만, 방식 1에서 익힌 스트림 개입 패턴(read → enqueue)은 남습니다. 스트림 중간에 무언가를 끼워 넣어야 할 때 필요한 구조입니다.

그리고 다음 장에서는 이 패턴을 일부러 잘못 써서 스트리밍을 죽여봅니다.