공부 및 일상기록

[Javascript] Vanilla JS로 SSR, SSG 구현하기 본문

개발/Javascript

[Javascript] Vanilla JS로 SSR, SSG 구현하기

낚시하고싶어요 2025. 9. 15. 15:13

항해 플러스 과정에서 CSR을 바닐라 JS로 직접 구현했던 적이 있다.
이번에는 그 CSR 구조를 바탕으로 SSR(Server Side Rendering)과 SSG(Static Site Generation) 를 직접 구현해 보는 것이 과제였다.

추가로 React로 SSR, SSG를 구현하는 과제도 있었는데, 일단은 바닐라 JS 쪽을 먼저 정리한다.
(Next.js로 SSR/SSG 경험은 있었지만, 직접 바닥부터 짜보려니 꽤 막막했다.)

 

시작하기 전에 알아보는 CSR, SSR, SSG

 

  • CSR(Client Side Rendering)
    브라우저가 빈 HTML + JS를 받고, JS가 실행되면서 fetch로 데이터를 가져와 DOM을 채움.
    → 단점: 초기 로딩이 늦고, SEO에 불리.
  • SSR(Server Side Rendering)
    서버에서 데이터를 먼저 불러와 HTML을 그려 내려주고, 브라우저는 하이드레이션만 하면 됨.
    → 장점: 첫 페이지 로딩이 빠르고 SEO에 유리.
  • SSG(Static Site Generation)
    빌드 시점에 HTML을 미리 만들어놓고 CDN에서 정적 파일처럼 제공.
    → 장점: 속도와 비용 최적화. 단, 실시간 데이터 반영은 약함.

 

 

SSR 구현

1. 서버 구축

SSR은 서버에서 HTML을 만들어 내려주는 구조이므로 서버 코드가 필요하다. Express.js 기반으로 서버를 작성했다.

import express from "express";
import fs from "fs";
import { mswServer } from "./src/mocks/serverHandlers.js";

const prod = process.env.NODE_ENV === "production";
const port = process.env.PORT || 5174;
const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/");

mswServer.listen({
  onUnhandledRequest: "bypass",
});

const templateHtml = prod ? fs.readFileSync("./dist/vanilla/index.html", "utf-8") : "";
const app = express();

let vite;

if (!prod) {
  const { createServer } = await import("vite");
  vite = await createServer({
    server: { middlewareMode: true },
    appType: "custom",
  });
  app.use(vite.middlewares);
} else {
  const compression = (await import("compression")).default;
  const sirv = (await import("sirv")).default;
  app.use(compression());
  app.use(
    base,
    sirv("dist/vanilla", {
      extensions: [],
    }),
  );
}

app.use("*all", async (req, res) => {
  try {
    // 정적 파일 요청은 SSR 제외
    if (
      req.originalUrl.includes("favicon") ||
      req.originalUrl.endsWith(".ico") ||
      req.originalUrl.endsWith(".png") ||
      req.originalUrl.endsWith(".jpg") ||
      req.originalUrl.endsWith(".css") ||
      req.originalUrl.endsWith(".js")
    ) {
      return res.status(404).end();
    }

    const url = req.originalUrl.replace(base, "");

    let template;
    let render;

    if (!prod) {
      template = fs.readFileSync("./index.html", "utf-8");
      template = await vite.transformIndexHtml(url, template);
      render = (await vite.ssrLoadModule("/src/main-server.js")).render;
    } else {
      template = templateHtml;
      render = (await import("./dist/vanilla-ssr/main-server.js")).render;
    }

    const rendered = await render(url || "/", req.query);
    // const rendered = await render(normalizedPath, query);

    const html = template
      .replace(`<!--app-head-->`, rendered.head ?? "")
      .replace(`<!--app-html-->`, rendered.html ?? "")
      .replace(
        `<!--app-initial-data-->`,
        `<script>window.__INITIAL_DATA__ = ${JSON.stringify(rendered.initialData)}</script>`,
      );

    res.status(200).set({ "Content-Type": "text/html" }).send(html);
  } catch (e) {
    vite?.ssrFixStacktrace(e);
    console.log(e.stack);
    res.status(500).end(e.stack);
  }
});

