๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
Sonamu์—์„œ ์„œ๋ฒ„ ๋ Œ๋”๋ง๋œ HTML์„ ํด๋ผ์ด์–ธํŠธ์—์„œ ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒํ•˜๊ฒŒ ๋งŒ๋“œ๋Š” ํ•˜์ด๋“œ๋ ˆ์ด์…˜ ์ „๋žต์„ ์•Œ์•„๋ด…๋‹ˆ๋‹ค.

Hydration ๊ฐœ์š”

TanStack Query

dehydrate/hydrate์ž๋™ ์ƒํƒœ ๋ณต์›

์ „์ฒด ๋ฌธ์„œ Hydration

document ๋ ˆ๋ฒจ์™„์ „ํ•œ SSR

Date ์ง๋ ฌํ™”

dateReviver์ž๋™ ๋ณ€ํ™˜

์„ ํƒ์  ๋น„ํ™œ์„ฑํ™”

disableHydrateCSR ์ „ํ™˜

Sonamu Hydration ํ”„๋กœ์„ธ์Šค

1. ์„œ๋ฒ„: Dehydration

์„œ๋ฒ„์—์„œ Query์ƒํƒœ๋ฅผ ์ง๋ ฌํ™”ํ•ฉ๋‹ˆ๋‹ค.
// entry-server.generated.tsx
import { dehydrate } from "@tanstack/react-query";

export async function render(url: string, preloadedData: PreloadedData[] = []) {
  const queryClient = new QueryClient();

  // ๋ฐ์ดํ„ฐ ์ฃผ์ž…
  for (const { queryKey, data } of preloadedData) {
    queryClient.setQueryData(queryKey, data);
  }

  // Dehydrate: QueryClient ์ƒํƒœ๋ฅผ JSON์œผ๋กœ ์ง๋ ฌํ™”
  const dehydratedState = dehydrate(queryClient);

  const appHtml = renderToString(<RouterProvider router={router} />);

  return {
    html: appHtml,
    dehydratedState,  // ์ง๋ ฌํ™”๋œ ์ƒํƒœ
  };
}

2. HTML์— ๋ฐ์ดํ„ฐ ์ฃผ์ž…

// sonamu/src/ssr/renderer.ts
const ssrDataScript = dehydratedState
  ? `<script>window.__SONAMU_SSR__ = ${JSON.stringify(dehydratedState).replace(/</g, "\\u003c")};</script>`
  : "";

// </body> ์ง์ „์— ์ฃผ์ž…
const finalHtml = fullDocHtml.replace(
  "</body>",
  `${ssrDataScript}\n${viteScripts}\n</body>`,
);
์ƒ์„ฑ๋œ HTML:
<!DOCTYPE html>
<html>
  <head>...</head>
  <body>
    <div id="root">
      <!-- ์„œ๋ฒ„ ๋ Œ๋”๋ง๋œ HTML -->
      <div>Welcome, John</div>
    </div>
    
    <!-- SSR ๋ฐ์ดํ„ฐ -->
    <script>
      window.__SONAMU_SSR__ = {
        "queries": [{
          "queryKey": ["User", "getUser", "C", 123],
          "queryHash": "[\"User\",\"getUser\",\"C\",123]",
          "state": {
            "data": {
              "user": {
                "id": 123,
                "username": "John",
                "createdAt": "2024-01-01T00:00:00.000Z"
              }
            },
            "dataUpdateCount": 1,
            "dataUpdatedAt": 1704067200000,
            "status": "success"
          }
        }]
      };
    </script>
    
    <!-- Vite ์Šคํฌ๋ฆฝํŠธ -->
    <script type="module" src="/src/entry-client.tsx"></script>
  </body>
</html>

3. ํด๋ผ์ด์–ธํŠธ: Hydration

ํด๋ผ์ด์–ธํŠธ์—์„œ ์ƒํƒœ๋ฅผ ๋ณต์›ํ•˜๊ณ  React๋ฅผ ํ™œ์„ฑํ™”ํ•ฉ๋‹ˆ๋‹ค.
// entry-client.tsx
import { hydrate } from "@tanstack/react-query";
import { dateReviver } from "./services/sonamu.shared";

// 1. window.__SONAMU_SSR__์—์„œ ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ
const dehydratedState = window.__SONAMU_SSR__
  ? JSON.parse(JSON.stringify(window.__SONAMU_SSR__), dateReviver)
  : undefined;

