background blurbackground mobile blur

1/1/1970

내가 2일 만에 SSG를 구현한 방법

Howdy! 1년 전만 해도 이건 절대 못 할 거라고 생각했어요. 그런데 방금 Foony에 Static Site Generation(SSG)을 이틀 만에 구현을 끝냈고, 지금 꽤 들떠 있어요. Foony에 SSG를 붙이려는 시도는 이번이 처음도 아니에요. 예전에 NextJS, Vike, Astro, Gatsby 같은 것들을 다 살펴봤고, 다른 몇 가지 솔루션도 봤어요. 심지어 NextJS로 한 번 시작했다가 중간에 접기도 했는데, Foony의 SPA 구조가 워낙 복잡하고 파일이 수천 개라 금방 벽에 부딪혔거든요. 마이그레이션은 악몽이었을 거고, 몇 달은 그냥 날렸을 거예요. 게다가 사이트를 같이 만드는 다른 사람들도 전부 NextJS랑 그 특이한 규칙들을 새로 배워야 해서, 전체 복잡도만 더 올라갔을 거고요.

가볍고 구현하기 쉬운 무언가가 필요했어요. 우리가 지금까지 해 온 방식 그대로 코드를 쓰면서, SSG를 거의 의식하지 않아도 되는 방식이요 (useMediaQuery는 어쩔 수 없이 예외지만요, 이건 도망갈 방법이 거의 없어요). 아래에서는 왜 맞춤형 솔루션을 선택했는지, 어떤 문제들을 만났는지(특히 React의 Suspense 경계 관련해서), 그리고 그걸 어떻게 풀었는지 차근차근 얘기해 볼게요.

왜 표준 솔루션을 쓰지 않았을까?

처음 Foony에 SSG를 붙이려고 했을 때, 당연히 업계 표준인 NextJS랑 Vike, Astro 같은 것부터 생각해 봤어요.

NextJS: 마이그레이션이 너무 거대함

NextJS는 강력하긴 해요. 하지만 Foony의 기존 React SPA 전체를 엄청나게 갈아엎어야 했어요. 우리는 파일이 수천 개고, 라우팅 로직도 복잡하고, 커스텀 인프라도 한가득 있거든요. NextJS로 옮긴다는 건 곧 이런 걸 의미했어요:

  • 라우팅 시스템 전체를 다시 짜기
  • 게임이랑 컴포넌트를 불러오는 방식을 전부 재구성하기
  • 기능을 그대로 유지하는 것만으로도 몇 달은 작업해야 함
  • 사용자 입장에서 깨지는 변화가 생길 위험
  • 이미지를 처리하는 방식까지 싹 바꾸기
  • 빌드 속도가 눈에 띄게 느려질 가능성 (아마 5~30분 정도. 이 5년 전 GitHub 토론 말고는 정확한 숫자를 들이밀 수 있는 건 없어요)
  • 팀 전체가 새로운 걸(NextJS) 공부해야 하고, 개발 속도는 영구적으로 느려질 가능성
  • NextJS가 큰 변경을 할 때마다 또다시 코드를 옮겨야 함

실제로 NextJS로 한 번 시작해 보기도 했는데, 금방 마이그레이션 비용이 너무 크다는 걸 깨달았어요. 그 정도 복잡함을 감수할 가치는 없었죠.

Vike: 비슷한 수준의 복잡도

Vike(이전 이름은 vite-plugin-ssr)도 비슷한 문제가 있었어요. NextJS보다는 더 유연하긴 하지만, 여전히 코드베이스를 크게 뜯어고쳐야 했죠. 새로 배워야 하는 양과 마이그레이션 노력이 이득에 비해 전혀 맞지 않았어요.

Astro: 아키텍처가 안 맞음

Astro는 콘텐츠 위주의 사이트에는 정말 좋아요. 하지만 Foony는 꽤 복잡한 멀티플레이어 게임 플랫폼이에요. 실시간 업데이트, WebSocket 연결, 동적인 React 컴포넌트가 기본으로 깔려 있어야 하죠. Astro의 아키텍처는 우리가 만들고 있는 것과는 잘 안 맞아요.

해결책: 맞춤형 SSG

