Skip to content

[7팀 황준태] Chapter 4-1 성능최적화: SSR, SSG, Infra#27

Open
jthw1005 wants to merge 10 commits intohanghae-plus:mainfrom
jthw1005:main
Open

[7팀 황준태] Chapter 4-1 성능최적화: SSR, SSG, Infra#27
jthw1005 wants to merge 10 commits intohanghae-plus:mainfrom
jthw1005:main

Conversation

@jthw1005
Copy link

@jthw1005 jthw1005 commented Dec 16, 2025

과제 체크포인트

배포 링크

vanilla
react

기본과제 (Vanilla SSR & SSG)

Express SSR 서버

  • Express 미들웨어 기반 서버 구현
  • 개발/프로덕션 환경 분기 처리
  • HTML 템플릿 치환 (<!--app-html-->, <!--app-head-->)

서버 사이드 렌더링

  • 서버에서 동작하는 Router 구현
  • 서버 데이터 프리페칭 (상품 목록, 상품 상세)
  • 서버 상태관리 초기화

클라이언트 Hydration

  • window.__INITIAL_DATA__ 스크립트 주입
  • 클라이언트 상태 복원
  • 서버-클라이언트 데이터 일치

Static Site Generation

  • 동적 라우트 SSG (상품 상세 페이지들)
  • 빌드 타임 페이지 생성
  • 파일 시스템 기반 배포

심화과제 (React SSR & SSG)

React SSR

  • renderToString 서버 렌더링
  • TypeScript SSR 모듈 빌드
  • Universal React Router (서버/클라이언트 분기)
  • React 상태관리 서버 초기화

React Hydration

  • Hydration 불일치 방지
  • 클라이언트 상태 복원

Static Site Generation

  • 동적 라우트 SSG (상품 상세 페이지들)
  • 빌드 타임 페이지 생성
  • 파일 시스템 기반 배포

자유롭게 회고하기

SSR이나 SSG, ISR 등의 개념만 어렴풋이 알고 있었지 직접 구현해보는건 처음이라 재밌을거 같았지만 어디서부터 시작해야할지 전혀 감이 잡히지 않았다.

직접 구현할 수 없다면 제대로 알고 있는게 아닌거 같아서 기초부터 다시 하나하나 공부하면서 과제를 진행했고, 그 과정에서 정말 많이 배운 것 같다.

1. CSR & SSR & SSG 구조 비교

CSR

sequenceDiagram
    participant Browser
    participant Server
    participant API

    Browser->>Server: HTML 요청
    Server-->>Browser: 빈 HTML + JS 번들
    Browser->>Browser: JS 실행, React 마운트
    Browser->>API: 데이터 요청
    API-->>Browser: JSON 응답
    Browser->>Browser: 화면 렌더링
Loading

SSR

sequenceDiagram
    participant Browser
    participant Server
    participant API

    Browser->>Server: HTML 요청
    Server->>API: 데이터 미리 가져오기
    API-->>Server: JSON 응답
    Server->>Server: HTML 문자열 생성
    Server-->>Browser: 완성된 HTML + 초기 데이터
    Browser->>Browser: Hydration (이벤트 연결)
Loading

SSG

sequenceDiagram
    participant Browser
    participant CDN
    participant Build as 빌드 타임

    Note over Build,CDN: 배포 전 (빌드 시점)
    Build->>Build: 데이터 가져오기 + HTML 생성
    Build->>CDN: 정적 파일 업로드

    Note over Browser,CDN: 사용자 접속
    Browser->>CDN: HTML 요청
    CDN-->>Browser: 미리 생성된 HTML + 초기 데이터
    Browser->>Browser: Hydration (이벤트 연결)
Loading

위 다이어그램은 CSR, SSR, SSG 등을 어렴풋이 알고만 있을 때 이해했던 각 렌더링 과정이다.
이를 직접 구현하려고 보니 아래와 같은 질문들이 쏟아졌다.