// 2. QueryClient์— hydrate
if (dehydratedState) {
  hydrate(queryClient, dehydratedState);
}

// 3. React Hydration
if (document.documentElement.innerHTML && dehydratedState) {
  // SSR ํŽ˜์ด์ง€ - Hydration
  ReactDOM.hydrateRoot(document, <RouterProvider router={router} />);
} else {
  // Pure CSR ํŽ˜์ด์ง€ - ์ƒˆ๋กœ ๋ Œ๋”๋ง
  ReactDOM.createRoot(document).render(<RouterProvider router={router} />);
}

Date ์ง๋ ฌํ™” ์ฒ˜๋ฆฌ

๋ฌธ์ œ: Date ๊ฐ์ฒด๋Š” JSON.stringify๋กœ ๋ฌธ์ž์—ด์ด ๋จ

const user = {
  id: 123,
  username: "John",
  createdAt: new Date("2024-01-01"),  // Date ๊ฐ์ฒด
};

// ์„œ๋ฒ„์—์„œ ์ง๋ ฌํ™”
JSON.stringify(user);
// '{"id":123,"username":"John","createdAt":"2024-01-01T00:00:00.000Z"}'
//                                          โ†‘ ๋ฌธ์ž์—ด!

// ํด๋ผ์ด์–ธํŠธ์—์„œ ์—ญ์ง๋ ฌํ™”
JSON.parse(json);
// { id: 123, username: "John", createdAt: "2024-01-01T00:00:00.000Z" }
//                                          โ†‘ ์—ฌ์ „ํžˆ ๋ฌธ์ž์—ด!

ํ•ด๊ฒฐ: dateReviver

Sonamu๋Š” ์ž๋™์œผ๋กœ ISO ๋‚ ์งœ ๋ฌธ์ž์—ด์„ Date ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค.
// services/sonamu.shared.ts
export function dateReviver(_key: string, value: unknown): unknown {
  if (typeof value === "string") {
    // ISO 8601 ๋‚ ์งœ ๋ฌธ์ž์—ด ํŒจํ„ด
    const isoDatePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
    if (isoDatePattern.test(value)) {
      return new Date(value);  // Date ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜
    }
  }
  return value;
}
// entry-client.tsx
const dehydratedState = window.__SONAMU_SSR__
  ? JSON.parse(JSON.stringify(window.__SONAMU_SSR__), dateReviver)
  : undefined;

// โœ… createdAt์ด Date ๊ฐ์ฒด๋กœ ๋ณต์›๋จ!

Hydration Mismatch ๋ฐฉ์ง€

๋ฌธ์ œ: ์„œ๋ฒ„์™€ ํด๋ผ์ด์–ธํŠธ์˜ HTML์ด ๋‹ค๋ฅด๋ฉด ์—๋Ÿฌ

// โŒ Hydration Mismatch ๋ฐœ์ƒ
function Clock() {
  const [time, setTime] = useState(new Date());
  // ์„œ๋ฒ„์™€ ํด๋ผ์ด์–ธํŠธ์˜ ์‹œ๊ฐ„์ด ๋‹ค๋ฆ„!
  
  return <div>{time.toLocaleTimeString()}</div>;
}
์—๋Ÿฌ ๋ฉ”์‹œ์ง€:
Warning: Text content did not match. Server: "10:30:45" Client: "10:30:47"

ํ•ด๊ฒฐ 1: useEffect ์‚ฌ์šฉ

// โœ… ํด๋ผ์ด์–ธํŠธ์—์„œ๋งŒ ์‹คํ–‰
function Clock() {
  const [time, setTime] = useState<Date | null>(null);
  
  useEffect(() => {
    setTime(new Date());
    const interval = setInterval(() => {
      setTime(new Date());
    }, 1000);
    return () => clearInterval(interval);
  }, []);
  
  if (!time) return <div>--:--:--</div>;  // ์„œ๋ฒ„ ๋ Œ๋”๋ง
  return <div>{time.toLocaleTimeString()}</div>;  // ํด๋ผ์ด์–ธํŠธ๋งŒ
}

ํ•ด๊ฒฐ 2: suppressHydrationWarning