며칠 전에 i18n을 구현한 뒤에 시도했던 "가짜 SSG" 방식에서 용기를 얻어서, Foony용 SSG는 작고 가벼운 맞춤형 솔루션으로 가기로 했어요.

제가 썼던 "가짜 SSG" 방식은, 블로그 글이 있는 페이지들(/posts 라우트랑 게임 페이지들)에서 블로그 콘텐츠만 뽑아 와서, 클라이언트가 렌더링할 위치에 그대로 붙여 두는 거였어요. 검색 엔진이랑 LLM들이 Foony를 좀 더 잘 이해하도록 돕기 위한 거였죠. 거기에 ld+json 스키마랑 자잘한 SEO 설정도 같이 넣었고요.

접근 방법은 아주 단순해요:

  1. 기존 React SPA 위에 그대로 얹기: 마이그레이션 없이, 빌드 시점에 SSG 생성만 살짝 추가하기
  2. renderToReadableStream 사용하기: React 18의 스트리밍 SSR API가 Suspense를 기본으로 처리해 줘요.
  3. 정적 HTML 파일 생성하기: 빌드 시점에 라우트를 미리 렌더링해서 정적 파일로 서빙하고, 라우트 목록은 우리 SitemapGenerator로 가져오기
  4. 기존 코드베이스 변경 최소화: 대부분의 컴포넌트는 그대로 둔 채로 작동

client/src/generators/GenerateShellSsgFromSitemap.ts에 핵심 구현이 다 들어 있어요. 여기서 사이트맵을 읽고, React의 renderToReadableStream으로 각 라우트를 렌더링한 다음, 그 HTML을 정적 파일로 써 줍니다. 심플하고, 제가 딱 좋아하는 스타일이에요!

속도도 꽤 잘 나왔어요. 라우트 약 2,800개를 렌더링하는 데 10초 정도면 끝나요. 괜찮죠. NextJS, Gatsby, Astro보다 훨씬 빠른 수준이에요. <img alt="걸린 시간을 보여주는 SSG 콘솔 로그" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />

단순함 얘기만 하자면 끝도 없어요. 큰 회사에서는 "복잡성이 부족하다"는 이유로 승진 포인트가 안 될 수도 있지만, 단순한 코드는 아름답고, 유지보수하기 좋고, 전반적으로 개발 속도를 훨씬 잘 지켜 줘요. 이런 점이 제가 Zen 원칙을 정말 좋아하는 이유이기도 해요.

Suspense 경계 문제

자, 이제 SSG도 있고, HTML 안에 콘텐츠도 잘 들어가 있는데... 정작 페이지는 새하얗게 비어 있는 거예요! 이게 어떻게 된 거죠?! <img alt="SSG가 만든 빈 페이지" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

알고 보니 renderToReadableStreamawait stream.allReady를 써도 여전히 Suspense 경계가 남아 있었어요. 제 추측으로는, 이게 "스트림"이라서, 서버에서 바이트를 받는 대로 클라이언트로 흘려보내도록 설계됐기 때문인 것 같아요.

React가 실제로 내보내는 것

renderToReadableStream을 Suspense랑 같이 쓰면, React는 대략 이런 HTML을 내보내요:

<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
  <!-- Actual content here -->
</div>
...
<script>/*Script that replaces the suspense boundaries*/</script>

<template id="B:0">는 실제 콘텐츠가 들어갈 자리 표시자예요. <div hidden id="S:0"> 안에 진짜 렌더링된 내용이 들어가고요. B:0S:0는 숫자로 매칭돼요(0부터 시작하는 인덱스).

자바스크립트가 없으면, 검색 엔진들(특히 너, Bing)이나 LLM들은 템플릿 자리 표시자만 살짝 보이는 거의 빈 페이지를 보게 돼요. 그러면 SSG를 하는 의미가 완전히 사라지죠!

이 Suspense 경계들을 깔끔하게 없앨 방법이 딱히 보이질 않아서, 저는 그냥 테스트 몇 개를 쓰고 resolveSuspenseBoundaries라는 함수를 만들어서 이걸 갈아끼우는 방식을 택했어요. JSDOM 같은 걸로 HTML을 파싱하고 스크립트를 실행하는 것보다 훨씬 빨랐고요. 그리고 더 중요한 건, 제가 원하던 목표 때문이었어요. 자바스크립트가 없어도 검색 엔진이랑 LLM들이 보기 좋은, 읽기 쉬운 페이지를 제공하면서, 클라이언트에선 여전히 Suspense 경계와 하이드레이션를 지원하는 거죠.