1. 클라이언트에서 render함수를 만들 때, 문자열 또는 템플릿 리터럴을 가지고 HTML 구조를 그렸었는데, 서버에서 내려주는건 string일까 파일일까?
2. js 파일은 통째로 다운 받는건가? 아니면 특정 페이지와 관련된 js 파일만 다운 받는건가?
3. fetching된 데이터는 기존 HTML 파일에 어떻게 추가해주는거지?
4. fetching 해야할 데이터를 어떻게 알아내지?
5. Next.js에서는 어떻게 처리되지?
6. window.__INITIAL_DATA__ 와 window.__NEXT_DATA__의 차이점이 뭘까? 이름이 달라도 되는거였나?
7. 하이드레이션 mismatch는 어떻게 알아내지?

등등...

1. HTTP 응답은 항상 바이트 스트림(문자열)이다.

// 서버 코드
app.get("/products", async (req, res) => {
  const html = `
    <!DOCTYPE html>
    <html>
      <body>
        <div id="root">
          <h1>Products</h1>
        </div>
      </body>
    </html>
  `;

  // 문자열을 HTTP 응답으로 전송
  res.setHeader("Content-Type", "text/html; charset=utf-8");
  res.send(html); // 문자열 전송
});

2. JS 번들이 여러 개인지는 코드 스플리팅을 어떻게 설정했는지에 따라 다름.

보통 공통적으로 모든 페이지에서 사용하는 js 파일이 있고, 각 html 페이지마다 사용하는 js파일이 따로 있다.

// 1. index.html 로드
<!DOCTYPE html>
<html>
  <body>
    <div id="root"></div>
    <script type="module" src="/assets/index-abc123.js"></script>
  </body>
</html>

// 2. index-abc123.js 다운로드 및 실행
import React from 'react'; // → react-vendor-def456.js 다운로드
import { BrowserRouter } from 'react-router-dom'; // → router-ghi789.js 다운로드

const Home = lazy(() => import('./Home')); // 아직 다운로드 안함!
const Products = lazy(() => import('./Products'));

// 3. 사용자가 /products 방문
// → Products-mno345.js만 다운로드 (Home-jkl012.js는 안받음)

3. fetching된 데이터를 기존 HTML에 어떻게 추가하지?

React 기준 renderToString 메서드를 이용해서 HTML을 만들고 미리 삽입해둔 문자열()과 교체해준다.

// server.js
app.get('/products', async (req, res) => {
  // 1. 데이터 fetch
  const products = await fetch('http://api.example.com/products')
    .then(r => r.json());

  // 2. React 컴포넌트를 HTML 문자열로 렌더링
  const appHtml = renderToString(
    <Products products={products} />
  );
  // appHtml = '<div class="products"><h1>Products</h1><ul>...</ul></div>'

  // 3. 템플릿 읽기
  const template = fs.readFileSync('dist/client/index.html', 'utf-8');
  // template = '<!DOCTYPE html><html>...<div id="root"><!--app-html--></div>...'

  // 4. 문자열 치환으로 HTML + 데이터 주입
  let finalHtml = template.replace('<!--app-html-->', appHtml);

  // 5. window 객체에 데이터 주입
  finalHtml = finalHtml.replace(
    '</body>',
    `
      <script>
        window.__INITIAL_DATA__ = ${JSON.stringify({ products })};
      </script>
    </body>
    `
  );

  // 6. 완성된 HTML 문자열 응답
  res.send(finalHtml);
});

4. fetching 해야할 데이터를 어떻게 알아내지?

1. Route에서 수동으로 매핑

// server.js
const routeDataMap = {
  '/': async () => {
    const posts = await fetch('/api/posts').then(r => r.json());
    return { posts };
  },
  '/products': async () => {
    const products = await fetch('/api/products').then(r => r.json());
    return { products };
  },
  '/products/:id': async (params) => {
    const product = await fetch(`/api/products/${params.id}`).then(r => r.json());
    return { product };
  }
};

app.get('*', async (req, res) => {
  // URL 매칭해서 데이터 fetcher 찾기
  const dataFetcher = matchRoute(req.url, routeDataMap);

  if (dataFetcher) {
    const data = await dataFetcher(req.params);
    const html = renderToString(<App data={data} url={req.url} />);
    // ...
  }
});

2. 컴포넌트에 메타 데이터로 추가

// Products.tsx
export async function serverData() {
  const products = await fetch("/api/products").then((r) => r.json());
  return { products };
}

function Products({ products }) {
  return <div>...</div>;
}

// server.js
import { serverData as productsData } from "./pages/Products";