// Start http server
app.listen(port, () => {
  console.log(`React Server started at http://localhost:${port}`);
});

여기서 중요한 포인트는:

  • MSW 설정
    원래 MSW는 브라우저의 Service Worker에서 fetch를 가로채는데, Node.js 환경에서는 불가능하다.
    그래서 setupServer로 만든 mswServer를 불러와 Express 서버에서 http 요청을 mocking하도록 했다.
  • Vite 연동
    개발 모드에서는 vite.createServer({ middlewareMode: true })로 Express에 붙였다.
    그래서 vite.ssrLoadModule()을 쓸 수 있는데, 이는 Node.js의 import가 처리하지 못하는 TS/JSX/alias 같은 것들을 Vite가 번들링해주고 실행할 수 있도록 도와준다.
  • 배포 모드
    빌드된 정적 자원은 sirv와 compression을 통해 제공한다.

결국 서버가 하는 일은 요청 URL에 맞는 render() 함수 호출 결과(head/html/initialData) 를 HTML 템플릿에 주입해서 내려주는 것이다.

 

2. main-server.js

SSR에서 가장 핵심은 URL에 맞게 데이터를 서버에서 먼저 가져오고, 그걸 기반으로 HTML 문자열을 만들어주는 부분이다.

import { router } from "./router/router.js";
import { HomePage, ProductDetailPage, NotFoundPage } from "./pages";
import { fetchProductDataSSR, fetchProductsDataSSR } from "./api/ssrProductApi.js";

router.addRoute("/", HomePage);
router.addRoute("/product/:id/", ProductDetailPage);

export async function render(url, query = {}) {
  const matched = router.match(url);

  console.log("👉 SSR Matched:", matched);
  console.log("👉 SSR URL:", url);
  console.log("👉 SSR Query:", query);

  if (!matched) {
    return {
      head: "<title>404</title>",
      html: NotFoundPage(),
      initialData: null,
    };
  }

  const { path, params } = matched;

  let initialData;

  if (path === "/") {
    initialData = await fetchProductsDataSSR(query);
  } else if (path === "/product/:id/") {
    initialData = await fetchProductDataSSR(params.id);
  }

  let pageTitle;
  let pageHtml;

  if (path === "/") {
    pageTitle = "쇼핑몰 - 홈";
    pageHtml = HomePage({ initialData });
  } else if (path === "/product/:id/") {
    pageTitle = initialData?.currentProduct?.title ? `${initialData?.currentProduct?.title} - 쇼핑몰` : "쇼핑몰";
    pageHtml = ProductDetailPage({ initialData });
  } else {
    pageHtml = NotFoundPage();
  }

  console.log(initialData);

  return {
    head: `<title>${pageTitle}</title>`,
    html: pageHtml,
    initialData,
  };
}

 

위 코드를 정리하면

  • 라우터로 URL 매칭
  • 필요한 데이터 fetch
  • CSR 때 만들었던 컴포넌트(HomePage, ProductDetailPage)를 실행 → HTML string 리턴
  • <title>과 initialData까지 포함해 반환

이정도 라고 볼 수 있다.

 

3. 하이드레이션

위까지만 하면 SSR은 “서버에서 HTML을 만들어 내려주는 것”에 그친다.
하지만 이 프로젝트는 CSR SPA 구조이므로, 클라이언트에서 하이드레이션이 필요하다.

즉, 서버가 내려준 __INITIAL_DATA__를 클라이언트 스토어에 복구하고, 이벤트 리스너를 붙이고, 라우터를 시작해야 한다.

import { registerGlobalEvents } from "./utils";
import { initRender } from "./render";
import { registerAllEvents } from "./events";
import { loadCartFromStorage } from "./services";
import { router } from "./router";
import { BASE_URL } from "./constants.js";
import { productStore } from "./stores/productStore.js";
import { PRODUCT_ACTIONS } from "./stores/actionTypes.js";