์˜๋„์ ์œผ๋กœ ์„œ๋ฒ„/ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋‹ค๋ฅธ ๊ฒฝ์šฐ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
function RandomQuote() {
  const [quote] = useState(() => getRandomQuote());
  
  return (
    <div suppressHydrationWarning>
      {quote}  {/* ์„œ๋ฒ„์™€ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์Œ */}
    </div>
  );
}

disableHydrate ์˜ต์…˜

Hydration์„ ๋น„ํ™œ์„ฑํ™”ํ•˜๊ณ  CSR๋กœ ์ „ํ™˜

// api/src/application/sonamu.ts
registerSSR({
  path: "/admin/realtime-dashboard",
  preload: (params) => [/* ... */],
  disableHydrate: true,  // Hydration ๋น„ํ™œ์„ฑํ™”
});
์ž‘๋™ ๋ฐฉ์‹:
// entry-client.tsx
const ssrConfig = window.__SONAMU_SSR_CONFIG__;

if (ssrConfig?.disableHydrate) {
  // Hydration ๋Œ€์‹  ์ƒˆ๋กœ ๋ Œ๋”๋ง
  console.log("[Sonamu] Hydration disabled, rendering as CSR");
  ReactDOM.createRoot(document).render(<RouterProvider router={router} />);
} else {
  // ์ •์ƒ Hydration
  ReactDOM.hydrateRoot(document, <RouterProvider router={router} />);
}
์‚ฌ์šฉ ์‚ฌ๋ก€:
  • ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ๊ฐ€ ์ค‘์š”ํ•œ ๊ฒฝ์šฐ (๋Œ€์‹œ๋ณด๋“œ, ์ฑ„ํŒ… ๋“ฑ)
  • ์„œ๋ฒ„/ํด๋ผ์ด์–ธํŠธ ๋ Œ๋”๋ง ๊ฒฐ๊ณผ๊ฐ€ ์˜๋„์ ์œผ๋กœ ๋‹ค๋ฅธ ๊ฒฝ์šฐ
  • Hydration mismatch ํ•ด๊ฒฐ์ด ์–ด๋ ค์šด ๊ฒฝ์šฐ

์ „์ฒด ๋ฌธ์„œ Hydration

Sonamu๋Š” document ์ „์ฒด๋ฅผ hydrateํ•ฉ๋‹ˆ๋‹ค (div#root๊ฐ€ ์•„๋‹˜).
// โŒ ์ผ๋ฐ˜์ ์ธ React ์•ฑ (div#root๋งŒ)
ReactDOM.hydrateRoot(
  document.getElementById("root"),
  <App />
);

// โœ… Sonamu (document ์ „์ฒด)
ReactDOM.hydrateRoot(
  document,
  <RouterProvider router={router} />
);
์ด์œ :
  • TanStack Router๊ฐ€ <html>, <head> ํƒœ๊ทธ๊นŒ์ง€ ๊ด€๋ฆฌ
  • <HeadContent />, <Scripts /> ์ปดํฌ๋„ŒํŠธ๊ฐ€ head/body ์ˆ˜์ •
  • SEO ๋ฉ”ํƒ€ ํƒœ๊ทธ๋ฅผ ๋™์ ์œผ๋กœ ๋ณ€๊ฒฝ ๊ฐ€๋Šฅ
// web/src/routes/__root.tsx
export const Route = createRootRouteWithContext<RouterContext>()({
  head: () => ({
    meta: [
      { charSet: "utf-8" },
      { title: "My App" },
    ],
  }),
  component: RootComponent,
});

function RootComponent() {
  return (
    <html lang="ko">
      <head>
        <HeadContent />  {/* head ํƒœ๊ทธ ๋™์  ์ƒ์„ฑ */}
      </head>
      <body>
        <div id="root">
          <Outlet />
        </div>
        <Scripts />  {/* script ํƒœ๊ทธ ๋™์  ์ƒ์„ฑ */}
      </body>
    </html>
  );
}

Suspense์™€ Hydration

์„œ๋ฒ„์—์„œ Suspense fallback์„ ๋ Œ๋”๋งํ•˜๊ณ  ํด๋ผ์ด์–ธํŠธ์—์„œ ์‹ค์ œ ์ปจํ…์ธ ๋ฅผ ๋กœ๋“œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
// entry-server.generated.tsx
const appHtml = renderToString(
  <Suspense fallback={null}>  {/* Hydration mismatch ๋ฐฉ์ง€ */}
    <RouterProvider router={router} />
  </Suspense>,
);
fallback=์„ ์‚ฌ์šฉํ•˜๋Š” ์ด์œ :
  • SSR์—์„œ๋Š” ๋ชจ๋“  ๋ฐ์ดํ„ฐ๊ฐ€ ์ด๋ฏธ ๋กœ๋“œ๋˜์–ด ์žˆ์Œ
  • fallback์ด ๋ณด์ด์ง€ ์•Š์Œ
  • Hydration mismatch ๋ฐฉ์ง€

QueryClient ์„ค์ •

์„œ๋ฒ„ ์„ค์ •

// entry-server.generated.tsx
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5000,  // 5์ดˆ
      retry: false,      // ์„œ๋ฒ„์—์„œ๋Š” ์žฌ์‹œ๋„ ์•ˆ ํ•จ
    },
  },
});

ํด๋ผ์ด์–ธํŠธ ์„ค์ •

// entry-client.tsx
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5000,        // 5์ดˆ
      retry: false,            // ์žฌ์‹œ๋„ ์•ˆ ํ•จ
      refetchOnMount: true,    // ๋งˆ์šดํŠธ ์‹œ ์žฌ๊ฒ€์ฆ
    },
  },
});
์ฃผ์˜: ์„œ๋ฒ„์™€ ํด๋ผ์ด์–ธํŠธ์˜ staleTime์„ ๋™์ผํ•˜๊ฒŒ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

Router Context

QueryClient๋ฅผ Router Context๋กœ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.
// web/src/routes/__root.tsx
export interface RouterContext {
  queryClient: QueryClient;
}

export const Route = createRootRouteWithContext<RouterContext>()({
  component: RootComponent,
});

function RootComponent() {
  const { queryClient } = Route.useRouteContext();

  return (
    <html>
      <body>
        <QueryClientProvider client={queryClient}>
          <Outlet />
        </QueryClientProvider>
      </body>
    </html>
  );
}
// entry-server.generated.tsx & entry-client.tsx
const router = createRouter({
  routeTree,
  context: { queryClient },  // Context๋กœ ์ „๋‹ฌ
  defaultPreload: "intent",
});

๋””๋ฒ„๊น…

Hydration ํ™•์ธ

// entry-client.tsx
if (document.documentElement.innerHTML && dehydratedState) {
  console.log("[Sonamu] Hydrating with SSR data");
  console.log("Queries:", Object.keys(dehydratedState.queries || {}));
  ReactDOM.hydrateRoot(document, <RouterProvider router={router} />);
} else {
  console.log("[Sonamu] Rendering as CSR (no SSR data)");
  ReactDOM.createRoot(document).render(<RouterProvider router={router} />);
}

QueryClient ์ƒํƒœ ํ™•์ธ

// Chrome Extension ์ง€์›
window.__TANSTACK_QUERY_CLIENT__ = queryClient;

// ์ฝ˜์†”์—์„œ ํ™•์ธ
window.__TANSTACK_QUERY_CLIENT__.getQueryCache().getAll();

์ฃผ์˜์‚ฌํ•ญ

Hydration ์‚ฌ์šฉ ์‹œ ์ฃผ์˜์‚ฌํ•ญ:
  1. ์„œ๋ฒ„/ํด๋ผ์ด์–ธํŠธ HTML ์ผ์น˜: Hydration mismatch ๋ฐฉ์ง€
  2. window ๊ฐ์ฒด ์ฃผ์˜: ์„œ๋ฒ„์—์„œ๋Š” window๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค
  3. Date ๊ฐ์ฒด: dateReviver๊ฐ€ ์ž๋™ ์ฒ˜๋ฆฌํ•˜์ง€๋งŒ ํ™•์ธ ํ•„์š”
  4. QueryClient ์„ค์ • ๋™์ผ: ์„œ๋ฒ„์™€ ํด๋ผ์ด์–ธํŠธ์˜ staleTime ์ผ์น˜
  5. disableHydrate ์ตœ์†Œํ™”: ํŠน๋ณ„ํ•œ ๊ฒฝ์šฐ์—๋งŒ ์‚ฌ์šฉ

๋‹ค์Œ ๋‹จ๊ณ„