app.get("/products", async (req, res) => {
  const data = await productsData(); // 컴포넌트의 serverData 실행
  // ...
});

3. Next.js의 처리 방식

파일 기반 라우팅으로 자동 매핑됨.

// pages/products/index.tsx
export async function getServerSideProps(context) {
  // Next.js가 자동으로 이 함수 실행
  const products = await fetch("http://api.example.com/products").then((r) => r.json());

  return {
    props: { products }, // 컴포넌트에 전달
  };
}

export default function Products({ products }) {
  return (
    <div>
      <h1>Products</h1>
      {products.map((p) => (
        <div key={p.id}>{p.name}</div>
      ))}
    </div>
  );
}

Next.js 서버 내부 동작

// Next.js 서버
app.get('/products', async (req, res) => {
  // 1. 파일 시스템에서 pages/products/index.tsx 찾기
  const page = await import('./pages/products/index.tsx');

  // 2. getServerSideProps가 있으면 실행
  let props = {};
  if (page.getServerSideProps) {
    const result = await page.getServerSideProps({
      req,
      res,
      params: {},
      query: req.query
    });
    props = result.props;
  }

  // 3. 컴포넌트 렌더링
  const html = renderToString(<page.default {...props} />);

  // 4. HTML + __NEXT_DATA__ 주입
  const finalHtml = template
    .replace('<!--ssr-outlet-->', html)
    .replace(
      '</body>',
      `
        <script id="__NEXT_DATA__" type="application/json">
          ${JSON.stringify({
            props: { pageProps: props },
            page: '/products',
            query: {},
            buildId: 'abc123'
          })}
        </script>
      </body>
      `
    );

  res.send(finalHtml);
});

-> Remix는 다르게 처리됨. 즉, 정해진 방식이 없이 자유롭게 구현할 수 있음.

5. window.__INITIAL_DATA__ vs window.__NEXT_DATA__ 차이?

이건 단순히 변수명의 차이 일뿐, 어떤 이름을 써도 상관이 없다.
(처음 하다 보니 이런 것도 괜히 궁금함...)

6. Vite 미들웨어 모드

개발 환경에서 Vite를 middlewareMode로 써야 한다는 게 이해가 안 됐다. 왜 그냥 Vite 개발 서버를 쓰면 안 되는 거지?

// 이게 왜 필요한지 처음엔 몰랐음
const vite = await createViteServer({
  server: { middlewareMode: true },
  appType: "custom",
});

Vite 개발 서버는 기본적으로 index.html을 그대로 던져준다. 근데 SSR은 우리가 HTML을 직접 조작해서 보내야 하잖아. 그래서 Vite의 기능(HMR, 모듈 해석 등)만 빌려 쓰고, 실제 요청 처리는 Express가 해야 하는 것.

개발/프로덕션 분기

if (!prod) {
  // 개발: Vite 미들웨어 + 매 요청마다 모듈 다시 로드
  const ssrModule = await vite.ssrLoadModule("/src/main-server.js");
  render = ssrModule.render;
} else {
  // 프로덕션: 미리 빌드된 모듈 한 번만 로드
  const ssrModule = await import("./dist/vanilla-ssr/main-server.js");
  render = ssrModule.render;
}

처음엔 그냥 똑같이 하면 되지 않나 싶었는데, 생각해보니 당연함. 개발 중에는 코드 바꿀 때마다 반영되어야 하고, 프로덕션에서는 빌드된 최적화 코드를 써야지.

7. 서버 라우터 구현

클라이언트 라우터는 popstate 이벤트 듣고, window.location 읽고 하면 되는데 서버에는 window가 없음;

ServerRouter

export class ServerRouter {
  addRoute(path, handler) {
    // :param 형태를 정규식으로 변환
    const paramNames = [];
    const regexPath = path.replace(/:([^/]+)/g, (_, paramName) => {
      paramNames.push(paramName);
      return "([^/]+)";
    });

    this.routes.push({
      path,
      regex: new RegExp(`^${regexPath}$`),
      paramNames,
      handler,
    });
  }

  findRoute(url) {
    const pathname = url.split("?")[0];

    for (const route of this.routes) {
      const match = pathname.match(route.regex);
      if (match) {
        const params = {};
        route.paramNames.forEach((name, index) => {
          params[name] = match[index + 1];
        });
        return { handler: route.handler, params, path: route.path };
      }
    }
    return null;
  }
}