if (typeof window !== "undefined" && window.__INITIAL_DATA__) {
  const data = window.__INITIAL_DATA__;
  console.log("Hydrating with server data:", data);

  // 홈(initial products)용 데이터 복원
  if (data.products) {
    productStore.dispatch({
      type: PRODUCT_ACTIONS.SETUP,
      payload: {
        products: data.products,
        categories: data.categories,
        totalCount: data.totalCount,
        loading: false,
        status: "done",
        error: null,
      },
    });
  }

  // 상품 상세(initial product detail)용 데이터 복원
  if (data.currentProduct) {
    productStore.dispatch({
      type: PRODUCT_ACTIONS.SET_CURRENT_PRODUCT,
      payload: data.currentProduct,
    });

    if (data.relatedProducts) {
      productStore.dispatch({
        type: PRODUCT_ACTIONS.SET_RELATED_PRODUCTS,
        payload: data.relatedProducts,
      });
    }
  }
  // 초기 데이터 정리
  delete window.__INITIAL_DATA__;
}

const enableMocking = () =>
  import("./mocks/browser.js").then(({ worker }) =>
    worker.start({
      serviceWorker: {
        url: `${BASE_URL}mockServiceWorker.js`,
      },
      onUnhandledRequest: "bypass",
    }),
  );

function main() {
  registerAllEvents();
  registerGlobalEvents();
  loadCartFromStorage();
  initRender();

  router.start();
}

if (import.meta.env.MODE !== "test") {
  enableMocking().then(main);
} else {
  main();
}

이 과정을 통해:

  • 서버에서 내려준 HTML과 클라이언트의 상태/이벤트를 동기화 (hydration)
  • 이후에는 CSR처럼 라우팅과 상태 관리가 자연스럽게 이어짐

 


SSG 구현

SSR은 요청 시점마다 서버에서 HTML을 만들어 내려주는 방식이었다.
반면 SSG는 빌드 시점에 HTML과 데이터를 미리 생성해 두고, 실제 서비스에서는 CDN/정적 파일로 빠르게 제공한다.

 

 

시작하기 전에… 내가 착각했던 SSG 동작 원리

처음에 나는 이렇게 생각했다.

“Next.js에서 SSG를 쓰면 빌드 시점에 모든 페이지의 HTML이 만들어진다.
그러니까 사용자가 페이지를 이동할 때도 그 HTML을 그대로 내려주니까 빠른 거겠지?”

겉으로만 보면 맞는 말 같지만, 실제 동작은 그렇지 않았다.

Next.js의 실제 동작

Next.js는 빌드시점에 단순히 HTML만 만드는 게 아니라, HTML + JSON 데이터를 같이 만든다.

  • 사용자가 첫 페이지에 진입할 때는 HTML과 함께 JSON 데이터가 내려와서 SEO도 되고 초기 로딩도 빠르다.
  • 그런데 그 이후 내부 페이지 전환을 할 때는 HTML 파일을 다시 불러오는 게 아니라, 미리 생성해둔 JSON 데이터를 읽어서 클라이언트에서 렌더링한다.

즉, 내부 이동 시에는 SPA처럼 CSR로 동작하는 것이다. HTML은 딱 첫 진입에만 쓰이고, 페이지 전환은 모두 JSON 기반이다.

왜 이렇게 할까?

만약 페이지 이동 때마다 HTML 파일을 받아왔다면,

  • 매번 전체 페이지를 다시 그려야 하고,
  • 자바스크립트 상태나 이벤트도 다시 붙여야 한다.

이건 느리고 비효율적이다.

그래서 Next.js는 빌드 시점에 각 페이지별로 JSON을 함께 만들어두고,
내부 전환 시에는 그 JSON만 불러와서 이미 로드된 JS 번들 + React 컴포넌트로 새 화면을 그리게 한다.

이 방식 덕분에:

  • 첫 진입은 SSG의 장점(SEO, 초기 로딩 빠름)
  • 내부 전환은 CSR의 장점(빠른 화면 전환, 상태 유지)
    을 동시에 가져갈 수 있는 것이다.

 

