diff --git a/packages/vanilla/index.html b/packages/vanilla/index.html index 483a6d5e..56f70659 100644 --- a/packages/vanilla/index.html +++ b/packages/vanilla/index.html @@ -5,18 +5,19 @@ - + + diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index b9a56d98..10acf34d 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,34 +1,78 @@ +import compression from "compression"; import express from "express"; +import fs from "fs/promises"; +import sirv from "sirv"; +import { mswServer } from "./src/mocks/mswServer.js"; -const prod = process.env.NODE_ENV === "production"; +// 환경 변수 및 설정 +const isProd = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5173; -const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/"); - +const baseUrl = process.env.BASE || (isProd ? "/front_6th_chapter4-1/vanilla/" : "/"); +const templateHtml = isProd ? await fs.readFile("dist/vanilla/index.html", "utf-8") : ""; const app = express(); -const render = () => { - return `
안녕하세요
`; -}; - -app.get("*all", (req, res) => { - res.send( - ` - - - - - - Vanilla Javascript SSR - - -
${render()}
- - - `.trim(), - ); +let vite; + +// MSW 서버 시작 (API 모킹을 위해) +mswServer.listen({ + onUnhandledRequest: "bypass", +}); + +// 환경별 정적 파일 서빙 및 미들웨어 설정 +if (isProd) { + // 프로덕션 환경: 빌드된 정적 파일 서빙 (assets만 서빙하도록 제한) + app.use(compression()); + app.use(`${baseUrl}assets`, sirv("dist/vanilla/assets", { dev: false })); + app.use(`${baseUrl}mockServiceWorker.js`, sirv("dist/vanilla", { dev: false })); +} else { + // 개발 환경: Vite 개발 서버를 미들웨어로 사용 + const { createServer } = await import("vite"); + vite = await createServer({ + server: { middlewareMode: true }, + appType: "custom", + baseUrl, + }); + + app.use(vite.middlewares); +} + +// 모든 라우트를 처리하는 SSR 핸들러 +app.get(/^(?!.*\/api).*/, async (req, res) => { + try { + let template; + let render; + + if (!isProd) { + // 개발 환경: 매 요청마다 템플릿을 다시 읽고 변환 + template = await fs.readFile("./index.html", "utf-8"); + template = await vite.transformIndexHtml(req.originalUrl, template); + render = (await vite.ssrLoadModule("./src/main-server.js")).render; + } else { + // 프로덕션 환경: 미리 로드된 템플릿과 빌드된 모듈 사용 + template = templateHtml; + render = (await import("./dist/vanilla-ssr/main-server.js")).render; + } + + // SSR 렌더링 실행 + const rendered = await render(req.originalUrl, req.query); + + // HTML 템플릿에 렌더링된 내용 삽입 + const html = template + .replace(``, rendered.head ?? "") + .replace(``, ``) + .replace(``, rendered.html ?? ""); + + // 클라이언트에 완성된 HTML 응답 + res.status(200).set({ "Content-Type": "text/html" }).send(html); + } catch (error) { + console.error(error); + res.status(500).send("Internal Server Error"); + } }); -// Start http server +// HTTP 서버 시작 app.listen(port, () => { - console.log(`React Server started at http://localhost:${port}`); + console.log(`🚀 Server started at http://localhost:${port}`); + console.log(`📁 Environment: ${isProd ? "production" : "development"}`); + console.log(`📍 Base URL: ${baseUrl}`); }); diff --git a/packages/vanilla/src/api/productApi.js b/packages/vanilla/src/api/productApi.js index c2330fbe..78cc5762 100644 --- a/packages/vanilla/src/api/productApi.js +++ b/packages/vanilla/src/api/productApi.js @@ -1,7 +1,33 @@ +// API 기본 URL을 환경별로 동적 설정 +const getBaseUrl = () => { + // 클라이언트 환경: 상대 경로 사용 (같은 도메인의 API 호출) + if (typeof window !== "undefined") { + return ""; // 브라우저에서는 빈 문자열로 상대 경로 사용 + } + + // 서버 환경: 절대 URL 필요 (서버에서 서버로 호출) + const prod = process.env.NODE_ENV === "production"; + return prod ? "http://localhost:4174" : "http://localhost:5174"; // 환경별 포트 설정 +}; + +const BASE_URL = getBaseUrl(); // 런타임에 환경에 맞는 BASE_URL 결정 + +/** + * 상품 목록 조회 API - 검색, 필터링, 페이징 지원 + * @param {Object} params - 쿼리 파라미터 객체 + * @param {number} params.limit - 페이지당 상품 수 (기본값: 20) + * @param {string} params.search - 검색 키워드 + * @param {string} params.category1 - 1차 카테고리 필터 + * @param {string} params.category2 - 2차 카테고리 필터 + * @param {string} params.sort - 정렬 방식 (기본값: "price_asc") + * @param {number} params.current|params.page - 현재 페이지 번호 + * @returns {Promise} {products: Array, pagination: Object} 형태의 응답 + */ export async function getProducts(params = {}) { const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; const page = params.current ?? params.page ?? 1; + // URL 쿼리 파라미터 구성 (빈 값들은 자동으로 제외됨) const searchParams = new URLSearchParams({ page: page.toString(), limit: limit.toString(), @@ -11,17 +37,28 @@ export async function getProducts(params = {}) { sort, }); - const response = await fetch(`/api/products?${searchParams}`); - + // API 호출 및 JSON 응답 파싱 + const response = await fetch(`${BASE_URL}/api/products?${searchParams}`); return await response.json(); } +/** + * 특정 상품의 상세 정보 조회 + * @param {string} productId - 조회할 상품의 고유 ID + * @returns {Promise} 상품 상세 정보 객체 + */ export async function getProduct(productId) { - const response = await fetch(`/api/products/${productId}`); + // RESTful API 패턴: GET /api/products/{id} + const response = await fetch(`${BASE_URL}/api/products/${productId}`); return await response.json(); } +/** + * 전체 카테고리 목록 조회 (1차, 2차 카테고리 포함) + * @returns {Promise} 카테고리 트리 구조 객체 + */ 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/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js new file mode 100644 index 00000000..be374bb0 --- /dev/null +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -0,0 +1,185 @@ +/** + * 서버사이드 라우터 - window 객체가 없는 SSR 환경에서 사용 + */ +export class ServerRouter { + #routes; + #route; + #baseUrl; + #currentQuery = {}; + + // 모든 라우트 설정 초기화 (라우트 저장소, 활성 라우트, url) + constructor(baseUrl = "") { + this.#routes = new Map(); + this.#route = null; + this.#baseUrl = baseUrl.replace(/\/$/, ""); + } + + // 현재 설정된 쿼리 파라미터를 반환 (서버 환경에서는 직접 관리) + get query() { + return this.#currentQuery; + } + + // 새 쿼리 파라미터로 URL 생성하고 라우팅 업데이트 + set query(newQuery) { + const newUrl = ServerRouter.getUrl(newQuery, this.#baseUrl); + this.push(newUrl); + } + + // 현재 라우트의 경로 파라미터 반환 (예: /product/:id에서 {id: "123"}) + get params() { + return this.#route?.params ?? {}; + } + + // 현재 매칭된 라우트 정보 반환 + get route() { + return this.#route; + } + + // 현재 라우트의 핸들러 함수 반환 + get target() { + return this.#route?.handler; + } + + /** + * 서버사이드 네비게이션 실행 - URL 변경 시 호출 + * (브라우저와 달리 히스토리 API 사용하지 않고 내부 상태만 업데이트) + * @param {string} url - 이동할 경로 (기본값: "/") + */ + push(url = "/") { + try { + // 주어진 URL에 매칭되는 라우트를 찾아서 현재 라우트로 설정 + this.#route = this.#findRoute(url); + } catch (error) { + console.error("서버 네비게이션 오류:", error); + } + } + + /** + * 서버 라우터 초기화 및 시작 - SSR 렌더링 시작점에서 호출 + * 쿼리 파라미터를 설정하고 url에 매칭되는 라우트 적용 + * @param {string} url - 초기 URL 경로 (기본값: "/") + * @param {object} query - 초기 쿼리 파라미터 객체 (기본값: {}) + */ + start(url = "/", query = {}) { + this.#currentQuery = query; + this.#route = this.#findRoute(url); + } + + /** + * 라우트 등록 - URL 패턴과 핸들러 함수를 매핑 + * @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, "\\/"); + + // baseUrl과 regexPath 사이의 중복 슬래시 제거 + const normalizedPath = `${this.#baseUrl}${regexPath}`.replace(/\/+/g, "/"); + const regex = new RegExp(`^${normalizedPath}$`); + + // 라우트 정보를 Map에 저장 + this.#routes.set(path, { + regex, + paramNames, + handler, + }); + } + + /** + * 주어진 URL과 매칭되는 라우트를 찾아서 반환 + * @param {string} url - 매칭할 URL (기본값: "/") + * @param {string} origin - 서버 도메인 (기본값: "http://localhost") + * @returns {Object|null} 매칭된 라우트 정보 또는 null + */ + #findRoute(url = "/", origin = "http://localhost") { + // URL 객체를 생성해서 pathname 추출 (쿼리스트링, 해시 제외) + const { pathname } = new URL(url, origin); + + // 등록된 모든 라우트를 순회하며 매칭 확인 + for (const [routePath, route] of this.#routes) { + const match = pathname.match(route.regex); // 정규식으로 URL 패턴 매칭 + + if (match) { + // 매칭된 동적 파라미터들을 객체로 변환 + // 예: /product/123 → {id: "123"} + const params = {}; + route.paramNames.forEach((name, index) => { + params[name] = match[index + 1]; + }); + + // 라우트 정보와 추출된 파라미터를 함께 반환 + return { + ...route, + params, + path: routePath, + }; + } + } + + return null; + } + + /** + * 쿼리 파라미터 문자열을 객체로 파싱 + * @param {string} search - location.search 또는 쿼리 문자열 (예: "?page=1&limit=20") + * @returns {Object} 파싱된 쿼리 객체 + */ + static parseQuery = (search) => { + const params = new URLSearchParams(search); + const query = {}; + + for (const [key, value] of params) { + query[key] = value; + } + return query; + }; + + /** + * 객체를 쿼리 문자열로 변환 (빈 값들은 제외) + * @param {Object} query - 쿼리 객체 (예: {page: 1, search: "test"}) + * @returns {string} 쿼리 문자열 ("page=1&search=test" 형태) + */ + static stringifyQuery = (query) => { + const params = new URLSearchParams(); + + for (const [key, value] of Object.entries(query)) { + // null, undefined, 빈 문자열이 아닌 값들만 추가 + if (value !== null && value !== undefined && value !== "") { + params.set(key, String(value)); + } + } + return params.toString(); + }; + + /** + * 새로운 쿼리 파라미터와 기존 쿼리를 병합하여 완전한 URL 생성 + * @param {Object} newQuery - 새로 추가할 쿼리 객체 + * @param {string} pathname - 경로 + * @param {string} baseUrl - 베이스 URL + * @returns {string} 완성된 URL (예: "/products?page=2&search=test") + */ + static getUrl = (newQuery, pathname = "/", baseUrl = "") => { + // 현재 쿼리 파라미터 가져오기 + const currentQuery = ServerRouter.parseQuery(); + // 기존 쿼리에 새 쿼리 병합 + const updatedQuery = { ...currentQuery, ...newQuery }; + + // 빈 값들 제거 (null, undefined, 빈 문자열) + Object.keys(updatedQuery).forEach((key) => { + if (updatedQuery[key] === null || updatedQuery[key] === undefined || updatedQuery[key] === "") { + delete updatedQuery[key]; + } + }); + + const queryString = ServerRouter.stringifyQuery(updatedQuery); + // 최종 URL 조합: baseUrl + pathname + queryString + return `${baseUrl}${pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`; + }; +} diff --git a/packages/vanilla/src/lib/createServerStorage.js b/packages/vanilla/src/lib/createServerStorage.js new file mode 100644 index 00000000..10cacd4d --- /dev/null +++ b/packages/vanilla/src/lib/createServerStorage.js @@ -0,0 +1,14 @@ +/** + * 서버 환경용 메모리 스토리지 - localStorage API와 동일한 인터페이스 제공 + * @returns {Object} localStorage와 호환되는 메서드를 가진 객체 + */ +export const createServerStorage = () => { + const storage = new Map(); + + return { + getItem: (key) => storage.get(key), + setItem: (key, value) => storage.set(key, value), + removeItem: (key) => storage.delete(key), + clear: () => storage.clear(), + }; +}; diff --git a/packages/vanilla/src/lib/createStorage.js b/packages/vanilla/src/lib/createStorage.js index 08b504f2..0aef539b 100644 --- a/packages/vanilla/src/lib/createStorage.js +++ b/packages/vanilla/src/lib/createStorage.js @@ -1,10 +1,15 @@ +import { createServerStorage } from "./createServerStorage.js"; /** - * 로컬스토리지 추상화 함수 - * @param {string} key - 스토리지 키 - * @param {Storage} storage - 기본값은 localStorage - * @returns {Object} { get, set, reset } + * 환경별 스토리지 추상화 함수 - 브라우저/서버 환경에서 동일한 인터페이스 제공 + * @param {string} key - 스토리지 키 (예: "cart", "user-preferences") + * @param {Storage} storage - 스토리지 구현체 (기본값: 환경별 자동 선택) + * @returns {Object} { get, set, reset } 스토리지 조작 메서드들 */ -export const createStorage = (key, storage = window.localStorage) => { +export const createStorage = ( + key, + storage = typeof window === "undefined" ? createServerStorage() : window.localStorage, +) => { + // 데이터 조회 const get = () => { try { const item = storage.getItem(key); @@ -15,6 +20,7 @@ export const createStorage = (key, storage = window.localStorage) => { } }; + // 데이터 저장 const set = (value) => { try { storage.setItem(key, JSON.stringify(value)); @@ -23,6 +29,7 @@ export const createStorage = (key, storage = window.localStorage) => { } }; + // 데이터 삭제 const reset = () => { try { storage.removeItem(key); diff --git a/packages/vanilla/src/lib/index.js b/packages/vanilla/src/lib/index.js index a598ef30..2d1927e9 100644 --- a/packages/vanilla/src/lib/index.js +++ b/packages/vanilla/src/lib/index.js @@ -1,4 +1,6 @@ export * from "./createObserver"; export * from "./createStore"; export * from "./createStorage"; +export * from "./createServerStorage"; export * from "./Router"; +export * from "./ServerRouter"; diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 40b58858..26300547 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,4 +1,172 @@ -export const render = async (url, query) => { - console.log({ url, query }); - return ""; +import { HomePage, NotFoundPage, ProductDetailPage } from "./pages"; +import { router } from "./router"; +import { getProducts, getCategories, getProduct } from "./api/productApi.js"; +import { productStore } from "./stores"; +import { PRODUCT_ACTIONS } from "./stores/actionTypes"; +import { BASE_URL } from "./constants.js"; + +// 라우터에 페이지별 경로와 컴포넌트 등록 +router.addRoute("/", HomePage); +router.addRoute("/product/:id/", ProductDetailPage); +router.addRoute(".*", NotFoundPage); + +/** + * SSR 렌더링 메인 함수 - 서버에서 HTML을 생성하여 클라이언트로 전송 + * @param {string} url - 요청받은 URL 경로 + * @param {Object} query - URL의 쿼리 파라미터 객체 + * @returns {Object} {html, head, data} 형태의 렌더링 결과 + */ +export const render = async (url = "", query) => { + try { + // URL 정규화 - BASE_URL 제거하여 상대 경로로 변환 + let normalizedUrl = url; + if (url.includes(BASE_URL) && BASE_URL !== "/") { + normalizedUrl = url.replace(BASE_URL, "/").replace(/\/+/g, "/"); + } + + // 서버사이드 라우터 시작 + router.start(normalizedUrl, query); + + const route = router.route; + if (!route) { + return { + html: NotFoundPage(), + head: "페이지를 찾을 수 없습니다", + data: JSON.stringify({}), + }; + } + + let head = "안녕하세요"; + let initialData = {}; + + // 라우트별 데이터 설정 + if (route.path === "/") { + try { + // 라우터의 쿼리 파라미터를 사용하여 검색/필터링/페이징 적용 + const [productsResponse, categories] = await Promise.all([getProducts(router.query), getCategories()]); + + // 서버사이드에서 스토어에 데이터 미리 설정 (하이드레이션) + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: productsResponse.products || [], + totalCount: productsResponse.pagination?.total || 0, + categories: categories || {}, + currentProduct: null, + relatedProducts: [], + loading: false, + error: null, + status: "done", + }, + }); + + head = "쇼핑몰 - 홈"; + + // 클라이언트 하이드레이션용 초기 데이터 (window.__INITIAL_DATA__) + initialData = { + products: productsResponse.products || [], + categories: categories || {}, + totalCount: productsResponse.pagination?.total || 0, + }; + } catch (dataError) { + // 데이터 로드 실패 시 에러 상태로 스토어 설정 + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: [], + totalCount: 0, + categories: {}, + currentProduct: null, + relatedProducts: [], + loading: false, + error: dataError.message, + status: "error", + }, + }); + + initialData = { + products: [], + categories: {}, + totalCount: 0, + }; + } + } else if (route.path === "/product/:id/") { + const productId = route.params.id; + + try { + const product = await getProduct(productId); + // 관련 상품 + let relatedProducts = []; + if (product && product.category2) { + const relatedResponse = await getProducts({ + category2: product.category2, + limit: 20, + page: 1, + }); + // 현재 상품은 관련 상품에서 제외 + relatedProducts = relatedResponse.products.filter((p) => p.productId !== productId); + } + + // 상품 상세페이지용 스토어 설정 + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: [], + totalCount: 0, + categories: {}, + currentProduct: product, + relatedProducts: relatedProducts, + loading: false, + error: null, + status: "done", + }, + }); + + head = `${product.title} - 쇼핑몰`; + + // 상품 상세페이지용 초기 데이터 + initialData = { + product: product, + relatedProducts: relatedProducts, + }; + } catch (dataError) { + // 상품 조회 실패 시 (존재하지 않는 상품 등) + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: [], + totalCount: 0, + categories: {}, + currentProduct: null, + relatedProducts: [], + loading: false, + error: dataError.message, + status: "error", + }, + }); + + initialData = { + product: null, + relatedProducts: [], + }; + } + } + + const PageComponent = router.target; + + const html = PageComponent(); + + // 최종 렌더링 결과 반환 + return { + html, + head, + data: JSON.stringify(initialData), + }; + } catch (error) { + return { + html: `
서버 오류: ${error.message}
`, + head: "서버 오류", + data: JSON.stringify({}), + }; + } }; diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js index 4c3f2765..150f7aed 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.js"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => @@ -15,10 +17,62 @@ const enableMocking = () => }), ); +// SSR 데이터를 클라이언트 스토어에 hydrate +function hydrateFromSSRData() { + if (typeof window === "undefined" || !window.__INITIAL_DATA__) { + return; + } + + try { + const initialData = window.__INITIAL_DATA__; + + const currentPath = window.location.pathname; + + // 홈페이지 hydration + if (currentPath === "/" && initialData.products) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: initialData.products || [], + totalCount: initialData.totalCount || 0, + categories: initialData.categories || {}, + currentProduct: null, + relatedProducts: [], + loading: false, + error: null, + status: "done", + }, + }); + } + // 상품 상세 페이지 hydration + else if (currentPath.includes("/product/") && initialData.product) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: [], + totalCount: 0, + categories: {}, + currentProduct: initialData.product, + relatedProducts: initialData.relatedProducts || [], + loading: false, + error: null, + status: "done", + }, + }); + } + + // hydration 완료 플래그 + window.__HYDRATED__ = true; + } catch (error) { + console.error("❌ SSR hydration 실패", error); + } +} + function main() { registerAllEvents(); registerGlobalEvents(); loadCartFromStorage(); + hydrateFromSSRData(); initRender(); router.start(); } diff --git a/packages/vanilla/src/mocks/handlers.js b/packages/vanilla/src/mocks/handlers.js index 6e3035e6..9cd19b09 100644 --- a/packages/vanilla/src/mocks/handlers.js +++ b/packages/vanilla/src/mocks/handlers.js @@ -1,5 +1,5 @@ import { http, HttpResponse } from "msw"; -import items from "./items.json"; +import items from "./items.json" with { type: "json" }; const delay = async () => await new Promise((resolve) => setTimeout(resolve, 200)); @@ -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/mocks/mswServer.js b/packages/vanilla/src/mocks/mswServer.js new file mode 100644 index 00000000..1e78a8ee --- /dev/null +++ b/packages/vanilla/src/mocks/mswServer.js @@ -0,0 +1,7 @@ +// jest.setup.js +import { setupServer } from "msw/node"; +import { handlers } from "./handlers.js"; + +const mswServer = setupServer(...handlers); + +export { mswServer }; diff --git a/packages/vanilla/src/pages/HomePage.js b/packages/vanilla/src/pages/HomePage.js index ca08c26c..fafbaea5 100644 --- a/packages/vanilla/src/pages/HomePage.js +++ b/packages/vanilla/src/pages/HomePage.js @@ -1,5 +1,5 @@ import { ProductList, SearchBar } from "../components"; -import { productStore } from "../stores"; +import { PRODUCT_ACTIONS, productStore } from "../stores"; import { router, withLifecycle } from "../router"; import { loadProducts, loadProductsAndCategories } from "../services"; import { PageWrapper } from "./PageWrapper.js"; @@ -7,7 +7,38 @@ import { PageWrapper } from "./PageWrapper.js"; export const HomePage = withLifecycle( { onMount: () => { - loadProductsAndCategories(); + if (typeof window === "undefined") { + console.log("이 코드는 서버에서 실행이 되고 "); + return; + } + if (window.__INITIAL_DATA__?.products?.length > 0) { + console.log("이 코드는 클라이언트에서 실행이 되는데, __INITIAL_DATA__ 가 있을 때에만!"); + const { products, categories, totalCount } = window.__INITIAL_DATA__; + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products, + categories, + totalCount, + loading: false, + status: "done", + }, + }); + return; + } + console.log("이 코드는 아무것도 없을 때!"); + + // SSG 서버 실행 + if (typeof window !== "undefined") { + const { products, categories, status } = productStore.getState(); + // Hydration된 데이터가 있으면 API 호출 스킵 + if (window.__HYDRATED__ && products.length > 0 && Object.keys(categories).length > 0 && status === "done") { + console.log("✅ SSR 데이터 이미 있어서 API 요청 스킵"); + return; + } + + loadProductsAndCategories(); + } }, watches: [ () => { @@ -17,8 +48,17 @@ export const HomePage = withLifecycle( () => loadProducts(true), ], }, - () => { - const productState = productStore.getState(); + (props = {}) => { + const productState = + props.products?.length > 0 + ? { + products: props.products, + loading: false, + error: null, + totalCount: props.totalCount, + categories: props.categories, + } + : productStore.getState(); const { search: searchQuery, limit, sort, category1, category2 } = router.query; const { products, loading, error, totalCount, categories } = productState; const category = { category1, category2 }; diff --git a/packages/vanilla/src/pages/ProductDetailPage.js b/packages/vanilla/src/pages/ProductDetailPage.js index 73d0ec30..8a4dbf1c 100644 --- a/packages/vanilla/src/pages/ProductDetailPage.js +++ b/packages/vanilla/src/pages/ProductDetailPage.js @@ -237,7 +237,20 @@ function ProductDetail({ product, relatedProducts = [] }) { export const ProductDetailPage = withLifecycle( { onMount: () => { - loadProductDetailForPage(router.params.id); + // SSG 서버 실행 + if (typeof window !== "undefined") { + if (window.__HYDRATED__) { + const { currentProduct, status } = productStore.getState(); + const productId = router.params.id; + // Hydration된 데이터가 있으면 API 호출 스킵 + if (currentProduct && currentProduct.productId === productId && status === "done") { + console.log("✅ SSR 데이터 이미 있어서 API 요청 스킵"); + return; + } + } + + loadProductDetailForPage(router.params.id); + } }, watches: [() => [router.params.id], () => loadProductDetailForPage(router.params.id)], }, diff --git a/packages/vanilla/src/router/router.js b/packages/vanilla/src/router/router.js index d897ee76..d89a2e91 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -1,5 +1,5 @@ // 글로벌 라우터 인스턴스 -import { Router } from "../lib"; +import { Router, ServerRouter } from "../lib"; import { BASE_URL } from "../constants.js"; -export const router = new Router(BASE_URL); +export const router = typeof window !== "undefined" ? new Router(BASE_URL) : new ServerRouter(""); diff --git a/packages/vanilla/src/router/withLifecycle.js b/packages/vanilla/src/router/withLifecycle.js index ccb21113..87b2d290 100644 --- a/packages/vanilla/src/router/withLifecycle.js +++ b/packages/vanilla/src/router/withLifecycle.js @@ -32,9 +32,11 @@ const mount = (page) => { if (lifecycle.mounted) return; // 마운트 콜백들 실행 - lifecycle.mount?.(); - lifecycle.mounted = true; - lifecycle.deps = []; + if (typeof window !== "undefined") { + lifecycle.mount?.(); + lifecycle.mounted = true; + lifecycle.deps = []; + } }; // 페이지 언마운트 처리 diff --git a/packages/vanilla/src/storage/cartStorage.js b/packages/vanilla/src/storage/cartStorage.js index 7aa68383..c6ab6172 100644 --- a/packages/vanilla/src/storage/cartStorage.js +++ b/packages/vanilla/src/storage/cartStorage.js @@ -1,3 +1,12 @@ import { createStorage } from "../lib"; -export const cartStorage = createStorage("shopping_cart"); +const storage = + typeof window !== "undefined" + ? window.localStorage + : { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }; + +export const cartStorage = createStorage("shopping_cart", storage); diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index c479f112..1aa96f31 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -1,20 +1,67 @@ -import fs from "fs"; +// SSG(Static Site Generation) 스크립트 +// 홈페이지와 상품 상세 페이지를 미리 생성하여 정적 파일로 저장 +import fs from "fs/promises"; +import { mswServer } from "./src/mocks/mswServer.js"; +import items from "./src/mocks/items.json" with { type: "json" }; -const render = () => { - return `
안녕하세요
`; -}; +// 서버 사이드 렌더링 함수 가져오기 +const { render } = await import("./dist/vanilla-ssr/main-server.js"); +const BASE = "/front_6th_chapter4-1/vanilla/"; + +/** + * 주어진 URL을 렌더링하여 HTML 파일로 생성 + * @param {string} url - 렌더링할 URL + * @param {string} template - HTML 템플릿 + * @param {string} outFile - 출력 파일 경로 + */ +async function writeRoute(url, template, outFile) { + // SSR로 페이지 렌더링 + const { html, head, data } = await render(url, {}); + + // 템플릿에 렌더링된 내용 삽입 + const result = template + .replace(``, head ?? "") + .replace(``, ``) + .replace(``, html ?? ""); + + await fs.writeFile(outFile, result, "utf-8"); +} + +/** + * 정적 사이트 생성 메인 함수 + */ async function generateStaticSite() { // HTML 템플릿 읽기 - const template = fs.readFileSync("../../dist/vanilla/index.html", "utf-8"); + const templatePath = "../../dist/vanilla/index.html"; + const template = await fs.readFile(templatePath, "utf-8"); + + // MSW 서버 시작 (API 요청 처리용) + mswServer.listen({ onUnhandledRequest: "bypass" }); + + try { + // 홈페이지 생성 (루트 경로로 전달) + await writeRoute(BASE, template, templatePath); + + // 상품 상세 + const productIds = items.slice(100, 130).map((p) => p.productId); + const testItem = items.find((product) => product.productId === "86940857379"); + productIds.push(testItem.productId); - // 어플리케이션 렌더링하기 - const appHtml = render(); + // 각 상품별로 상세 페이지 생성 + for (const id of productIds) { + const url = `${BASE}/product/${id}/`; + const outDir = `../../dist/vanilla/product/${id}`; + await fs.mkdir(outDir, { recursive: true }); + await writeRoute(url, template, `${outDir}/index.html`); + } - // 결과 HTML 생성하기 - const result = template.replace("", appHtml); - fs.writeFileSync("../../dist/vanilla/index.html", result); + console.log("✅ SSG 실행 완료"); + } finally { + // MSW 서버 종료 + mswServer.close(); + } } -// 실행 -generateStaticSite(); +// SSG 실행 +await generateStaticSite();