diff --git a/README.md b/README.md
new file mode 100644
index 00000000..0433cd3d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,25 @@
+
+## SSR, SSG, 그리고 하이드레이션
+
+하이드레이션이 무엇인지 근본적으로 알 수 있게 되었다. 전에는 그냥 그런 게 있나보다 했는데 이제 왜 하는 것인지 알게 되었다.
+나의 서버 사이드 렌더링은 과거의 PHP 정도 지식에 머물러있어서 그 SSR과 Next와 같은 프레임워크의 SSR의 차이를 알 수 있었다.
+서버에서 렌더링한 HTML과 클라이언트의 HTML을 일치 시켜야 한다는 것과,
+왜 그렇게 Next.js를 쓰면 하이드레이션 오류가 발생하는 것인지도 알 수 있었다.
+
+서버로 인한 레이스 컨디션과 같은 문제도 왜 발생하는지, 독립적 컨텍스트가 왜 필요하는지도 알 수 있었다.
+
+추가로 VITE에서 꽤 많은 걸 해준다는 것을 배웠다.
+https://ko.vite.dev/guide/ssr
+
+사실 회사에서 내가 맡은 프로젝트는 대부분 CSR이었다. 홈페이지성 프로젝트가 아니어서 굳이 사용할 필요도 없다고 생각했고, 인프라 쪽 문제도 있을 거 같았다.
+Nuxt2 프레임워크를 사용한 프로젝트도 있었으나 서버 사이드 기능을 사용하지 않았다. SSG로 빌드했었는데 왜 굳이 SSG로 만들었을까 항상 궁금했었다. 금주 과제를 진행하면서, 그래도 SEO나 첫 화면의 빠른 렌더링 면에서는 SSG였어서 이득이 있었을 것이라는 생각이 들었다.
+
+과제를 뭐부터 시작해야 할지 모르겠어서 무작정 VITE의 SSR문서를 보았더니 대략적으로 감을 잡을 수 있었다.
+서버에서 HTML을 만들어서 내려준다는 것 자체는 이전의 PHP라든가...그런 것들이 있었기 때문에 낯설지 않았는데
+CSR는 CSR대로 잘 되면서 SSR로도 호환되게 만든다는 게 어렵게 느껴졌다.
+MSW와 같은 설정이 너무 힘들게 느껴지기도 했다.
+
+SSR 프레임워크는 깊게 다뤄보지 않았어서 별로 자신 없는 분야였는데 과제를 하면서 전체적인 컨셉을 알 수 있어서 좋았다.
+
+리액트로 전환하는 작업은 더 어렵게 느껴지는데.... 넥스트가 없었다면 서버사이드 렌더링을 하기 위해 참 힘든 작업을 해야겠구나 하고 느꼈다.
+프레임워크라는 게 있어야 사람을 구하기도 편하고 유지보수하기도 편한 것 같다.
diff --git a/packages/react/server.js b/packages/react/server.js
index 81e3e1d8..bafd6f53 100644
--- a/packages/react/server.js
+++ b/packages/react/server.js
@@ -1,32 +1,90 @@
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
import express from "express";
-import { renderToString } from "react-dom/server";
-import { createElement } from "react";
+import compression from "compression";
+import sirv from "sirv";
+import { createServer as createViteServer } from "vite";
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
const prod = process.env.NODE_ENV === "production";
-const port = process.env.PORT || 5174;
+
+const port = process.env.PORT || 5173;
const base = process.env.BASE || (prod ? "/front_7th_chapter4-1/react/" : "/");
const app = express();
-app.get("*all", (req, res) => {
- res.send(
- `
-
-
-
-
-
- React SSR
-
-
-${renderToString(createElement("div", null, "안녕하세요"))}
-
-
- `.trim(),
- );
-});
+async function renderSSR(template, render, url, query) {
+ const { head, html, initialDataScript } = await render(url, query);
+
+ return template
+ .replace("", head)
+ .replace("", html)
+ .replace("", `${initialDataScript}`);
+}
+
+function normalizeUrl(url, base) {
+ if (base && url.startsWith(base)) {
+ url = url.slice(base.length - 1);
+ }
+ if (!url.startsWith("/")) {
+ url = "/" + url;
+ }
+ return url;
+}
+
+// 개발 모드
+if (!prod) {
+ const vite = await createViteServer({
+ server: { middlewareMode: true },
+ appType: "custom",
+ base,
+ });
+
+ app.use(vite.middlewares);
+
+ app.use("*all", async (req, res, next) => {
+ const url = req.originalUrl;
+ const query = req.query;
+
+ try {
+ let template = fs.readFileSync(path.resolve(__dirname, "index.html"), "utf-8");
+ template = await vite.transformIndexHtml(url, template);
+
+ const { render } = await vite.ssrLoadModule(path.resolve(__dirname, "src/main-server.tsx"));
+
+ const finalHtml = await renderSSR(template, render, url, query);
+
+ res.status(200).set({ "Content-Type": "text/html" }).end(finalHtml);
+ } catch (e) {
+ vite.ssrFixStacktrace(e);
+ next(e);
+ }
+ });
+} else {
+ // 프로덕션 모드
+ app.use(compression());
+ app.use(base, sirv(path.resolve(__dirname, "./dist/react"), { extensions: [] }));
+
+ app.use("*all", async (req, res, next) => {
+ try {
+ const url = normalizeUrl(req.originalUrl, base);
+ const query = req.query;
+
+ const template = fs.readFileSync(path.resolve(__dirname, "./dist/react/index.html"), "utf-8");
+ const { render } = await import(path.resolve(__dirname, "./dist/react-ssr/main-server.js"));
+
+ const finalHtml = await renderSSR(template, render, url, query);
+
+ res.status(200).set({ "Content-Type": "text/html" }).end(finalHtml);
+ } catch (e) {
+ console.error("SSR Error:", e);
+ next(e);
+ }
+ });
+}
// Start http server
app.listen(port, () => {
- console.log(`React Server started at http://localhost:${port}`);
+ console.log(`React SSR Server started at http://localhost:${port}`);
});
diff --git a/packages/react/src/App.tsx b/packages/react/src/App.tsx
index 36b302ca..11d3d856 100644
--- a/packages/react/src/App.tsx
+++ b/packages/react/src/App.tsx
@@ -1,14 +1,21 @@
-import { router, useCurrentPage } from "./router";
-import { HomePage, NotFoundPage, ProductDetailPage } from "./pages";
-import { useLoadCartStore } from "./entities";
+import { useCurrentPage } from "./router";
import { ModalProvider, ToastProvider } from "./components";
-
-// 홈 페이지 (상품 목록)
-router.addRoute("/", HomePage);
-router.addRoute("/product/:id/", ProductDetailPage);
-router.addRoute(".*", NotFoundPage);
+import { useLoadCartStore } from "./entities";
+import { useEffect, useState } from "react";
const CartInitializer = () => {
+ const [isClient, setIsClient] = useState(false);
+
+ useEffect(() => {
+ setIsClient(true);
+ }, []);
+
+ if (!isClient) return null;
+
+ return ;
+};
+
+const CartLoader = () => {
useLoadCartStore();
return null;
};
@@ -24,7 +31,7 @@ export const App = () => {
{PageComponent ? : null}
-
+ {typeof window !== "undefined" && }
>
);
};
diff --git a/packages/react/src/components/modal/Modal.tsx b/packages/react/src/components/modal/Modal.tsx
index c23842d5..f8f84e1f 100644
--- a/packages/react/src/components/modal/Modal.tsx
+++ b/packages/react/src/components/modal/Modal.tsx
@@ -19,7 +19,10 @@ const Header = ({ children }: PropsWithChildren) => {
const Container = ({ children }: PropsWithChildren) => {
return (
-
+
e.stopPropagation()}
+ >
{children}
@@ -41,11 +44,8 @@ const ModalRoot = ({ children }: Readonly
) => {
}, [modal]);
return (
-
-
+
diff --git a/packages/react/src/entities/carts/hooks/useCartStoreSelector.ts b/packages/react/src/entities/carts/hooks/useCartStoreSelector.ts
index 1a7d66a7..e469f273 100644
--- a/packages/react/src/entities/carts/hooks/useCartStoreSelector.ts
+++ b/packages/react/src/entities/carts/hooks/useCartStoreSelector.ts
@@ -1,8 +1,8 @@
-import { useStore } from "@hanghae-plus/lib";
+import { useUniversalStore } from "../../../hooks";
import { cartStore } from "../cartStore";
type CartState = ReturnType<(typeof cartStore)["getState"]>;
export const useCartStoreSelector =
(selector: (cart: CartState) => T) => {
- return useStore(cartStore, selector);
+ return useUniversalStore(cartStore, selector);
};
diff --git a/packages/react/src/entities/carts/storage/cartStorage.ts b/packages/react/src/entities/carts/storage/cartStorage.ts
index 6af02204..fd535743 100644
--- a/packages/react/src/entities/carts/storage/cartStorage.ts
+++ b/packages/react/src/entities/carts/storage/cartStorage.ts
@@ -1,7 +1,34 @@
import { createStorage } from "@hanghae-plus/lib";
import type { Cart } from "../types";
-export const cartStorage = createStorage<{
- items: Cart[];
- selectedAll: boolean;
-}>("shopping_cart");
+const createDummyStorage = () => {
+ let data: T | null = null;
+ const listeners: Set<() => void> = new Set();
+
+ return {
+ get: () => data,
+ set: (value: T) => {
+ data = value;
+ listeners.forEach((fn) => fn());
+ },
+ reset: () => {
+ data = null;
+ listeners.forEach((fn) => fn());
+ },
+ subscribe: (fn: () => void) => {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+ },
+ };
+};
+
+export const cartStorage =
+ typeof window !== "undefined"
+ ? createStorage<{
+ items: Cart[];
+ selectedAll: boolean;
+ }>("shopping_cart")
+ : createDummyStorage<{
+ items: Cart[];
+ selectedAll: boolean;
+ }>();
diff --git a/packages/react/src/entities/products/components/hooks/index.ts b/packages/react/src/entities/products/components/hooks/index.ts
index 51eeb830..4bd43626 100644
--- a/packages/react/src/entities/products/components/hooks/index.ts
+++ b/packages/react/src/entities/products/components/hooks/index.ts
@@ -1,2 +1,4 @@
export * from "./useProductFilter";
+export * from "./useProductURLSync";
+
export * from "./useLoadProductDetail";
diff --git a/packages/react/src/entities/products/components/hooks/useLoadProductDetail.ts b/packages/react/src/entities/products/components/hooks/useLoadProductDetail.ts
index 509f166f..489c7d7a 100644
--- a/packages/react/src/entities/products/components/hooks/useLoadProductDetail.ts
+++ b/packages/react/src/entities/products/components/hooks/useLoadProductDetail.ts
@@ -5,6 +5,10 @@ import { loadProductDetailForPage } from "../../productUseCase";
export const useLoadProductDetail = () => {
const productId = useRouterParams((params) => params.id);
useEffect(() => {
- loadProductDetailForPage(productId);
+ // SSR/SSG 데이터가 없으면 로드
+ const hasInitialData = window.__INITIAL_DATA__;
+ if (!hasInitialData) {
+ loadProductDetailForPage(productId);
+ }
}, [productId]);
};
diff --git a/packages/react/src/entities/products/components/hooks/useProductFilter.ts b/packages/react/src/entities/products/components/hooks/useProductFilter.ts
index ab24c1ec..7a6a14a2 100644
--- a/packages/react/src/entities/products/components/hooks/useProductFilter.ts
+++ b/packages/react/src/entities/products/components/hooks/useProductFilter.ts
@@ -1,14 +1,13 @@
-import { useEffect } from "react";
import { useRouterQuery } from "../../../../router";
-import { loadProducts } from "../../productUseCase";
export const useProductFilter = () => {
const { search: searchQuery, limit, sort, category1, category2 } = useRouterQuery();
const category = { category1, category2 };
- useEffect(() => {
- loadProducts(true);
- }, [searchQuery, limit, sort, category1, category2]);
+ // Data fetching is now handled by useProductURLSync in HomePage
+ // useEffect(() => {
+ // loadProducts(true);
+ // }, [searchQuery, limit, sort, category1, category2]);
return {
searchQuery,
diff --git a/packages/react/src/entities/products/components/hooks/useProductURLSync.ts b/packages/react/src/entities/products/components/hooks/useProductURLSync.ts
new file mode 100644
index 00000000..c752b848
--- /dev/null
+++ b/packages/react/src/entities/products/components/hooks/useProductURLSync.ts
@@ -0,0 +1,23 @@
+import { useEffect, useRef } from "react";
+import { useRouterQuery } from "../../../../router";
+import { loadProducts } from "../../productUseCase";
+
+export const useProductURLSync = () => {
+ const query = useRouterQuery();
+ const prevQueryString = useRef("");
+
+ useEffect(() => {
+ // current 파라미터는 무한 스크롤 시 변경되므로 제외하고 비교
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { current, ...restQuery } = query;
+
+ // 쿼리 객체를 문자열로 변환하여 비교 (deep compare 효과)
+ const currentQueryString = JSON.stringify(restQuery);
+
+ // 이전 쿼리와 다르면 로드
+ if (prevQueryString.current !== currentQueryString) {
+ loadProducts(true);
+ prevQueryString.current = currentQueryString;
+ }
+ }, [query]);
+};
diff --git a/packages/react/src/entities/products/hooks/useProductStore.ts b/packages/react/src/entities/products/hooks/useProductStore.ts
index 8395fe2e..93cf70f2 100644
--- a/packages/react/src/entities/products/hooks/useProductStore.ts
+++ b/packages/react/src/entities/products/hooks/useProductStore.ts
@@ -1,4 +1,4 @@
-import { useStore } from "@hanghae-plus/lib";
+import { useUniversalStore } from "../../../hooks";
import { productStore } from "../productStore";
-export const useProductStore = () => useStore(productStore);
+export const useProductStore = () => useUniversalStore(productStore);
diff --git a/packages/react/src/entities/products/productUseCase.ts b/packages/react/src/entities/products/productUseCase.ts
index fa967b56..95f9bbbc 100644
--- a/packages/react/src/entities/products/productUseCase.ts
+++ b/packages/react/src/entities/products/productUseCase.ts
@@ -173,10 +173,9 @@ export const loadRelatedProducts = async (category2: string, excludeProductId: s
};
export const loadNextProducts = async () => {
- // 현재 라우트가 홈이 아니면 무한 스크롤 비활성화
- if (router.route?.path !== "/") {
- return;
- }
+ // router.route 정보가 없거나 path가 일치하지 않아도
+ // HomePage에서 등록한 scroll 이벤트에 의해 호출되므로
+ // 별도의 path 체크 없이 실행하도록 변경 (테스트 환경 호환성)
if (isNearBottom(200)) {
const productState = productStore.getState();
diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts
new file mode 100644
index 00000000..a8c20f42
--- /dev/null
+++ b/packages/react/src/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from "./useUniversalStore";
+export * from "./useUniversalRouter";
diff --git a/packages/react/src/hooks/useUniversalRouter.ts b/packages/react/src/hooks/useUniversalRouter.ts
new file mode 100644
index 00000000..40bf26ea
--- /dev/null
+++ b/packages/react/src/hooks/useUniversalRouter.ts
@@ -0,0 +1,50 @@
+import { useRef, useSyncExternalStore } from "react";
+import type { RouterInstance } from "@hanghae-plus/lib";
+import type { AnyFunction } from "@hanghae-plus/lib";
+
+const defaultSelector = (state: T) => state as unknown as S;
+
+export const useUniversalRouter = , S>(
+ router: T | null,
+ selector = defaultSelector,
+) => {
+ const isServer = typeof window === "undefined";
+ const getSnapshotRef = useRef(undefined);
+
+ // Create a dummy subscribe function for when router is null
+ const subscribe = router?.subscribe ?? (() => () => {});
+ const getSnapshot = () => {
+ if (!router) {
+ return null as S;
+ }
+ const nextSnapshot = selector(router);
+ if (getSnapshotRef.current === undefined) {
+ getSnapshotRef.current = nextSnapshot;
+ return nextSnapshot;
+ }
+
+ if (getSnapshotRef.current === nextSnapshot) {
+ return getSnapshotRef.current;
+ }
+
+ if (
+ typeof nextSnapshot === "object" &&
+ nextSnapshot !== null &&
+ JSON.stringify(getSnapshotRef.current) === JSON.stringify(nextSnapshot)
+ ) {
+ return getSnapshotRef.current;
+ }
+
+ getSnapshotRef.current = nextSnapshot;
+ return getSnapshotRef.current as S;
+ };
+
+ // Hooks must be called unconditionally
+ const snapshot = useSyncExternalStore(subscribe, getSnapshot, isServer ? getSnapshot : getSnapshot);
+
+ if (isServer || !router) {
+ return getSnapshot();
+ }
+
+ return snapshot;
+};
diff --git a/packages/react/src/hooks/useUniversalStore.ts b/packages/react/src/hooks/useUniversalStore.ts
new file mode 100644
index 00000000..452b8638
--- /dev/null
+++ b/packages/react/src/hooks/useUniversalStore.ts
@@ -0,0 +1,11 @@
+import { useSyncExternalStore } from "react";
+import type { createStore } from "@hanghae-plus/lib";
+
+type Store = ReturnType>;
+
+const defaultSelector = (state: T) => state as unknown as S;
+
+export const useUniversalStore = (store: Store, selector: (state: T) => S = defaultSelector) => {
+ const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
+ return selector(state);
+};
diff --git a/packages/react/src/main-server.tsx b/packages/react/src/main-server.tsx
index 611b0a58..9c722949 100644
--- a/packages/react/src/main-server.tsx
+++ b/packages/react/src/main-server.tsx
@@ -1,4 +1,266 @@
-export const render = async (url: string, query: Record) => {
- console.log({ url, query });
- return "";
-};
+import { renderToString } from "react-dom/server";
+import { readFileSync } from "fs";
+import { fileURLToPath } from "url";
+import { dirname, join } from "path";
+import { HomePage } from "./pages/HomePage";
+import { ProductDetailPage } from "./pages/ProductDetailPage";
+import { NotFoundPage } from "./pages/NotFoundPage";
+import { productStore, initialProductState, PRODUCT_ACTIONS } from "./entities/products/productStore";
+import { ServerRouter } from "./router/ServerRouter";
+import { setSSRContext, clearSSRContext } from "./router/ssrContext";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const isProd = __dirname.includes("/dist/");
+const itemsPath = isProd ? join(__dirname, "../../src/mocks/items.json") : join(__dirname, "mocks", "items.json");
+const items = JSON.parse(readFileSync(itemsPath, "utf-8"));
+
+interface Item {
+ category1: string;
+ category2?: string;
+ [key: string]: unknown;
+}
+
+function getUniqueCategories() {
+ const categories: Record>> = {};
+ items.forEach((item: Item) => {
+ const cat1 = item.category1;
+ const cat2 = item.category2;
+ if (!categories[cat1]) categories[cat1] = {};
+ if (cat2 && !categories[cat1][cat2]) categories[cat1][cat2] = {};
+ });
+ return categories;
+}
+
+interface QueryParams {
+ search?: string;
+ category1?: string;
+ category2?: string;
+ sort?: string;
+}
+
+function filterProducts(products: Item[], query: QueryParams) {
+ let filtered = [...products];
+
+ if (query.search) {
+ const searchTerm = query.search.toLowerCase();
+ filtered = filtered.filter(
+ (item) => item.title.toLowerCase().includes(searchTerm) || item.brand?.toLowerCase().includes(searchTerm),
+ );
+ }
+
+ if (query.category1) {
+ filtered = filtered.filter((item) => item.category1 === query.category1);
+ }
+ if (query.category2) {
+ filtered = filtered.filter((item) => item.category2 === query.category2);
+ }
+
+ if (query.sort) {
+ switch (query.sort) {
+ case "price_asc":
+ filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice));
+ break;
+ case "price_desc":
+ filtered.sort((a, b) => parseInt(b.lprice) - parseInt(a.lprice));
+ break;
+ case "name_asc":
+ filtered.sort((a, b) => a.title.localeCompare(b.title, "ko"));
+ break;
+ case "name_desc":
+ filtered.sort((a, b) => b.title.localeCompare(a.title, "ko"));
+ break;
+ default:
+ filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice));
+ }
+ }
+
+ return filtered;
+}
+
+interface GetProductsParams {
+ limit?: number;
+ search?: string;
+ category1?: string;
+ category2?: string;
+ sort?: string;
+ page?: number;
+}
+
+async function mockGetProducts(params: GetProductsParams = {}) {
+ const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params;
+ const page = params.page || 1;
+
+ const filteredProducts = filterProducts(items, { search, category1, category2, sort });
+
+ const startIndex = (page - 1) * limit;
+ const endIndex = startIndex + limit;
+ const paginatedProducts = filteredProducts.slice(startIndex, endIndex);
+
+ return {
+ products: paginatedProducts,
+ pagination: {
+ page,
+ limit,
+ total: filteredProducts.length,
+ totalPages: Math.ceil(filteredProducts.length / limit),
+ hasNext: endIndex < filteredProducts.length,
+ hasPrev: page > 1,
+ },
+ };
+}
+
+async function mockGetProduct(productId: string) {
+ const product = items.find((item: Item) => item.productId === productId);
+ if (!product) return null;
+
+ return {
+ ...product,
+ description: `${product.title}에 대한 상세 설명입니다. ${product.brand} 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.`,
+ rating: Math.floor(Math.random() * 2) + 4,
+ reviewCount: Math.floor(Math.random() * 1000) + 50,
+ stock: Math.floor(Math.random() * 100) + 10,
+ };
+}
+
+async function mockGetCategories() {
+ return getUniqueCategories();
+}
+
+async function mockGetRelatedProducts(category1: string, category2: string, currentProductId: string, limit = 20) {
+ const related = items.filter((item: Item) => {
+ if (item.productId === currentProductId) return false;
+ if (category2 && item.category2 === category2) return true;
+ if (category1 && item.category1 === category1) return true;
+ return false;
+ });
+
+ return related.slice(0, limit);
+}
+
+async function prefetchData(routePath: string, params: Record, query: Record) {
+ if (routePath === "/") {
+ const [productsData, categories] = await Promise.all([
+ mockGetProducts({
+ limit: parseInt(query.limit) || 20,
+ search: query.search || "",
+ category1: query.category1 || "",
+ category2: query.category2 || "",
+ sort: query.sort || "price_asc",
+ page: 1,
+ }),
+ mockGetCategories(),
+ ]);
+
+ return {
+ products: productsData.products,
+ totalCount: productsData.pagination.total,
+ categories,
+ };
+ } else if (routePath === "/product/:id/") {
+ const product = await mockGetProduct(params.id);
+
+ if (!product) return { error: "Product not found" };
+
+ const relatedProducts = await mockGetRelatedProducts(product.category1, product.category2, product.productId, 20);
+
+ return {
+ currentProduct: product,
+ relatedProducts,
+ };
+ }
+
+ return {};
+}
+
+function generateMetaTags(routePath: string, query: Record, data: Record) {
+ let title = "쇼핑몰 - 홈";
+
+ if (routePath === "/product/:id/" && data.currentProduct) {
+ const product = data.currentProduct;
+ title = `${product.title} - 쇼핑몰`;
+ } else if (routePath === "/") {
+ if (query?.search) {
+ title = `${query.search} 검색 결과`;
+ } else if (query?.category1) {
+ title = `${query.category1} ${query.category2 || ""}`.trim();
+ } else {
+ title = "쇼핑몰 - 홈";
+ }
+ }
+
+ return `${title}`;
+}
+
+export async function render(url: string, query: Record = {}) {
+ const router = new ServerRouter("");
+ router.addRoute("/", HomePage);
+ router.addRoute("/product/:id/", ProductDetailPage);
+
+ const routeInfo = router.matchRoute(url, query);
+
+ if (!routeInfo) {
+ return {
+ html: renderToString(),
+ head: "404 - Not Found",
+ initialDataScript: "",
+ };
+ }
+
+ const storeData = await prefetchData(routeInfo.path, routeInfo.params, query);
+
+ if (storeData.error) {
+ return {
+ html: renderToString(),
+ head: "404 - Not Found",
+ initialDataScript: "",
+ };
+ }
+
+ // 스토어에 데이터 설정 (렌더링용)
+ productStore.dispatch({ type: PRODUCT_ACTIONS.SETUP, payload: { ...initialProductState, ...storeData } });
+
+ // SSR 컨텍스트 설정 (렌더링 중 라우터 정보 접근용)
+ setSSRContext({
+ query,
+ params: routeInfo.params,
+ });
+
+ // SSR에서는 간단한 구조로 렌더링 (Provider 없이)
+ const PageComponent = routeInfo.handler;
+ const html = renderToString();
+ const head = generateMetaTags(routeInfo.path, query, storeData);
+
+ // SSR 컨텍스트 정리
+ clearSSRContext();
+
+ // 스토어 초기화
+ productStore.dispatch({ type: PRODUCT_ACTIONS.SETUP, payload: { ...initialProductState } });
+
+ // 필요한 데이터만 추출
+ let initialData;
+ if (routeInfo.path === "/") {
+ initialData = {
+ products: storeData.products,
+ categories: storeData.categories,
+ totalCount: storeData.totalCount,
+ query, // 쿼리 파라미터 포함
+ };
+ } else if (routeInfo.path === "/product/:id/") {
+ initialData = {
+ currentProduct: storeData.currentProduct,
+ relatedProducts: storeData.relatedProducts,
+ };
+ } else {
+ initialData = storeData;
+ }
+
+ const initialDataScript = `
+
+ `;
+
+ return { html, head, initialDataScript };
+}
diff --git a/packages/react/src/main.tsx b/packages/react/src/main.tsx
index 0c5b8a67..ad201cde 100644
--- a/packages/react/src/main.tsx
+++ b/packages/react/src/main.tsx
@@ -1,7 +1,9 @@
import { App } from "./App";
import { router } from "./router";
import { BASE_URL } from "./constants.ts";
-import { createRoot } from "react-dom/client";
+import { createRoot, hydrateRoot } from "react-dom/client";
+import { productStore, PRODUCT_ACTIONS } from "./entities/products/productStore";
+import { HomePage, NotFoundPage, ProductDetailPage } from "./pages";
const enableMocking = () =>
import("./mocks/browser").then(({ worker }) =>
@@ -14,10 +16,37 @@ const enableMocking = () =>
);
function main() {
+ // 라우트 등록 (router.start() 전에 수행)
+ router.addRoute("/", HomePage);
+ router.addRoute("/product/:id/", ProductDetailPage);
+ router.addRoute(".*", NotFoundPage);
+
router.start();
+ // SSR/SSG에서 전달된 초기 데이터 로드
+ if (window.__INITIAL_DATA__) {
+ const data = window.__INITIAL_DATA__;
+
+ // store sync - Vanilla와 동일한 방식으로 처리
+ if (data.products) {
+ productStore.dispatch({ type: PRODUCT_ACTIONS.SETUP, payload: data });
+ }
+ if (data.currentProduct) {
+ productStore.dispatch({ type: PRODUCT_ACTIONS.SETUP, payload: data });
+ }
+
+ // 레이스 컨디션 방지를 위해 삭제
+ delete window.__INITIAL_DATA__;
+ }
+
const rootElement = document.getElementById("root")!;
- createRoot(rootElement).render();
+
+ // SSR/SSG HTML이 있으면 hydrate, 없으면 render
+ if (rootElement.hasChildNodes()) {
+ hydrateRoot(rootElement, );
+ } else {
+ createRoot(rootElement).render();
+ }
}
// 애플리케이션 시작
diff --git a/packages/react/src/pages/HomePage.tsx b/packages/react/src/pages/HomePage.tsx
index 4edbccc6..3b61d2e4 100644
--- a/packages/react/src/pages/HomePage.tsx
+++ b/packages/react/src/pages/HomePage.tsx
@@ -1,5 +1,5 @@
import { useEffect } from "react";
-import { loadNextProducts, loadProductsAndCategories, ProductList, SearchBar } from "../entities";
+import { loadNextProducts, loadProductsAndCategories, ProductList, SearchBar, useProductURLSync } from "../entities";
import { PageWrapper } from "./PageWrapper";
const headerLeft = (
@@ -27,9 +27,17 @@ const unregisterScrollHandler = () => {
};
export const HomePage = () => {
+ // URL 쿼리 변경 시 상품 목록 다시 로드
+ useProductURLSync();
+
useEffect(() => {
registerScrollHandler();
- loadProductsAndCategories();
+
+ // SSR/SSG 데이터가 없으면 로드 (카테고리 포함)
+ const hasInitialData = window.__INITIAL_DATA__;
+ if (!hasInitialData) {
+ loadProductsAndCategories();
+ }
return unregisterScrollHandler;
}, []);
diff --git a/packages/react/src/router/ServerRouter.ts b/packages/react/src/router/ServerRouter.ts
new file mode 100644
index 00000000..4853c112
--- /dev/null
+++ b/packages/react/src/router/ServerRouter.ts
@@ -0,0 +1,61 @@
+import type { FunctionComponent } from "react";
+
+interface Route {
+ regex: RegExp;
+ paramNames: string[];
+ handler: Handler;
+}
+
+type QueryPayload = Record;
+type StringRecord = Record;
+
+export class ServerRouter {
+ readonly #routes: Map>;
+ readonly #baseUrl: string;
+
+ constructor(baseUrl = "") {
+ this.#routes = new Map();
+ this.#baseUrl = baseUrl.replace(/\/$/, "");
+ }
+
+ addRoute(path: string, handler: Handler) {
+ const paramNames: string[] = [];
+ const regexPath = path
+ .replace(/:\w+/g, (match) => {
+ paramNames.push(match.slice(1)); // ':id' -> 'id'
+ return "([^/]+)";
+ })
+ .replace(/\//g, "\\/");
+
+ const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`);
+
+ this.#routes.set(path, {
+ regex,
+ paramNames,
+ handler,
+ });
+ }
+
+ matchRoute(url: string, query?: QueryPayload) {
+ const pathname = url.includes("?") ? url.split("?")[0] : url;
+
+ 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];
+ });
+
+ return {
+ path: routePath,
+ params,
+ handler: route.handler,
+ query: query || {},
+ };
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/packages/react/src/router/hooks/useCurrentPage.ts b/packages/react/src/router/hooks/useCurrentPage.ts
index 888e4d06..0f38d4e5 100644
--- a/packages/react/src/router/hooks/useCurrentPage.ts
+++ b/packages/react/src/router/hooks/useCurrentPage.ts
@@ -1,6 +1,6 @@
import { router } from "../router";
-import { useRouter } from "@hanghae-plus/lib";
+import { useUniversalRouter } from "../../hooks";
export const useCurrentPage = () => {
- return useRouter(router, ({ target }) => target);
+ return useUniversalRouter(router, ({ target }) => target);
};
diff --git a/packages/react/src/router/hooks/useRouterParams.ts b/packages/react/src/router/hooks/useRouterParams.ts
index 88bf4e22..5c6c9c51 100644
--- a/packages/react/src/router/hooks/useRouterParams.ts
+++ b/packages/react/src/router/hooks/useRouterParams.ts
@@ -1,10 +1,21 @@
import { router } from "../router";
-import { useRouter } from "@hanghae-plus/lib";
+import { useUniversalRouter } from "../../hooks";
+import { getSSRContext } from "../ssrContext";
type Params = Record;
const defaultSelector = (params: Params) => params as S;
export const useRouterParams = (selector = defaultSelector) => {
- return useRouter(router, ({ params }) => selector(params));
+ // SSR 중이면 SSR 컨텍스트에서 params 가져오기
+ const ssrContext = getSSRContext();
+ // 클라이언트 사이드에서는 router에서 가져오기 (hooks must be called unconditionally)
+ const result = useUniversalRouter(router, ({ params }) => selector(params));
+
+ if (ssrContext) {
+ return selector(ssrContext.params);
+ }
+
+ // SSR 환경에서 null일 수 있으므로 빈 객체 반환
+ return result ?? ({} as ReturnType);
};
diff --git a/packages/react/src/router/hooks/useRouterQuery.ts b/packages/react/src/router/hooks/useRouterQuery.ts
index 261299da..78e4bac5 100644
--- a/packages/react/src/router/hooks/useRouterQuery.ts
+++ b/packages/react/src/router/hooks/useRouterQuery.ts
@@ -1,6 +1,23 @@
-import { useRouter } from "@hanghae-plus/lib";
+import { useUniversalRouter } from "../../hooks";
import { router } from "../router";
+import { getSSRContext } from "../ssrContext";
export const useRouterQuery = () => {
- return useRouter(router, ({ query }) => query);
+ // SSR 중이면 SSR 컨텍스트에서 쿼리 가져오기
+ const ssrContext = getSSRContext();
+ // hooks must be called unconditionally
+ const query = useUniversalRouter(router, ({ query }) => query);
+
+ if (ssrContext) {
+ return ssrContext.query;
+ }
+
+ // router에서 query를 가져올 수 없으면 initial data에서
+ if (!query || Object.keys(query).length === 0) {
+ if (typeof window !== "undefined" && window.__INITIAL_DATA__?.query) {
+ return window.__INITIAL_DATA__.query;
+ }
+ }
+
+ return query || {};
};
diff --git a/packages/react/src/router/index.ts b/packages/react/src/router/index.ts
index 704bc03c..78e0929b 100644
--- a/packages/react/src/router/index.ts
+++ b/packages/react/src/router/index.ts
@@ -1,2 +1,3 @@
export * from "./router";
export * from "./hooks";
+export * from "./ServerRouter";
diff --git a/packages/react/src/router/router.ts b/packages/react/src/router/router.ts
index ddb3a7cd..cea24907 100644
--- a/packages/react/src/router/router.ts
+++ b/packages/react/src/router/router.ts
@@ -1,6 +1,41 @@
-// 글로벌 라우터 인스턴스
import { Router } from "@hanghae-plus/lib";
import { BASE_URL } from "../constants";
import type { FunctionComponent } from "react";
-export const router = new Router(BASE_URL);
+let _router: Router | null = null;
+
+const createRouterInstance = (): Router => {
+ // 클라이언트 환경에서만 실제 Router 인스턴스 생성
+ if (typeof window !== "undefined") {
+ if (!_router) {
+ _router = new Router(BASE_URL);
+ }
+ return _router;
+ }
+
+ return {
+ subscribe: () => () => {},
+ push: () => {},
+ replace: () => {},
+ back: () => {},
+ forward: () => {},
+ addRoute: () => {},
+ start: () => {},
+ stop: () => {},
+ get query() {
+ return {};
+ },
+ set query(_) {},
+ get params() {
+ return {};
+ },
+ get route() {
+ return null;
+ },
+ get target() {
+ return null;
+ },
+ } as Router;
+};
+
+export const router: Router = createRouterInstance();
diff --git a/packages/react/src/router/ssrContext.ts b/packages/react/src/router/ssrContext.ts
new file mode 100644
index 00000000..8839cec7
--- /dev/null
+++ b/packages/react/src/router/ssrContext.ts
@@ -0,0 +1,18 @@
+type SSRContext = {
+ query: Record;
+ params: Record;
+} | null;
+
+let ssrContext: SSRContext = null;
+
+export const setSSRContext = (context: SSRContext) => {
+ ssrContext = context;
+};
+
+export const getSSRContext = () => {
+ return ssrContext;
+};
+
+export const clearSSRContext = () => {
+ ssrContext = null;
+};
diff --git a/packages/react/src/utils/log.ts b/packages/react/src/utils/log.ts
index 00aa2d47..507ebf1a 100644
--- a/packages/react/src/utils/log.ts
+++ b/packages/react/src/utils/log.ts
@@ -6,12 +6,18 @@ declare global {
}
}
-window.__spyCalls = [];
-window.__spyCallsClear = () => {
+// 클라이언트 환경에서만 초기화
+if (typeof window !== "undefined") {
window.__spyCalls = [];
-};
+ window.__spyCallsClear = () => {
+ window.__spyCalls = [];
+ };
+}
export const log: typeof console.log = (...args) => {
- window.__spyCalls.push(args);
+ // 클라이언트 환경에서만 spy 호출 기록
+ if (typeof window !== "undefined") {
+ window.__spyCalls.push(args);
+ }
return console.log(...args);
};
diff --git a/packages/react/src/vite-env.d.ts b/packages/react/src/vite-env.d.ts
index 11f02fe2..44b6c318 100644
--- a/packages/react/src/vite-env.d.ts
+++ b/packages/react/src/vite-env.d.ts
@@ -1 +1,13 @@
///
+
+interface Window {
+ __INITIAL_DATA__?: {
+ products?: unknown[];
+ categories?: Record>;
+ totalCount?: number;
+ currentProduct?: unknown;
+ relatedProducts?: unknown[];
+ query?: Record;
+ [key: string]: unknown;
+ };
+}
diff --git a/packages/react/static-site-generate.js b/packages/react/static-site-generate.js
index 145c957b..7c6d0cba 100644
--- a/packages/react/static-site-generate.js
+++ b/packages/react/static-site-generate.js
@@ -1,18 +1,50 @@
-import { renderToString } from "react-dom/server";
-import { createElement } from "react";
import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function generateStaticSite() {
- // HTML 템플릿 읽기
- const template = fs.readFileSync("../../dist/react/index.html", "utf-8");
+ const { render } = await import("./dist/react-ssr/main-server.js");
+
+ const template = fs.readFileSync(path.resolve(__dirname, "../../dist/react/index.html"), "utf-8");
+
+ const itemsPath = path.resolve(__dirname, "src/mocks/items.json");
+ const items = JSON.parse(fs.readFileSync(itemsPath, "utf-8"));
+
+ const pages = [{ url: "/", outputPath: "../../dist/react/index.html" }];
+
+ items.forEach((item) => {
+ pages.push({
+ url: `/product/${item.productId}/`,
+ outputPath: `../../dist/react/product/${item.productId}/index.html`,
+ });
+ });
+
+ for (const page of pages) {
+ try {
+ const { html, head, initialDataScript } = await render(page.url, {});
+
+ const result = template
+ .replace("", head)
+ .replace("", html)
+ .replace("", `${initialDataScript}`);
- // 어플리케이션 렌더링하기
- const appHtml = renderToString(createElement("div", null, "안녕하세요"));
+ const outputPath = path.resolve(__dirname, page.outputPath);
+ const dir = path.dirname(outputPath);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
- // 결과 HTML 생성하기
- const result = template.replace("", appHtml);
- fs.writeFileSync("../../dist/react/index.html", result);
+ fs.writeFileSync(outputPath, result);
+ console.log(`페이지 생성 완료 (${page.url})`);
+ } catch (error) {
+ console.error(`페이지 생성 실패 (${page.url}):`, error.message);
+ }
+ }
}
-// 실행
-generateStaticSite();
+generateStaticSite().catch((error) => {
+ console.error("❌ SSG 실행 실패:", error);
+ process.exit(1);
+});
diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js
index 67f03afa..acfc5e27 100644
--- a/packages/vanilla/server.js
+++ b/packages/vanilla/server.js
@@ -1,34 +1,100 @@
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
import express from "express";
+import compression from "compression";
+import sirv from "sirv";
+import { createServer as createViteServer } from "vite";
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
const prod = process.env.NODE_ENV === "production";
+
const port = process.env.PORT || 5173;
const base = process.env.BASE || (prod ? "/front_7th_chapter4-1/vanilla/" : "/");
const app = express();
-const render = () => {
- return `안녕하세요
`;
-};
-
-app.get("*all", (req, res) => {
- res.send(
- `
-
-
-
-
-
- Vanilla Javascript SSR
-
-
-${render()}
-
-
- `.trim(),
- );
-});
+async function renderSSR(template, render, url, query) {
+ const { head, html, initialDataScript } = await render(url, query);
+
+ return template
+ .replace("", head)
+ .replace("", html)
+ .replace("", `${initialDataScript}`);
+}
+
+function normalizeUrl(url, base) {
+ if (base && url.startsWith(base)) {
+ url = url.slice(base.length - 1);
+ }
+ if (!url.startsWith("/")) {
+ url = "/" + url;
+ }
+ return url;
+}
+
+// 개발 모드
+if (!prod) {
+ // Vite dev server + middleware
+ const vite = await createViteServer({
+ server: { middlewareMode: true },
+ appType: "custom",
+ base,
+ });
+
+ app.use(vite.middlewares);
+
+ app.use("*all", async (req, res, next) => {
+ const url = req.originalUrl;
+ const query = req.query;
+
+ try {
+ let template = fs.readFileSync(path.resolve(__dirname, "index.html"), "utf-8");
+
+ // Vite의 HTML 변환 작업을 통해 Vite HMR 클라이언트를 주입하고,
+ // Vite 플러그인의 HTML 변환도 적용합니다.
+ // (예: @vitejs/plugin-react의 전역 초기화 코드)
+ template = await vite.transformIndexHtml(url, template);
+
+ // 서버의 진입점(Entry)을 로드합니다.
+ // ssrLoadModule은 Node.js에서 사용할 수 있도록 ESM 소스 코드를 자동으로 변환합니다.
+ // 추가적인 번들링이 필요하지 않으며, HMR과 유사한 동작을 수행합니다.
+ const { render } = await vite.ssrLoadModule(path.resolve(__dirname, "src/main-server.js"));
+
+ const finalHtml = await renderSSR(template, render, url, query);
+
+ res.status(200).set({ "Content-Type": "text/html" }).end(finalHtml);
+ } catch (e) {
+ vite.ssrFixStacktrace(e);
+ next(e);
+ }
+ });
+} else {
+ // compression + sirv
+ app.use(compression());
+ app.use(base, sirv(path.resolve(__dirname, "./dist/vanilla"), { extensions: [] }));
+
+ // SSR 렌더링
+ app.use("*all", async (req, res, next) => {
+ try {
+ const url = normalizeUrl(req.originalUrl, base);
+ const query = req.query;
+
+ // 프로덕션 빌드된 템플릿과 SSR 모듈 로드
+ const template = fs.readFileSync(path.resolve(__dirname, "./dist/vanilla/index.html"), "utf-8");
+ const { render } = await import(path.resolve(__dirname, "./dist/vanilla-ssr/main-server.js"));
+
+ const finalHtml = await renderSSR(template, render, url, query);
+
+ res.status(200).set({ "Content-Type": "text/html" }).end(finalHtml);
+ } catch (e) {
+ console.error("SSR Error:", e);
+ next(e);
+ }
+ });
+}
// Start http server
app.listen(port, () => {
- console.log(`React Server started at http://localhost:${port}`);
+ console.log(`Vanilla SSR Server started at http://localhost:${port}`);
});
diff --git a/packages/vanilla/src/api/productApi.js b/packages/vanilla/src/api/productApi.js
index c2330fbe..31daa500 100644
--- a/packages/vanilla/src/api/productApi.js
+++ b/packages/vanilla/src/api/productApi.js
@@ -1,3 +1,5 @@
+import { BASE_URL } from "../constants.js";
+
export async function getProducts(params = {}) {
const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params;
const page = params.current ?? params.page ?? 1;
@@ -11,17 +13,17 @@ export async function getProducts(params = {}) {
sort,
});
- const response = await fetch(`/api/products?${searchParams}`);
+ const response = await fetch(`${BASE_URL}api/products?${searchParams}`);
return await response.json();
}
export async function getProduct(productId) {
- const response = await fetch(`/api/products/${productId}`);
+ const response = await fetch(`${BASE_URL}api/products/${productId}`);
return await response.json();
}
export async function getCategories() {
- const response = await fetch("/api/categories");
+ const response = await fetch(`${BASE_URL}api/categories`);
return await response.json();
}
diff --git a/packages/vanilla/src/lib/Router.js b/packages/vanilla/src/lib/Router.js
deleted file mode 100644
index 2238a878..00000000
--- a/packages/vanilla/src/lib/Router.js
+++ /dev/null
@@ -1,171 +0,0 @@
-/**
- * 간단한 SPA 라우터
- */
-import { createObserver } from "./createObserver.js";
-
-export class Router {
- #routes;
- #route;
- #observer = createObserver();
- #baseUrl;
-
- constructor(baseUrl = "") {
- this.#routes = new Map();
- this.#route = null;
- this.#baseUrl = baseUrl.replace(/\/$/, "");
-
- window.addEventListener("popstate", () => {
- this.#route = this.#findRoute();
- this.#observer.notify();
- });
- }
-
- get baseUrl() {
- return this.#baseUrl;
- }
-
- get query() {
- return Router.parseQuery(window.location.search);
- }
-
- set query(newQuery) {
- const newUrl = Router.getUrl(newQuery, this.#baseUrl);
- this.push(newUrl);
- }
-
- get params() {
- return this.#route?.params ?? {};
- }
-
- get route() {
- return this.#route;
- }
-
- get target() {
- return this.#route?.handler;
- }
-
- subscribe(fn) {
- this.#observer.subscribe(fn);
- }
-
- /**
- * 라우트 등록
- * @param {string} path - 경로 패턴 (예: "/product/:id")
- * @param {Function} handler - 라우트 핸들러
- */
- addRoute(path, handler) {
- // 경로 패턴을 정규식으로 변환
- const paramNames = [];
- const regexPath = path
- .replace(/:\w+/g, (match) => {
- paramNames.push(match.slice(1)); // ':id' -> 'id'
- return "([^/]+)";
- })
- .replace(/\//g, "\\/");
-
- const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`);
-
- this.#routes.set(path, {
- regex,
- paramNames,
- handler,
- });
- }
-
- #findRoute(url = window.location.pathname) {
- const { pathname } = new URL(url, window.location.origin);
- 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;
- }
-
- /**
- * 네비게이션 실행
- * @param {string} url - 이동할 경로
- */
- push(url) {
- try {
- // baseUrl이 없으면 자동으로 붙여줌
- let fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url);
-
- const prevFullUrl = `${window.location.pathname}${window.location.search}`;
-
- // 히스토리 업데이트
- if (prevFullUrl !== fullUrl) {
- window.history.pushState(null, "", fullUrl);
- }
-
- this.#route = this.#findRoute(fullUrl);
- this.#observer.notify();
- } catch (error) {
- console.error("라우터 네비게이션 오류:", error);
- }
- }
-
- /**
- * 라우터 시작
- */
- start() {
- this.#route = this.#findRoute();
- this.#observer.notify();
- }
-
- /**
- * 쿼리 파라미터를 객체로 파싱
- * @param {string} search - location.search 또는 쿼리 문자열
- * @returns {Object} 파싱된 쿼리 객체
- */
- static parseQuery = (search = window.location.search) => {
- const params = new URLSearchParams(search);
- const query = {};
- for (const [key, value] of params) {
- query[key] = value;
- }
- return query;
- };
-
- /**
- * 객체를 쿼리 문자열로 변환
- * @param {Object} query - 쿼리 객체
- * @returns {string} 쿼리 문자열
- */
- static stringifyQuery = (query) => {
- const params = new URLSearchParams();
- for (const [key, value] of Object.entries(query)) {
- if (value !== null && value !== undefined && value !== "") {
- params.set(key, String(value));
- }
- }
- return params.toString();
- };
-
- static getUrl = (newQuery, baseUrl = "") => {
- const currentQuery = Router.parseQuery();
- const updatedQuery = { ...currentQuery, ...newQuery };
-
- // 빈 값들 제거
- Object.keys(updatedQuery).forEach((key) => {
- if (updatedQuery[key] === null || updatedQuery[key] === undefined || updatedQuery[key] === "") {
- delete updatedQuery[key];
- }
- });
-
- const queryString = Router.stringifyQuery(updatedQuery);
- return `${baseUrl}${window.location.pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`;
- };
-}
diff --git a/packages/vanilla/src/lib/createStorage.js b/packages/vanilla/src/lib/createStorage.js
index 08b504f2..c61304f0 100644
--- a/packages/vanilla/src/lib/createStorage.js
+++ b/packages/vanilla/src/lib/createStorage.js
@@ -4,7 +4,14 @@
* @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 : null) => {
+ if (!storage) {
+ return {
+ get: () => null,
+ set: () => {},
+ reset: () => {},
+ };
+ }
const get = () => {
try {
const item = storage.getItem(key);
diff --git a/packages/vanilla/src/lib/index.js b/packages/vanilla/src/lib/index.js
index a598ef30..2e669251 100644
--- a/packages/vanilla/src/lib/index.js
+++ b/packages/vanilla/src/lib/index.js
@@ -1,4 +1,3 @@
export * from "./createObserver";
export * from "./createStore";
export * from "./createStorage";
-export * from "./Router";
diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js
index 40b58858..6134e00f 100644
--- a/packages/vanilla/src/main-server.js
+++ b/packages/vanilla/src/main-server.js
@@ -1,4 +1,249 @@
-export const render = async (url, query) => {
- console.log({ url, query });
- return "";
-};
+import { readFileSync } from "fs";
+import { fileURLToPath } from "url";
+import { dirname, join } from "path";
+import { HomePage } from "./pages/HomePage.js";
+import { ProductDetailPage } from "./pages/ProductDetailPage.js";
+import { NotFoundPage } from "./pages/NotFoundPage.js";
+import { productStore, initialProductState } from "./stores/productStore.js";
+import { PRODUCT_ACTIONS } from "./stores/actionTypes.js";
+import { UniversalRouter } from "./router/router.js";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const isProd = __dirname.includes("/dist/");
+const itemsPath = isProd ? join(__dirname, "../../src/mocks/items.json") : join(__dirname, "mocks", "items.json");
+const items = JSON.parse(readFileSync(itemsPath, "utf-8"));
+
+function getUniqueCategories() {
+ const categories = {};
+ items.forEach((item) => {
+ const cat1 = item.category1;
+ const cat2 = item.category2;
+ if (!categories[cat1]) categories[cat1] = {};
+ if (cat2 && !categories[cat1][cat2]) categories[cat1][cat2] = {};
+ });
+ return categories;
+}
+
+function filterProducts(products, query) {
+ let filtered = [...products];
+
+ if (query.search) {
+ const searchTerm = query.search.toLowerCase();
+ filtered = filtered.filter(
+ (item) => item.title.toLowerCase().includes(searchTerm) || item.brand.toLowerCase().includes(searchTerm),
+ );
+ }
+
+ if (query.category1) {
+ filtered = filtered.filter((item) => item.category1 === query.category1);
+ }
+ if (query.category2) {
+ filtered = filtered.filter((item) => item.category2 === query.category2);
+ }
+
+ if (query.sort) {
+ switch (query.sort) {
+ case "price_asc":
+ filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice));
+ break;
+ case "price_desc":
+ filtered.sort((a, b) => parseInt(b.lprice) - parseInt(a.lprice));
+ break;
+ case "name_asc":
+ filtered.sort((a, b) => a.title.localeCompare(b.title, "ko"));
+ break;
+ case "name_desc":
+ filtered.sort((a, b) => b.title.localeCompare(a.title, "ko"));
+ break;
+ default:
+ filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice));
+ }
+ }
+
+ return filtered;
+}
+
+async function mockGetProducts(params = {}) {
+ const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params;
+ const page = params.page || 1;
+
+ const filteredProducts = filterProducts(items, { search, category1, category2, sort });
+
+ const startIndex = (page - 1) * limit;
+ const endIndex = startIndex + limit;
+ const paginatedProducts = filteredProducts.slice(startIndex, endIndex);
+
+ return {
+ products: paginatedProducts,
+ pagination: {
+ page,
+ limit,
+ total: filteredProducts.length,
+ totalPages: Math.ceil(filteredProducts.length / limit),
+ hasNext: endIndex < filteredProducts.length,
+ hasPrev: page > 1,
+ },
+ };
+}
+
+async function mockGetProduct(productId) {
+ const product = items.find((item) => item.productId === productId);
+ if (!product) return null;
+
+ return {
+ ...product,
+ description: `${product.title}에 대한 상세 설명입니다. ${product.brand} 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.`,
+ rating: Math.floor(Math.random() * 2) + 4,
+ reviewCount: Math.floor(Math.random() * 1000) + 50,
+ stock: Math.floor(Math.random() * 100) + 10,
+ };
+}
+
+async function mockGetCategories() {
+ return getUniqueCategories();
+}
+
+async function mockGetRelatedProducts(category1, category2, currentProductId, limit = 20) {
+ let related = items.filter((item) => {
+ if (item.productId === currentProductId) return false;
+ if (category2 && item.category2 === category2) return true;
+ if (category1 && item.category1 === category1) return true;
+ return false;
+ });
+
+ return related.slice(0, limit);
+}
+
+async function prefetchData(routeInfo, params, query) {
+ if (routeInfo.path === "/") {
+ const [productsData, categories] = await Promise.all([
+ mockGetProducts({
+ limit: parseInt(query.limit) || 20,
+ search: query.search || "",
+ category1: query.category1 || "",
+ category2: query.category2 || "",
+ sort: query.sort || "price_asc",
+ page: 1,
+ }),
+ mockGetCategories(),
+ ]);
+
+ return {
+ products: productsData.products,
+ totalCount: productsData.pagination.total,
+ categories,
+ loading: false,
+ error: null,
+ status: "done",
+ };
+ } else if (routeInfo.path === "/product/:id/") {
+ const product = await mockGetProduct(params.id);
+
+ if (!product) return { error: "Product not found" };
+
+ const relatedProducts = await mockGetRelatedProducts(product.category1, product.category2, product.productId, 20);
+
+ return {
+ currentProduct: product,
+ relatedProducts,
+ loading: false,
+ error: null,
+ status: "done",
+ };
+ }
+
+ return {};
+}
+
+function generateMetaTags(routeInfo, query, data) {
+ let title = "쇼핑몰 - 홈";
+ let description = "최고의 쇼핑 경험을 제공합니다.";
+
+ if (routeInfo?.path === "/product/:id/" && data.currentProduct) {
+ const product = data.currentProduct;
+ title = `${product.title} - 쇼핑몰`;
+ description = product.description || `${product.brand} ${product.title}`;
+ } else if (routeInfo?.path === "/") {
+ if (query?.search) {
+ title = `${query.search} 검색 결과 - 쇼핑몰`;
+ } else if (query?.category1) {
+ title = `${query.category1} ${query.category2 || ""} - 쇼핑몰`.trim();
+ } else {
+ title = "쇼핑몰 - 홈";
+ }
+ }
+
+ return `
+ ${title}
+
+
+
+ `.trim();
+}
+
+export async function render(url, query = {}) {
+ const serverRouter = new UniversalRouter("");
+ serverRouter.addRoute("/", HomePage);
+ serverRouter.addRoute("/product/:id/", ProductDetailPage);
+
+ const routeInfo = serverRouter.matchRoute(url, query);
+
+ if (!routeInfo) {
+ return {
+ html: NotFoundPage(),
+ head: "404 - Not Found",
+ initialDataScript: "",
+ };
+ }
+
+ const storeData = await prefetchData(routeInfo, routeInfo.params, query);
+
+ if (storeData.error) {
+ return {
+ html: NotFoundPage(),
+ head: "404 - Not Found",
+ initialDataScript: "",
+ };
+ }
+
+ // 서버 렌더링용 임시 스토어 설정 (스토어 자체는 변경하지 않음)
+ productStore.dispatch({ type: PRODUCT_ACTIONS.SETUP, payload: { ...initialProductState, ...storeData } });
+
+ global.router = serverRouter;
+
+ const html = routeInfo.handler();
+ const head = generateMetaTags(routeInfo, query, storeData);
+
+ delete global.router;
+
+ // 스토어를 다시 초기화해서 다음 요청에 영향 없도록
+ productStore.dispatch({ type: PRODUCT_ACTIONS.SETUP, payload: { ...initialProductState } });
+
+ // 페이지 타입에 따라 필요한 데이터만 추출
+ let initialData;
+ if (routeInfo.path === "/") {
+ // 홈 페이지: products, categories, totalCount만
+ initialData = {
+ products: storeData.products,
+ categories: storeData.categories,
+ totalCount: storeData.totalCount,
+ };
+ } else if (routeInfo.path === "/product/:id/") {
+ initialData = {
+ currentProduct: storeData.currentProduct,
+ relatedProducts: storeData.relatedProducts,
+ };
+ } else {
+ initialData = storeData;
+ }
+
+ const initialDataScript = `
+
+ `;
+
+ return { html, head, initialDataScript };
+}
diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js
index 4c3f2765..1fa63a11 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";
+import { PRODUCT_ACTIONS } from "./stores/actionTypes";
const enableMocking = () =>
import("./mocks/browser.js").then(({ worker }) =>
@@ -16,6 +18,16 @@ const enableMocking = () =>
);
function main() {
+ if (window.__INITIAL_DATA__) {
+ const data = window.__INITIAL_DATA__;
+
+ // store sync
+ if (data.products) productStore.dispatch(PRODUCT_ACTIONS.SETUP, data);
+ if (data.currentProduct) productStore.dispatch(PRODUCT_ACTIONS.SET_CURRENT_PRODUCT, data);
+
+ delete window.__INITIAL_DATA__;
+ }
+
registerAllEvents();
registerGlobalEvents();
loadCartFromStorage();
diff --git a/packages/vanilla/src/mocks/handlers.js b/packages/vanilla/src/mocks/handlers.js
index 6e3035e6..8d3a1faa 100644
--- a/packages/vanilla/src/mocks/handlers.js
+++ b/packages/vanilla/src/mocks/handlers.js
@@ -64,7 +64,7 @@ function filterProducts(products, query) {
export const handlers = [
// 상품 목록 API
- http.get("/api/products", async ({ request }) => {
+ http.get("*/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 +111,7 @@ export const handlers = [
}),
// 상품 상세 API
- http.get("/api/products/:id", ({ params }) => {
+ http.get("*/api/products/:id", ({ params }) => {
const { id } = params;
const product = items.find((item) => item.productId === id);
@@ -133,7 +133,7 @@ export const handlers = [
}),
// 카테고리 목록 API
- http.get("/api/categories", async () => {
+ http.get("*/api/categories", async () => {
const categories = getUniqueCategories();
await delay();
return HttpResponse.json(categories);
diff --git a/packages/vanilla/src/router/router.js b/packages/vanilla/src/router/router.js
index d897ee76..1b3121a9 100644
--- a/packages/vanilla/src/router/router.js
+++ b/packages/vanilla/src/router/router.js
@@ -1,5 +1,353 @@
-// 글로벌 라우터 인스턴스
-import { Router } from "../lib";
import { BASE_URL } from "../constants.js";
-export const router = new Router(BASE_URL);
+function createObserver() {
+ const listeners = new Set();
+
+ const subscribe = (fn) => {
+ listeners.add(fn);
+ return () => unsubscribe(fn);
+ };
+
+ const unsubscribe = (fn) => {
+ listeners.delete(fn);
+ };
+
+ const notify = () => listeners.forEach((listener) => listener());
+
+ return { subscribe, unsubscribe, notify };
+}
+
+class ClientRouter {
+ #routes = new Map();
+ #observer = createObserver();
+ #baseUrl;
+ #route = null;
+
+ constructor(baseUrl = "") {
+ this.#baseUrl = baseUrl.replace(/\/$/, "");
+
+ if (typeof window !== "undefined") {
+ window.addEventListener("popstate", () => {
+ this.#route = this.#findRoute();
+ this.#observer.notify();
+ });
+
+ document.addEventListener("click", (e) => {
+ const target = e.target;
+ if (!target?.closest("[data-link]")) {
+ return;
+ }
+ e.preventDefault();
+ const url = target.getAttribute("href") ?? target.closest("[data-link]")?.getAttribute("href");
+ if (url) {
+ this.push(url);
+ }
+ });
+ }
+ }
+
+ get query() {
+ return ClientRouter.parseQuery(window.location.search);
+ }
+
+ set query(newQuery) {
+ const newUrl = ClientRouter.getUrl(newQuery, this.#baseUrl);
+ this.push(newUrl);
+ }
+
+ get params() {
+ return this.#route?.params ?? {};
+ }
+
+ get route() {
+ return this.#route;
+ }
+
+ get target() {
+ return this.#route?.handler;
+ }
+
+ get baseUrl() {
+ return this.#baseUrl;
+ }
+
+ subscribe = this.#observer.subscribe;
+
+ addRoute(path, handler) {
+ const paramNames = [];
+ const regexPath = path
+ .replace(/:\w+/g, (match) => {
+ paramNames.push(match.slice(1));
+ return "([^/]+)";
+ })
+ .replace(/\//g, "\\/");
+
+ const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`);
+
+ this.#routes.set(path, {
+ regex,
+ paramNames,
+ handler,
+ });
+ }
+
+ #findRoute(url = window.location.pathname) {
+ const pathname = new URL(url, window.location.origin).pathname;
+ 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;
+ }
+
+ push(url) {
+ try {
+ const fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url);
+
+ const prevFullUrl = `${window.location.pathname}${window.location.search}`;
+
+ if (prevFullUrl !== fullUrl) {
+ window.history.pushState(null, "", fullUrl);
+ }
+
+ this.#route = this.#findRoute(fullUrl);
+ this.#observer.notify();
+ } catch (error) {
+ console.error("라우터 네비게이션 오류:", error);
+ }
+ }
+
+ start() {
+ this.#route = this.#findRoute();
+ this.#observer.notify();
+ }
+
+ static parseQuery(search = window.location.search) {
+ const params = new URLSearchParams(search);
+ const query = {};
+ for (const [key, value] of params) {
+ query[key] = value;
+ }
+ return query;
+ }
+
+ static stringifyQuery(query) {
+ const params = new URLSearchParams();
+ for (const [key, value] of Object.entries(query)) {
+ if (value !== null && value !== undefined && value !== "") {
+ params.set(key, String(value));
+ }
+ }
+ return params.toString();
+ }
+
+ static getUrl(newQuery, baseUrl = "") {
+ const currentQuery = ClientRouter.parseQuery();
+ const updatedQuery = { ...currentQuery, ...newQuery };
+
+ Object.keys(updatedQuery).forEach((key) => {
+ if (updatedQuery[key] === null || updatedQuery[key] === undefined || updatedQuery[key] === "") {
+ delete updatedQuery[key];
+ }
+ });
+
+ const queryString = ClientRouter.stringifyQuery(updatedQuery);
+ return `${baseUrl}${window.location.pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`;
+ }
+}
+
+class ServerRouter {
+ #routes = new Map();
+ #currentRoute = null;
+ #params = {};
+ #query = {};
+ #baseUrl;
+
+ constructor(baseUrl = "") {
+ this.#baseUrl = baseUrl;
+ }
+
+ get params() {
+ return this.#params;
+ }
+
+ get query() {
+ return this.#query;
+ }
+
+ set query(newQuery) {
+ this.#query = { ...this.#query, ...newQuery };
+ }
+
+ get route() {
+ return this.#currentRoute;
+ }
+
+ get target() {
+ return this.#currentRoute?.handler;
+ }
+
+ get baseUrl() {
+ return this.#baseUrl;
+ }
+
+ addRoute(path, handler) {
+ const paramNames = [];
+ const regexPath = path
+ .replace(/:\w+/g, (match) => {
+ paramNames.push(match.slice(1));
+ return "([^/]+)";
+ })
+ .replace(/\//g, "\\/");
+
+ const regex = new RegExp(`^${regexPath}$`);
+ this.#routes.set(path, {
+ path,
+ regex,
+ paramNames,
+ handler,
+ });
+ }
+
+ matchRoute(url, query = {}) {
+ const 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];
+ });
+
+ this.#currentRoute = route;
+ this.#params = params;
+ this.#query = query;
+
+ return {
+ path: routePath,
+ params,
+ handler: route.handler,
+ };
+ }
+ }
+ return null;
+ }
+
+ subscribe() {
+ return () => {};
+ }
+
+ push() {}
+
+ start() {}
+}
+
+class UniversalRouter {
+ #router;
+
+ constructor(baseUrl = "") {
+ const isServer = typeof window === "undefined";
+ this.#router = isServer ? new ServerRouter(baseUrl) : new ClientRouter(baseUrl);
+ }
+
+ addRoute(...args) {
+ return this.#router.addRoute(...args);
+ }
+
+ matchRoute(...args) {
+ return this.#router.matchRoute?.(...args);
+ }
+
+ push(...args) {
+ return this.#router.push?.(...args);
+ }
+
+ start() {
+ return this.#router.start?.();
+ }
+
+ subscribe(...args) {
+ return this.#router.subscribe?.(...args);
+ }
+
+ get params() {
+ return this.#router.params;
+ }
+
+ get query() {
+ return this.#router.query;
+ }
+
+ set query(newQuery) {
+ this.#router.query = newQuery;
+ }
+
+ get route() {
+ return this.#router.route;
+ }
+
+ get target() {
+ return this.#router.target;
+ }
+
+ get baseUrl() {
+ return this.#router.baseUrl;
+ }
+}
+
+const clientRouter = typeof window !== "undefined" ? new UniversalRouter(BASE_URL) : null;
+
+const getRouterInstance = () => {
+ if (clientRouter) return clientRouter;
+ if (typeof global !== "undefined" && global.router) return global.router;
+ return { params: {}, query: {} };
+};
+
+export const router = {
+ get params() {
+ return getRouterInstance().params;
+ },
+ get query() {
+ return getRouterInstance().query;
+ },
+ set query(newQuery) {
+ if (clientRouter) {
+ clientRouter.query = newQuery;
+ }
+ },
+ get route() {
+ return getRouterInstance().route;
+ },
+ get target() {
+ return getRouterInstance().target;
+ },
+ get baseUrl() {
+ return getRouterInstance().baseUrl || BASE_URL;
+ },
+ addRoute(...args) {
+ return clientRouter?.addRoute(...args);
+ },
+ push(...args) {
+ return clientRouter?.push(...args);
+ },
+ start() {
+ return clientRouter?.start();
+ },
+ subscribe(...args) {
+ return clientRouter?.subscribe(...args);
+ },
+};
+
+export { UniversalRouter };
diff --git a/packages/vanilla/src/router/withLifecycle.js b/packages/vanilla/src/router/withLifecycle.js
index ccb21113..251939e0 100644
--- a/packages/vanilla/src/router/withLifecycle.js
+++ b/packages/vanilla/src/router/withLifecycle.js
@@ -31,8 +31,10 @@ const mount = (page) => {
const lifecycle = getPageLifecycle(page);
if (lifecycle.mounted) return;
- // 마운트 콜백들 실행
- lifecycle.mount?.();
+ // 마운트 콜백들 실행 (클라이언트에서만)
+ if (typeof window !== "undefined") {
+ lifecycle.mount?.();
+ }
lifecycle.mounted = true;
lifecycle.deps = [];
};
@@ -64,6 +66,7 @@ export const withLifecycle = ({ onMount, onUnmount, watches } = {}, page) => {
return (...args) => {
const wasNewPage = pageState.current !== page;
+ const isServer = typeof window === "undefined";
// 이전 페이지 언마운트
if (pageState.current && wasNewPage) {
@@ -74,20 +77,21 @@ export const withLifecycle = ({ onMount, onUnmount, watches } = {}, page) => {
pageState.previous = pageState.current;
pageState.current = page;
- // 새 페이지면 마운트, 기존 페이지면 업데이트
- if (wasNewPage) {
- mount(page);
- } else if (lifecycle.watches) {
- lifecycle.watches.forEach(([getDeps, callback], index) => {
- const newDeps = getDeps();
-
- if (depsChanged(newDeps, lifecycle.deps[index])) {
- callback();
- }
-
- // deps 업데이트 (이 부분이 중요!)
- lifecycle.deps[index] = Array.isArray(newDeps) ? [...newDeps] : [];
- });
+ if (!isServer) {
+ if (wasNewPage) {
+ mount(page);
+ } else if (lifecycle.watches) {
+ lifecycle.watches.forEach(([getDeps, callback], index) => {
+ const newDeps = getDeps();
+
+ if (depsChanged(newDeps, lifecycle.deps[index])) {
+ callback();
+ }
+
+ // deps 업데이트 (이 부분이 중요!)
+ lifecycle.deps[index] = Array.isArray(newDeps) ? [...newDeps] : [];
+ });
+ }
}
// 페이지 함수 실행
diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js
index c479f112..726a439c 100644
--- a/packages/vanilla/static-site-generate.js
+++ b/packages/vanilla/static-site-generate.js
@@ -1,20 +1,53 @@
import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
-const render = () => {
- return `안녕하세요
`;
-};
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function generateStaticSite() {
- // HTML 템플릿 읽기
- const template = fs.readFileSync("../../dist/vanilla/index.html", "utf-8");
+ const { render } = await import("./dist/vanilla-ssr/main-server.js");
- // 어플리케이션 렌더링하기
- const appHtml = render();
+ const template = fs.readFileSync(path.resolve(__dirname, "../../dist/vanilla/index.html"), "utf-8");
- // 결과 HTML 생성하기
- const result = template.replace("", appHtml);
- fs.writeFileSync("../../dist/vanilla/index.html", result);
+ const itemsPath = path.resolve(__dirname, "src/mocks/items.json");
+ const items = JSON.parse(fs.readFileSync(itemsPath, "utf-8"));
+
+ const pages = [{ url: "/", outputPath: "../../dist/vanilla/index.html" }];
+
+ // 모든 상품 상세 페이지 추가
+ items.forEach((item) => {
+ pages.push({
+ url: `/product/${item.productId}/`,
+ outputPath: `../../dist/vanilla/product/${item.productId}/index.html`,
+ });
+ });
+
+ // 각 페이지 생성
+ for (const page of pages) {
+ try {
+ // SSR 렌더링
+ const { html, head, initialDataScript } = await render(page.url, {});
+
+ const result = template
+ .replace("", head)
+ .replace("", html)
+ .replace("", `${initialDataScript}`);
+
+ const outputPath = path.resolve(__dirname, page.outputPath);
+ const dir = path.dirname(outputPath);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+
+ fs.writeFileSync(outputPath, result);
+ console.log(`페이지 생성 완료 (${page.url})`);
+ } catch (error) {
+ console.error(`페이지 생성 실패 (${page.url}):`, error.message);
+ }
+ }
}
-// 실행
-generateStaticSite();
+generateStaticSite().catch((error) => {
+ console.error("❌ SSG 실행 실패:", error);
+ process.exit(1);
+});