From 6634015cc9a21f40258d97c4480c53fb1dc67ede Mon Sep 17 00:00:00 2001 From: minjaeleee Date: Wed, 3 Sep 2025 02:40:02 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20Express=20SSR=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/package.json | 4 +- packages/vanilla/server.js | 81 ++++++++++++++++++++++++++--------- pnpm-lock.yaml | 48 ++++++++++----------- 3 files changed, 87 insertions(+), 46 deletions(-) diff --git a/packages/vanilla/package.json b/packages/vanilla/package.json index 0916430b..1fc33123 100644 --- a/packages/vanilla/package.json +++ b/packages/vanilla/package.json @@ -47,6 +47,7 @@ "lint-staged": "^15.2.11", "msw": "^2.10.2", "prettier": "^3.4.2", + "sirv": "^3.0.1", "vite": "npm:rolldown-vite@latest", "vitest": "latest" }, @@ -57,7 +58,6 @@ }, "dependencies": { "compression": "^1.8.1", - "express": "^5.1.0", - "sirv": "^3.0.1" + "express": "^5.1.0" } } diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index b9a56d98..d7b9022f 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,31 +1,72 @@ import express from "express"; +import compression from "compression"; +import sirv from "sirv"; +import { readFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +const __dirname = 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_6th_chapter4-1/vanilla/" : "/"); +// HTML 템플릿 로드 및 분할 +const template = readFileSync(join(__dirname, "index.html"), "utf-8"); + const app = express(); -const render = () => { - return `
안녕하세요
`; -}; - -app.get("*all", (req, res) => { - res.send( - ` - - - - - - Vanilla Javascript SSR - - -
${render()}
- - - `.trim(), - ); +// Express 미들웨어 설정 +app.use(compression()); +app.use(express.static("public")); + +// 환경 분기 +if (!prod) { + // 개발 환경: Vite dev server + const { createServer: createViteServer } = await import("vite"); + const vite = await createViteServer({ + server: { middlewareMode: true }, + appType: "custom", + }); + + // Vite의 미들웨어 사용 + app.use(vite.middlewares); + + // Vite 인스턴스를 res.locals에 저장 + app.use((req, res, next) => { + res.locals.vite = vite; + next(); + }); +} else { + // 프로덕션 환경: sirv로 정적 파일 서빙 + app.use(base, sirv("dist/vanilla", { extensions: [] })); +} + +// 렌더링 파이프라인 +app.use("*all", async (req, res) => { + try { + const url = req.originalUrl; + + let html = template; + + // 개발 환경에서 Vite transform 적용 + if (!prod) { + const vite = res.locals.vite; + if (vite) { + html = await vite.transformIndexHtml(url, html); + } + } + + // 렌더링 데이터 - 테스트용 + const appHead = `Vanilla Javascript SSR`; + + // Template 치환 + const finalHtml = html.replace("", appHead).replace("", html); + + res.status(200).set({ "Content-Type": "text/html" }).end(finalHtml); + } catch (e) { + console.error(e.stack); + res.status(500).end(e.stack); + } }); // Start http server diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 766adcbc..4c66ae47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,7 +101,7 @@ importers: version: 6.8.0 '@testing-library/react': specifier: latest - version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@testing-library/user-event': specifier: latest version: 14.6.1(@testing-library/dom@10.4.1) @@ -110,10 +110,10 @@ importers: version: 24.0.13 '@types/react': specifier: latest - version: 19.1.11 + version: 19.1.12 '@types/react-dom': specifier: latest - version: 19.1.7(@types/react@19.1.11) + version: 19.1.9(@types/react@19.1.12) '@types/use-sync-external-store': specifier: latest version: 1.5.0 @@ -177,16 +177,16 @@ importers: version: 24.0.13 '@types/react': specifier: latest - version: 19.1.11 + version: 19.1.12 '@types/react-dom': specifier: latest - version: 19.1.7(@types/react@19.1.11) + version: 19.1.9(@types/react@19.1.12) '@types/use-sync-external-store': specifier: latest version: 1.5.0 '@vitejs/plugin-react': specifier: latest - version: 5.0.1(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0)) + version: 5.0.2(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0)) compression: specifier: ^1.7.5 version: 1.8.1 @@ -232,9 +232,6 @@ importers: express: specifier: ^5.1.0 version: 5.1.0 - sirv: - specifier: ^3.0.1 - version: 3.0.1 devDependencies: '@eslint/js': specifier: ^9.16.0 @@ -287,6 +284,9 @@ importers: prettier: specifier: ^3.4.2 version: 3.6.2 + sirv: + specifier: ^3.0.1 + version: 3.0.1 vite: specifier: npm:rolldown-vite@latest version: rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0) @@ -1103,13 +1103,13 @@ packages: '@types/node@24.0.13': resolution: {integrity: sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==} - '@types/react-dom@19.1.7': - resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==} + '@types/react-dom@19.1.9': + resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==} peerDependencies: '@types/react': ^19.0.0 - '@types/react@19.1.11': - resolution: {integrity: sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==} + '@types/react@19.1.12': + resolution: {integrity: sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==} '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -1191,8 +1191,8 @@ packages: peerDependencies: vite: ^4 || ^5 || ^6 || ^7 - '@vitejs/plugin-react@5.0.1': - resolution: {integrity: sha512-DE4UNaBXwtVoDJ0ccBdLVjFTWL70NRuWNCxEieTI3lrq9ORB9aOCQEKstwDXBl87NvFdbqh/p7eINGyj0BthJA==} + '@vitejs/plugin-react@5.0.2': + resolution: {integrity: sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -3701,15 +3701,15 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@babel/runtime': 7.27.6 '@testing-library/dom': 10.4.1 react: 19.1.1 react-dom: 19.1.1(react@19.1.1) optionalDependencies: - '@types/react': 19.1.11 - '@types/react-dom': 19.1.7(@types/react@19.1.11) + '@types/react': 19.1.12 + '@types/react-dom': 19.1.9(@types/react@19.1.12) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -3759,11 +3759,11 @@ snapshots: dependencies: undici-types: 7.8.0 - '@types/react-dom@19.1.7(@types/react@19.1.11)': + '@types/react-dom@19.1.9(@types/react@19.1.12)': dependencies: - '@types/react': 19.1.11 + '@types/react': 19.1.12 - '@types/react@19.1.11': + '@types/react@19.1.12': dependencies: csstype: 3.1.3 @@ -3878,12 +3878,12 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - '@vitejs/plugin-react@5.0.1(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0))': + '@vitejs/plugin-react@5.0.2(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0))': dependencies: '@babel/core': 7.28.3 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.3) '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.3) - '@rolldown/pluginutils': 1.0.0-beta.32 + '@rolldown/pluginutils': 1.0.0-beta.34 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 vite: rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0) @@ -3905,7 +3905,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.0.13)(@vitest/ui@3.2.4)(jsdom@26.1.0)(lightningcss@1.30.1)(msw@2.10.3(@types/node@24.0.13)(typescript@5.8.3))(yaml@2.8.0) + vitest: 3.2.4(@types/node@24.0.13)(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.3(@types/node@24.0.13)(typescript@5.8.3))(yaml@2.8.0) transitivePeerDependencies: - supports-color From 8c1429b6a11830b9a4f402c10c4c923a12aa80e7 Mon Sep 17 00:00:00 2001 From: minjaeleee Date: Wed, 3 Sep 2025 03:16:34 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20ssr=20=EB=9D=BC=EC=9A=B0=ED=84=B0?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/server.js | 39 ++++++- packages/vanilla/src/lib/ServerRouter.js | 125 +++++++++++++++++++++++ 2 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 packages/vanilla/src/lib/ServerRouter.js diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index d7b9022f..d5444f44 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -41,6 +41,40 @@ if (!prod) { app.use(base, sirv("dist/vanilla", { extensions: [] })); } +// 서버사이드 렌더링을 위한 라우터 설정 +const setupServerRoutes = async (url) => { + const { ServerRouter } = await import("./src/lib/ServerRouter.js"); + const { HomePage, ProductDetailPage, NotFoundPage } = await import("./src/pages/index.js"); + + const serverRouter = new ServerRouter(url, base); + + // 라우트 등록 (클라이언트와 동일) + serverRouter.addRoute("/", HomePage); + serverRouter.addRoute("/product/:id/", ProductDetailPage); + serverRouter.addRoute(".*", NotFoundPage); + + return serverRouter; +}; + +// 서버사이드 렌더링 함수 +const renderPage = async (url) => { + try { + const serverRouter = await setupServerRoutes(url); + const route = serverRouter.start(); + + if (!route) { + return `
페이지를 찾을 수 없습니다.
`; + } + + // 서버에서 컴포넌트 렌더링 + const PageComponent = route.handler; + return PageComponent(); + } catch (error) { + console.error("SSR 렌더링 오류:", error); + return `
서버 렌더링 중 오류가 발생했습니다.
`; + } +}; + // 렌더링 파이프라인 app.use("*all", async (req, res) => { try { @@ -56,11 +90,12 @@ app.use("*all", async (req, res) => { } } - // 렌더링 데이터 - 테스트용 + // 서버사이드 렌더링 + const appHtml = await renderPage(url); const appHead = `Vanilla Javascript SSR`; // Template 치환 - const finalHtml = html.replace("", appHead).replace("", html); + const finalHtml = html.replace("", appHead).replace("", appHtml); res.status(200).set({ "Content-Type": "text/html" }).end(finalHtml); } catch (e) { diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js new file mode 100644 index 00000000..93faa528 --- /dev/null +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -0,0 +1,125 @@ +/** + * 서버사이드 렌더링용 라우터 + * 브라우저 API에 의존하지 않고 URL 문자열만으로 동작 + */ +export class ServerRouter { + #routes; + #url; + #route; + #baseUrl; + + constructor(urlString, baseUrl = "") { + this.#routes = new Map(); + this.#url = new URL(urlString, "http://localhost"); + this.#baseUrl = baseUrl.replace(/\/$/, ""); + this.#route = null; + } + + get baseUrl() { + return this.#baseUrl; + } + + get query() { + return ServerRouter.parseQuery(this.#url.search); + } + + get params() { + return this.#route?.params ?? {}; + } + + get route() { + return this.#route; + } + + get target() { + return this.#route?.handler; + } + + get pathname() { + return this.#url.pathname; + } + + /** + * 라우트 등록 (클라이언트 Router와 동일한 로직) + * @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, + }); + } + + /** + * 현재 URL에 맞는 라우트 찾기 + */ + findRoute(pathname = this.#url.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; + } + + /** + * 라우터 시작 - 현재 URL에 맞는 라우트 찾기 + */ + start() { + this.#route = this.findRoute(); + return this.#route; + } + + /** + * 쿼리 파라미터를 객체로 파싱 + * @param {string} search - 쿼리 문자열 + * @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 - 쿼리 객체 + * @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(); + } +} From 8036f70840f92f0769bbebb68bcd6bf3b3a4dd8c Mon Sep 17 00:00:00 2001 From: minjaeleee Date: Wed, 3 Sep 2025 12:28:49 +0900 Subject: [PATCH 03/11] =?UTF-8?q?fix:=20SSR=20=EB=9D=BC=EC=9A=B0=ED=84=B0?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=EC=97=90=20=EB=94=B0=EB=9D=BC,=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20router=20class=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9D=84=20GlobalRouter=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/lib/Router.js | 10 +++++----- packages/vanilla/src/router/router.js | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/vanilla/src/lib/Router.js b/packages/vanilla/src/lib/Router.js index 2238a878..1eadb6a2 100644 --- a/packages/vanilla/src/lib/Router.js +++ b/packages/vanilla/src/lib/Router.js @@ -3,7 +3,7 @@ */ import { createObserver } from "./createObserver.js"; -export class Router { +export class GlobalRouter { #routes; #route; #observer = createObserver(); @@ -25,11 +25,11 @@ export class Router { } get query() { - return Router.parseQuery(window.location.search); + return GlobalRouter.parseQuery(window.location.search); } set query(newQuery) { - const newUrl = Router.getUrl(newQuery, this.#baseUrl); + const newUrl = GlobalRouter.getUrl(newQuery, this.#baseUrl); this.push(newUrl); } @@ -155,7 +155,7 @@ export class Router { }; static getUrl = (newQuery, baseUrl = "") => { - const currentQuery = Router.parseQuery(); + const currentQuery = GlobalRouter.parseQuery(); const updatedQuery = { ...currentQuery, ...newQuery }; // 빈 값들 제거 @@ -165,7 +165,7 @@ export class Router { } }); - const queryString = Router.stringifyQuery(updatedQuery); + const queryString = GlobalRouter.stringifyQuery(updatedQuery); return `${baseUrl}${window.location.pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`; }; } diff --git a/packages/vanilla/src/router/router.js b/packages/vanilla/src/router/router.js index d897ee76..a0e8c9fb 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -1,5 +1,5 @@ // 글로벌 라우터 인스턴스 -import { Router } from "../lib"; +import { GlobalRouter } from "../lib"; import { BASE_URL } from "../constants.js"; -export const router = new Router(BASE_URL); +export const router = new GlobalRouter(BASE_URL); From a46f9cf1c0238560911c31b2f77e44224983762d Mon Sep 17 00:00:00 2001 From: minjaeleee Date: Thu, 4 Sep 2025 01:05:35 +0900 Subject: [PATCH 04/11] =?UTF-8?q?fix:=20SSR=20=EB=9D=BC=EC=9A=B0=ED=8C=85?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20-=20serverRoute=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8A=B8=20=EB=93=B1=EB=A1=9D=20(=ED=85=8C=EC=8A=A4=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/server.js | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index d5444f44..04e8ed2f 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -44,14 +44,22 @@ if (!prod) { // 서버사이드 렌더링을 위한 라우터 설정 const setupServerRoutes = async (url) => { const { ServerRouter } = await import("./src/lib/ServerRouter.js"); - const { HomePage, ProductDetailPage, NotFoundPage } = await import("./src/pages/index.js"); const serverRouter = new ServerRouter(url, base); - - // 라우트 등록 (클라이언트와 동일) - serverRouter.addRoute("/", HomePage); - serverRouter.addRoute("/product/:id/", ProductDetailPage); - serverRouter.addRoute(".*", NotFoundPage); + // const { HomePage, ProductDetailPage, NotFoundPage } = await import("./src/pages/index.js"); + + // 테스트용 간단한 렌더링 함수들 + const serverHomePage = () => `
홈페이지 - 상품 목록
`; + const serverProductDetailPage = () => `
상품 상세 페이지
`; + const serverNotFoundPage = () => `
페이지를 찾을 수 없습니다.
`; + + // 테스트용 라우트 등록 (Node 환경에서 js 확장자 필요) + serverRouter.addRoute("/", serverHomePage); + serverRouter.addRoute("/product/:id/", serverProductDetailPage); + serverRouter.addRoute(".*", serverNotFoundPage); + // serverRouter.addRoute("/", HomePage); + // serverRouter.addRoute("/product/:id/", ProductDetailPage); + // serverRouter.addRoute(".*", NotFoundPage); return serverRouter; }; From 6b8140b65679da9850e85b7d49b94c4a33d24cce Mon Sep 17 00:00:00 2001 From: minjaeleee Date: Thu, 4 Sep 2025 01:22:06 +0900 Subject: [PATCH 05/11] =?UTF-8?q?wip:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A4=91=20-=20MSW=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=9C=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=94=84?= =?UTF-8?q?=EB=A6=AC=ED=8E=98=EC=B9=AD=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/server.js | 154 ++++++++++++++++++++-- packages/vanilla/src/mocks/handlers.js | 2 +- packages/vanilla/src/mocks/setupServer.js | 26 ++++ 3 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 packages/vanilla/src/mocks/setupServer.js diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index 04e8ed2f..e2a25a99 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -15,6 +15,10 @@ const template = readFileSync(join(__dirname, "index.html"), "utf-8"); const app = express(); +// MSW Mock Server 시작 +const { startMockServer } = await import("./src/mocks/setupServer.js"); +startMockServer(); + // Express 미들웨어 설정 app.use(compression()); app.use(express.static("public")); @@ -41,25 +45,146 @@ if (!prod) { app.use(base, sirv("dist/vanilla", { extensions: [] })); } +// 서버용 API 호출 함수들 +async function getProducts(params = {}) { + const searchParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + searchParams.set(key, String(value)); + } + }); + + const response = await fetch(`http://localhost/api/products?${searchParams}`); + return await response.json(); +} + +async function getProduct(productId) { + const response = await fetch(`http://localhost/api/products/${productId}`); + return await response.json(); +} + +async function getCategories() { + const response = await fetch("http://localhost/api/categories"); + return await response.json(); +} + +// 라우트별 데이터 프리페칭 +const prefetchData = async (route, params, query) => { + try { + switch (route.path) { + case "/": + // 홈페이지: 상품 목록 + 카테고리 + console.log("홈페이지 데이터 프리페칭 시작", query); + // eslint-disable-next-line no-case-declarations + const [productsResponse, categories] = await Promise.all([getProducts(query), getCategories()]); + return { + products: productsResponse.products, + totalCount: productsResponse.pagination.total, + categories, + currentProduct: null, + relatedProducts: [], + }; + + case "/product/:id/": + // 상품 상세: 상품 정보 + 관련 상품 + console.log("상품 상세 데이터 프리페칭 시작", params.id); + // eslint-disable-next-line no-case-declarations + const product = await getProduct(params.id); + + // 관련 상품 로드 (같은 category2) + // eslint-disable-next-line no-case-declarations + let relatedProducts = []; + if (product.category2) { + const relatedResponse = await getProducts({ + category2: product.category2, + limit: 20, + }); + relatedProducts = relatedResponse.products.filter((p) => p.productId !== product.productId); + } + + return { + products: [], + totalCount: 0, + categories: {}, + currentProduct: product, + relatedProducts, + }; + + default: + return { + products: [], + totalCount: 0, + categories: {}, + currentProduct: null, + relatedProducts: [], + }; + } + } catch (error) { + console.error("데이터 프리페칭 오류:", error); + return { + products: [], + totalCount: 0, + categories: {}, + currentProduct: null, + relatedProducts: [], + }; + } +}; + // 서버사이드 렌더링을 위한 라우터 설정 const setupServerRoutes = async (url) => { const { ServerRouter } = await import("./src/lib/ServerRouter.js"); const serverRouter = new ServerRouter(url, base); - // const { HomePage, ProductDetailPage, NotFoundPage } = await import("./src/pages/index.js"); - // 테스트용 간단한 렌더링 함수들 - const serverHomePage = () => `
홈페이지 - 상품 목록
`; - const serverProductDetailPage = () => `
상품 상세 페이지
`; + // 데이터를 포함한 렌더링 함수들 + const serverHomePage = (data) => { + const { products, totalCount, categories } = data || {}; + return ` +
+

홈페이지 - 상품 목록 (SSR + MSW)

+

총 ${totalCount || 0}개 상품

+

카테고리 수: ${categories ? Object.keys(categories).length : 0}개

+

현재 페이지 상품: ${products ? products.length : 0}개

+ ${ + products && products.length > 0 + ? ` +
    + ${products + .slice(0, 5) + .map((p) => `
  • ${p.title} - ${Number(p.lprice).toLocaleString()}원
  • `) + .join("")} + ${products.length > 5 ? "
  • ...
  • " : ""} +
+ ` + : "

상품이 없습니다.

" + } +
+ `; + }; + + const serverProductDetailPage = (data) => { + const { currentProduct, relatedProducts } = data || {}; + if (!currentProduct) return `
상품을 찾을 수 없습니다.
`; + + return ` +
+

상품 상세 페이지 (SSR + MSW)

+

${currentProduct.title}

+

가격: ${Number(currentProduct.lprice).toLocaleString()}원

+

브랜드: ${currentProduct.brand}

+

카테고리: ${currentProduct.category1} > ${currentProduct.category2}

+ ${relatedProducts && relatedProducts.length > 0 ? `

관련 상품: ${relatedProducts.length}개

` : ""} +
+ `; + }; + const serverNotFoundPage = () => `
페이지를 찾을 수 없습니다.
`; - // 테스트용 라우트 등록 (Node 환경에서 js 확장자 필요) + // 라우트 등록 serverRouter.addRoute("/", serverHomePage); serverRouter.addRoute("/product/:id/", serverProductDetailPage); serverRouter.addRoute(".*", serverNotFoundPage); - // serverRouter.addRoute("/", HomePage); - // serverRouter.addRoute("/product/:id/", ProductDetailPage); - // serverRouter.addRoute(".*", NotFoundPage); return serverRouter; }; @@ -74,9 +199,18 @@ const renderPage = async (url) => { return `
페이지를 찾을 수 없습니다.
`; } - // 서버에서 컴포넌트 렌더링 + // 데이터 프리페칭 + const initialData = await prefetchData(route, serverRouter.params, serverRouter.query); + console.log("프리페칭된 데이터:", { + route: route.path, + params: serverRouter.params, + query: serverRouter.query, + dataKeys: Object.keys(initialData), + }); + + // 서버에서 컴포넌트 렌더링 (데이터와 함께) const PageComponent = route.handler; - return PageComponent(); + return PageComponent(initialData); } catch (error) { console.error("SSR 렌더링 오류:", error); return `
서버 렌더링 중 오류가 발생했습니다.
`; diff --git a/packages/vanilla/src/mocks/handlers.js b/packages/vanilla/src/mocks/handlers.js index 6e3035e6..9836e0f2 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)); diff --git a/packages/vanilla/src/mocks/setupServer.js b/packages/vanilla/src/mocks/setupServer.js new file mode 100644 index 00000000..e4c5f6c1 --- /dev/null +++ b/packages/vanilla/src/mocks/setupServer.js @@ -0,0 +1,26 @@ +/** + * 서버용 MSW 설정 + */ +import { setupServer } from "msw/node"; +import { handlers } from "./handlers.js"; + +// MSW 서버 인스턴스 생성 +export const mockServer = setupServer(...handlers); + +/** + * MSW 서버 시작 + */ +export function startMockServer() { + mockServer.listen({ + onUnhandledRequest: "bypass", // 처리되지 않은 요청은 통과 + }); + console.log("MSW Mock Server started"); +} + +/** + * MSW 서버 종료 + */ +export function stopMockServer() { + mockServer.close(); + console.log("MSW Mock Server stopped"); +} From 80875a4bfc38f1788027bc08dd7ccb8c328d8d90 Mon Sep 17 00:00:00 2001 From: minjaeleee Date: Thu, 4 Sep 2025 22:46:34 +0900 Subject: [PATCH 06/11] =?UTF-8?q?Revert=20"wip:=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A4=91=20-=20MSW=EB=A5=BC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=20=EC=84=9C=EB=B2=84=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=94=84=EB=A6=AC=ED=8E=98=EC=B9=AD=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EC=A4=91"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 6b8140b65679da9850e85b7d49b94c4a33d24cce. --- packages/vanilla/server.js | 154 ++-------------------- packages/vanilla/src/mocks/handlers.js | 2 +- packages/vanilla/src/mocks/setupServer.js | 26 ---- 3 files changed, 11 insertions(+), 171 deletions(-) delete mode 100644 packages/vanilla/src/mocks/setupServer.js diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index e2a25a99..04e8ed2f 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -15,10 +15,6 @@ const template = readFileSync(join(__dirname, "index.html"), "utf-8"); const app = express(); -// MSW Mock Server 시작 -const { startMockServer } = await import("./src/mocks/setupServer.js"); -startMockServer(); - // Express 미들웨어 설정 app.use(compression()); app.use(express.static("public")); @@ -45,146 +41,25 @@ if (!prod) { app.use(base, sirv("dist/vanilla", { extensions: [] })); } -// 서버용 API 호출 함수들 -async function getProducts(params = {}) { - const searchParams = new URLSearchParams(); - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== "") { - searchParams.set(key, String(value)); - } - }); - - const response = await fetch(`http://localhost/api/products?${searchParams}`); - return await response.json(); -} - -async function getProduct(productId) { - const response = await fetch(`http://localhost/api/products/${productId}`); - return await response.json(); -} - -async function getCategories() { - const response = await fetch("http://localhost/api/categories"); - return await response.json(); -} - -// 라우트별 데이터 프리페칭 -const prefetchData = async (route, params, query) => { - try { - switch (route.path) { - case "/": - // 홈페이지: 상품 목록 + 카테고리 - console.log("홈페이지 데이터 프리페칭 시작", query); - // eslint-disable-next-line no-case-declarations - const [productsResponse, categories] = await Promise.all([getProducts(query), getCategories()]); - return { - products: productsResponse.products, - totalCount: productsResponse.pagination.total, - categories, - currentProduct: null, - relatedProducts: [], - }; - - case "/product/:id/": - // 상품 상세: 상품 정보 + 관련 상품 - console.log("상품 상세 데이터 프리페칭 시작", params.id); - // eslint-disable-next-line no-case-declarations - const product = await getProduct(params.id); - - // 관련 상품 로드 (같은 category2) - // eslint-disable-next-line no-case-declarations - let relatedProducts = []; - if (product.category2) { - const relatedResponse = await getProducts({ - category2: product.category2, - limit: 20, - }); - relatedProducts = relatedResponse.products.filter((p) => p.productId !== product.productId); - } - - return { - products: [], - totalCount: 0, - categories: {}, - currentProduct: product, - relatedProducts, - }; - - default: - return { - products: [], - totalCount: 0, - categories: {}, - currentProduct: null, - relatedProducts: [], - }; - } - } catch (error) { - console.error("데이터 프리페칭 오류:", error); - return { - products: [], - totalCount: 0, - categories: {}, - currentProduct: null, - relatedProducts: [], - }; - } -}; - // 서버사이드 렌더링을 위한 라우터 설정 const setupServerRoutes = async (url) => { const { ServerRouter } = await import("./src/lib/ServerRouter.js"); const serverRouter = new ServerRouter(url, base); + // const { HomePage, ProductDetailPage, NotFoundPage } = await import("./src/pages/index.js"); - // 데이터를 포함한 렌더링 함수들 - const serverHomePage = (data) => { - const { products, totalCount, categories } = data || {}; - return ` -
-

홈페이지 - 상품 목록 (SSR + MSW)

-

총 ${totalCount || 0}개 상품

-

카테고리 수: ${categories ? Object.keys(categories).length : 0}개

-

현재 페이지 상품: ${products ? products.length : 0}개

- ${ - products && products.length > 0 - ? ` -
    - ${products - .slice(0, 5) - .map((p) => `
  • ${p.title} - ${Number(p.lprice).toLocaleString()}원
  • `) - .join("")} - ${products.length > 5 ? "
  • ...
  • " : ""} -
- ` - : "

상품이 없습니다.

" - } -
- `; - }; - - const serverProductDetailPage = (data) => { - const { currentProduct, relatedProducts } = data || {}; - if (!currentProduct) return `
상품을 찾을 수 없습니다.
`; - - return ` -
-

상품 상세 페이지 (SSR + MSW)

-

${currentProduct.title}

-

가격: ${Number(currentProduct.lprice).toLocaleString()}원

-

브랜드: ${currentProduct.brand}

-

카테고리: ${currentProduct.category1} > ${currentProduct.category2}

- ${relatedProducts && relatedProducts.length > 0 ? `

관련 상품: ${relatedProducts.length}개

` : ""} -
- `; - }; - + // 테스트용 간단한 렌더링 함수들 + const serverHomePage = () => `
홈페이지 - 상품 목록
`; + const serverProductDetailPage = () => `
상품 상세 페이지
`; const serverNotFoundPage = () => `
페이지를 찾을 수 없습니다.
`; - // 라우트 등록 + // 테스트용 라우트 등록 (Node 환경에서 js 확장자 필요) serverRouter.addRoute("/", serverHomePage); serverRouter.addRoute("/product/:id/", serverProductDetailPage); serverRouter.addRoute(".*", serverNotFoundPage); + // serverRouter.addRoute("/", HomePage); + // serverRouter.addRoute("/product/:id/", ProductDetailPage); + // serverRouter.addRoute(".*", NotFoundPage); return serverRouter; }; @@ -199,18 +74,9 @@ const renderPage = async (url) => { return `
페이지를 찾을 수 없습니다.
`; } - // 데이터 프리페칭 - const initialData = await prefetchData(route, serverRouter.params, serverRouter.query); - console.log("프리페칭된 데이터:", { - route: route.path, - params: serverRouter.params, - query: serverRouter.query, - dataKeys: Object.keys(initialData), - }); - - // 서버에서 컴포넌트 렌더링 (데이터와 함께) + // 서버에서 컴포넌트 렌더링 const PageComponent = route.handler; - return PageComponent(initialData); + return PageComponent(); } catch (error) { console.error("SSR 렌더링 오류:", error); return `
서버 렌더링 중 오류가 발생했습니다.
`; diff --git a/packages/vanilla/src/mocks/handlers.js b/packages/vanilla/src/mocks/handlers.js index 9836e0f2..6e3035e6 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" with { type: "json" }; +import items from "./items.json"; const delay = async () => await new Promise((resolve) => setTimeout(resolve, 200)); diff --git a/packages/vanilla/src/mocks/setupServer.js b/packages/vanilla/src/mocks/setupServer.js deleted file mode 100644 index e4c5f6c1..00000000 --- a/packages/vanilla/src/mocks/setupServer.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * 서버용 MSW 설정 - */ -import { setupServer } from "msw/node"; -import { handlers } from "./handlers.js"; - -// MSW 서버 인스턴스 생성 -export const mockServer = setupServer(...handlers); - -/** - * MSW 서버 시작 - */ -export function startMockServer() { - mockServer.listen({ - onUnhandledRequest: "bypass", // 처리되지 않은 요청은 통과 - }); - console.log("MSW Mock Server started"); -} - -/** - * MSW 서버 종료 - */ -export function stopMockServer() { - mockServer.close(); - console.log("MSW Mock Server stopped"); -} From a736dd402dcb21d4cce83692bc27a55ae046e7f3 Mon Sep 17 00:00:00 2001 From: minjaeleee Date: Fri, 5 Sep 2025 01:22:50 +0900 Subject: [PATCH 07/11] =?UTF-8?q?fix:=20=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=94=84=EB=A6=AC=ED=8E=98=EC=B9=AD,=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=EC=83=81=ED=83=9C,=20html=EB=A5=BC=20main-server.js=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=B2=98=EB=A6=AC=ED=95=98=EA=B8=B0=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20server.js=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/server.js | 84 +++++++++----------------------------- 1 file changed, 20 insertions(+), 64 deletions(-) diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index 04e8ed2f..8df4364d 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -15,97 +15,53 @@ const template = readFileSync(join(__dirname, "index.html"), "utf-8"); const app = express(); -// Express 미들웨어 설정 -app.use(compression()); -app.use(express.static("public")); - +let vite; // 환경 분기 if (!prod) { // 개발 환경: Vite dev server const { createServer: createViteServer } = await import("vite"); - const vite = await createViteServer({ + vite = await createViteServer({ server: { middlewareMode: true }, appType: "custom", }); // Vite의 미들웨어 사용 app.use(vite.middlewares); - - // Vite 인스턴스를 res.locals에 저장 - app.use((req, res, next) => { - res.locals.vite = vite; - next(); - }); } else { + app.use(compression()); // 프로덕션 환경: sirv로 정적 파일 서빙 app.use(base, sirv("dist/vanilla", { extensions: [] })); } -// 서버사이드 렌더링을 위한 라우터 설정 -const setupServerRoutes = async (url) => { - const { ServerRouter } = await import("./src/lib/ServerRouter.js"); - - const serverRouter = new ServerRouter(url, base); - // const { HomePage, ProductDetailPage, NotFoundPage } = await import("./src/pages/index.js"); - - // 테스트용 간단한 렌더링 함수들 - const serverHomePage = () => `
홈페이지 - 상품 목록
`; - const serverProductDetailPage = () => `
상품 상세 페이지
`; - const serverNotFoundPage = () => `
페이지를 찾을 수 없습니다.
`; - - // 테스트용 라우트 등록 (Node 환경에서 js 확장자 필요) - serverRouter.addRoute("/", serverHomePage); - serverRouter.addRoute("/product/:id/", serverProductDetailPage); - serverRouter.addRoute(".*", serverNotFoundPage); - // serverRouter.addRoute("/", HomePage); - // serverRouter.addRoute("/product/:id/", ProductDetailPage); - // serverRouter.addRoute(".*", NotFoundPage); - - return serverRouter; -}; - -// 서버사이드 렌더링 함수 -const renderPage = async (url) => { - try { - const serverRouter = await setupServerRoutes(url); - const route = serverRouter.start(); - - if (!route) { - return `
페이지를 찾을 수 없습니다.
`; - } - - // 서버에서 컴포넌트 렌더링 - const PageComponent = route.handler; - return PageComponent(); - } catch (error) { - console.error("SSR 렌더링 오류:", error); - return `
서버 렌더링 중 오류가 발생했습니다.
`; - } -}; - // 렌더링 파이프라인 app.use("*all", async (req, res) => { try { const url = req.originalUrl; - let html = template; + let htmlTemplate = template; + let render; // 개발 환경에서 Vite transform 적용 if (!prod) { - const vite = res.locals.vite; - if (vite) { - html = await vite.transformIndexHtml(url, html); - } + htmlTemplate = await vite.transformIndexHtml(url, template); + render = (await vite.ssrLoadModule("/src/main-server.js")).render; + } else { + render = (await import("./dist/vanilla-ssr/main-server.js")).render; } - // 서버사이드 렌더링 - const appHtml = await renderPage(url); - const appHead = `Vanilla Javascript SSR`; + const rendered = await render(url); - // Template 치환 - const finalHtml = html.replace("", appHead).replace("", appHtml); + const initialDataScript = rendered.initialData + ? `` + : ""; - res.status(200).set({ "Content-Type": "text/html" }).end(finalHtml); + const html = htmlTemplate + .replace(``, rendered.head ?? "") + .replace(``, rendered.html ?? "") + .replace(``, `${initialDataScript}`); + + // Template 치환 + res.status(200).set({ "Content-Type": "text/html" }).send(html); } catch (e) { console.error(e.stack); res.status(500).end(e.stack); From 95a67f997464c843fc3cc7a9015d0315d155b904 Mon Sep 17 00:00:00 2001 From: minjaeleee Date: Fri, 5 Sep 2025 02:22:02 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat:=20SSR=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=ED=94=84=EB=A6=AC=ED=8E=98=EC=B9=AD=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(main-server.js)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/main-server.js | 218 +++++++++++++++++- packages/vanilla/src/mocks/server.js | 119 ++++++++++ packages/vanilla/src/pages/ServerPages.js | 256 ++++++++++++++++++++++ 3 files changed, 590 insertions(+), 3 deletions(-) create mode 100644 packages/vanilla/src/mocks/server.js create mode 100644 packages/vanilla/src/pages/ServerPages.js diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 40b58858..2d019ab1 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,4 +1,216 @@ -export const render = async (url, query) => { - console.log({ url, query }); - return ""; +import { ServerRouter } from "./lib/ServerRouter.js"; +import { mockGetProducts, mockGetProduct, mockGetCategories } from "./mocks/server.js"; +import { productStore } from "./stores/productStore.js"; +import { PRODUCT_ACTIONS } from "./stores/actionTypes.js"; + +// 스토어와 통합된 데이터 프리페칭 함수 +async function prefetchData(route, query, params) { + try { + if (route.path === "/") { + // 홈페이지: 상품 목록과 카테고리 데이터 미리 로드 + const productsData = mockGetProducts(query); + const categories = mockGetCategories(); + + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: productsData.products, + categories, + totalCount: productsData.pagination.total, + loading: false, + status: "done", + error: null, + }, + }); + + return { + products: productsData.products, + categories, + totalCount: productsData.pagination.total, + }; + } else if (route.path === "/product/:id/") { + // 상품 상세 페이지: 해당 상품 데이터 미리 로드 + const productId = params.id; + const product = mockGetProduct(productId); + + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_CURRENT_PRODUCT, + payload: product, + }); + + // 관련 상품도 로드 + let relatedProducts = []; + if (product.category2) { + try { + const relatedData = mockGetProducts({ + category2: product.category2, + limit: 20, + page: 1, + }); + relatedProducts = relatedData.products.filter((p) => p.productId !== productId); + + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_RELATED_PRODUCTS, + payload: relatedProducts, + }); + } catch (error) { + console.error("관련 상품 로드 실패:", error); + } + } + + return { + currentProduct: product, + relatedProducts, + }; + } + return null; + } catch (error) { + console.error("데이터 프리페칭 오류:", error); + return null; + } +} + +// 라우트 핸들러들 +const routeHandlers = { + // 홈페이지 + "/": async (url, params, query) => { + try { + // 라우트 정보 객체 생성 + const route = { path: "/" }; + + // 데이터 프리페칭 및 스토어 업데이트 + const prefetchedData = await prefetchData(route, query, params); + + if (!prefetchedData) { + throw new Error("데이터 프리페칭 실패"); + } + + // 스토어에서 현재 상태 가져오기 + const productState = productStore.getState(); + + const initialData = { + products: productState.products || [], + totalCount: productState.totalCount || 0, + categories: productState.categories || {}, + currentProduct: null, + relatedProducts: [], + loading: false, + error: null, + status: "done", + }; + + // 서버사이드 HTML 렌더링 + const { ServerHomePage } = await import("./pages/ServerPages.js"); + const html = ServerHomePage(initialData); + + return { + head: `쇼핑몰 - 홈`, + html, + initialData, + }; + } catch (error) { + console.error("홈페이지 렌더링 오류:", error); + return { + head: `오류 - 쇼핑몰`, + html: `
서버 렌더링 중 오류가 발생했습니다: ${error.message}
`, + initialData: { error: error.message }, + }; + } + }, + + // 상품 상세 페이지 + "/product/:id/": async (url, params, query) => { + try { + // 라우트 정보 객체 생성 + const route = { path: "/product/:id/" }; + + // 데이터 프리페칭 및 스토어 업데이트 + const prefetchedData = await prefetchData(route, query, params); + + if (!prefetchedData) { + throw new Error("상품 데이터를 찾을 수 없습니다"); + } + + // 스토어에서 현재 상태 가져오기 + const productState = productStore.getState(); + + const initialData = { + products: [], + totalCount: 0, + categories: productState.categories || {}, + currentProduct: productState.currentProduct, + relatedProducts: productState.relatedProducts || [], + loading: false, + error: null, + status: "done", + }; + + // 서버사이드 HTML 렌더링 + const { ServerProductDetailPage } = await import("./pages/ServerPages.js"); + const html = ServerProductDetailPage(initialData); + + return { + head: `${productState.currentProduct?.title || "상품"} - 쇼핑몰`, + html, + initialData, + }; + } catch (error) { + console.error("상품 상세 렌더링 오류:", error); + return { + head: `상품을 찾을 수 없습니다 - 쇼핑몰`, + html: `
상품을 찾을 수 없습니다: ${error.message}
`, + initialData: { error: error.message }, + }; + } + }, + + // 404 페이지 + ".*": async () => { + return { + head: `404 - 페이지를 찾을 수 없습니다`, + html: ` +
+
+

404 - 페이지를 찾을 수 없습니다

+ 홈으로 돌아가기 +
+
+ `, + initialData: { error: "Page not found" }, + }; + }, +}; + +export const render = async (url) => { + try { + const serverRouter = new ServerRouter(url); + + // 라우트 등록 + Object.entries(routeHandlers).forEach(([path, handler]) => { + serverRouter.addRoute(path, handler); + }); + + // 라우팅 시작 + const route = serverRouter.start(); + + if (!route) { + return routeHandlers[".*"](); + } + + // URL 쿼리 파라미터 가져오기 + const mergedQuery = { ...serverRouter.query }; + + // 라우트 핸들러 실행 + const result = await route.handler(url, serverRouter.params, mergedQuery); + console.log("result", result); + + return result; + } catch (error) { + console.error("SSR 렌더링 오류:", error); + return { + head: `서버 오류 - 쇼핑몰`, + html: `
서버 렌더링 중 오류가 발생했습니다: ${error.message}
`, + initialData: { error: error.message }, + }; + } }; diff --git a/packages/vanilla/src/mocks/server.js b/packages/vanilla/src/mocks/server.js new file mode 100644 index 00000000..0ed5876e --- /dev/null +++ b/packages/vanilla/src/mocks/server.js @@ -0,0 +1,119 @@ +import items from "./items.json" with { type: "json" }; + +// 카테고리 추출 함수 +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; +} + +export function mockGetProducts(params = {}) { + const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; + const page = params.current ?? 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, + }, + filters: { + search, + category1, + category2, + sort, + }, + }; +} + +export function mockGetProduct(productId) { + const product = items.find((item) => item.productId === productId); + + if (!product) { + throw new Error("Product not found"); + } + + // 상세 정보에 추가 데이터 포함 + return { + ...product, + description: `${product.title}에 대한 상세 설명입니다. ${product.brand} 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.`, + rating: Math.floor(Math.random() * 2) + 4, // 4~5점 랜덤 + reviewCount: Math.floor(Math.random() * 1000) + 50, // 50~1050개 랜덤 + stock: Math.floor(Math.random() * 100) + 10, // 10~110개 랜덤 + images: [product.image, product.image.replace(".jpg", "_2.jpg"), product.image.replace(".jpg", "_3.jpg")], + }; +} + +export function mockGetCategories() { + return getUniqueCategories(); +} diff --git a/packages/vanilla/src/pages/ServerPages.js b/packages/vanilla/src/pages/ServerPages.js new file mode 100644 index 00000000..f5a02424 --- /dev/null +++ b/packages/vanilla/src/pages/ServerPages.js @@ -0,0 +1,256 @@ +/** + * 서버사이드 렌더링 전용 페이지 컴포넌트들 + * 클라이언트 의존성 없이 순수 HTML 문자열 반환 + */ + +// 서버용 PageWrapper (클라이언트 의존성 제거) +const ServerPageWrapper = ({ headerLeft = "", children = "" }) => { + return ` +
+ +
+
+
+ ${headerLeft} +
+
+ +
+
+
+ + +
+ ${children} +
+
+ `; +}; + +// 서버용 SearchBar 컴포넌트 +const ServerSearchBar = ({ searchQuery = "", sort = "price_asc", category = {}, categories = {} }) => { + const { category1 = "" } = category; + + return ` +
+ +
+
+ + + + +
+
+ + +
+ + + + + +
+
+ `; +}; + +// 서버용 ProductList 컴포넌트 +const ServerProductList = ({ products = [], loading = false, error = null, totalCount = 0 }) => { + if (loading) { + return `
로딩 중...
`; + } + + if (error) { + return `
오류: ${error}
`; + } + + if (products.length === 0) { + return `
상품이 없습니다.
`; + } + + return ` +
+ + + + +
+ 총 ${totalCount}개 상품 중 ${products.length}개 표시 +
+
+ `; +}; + +// 서버용 홈페이지 +export const ServerHomePage = (initialData) => { + const { products = [], totalCount = 0, categories = {}, loading = false, error = null } = initialData || {}; + const query = { search: "", limit: 20, sort: "price_asc", category1: "", category2: "" }; + const { search: searchQuery, limit, sort, category1, category2 } = query; + const category = { category1, category2 }; + + return ServerPageWrapper({ + headerLeft: ` +

+ 쇼핑몰 (SSR) +

+ `, + children: ` + + ${ServerSearchBar({ searchQuery, limit, sort, category, categories })} + + +
+ ${ServerProductList({ products, loading, error, totalCount })} +
+ `, + }); +}; + +// 서버용 상품 상세 페이지 +export const ServerProductDetailPage = (initialData) => { + const { currentProduct: product, relatedProducts = [], error, loading = false } = initialData || {}; + + if (loading) { + return ServerPageWrapper({ + headerLeft: `

상품 상세

`, + children: `
로딩 중...
`, + }); + } + + if (error && !product) { + return ServerPageWrapper({ + headerLeft: `

상품 상세

`, + children: ` +
+

상품을 찾을 수 없습니다

+

${error || "요청하신 상품이 존재하지 않습니다."}

+ 홈으로 +
+ `, + }); + } + + if (!product) { + return ServerPageWrapper({ + headerLeft: `

상품 상세

`, + children: `
상품 정보를 불러올 수 없습니다.
`, + }); + } + + const price = Number(product.lprice); + + return ServerPageWrapper({ + headerLeft: ` +
+ +

상품 상세 (SSR)

+
+ `, + children: ` + +
+
+
+ ${product.title} +
+ +
+

${product.brand}

+

${product.title}

+
+ ${price.toLocaleString()}원 +
+
+ 카테고리: ${product.category1} > ${product.category2} +
+
+
+ + +
+ +
+
+ + + ${ + relatedProducts.length > 0 + ? ` +
+
+

관련 상품

+

같은 카테고리의 다른 상품들 (${relatedProducts.length}개)

+
+
+
+ ${relatedProducts + .slice(0, 4) + .map( + (relatedProduct) => ` + +
+ ${relatedProduct.title} +
+

${relatedProduct.title}

+

${Number(relatedProduct.lprice).toLocaleString()}원

+
+ `, + ) + .join("")} +
+
+
+ ` + : "" + } + `, + }); +}; From 1f99761df0f8a00364eb16fbe63616eaf7fe94e2 Mon Sep 17 00:00:00 2001 From: minjaeleee Date: Fri, 5 Sep 2025 03:14:02 +0900 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20SSR=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=8B=9C,=20=EC=A0=84=EC=97=AD=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/lib/Router.js | 33 ++- packages/vanilla/src/lib/createStorage.js | 7 +- packages/vanilla/src/pages/ServerPages.js | 256 ---------------------- 3 files changed, 26 insertions(+), 270 deletions(-) delete mode 100644 packages/vanilla/src/pages/ServerPages.js diff --git a/packages/vanilla/src/lib/Router.js b/packages/vanilla/src/lib/Router.js index 1eadb6a2..cfe43fff 100644 --- a/packages/vanilla/src/lib/Router.js +++ b/packages/vanilla/src/lib/Router.js @@ -14,10 +14,13 @@ export class GlobalRouter { this.#route = null; this.#baseUrl = baseUrl.replace(/\/$/, ""); - window.addEventListener("popstate", () => { - this.#route = this.#findRoute(); - this.#observer.notify(); - }); + // 브라우저 환경에서만 popstate 이벤트 리스너 등록 + if (typeof window !== "undefined") { + window.addEventListener("popstate", () => { + this.#route = this.#findRoute(); + this.#observer.notify(); + }); + } } get baseUrl() { @@ -25,6 +28,7 @@ export class GlobalRouter { } get query() { + if (typeof window === "undefined") return {}; return GlobalRouter.parseQuery(window.location.search); } @@ -73,8 +77,9 @@ export class GlobalRouter { }); } - #findRoute(url = window.location.pathname) { - const { pathname } = new URL(url, window.location.origin); + #findRoute(url = typeof window !== "undefined" ? window.location.pathname : "/") { + const { pathname } = + typeof window !== "undefined" ? new URL(url, window.location.origin) : new URL(url, "http://localhost"); for (const [routePath, route] of this.#routes) { const match = pathname.match(route.regex); if (match) { @@ -103,11 +108,14 @@ export class GlobalRouter { // baseUrl이 없으면 자동으로 붙여줌 let fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url); - const prevFullUrl = `${window.location.pathname}${window.location.search}`; + // 브라우저 환경에서만 히스토리 업데이트 + if (typeof window !== "undefined") { + const prevFullUrl = `${window.location.pathname}${window.location.search}`; - // 히스토리 업데이트 - if (prevFullUrl !== fullUrl) { - window.history.pushState(null, "", fullUrl); + // 히스토리 업데이트 + if (prevFullUrl !== fullUrl) { + window.history.pushState(null, "", fullUrl); + } } this.#route = this.#findRoute(fullUrl); @@ -130,7 +138,7 @@ export class GlobalRouter { * @param {string} search - location.search 또는 쿼리 문자열 * @returns {Object} 파싱된 쿼리 객체 */ - static parseQuery = (search = window.location.search) => { + static parseQuery = (search = typeof window !== "undefined" ? window.location.search : "") => { const params = new URLSearchParams(search); const query = {}; for (const [key, value] of params) { @@ -166,6 +174,7 @@ export class GlobalRouter { }); const queryString = GlobalRouter.stringifyQuery(updatedQuery); - return `${baseUrl}${window.location.pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`; + const pathname = typeof window !== "undefined" ? window.location.pathname.replace(baseUrl, "") : "/"; + return `${baseUrl}${pathname}${queryString ? `?${queryString}` : ""}`; }; } diff --git a/packages/vanilla/src/lib/createStorage.js b/packages/vanilla/src/lib/createStorage.js index 08b504f2..8c3f8386 100644 --- a/packages/vanilla/src/lib/createStorage.js +++ b/packages/vanilla/src/lib/createStorage.js @@ -1,11 +1,12 @@ /** * 로컬스토리지 추상화 함수 * @param {string} key - 스토리지 키 - * @param {Storage} storage - 기본값은 localStorage + * @param {Storage} storage - 기본값은 localStorage (브라우저 환경에서만) * @returns {Object} { get, set, reset } */ -export const createStorage = (key, storage = window.localStorage) => { +export const createStorage = (key, storage = typeof window !== "undefined" ? window.localStorage : null) => { const get = () => { + if (!storage) return null; // 서버 환경에서는 null 반환 try { const item = storage.getItem(key); return item ? JSON.parse(item) : null; @@ -16,6 +17,7 @@ export const createStorage = (key, storage = window.localStorage) => { }; const set = (value) => { + if (!storage) return; // 서버 환경에서는 아무것도 하지 않음 try { storage.setItem(key, JSON.stringify(value)); } catch (error) { @@ -24,6 +26,7 @@ export const createStorage = (key, storage = window.localStorage) => { }; const reset = () => { + if (!storage) return; // 서버 환경에서는 아무것도 하지 않음 try { storage.removeItem(key); } catch (error) { diff --git a/packages/vanilla/src/pages/ServerPages.js b/packages/vanilla/src/pages/ServerPages.js deleted file mode 100644 index f5a02424..00000000 --- a/packages/vanilla/src/pages/ServerPages.js +++ /dev/null @@ -1,256 +0,0 @@ -/** - * 서버사이드 렌더링 전용 페이지 컴포넌트들 - * 클라이언트 의존성 없이 순수 HTML 문자열 반환 - */ - -// 서버용 PageWrapper (클라이언트 의존성 제거) -const ServerPageWrapper = ({ headerLeft = "", children = "" }) => { - return ` -
- -
-
-
- ${headerLeft} -
-
- -
-
-
- - -
- ${children} -
-
- `; -}; - -// 서버용 SearchBar 컴포넌트 -const ServerSearchBar = ({ searchQuery = "", sort = "price_asc", category = {}, categories = {} }) => { - const { category1 = "" } = category; - - return ` -
- -
-
- - - - -
-
- - -
- - - - - -
-
- `; -}; - -// 서버용 ProductList 컴포넌트 -const ServerProductList = ({ products = [], loading = false, error = null, totalCount = 0 }) => { - if (loading) { - return `
로딩 중...
`; - } - - if (error) { - return `
오류: ${error}
`; - } - - if (products.length === 0) { - return `
상품이 없습니다.
`; - } - - return ` -
- - - - -
- 총 ${totalCount}개 상품 중 ${products.length}개 표시 -
-
- `; -}; - -// 서버용 홈페이지 -export const ServerHomePage = (initialData) => { - const { products = [], totalCount = 0, categories = {}, loading = false, error = null } = initialData || {}; - const query = { search: "", limit: 20, sort: "price_asc", category1: "", category2: "" }; - const { search: searchQuery, limit, sort, category1, category2 } = query; - const category = { category1, category2 }; - - return ServerPageWrapper({ - headerLeft: ` -

- 쇼핑몰 (SSR) -

- `, - children: ` - - ${ServerSearchBar({ searchQuery, limit, sort, category, categories })} - - -
- ${ServerProductList({ products, loading, error, totalCount })} -
- `, - }); -}; - -// 서버용 상품 상세 페이지 -export const ServerProductDetailPage = (initialData) => { - const { currentProduct: product, relatedProducts = [], error, loading = false } = initialData || {}; - - if (loading) { - return ServerPageWrapper({ - headerLeft: `

상품 상세

`, - children: `
로딩 중...
`, - }); - } - - if (error && !product) { - return ServerPageWrapper({ - headerLeft: `

상품 상세

`, - children: ` -
-

상품을 찾을 수 없습니다

-

${error || "요청하신 상품이 존재하지 않습니다."}

- 홈으로 -
- `, - }); - } - - if (!product) { - return ServerPageWrapper({ - headerLeft: `

상품 상세

`, - children: `
상품 정보를 불러올 수 없습니다.
`, - }); - } - - const price = Number(product.lprice); - - return ServerPageWrapper({ - headerLeft: ` -
- -

상품 상세 (SSR)

-
- `, - children: ` - -
-
-
- ${product.title} -
- -
-

${product.brand}

-

${product.title}

-
- ${price.toLocaleString()}원 -
-
- 카테고리: ${product.category1} > ${product.category2} -
-
-
- - -
- -
-
- - - ${ - relatedProducts.length > 0 - ? ` -
-
-

관련 상품

-

같은 카테고리의 다른 상품들 (${relatedProducts.length}개)

-
-
-
- ${relatedProducts - .slice(0, 4) - .map( - (relatedProduct) => ` - -
- ${relatedProduct.title} -
-

${relatedProduct.title}

-

${Number(relatedProduct.lprice).toLocaleString()}원

-
- `, - ) - .join("")} -
-
-
- ` - : "" - } - `, - }); -}; From e897548230eca6020fc91d968fd64453b9c5ca55 Mon Sep 17 00:00:00 2001 From: minjaeleee Date: Fri, 5 Sep 2025 03:27:23 +0900 Subject: [PATCH 10/11] =?UTF-8?q?feat:=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=20=EC=82=AC=EC=9D=B4=EB=93=9C=20window.=5F?= =?UTF-8?q?=5FINITIAL=5FDATA=5F=5F=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=ED=86=B5=ED=95=9C=20hydration=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/main-server.js | 44 +++++++++++++------- packages/vanilla/src/main.js | 42 +++++++++++++++++++ packages/vanilla/src/router/withLifecycle.js | 5 +++ 3 files changed, 75 insertions(+), 16 deletions(-) diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 2d019ab1..f827a928 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -100,8 +100,8 @@ const routeHandlers = { }; // 서버사이드 HTML 렌더링 - const { ServerHomePage } = await import("./pages/ServerPages.js"); - const html = ServerHomePage(initialData); + const { HomePage } = await import("./pages/HomePage.js"); + const html = HomePage(initialData); return { head: `쇼핑몰 - 홈`, @@ -146,8 +146,8 @@ const routeHandlers = { }; // 서버사이드 HTML 렌더링 - const { ServerProductDetailPage } = await import("./pages/ServerPages.js"); - const html = ServerProductDetailPage(initialData); + const { ProductDetailPage } = await import("./pages/ProductDetailPage.js"); + const html = ProductDetailPage(initialData); return { head: `${productState.currentProduct?.title || "상품"} - 쇼핑몰`, @@ -166,18 +166,31 @@ const routeHandlers = { // 404 페이지 ".*": async () => { - return { - head: `404 - 페이지를 찾을 수 없습니다`, - html: ` -
-
-

404 - 페이지를 찾을 수 없습니다

- 홈으로 돌아가기 + try { + // 서버사이드 HTML 렌더링 + const { NotFoundPage } = await import("./pages/NotFoundPage.js"); + const html = NotFoundPage(); + + return { + head: `404 - 페이지를 찾을 수 없습니다`, + html, + initialData: { error: "Page not found" }, + }; + } catch (error) { + console.error("404 페이지 렌더링 오류:", error); + return { + head: `404 - 페이지를 찾을 수 없습니다`, + html: ` +
+
+

404 - 페이지를 찾을 수 없습니다

+ 홈으로 돌아가기 +
-
- `, - initialData: { error: "Page not found" }, - }; + `, + initialData: { error: "Page not found" }, + }; + } }, }; @@ -202,7 +215,6 @@ export const render = async (url) => { // 라우트 핸들러 실행 const result = await route.handler(url, serverRouter.params, mergedQuery); - console.log("result", result); return result; } catch (error) { diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js index 4c3f2765..279bd3fe 100644 --- a/packages/vanilla/src/main.js +++ b/packages/vanilla/src/main.js @@ -4,6 +4,8 @@ import { registerAllEvents } from "./events"; import { loadCartFromStorage } from "./services"; import { router } from "./router"; import { BASE_URL } from "./constants.js"; +import { productStore } from "./stores/productStore.js"; +import { PRODUCT_ACTIONS } from "./stores/actionTypes.js"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => @@ -15,7 +17,47 @@ const enableMocking = () => }), ); +function hydrateWithServerData() { + // 서버에서 전달받은 초기 데이터가 있는지 확인 + if (typeof window !== "undefined" && window.__INITIAL_DATA__) { + const initialData = window.__INITIAL_DATA__; + + if (initialData.products && initialData.categories) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: initialData.products, + categories: initialData.categories, + totalCount: initialData.totalCount, + loading: false, + status: "done", + error: null, + }, + }); + } else if (initialData.currentProduct) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_CURRENT_PRODUCT, + payload: initialData.currentProduct, + }); + + if (initialData.relatedProducts) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_RELATED_PRODUCTS, + payload: initialData.relatedProducts, + }); + } + } + + // 초기 데이터 사용 후 제거 + delete window.__INITIAL_DATA__; + } else { + console.log("❌ 서버 데이터 없음"); + } +} + function main() { + // 서버 데이터로 스토어 하이드레이션 + hydrateWithServerData(); registerAllEvents(); registerGlobalEvents(); loadCartFromStorage(); diff --git a/packages/vanilla/src/router/withLifecycle.js b/packages/vanilla/src/router/withLifecycle.js index ccb21113..cceb5751 100644 --- a/packages/vanilla/src/router/withLifecycle.js +++ b/packages/vanilla/src/router/withLifecycle.js @@ -49,6 +49,11 @@ const unmount = (pageFunction) => { }; export const withLifecycle = ({ onMount, onUnmount, watches } = {}, page) => { + // 서버 환경에서는 라이프사이클을 무시하고 페이지 함수만 반환 + if (typeof window === "undefined") { + return page; + } + const lifecycle = getPageLifecycle(page); if (typeof onMount === "function") { lifecycle.mount = onMount; From 3198527621022efc517136da0da10380ee460484 Mon Sep 17 00:00:00 2001 From: minjaeleee Date: Fri, 5 Sep 2025 04:01:13 +0900 Subject: [PATCH 11/11] =?UTF-8?q?feat:=20SSG=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/static-site-generate.js | 109 ++++++++++++++++++++--- 1 file changed, 97 insertions(+), 12 deletions(-) diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index c479f112..de564e8f 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -1,20 +1,105 @@ -import fs from "fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; -const render = () => { - return `
안녕하세요
`; -}; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// NODE_ENV을 development로 설정 (BASE_URL을 빈 문자열로 사용하기 위해) +process.env.NODE_ENV = "development"; + +// Constants +const DIST_DIR = path.resolve(__dirname, "../../dist/vanilla"); +const SSR_DIR = path.resolve(__dirname, "./dist/vanilla-ssr"); async function generateStaticSite() { - // HTML 템플릿 읽기 - const template = fs.readFileSync("../../dist/vanilla/index.html", "utf-8"); + console.log("🚀 Static Site Generation 시작..."); + + try { + // 1. 템플릿 HTML 로드 + const templatePath = path.join(DIST_DIR, "index.html"); + const template = await fs.readFile(templatePath, "utf-8"); + + // 2. SSR 렌더 함수 로드 + const ssrModulePath = path.join(SSR_DIR, "main-server.js"); + + const ssrModule = await import(`file://${ssrModulePath}`); + const { render } = ssrModule; + + if (!render) { + throw new Error("render 함수를 찾을 수 없습니다"); + } + + // 3. 생성할 페이지 목록 정의 + const pagesToGenerate = await getPages(); + + // 4. 각 페이지별로 HTML 생성 + for (const page of pagesToGenerate) { + try { + const rendered = await render(page.url); + + // 서버 데이터를 클라이언트로 전달하기 위한 스크립트 생성 + const initialDataScript = rendered.initialData + ? `` + : ""; + + const html = template + .replace(``, rendered.head ?? "") + .replace(``, rendered.html ?? "") + .replace(``, `${initialDataScript}`); + + // HTML 파일 저장 + await saveHtmlFile(page.filePath, html); + } catch (error) { + console.error(`❌ ${page.url} 생성 실패:`, error.message); + } + } + } catch (error) { + console.error("💥 SSG 실패:", error); + process.exit(1); + } +} + +async function getPages() { + const pages = []; + + // 홈페이지 + pages.push({ + url: "/", + filePath: path.join(DIST_DIR, "index.html"), + }); + + // 404 페이지 + pages.push({ + url: "/404", + filePath: path.join(DIST_DIR, "404.html"), + }); + + // 상품 상세 페이지들 + try { + const { mockGetProducts } = await import("./src/mocks/server.js"); + const productsData = mockGetProducts({ limit: 20 }); // 20개의 상품 가져오기 + + for (const product of productsData.products) { + pages.push({ + url: `/product/${product.productId}/`, + filePath: path.join(DIST_DIR, "product", product.productId, "index.html"), + }); + } + } catch (error) { + console.error("상품 목록 로드 실패:", error); + } + + return pages; +} - // 어플리케이션 렌더링하기 - const appHtml = render(); +async function saveHtmlFile(filePath, html) { + // 디렉토리 생성 + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); - // 결과 HTML 생성하기 - const result = template.replace("", appHtml); - fs.writeFileSync("../../dist/vanilla/index.html", result); + // HTML 파일 저장 + await fs.writeFile(filePath, html, "utf-8"); } // 실행 -generateStaticSite(); +generateStaticSite().catch(console.error);