=> 서버 라우터는 진짜 단순하게 만들면 됨. 이벤트 리스너, 구독 같은 거 필요 없고, 그냥 URL 받아서 매칭되는 라우트 찾아주면 끝.
(클라이언트 라우터가 복잡했던 건 상태 변화를 추적해야 하기 때문.)

8. 서버 Store 격리

// 클라이언트 Store - 구독 기능 있음
export const productStore = createStore(reducer, initialState);
productStore.subscribe(() => {
  // 상태 바뀌면 리렌더링
});

근데 서버에서는:

  1. 구독할 컴포넌트가 없음 (React 인스턴스가 매 요청마다 새로 만들어지기 떄문)
  2. 전역 Store 쓰면 요청 간 상태가 공유되는 대참사 발생
// 서버 Store - 각 요청마다 새로 생성
export function createServerStore(initialState) {
  let state = { ...initialState };

  return {
    getState: () => state,
    dispatch: (action) => {
      // 상태 업데이트 로직
      state = { ...state, ...action.payload };
    },
    subscribe: () => () => {}, // 서버에서는 불필요
  };
}

SSR에서는 요청마다 격리된 상태가 핵심. A 사용자의 요청이 B 사용자에게 영향 주면 안 되니까.

5. main-server.js - SSR 관련 로직이 있는 곳

flowchart TD
    A[URL 요청] --> B[URL 파싱]
    B --> C{라우트 매칭}

    C -->|홈| E[상품 목록 + 카테고리 조회]
    C -->|상세| F[상품 상세 + 관련 상품 조회]
    C -->|404| G[에러 페이지]

    E --> H[Store에 데이터 세팅]
    F --> H
    G --> H

    H --> I[HTML 문자열 생성]
    I --> J[head 메타 태그 생성]
    J --> K[initialData 반환]
Loading
export async function render(url) {
  const { pathname, query } = parseUrl(url);
  const stores = createServerStores();  // 요청마다 새 Store

  const router = new ServerRouter();
  router.addRoute("/", "home");
  router.addRoute("/product/:id/", "product");

  const route = router.findRoute(pathname);

  // 데이터 프리페칭
  if (route.path === "/") {
    const [productsData, categories] = await Promise.all([
      getProducts(query),
      getCategories(),
    ]);

    stores.productStore.dispatch({
      type: PRODUCT_ACTIONS.SETUP,
      payload: { products: productsData.products, ... }
    });

    initialData = { products: ..., categories: ..., totalCount: ... };
  }

  // HTML 렌더링
  const html = renderHomePage(stores, query);
  const head = `<title>쇼핑몰 - 홈</title>`;

  return { html, head, initialData };
}

6. Hydration

flowchart LR
    A[서버 HTML] --> B{클라이언트에서}
    B --> C[HTML 파싱해서 화면 표시]
    B --> D[JS 로드 및 실행]
    D --> E[__INITIAL_DATA__ 읽기]
    E --> F[Store 상태 복원]
    F --> G[React hydrate]
    G --> H[이벤트 핸들러 연결]
Loading

window.__INITIAL_DATA__ 주입

// server.js
const initialDataScript = `<script>
  window.__INITIAL_DATA__ = ${JSON.stringify(initialData)};
</script>`;

const finalHtml = template.replace("<!--app-html-->", html).replace("</head>", `${initialDataScript}</head>`);

클라이언트에서 복원

// hydration.ts
export function hydrateFromServerData(): boolean {
  if (typeof window === "undefined") return false;

  const initialData = window.__INITIAL_DATA__;
  if (!initialData) return false;

  if (isHomePageData(initialData)) {
    productStore.dispatch({
      type: PRODUCT_ACTIONS.SETUP,
      payload: {
        products: initialData.products,
        categories: initialData.categories,
        totalCount: initialData.totalCount,
        loading: false,
        status: "done",
      },
    });
    return true;
  }
  // ...
}

main.tsx에서 분기 처리