변환 테스트하기

먼저 지금 가진 상태(자바스크립트 꺼 둔 DOM)와 내가 원하는 상태(자바스크립트 켠 DOM)에서 예시들을 몇 개 뽑아서, 그걸 기준으로 변환 테스트를 만들기 시작했어요. 이걸 LLM에 넣어서 테스트 코드를 만들어 달라고 했고, 이런 일에는 LLM이 꽤 능력이 좋더라고요. 이 테스트들은 client/src/generators/ssr/renderRoute.test.ts에 있고, 변환이 제대로 동작하는지 확인해 줘요. 테스트 내용은 대략 이런 것들을 다루고 있어요:

  • 단순한 경계 치환(블로그 목록 페이지)
  • 템플릿이랑 닫는 주석 사이에 내용이 끼어 있는 복잡한 경계
  • 여러 개의 경계가 한 페이지에 있는 경우
  • 주석 마커가 없는 경계
  • 그 밖의 엣지 케이스들

이런 식의 "TDD"는, 기대하는 입력과 출력이 딱 정해져 있는 이런 종류의 작업에서는 꽤 유용해요.

그렇다고 해서 "Robert C. Martin이 말했으니까 모든 걸 TDD로 하자"는 얘기는 절대 아니어요(그렇게 하면 팀 개발 속도는 확실히 느려져요). UI처럼 계속 바뀌는 영역이나, 자주 변하는 코드에 TDD를 들이붓는 건 정말 비추입니다!

해결책: resolveSuspenseBoundaries

테스트를 다 마련해 두고 나서는, LLM에게 resolveSuspenseBoundaries 함수를 써 달라고 했어요. 여기서는 정규식을 쓰면 SSG 시간이 한 40%는 줄어들긴 하지만, 정규식이 너무 부서지기 쉬워서 cheerio를 쓰는 쪽을 택했어요.

export function resolveSuspenseBoundaries(html: string): {html: string; didResolveSuspense: boolean} {
  const originalHtml = html;
  const $ = cheerio.load(originalHtml, {xml: false, isDocument: false, sourceCodeLocationInfo: true});
  const operations: Array<{index: number; removeLength: number; insertText?: string}> = [];

  // 숨겨진 div들의 내용과 위치를 수집합니다.
  const hiddenDivs = new Map<string, {content: string; divStartIndex: number; divEndIndex: number}>();
  $('div[hidden][id^="S:"]').each((_, el) => {
    const id = $(el).attr('id');
    if (!id) {
      return;
    }
    const boundaryId = id.substring(2);
    const content = $(el).html() || '';
    const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
    if (typeof startOffset === 'number' && typeof endOffset === 'number') {
      hiddenDivs.set(boundaryId, {content, divStartIndex: startOffset, divEndIndex: endOffset});
    }
  });

  if (hiddenDivs.size === 0) {
    return {html: originalHtml, didResolveSuspense: false};
  }

  // 템플릿(B:0)을 찾아서, React의 내부 $RV 동작을 따라
  // 일치하는 숨겨진 콘텐츠(S:0)로 교체합니다.
  $('template[id^="B:"]').each((_, el) => {
    const id = $(el).attr('id');
    if (!id) {
      return;
    }
    const boundaryId = id.substring(2);
    const divInfo = hiddenDivs.get(boundaryId);
    if (!divInfo) {
      return;
    }
    const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
    if (typeof startOffset !== 'number' || typeof endOffset !== 'number') {
      return;
    }

    const templateIndex = startOffset;
    const templateLength = endOffset - startOffset;
    const afterTemplate = originalHtml.substring(templateIndex + templateLength);
    const closingCommentMatch = afterTemplate.match(/<!--\/[
amp;]-->/); const removeEndIndex = closingCommentMatch ? templateIndex + templateLength + closingCommentMatch.index! : templateIndex + templateLength; const divContentStartIndex = originalHtml.indexOf('>', divInfo.divStartIndex) + 1; const divContentEndIndex = originalHtml.lastIndexOf('</', divInfo.divEndIndex); const divContent = originalHtml.substring(divContentStartIndex, divContentEndIndex); operations.push({index: templateIndex, removeLength: removeEndIndex - templateIndex}); operations.push({index: templateIndex, removeLength: 0, insertText: divContent}); operations.push({index: divContentStartIndex, removeLength: divContentEndIndex - divContentStartIndex}); operations.push({index: divInfo.divStartIndex, removeLength: divContentStartIndex - divInfo.divStartIndex}); operations.push({index: divContentEndIndex, removeLength: divInfo.divEndIndex - divContentEndIndex}); }); operations.sort((a, b) => (a.index !== b.index ? b.index - a.index : b.removeLength - a.removeLength)); let resultHtml = originalHtml; for (const operation of operations) { resultHtml = resultHtml.slice(0, operation.index) + (operation.insertText ?? '') + resultHtml.slice(operation.index + operation.removeLength); } return {html: resultHtml, didResolveSuspense: true}; }