이제 진짜 내가 어떻게 구현했는지 코드로 알아보자.

 

1. 정적페이지 생성 코드

일단 정적페이지를 생성하기 위해 빌드시점에 실행할 정적페이지 생성 코드를 다음과 같이 만들었다.

// static-site-generater.js

// 홈 페이지 생성
const homeResult = await render("/");
fs.writeFileSync("../../dist/vanilla/index.html", homeResult.html);
fs.writeFileSync("../../dist/vanilla/index.json", JSON.stringify(homeResult.initialData));

// 상품 상세 페이지 생성
const { getProducts } = await vite.ssrLoadModule("./src/api/productApi.js");
const { products } = await getProducts();

for (const product of products) {
  const productResult = await render(`/product/${product.productId}/`);
  const productDir = `../../dist/vanilla/product/${product.productId}`;
  fs.mkdirSync(productDir, { recursive: true });
  fs.writeFileSync(`${productDir}/index.html`, productResult.html);
  fs.writeFileSync(
    `../../dist/vanilla/product/${product.productId}.json`,
    JSON.stringify(productResult.initialData)
  );
}

 

  • index.html → 최초 진입/새로고침 시 사용
  • index.json → 내부 페이지 전환 시 사용 (데이터 하이드레이션 용도)

 

2. 내부 전환 최적화

Next.js처럼 정적 JSON을 먼저 읽고, 없으면 API fallback으로 동작하도록 구현했다.

 

HomePage 예시

onMount: async () => {
  const state = productStore.getState();
  if (state.products && state.products.length > 0) return;

  // ✅ SSG JSON 먼저 시도
  const staticData = await loadInitialData("/");
  if (staticData) {
    hydrateStores(staticData);
    return;
  }

  // ✅ 없으면 API fallback
  loadProductsAndCategories();
}

 

 

 

ProductDetailPage 예시

onMount: async () => {
  const state = productStore.getState();
  const currentId = state.currentProduct?.productId;
  if (currentId === router.params.id) return;

  const staticData = await loadInitialData(`/product/${router.params.id}/`);
  if (staticData) {
    hydrateStores(staticData);
    return;
  }

  loadProductDetailForPage(router.params.id);
}

 

 

3. 이니셜데이터 로드 및 하이드레이션

경로에 맞는 JSON이 있으면 fetch해서 스토어에 주입한다. 없으면 null을 반환하여 API fallback으로 넘어간다.

export const loadInitialData = async (pathname) => {
  try {
    let jsonPath;
    const relativePath = pathname.replace(BASE_PATH, "") || "/";

    if (relativePath === "/") {
      jsonPath = `${window.location.origin}${BASE_PATH}/index.json`;
    } else if (relativePath.startsWith("/product/")) {
      const productId = relativePath.split("/")[2];
      jsonPath = `${window.location.origin}${BASE_PATH}/product/${productId}.json`;
    }

    if (jsonPath) {
      const res = await fetch(jsonPath, { cache: "force-cache" });
      if (res.ok) return await res.json();
    }
  } catch (e) {
    console.warn("👉 Static JSON not found, falling back to API:", e);
  }
  return null;
};

스토어에 데이터를 복원하는 hydrateStores는 SSR 하이드레이션과 동일한 로직을 재활용한다.

 

이 과정을 통해:

  • 최초 진입/새로고침 → 미리 생성된 index.html 로드
  • 내부 페이지 전환 → 미리 빌드된 index.json 사용 → API 호출 없음 → 빠른 전환
  • SSG JSON 없음 → API fallback

즉, **“최초 한 번은 SSG, 내부 이동은 CSR처럼 빠르지만 API 호출은 줄어든 구조”**를 만들 수 있었다.

Next.js의 __NEXT_DATA__나 prefetch JSON 전략과 유사한 동작을 직접 구현해 보면서,
“SSR/SSG의 핵심은 단순히 HTML을 미리 뽑아두는 게 아니라, 초기 데이터까지 함께 다루는 것”이라는 점을 체감할 수 있었다.