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์ ๊ตฌํํฉ๋๋ค.
ํต์ฌ ๊ตฌ์ฑ ์์
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" ;
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 5173 --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 ์ฌ์ฉ : ๋ฐ์ดํฐ ํ๋ฆฌ๋ก๋ฉ์ ๋ค์ ๋ฌธ์ ์ฐธ๊ณ
๋ค์ ๋จ๊ณ
๋ฐ์ดํฐ ํ๋ฆฌ๋ก๋ฉ registerSSR ์ฌ์ฉ๋ฒ
Hydration ์ ๋ต ํ์ด๋๋ ์ด์
์ต์ ํ
์บ์ ์ ์ด TanStack Query ์บ์ฑ
TanStack Router ๊ณต์ ๋ฌธ์ ๋ผ์ฐํฐ ์์ธ ๊ฐ์ด๋