background blurbackground mobile blur

1/1/1970

2일 만에 SSG를 구현한 방법

안녕하세요! 1년 전이었다면 이건 불가능하다고 생각했을 거예요. 그런데 방금 Foony에 정적 사이트 생성(SSG)을 단 2일 만에 구현했고, 꽤 신이 난 상태예요. 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:0은 숫자(0부터 시작하는 인덱스)로 S:0과 매칭돼요.

JavaScript가 없으면, 검색 엔진(Bing, 너 말이야)과 LLM은 그냥 템플릿 자리 표시자만 있는 거의 빈 페이지를 보게 돼요. 그러면 SSG의 전체 목적이 무색해지죠!

이 Suspense 경계를 깔끔하게 제거할 방법을 찾지 못해서, 테스트를 작성하고 이걸 교체하는 resolveSuspenseBoundaries 함수를 만들기로 했어요. HTML을 파싱하고 JSDOM 같은 걸로 스크립트를 실행하는 것보다 빨랐어요. 그리고 더 중요한 건, 이게 제가 계획한 것에 필수적이었다는 거예요: JavaScript 없이도 검색 엔진/LLM에 잘 읽히는 사이트를 제공하면서, 클라이언트에서는 Suspense 경계와 hydration을 지원하는 것 말이에요.

변환 테스트하기

먼저 가지고 있던 것(JavaScript 비활성화)과 원하는 것(JavaScript 활성화)에 대한 DOM의 일부 예제를 가져와서 변환에 대한 테스트를 작성했어요. 이걸 LLM에 넣고 테스트 생성을 맡겼는데, 이런 일에 꽤 능숙하더라고요. 이 테스트는 client/src/generators/ssr/renderRoute.test.ts에 있고 변환이 올바르게 작동하는지 확인해요. 테스트가 다루는 내용:

  • 단순 경계 교체(블로그 목록)
  • 템플릿과 닫는 주석 사이에 콘텐츠가 있는 복잡한 경계
  • 다중 경계
  • 주석 마커가 없는 경계
  • 엣지 케이스

이런 종류의 "TDD"는 예상되는 입력과 출력이 있는 이 사용 사례에 정말 유용해요.

"Robert C. Martin이 말했으니 모든 것에 TDD를 적용하라"(이건 팀의 개발 속도를 늦출 거예요)와 혼동하면 안 돼요. UI나 끊임없이 변하는 코드 영역에는 TDD를 사용하면 안 됩니다!

해결책: resolveSuspenseBoundaries

이제 테스트가 준비되었으니, LLM에게 resolveSuspenseBoundaries 함수를 작성하도록 했어요. RegEx를 쓰면 SSG 시간을 약 40% 줄일 수 있었지만, RegEx의 취약함을 피하려고 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}> = [];

  // Collect hidden divs with their content and positions.
  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};
  }

  // Find templates (B:0) and replace them with the matching hidden content (S:0),
  // following React's internal $RV behavior.
  $('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이 거의 빈 페이지 대신 완전히 렌더링된 페이지를 보게 돼요.

이제 JavaScript 없이도 SSG가 잘 작동해요! <img alt="Foony 블로그를 위한 No JavaScript 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 해결 코드를 제거할지도 몰라요.

Hydration 전략 (업데이트: 이건 3일 + 1일 추가가 걸렸어요)

Hydration은 어려워요. 알고 있었어요. 하지만 약간의 작업 끝에 작동하게 만들었어요!

Hydration 총 소요 시간: 3일, 그리고 dehydration 접근법을 교체하는 데 추가로 1일.

가장 까다로운 부분은 그저 첫 번째 최소한의 작동하는 hydrate를 얻는 것이었어요. 네비게이션 바와 함께 "Hello World"를 렌더링할 수 있게 되자, 그래, 이게 한 달이 걸리지는 않을 수도 있겠다는 자신감이 생겼어요!

<img alt="네비게이션 바와 함께 성공적으로 hydrate되는 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" }} />

그 첫 번째 최소한의 작동하는 hydrate를 위해, 저는 독특한 도전 과제가 있었어요: hydration도 원했지만, 개발자가 Suspense 경계에 대해 생각하지 않고도 검색 엔진과 LLM을 위한 좋은 SEO도 원했어요.

도전 과제