function main() {
  router.start();

  const rootElement = document.getElementById("root")!;
  const hasServerRenderedContent = rootElement.innerHTML.trim() !== "" && rootElement.innerHTML !== "<!--app-html-->";

  if (hasServerRenderedContent) {
    // SSR된 페이지 → hydrate
    hydrateFromServerData();
    hydrateRoot(rootElement, <App />);
    cleanupInitialData(); // 메모리 정리
  } else {
    // 일반 CSR → createRoot
    createRoot(rootElement).render(<App />);
  }
}

Hydration mismatch 에러가 왜 나는지 드디어 이해함. 서버에서 렌더링한 HTML과 클라이언트에서 첫 렌더링할 때의 HTML이 다르면 React가 경고를 뱉는 거였다. 그래서 같은 데이터로 같은 결과가 나와야 함.

7. SSG 구현은 어떻게 할까

SSG는 그냥 SSR을 빌드 타임에 돌리는 것. 진짜 이게 다였다.

async function generateStaticSite() {
  const ssrModule = await import("./dist/react-ssr/main-server.js");
  const render = ssrModule.render;
  const template = fs.readFileSync("dist/react/index.html", "utf-8");

  // 생성할 페이지 목록
  const routes = [{ url: "/front_7th_chapter4-1/react/", query: {} }];

  // 상품 상세 페이지들 추가
  items.forEach((item) => {
    routes.push({
      url: `/front_7th_chapter4-1/react/product/${item.productId}/`,
      query: {},
    });
  });

  // 각 페이지 렌더링 및 저장
  for (const route of routes) {
    const { html, head, initialData } = await render(route.url, route.query);

    const finalHtml = template
      .replace("<!--app-html-->", html)
      .replace("<!--app-head-->", head)
      .replace("</head>", `${initialDataScript}</head>`);

    // 파일로 저장
    fs.writeFileSync(filePath, finalHtml);
  }
}

서버에서 요청 올 때마다 렌더링하는 게 아니라, 미리 다 만들어놓고 정적 파일로 서빙하니까 CDN에 올려도 되고, 서버 부하도 없다는 장점이 있음.

8. React SSR - TypeScript + Universal Router

React SSR은 Vanilla보다 조금 더 복잡했다. renderToString 쓰면 되는 건 알았는데, 문제는 라우터.

서버용 React Router

export class ServerRouter {
  readonly #routes: Map<string, Route>;
  #route: (Route & { params: StringRecord; path: string }) | null = null;
  #query: StringRecord = {};

  navigate(url: string, query: StringRecord = {}) {
    this.#query = query;
    let pathname = url.split("?")[0];

    for (const [routePath, route] of this.#routes) {
      const match = pathname.match(route.regex);
      if (match) {
        const params: StringRecord = {};
        route.paramNames.forEach((name, index) => {
          params[name] = match[index + 1];
        });
        this.#route = { ...route, params, path: routePath };
        return;
      }
    }
    this.#route = null;
  }

  // 서버에서는 이벤트 구독 필요 없음
  subscribe = () => () => {};
  push() {}
  start() {}
}

renderToString 사용

import { renderToString } from "react-dom/server";

export const render = async (url: string, query: StringRecord) => {
  serverRouter.navigate(url, query);

  // 데이터 로딩...

  const html = renderToString(
    <QueryProvider initialQuery={query}>
      <ToastProvider>
        <ModalProvider>
          <PageComponent />
        </ModalProvider>
      </ToastProvider>
    </QueryProvider>,
  );

  return { html, head, initialData };
};

Lesson Learned 총정리

1. SSR은 결국 문자열 조작이다

처음에 SSR을 너무 복잡하게 생각했다. "서버에서 React를 돌린다"는 게 대체 무슨 말인지, 가상 DOM이 서버에서 어떻게 동작하는지 막연하게 어렵게만 느껴졌음.

근데 막상 구현해보니까 핵심은 간단했다:

renderToString(<App />) → "<div>...</div>" 문자열 반환

이게 다임. React 컴포넌트를 실행해서 HTML 문자열을 뽑아내는 것. DOM API 호출 없이 순수하게 문자열만 만들어냄. 그래서 서버에서 돌릴 수 있는 거였다.

// 결국 이런 느낌
const html = renderToString(<App />);
const finalHtml = `
  <!DOCTYPE html>
  <html>
    <body>
      <div id="root">${html}</div>
    </body>
  </html>
`;
res.send(finalHtml);

