DailBit 서비스 중 OTO(짝·팀 구성), 토크잡이(대화 질문 뽑기), 뭐먹지?(메뉴 추천), Memomo(카드 짝 맞추기), 지하철 어디는 Next.js가 아니라 Vite + React + React Router로 만든 SPA다. Vite로 만든 SPA는 기본 설정 그대로 빌드하면 index.html에 <div id="root"></div>만 있고, 자바스크립트가 실행된 뒤에야 화면이 그려진다.

이 구조의 문제는 검색 엔진이나 링크 미리보기 크롤러가 처음 받는 HTML에 본문이 없다는 것이다. 구글은 자바스크립트를 실행해 렌더링할 수 있지만, 렌더링은 크롤링보다 늦게 이뤄질 수 있고 모든 크롤러가 자바스크립트를 실행하지도 않는다. 애드센스 심사에서도 "콘텐츠가 부족하다"는 판단을 받을 위험이 있다.

이 서비스들은 Next.js로 옮기는 대신 Vite 빌드 뒤에 사전 렌더링(prerender) 단계를 붙여 이 문제를 해결한다. 이 글은 그 구조와, 나중에 빌드 결과를 다시 보며 발견한 문제를 정리한다.

빌드 명령 한 줄로 보는 전체 흐름

OTO의 build 스크립트는 이렇다.

"build": "tsc -b && vite build && vite build --ssr src/entry-server.tsx --outDir dist-server && node scripts/prerender.mjs / /teamShare /saveList /privacy /guide && node scripts/generate-ads-txt.mjs"

순서대로 풀면 다음과 같다.

  1. vite build — 평소처럼 브라우저용 번들과 dist/index.html 템플릿을 만든다.
  2. vite build --ssr src/entry-server.tsx — 같은 React 앱을 Node에서 실행할 수 있는 형태로 한 번 더 빌드해 dist-server/에 둔다.
  3. node scripts/prerender.mjs <경로들> — 경로마다 앱을 HTML 문자열로 렌더링해서 템플릿의 #root 안에 끼워 넣고, dist/<경로>/index.html로 저장한다.
  4. 사전 렌더링에 쓴 dist-server/는 지운다.

Vite는 SSR 빌드 모드를 기본으로 지원하므로, 별도 프레임워크 없이 이 정도로 충분했다.

서버용 진입점: renderToString

// src/entry-server.tsx
import { renderToString } from "react-dom/server";
import App from "./App";

export const render = (url: string) => renderToString(<App url={url} />);

App은 url을 받으면 메모리 라우터로, 받지 않으면 브라우저 라우터로 동작한다.

function App({ url }: { url?: string }) {
  const content = (
    <Routes>
      <Route path="/" element={<BaseLayout />}>
        <Route index element={<OneToOne />} />
        <Route path="teamShare" element={<TeamShare />} />
        <Route path="privacy" element={<Privacy />} />
        <Route path="guide" element={<Guide />} />
        {/* ... */}
      </Route>
      <Route path="*" element={<ErrorPage />} />
    </Routes>
  );
  return url
    ? <MemoryRouter initialEntries={[url]}>{content}</MemoryRouter>
    : <BrowserRouter>{content}</BrowserRouter>;
}

Node에는 window.location이 없으므로 BrowserRouter를 쓸 수 없다. MemoryRouter에 렌더링할 경로를 직접 넣어 주면, 같은 라우트 정의로 원하는 화면을 문자열로 만들 수 있다. 라우트를 두 벌 관리하지 않아도 된다는 점이 핵심이다.

사전 렌더링 스크립트

// scripts/prerender.mjs
const template = await readFile(join(root, "dist/index.html"), "utf8");
const { render } = await import(join(root, "dist-server/entry-server.js"));
const routes = process.argv.slice(2).length ? process.argv.slice(2) : ["/"];

for (const route of routes) {
  const html = template.replace(/<div id="root">[\s\S]*?<\/div>/, `<div id="root">${render(route)}</div>`);
  const output = route === "/" ? join(root, "dist/index.html") : join(root, "dist", route.slice(1), "index.html");
  await mkdir(dirname(output), { recursive: true });
  await writeFile(output, html);
}
await rm(join(root, "dist-server"), { recursive: true, force: true });

/guide는 dist/guide/index.html이 된다. 정적 호스팅은 /guide/ 요청에 이 파일을 돌려주므로, 크롤러는 처음 받는 HTML에서 가이드 본문을 바로 읽을 수 있다.

