diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js
index b9a56d98..0a47841e 100644
--- a/packages/vanilla/server.js
+++ b/packages/vanilla/server.js
@@ -1,31 +1,66 @@
+import fs from "node:fs/promises";
import express from "express";
+import { getBaseUrl } from "./src/mocks/utils.js";
+import { server as mswServer } from "./src/mocks/node.js";
const prod = process.env.NODE_ENV === "production";
const port = process.env.PORT || 5173;
-const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/");
+mswServer.listen({ onUnhandledRequest: "warn" });
+
+// gh-pages 배포 기준
+const base = getBaseUrl(prod);
+
+const templateHtml = prod ? await fs.readFile("./dist/vanilla/index.html", "utf-8") : "";
const app = express();
-const render = () => {
- return `
안녕하세요
`;
-};
-
-app.get("*all", (req, res) => {
- res.send(
- `
-
-
-
-
-
- Vanilla Javascript SSR
-
-
-${render()}
-
-
- `.trim(),
- );
+let vite;
+if (!prod) {
+ // 개발 모드일 때, hmr을 제공하기 위함
+ const { createServer } = await import("vite");
+ vite = await createServer({
+ server: { middlewareMode: true },
+ appType: "custom",
+ base,
+ });
+ app.use(vite.middlewares);
+} else {
+ const compression = (await import("compression")).default;
+ app.use(compression());
+ // 👇 express 내장 static 사용
+ app.use(base, express.static("./dist/vanilla-ssr", { extensions: [] }));
+}
+
+// Serve HTML
+app.get("*all", async (req, res) => {
+ try {
+ const url = req.originalUrl.replace(base, "");
+
+ /** @type {string} */
+ let template;
+ let render;
+ if (!prod) {
+ // 실시간 index.html 반영
+ template = await fs.readFile("./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);
+
+ const html = template
+ .replace(``, rendered.head ?? "")
+ .replace(``, rendered.html ?? "");
+
+ 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
diff --git a/packages/vanilla/src/api/productApi.js b/packages/vanilla/src/api/productApi.js
index c2330fbe..a946c097 100644
--- a/packages/vanilla/src/api/productApi.js
+++ b/packages/vanilla/src/api/productApi.js
@@ -1,3 +1,8 @@
+import { getBaseUrl } from "../mocks/utils.js";
+
+const isProd = process.env.NODE_ENV === "production";
+const baseUrl = getBaseUrl(isProd);
+
export async function getProducts(params = {}) {
const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params;
const page = params.current ?? params.page ?? 1;
@@ -11,17 +16,17 @@ export async function getProducts(params = {}) {
sort,
});
- const response = await fetch(`/api/products?${searchParams}`);
+ const response = await fetch(`${baseUrl}api/products?${searchParams}`);
return await response.json();
}
export async function getProduct(productId) {
- const response = await fetch(`/api/products/${productId}`);
+ const response = await fetch(`${baseUrl}api/products/${productId}`);
return await response.json();
}
export async function getCategories() {
- const response = await fetch("/api/categories");
+ const response = await fetch(`${baseUrl}api/categories`);
return await response.json();
}
diff --git a/packages/vanilla/src/lib/Router.js b/packages/vanilla/src/lib/Router.js
index 2238a878..a817cd63 100644
--- a/packages/vanilla/src/lib/Router.js
+++ b/packages/vanilla/src/lib/Router.js
@@ -14,10 +14,12 @@ export class Router {
this.#route = null;
this.#baseUrl = baseUrl.replace(/\/$/, "");
- window.addEventListener("popstate", () => {
- this.#route = this.#findRoute();
- this.#observer.notify();
- });
+ if (typeof window !== "undefined") {
+ window.addEventListener("popstate", () => {
+ this.#route = this.#findRoute();
+ this.#observer.notify();
+ });
+ }
}
get baseUrl() {
@@ -25,7 +27,7 @@ export class Router {
}
get query() {
- return Router.parseQuery(window.location.search);
+ return Router.parseQuery();
}
set query(newQuery) {
@@ -73,8 +75,11 @@ export class Router {
});
}
- #findRoute(url = window.location.pathname) {
- const { pathname } = new URL(url, window.location.origin);
+ #findRoute(url) {
+ const defaultUrl = typeof window !== "undefined" ? window.location.pathname : "/";
+ const currentUrl = url || defaultUrl;
+ const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
+ const { pathname } = new URL(currentUrl, origin);
for (const [routePath, route] of this.#routes) {
const match = pathname.match(route.regex);
if (match) {
@@ -103,11 +108,13 @@ export class Router {
// baseUrl이 없으면 자동으로 붙여줌
let fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url);
- const prevFullUrl = `${window.location.pathname}${window.location.search}`;
+ if (typeof window !== "undefined") {
+ const prevFullUrl = `${window.location.pathname}${window.location.search}`;
- // 히스토리 업데이트
- if (prevFullUrl !== fullUrl) {
- window.history.pushState(null, "", fullUrl);
+ // 히스토리 업데이트
+ if (prevFullUrl !== fullUrl) {
+ window.history.pushState(null, "", fullUrl);
+ }
}
this.#route = this.#findRoute(fullUrl);
@@ -130,7 +137,10 @@ export class Router {
* @param {string} search - location.search 또는 쿼리 문자열
* @returns {Object} 파싱된 쿼리 객체
*/
- static parseQuery = (search = window.location.search) => {
+ static parseQuery = (search) => {
+ if (search === undefined) {
+ search = typeof window !== "undefined" ? window.location.search : "";
+ }
const params = new URLSearchParams(search);
const query = {};
for (const [key, value] of params) {
@@ -166,6 +176,7 @@ export class Router {
});
const queryString = Router.stringifyQuery(updatedQuery);
- return `${baseUrl}${window.location.pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`;
+ const pathname = typeof window !== "undefined" ? window.location.pathname : "/";
+ return `${baseUrl}${pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`;
};
}
diff --git a/packages/vanilla/src/lib/createStorage.js b/packages/vanilla/src/lib/createStorage.js
index 08b504f2..b4b67743 100644
--- a/packages/vanilla/src/lib/createStorage.js
+++ b/packages/vanilla/src/lib/createStorage.js
@@ -1,10 +1,31 @@
+/**
+ * 메모리 기반 스토리지 구현체 (서버 사이드용)
+ */
+const createMemoryStorage = () => {
+ const store = new Map();
+
+ return {
+ getItem: (key) => store.get(key) || null,
+ setItem: (key, value) => store.set(key, value),
+ removeItem: (key) => store.delete(key),
+ clear: () => store.clear(),
+ get length() {
+ return store.size;
+ },
+ key: (index) => Array.from(store.keys())[index] || null,
+ };
+};
+
/**
* 로컬스토리지 추상화 함수
* @param {string} key - 스토리지 키
- * @param {Storage} storage - 기본값은 localStorage
+ * @param {Storage} storage - 기본값은 localStorage (서버에서는 메모리 스토리지)
* @returns {Object} { get, set, reset }
*/
-export const createStorage = (key, storage = window.localStorage) => {
+export const createStorage = (
+ key,
+ storage = typeof window !== "undefined" ? window.localStorage : createMemoryStorage(),
+) => {
const get = () => {
try {
const item = storage.getItem(key);
diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js
index 40b58858..0e5ddaf9 100644
--- a/packages/vanilla/src/main-server.js
+++ b/packages/vanilla/src/main-server.js
@@ -1,4 +1,190 @@
-export const render = async (url, query) => {
- console.log({ url, query });
- return "";
-};
+import { getBaseUrl } from "./mocks/utils";
+import { HomePage, NotFoundPage, ProductDetailPage } from "./pages";
+import { productStore } from "./stores/productStore.js";
+import { PRODUCT_ACTIONS } from "./stores/actionTypes.js";
+
+class ServerRouter {
+ #routes;
+ // #route;
+ #baseUrl;
+
+ constructor(baseUrl = "") {
+ this.#routes = new Map();
+ // this.#route = null;
+ this.#baseUrl = baseUrl.replace(/\/$/, "");
+ }
+
+ get baseUrl() {
+ return this.#baseUrl;
+ }
+
+ get routes() {
+ return this.#routes;
+ }
+
+ addRoute(path, handler) {
+ // :id → (\\\\d+) 정규식 변환
+ // paramNames 배열 저장
+ const paramNames = [];
+ const regexPath = path
+ .replace(/:\w+/g, (match) => {
+ paramNames.push(match.slice(1)); // ':id' -> 'id'
+ return "([^/]+)";
+ })
+ .replace(/\//g, "\\/");
+
+ const regex = new RegExp(`^${regexPath}/?$`); // pathname만 비교하면 됨
+
+ this.#routes.set(path, {
+ regex,
+ paramNames, // :id 값 반환
+ handler, // 얘일 때 뭐실행?
+ });
+ }
+
+ findRoute(url) {
+ // full URL인 경우 pathname만 추출, 아니면 쿼리스트링만 제거
+ let pathname;
+ if (url.startsWith("http")) {
+ pathname = new URL(url).pathname;
+ } else {
+ pathname = url.split("?")[0];
+ }
+
+ for (const [routePath, 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 {
+ ...route,
+ params,
+ path: routePath,
+ };
+ }
+ }
+ return null;
+ }
+
+ static getQueryString(url) {
+ const query = url.split("?")[1] || "";
+ return query;
+ }
+
+ static parseQueryString(url) {
+ // "?"로 나눠서 뒷부분만 가져오기
+ const query = url.split("?")[1] || "";
+ const params = new URLSearchParams(query);
+ const result = {};
+
+ for (const [key, value] of params.entries()) {
+ if (result[key]) {
+ if (Array.isArray(result[key])) {
+ result[key].push(value);
+ } else {
+ result[key] = [result[key], value];
+ }
+ } else {
+ result[key] = value;
+ }
+ }
+
+ return result;
+ }
+}
+
+const isProd = process.env.NODE_ENV === "production";
+const baseUrl = getBaseUrl(isProd);
+
+async function prefetchData(route, { params, query }) {
+ if (!route || route.path === "/") {
+ const productsRes = await (await fetch(`${baseUrl}api/products${query ? `?${query}` : ""}`)).json();
+ const categories = await (await fetch(`${baseUrl}api/categories`)).json();
+ console.log("!?!?", productsRes, categories);
+ return { products: productsRes.products, categories: categories, totalCount: productsRes.pagination.total };
+ } else {
+ const product = await fetch(`${baseUrl}api/products/${params.id}`).then((r) => r.json());
+
+ // 관련 상품도 prefetch
+ let relatedProducts = [];
+ if (product.category2) {
+ try {
+ const relatedParams = new URLSearchParams({
+ category2: product.category2,
+ limit: "20",
+ page: "1",
+ });
+ const relatedResponse = await fetch(`${baseUrl}api/products?${relatedParams}`).then((r) => r.json());
+ // 현재 상품 제외
+ relatedProducts = relatedResponse.products.filter((p) => p.productId !== params.id);
+ } catch (error) {
+ console.error("관련 상품 prefetch 실패:", error);
+ relatedProducts = [];
+ }
+ }
+
+ return { product, relatedProducts };
+ }
+}
+
+// --- render.js ---
+
+export async function render(url = "/") {
+ const router = new ServerRouter("", url);
+ router.addRoute("/", HomePage);
+ router.addRoute("/product/:id", ProductDetailPage);
+
+ const route = router.findRoute(url);
+ const queryString = ServerRouter.getQueryString(url);
+ const parsedQuery = ServerRouter.parseQueryString(url);
+ console.log(queryString);
+ if (!route) {
+ const rendered = {
+ html: NotFoundPage(),
+ head: "",
+ };
+ return rendered;
+ }
+
+ const initialData = await prefetchData(route, { params: route ? route.params : {}, query: queryString });
+
+ // SSR 환경에서만 임시로 store 초기화 (페이지 렌더링용)
+ productStore.dispatch({
+ type: PRODUCT_ACTIONS.SETUP,
+ payload: {
+ products: initialData.products || [],
+ totalCount: initialData.totalCount || 0,
+ categories: initialData.categories || {},
+ currentProduct: initialData.product || null,
+ relatedProducts: initialData.relatedProducts || [],
+ loading: false,
+ status: "done",
+ },
+ });
+
+ let content = "";
+ let head = `;`;
+ if (route.handler) {
+ if (route.handler === HomePage) {
+ content = HomePage(parsedQuery);
+ head += `쇼핑몰 - 홈`;
+ } else if (route.handler === ProductDetailPage) {
+ head += `${initialData.product.title} - 쇼핑몰`;
+ content = ProductDetailPage(route.params.id);
+ }
+ } else {
+ content = NotFoundPage();
+ }
+
+ const rendered = {
+ html: content,
+ head,
+ };
+
+ return rendered;
+}
diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js
index 4c3f2765..76c6fd37 100644
--- a/packages/vanilla/src/main.js
+++ b/packages/vanilla/src/main.js
@@ -4,6 +4,8 @@ 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";
const enableMocking = () =>
import("./mocks/browser.js").then(({ worker }) =>
@@ -11,11 +13,30 @@ const enableMocking = () =>
serviceWorker: {
url: `${BASE_URL}mockServiceWorker.js`,
},
- onUnhandledRequest: "bypass",
+ onUnhandledRequest: "warn",
}),
);
function main() {
+ // SSR에서 주입된 초기 데이터 확인 및 hydration
+ const initialData = window.__INITIAL_DATA__;
+ if (initialData) {
+ console.log("Hydrating with initial data:", initialData);
+
+ productStore.dispatch({
+ type: PRODUCT_ACTIONS.SETUP,
+ payload: {
+ products: initialData.products || [],
+ totalCount: initialData.totalCount || 0,
+ categories: initialData.categories || {},
+ currentProduct: initialData.product || null,
+ relatedProducts: initialData.relatedProducts || [],
+ loading: false,
+ status: "done",
+ },
+ });
+ }
+
registerAllEvents();
registerGlobalEvents();
loadCartFromStorage();
@@ -23,8 +44,7 @@ function main() {
router.start();
}
-if (import.meta.env.MODE !== "test") {
- enableMocking().then(main);
-} else {
- main();
-}
+// 현재 node, test, browser환경에서 모두 msw를 사용하므로 분기처리하지 않음.
+enableMocking().then(main);
+
+// enableMocking().then(main);
diff --git a/packages/vanilla/src/mocks/handlers.js b/packages/vanilla/src/mocks/handlers.js
index 6e3035e6..417fa363 100644
--- a/packages/vanilla/src/mocks/handlers.js
+++ b/packages/vanilla/src/mocks/handlers.js
@@ -1,5 +1,6 @@
import { http, HttpResponse } from "msw";
-import items from "./items.json";
+import items from "./items.json" with { type: "json" };
+import { getBaseUrl } from "./utils.js";
const delay = async () => await new Promise((resolve) => setTimeout(resolve, 200));
@@ -62,9 +63,12 @@ function filterProducts(products, query) {
return filtered;
}
+const prod = process.env.NODE_ENV === "production";
+const baseUrl = getBaseUrl(prod);
+
export const handlers = [
// 상품 목록 API
- http.get("/api/products", async ({ request }) => {
+ http.get(`${baseUrl}api/products`, async ({ request }) => {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get("page") ?? url.searchParams.get("current")) || 1;
const limit = parseInt(url.searchParams.get("limit")) || 20;
@@ -111,7 +115,7 @@ export const handlers = [
}),
// 상품 상세 API
- http.get("/api/products/:id", ({ params }) => {
+ http.get(`${baseUrl}api/products/:id`, ({ params }) => {
const { id } = params;
const product = items.find((item) => item.productId === id);
@@ -133,7 +137,7 @@ export const handlers = [
}),
// 카테고리 목록 API
- http.get("/api/categories", async () => {
+ http.get(`${baseUrl}api/categories`, async () => {
const categories = getUniqueCategories();
await delay();
return HttpResponse.json(categories);
diff --git a/packages/vanilla/src/mocks/node.js b/packages/vanilla/src/mocks/node.js
new file mode 100644
index 00000000..2c0b4418
--- /dev/null
+++ b/packages/vanilla/src/mocks/node.js
@@ -0,0 +1,5 @@
+import { setupServer } from "msw/node";
+import { handlers } from "./handlers.js";
+
+// Node 환경용 서버 MSW
+export const server = setupServer(...handlers);
diff --git a/packages/vanilla/src/mocks/utils.js b/packages/vanilla/src/mocks/utils.js
new file mode 100644
index 00000000..bdf26c08
--- /dev/null
+++ b/packages/vanilla/src/mocks/utils.js
@@ -0,0 +1,23 @@
+export const getBaseUrl = (isProd) => {
+ // Node 환경 (process.env 있음)
+ const nodeEnv = typeof process !== "undefined" ? process.env : {};
+
+ // Browser 환경 (import.meta.env 있음)
+ const browserEnv = typeof import.meta !== "undefined" ? import.meta.env : {};
+
+ if (typeof window !== "undefined") {
+ // 브라우저 환경
+ if (isProd) {
+ // gh-pages 같은 정적 배포
+ return browserEnv.VITE_BASE || "/front_6th_chapter4-1/vanilla/";
+ } else {
+ // 개발 환경 → origin만 사용
+
+ return window.location.origin + "/";
+ }
+ }
+
+ // Node 환경 (서버에서 실행될 때)
+ const port = nodeEnv.PORT || 5173;
+ return nodeEnv.BASE || (isProd ? "/front_6th_chapter4-1/vanilla/" : `http://localhost:${port}/`);
+};
diff --git a/packages/vanilla/src/pages/HomePage.js b/packages/vanilla/src/pages/HomePage.js
index ca08c26c..43ce10f6 100644
--- a/packages/vanilla/src/pages/HomePage.js
+++ b/packages/vanilla/src/pages/HomePage.js
@@ -17,9 +17,11 @@ export const HomePage = withLifecycle(
() => loadProducts(true),
],
},
- () => {
+ (serverQuery) => {
const productState = productStore.getState();
- const { search: searchQuery, limit, sort, category1, category2 } = router.query;
+
+ const currentQuery = serverQuery || router.query;
+ const { search: searchQuery, limit, sort, category1, category2 } = currentQuery;
const { products, loading, error, totalCount, categories } = productState;
const category = { category1, category2 };
const hasMore = products.length < totalCount;
diff --git a/packages/vanilla/src/pages/ProductDetailPage.js b/packages/vanilla/src/pages/ProductDetailPage.js
index 73d0ec30..59f96aa2 100644
--- a/packages/vanilla/src/pages/ProductDetailPage.js
+++ b/packages/vanilla/src/pages/ProductDetailPage.js
@@ -239,10 +239,16 @@ export const ProductDetailPage = withLifecycle(
onMount: () => {
loadProductDetailForPage(router.params.id);
},
- watches: [() => [router.params.id], () => loadProductDetailForPage(router.params.id)],
+ watches: [
+ () => [router.params.id],
+ () => {
+ loadProductDetailForPage(router.params.id);
+ },
+ ],
},
() => {
- const { currentProduct: product, relatedProducts = [], error, loading } = productStore.getState();
+ const productStoreCopy = productStore.getState();
+ const { currentProduct: product, relatedProducts = [], error, loading } = productStoreCopy;
return PageWrapper({
headerLeft: `
diff --git a/packages/vanilla/src/services/productService.js b/packages/vanilla/src/services/productService.js
index 8a12e8bd..2658506f 100644
--- a/packages/vanilla/src/services/productService.js
+++ b/packages/vanilla/src/services/productService.js
@@ -3,6 +3,14 @@ import { initialProductState, productStore, PRODUCT_ACTIONS } from "../stores";
import { router } from "../router";
export const loadProductsAndCategories = async () => {
+ const currentState = productStore.getState();
+
+ // 이미 상품 목록과 카테고리 데이터가 있으면 API 호출 스킵 (SSR hydration)
+ if (currentState.products.length > 0 && Object.keys(currentState.categories).length > 0 && !currentState.loading) {
+ console.log("이미 상품 목록 데이터가 있어서 API 호출 스킵");
+ return;
+ }
+
router.query = { current: undefined }; // 항상 첫 페이지로 초기화
productStore.dispatch({
type: PRODUCT_ACTIONS.SETUP,
@@ -120,14 +128,11 @@ export const setLimit = (limit) => {
*/
export const loadProductDetailForPage = async (productId) => {
try {
- const currentProduct = productStore.getState().currentProduct;
- if (productId === currentProduct?.productId) {
- // 관련 상품 로드 (같은 category2 기준)
- if (currentProduct.category2) {
- await loadRelatedProducts(currentProduct.category2, productId);
- }
+ // 이미 해당 상품 데이터가 있고, 관련 상품도 있으면 API 호출 스킵
+ if (import.meta.env.SSR) {
return;
}
+
// 현재 상품 클리어
productStore.dispatch({
type: PRODUCT_ACTIONS.SETUP,