React hydration은 극도로 문자 그대로예요: DOM이 React가 그 첫 번째 렌더링에 대해 기대하는 것과 다르면, 콘솔에 거의 쓸모없는 친절한 에러 메시지가 뜨고, React는 모든 걸 버리고 처음부터 다시 렌더링해요. 무엇이 잘못되었는지 알려주는 diff조차 없이요!

우리의 경우, SSG가 이걸 몇 가지 방식으로 더 악화시켰어요:

  1. React 18 스트리밍 Suspense 아티팩트를 제거/해결하기 위해 HTML을 후처리했어요(이건 봇에게는 좋아요).
  2. 클라이언트는 시각 (t = 0)에 서버 렌더링이 가지고 있던 것과 정확히 동일한 데이터를 항상 가지고 있지는 않았어요(SSG 데이터, 블로그 메타데이터 등).
  3. 우리의 i18n은 기본적으로 "lazy"여서, SSG에서 어떤 번역이 사용되었는지 기록하고 React가 렌더링하기 전에 주입하지 않으면 첫 렌더링에서 번역이 누락될 수 있어요.

작동했던 것 (초기 접근법: Dehydration)

처음에는 영리하고 귀여운 시도를 했어요: 명령 패턴을 사용해서 HTML의 Suspense 경계를 해결하는 데 사용된 명령을 기록하고, 역변환 명령을 반환해서 hydration에 필요한 React의 형태로 HTML을 복원할 수 있게 했어요. 이 명령 방식으로 index.html에 훨씬 적은 바이트를 보낼 수 있길 바랐어요. 하지만 대부분의 영리한 솔루션이 그렇듯, 이건 실패했어요. 브라우저가 ;/를 추가하거나 제거하는 등 미묘한 방식으로 HTML을 수정해서 교체 인덱스가 어긋났거든요. 기술적으로는 이런 미묘한 브라우저 변경을 고려할 수도 있겠지만, 그렇게 취약한 걸 출시할 생각은 없었어요. Suspense 경계 변환을 React의 스트리밍 마크업으로 "역변환"하려는 대신, 정말 간단한 일을 했어요:

원본 미해결 HTML을 <script type="text">에 번들링한다.

이 "dehydration" 접근법은 작동했지만, 더 나은 솔루션으로 교체하는 데 추가로 하루를 썼어요.

더 나은 접근법: 크리티컬 패스 Suspense 경계 교체

초기 구현 후에도 Suspense 경계와 관련된 몇 가지 문제가 여전히 있었어요. 그때 더 깔끔하고, 더 좋고, 더 단순한 솔루션이 있다는 걸 깨달았어요. dehydration 접근법을 크리티컬 패스 Suspense 경계 교체로 바꿨는데, 이건:

  • Hydration 전에 크리티컬 패스를 로드함: SSR 동안 사전 로드된 컴포넌트들이 식별되고 hydrateRoot가 호출되기 전에 클라이언트에서 사전 로드돼요
  • 유지보수가 더 단순함: React 내부나 AST 파싱이 필요 없어요(dehydration 접근법은 HTML을 파싱하고 복원해야 했어요)
  • 더 적은 바이트 전송: React의 원본 SSR 응답을 스크립트 태그에 번들링하지 않아요
  • 잠재적인 깜빡임 방지: HTML을 dehydrate/rehydrate할 필요가 없어서 잠재적인 시각적 깜빡임을 제거해요

구현은 SSR 동안 어떤 lazy 컴포넌트가 사전 로드되었는지(SSRLazyComponentTracker를 통해) 추적하고, 그들의 import 경로를 hydration 데이터에 포함시키고, hydration 전에 동기적으로 사전 로드해요. 크리티컬 패스 컴포넌트는 Suspense 경계 없이 직접 렌더링되어 SSR 출력과 정확히 일치해요.

그 외 모든 것에 대해서는, 첫 번째 클라이언트 렌더링이 SSR/SSG처럼 작동하도록 해요. 즉, 동일한 입력을 사용하고, 그 입력을 hydrateRoot 전에 동기적으로 사용 가능하게 만들어요. 이건 우리의 "ssg-data"를 통한 번들링으로 이루어져요.