오히려 복잡한 건 주변 인프라(빌드 설정, 데이터 페칭, Hydration)지, SSR 자체는 문자열 생성이 핵심.


2. 요청 간 격리가 생명이다

이건 실수하면 진짜 큰일 나는 부분이다. 처음에 별 생각 없이 클라이언트 Store를 서버에서도 그대로 썼다가 문제를 깨달음.

문제 상황:

// ❌ 잘못된 방식 - 전역 Store
export const productStore = createStore(reducer, initialState);

// server.js
app.use(async (req, res) => {
  productStore.dispatch({ type: "SET_PRODUCTS", payload: userAProducts });
  const html = render(); // A 유저의 데이터로 렌더링
  res.send(html);
});

A 유저 요청 처리 중에 B 유저 요청이 들어오면? Store에 B 유저 데이터가 덮어씌워지고, A 유저는 B 유저 데이터가 담긴 HTML을 받게 됨. 보안 이슈이자 버그.

해결:

// ✅ 올바른 방식 - 요청마다 새 Store
app.use(async (req, res) => {
  const stores = createServerStores(); // 매번 새로 생성
  stores.productStore.dispatch({ type: "SET_PRODUCTS", payload: products });
  const html = render(stores);
  res.send(html);
  // 요청 끝나면 stores는 GC됨
});

Node.js는 싱글 스레드지만 비동기로 여러 요청을 동시에 처리함. 그래서 전역 상태 쓰면 요청 간 데이터가 섞일 수 있다. 이건 클라이언트 개발할 때는 고려 안 해도 되는 부분이라 처음엔 생각도 못 했다.


3. Hydration은 "이어받기"다

서버에서 HTML 만드는 건 쉬운데, 클라이언트가 그걸 받아서 React 앱으로 이어받는게 어려움.

Hydration이 하는 일:

  1. 서버가 보낸 HTML을 파싱해서 화면에 표시 (이건 브라우저가 알아서 함)
  2. JS 번들 로드
  3. React가 기존 HTML 구조를 확인
  4. 가상 DOM을 생성하고 기존 DOM과 연결
  5. 이벤트 핸들러 부착

왜 어렵지?

서버에서 렌더링한 HTML과 클라이언트에서 첫 렌더링하려는 HTML이 완전히 동일해야 함. 한 글자라도 다르면 React가 경고를 뱉고, 심하면 전체를 다시 렌더링함

// ❌ Hydration mismatch 유발
function Header() {
  return <span>현재 시간: {Date.now()}</span>;
  // 서버: 1703001234567
  // 클라이언트: 1703001234890
  // → 불일치!
}

// ❌ 이것도 문제
function UserInfo() {
  const isLoggedIn = localStorage.getItem("token"); // 서버에서 터짐
  return isLoggedIn ? <span>환영합니다</span> : <span>로그인하세요</span>;
}

// ✅ 올바른 방식
function UserInfo() {
  const [isLoggedIn, setIsLoggedIn] = useState(false);

  useEffect(() => {
    // 클라이언트에서만 실행
    setIsLoggedIn(!!localStorage.getItem("token"));
  }, []);

  return isLoggedIn ? <span>환영합니다</span> : <span>로그인하세요</span>;
}

이번 과제에서 window.__INITIAL_DATA__로 서버 데이터를 클라이언트에 전달한 이유도 이거다. 같은 데이터로 렌더링해야 같은 결과가 나오니까.
실제로 category 필드의 순서가 달랐어서 테스트 계속 실패했었는데, 디버깅하느라 시간 은근히 뺏김.


4. "Universal" 코드 작성법

서버와 클라이언트 양쪽에서 돌아가는 코드를 작성하는 건 생각보다 신경 쓸 게 많았다.

window 타입의 존재 유무로 환경을 판단한다.

서버에서 안 되는 것들:

// ❌ 모두 서버에서 터짐
window.location.href;
document.getElementById("root");
localStorage.getItem("key");
navigator.userAgent;
fetch("/api/data"); // 상대 경로는 안 됨, 절대 경로 필요

조건부 실행 패턴:

// 패턴 1: 조건문
if (typeof window !== "undefined") {
  localStorage.setItem("key", "value");
}