브라우저에서 이어 붙이기: hydrateRoot

// src/main.tsx
const root = document.getElementById("root")!;
const app = <StrictMode><App /></StrictMode>;

if (root.hasChildNodes()) {
  hydrateRoot(root, app);        // 사전 렌더링된 HTML이 있으면 이어 붙인다
} else {
  createRoot(root).render(app);  // 없으면(사전 렌더링하지 않은 경로) 새로 그린다
}

사전 렌더링된 경로에서는 이미 HTML이 있으니 hydrateRoot로 이벤트만 연결한다. 사전 렌더링하지 않은 경로는 새로 그리려는 의도였는데, 이 코드는 처음 쓴 형태이고 동적 경로에서 문제가 있었다. 아래 "시행착오" 섹션에서 증상, 원인, 고친 코드를 다룬다.

하이드레이션에서 주의할 점은 서버와 브라우저의 첫 렌더링 결과가 같아야 한다는 것이다. localStorage에 저장된 명단처럼 브라우저에만 있는 값을 첫 렌더링에서 바로 읽으면 결과가 달라져 경고가 난다. 이런 값은 useEffect 안에서 읽어야 한다.

어떤 경로를 사전 렌더링할까

사전 렌더링할 경로는 빌드 명령에 직접 적는다. OTO는 /, /teamShare, /saveList, /privacy, /guide 다섯 개다. 기준은 **"누가 열어도 같은 내용인가"**였다. 홈, 가이드, 개인정보처리방침처럼 모든 방문자에게 같은 화면은 사전 렌더링한다. 반대로 /team/:teamId처럼 사용자가 저장한 명단을 보여 주는 경로는 내용이 브라우저의 localStorage에 달려 있어서 빌드 시점에 그릴 것이 없다. 이런 경로는 목록에서 빼면 파일이 만들어지지 않는다. Cloudflare Pages는 최상위 404.html이 없으면 사이트를 SPA로 보고, 정적 파일과 맞지 않는 모든 경로에 루트(/) 페이지를 돌려준다(Cloudflare Pages: Serving Pages). 그러면 브라우저가 라우터로 경로에 맞는 화면을 그린다.

실제로 운영 중인 OTO에서 확인했다(2026-10-23, curl).

요청 응답 본문
/ HTTP 200 5,807바이트
/team/abc123 (빌드 목록에 없는 경로) HTTP 200 /와 MD5가 같은 5,807바이트
/no/such/path HTTP 200 /와 MD5가 같은 5,807바이트
/404.html HTTP 200 /와 MD5가 같은 5,807바이트

목록에 없는 경로가 받는 것은 빈 파일이 아니라 사전 렌더링된 홈의 index.html이다. 존재하지 않는 주소도 404가 아니라 200으로 응답한다는 뜻이기도 하다.

검색에 나와야 하는 페이지일수록 사전 렌더링 목록에 꼭 넣어야 한다. 특히 가이드처럼 글이 많은 페이지는 애드센스 심사에서 사이트의 콘텐츠로 평가받는 부분이라 빠뜨리면 손해가 크다.

시행착오: 동적 경로에서 나온 하이드레이션 오류

증상

운영 중인 OTO에서 /team/abc123을 Chromium으로 열고 콘솔을 보니 Minified React error #418이 남았다(Chromium 156.0.8078.4, 2026-10-23). 사전 렌더링한 /에서는 같은 오류가 없었다. 화면은 팀 화면으로 정상 표시되어서, 오류 메시지를 보기 전까지는 알아채기 어려웠다.

원인

위의 폴백 표에서 확인했듯 사전 렌더링 목록에 없는 경로는 사전 렌더링된 홈의 index.html을 받는다. 처음 쓴 main.tsx는 root.hasChildNodes()만 보고 hydrateRoot를 부르므로, 이 경로에서도 홈 HTML에 이어 붙이려 한다. 그런데 브라우저의 첫 렌더는 홈이 아니라 팀 화면이라 서버 HTML과 달라진다. React 문서는 hydrateRoot가 서버 HTML과 같은 내용을 기대하며 불일치는 버그로 보고 고쳐야 한다고, 일부 오류는 복구되지만 최악의 경우 이벤트 핸들러가 엉뚱한 요소에 붙을 수 있다고 안내한다(React: hydrateRoot).

원인을 확정하려고 같은 빌드를 로컬에서 Cloudflare Pages처럼 동작하는 정적 서버(없는 경로는 index.html 폴백)로 띄워 경로별로 콘솔을 확인했다. 사전 렌더링한 다섯 경로에서는 오류가 없었고, 목록에 없는 세 경로에서만 #418이 났다.

