| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- SSE적용방법
- 항해99
- 자바스크립트
- SSE 적용 방법
- 리팩토링
- 백준 19939
- 프로그래머스
- 프론트엔드
- 클린코드
- JavaScript
- react
- 항해
- EC2
- 박 터뜨리기
- greedy
- next.js
- 코딩테스트
- 코테
- 탐욕알고리즘
- SSE 후기
- 백준 반례
- 백준
- 리액트
- 테스트코드
- 그리디
- jQuery
- 2025년회고
- SSE
- 항해플러스
- 알고리즘
- Today
- Total
공부 및 일상기록
[Javascript] Vanilla JS로 SSR, SSG 구현하기 본문
항해 플러스 과정에서 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을 미리 뽑아두는 게 아니라, 초기 데이터까지 함께 다루는 것”이라는 점을 체감할 수 있었다.
'개발 > Javascript' 카테고리의 다른 글
| [Javascript] 클린코드와 리팩토링 (6) | 2025.08.05 |
|---|---|
| Vitest + userEvent 테스트 중 innerHTML로 인한 DOM 재생성 문제 해결기 (5) | 2025.07.31 |
| [Javascript, React] 각종 리액트 훅 구현해보기 및 최적화 하기 (5) | 2025.07.25 |
| [Javascript] 바닐라 자바스크립트로 가상돔 구현하기 (3) | 2025.07.21 |
| [Javascript] 바닐라 자바스크립트로 SPA를 간단히 구현해보고 느낀점 (4) | 2025.07.12 |