이렇게 하면 검색 엔진이랑 LLM들이 거의 빈 페이지 대신, 완전히 렌더링된 페이지를 보게 돼요.

이제 자바스크립트 없이도 SSG가 제대로 돌아가게 됐어요! <img alt="Foony 블로그용 자바스크립트 없는 SSG" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />

장기적으로는 React가 Suspense 포맷을 바꿀 수도 있어요. 나중에 lazy-load되는 페이지들(그래서 Suspense 경계가 꼭 필요한 페이지들)에 더 나은 해결책이 생기면, 이 Suspense 해소 코드는 아예 치워 버릴 수도 있을 것 같아요.

하이드레이션 전략 (업데이트: 3일 + 추가로 1일 걸림)

하이드레이션은 어렵다는 거, 이미 알고 있었어요. 그래도 조금씩 파고들다 보니 결국 잘 돌아가게 만들었습니다!

하이드레이션에 걸린 총 시간: 3일 + 디하이드레이션 방식을 갈아엎는 데 추가 1일.

가장 어려웠던 부분은, 최소한으로라도 "한 번 제대로 하이드레이션이 되는 상태"를 만드는 거였어요. 네비게이션 바랑 같이 "Hello World"를 한 번이라도 제대로 렌더링하는 데 성공하고 나니까, "아, 이거 한 달짜리 프로젝트는 아니겠구나!" 하는 자신감이 생기더라고요.

<img alt="Foony에서 네비게이션 바와 함께 Hello World가 정상적으로 하이드레이션된 모습" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

그 최소한의 동작하는 하이드레이션을 만들 때 나름 독특한 도전 과제가 하나 있었어요. 하이드레이션은 필요하지만, 개발자들이 Suspense 경계를 의식하지 않아도 되면서, 검색 엔진이랑 LLM 입장에서도 SEO가 잘 나와야 했거든요.

문제점

React 하이드레이션은 정말 말 그대로의 의미예요. 첫 렌더를 할 때 DOM이 React가 예상하는 모습과 조금이라도 다르면, 콘솔에는 그다지 도움이 안 되는 에러 메시지가 하나 찍히고, React는 그냥 전부 버리고 처음부터 다시 렌더링해요. 뭐가 어떻게 틀렸는지 보여 주는 diff 같은 것도 없어요!

우리 상황에서는 SSG 때문에 이게 몇 가지 면에서 더 심각해졌어요:

  1. React 18 스트리밍이 만들어 내는 Suspense 관련 찌꺼기들을 지우거나 풀어 주려고, HTML을 후처리했어요(봇 입장에서는 아주 좋지만요).
  2. 클라이언트가 시점 (t = 0)에 서버 렌더링 때와 완전히 똑같은 데이터를 항상 갖고 있는 건 아니었어요(SSG 데이터, 블로그 메타데이터 등).
  3. 우리 i18n은 기본이 "lazy"라서, SSG 때 어떤 번역 키들이 쓰였는지 기록해 두고 React가 렌더링하기 전에 미리 주입해 주지 않으면, 첫 렌더에서는 번역이 빠져 있을 수도 있어요.

처음에 쓴 방법 (디하이드레이션)