// 패턴 2: useEffect (React)
useEffect(() => {
  // 여기는 클라이언트에서만 실행됨
  const token = localStorage.getItem("token");
}, []);

// 패턴 3: dynamic import (Next.js 스타일)
const Chart = dynamic(() => import("./Chart"), { ssr: false });

라우터도 분기:

// 클라이언트: History API 사용
class ClientRouter {
  push(path: string) {
    window.history.pushState(null, "", path);
    window.dispatchEvent(new PopStateEvent("popstate"));
  }
}

// 서버: 그냥 URL 파싱만
class ServerRouter {
  navigate(url: string) {
    const pathname = url.split("?")[0];
    // 매칭되는 라우트 찾기
  }

  push() {} // 빈 메서드 (서버에서는 의미 없음)
}

5. 빌드 과정에 대한 정리

처음에 package.json에 있는 빌드 스크립트 이해하는데만 꽤나 시간이 걸렸다;

클라이언트 빌드 (브라우저용):

vite build --outDir ./dist/react
  • 진입점: main.tsx
  • 타겟: 브라우저
  • 결과물: JS 번들 + CSS + 정적 자산
  • 특징: 코드 스플리팅, 트리 쉐이킹, 압축

서버 빌드 (Node.js용):

vite build --outDir ./dist/react-ssr --ssr src/main-server.tsx
  • 진입점: main-server.tsx
  • 타겟: Node.js
  • 결과물: CommonJS 또는 ESM 모듈
  • 특징: node_modules 외부화, 브라우저 API 폴리필 제외

왜 다를까?

// 클라이언트 빌드에 포함되는 것
import { hydrateRoot } from "react-dom/client"; // 브라우저용
import "./styles.css"; // CSS 번들링

// 서버 빌드에 포함되는 것
import { renderToString } from "react-dom/server"; // Node.js용
import express from "express"; // 서버 프레임워크

react-dom/clientreact-dom/server는 완전히 다른 코드다. 하나는 DOM 조작하고, 하나는 문자열 생성함. 그래서 빌드도 따로 해야 하는 것.


6. SSG는 "미리 돌려놓은 SSR"

SSG는 생각보다? 간단했다.

  • SSR: 요청 올 때마다 서버에서 렌더링
[요청] → [렌더링] → [응답]
[요청] → [렌더링] → [응답]
[요청] → [렌더링] → [응답]
  • SSG: 빌드할 때 미리 다 렌더링해놓음
[빌드 시점]
  → 페이지1 렌더링 → HTML 파일 저장
  → 페이지2 렌더링 → HTML 파일 저장
  → 페이지3 렌더링 → HTML 파일 저장

[런타임]
  [요청] → HTML 파일 그대로 전송 (렌더링 없음!)
// SSG 빌드 스크립트
async function generateStaticSite() {
  const routes = [
    { url: "/", file: "index.html" },
    { url: "/product/123/", file: "product/123/index.html" },
    // ...
  ];

  for (const route of routes) {
    // SSR이랑 똑같은 render 함수 호출
    const { html, head, initialData } = await render(route.url);

    // 대신 응답 대신 파일로 저장
    fs.writeFileSync(route.file, finalHtml);
  }
}

그래서 SSG의 장점:

  • 서버 부하 없음 (정적 파일이니까)
  • CDN에 올리면 전 세계에서 빠르게 접근 가능
  • 서버 비용 절감

단점:

  • 빌드할 때 모든 페이지 경로를 알아야 함
  • 데이터가 바뀌면 다시 빌드해야 함
  • 동적 콘텐츠에는 부적합

7. 데이터 페칭 타이밍

CSR에서는 컴포넌트 마운트 후 useEffect에서 데이터 가져왔다. SSR에서는 렌더링 전에 데이터를 다 가져와야 함.

// ❌ CSR 스타일 - SSR에서 안 됨
function ProductList() {
  const [products, setProducts] = useState([]);

  useEffect(() => {
    fetch("/api/products")
      .then((res) => res.json())
      .then(setProducts);
  }, []);

  return products.map((p) => <Product key={p.id} {...p} />);
}
// 서버에서는 useEffect가 실행 안 됨
// 빈 배열로 렌더링됨
// ✅ SSR 스타일 - 렌더링 전에 데이터 로드
export async function render(url) {
  // 1. 먼저 데이터 가져오기
  const products = await fetchProducts();

  // 2. Store에 세팅
  productStore.dispatch({ type: "SET_PRODUCTS", payload: products });

  // 3. 그 다음 렌더링
  const html = renderToString(<App />);

  return { html, initialData: { products } };
}

