03
문자열로 조립할 것인가, React 트리 안에 넣을 것인가
2장에서 본 응답을 다시 보면 아래와 같이 시작합니다.
<div><h1>Todo List</h1><!--$?-->...
<!DOCTYPE html>도 <html>도 <head>도 없습니다. App이 <div>부터 반환하도록 짰기 때문입니다.
브라우저가 알아서 보정해주기 때문에 화면은 나오지만 정상은 아닙니다. <title>을 넣을 자리도 없고, 나중에 클라이언트 스크립트를 주입할 곳도 없습니다.
껍데기를 붙여야 합니다. 방법이 두 가지 있습니다.
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
읽고 쓴다
reader.read() — 스트림에서 꺼내는 것controller.enqueue() — 스트림에 넣는 것우리가 만든 ReadableStream은 “이 스트림을 읽는 사람에게 무엇을 줄지”를 정의합니다. enqueue한 순서가 곧 나가는 순서입니다.
개입하지 않으면 이 둘이 아예 없습니다. 2장 코드에는 read도 enqueue도 없었습니다.
while 루프는 await reader.read()에서 멈춰 있습니다. 하지만 이미 enqueue한 것은 벌써 나갔습니다. 큐에 넣는 즉시 소비되기 때문입니다.
그러면 읽는 즉시 내보내지 않고 다 모았다가 한 번에 내보내면 어떻게 될까요. 다음 장에서 직접 확인해봤습니다.
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 html>은 HTML 태그가 아니라 문서 선언입니다. React 엘리먼트로 표현할 방법이 없습니다.
그래서 방식 2에서도 DOCTYPE 한 줄은 직접 주입해야 한다고 생각했습니다. 확인해보려고 DOCTYPE 없이 먼저 돌려봤습니다.
<!DOCTYPE html><html lang="ko"><head>...
이미 있었습니다.
루트 컴포넌트가 <html>을 반환하면 React가 DOCTYPE을 넣어줍니다. 공식 문서에 명시되어 있습니다.
확실히 하려고 직접 주입하는 버전도 돌려봤습니다.
<!DOCTYPE html><!DOCTYPE html><html lang="ko">...
두 번 찍혔습니다. 앞의 것은 React가, 뒤의 것은 제가 넣은 것입니다.
결과적으로 문제가 되지 않았지만, 확인해둘 값어치는 있습니다.
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에서 닫는데, 두 상수가 서로 다른 곳에 있습니다. 한쪽만 고쳐도 아무도 알려주지 않습니다.
방식 2로 결론이 났지만, 방식 1에서 익힌 스트림 개입 패턴(read → enqueue)은 남습니다.
스트림 중간에 무언가를 끼워 넣어야 할 때 필요한 구조입니다.
그리고 다음 장에서는 이 패턴을 일부러 잘못 써서 스트리밍을 죽여봅니다.