구체적으로, 조정 사항은:

  1. SSR 입력을 단일 텍스트 스크립트로 번들링

    • SSG 동안, Vite 모듈 진입점 직전에 <script type="text/foony-ssg" id="foony-ssg-data">...</script>를 주입해요.
    • 그 스크립트에는 다음이 포함돼요:
      • html: 정적 파일에 실제로 출고한 해결된 HTML
      • ssgData: SSR 래퍼가 사용한 직렬화된 SSGData. 접근된 데이터만 포함되도록 Proxy 같은 걸로 업데이트할 계획이에요.
      • translationData: SSR 동안 접촉한 번역 키-값 블롭
  2. Hydration 직전에 그 입력들을 주입

    • main.tsx에서 동기적으로:
      • #root.innerHTML을 직렬화된 해결 HTML로 설정해요(그래서 DOM이 hydration이 보는 것과 정확히 일치해요)
      • 앱을 SSGDataProvider로 감싸서 컴포넌트가 첫 렌더링에서 동일한 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(() => {
    // After mount (hydration complete), switch to client mode
    setIsSSRMode(false);
  }, []);
  
  return isSSRMode;
}

이 훅은 SSR 동안과 첫 클라이언트 렌더링(hydration) 동안 true를 반환하고, 마운트 후에는 false로 전환돼요. UserBanner, Navbar, Dialog 같은 컴포넌트가 이미 hydration 불일치를 방지하기 위해 이걸 사용하고 있어요.

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

hydration-overlay를 그냥 사용할 수 있길 바랐어요. 하지만 활발하게 유지보수되지 않고, React 18까지만 지원하고, 프로덕션 준비가 되어 있지 않았어요. 그래서 LLM에게 영감을 위해 레포를 클론하게 한 다음, 몇 분 만에 최소한의 hydration 오버레이를 만들었어요. 화려한 건 필요 없었어요. 그저 개발 중에 나타나서 어디가 잘못됐는지 알 수 있게 해주는 정도면 됐어요.

이 새 오버레이는 정말 기본적이라 diff가 완벽하지는 않아요. React는 주석을 제거하고, 스타일 속성 뒤에 ;를 추가하고, 공백을 수정하고, 그 외 몇 가지 작은 일들을 하는데, 우리의 오버레이는 (아직) 이를 고려하지 않아요. 우리의 오버레이는 또한 React가 hydration에서 무시하는 HTML 주석을 포함해요.

<img alt="새로운 hydration 오버레이" 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 hydration을 위한 우리 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가 일을 망치지 않으면서 hydration이 잘 작동하도록 만드는 데 4일 소요.
  • dehydration 접근법을 크리티컬 패스 Suspense 경계 교체로 바꾸는 데 1일 추가 소요(더 단순하고, 더 적은 바이트, 잠재적 깜빡임 없음).
  • 핵심 SSG 생성 코드 약 200줄 (GenerateShellSsgFromSitemap.ts)
  • Suspense 경계 해결 약 120줄 (renderRoute.tsxresolveSuspenseBoundaries) - 참고: 나중에 크리티컬 패스 접근법으로 교체됨
  • SSR 유틸리티 약 50줄 (isSSRMode.ts)
  • 테스트 약 100줄 (renderRoute.test.ts)
  • SSR을 위한 폴리필 약 150줄 (setupSSREnvironment)
  • 기존 컴포넌트에 최소한의 변경(주로 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 라우트의 hydration은 예상보다 오래 걸렸고(3일), 초기 dehydration 접근법을 크리티컬 패스 Suspense 경계 교체로 바꾸는 데 하루를 더 썼어요. 새 접근법은 유지보수가 더 단순하고, 더 적은 바이트를 출고하고, HTML을 dehydrate/rehydrate해서 생기는 잠재적 시각 깜빡임을 방지해요.

SSG에 대한 맞춤형 솔루션을 구현하는 데 단 2일밖에 걸리지 않았다는 게 여전히 놀라워요. 하지만 때로는 올바른 솔루션이 가장 단순한 거예요.

향후 작업에는 hydration 매칭 완성과 더 나은 디버깅을 위한 React 패치 가능성이 포함돼요. 하지만 지금은 Foony에 작동하는 SSG가 있어요. 앞으로 몇 주 동안 Google Search ConsoleBing Webmaster Tools를 주시하면서 이게 우리 SEO에 어떤 영향을 미치는지 살펴볼 거예요.

8 Ball Pool online multiplayer billiards icon