수정

"HTML이 있는가"가 아니라 "그 HTML이 지금 경로의 것인가"를 봐야 한다. 사전 렌더링할 때 HTML이 어느 경로의 것인지 <html>에 남기고, 브라우저에서 현재 경로와 같을 때만 이어 붙인다.

// scripts/prerender.mjs (경로별 HTML을 만들 때)
html = replaceOnce(html, /<html lang="ko">/, `<html lang="ko" data-prerendered-path="${route}">`, "html");
// src/main.tsx
const normalize = (path: string) => path.replace(/\/+$/, "") || "/";
const prerenderedPath = document.documentElement.dataset.prerenderedPath;

if (root.hasChildNodes() && prerenderedPath !== undefined
    && normalize(prerenderedPath) === normalize(location.pathname)) {
  hydrateRoot(root, app);        // 이 경로의 사전 렌더링 HTML이면 이어 붙인다
} else {
  createRoot(root).render(app);  // 아니면(폴백으로 받은 다른 경로의 HTML 포함) 새로 그린다
}

경로 끝의 /는 정적 호스팅이 /teamShare를 /teamShare/로 리다이렉트하기 때문에 normalize로 맞췄다.

수정 전후 확인

같은 로컬 정적 서버에서 수정 전 빌드와 수정 후 빌드를 경로마다 열어 콘솔의 #418과, 이어 붙이기가 유지되는지(#root의 기존 DOM 노드가 제거되었는지)를 확인했다. 환경은 Chromium 156.0.8078.4(Playwright 1.64.1), macOS 26.6.2 arm64(Apple M5 Pro), 2026-10-23이다.

경로 수정 전 #418 수정 후 #418 수정 후 기존 DOM 제거
/ 없음 없음 0개(이어 붙임)
/teamShare/ 없음 없음 0개(이어 붙임)
/saveList/ 없음 없음 0개(이어 붙임)
/guide/ 없음 없음 0개(이어 붙임)
/privacy/ 없음 없음 0개(이어 붙임)
/team/abc123/ 있음 없음 2개(새로 그림)
/restriction/xyz/ 있음 없음 2개(새로 그림)
/no/such/path 있음 없음 2개(새로 그림)

사전 렌더링한 경로는 이전처럼 이어 붙고, 목록에 없는 경로는 새로 그려지며 오류가 사라졌다. 이 확인은 로컬 빌드에서 한 것이고, 운영 사이트에 반영한 뒤의 확인은 이 글에 포함하지 않았다.

같은 구조의 다른 앱

같은 hasChildNodes() 구조를 쓰는 앱이 더 있다. 운영 사이트에서 사전 렌더링 목록 밖의 경로를 열어 보니 지하철 어디의 /metro/seoul/과 임의 경로에서도 #418이 났다. 이 글에서는 OTO만 고쳤다. 사전 렌더링 경로에서도 오류가 난 앱이 있어서, 그 앱은 폴백이 아닌 다른 원인을 따로 조사해야 한다.

다시 보니: 모든 페이지가 같은 head를 갖고 있었다

빌드 결과를 다시 열어 보다가 문제를 발견했다. OTO의 세 경로를 비교하면 이렇다.

$ grep -o "<title>[^<]*</title>" dist/index.html dist/guide/index.html dist/teamShare/index.html
dist/index.html:<title>OTO | 쉽고 빠른 짝짓기 팀짜기 플랫폼</title>
dist/guide/index.html:<title>OTO | 쉽고 빠른 짝짓기 팀짜기 플랫폼</title>
dist/teamShare/index.html:<title>OTO | 쉽고 빠른 짝짓기 팀짜기 플랫폼</title>

$ grep -o '<link rel="canonical"[^>]*>' dist/guide/index.html
<link rel="canonical" href="https://oto.dailbit.com/" />

스크립트가 #root 안만 바꾸고 <head>는 템플릿 그대로 두기 때문에, 모든 경로가 홈과 같은 제목과 캐노니컬을 갖는다. 특히 캐노니컬이 문제다. /guide/ 페이지가 "이 페이지의 대표 주소는 홈입니다"라고 선언하는 셈이라, 검색 엔진은 가이드 페이지를 홈의 중복으로 보고 색인하지 않을 수 있다. 본문을 정성껏 사전 렌더링해 놓고 스스로 숨긴 것이다.

토크잡이의 방식: 경로별 SEO 정보도 함께

같은 구조를 쓰는 토크잡이는 서버 진입점에서 렌더링 함수와 함께 경로별 SEO 정보를 돌려주는 함수도 내보낸다.

// PickQuestion/src/entry-server.tsx (요약)
export function getSeo(url: string) {
  if (url === "/privacy") return {
    title: "개인정보처리방침 | 토크잡이",
    description: "토크잡이 서비스의 개인정보 및 광고 쿠키 처리 안내",
    canonical: "https://askdraw.dailbit.com/privacy",
  };
  const seo = /* 주제별 제목과 설명 */;
  return { title: `${seo.title} | 토크잡이`, description: seo.description,
           canonical: `https://askdraw.dailbit.com${url === "/" ? "/" : url}` };
}

사전 렌더링 스크립트는 <title>, description, og:title, og:description, twitter:*, canonical을 정규식으로 찾아 바꾼다. 결과적으로 /topic/couple/의 제목은 "커플 질문 | 토크잡이"가 된다.

다만 여기에도 작은 문제가 있다. 파일은 dist/topic/couple/index.html로 만들어져 실제 주소가 /topic/couple/(슬래시로 끝남)인데, 캐노니컬은 https://askdraw.dailbit.com/topic/couple(슬래시 없음)이다. 호스팅이 슬래시 없는 주소를 슬래시 있는 주소로 리다이렉트한다면, 캐노니컬이 리다이렉트되는 주소를 가리키게 된다. 캐노니컬은 최종 응답 주소와 정확히 같아야 한다.

정규식 치환 대신 템플릿 자리 표시자

정규식으로 기존 태그를 찾아 바꾸는 방식은 템플릿의 태그 순서나 따옴표 모양이 조금만 바뀌어도 조용히 실패한다. 치환이 안 되어도 에러가 나지 않기 때문에 발견이 늦다. 더 안전한 방법은 index.html에 자리 표시자를 두는 것이다.

<!-- index.html -->
<head>
  <!--app-head-->
</head>
<body><div id="root"><!--app-html--></div></body>
const head = renderHead(getSeo(route)); // title, meta, canonical 태그 문자열
const html = template
  .replace("<!--app-head-->", head)
  .replace("<!--app-html-->", render(route));
if (html.includes("<!--app-")) throw new Error(`치환되지 않은 자리 표시자: ${route}`);

자리 표시자가 남아 있으면 빌드를 실패시키면, 누락을 배포 전에 잡을 수 있다.

고친 결과

두 서비스 모두 고쳤다. OTO는 서버 진입점에 경로별 제목·설명을 담은 getSeo를 추가하고, 사전 렌더링 스크립트가 <title>, description, canonical, og:title, og:description, og:url, twitter:*를 경로마다 바꾸게 했다. 이때 태그를 찾지 못하면 조용히 넘어가지 않고 빌드를 실패시키도록 했다. 앞에서 말한 정규식 방식의 약점을 최소한으로 막는 장치다. 토크잡이는 캐노니컬과 og:url을 /topic/couple/처럼 슬래시로 끝나게 바꾸고, 홈과 같은 제목을 쓰던 /guide/에도 별도 제목과 설명을 줬다. 두 서비스의 사이트맵도 같은 형식으로 맞추고 빠져 있던 가이드 페이지를 추가했다.

$ grep -o '<link rel="canonical"[^>]*>' dist/guide/index.html
<link rel="canonical" href="https://oto.dailbit.com/guide/" />

정리

  • Vite의 SSR 빌드와 renderToString만으로 SPA의 주요 경로를 정적 HTML로 만들 수 있다. 라우트는 MemoryRouter/BrowserRouter 분기로 한 벌만 유지한다.
  • 브라우저에서는 "HTML이 있는가"가 아니라 "그 HTML이 지금 경로의 것인가"로 hydrateRoot와 createRoot를 나눈다. 폴백으로 다른 경로의 HTML을 받는 경로에서 hasChildNodes()만 보면 하이드레이션 불일치가 난다.
  • 본문만 사전 렌더링하고 <head>를 그대로 두면 모든 페이지가 같은 제목과 캐노니컬을 갖게 되어, 오히려 하위 페이지가 색인되지 않을 수 있다.
  • 캐노니컬은 실제 응답 주소(슬래시 포함 여부까지)와 정확히 같아야 한다.
  • 치환은 정규식보다 자리 표시자로 하고, 누락 시 빌드를 실패시킨다.