이게 Next.js의 getServerSidePropsgetStaticProps가 하는 일이다. 페이지 렌더링 전에 데이터를 미리 가져오는 것. 이번 과제 하면서 프레임워크가 왜 저런 API를 제공하는지 이해됨.


8. 에러 처리가 더 중요해진다

CSR에서 에러 나면 그 컴포넌트만 안 보이거나, Error Boundary가 잡아줌. SSR에서 에러 나면?

app.use(async (req, res) => {
  try {
    const { html } = await render(url);
    res.send(html);
  } catch (error) {
    // 여기서 에러 안 잡으면 서버 죽음
    console.error(error);
    res.status(500).send("Internal Server Error");
  }
});

서버가 죽으면 모든 사용자가 영향 받음. 그래서 SSR 코드는 에러 처리를 더 꼼꼼하게 해야 함.

특히 데이터 페칭 실패 시:

export async function render(url) {
  let products = [];

  try {
    products = await fetchProducts();
  } catch (error) {
    console.error("상품 조회 실패:", error);
    // 빈 배열로라도 렌더링 진행
  }

  // 에러 나도 페이지는 보여줌
  const html = renderToString(<App products={products} />);
  return { html };
}

9. 디버깅,,,

CSR은 브라우저 개발자 도구에서 확인할 수 있다. 서버는,

  • 서버 로그 봐야 함
  • 서버에서 렌더링된 HTML이 뭔지 확인하려면 curl 찍어봐야 함
  • Hydration mismatch는 클라이언트 콘솔에서만 보임
  • 서버 에러는 클라이언트에서 안 보임

디버깅 팁:

nodemon..


마무리

SSR이 왜 "은탄환"이 아닌지 이제 알겠다. 성능 개선, SEO 이점이 있지만, 복잡도가 확실히 올라감. 모든 프로젝트에 SSR이 필요한 건 아니다.
실제로 회사에서 SSR을 많이들 쓰고 있는데, 한 번 더 생각해보는 계기가 되었다.

SSR이 필요한 경우:

  • SEO가 중요한 서비스 (쇼핑몰, 블로그, 뉴스, 콘텐츠 중심의 서비스)
  • 첫 페이지 로딩 속도가 중요한 서비스
  • 소셜 미디어 공유 미리보기가 필요한 서비스

+이번 과제 하면서 "아 그래서 Next.js가 이렇게 해주는 거구나" 싶은 순간이 많았다. 프레임워크가 추상화해주는게 얼마나 많은지 직접 해보니까 체감되었음..


추후 학습 예정

  • renderToPipeableStream을 이용한 Streaming SSR
  • Server Component와 Client Component가 SSR에서 어떻게 다르게 동작하는지
  • 바닐라 환경에서의 hydration mismatch

리뷰 받고 싶은 내용

  1. 서버에서 Store를 매 요청마다 생성하는 방식이 맞을까요? - 요청이 많아지면 메모리 부담이 클 것 같은데, 다른 방식이 있는지 궁금합니다. 요청 보낸 직후 해제한다거나 하면 될까요?

  2. Hydration 불일치 방지를 위한 더 좋은 패턴? - 현재는 조심해서 코드 짜는 수밖에 없는데, 구조적으로 방지하는 방법이 있는지.

  3. ISR 전략을 취하는 경우 정해진 주기마다 스크립트 파일을 돌리는건가요? on-demand 방식이라면 stale 같은 상태를 페이지 별로 관리해야하는건가요?

  4. window.__INITIAL_DATA__ 와 같은 방식으로 데이터를 내려주게 되면 보안상의 문제가 있을거 같은데, 민감한 데이터나 토큰 같은 정보들은 어떻게 내려줘야할까요?

@Jihoon-Yoon96
Copy link

수료하고 여유로울때 다시보니까 또 한 번 감탄 + 학습하게 된다 ㅎㅎ 좋은 PR 땡큐 준태햄~

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants