๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
Sonamu์—์„œ Server-Side Rendering(SSR)์„ ์„ค์ •ํ•˜๊ณ  ์‚ฌ์šฉํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์•Œ์•„๋ด…๋‹ˆ๋‹ค.

SSR ๊ฐœ์š”

TanStack Router

ํŒŒ์ผ ๊ธฐ๋ฐ˜ ๋ผ์šฐํŒ…Type-safe ๋„ค๋น„๊ฒŒ์ด์…˜

Vite SSR

๋น ๋ฅธ ๋นŒ๋“œHMR ์ง€์›

Direct API Call

HTTP ์˜ค๋ฒ„ํ—ค๋“œ ์—†์Œ๋น ๋ฅธ ์‘๋‹ต

TanStack Query

Hydration ์ž๋™ํ™”์บ์‹ฑ ํ†ตํ•ฉ

Sonamu SSR ์•„ํ‚คํ…์ฒ˜

Sonamu๋Š” TanStack Router + Vite๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ SSR์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.

ํ•ต์‹ฌ ๊ตฌ์„ฑ ์š”์†Œ

๐Ÿ“web/src/
๐Ÿ“„TSXentry-server.generated.tsx - ์„œ๋ฒ„ ๋ Œ๋”๋ง ์—”ํŠธ๋ฆฌ (์ž๋™ ์ƒ์„ฑ)
๐Ÿ“„TSXentry-client.tsx - ํด๋ผ์ด์–ธํŠธ ํ•˜์ด๋“œ๋ ˆ์ด์…˜ ์—”ํŠธ๋ฆฌ
๐Ÿ“„TSrouteTree.gen.ts - TanStack Router ๋ผ์šฐํŠธ ํŠธ๋ฆฌ (์ž๋™ ์ƒ์„ฑ)
๐Ÿ“routes/ - ํŒŒ์ผ ๊ธฐ๋ฐ˜ ๋ผ์šฐํŠธ
๐Ÿ“„TSX__root.tsx - ๋ฃจํŠธ ๋ ˆ์ด์•„์›ƒ
๐Ÿ“„TSXindex.tsx - / ๊ฒฝ๋กœ
๐Ÿ“users/
๐Ÿ“„TSX$id.tsx - /users/:id ๊ฒฝ๋กœ

entry-server.generated.tsx

์„œ๋ฒ„์—์„œ React๋ฅผ ๋ Œ๋”๋งํ•˜๋Š” ์ง„์ž…์ ์ž…๋‹ˆ๋‹ค (์ž๋™ ์ƒ์„ฑ๋จ):
// web/src/entry-server.generated.tsx
import { QueryClient, dehydrate } from "@tanstack/react-query";
import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router";
import { Suspense } from "react";
import { renderToString } from "react-dom/server";
import { routeTree } from "./routeTree.gen";

export type PreloadedData = {
  queryKey: any[];
  data: any;
};

export async function render(url: string, preloadedData: PreloadedData[] = []) {
  // QueryClient ์ƒ์„ฑ
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 5000,
        retry: false,
      },
    },
  });

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

  // Dehydrate
  const dehydratedState = dehydrate(queryClient);

  // SSR์šฉ ๋ฉ”๋ชจ๋ฆฌ ํžˆ์Šคํ† ๋ฆฌ ์ƒ์„ฑ
  const memoryHistory = createMemoryHistory({
    initialEntries: [url],
  });

  // Router ์ƒ์„ฑ (SSR ๋ชจ๋“œ)
  const router = createRouter({
    routeTree,
    context: { queryClient },
    history: memoryHistory,
    defaultPreload: "intent",
  });

  // ๋ผ์šฐํ„ฐ ์ดˆ๊ธฐํ™”: SSR์—์„œ ๋ฐ˜๋“œ์‹œ await router.load() ํ˜ธ์ถœ ํ•„์š”
  await router.load();

  // RouterProvider๋งŒ ๋ Œ๋”๋ง (Suspense๋กœ ๋ž˜ํ•‘ - hydration mismatch ๋ฐฉ์ง€)
  const appHtml = renderToString(
    <Suspense fallback={null}>
      <RouterProvider router={router} />
    </Suspense>,
  );

  return {
    html: appHtml,
    dehydratedState,
  };
}

entry-client.tsx

ํด๋ผ์ด์–ธํŠธ์—์„œ React๋ฅผ ํ•˜์ด๋“œ๋ ˆ์ด์…˜ํ•˜๋Š” ์ง„์ž…์ ์ž…๋‹ˆ๋‹ค:
// web/src/entry-client.tsx
import { hydrate, QueryClient } from "@tanstack/react-query";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import ReactDOM from "react-dom/client";
import { routeTree } from "./routeTree.gen";
import { dateReviver } from "./services/sonamu.shared";

// SSR ๋ฐ์ดํ„ฐ ํƒ€์ž…
declare global {
  interface Window {
    __SONAMU_SSR__?: any;
    __SONAMU_SSR_CONFIG__?: {
      disableHydrate?: boolean;
    };
  }
}

// QueryClient ์ƒ์„ฑ
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5000,
      retry: false,
      refetchOnMount: true,
    },
  },
});

// SSR ๋ฐ์ดํ„ฐ ๋ณต์›
const dehydratedState = window.__SONAMU_SSR__
  ? JSON.parse(JSON.stringify(window.__SONAMU_SSR__), dateReviver)
  : undefined;

if (dehydratedState) {
  hydrate(queryClient, dehydratedState);
}

// Router ์ƒ์„ฑ
const router = createRouter({
  routeTree,
  context: { queryClient },
  defaultPreload: "intent",
});

await router.load();

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

TanStack Router ๋ผ์šฐํŠธ ๊ตฌ์กฐ

Root Route

// web/src/routes/__root.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRootRouteWithContext, HeadContent, Outlet, Scripts } from "@tanstack/react-router";

export interface RouterContext {
  queryClient: QueryClient;
}

export const Route = createRootRouteWithContext<RouterContext>()({
  head: () => ({
    meta: [
      { charSet: "utf-8" },
      { name: "viewport", content: "width=device-width, initial-scale=1.0" },
      { title: "My App" },
    ],
  }),
  component: RootComponent,
});

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

  return (
    <html lang="ko">
      <head>
        <HeadContent />
      </head>
      <body>
        <div id="root">
          <QueryClientProvider client={queryClient}>
            <Outlet />
          </QueryClientProvider>
        </div>
        <Scripts />
      </body>
    </html>
  );
}

ํŒŒ์ผ ๋ผ์šฐํŠธ

// web/src/routes/index.tsx
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/")({
  component: HomePage,
});

function HomePage() {
  return (
    <div>
      <h1>Home Page</h1>
    </div>
  );
}

๋™์  ๋ผ์šฐํŠธ

// web/src/routes/users/$id.tsx
import { createFileRoute } from "@tanstack/react-router";
import { UserService } from "@/services/services.generated";

export const Route = createFileRoute("/users/$id")({
  component: UserPage,
});

function UserPage() {
  const { id } = Route.useParams();
  const { data } = UserService.useUser("C", parseInt(id));

  return (
    <div>
      <h1>{data?.user.username}</h1>
      <p>{data?.user.email}</p>
    </div>
  );
}

Vite ์„ค์ •

// web/vite.config.ts
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";

export default defineConfig(({ isSsrBuild }) => ({
  plugins: [
    react(),
    tanstackRouter({
      autoCodeSplitting: true,
      generatedRouteTree: "./src/routeTree.gen.ts",
    }),
  ],
  build: {
    outDir: "dist/client",
    rollupOptions: {
      output: isSsrBuild
        ? {}
        : {
            manualChunks: {
              "vendor-react": ["react", "react-dom"],
              "vendor-tanstack": ["@tanstack/react-query", "@tanstack/react-router"],
            },
          },
    },
  },
  ssr: {
    noExternal: true, // Production ๋นŒ๋“œ ์‹œ ๋ชจ๋“  ์˜์กด์„ฑ ๋ฒˆ๋“ค์— ํฌํ•จ
  },
}));

SSR vs CSR

Sonamu๋Š” ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ์ ‘๊ทผ ๋ฐฉ์‹์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค:

SSR ๋ Œ๋”๋ง (๋ฐ์ดํ„ฐ ํ”„๋ฆฌ๋กœ๋”ฉ ์‹œ)

// registerSSR๋กœ ๋“ฑ๋ก๋œ ๊ฒฝ๋กœ
// โ†’ ์„œ๋ฒ„์—์„œ HTML ์ƒ์„ฑ + ๋ฐ์ดํ„ฐ ํฌํ•จ
// โ†’ ํด๋ผ์ด์–ธํŠธ์—์„œ hydration
์žฅ์ :
  • ๋น ๋ฅธ First Contentful Paint
  • SEO ์ตœ์ ํ™”
  • ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ ์ฆ‰์‹œ ํ‘œ์‹œ

CSR ๋ Œ๋”๋ง (๊ธฐ๋ณธ)

// registerSSR ์—†๋Š” ๊ฒฝ๋กœ
// โ†’ ํด๋ผ์ด์–ธํŠธ์—์„œ ์ง์ ‘ ๋ Œ๋”๋ง
// โ†’ API ํ˜ธ์ถœํ•˜์—ฌ ๋ฐ์ดํ„ฐ ๋กœ๋“œ
์žฅ์ :
  • ์„œ๋ฒ„ ๋ถ€ํ•˜ ๊ฐ์†Œ
  • ๊ฐ„๋‹จํ•œ ๊ตฌํ˜„
  • ๋น ๋ฅธ ๊ฐœ๋ฐœ

๊ฐœ๋ฐœ vs ํ”„๋กœ๋•์…˜

๊ฐœ๋ฐœ ๋ชจ๋“œ

sonamu dev            # ํ†ตํ•ฉ ๋ชจ๋“œ (= sonamu dev all)
sonamu dev all        # ํ†ตํ•ฉ ๋ชจ๋“œ (one-port: API + Web)
sonamu dev api        # API-only ๋ชจ๋“œ (Vite ํ†ตํ•ฉ ๋น„ํ™œ์„ฑ)
sonamu dev web        # Vite ๋‹จ๋… ์‹คํ–‰
sonamu dev web -- --port 3028 --host 0.0.0.0  # Vite ์˜ต์…˜ ์ „๋‹ฌ
  • dev all: API ์„œ๋ฒ„๊ฐ€ Vite๋ฅผ ํ†ตํ•ฉํ•˜์—ฌ ํ•˜๋‚˜์˜ ํฌํŠธ๋กœ API + Web ์„œ๋น™ (HMR ์ง€์›)
  • dev api: API๋งŒ ์‹คํ–‰. Web์ด ํ•„์š” ์—†๊ฑฐ๋‚˜ Vite๋ฅผ ๋ณ„๋„๋กœ ์‹คํ–‰ํ•˜๊ณ  ์‹ถ์„ ๋•Œ ์‚ฌ์šฉ
  • dev web: Vite ๊ฐœ๋ฐœ ์„œ๋ฒ„๋งŒ ๋‹จ๋… ์‹คํ–‰. -- ๋’ค์— Vite ์˜ต์…˜ ์ „๋‹ฌ ๊ฐ€๋Šฅ

ํ”„๋กœ๋•์…˜ ๋นŒ๋“œ

sonamu build          # ์ „์ฒด ๋นŒ๋“œ (= sonamu build all)
sonamu build api      # API๋งŒ ๋นŒ๋“œ
sonamu build web      # Web๋งŒ ๋นŒ๋“œ
๋นŒ๋“œ ๊ฒฐ๊ณผ:
web/dist/                               # Web ๋นŒ๋“œ ์›๋ณธ
โ”œโ”€โ”€ client/                             # ํด๋ผ์ด์–ธํŠธ ๋ฒˆ๋“ค
โ””โ”€โ”€ server/                             # SSR ๋ฒˆ๋“ค

api/web-dist/                           # Web ๋นŒ๋“œ ๋ฏธ๋Ÿฌ (๋ฐฐํฌ์šฉ)
โ”œโ”€โ”€ client/                             # = web/dist/client ๋ฏธ๋Ÿฌ
โ”‚   โ”œโ”€โ”€ index.html
โ”‚   โ””โ”€โ”€ assets/
โ””โ”€โ”€ server/                             # = web/dist/server ๋ฏธ๋Ÿฌ
    โ””โ”€โ”€ entry-server.generated.js

api/dist/                               # API ๋นŒ๋“œ ๊ฒฐ๊ณผ
โ””โ”€โ”€ ssr/
    โ””โ”€โ”€ routes.js                       # SSR ๋ผ์šฐํŠธ (API ์†Œ์œ )

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

Sonamu SSR ์‚ฌ์šฉ ์‹œ ์ฃผ์˜์‚ฌํ•ญ:
  1. TanStack Router ๊ธฐ๋ฐ˜: Next.js App Router๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค
  2. entry-server.generated.tsx๋Š” ์ž๋™ ์ƒ์„ฑ: ์ง์ ‘ ์ˆ˜์ •ํ•˜์ง€ ๋งˆ์„ธ์š”
  3. window ๊ฐ์ฒด ์ฃผ์˜: ์„œ๋ฒ„์—์„œ๋Š” window๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค
  4. dateReviver ํ•„์ˆ˜: Date ๊ฐ์ฒด ์ง๋ ฌํ™”/์—ญ์ง๋ ฌํ™” ์ฒ˜๋ฆฌ
  5. registerSSR ์‚ฌ์šฉ: ๋ฐ์ดํ„ฐ ํ”„๋ฆฌ๋กœ๋”ฉ์€ ๋‹ค์Œ ๋ฌธ์„œ ์ฐธ๊ณ 

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