처음에는 나름 영리하고 귀여운 방법을 하나 시도해 봤어요. HTML의 Suspense 경계를 풀어 내는 데 사용한 명령들을 커맨드 패턴으로 전부 기록해 두고, 그걸 거꾸로 돌리는 변환 명령을 만들어서, 나중에 React 하이드레이션에 필요한 모양의 HTML로 다시 복원할 수 있게 하는 방식이었죠. 이렇게 하면 index.html에 실어 보내야 하는 바이트 수를 확 줄일 수 있을 거라고 기대했어요. 하지만 대부분의 "영리한" 해결책이 그렇듯, 브라우저가 HTML을 미세하게 바꾸는 바람에 망했어요. 예를 들면 ;/를 살짝 지우거나 추가한다든가 하는 식으로요. 그러다 보니 우리가 기억해 둔 치환 인덱스들이 전부 틀어져 버렸죠. 이론적으로는 이런 브라우저의 미묘한 수정들까지 전부 감안해서 만들 수도 있겠지만, 그렇게까지 부서지기 쉬운 걸 실제로 서비스에 쓰고 싶진 않았어요. 그래서 Suspense 경계 변환을 다시 React 스트리밍 마크업으로 "되감기"하려 하지 말고, 아주 단순한 방법으로 방향을 틀었어요:

원본, 아직 해소되지 않은 HTML을 통째로 <script type="text"> 안에 같이 실어 보내기.

이 "디하이드레이션" 방식은 일단 잘 작동했어요. 하지만 나중에 더 나은 방법으로 갈아타느라 하루를 더 썼습니다.

더 나은 방법: 크리티컬 패스 Suspense 경계 치환

처음 구현을 끝내고도 여전히 Suspense 경계 쪽에서 문제가 조금씩 튀어나오더라고요. 그때 "아, 더 깔끔하고, 더 좋고, 더 단순한 방법이 있겠다"는 걸 깨달았어요. 그래서 디하이드레이션 방식 대신 크리티컬 패스 Suspense 경계 치환으로 갈아탔고, 이 방식은:

  • 하이드레이션 전에 크리티컬 패스를 먼저 로드해요: SSR 시점에 미리 로드됐던 컴포넌트들을 찾아서, 클라이언트에서도 hydrateRoot를 호출하기 전에 똑같이 미리 로드해 둡니다.
  • 유지보수가 훨씬 쉬워요: React 내부 동작을 파거나 AST를 파싱할 필요가 없어요(디하이드레이션 방식은 HTML을 파싱해서 다시 복원해야 했거든요).
  • 전송하는 바이트가 줄어요: 더 이상 React가 만든 원본 SSR 응답을 스크립트 태그 안에 그대로 싣지 않아도 돼요.
  • 깜빡임을 막아 줘요: HTML을 디하이드레이트/리하이드레이트할 필요가 없어서, 화면이 순간적으로 번쩍이는 현상을 없앨 수 있어요.

구현 방식은 간단해요. SSR 중에 어떤 lazy 컴포넌트가 미리 로드됐는지 SSRLazyComponentTracker로 추적하고, 그 import 경로를 하이드레이션 데이터에 넣은 다음, 클라이언트에서는 하이드레이션 전에 동기적으로 미리 로드해 줍니다. 이렇게 하면 크리티컬 패스 컴포넌트들은 Suspense 경계 없이 바로 렌더링되고, SSR 결과와 완전히 똑같이 맞춰져요.

그 밖의 부분들은, 첫 번째 클라이언트 렌더가 사실상 SSR/SSG처럼 동작하도록 만들었어요. 같은 입력을 쓰고, 그 입력을 hydrateRoot 전에 동기적으로 준비해 두는 거죠. 이건 우리가 쓰는 "ssg-data" 번들링으로 해결했어요.

구체적으로는 이런 식으로 손봤어요:

  1. SSR에 쓰였던 입력들을 하나의 텍스트 스크립트로 묶기

    • SSG 시점에, Vite 모듈 엔트리포인트 바로 앞에 <script type="text/foony-ssg" id="foony-ssg-data">...</script>를 주입해요.
    • 그 스크립트 안에는 이런 것들이 들어 있어요:
      • html: 정적 파일로 실제 전송된, Suspense가 해소된 HTML
      • ssgData: SSR 래퍼에서 사용한 SSGData를 직렬화한 값. 나중에는 Proxy 같은 걸 써서 실제로 접근된 데이터만 포함되게 바꿀 생각이에요.
      • translationData: SSR 중에 한 번이라도 접근했던 번역 key-value 덩어리들
  2. 하이드레이션 직전에 그 입력들을 주입하기

    • main.tsx에서 동기적으로:
      • #root.innerHTML을 직렬화된, Suspense가 해소된 HTML로 설정해서, DOM이 하이드레이션이 기대하는 모습과 정확히 같게 만들고
      • 앱을 SSGDataProvider로 감싸서, 첫 렌더 때 컴포넌트들이 SSR 때와 같은 SSGData를 보게 해요
  3. 번역 값을 미리 주입해서 i18n을 즉시 동작하게 만들기

    • SSR에서 실제로 접근된 번역 객체들을 기록해 두고, SSG 스크립트에 같이 실어 보냅니다.
    • 클라이언트에서는 전용 LocaleQueryer.inject() 메서드를 통해 이 값들을 그대로 LocaleQueryer 캐시에 넣어서, 번역이 바로바로 나올 수 있게 했어요.

이렇게 해 두면, 첫 번째 렌더가 SSR 때와 똑같은 데이터를 볼 수 있어요!

useIsSSRMode() 훅은 이미 client/src/generators/ssr/isSSRMode.ts에 구현돼 있어요:

export function useIsSSRMode(): boolean {
  const [isSSRMode, setIsSSRMode] = React.useState(true);
  
  React.useEffect(() => {
    // 마운트 이후(하이드레이션 완료 후)에는 클라이언트 모드로 전환
    setIsSSRMode(false);
  }, []);
  
  return isSSRMode;
}

이 훅은 SSR 중이거나 클라이언트의 첫 렌더(하이드레이션)까지는 true를 리턴하다가, 마운트가 끝나면 false로 바뀌어요. UserBanner, Navbar, Dialog 같은 컴포넌트들은 이미 이 훅을 써서 하이드레이션에서 어긋나는 걸 막고 있어요.

  1. 더 나은 diff를 위해 React 패치하기

원래는 hydration-overlay만 가져다 쓰면 되겠지 하고 생각했어요. 그런데 이 프로젝트는 활발히 유지보수되고 있지도 않고, React 18까지만 지원하고, 프로덕션에서 쓰기엔 상태가 아쉬웠어요. 그래서 LLM에게 저장소를 클론해서 참고만 하고, 몇 분 만에 최소한의 하이드레이션 오버레이를 새로 만들어 달라고 했죠. 거창한 건 필요 없었고, 개발 중에 어디가 잘못됐는지 보여 주기만 하면 되는 정도면 충분했어요.

새로 만든 오버레이는 엄청 기본적인 수준이라, diff가 완전히 정확하진 않아요. React가 주석을 지워 버리기도 하고, style 속성 뒤에 ;를 붙이기도 하고, 공백을 바꾸기도 하고, 이런저런 자잘한 걸 손대는데, 아직는 그런 차이들을 다 반영하지 못하고 있거든요. 우리가 만든 오버레이는 React가 하이드레이션할 때는 무시해 버리는 HTML 주석도 포함돼 있고요.

<img alt="우리가 새로 만든 하이드레이션 오버레이" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />

그래도 어디를 고쳐야 하는지는 충분히 보여 줘요.

<img alt="React 하이드레이션을 위해 SSG와 클라이언트 첫 렌더를 비교한 diff" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />

숫자로 보는 이번 구현

이번 구현이 어느 정도 규모였는지 감이 오도록 정리해 보면:

  • 2일 작업(시작부터 동작하는 SSG까지). 휴가 중에 했고, 실제로는 24시간 조금 넘게 쓴 셈이에요.
  • 하이드레이션이 비동기 번역 레이스나 useMediaQuery 때문에 망가지지 않게 정리하는 데 4일.
  • 디하이드레이션 방식을 크리티컬 패스 Suspense 경계 치환으로 바꾸는 데 추가 1일(더 단순하고, 전송 바이트도 줄고, 깜빡임도 없음).
  • 핵심 SSG 생성 코드(GenerateShellSsgFromSitemap.ts) 약 200줄
  • Suspense 경계 해소 코드(renderRoute.tsx 안의 resolveSuspenseBoundaries) 약 120줄 - 참고: 나중에 크리티컬 패스 방식으로 대체됐어요
  • SSR 유틸리티(isSSRMode.ts) 약 50줄
  • 테스트(renderRoute.test.ts) 약 100줄
  • SSR용 폴리필(setupSSREnvironment) 약 150줄
  • 기존 컴포넌트 변경은 최소한으로(대부분 useIsSSRMode() 체크만 추가)

