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 ( -
-
+
+
{children}
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); +});