전체 솔루션은 가볍고 유지보수하기 쉬워요. 프레임워크를 갈아탈 필요도 없고, 기존 React SPA랑도 잘 맞습니다.

핵심 정리

때로는 맞춤형 솔루션이 더 낫다

모든 문제가 프레임워크를 필요로 하는 건 아니에요. Foony에게는 작고 맞춤형인 SSG 솔루션이 더 잘 맞았어요. 이건 이런 특징이 있어요:

  • 가볍다: 무거운 의존성이나 프레임워크 오버헤드가 없음
  • 유지보수가 쉽다: 우리가 이해하고 있는 단순한 코드
  • 유연하다: 필요할 때 고치거나 확장하기 쉬움
  • 호환된다: 마이그레이션 없이 기존 React SPA와 함께 동작

React의 스트리밍 SSR에는 특이한 점이 있다

React의 renderToReadableStream은 Suspense를 다루기에는 꽤 괜찮은 도구지만, 특이한 점이 있어요. await stream.allReady를 써도 결과물에는 여전히 Suspense 경계가 남아요. 이건 버그가 아니라, 스트리밍을 위한 의도된 동작이에요. 하지만 SSG 입장에서는 완전히 해소된 HTML이 필요하죠. 이런 시나리오를 깔끔하게 처리할 수 있는 옵션을 안 넣은 건 React 팀의 아쉬운 지점처럼 느껴져요.

그래서 저는 HTML을 후처리해서 경계를 직접 해소하는 쪽을 택했어요. 엄청 예쁜 방법은 아니지만, 제 사용 사례에서는 충분히 빠르고 유연했어요.

TDD는 LLM과 함께 쓸 때 꽤 유용할 수 있다

HTML을 변환하는 작업은 실수하기 정말 쉬워요. 작은 버그 하나로 SSG 출력 전체가 깨져서, 최종 사용자 경험까지 망가질 수 있죠. 그래서 저는 LLM에게 (제가 예시를 주고) 꽤 포괄적인 테스트들을 쓰게 해서, 변환이 제대로 동작하는지 검증하게 했어요.

마무리

이제 Foony에는 SSG가 잘 붙어 있어요. 페이지는 검색 엔진과 LLM을 위해 완전히 렌더링된 상태로 제공되고, 솔루션 자체도 가볍고 유지보수하기 쉬워요. SSG 라우트에 하이드레이션을 붙이는 데는 생각보다 시간이 더 걸렸고(3일), 처음에 썼던 디하이드레이션 방식을 크리티컬 패스 Suspense 경계 치환으로 바꾸느라 하루를 더 썼어요. 새 방식은 유지보수가 더 쉽고, 전송 바이트도 줄고, HTML을 디하이드레이트/리하이드레이트하면서 생길 수 있는 시각적인 깜빡임도 막아 줍니다.

맞춤형 SSG 솔루션을 만드는 데 진짜로 이틀밖에 안 걸렸다는 게 지금도 좀 놀라워요. 하지만 때로는 가장 단순한 방법이 가장 알맞은 해답이기도 하죠.

앞으로 할 일로는 하이드레이션 매칭을 더 완성도 있게 다듬고, 디버깅을 더 잘 할 수 있도록 React를 패치하는 것도 검토하고 있어요. 그래도 일단 지금은 Foony에 잘 동작하는 SSG가 생겼다는 것만으로도 충분히 만족스럽습니다. 앞으로 몇 주 동안은 Google Search Console이랑 Bing Webmaster Tools을 보면서, 이번 변화가 우리 SEO에 어떤 영향을 주는지 지켜볼 생각이에요.

8 Ball Pool online multiplayer billiards icon