From 81c392e29d37ac98c9a55bf6562716b5b48babc1 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Thu, 4 Sep 2025 14:32:07 +0900 Subject: [PATCH 01/46] Feature : pnpm install (express, sirv, compression --- package.json | 5 ++++ pnpm-lock.yaml | 64 +++++++++++++++++++++++++++++++++----------------- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 27780346..019895f2 100644 --- a/package.json +++ b/package.json @@ -50,5 +50,10 @@ "typescript-eslint": "^8.36.0", "vite": "npm:rolldown-vite@latest", "vitest": "latest" + }, + "dependencies": { + "compression": "^1.8.1", + "express": "^5.1.0", + "sirv": "^3.0.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 766adcbc..64929d2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,16 @@ settings: importers: .: + dependencies: + compression: + specifier: ^1.8.1 + version: 1.8.1 + express: + specifier: ^5.1.0 + version: 5.1.0 + sirv: + specifier: ^3.0.2 + version: 3.0.2 devDependencies: '@babel/core': specifier: latest @@ -101,7 +111,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 +120,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 +187,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 @@ -1103,13 +1113,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 +1201,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 @@ -2661,6 +2671,10 @@ packages: resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} engines: {node: '>=18'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -3701,15 +3715,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 +3773,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 +3892,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) @@ -3967,7 +3981,7 @@ snapshots: fflate: 0.8.2 flatted: 3.3.3 pathe: 2.0.3 - sirv: 3.0.1 + sirv: 3.0.2 tinyglobby: 0.2.14 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) @@ -4154,7 +4168,7 @@ snapshots: compressible@2.0.18: dependencies: - mime-db: 1.52.0 + mime-db: 1.54.0 compression@1.8.1: dependencies: @@ -5405,6 +5419,12 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + slash@3.0.0: {} slice-ansi@5.0.0: From ddb5935bb63d9ca1a7e743e4f559fa7ffd992284 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Thu, 4 Sep 2025 14:46:04 +0900 Subject: [PATCH 02/46] =?UTF-8?q?Feature=20:=20=EB=9D=BC=EC=9A=B0=ED=84=B0?= =?UTF-8?q?=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/lib/ServerRouter.js | 4 ++++ packages/vanilla/src/lib/index.js | 1 + packages/vanilla/src/router/router.js | 5 ++++- 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 packages/vanilla/src/lib/ServerRouter.js diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js new file mode 100644 index 00000000..f50ba95d --- /dev/null +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -0,0 +1,4 @@ +// SSG 서버 라우터 +export const ServerRouter = () => { + return; +}; diff --git a/packages/vanilla/src/lib/index.js b/packages/vanilla/src/lib/index.js index a598ef30..13d45422 100644 --- a/packages/vanilla/src/lib/index.js +++ b/packages/vanilla/src/lib/index.js @@ -2,3 +2,4 @@ export * from "./createObserver"; export * from "./createStore"; export * from "./createStorage"; export * from "./Router"; +export * from "./serverRouter"; diff --git a/packages/vanilla/src/router/router.js b/packages/vanilla/src/router/router.js index d897ee76..c9db3dd5 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -1,5 +1,8 @@ // 글로벌 라우터 인스턴스 import { Router } from "../lib"; import { BASE_URL } from "../constants.js"; +import { ServerRouter } from "../lib/serverRouter.js"; -export const router = new Router(BASE_URL); +// window 여부에 따라서 clinet router와 server router 분기 +// BASE_URL을 serverRouter에만 전달하는 이유 : 서버 환경에서는 window객체가 없기 때문에 url정보를 불러올 수 없어 미리 명시 +export const router = typeof window !== "undefined" ? new Router() : new ServerRouter(BASE_URL); From 968301fa9312407360977afc49061ef657754011 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Thu, 4 Sep 2025 17:05:55 +0900 Subject: [PATCH 03/46] =?UTF-8?q?Feature=20:=20SSG=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=84=B0=20addRoute=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/lib/ServerRouter.js | 49 ++++++++++++++++++++++-- packages/vanilla/src/router/router.js | 3 +- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js index f50ba95d..1f340636 100644 --- a/packages/vanilla/src/lib/ServerRouter.js +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -1,4 +1,45 @@ -// SSG 서버 라우터 -export const ServerRouter = () => { - return; -}; +/** + * SSG 서버 라우터 - 서버 환경에서 라우팅 처리 + */ +export class ServerRouter { + #routes; + #baseUrl; + + constructor(baseUrl = "") { + // 라우트 저장을 위한 Map 초기화 - 클라이언트와 동일한 구조 사용 + this.#routes = new Map(); + // baseUrl 설정 - 서버에서는 window 객체 없이 직접 전달받아 설정 + this.#baseUrl = baseUrl.replace(/\/$/, ""); + } + + get baseUrl() { + return this.#baseUrl; + } + + /** + * 라우트 등록 - 클라이언트 Router와 동일한 방식 + * @param {string} path - 경로 패턴 (예: "/product/:id") + * @param {Function} handler - 라우트 핸들러 + */ + addRoute(path, handler) { + // 동적 파라미터 이름들을 저장할 배열 (예: [id] for "/product/:id") + const paramNames = []; + // 경로 패턴을 정규식으로 변환 (:id → ([^/]+)) + const regexPath = path + .replace(/:\w+/g, (match) => { + paramNames.push(match.slice(1)); // ':id' -> 'id' + return "([^/]+)"; + }) + .replace(/\//g, "\\/"); + + // baseUrl을 포함한 완전한 정규식 패턴 생성 + const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`); + + // 라우트 정보를 Map에 저장 - 나중에 findRoute에서 사용 + this.#routes.set(path, { + regex, + paramNames, + handler, + }); + } +} diff --git a/packages/vanilla/src/router/router.js b/packages/vanilla/src/router/router.js index c9db3dd5..7f0f5ed8 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -1,7 +1,6 @@ // 글로벌 라우터 인스턴스 -import { Router } from "../lib"; +import { Router, ServerRouter } from "../lib"; import { BASE_URL } from "../constants.js"; -import { ServerRouter } from "../lib/serverRouter.js"; // window 여부에 따라서 clinet router와 server router 분기 // BASE_URL을 serverRouter에만 전달하는 이유 : 서버 환경에서는 window객체가 없기 때문에 url정보를 불러올 수 없어 미리 명시 From ba66a741d50459f90eee4db6d17bc0a6a5f768ea Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Thu, 4 Sep 2025 17:07:40 +0900 Subject: [PATCH 04/46] =?UTF-8?q?Feature=20:=20SSG=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=84=B0=20findRoute=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/lib/ServerRouter.js | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js index 1f340636..95c4dbee 100644 --- a/packages/vanilla/src/lib/ServerRouter.js +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -42,4 +42,34 @@ export class ServerRouter { handler, }); } + + /** + * URL에 매칭되는 라우트를 찾기 - 서버에서 사용 + * @param {string} url - 매칭할 URL 경로 + * @returns {Object|null} 매칭된 라우트 정보 또는 null + */ + findRoute(url) { + // 서버 환경에서는 전달받은 URL 문자열을 직접 파싱 (쿼리스트링 제거) + 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]; // 첫 번째 캡처 그룹부터 시작 + }); + + // 클라이언트 Router와 동일한 형태로 반환 + return { + ...route, + params, + path: routePath, + }; + } + } + return null; // 매칭되는 라우트 없음 + } } From c2f596dd42b234cf417b297edf724c18796fbca5 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Thu, 4 Sep 2025 17:11:51 +0900 Subject: [PATCH 05/46] =?UTF-8?q?Feature=20:=20SSG=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=84=B0=20getAllRoutes,=20parseQuery=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/lib/ServerRouter.js | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js index 95c4dbee..9ff8dd73 100644 --- a/packages/vanilla/src/lib/ServerRouter.js +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -72,4 +72,31 @@ export class ServerRouter { } return null; // 매칭되는 라우트 없음 } + + /** + * 등록된 모든 라우트 목록 반환 - SSG 빌드용 + * @returns {Array} 라우트 목록 배열 + */ + getAllRoutes() { + // Map의 모든 엔트리를 배열로 변환하여 반환 + return Array.from(this.#routes.entries()).map(([path, route]) => ({ + path, // 원본 경로 패턴 (예: "/product/:id") + ...route, // regex, paramNames, handler 포함 + })); + } + + /** + * 쿼리 파라미터를 객체로 파싱 - 정적 메서드 + * @param {string} search - 쿼리 문자열 (예: "?page=1&limit=10") + * @returns {Object} 파싱된 쿼리 객체 + */ + static parseQuery(search = "") { + // 서버에서는 URLSearchParams를 직접 사용하여 파싱 + const params = new URLSearchParams(search); + const query = {}; + for (const [key, value] of params) { + query[key] = value; + } + return query; + } } From 2cf6afb2cf2ed5695b0275e633ff711a342063f3 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Thu, 4 Sep 2025 17:19:01 +0900 Subject: [PATCH 06/46] =?UTF-8?q?Feature=20:=20main-server.js=EC=97=90=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EC=9A=A9=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=ED=95=A8=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/main-server.js | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 40b58858..d6b3a6a6 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,3 +1,45 @@ +import { readFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; + +// ESM에서 __dirname 대체 +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// items.json 데이터 로드 - 서버에서 mock 데이터 사용을 위해 +const itemsPath = join(__dirname, "mocks", "items.json"); +const items = JSON.parse(readFileSync(itemsPath, "utf-8")); + +// 서버용 상품 목록 조회 함수 +export const mockGetProducts = async (params = {}) => { + const { limit = 20, search = "" } = params; + const page = params.current ?? params.page ?? 1; + + // 검색 필터링 + let filtered = [...items]; + if (search) { + const searchTerm = search.toLowerCase(); + filtered = filtered.filter( + (item) => item.title.toLowerCase().includes(searchTerm) || item.brand.toLowerCase().includes(searchTerm), + ); + } + + // 페이지네이션 + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const paginatedProducts = filtered.slice(startIndex, endIndex); + + return { + products: paginatedProducts, + pagination: { + page, + limit, + total: filtered.length, + totalPages: Math.ceil(filtered.length / limit), + }, + }; +}; + export const render = async (url, query) => { console.log({ url, query }); return ""; From 6674650b2595a2005923f7672fb7543ce25a0c30 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Thu, 4 Sep 2025 17:22:26 +0900 Subject: [PATCH 07/46] =?UTF-8?q?Feature=20:=20main-server.js=EC=97=90=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EC=9A=A9=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/main-server.js | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index d6b3a6a6..c8a2dceb 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -40,6 +40,37 @@ export const mockGetProducts = async (params = {}) => { }; }; +// 서버용 개별 상품 조회 함수 +export const mockGetProduct = async (productId) => { + // 해당 상품 찾기 + const product = items.find((item) => item.productId === productId); + + if (!product) { + return null; // 상품 없음 + } + + // 기본 상품 정보 반환 + return { + ...product, + description: `${product.title}에 대한 상세 설명입니다.`, + rating: 4, + reviewCount: 100, + stock: 50, + }; +}; + +// 서버용 카테고리 목록 조회 함수 +export const mockGetCategories = async () => { + const categories = {}; + + items.forEach((item) => { + const cat1 = item.category1; + if (!categories[cat1]) categories[cat1] = {}; + }); + + return categories; +}; + export const render = async (url, query) => { console.log({ url, query }); return ""; From dda70384ed02f7a7201d8d101de3cfa795d44e69 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Thu, 4 Sep 2025 17:25:04 +0900 Subject: [PATCH 08/46] =?UTF-8?q?Feature=20:=20main-server.js=EC=97=90=20r?= =?UTF-8?q?ender=ED=95=A8=EC=88=98=EC=97=90=20=EB=9D=BC=EC=9A=B0=ED=8A=B8?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/main-server.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index c8a2dceb..bd992222 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,6 +1,7 @@ import { readFileSync } from "fs"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; +import { ServerRouter } from "./lib/ServerRouter.js"; // ESM에서 __dirname 대체 const __filename = fileURLToPath(import.meta.url); @@ -72,6 +73,14 @@ export const mockGetCategories = async () => { }; export const render = async (url, query) => { + // 서버용 라우터 인스턴스 생성 + const router = new ServerRouter(""); + + // 라우트 등록 - 홈페이지 + router.addRoute("/", () => "HomePage"); + // 라우트 등록 - 상품 상세 + router.addRoute("/product/:id", () => "ProductDetailPage"); + console.log({ url, query }); return ""; }; From 4c4b028b4e556a5ab4a8619c578dd62c77e2d5ef Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Thu, 4 Sep 2025 17:30:46 +0900 Subject: [PATCH 09/46] =?UTF-8?q?Feature=20:=20ssg=20main-server.js=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=94=84=EB=A6=AC=ED=8C=A8?= =?UTF-8?q?=EC=B9=AD=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/main-server.js | 41 ++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index bd992222..b401ff6c 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -72,15 +72,42 @@ export const mockGetCategories = async () => { return categories; }; -export const render = async (url, query) => { - // 서버용 라우터 인스턴스 생성 - const router = new ServerRouter(""); +// 라우트별 데이터 프리페칭 함수 +async function prefetchData(route, params) { + if (route.path === "/") { + // mockGetProducts + mockGetCategories + // productStore.dispatch(SETUP) + const productsData = await mockGetProducts({ limit: 20 }); + const categoriesData = await mockGetCategories(); + return { products: productsData.products, categories: categoriesData }; + } else if (route.path === "/product/:id") { + // mockGetProduct(params.id) + // productStore.dispatch(SET_CURRENT_PRODUCT) + const productData = await mockGetProduct(params.id); + return { currentProduct: productData }; + } + return {}; +} - // 라우트 등록 - 홈페이지 +export const render = async (url) => { + // 1. Store 초기화 (TODO: 나중에 실제 store 추가) + + // 2. 라우트 매칭 + const router = new ServerRouter(""); router.addRoute("/", () => "HomePage"); - // 라우트 등록 - 상품 상세 router.addRoute("/product/:id", () => "ProductDetailPage"); - console.log({ url, query }); - return ""; + const matchedRoute = router.findRoute(url); + if (!matchedRoute) { + return { html: "

404 Not Found

", head: "", initialData: {} }; + } + + // 3. 데이터 프리페칭 + const initialData = await prefetchData(matchedRoute, matchedRoute.params); + + // 4. HTML 생성 (TODO: 실제 컴포넌트 렌더링) + const html = `

Server Rendered: ${matchedRoute.path}

`; + const head = ""; + + return { html, head, initialData }; }; From bdeb65ec7ce649a42a6036bc56661f8883db682c Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Thu, 4 Sep 2025 17:31:08 +0900 Subject: [PATCH 10/46] =?UTF-8?q?Feature=20:=20ssg=20server.js=EC=97=90=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=ED=99=98=EA=B2=BD=20=EB=B6=84=EA=B8=B0?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/server.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index b9a56d98..3a876618 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,4 +1,6 @@ import express from "express"; +import compression from "compression"; +import sirv from "sirv"; const prod = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5173; @@ -6,6 +8,16 @@ const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/") const app = express(); +// 환경 분기 - 구현 가이드 예시대로 +if (!prod) { + // Vite dev server + middleware (TODO: Vite 미들웨어 추가) + console.log("Development mode"); +} else { + // compression + sirv + app.use(compression()); + app.use(base, sirv("./dist/vanilla", { extensions: [] })); +} + const render = () => { return `
안녕하세요
`; }; From ceed94a44baf835319768c6ff321b7fda7c10160 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Thu, 4 Sep 2025 17:37:52 +0900 Subject: [PATCH 11/46] =?UTF-8?q?Feature=20:=20ssg=20server.js=EC=97=90=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/server.js | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index 3a876618..6d18de07 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,6 +1,7 @@ import express from "express"; import compression from "compression"; import sirv from "sirv"; +import { render } from "./src/main-server.js"; const prod = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5173; @@ -8,7 +9,7 @@ const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/") const app = express(); -// 환경 분기 - 구현 가이드 예시대로 +// 환경 분기 if (!prod) { // Vite dev server + middleware (TODO: Vite 미들웨어 추가) console.log("Development mode"); @@ -18,26 +19,36 @@ if (!prod) { app.use(base, sirv("./dist/vanilla", { extensions: [] })); } -const render = () => { - return `
안녕하세요
`; -}; +// 렌더링 파이프라인 +app.use("*", async (req, res) => { + const url = req.originalUrl.replace(base, ""); + const { html, head, initialData } = await render(url); -app.get("*all", (req, res) => { - res.send( - ` + // initialData 스크립트 생성 + const initialDataScript = ``; + + // Template 치환 (TODO: 실제 HTML 템플릿 로드) + const template = ` Vanilla Javascript SSR + -
${render()}
+
- `.trim(), - ); + `.trim(); + + const finalHtml = template + .replace("", head) + .replace("", html) + .replace("", `${initialDataScript}`); + + res.send(finalHtml); }); // Start http server From 460e6be23724175201deb13d710d27674361b503 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Thu, 4 Sep 2025 17:41:09 +0900 Subject: [PATCH 12/46] =?UTF-8?q?Feature=20:=20ssg=20static-site-generate.?= =?UTF-8?q?js=EC=97=90=20gePages,=20saveHtmlFile,=20generateStaticSite=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/static-site-generate.js | 60 +++++++++++++++++++----- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index c479f112..0c53e8d8 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -1,19 +1,57 @@ -import fs from "fs"; +import { writeFileSync, mkdirSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { mockGetProducts } from "./src/main-server.js"; -const render = () => { - return `
안녕하세요
`; -}; +// ESM에서 __dirname 대체 +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const DIST_DIR = join(__dirname, "../../dist/vanilla"); + +// 페이지 목록 생성 +async function getPages() { + const products = await mockGetProducts({ limit: 20 }); + return [ + { url: "/", filePath: `${DIST_DIR}/index.html` }, + { url: "/404", filePath: `${DIST_DIR}/404.html` }, + ...products.products.map((p) => ({ + url: `/product/${p.productId}/`, + filePath: `${DIST_DIR}/product/${p.productId}/index.html`, + })), + ]; +} + +// HTML 파일 저장 함수 +async function saveHtmlFile(filePath, html) { + // 디렉토리 생성 + const dir = dirname(filePath); + try { + mkdirSync(dir, { recursive: true }); + } catch (err) { + console.error(err); + // 디렉토리가 이미 존재하는 경우 무시 + } + + // 파일 저장 + writeFileSync(filePath, html); + console.log(`Generated: ${filePath}`); +} async function generateStaticSite() { - // HTML 템플릿 읽기 - const template = fs.readFileSync("../../dist/vanilla/index.html", "utf-8"); + console.log("Starting SSG build..."); + + // 2. 페이지 목록 생성 + const pages = await getPages(); + console.log(`Total pages to generate: ${pages.length}`); - // 어플리케이션 렌더링하기 - const appHtml = render(); + // 3. 각 페이지 렌더링 + 저장 (TODO: 실제 render 함수 사용) + for (const page of pages) { + const html = `

Static Page: ${page.url}

`; + await saveHtmlFile(page.filePath, html); + } - // 결과 HTML 생성하기 - const result = template.replace("", appHtml); - fs.writeFileSync("../../dist/vanilla/index.html", result); + console.log("SSG build completed!"); } // 실행 From a41b753785660fcd5fcc7c0dae8ad7309b3c0784 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Thu, 4 Sep 2025 17:44:12 +0900 Subject: [PATCH 13/46] =?UTF-8?q?Feature=20:=20ssg=20main.js=EC=97=90=20?= =?UTF-8?q?=5F=5FINITIAL=5FDATA=5F=5F=EC=84=B8=ED=8C=85=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/main.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js index 4c3f2765..dfac3c48 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 }) => @@ -16,6 +18,14 @@ const enableMocking = () => ); function main() { + // 서버 데이터 복원 + if (window.__INITIAL_DATA__) { + const data = window.__INITIAL_DATA__; + 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(); From 9d1432e9d388a0f95de86c751796677f46cf75d8 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Thu, 4 Sep 2025 18:32:39 +0900 Subject: [PATCH 14/46] =?UTF-8?q?Feature=20:=20serverRouter=20import=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=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/index.js | 2 +- packages/vanilla/src/router/router.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/vanilla/src/lib/index.js b/packages/vanilla/src/lib/index.js index 13d45422..148e2ed7 100644 --- a/packages/vanilla/src/lib/index.js +++ b/packages/vanilla/src/lib/index.js @@ -2,4 +2,4 @@ export * from "./createObserver"; export * from "./createStore"; export * from "./createStorage"; export * from "./Router"; -export * from "./serverRouter"; +// ServerRouter는 순환 의존성 방지를 위해 직접 import 사용 diff --git a/packages/vanilla/src/router/router.js b/packages/vanilla/src/router/router.js index 7f0f5ed8..b3b1d336 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -1,5 +1,6 @@ // 글로벌 라우터 인스턴스 -import { Router, ServerRouter } from "../lib"; +import { Router } from "../lib"; +import { ServerRouter } from "../lib/ServerRouter.js"; import { BASE_URL } from "../constants.js"; // window 여부에 따라서 clinet router와 server router 분기 From 96844ef0e0b2cb8af643375bd9cb1b87d7dc7100 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Thu, 4 Sep 2025 18:33:01 +0900 Subject: [PATCH 15/46] =?UTF-8?q?Feature=20:=20msw=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/server.js | 41 ++++++++++++++++++---------- packages/vanilla/src/mocks/server.js | 5 ++++ 2 files changed, 32 insertions(+), 14 deletions(-) create mode 100644 packages/vanilla/src/mocks/server.js diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index 6d18de07..d8711569 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,8 +1,15 @@ import express from "express"; import compression from "compression"; import sirv from "sirv"; +import { server as mswServer } from "./src/mocks/server.js"; import { render } from "./src/main-server.js"; +// MSW 서버 시작 - 서버 환경에서 API 모킹 +mswServer.listen({ + onUnhandledRequest: "bypass", +}); +console.log("🚀 MSW server started"); + const prod = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5173; const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/"); @@ -14,21 +21,23 @@ if (!prod) { // Vite dev server + middleware (TODO: Vite 미들웨어 추가) console.log("Development mode"); } else { - // compression + sirv app.use(compression()); app.use(base, sirv("./dist/vanilla", { extensions: [] })); } -// 렌더링 파이프라인 -app.use("*", async (req, res) => { - const url = req.originalUrl.replace(base, ""); - const { html, head, initialData } = await render(url); +// 렌더링 파이프라인 - 올바른 Express 패턴 +app.use("/*", async (req, res) => { + try { + const url = req.originalUrl.replace(base, ""); + console.log("Request URL:", url); + + const { html, head, initialData } = await render(url); - // initialData 스크립트 생성 - const initialDataScript = ``; + // initialData 스크립트 생성 + const initialDataScript = ``; - // Template 치환 (TODO: 실제 HTML 템플릿 로드) - const template = ` + // Template 치환 (TODO: 실제 HTML 템플릿 로드) + const template = ` @@ -43,12 +52,16 @@ app.use("*", async (req, res) => { `.trim(); - const finalHtml = template - .replace("", head) - .replace("", html) - .replace("", `${initialDataScript}`); + const finalHtml = template + .replace("", head) + .replace("", html) + .replace("", `${initialDataScript}`); - res.send(finalHtml); + res.send(finalHtml); + } catch (error) { + console.error("Render error:", error); + res.status(500).send("Internal Server Error"); + } }); // Start http server diff --git a/packages/vanilla/src/mocks/server.js b/packages/vanilla/src/mocks/server.js new file mode 100644 index 00000000..091310e3 --- /dev/null +++ b/packages/vanilla/src/mocks/server.js @@ -0,0 +1,5 @@ +import { setupServer } from "msw/node"; +import { handlers } from "./handlers.js"; + +// MSW 서버 설정 - Node.js 환경에서 사용 +export const server = setupServer(...handlers); From 8e99e6938609db8631d53a201fc671738618ba42 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Thu, 4 Sep 2025 18:56:13 +0900 Subject: [PATCH 16/46] =?UTF-8?q?Feature=20:=20ssg=20main-server.js?= =?UTF-8?q?=EC=97=90=20productApi=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/api/productApi.js | 26 ++++++++- packages/vanilla/src/main-server.js | 76 ++------------------------ 2 files changed, 28 insertions(+), 74 deletions(-) diff --git a/packages/vanilla/src/api/productApi.js b/packages/vanilla/src/api/productApi.js index c2330fbe..b1a1986b 100644 --- a/packages/vanilla/src/api/productApi.js +++ b/packages/vanilla/src/api/productApi.js @@ -1,3 +1,20 @@ +// API 기본 URL 설정 +const getApiBaseUrl = async () => { + // Node.js 환경(서버 또는 SSG)인지 확인 + const isServerSide = typeof window === "undefined" || (import.meta.env && import.meta.env.SSR); + + if (isServerSide) { + // 서버 환경에서는 절대 URL 사용 + // 개발 환경: 5173, SSR 서버: 5174 + const port = process.env.PORT || 5174; + + return `http://localhost:${port}`; + } else { + // 클라이언트 환경에서는 상대 URL 사용 + return ""; + } +}; + export async function getProducts(params = {}) { const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; const page = params.current ?? params.page ?? 1; @@ -11,17 +28,20 @@ export async function getProducts(params = {}) { sort, }); - const response = await fetch(`/api/products?${searchParams}`); + const baseUrl = await getApiBaseUrl(); + const response = await fetch(`${baseUrl}/api/products?${searchParams}`); return await response.json(); } export async function getProduct(productId) { - const response = await fetch(`/api/products/${productId}`); + const baseUrl = await getApiBaseUrl(); + const response = await fetch(`${baseUrl}/api/products/${productId}`); return await response.json(); } export async function getCategories() { - const response = await fetch("/api/categories"); + const baseUrl = await getApiBaseUrl(); + const response = await fetch(`${baseUrl}/api/categories`); return await response.json(); } diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index b401ff6c..d8414f68 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,76 +1,10 @@ -import { readFileSync } from "fs"; -import { fileURLToPath } from "url"; -import { dirname, join } from "path"; import { ServerRouter } from "./lib/ServerRouter.js"; +import { getProducts, getProduct, getCategories } from "./api/productApi.js"; -// ESM에서 __dirname 대체 -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -// items.json 데이터 로드 - 서버에서 mock 데이터 사용을 위해 -const itemsPath = join(__dirname, "mocks", "items.json"); -const items = JSON.parse(readFileSync(itemsPath, "utf-8")); - -// 서버용 상품 목록 조회 함수 -export const mockGetProducts = async (params = {}) => { - const { limit = 20, search = "" } = params; - const page = params.current ?? params.page ?? 1; - - // 검색 필터링 - let filtered = [...items]; - if (search) { - const searchTerm = search.toLowerCase(); - filtered = filtered.filter( - (item) => item.title.toLowerCase().includes(searchTerm) || item.brand.toLowerCase().includes(searchTerm), - ); - } - - // 페이지네이션 - const startIndex = (page - 1) * limit; - const endIndex = startIndex + limit; - const paginatedProducts = filtered.slice(startIndex, endIndex); - - return { - products: paginatedProducts, - pagination: { - page, - limit, - total: filtered.length, - totalPages: Math.ceil(filtered.length / limit), - }, - }; -}; - -// 서버용 개별 상품 조회 함수 -export const mockGetProduct = async (productId) => { - // 해당 상품 찾기 - const product = items.find((item) => item.productId === productId); - - if (!product) { - return null; // 상품 없음 - } - - // 기본 상품 정보 반환 - return { - ...product, - description: `${product.title}에 대한 상세 설명입니다.`, - rating: 4, - reviewCount: 100, - stock: 50, - }; -}; - -// 서버용 카테고리 목록 조회 함수 -export const mockGetCategories = async () => { - const categories = {}; - - items.forEach((item) => { - const cat1 = item.category1; - if (!categories[cat1]) categories[cat1] = {}; - }); - - return categories; -}; +// productApi.js 함수들을 직접 export (환경별 baseUrl 처리 로직 포함) +export const mockGetProducts = getProducts; +export const mockGetProduct = getProduct; +export const mockGetCategories = getCategories; // 라우트별 데이터 프리페칭 함수 async function prefetchData(route, params) { From 2b59c70ae717bc16da63df584c3f48b44a2d43d1 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Thu, 4 Sep 2025 19:01:10 +0900 Subject: [PATCH 17/46] =?UTF-8?q?Feature=20:=20ssg=20main-server=EC=97=90?= =?UTF-8?q?=EC=84=9C=20api=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20serverRouter=EC=97=90=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/lib/ServerRouter.js | 76 ++++++++++++++++++++++++ packages/vanilla/src/main-server.js | 55 ++++++----------- 2 files changed, 93 insertions(+), 38 deletions(-) diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js index 9ff8dd73..977bc6fd 100644 --- a/packages/vanilla/src/lib/ServerRouter.js +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -1,3 +1,4 @@ +import { getProducts, getProduct, getCategories } from "../api/productApi.js"; /** * SSG 서버 라우터 - 서버 환경에서 라우팅 처리 */ @@ -10,6 +11,12 @@ export class ServerRouter { this.#routes = new Map(); // baseUrl 설정 - 서버에서는 window 객체 없이 직접 전달받아 설정 this.#baseUrl = baseUrl.replace(/\/$/, ""); + + // 현재 라우트 정보 저장용 속성들 + this.pathname = ""; + this.query = {}; + this.params = {}; + this.target = null; } get baseUrl() { @@ -85,6 +92,75 @@ export class ServerRouter { })); } + /** + * 라우터 시작 - URL 파싱 및 라우트 매칭 + * @param {string} url - 처리할 URL + */ + start(url) { + const [pathname, search = ""] = url.split("?"); + this.pathname = pathname; + this.query = ServerRouter.parseQuery(search); + + // 라우트 매칭 + const matchedRoute = this.findRoute(url); + if (matchedRoute) { + this.params = matchedRoute.params; + this.target = matchedRoute.handler; + } else { + this.target = null; + } + } + + /** + * 라우트별 데이터 프리페칭 + * @param {Object} routeParams - { pathname, query, params } + * @returns {Object} 프리페칭된 데이터 + */ + async prefetch({ pathname, query, params }) { + if (pathname === "/") { + // 홈페이지: 상품 목록과 카테고리 데이터 로드 + const { search = "", category1 = "", category2 = "", sort = "price_asc", limit = "20" } = query; + const productsData = await getProducts({ + search, + category1, + category2, + sort, + limit: parseInt(limit), + current: 1, + }); + const categoriesData = await getCategories(); + + return { + products: productsData.products, + categories: categoriesData, + totalCount: productsData.totalCount, + loading: false, + error: null, + }; + } else if (pathname.startsWith("/product/")) { + // 상품 상세 페이지: 해당 상품과 관련 상품 로드 + const productData = await getProduct(params.id); + + // 관련 상품 로드 (같은 카테고리2의 다른 상품들) + let relatedProducts = []; + if (productData && productData.category2) { + const relatedData = await getProducts({ + category2: productData.category2, + limit: 20, + }); + relatedProducts = relatedData.products.filter((p) => p.productId !== productData.productId); + } + + return { + currentProduct: productData, + relatedProducts: relatedProducts, + loading: false, + error: null, + }; + } + return {}; + } + /** * 쿼리 파라미터를 객체로 파싱 - 정적 메서드 * @param {string} search - 쿼리 문자열 (예: "?page=1&limit=10") diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index d8414f68..5baf93f6 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,47 +1,26 @@ import { ServerRouter } from "./lib/ServerRouter.js"; -import { getProducts, getProduct, getCategories } from "./api/productApi.js"; +import { HomePage, NotFoundPage, ProductDetailPage } from "./pages"; -// productApi.js 함수들을 직접 export (환경별 baseUrl 처리 로직 포함) -export const mockGetProducts = getProducts; -export const mockGetProduct = getProduct; -export const mockGetCategories = getCategories; +export async function render(url) { + // 2. 라우트 매칭 + const serverRouter = new ServerRouter(); -// 라우트별 데이터 프리페칭 함수 -async function prefetchData(route, params) { - if (route.path === "/") { - // mockGetProducts + mockGetCategories - // productStore.dispatch(SETUP) - const productsData = await mockGetProducts({ limit: 20 }); - const categoriesData = await mockGetCategories(); - return { products: productsData.products, categories: categoriesData }; - } else if (route.path === "/product/:id") { - // mockGetProduct(params.id) - // productStore.dispatch(SET_CURRENT_PRODUCT) - const productData = await mockGetProduct(params.id); - return { currentProduct: productData }; - } - return {}; -} + serverRouter.addRoute("/", HomePage); + serverRouter.addRoute("/product/:id/", ProductDetailPage); + serverRouter.addRoute("/404", NotFoundPage); -export const render = async (url) => { - // 1. Store 초기화 (TODO: 나중에 실제 store 추가) + serverRouter.start(url); - // 2. 라우트 매칭 - const router = new ServerRouter(""); - router.addRoute("/", () => "HomePage"); - router.addRoute("/product/:id", () => "ProductDetailPage"); - - const matchedRoute = router.findRoute(url); - if (!matchedRoute) { - return { html: "

404 Not Found

", head: "", initialData: {} }; - } + const { pathname, query, params } = serverRouter; // 3. 데이터 프리페칭 - const initialData = await prefetchData(matchedRoute, matchedRoute.params); + const routeParams = { pathname, query, params }; + const data = await serverRouter.prefetch(routeParams); + const metaData = serverRouter.target.meta ? serverRouter.target.meta(data) : ""; + const head = metaData; - // 4. HTML 생성 (TODO: 실제 컴포넌트 렌더링) - const html = `

Server Rendered: ${matchedRoute.path}

`; - const head = ""; + // 4. HTML 생성 - 프리패치된 데이터를 페이지 컴포넌트에 전달 + const html = await serverRouter.target(data, query); - return { html, head, initialData }; -}; + return { html, head, initialData: data }; +} From 7562dd4294ea215e8f3828c16c601d6bafd43eeb Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Thu, 4 Sep 2025 19:43:30 +0900 Subject: [PATCH 18/46] =?UTF-8?q?Feature=20:=20=EC=83=98=ED=94=8C=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=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 | 105 ++++++----- packages/vanilla/src/lib/BaseRouter.js | 133 ++++++++++++++ packages/vanilla/src/lib/Router.js | 142 ++------------- packages/vanilla/src/lib/ServerRouter.js | 182 ++++---------------- packages/vanilla/src/lib/index.js | 2 +- packages/vanilla/src/main-server.js | 73 ++++++-- packages/vanilla/src/mocks/server.js | 138 ++++++++++++++- packages/vanilla/src/pages/HomePage.js | 35 +++- packages/vanilla/src/router/router.js | 9 +- packages/vanilla/src/storage/cartStorage.js | 11 +- 10 files changed, 471 insertions(+), 359 deletions(-) create mode 100644 packages/vanilla/src/lib/BaseRouter.js diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index d8711569..005dc7ad 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,70 +1,83 @@ import express from "express"; -import compression from "compression"; -import sirv from "sirv"; -import { server as mswServer } from "./src/mocks/server.js"; -import { render } from "./src/main-server.js"; +import fs from "node:fs/promises"; -// MSW 서버 시작 - 서버 환경에서 API 모킹 -mswServer.listen({ - onUnhandledRequest: "bypass", -}); -console.log("🚀 MSW server started"); - -const prod = process.env.NODE_ENV === "production"; -const port = process.env.PORT || 5173; -const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/"); +// 환경 변수 및 상수 설정 +const isProduction = process.env.NODE_ENV === "production"; +const port = process.env.PORT || 5174; // SSR 포트 +const base = process.env.BASE || (isProduction ? "/front_6th_chapter4-1/vanilla/" : "/"); +// Express 앱 생성 const app = express(); -// 환경 분기 -if (!prod) { - // Vite dev server + middleware (TODO: Vite 미들웨어 추가) - console.log("Development mode"); +// 템플릿과 렌더 함수 변수 +let template; +let render; +let vite; + +// 환경별 설정 +if (!isProduction) { + // 개발 환경: Vite 개발 서버 연동 + console.log("🛠️ 개발 환경 - Vite 설정 중..."); + const { createServer } = await import("vite"); + vite = await createServer({ + server: { middlewareMode: true }, + appType: "custom", + base, + }); + app.use(vite.middlewares); } else { + // 프로덕션 환경: 압축 및 정적 파일 서빙 + console.log("🏭 프로덕션 미들웨어 설정 중..."); + const compression = (await import("compression")).default; + const sirv = (await import("sirv")).default; app.use(compression()); app.use(base, sirv("./dist/vanilla", { extensions: [] })); + + // 프로덕션 템플릿 로드 + template = await fs.readFile("./dist/vanilla/index.html", "utf-8"); + render = (await import("./dist/vanilla-ssr/main-server.js")).render; } -// 렌더링 파이프라인 - 올바른 Express 패턴 -app.use("/*", async (req, res) => { +// SSR 렌더링 미들웨어 +app.use("*all", async (req, res) => { try { + // URL에서 베이스 경로 제거 (정규화) const url = req.originalUrl.replace(base, ""); - console.log("Request URL:", url); + console.log("🌐 SSR 요청:", url); - const { html, head, initialData } = await render(url); + if (!isProduction) { + // 개발 환경: 매 요청마다 최신 템플릿과 렌더 함수 로드 + template = await fs.readFile("./index.html", "utf-8"); + template = await vite.transformIndexHtml(url, template); + render = (await vite.ssrLoadModule("/src/main-server.js")).render; + } - // initialData 스크립트 생성 - const initialDataScript = ``; + const rendered = await render(url, req.query); - // Template 치환 (TODO: 실제 HTML 템플릿 로드) - const template = ` - - - - - - Vanilla Javascript SSR - - - -
- - - `.trim(); + // 초기 데이터 스크립트 생성 (Hydration용) + const initialDataScript = rendered.initialData + ? `` + : ""; - const finalHtml = template - .replace("", head) - .replace("", html) + // HTML 템플릿에 렌더링 결과 주입 + const html = template + .replace("", rendered.head ?? "") + .replace("", rendered.html ?? "") .replace("", `${initialDataScript}`); - res.send(finalHtml); + res.status(200).set({ "Content-Type": "text/html" }).send(html); } catch (error) { - console.error("Render error:", error); - res.status(500).send("Internal Server Error"); + // 개발 환경에서 스택 트레이스 정리 + if (!isProduction && vite) { + vite.ssrFixStacktrace(error); + } + + console.error("❌ SSR 에러:", error.stack); + res.status(500).end(error.stack); } }); -// Start http server +// HTTP 서버 시작 app.listen(port, () => { - console.log(`React Server started at http://localhost:${port}`); + console.log(`🌐 SSR 서버가 http://localhost:${port} 에서 실행 중입니다`); }); diff --git a/packages/vanilla/src/lib/BaseRouter.js b/packages/vanilla/src/lib/BaseRouter.js new file mode 100644 index 00000000..282c0455 --- /dev/null +++ b/packages/vanilla/src/lib/BaseRouter.js @@ -0,0 +1,133 @@ +/** + * 기본 라우터 - 공통 기능을 제공하는 추상 클래스 + */ +import { createObserver } from "./createObserver.js"; + +export class BaseRouter { + #routes; + #route; + #observer = createObserver(); + #baseUrl; + + constructor(baseUrl = "") { + this.#routes = new Map(); + this.#route = null; + this.#baseUrl = baseUrl.replace(/\/$/, ""); + } + + get baseUrl() { + return this.#baseUrl; + } + + 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)); + return "([^/]+)"; + }) + .replace(/\//g, "\\/"); + + const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`); + + this.#routes.set(path, { + regex, + paramNames, + handler, + }); + } + + findRoute(url) { + const { pathname } = new URL(url, this.getOrigin()); + 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; + } + + updateRoute(url) { + this.#route = this.findRoute(url); + this.#observer.notify(); + } + + // 추상 메서드들 - 하위 클래스에서 구현 필요 + getCurrentUrl() { + throw new Error("getCurrentUrl must be implemented by subclass"); + } + + getOrigin() { + throw new Error("getOrigin must be implemented by subclass"); + } + + /** + * 쿼리 파라미터를 객체로 파싱 + */ + static parseQuery(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 = "", pathname = "", search = "") { + const currentQuery = BaseRouter.parseQuery(search); + const updatedQuery = { ...currentQuery, ...newQuery }; + + Object.keys(updatedQuery).forEach((key) => { + if (updatedQuery[key] === null || updatedQuery[key] === undefined || updatedQuery[key] === "") { + delete updatedQuery[key]; + } + }); + + const queryString = BaseRouter.stringifyQuery(updatedQuery); + return `${baseUrl}${pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`; + } +} diff --git a/packages/vanilla/src/lib/Router.js b/packages/vanilla/src/lib/Router.js index 2238a878..7907c4a3 100644 --- a/packages/vanilla/src/lib/Router.js +++ b/packages/vanilla/src/lib/Router.js @@ -1,117 +1,48 @@ /** - * 간단한 SPA 라우터 + * 클라이언트사이드 SPA 라우터 */ -import { createObserver } from "./createObserver.js"; - -export class Router { - #routes; - #route; - #observer = createObserver(); - #baseUrl; +import { BaseRouter } from "./BaseRouter.js"; +export class Router extends BaseRouter { constructor(baseUrl = "") { - this.#routes = new Map(); - this.#route = null; - this.#baseUrl = baseUrl.replace(/\/$/, ""); + super(baseUrl); window.addEventListener("popstate", () => { - this.#route = this.#findRoute(); - this.#observer.notify(); + this.updateRoute(this.getCurrentUrl()); }); } - get baseUrl() { - return this.#baseUrl; - } - get query() { - return Router.parseQuery(window.location.search); + return BaseRouter.parseQuery(window.location.search); } set query(newQuery) { - const newUrl = Router.getUrl(newQuery, this.#baseUrl); + const newUrl = BaseRouter.getUrl(newQuery, this.baseUrl, window.location.pathname, window.location.search); this.push(newUrl); } - get params() { - return this.#route?.params ?? {}; - } - - get route() { - return this.#route; + getCurrentUrl() { + return `${window.location.pathname}${window.location.search}`; } - 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; + getOrigin() { + return window.location.origin; } /** * 네비게이션 실행 - * @param {string} url - 이동할 경로 */ push(url) { try { - // baseUrl이 없으면 자동으로 붙여줌 - let fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url); + 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(); + this.updateRoute(fullUrl); } catch (error) { console.error("라우터 네비게이션 오류:", error); } @@ -121,51 +52,6 @@ export class Router { * 라우터 시작 */ start() { - this.#route = this.#findRoute(); - this.#observer.notify(); + this.updateRoute(this.getCurrentUrl()); } - - /** - * 쿼리 파라미터를 객체로 파싱 - * @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/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js index 977bc6fd..d92dfdb0 100644 --- a/packages/vanilla/src/lib/ServerRouter.js +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -1,178 +1,56 @@ -import { getProducts, getProduct, getCategories } from "../api/productApi.js"; /** - * SSG 서버 라우터 - 서버 환경에서 라우팅 처리 + * 서버사이드 라우터 */ -export class ServerRouter { - #routes; - #baseUrl; +import { BaseRouter } from "./BaseRouter.js"; - constructor(baseUrl = "") { - // 라우트 저장을 위한 Map 초기화 - 클라이언트와 동일한 구조 사용 - this.#routes = new Map(); - // baseUrl 설정 - 서버에서는 window 객체 없이 직접 전달받아 설정 - this.#baseUrl = baseUrl.replace(/\/$/, ""); +export class ServerRouter extends BaseRouter { + #currentUrl = "/"; + #origin = "http://localhost"; - // 현재 라우트 정보 저장용 속성들 - this.pathname = ""; - this.query = {}; - this.params = {}; - this.target = null; + constructor(baseUrl = "") { + super(baseUrl); } - get baseUrl() { - return this.#baseUrl; + get query() { + const url = new URL(this.#currentUrl, this.#origin); + return BaseRouter.parseQuery(url.search); } - /** - * 라우트 등록 - 클라이언트 Router와 동일한 방식 - * @param {string} path - 경로 패턴 (예: "/product/:id") - * @param {Function} handler - 라우트 핸들러 - */ - addRoute(path, handler) { - // 동적 파라미터 이름들을 저장할 배열 (예: [id] for "/product/:id") - const paramNames = []; - // 경로 패턴을 정규식으로 변환 (:id → ([^/]+)) - const regexPath = path - .replace(/:\w+/g, (match) => { - paramNames.push(match.slice(1)); // ':id' -> 'id' - return "([^/]+)"; - }) - .replace(/\//g, "\\/"); - - // baseUrl을 포함한 완전한 정규식 패턴 생성 - const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`); - - // 라우트 정보를 Map에 저장 - 나중에 findRoute에서 사용 - this.#routes.set(path, { - regex, - paramNames, - handler, - }); + set query(newQuery) { + const newUrl = BaseRouter.getUrl(newQuery, this.baseUrl, this.#currentUrl); + this.setUrl(newUrl, this.#origin); } - /** - * URL에 매칭되는 라우트를 찾기 - 서버에서 사용 - * @param {string} url - 매칭할 URL 경로 - * @returns {Object|null} 매칭된 라우트 정보 또는 null - */ - findRoute(url) { - // 서버 환경에서는 전달받은 URL 문자열을 직접 파싱 (쿼리스트링 제거) - 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]; // 첫 번째 캡처 그룹부터 시작 - }); - - // 클라이언트 Router와 동일한 형태로 반환 - return { - ...route, - params, - path: routePath, - }; - } - } - return null; // 매칭되는 라우트 없음 + getCurrentUrl() { + return this.#currentUrl; } - /** - * 등록된 모든 라우트 목록 반환 - SSG 빌드용 - * @returns {Array} 라우트 목록 배열 - */ - getAllRoutes() { - // Map의 모든 엔트리를 배열로 변환하여 반환 - return Array.from(this.#routes.entries()).map(([path, route]) => ({ - path, // 원본 경로 패턴 (예: "/product/:id") - ...route, // regex, paramNames, handler 포함 - })); + getOrigin() { + return this.#origin; } /** - * 라우터 시작 - URL 파싱 및 라우트 매칭 - * @param {string} url - 처리할 URL + * 서버 URL 설정 + * @param {string} url - 요청 URL + * @param {string} [origin] - 서버 origin (선택적) */ - start(url) { - const [pathname, search = ""] = url.split("?"); - this.pathname = pathname; - this.query = ServerRouter.parseQuery(search); - - // 라우트 매칭 - const matchedRoute = this.findRoute(url); - if (matchedRoute) { - this.params = matchedRoute.params; - this.target = matchedRoute.handler; - } else { - this.target = null; - } + setUrl(url, origin = "http://localhost") { + this.#currentUrl = url; + this.#origin = origin; + this.updateRoute(this.getCurrentUrl()); } /** - * 라우트별 데이터 프리페칭 - * @param {Object} routeParams - { pathname, query, params } - * @returns {Object} 프리페칭된 데이터 + * 서버사이드에서는 네비게이션 불가 */ - async prefetch({ pathname, query, params }) { - if (pathname === "/") { - // 홈페이지: 상품 목록과 카테고리 데이터 로드 - const { search = "", category1 = "", category2 = "", sort = "price_asc", limit = "20" } = query; - const productsData = await getProducts({ - search, - category1, - category2, - sort, - limit: parseInt(limit), - current: 1, - }); - const categoriesData = await getCategories(); - - return { - products: productsData.products, - categories: categoriesData, - totalCount: productsData.totalCount, - loading: false, - error: null, - }; - } else if (pathname.startsWith("/product/")) { - // 상품 상세 페이지: 해당 상품과 관련 상품 로드 - const productData = await getProduct(params.id); - - // 관련 상품 로드 (같은 카테고리2의 다른 상품들) - let relatedProducts = []; - if (productData && productData.category2) { - const relatedData = await getProducts({ - category2: productData.category2, - limit: 20, - }); - relatedProducts = relatedData.products.filter((p) => p.productId !== productData.productId); - } - - return { - currentProduct: productData, - relatedProducts: relatedProducts, - loading: false, - error: null, - }; - } - return {}; + push() { + throw new Error("Navigation is not supported in server-side routing"); } /** - * 쿼리 파라미터를 객체로 파싱 - 정적 메서드 - * @param {string} search - 쿼리 문자열 (예: "?page=1&limit=10") - * @returns {Object} 파싱된 쿼리 객체 + * 라우터 시작 */ - static parseQuery(search = "") { - // 서버에서는 URLSearchParams를 직접 사용하여 파싱 - const params = new URLSearchParams(search); - const query = {}; - for (const [key, value] of params) { - query[key] = value; - } - return query; + start() { + this.updateRoute(this.getCurrentUrl()); } } diff --git a/packages/vanilla/src/lib/index.js b/packages/vanilla/src/lib/index.js index 148e2ed7..da63f1df 100644 --- a/packages/vanilla/src/lib/index.js +++ b/packages/vanilla/src/lib/index.js @@ -2,4 +2,4 @@ export * from "./createObserver"; export * from "./createStore"; export * from "./createStorage"; export * from "./Router"; -// ServerRouter는 순환 의존성 방지를 위해 직접 import 사용 +export * from "./ServerRouter"; diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 5baf93f6..4f74383f 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,26 +1,61 @@ -import { ServerRouter } from "./lib/ServerRouter.js"; +// ===== 간단한 라우터 ===== import { HomePage, NotFoundPage, ProductDetailPage } from "./pages"; +import { router } from "./router"; +import { getProductsOnServer, getUniqueCategories } from "./mocks/server.js"; -export async function render(url) { - // 2. 라우트 매칭 - const serverRouter = new ServerRouter(); +// ===== 라우트 등록 ===== +router.addRoute("/", () => { + const { + products, + pagination: { total: totalCount }, + } = getProductsOnServer(router.query); + const categories = getUniqueCategories(); - serverRouter.addRoute("/", HomePage); - serverRouter.addRoute("/product/:id/", ProductDetailPage); - serverRouter.addRoute("/404", NotFoundPage); + const results = { + products, + categories, + totalCount, + }; - serverRouter.start(url); + return { + initialData: results, + html: HomePage(results), + head: "쇼핑몰 홈", + }; +}); +router.addRoute("/product/:id/", () => { + return { + initialData: { products: [] }, + html: ProductDetailPage(), + head: "쇼핑몰 상세페이지", + }; +}); +router.addRoute(".*", () => { + return { + initialData: {}, + html: NotFoundPage(), + head: "페이지 없음", + }; +}); - const { pathname, query, params } = serverRouter; +// ===== 메인 렌더 함수 ===== +export const render = async (url, query) => { + try { + router.setUrl(url, "http://localhost"); + router.query = query; + router.start(); + const routeInfo = router.findRoute(url); - // 3. 데이터 프리페칭 - const routeParams = { pathname, query, params }; - const data = await serverRouter.prefetch(routeParams); - const metaData = serverRouter.target.meta ? serverRouter.target.meta(data) : ""; - const head = metaData; + const result = await routeInfo.handler(routeInfo.params); + console.log("✅ SSR 완료"); - // 4. HTML 생성 - 프리패치된 데이터를 페이지 컴포넌트에 전달 - const html = await serverRouter.target(data, query); - - return { html, head, initialData: data }; -} + return result; + } catch (error) { + console.error("❌ SSR 에러:", error); + return { + head: "에러", + html: "
서버 오류가 발생했습니다.
", + initialData: { error: error.message }, + }; + } +}; diff --git a/packages/vanilla/src/mocks/server.js b/packages/vanilla/src/mocks/server.js index 091310e3..992e89ee 100644 --- a/packages/vanilla/src/mocks/server.js +++ b/packages/vanilla/src/mocks/server.js @@ -1,5 +1,135 @@ -import { setupServer } from "msw/node"; -import { handlers } from "./handlers.js"; +import { http, HttpResponse } from "msw"; +import items from "./items.json"; -// MSW 서버 설정 - Node.js 환경에서 사용 -export const server = setupServer(...handlers); +const delay = async () => await new Promise((resolve) => setTimeout(resolve, 200)); + +// 카테고리 추출 함수 +export 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 getProductsOnServer(query = {}) { + const page = parseInt(query.page ?? query.current) || 1; + const limit = parseInt(query.limit) || 20; + const search = query.search || ""; + const category1 = query.category1 || ""; + const category2 = query.category2 || ""; + const sort = query.sort || "price_asc"; + + // 필터링된 상품들 + 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 const handlers = [ + // 상품 상세 API + http.get("/api/products/:id", ({ params }) => { + const { id } = params; + const product = items.find((item) => item.productId === id); + + if (!product) { + return HttpResponse.json({ error: "Product not found" }, { status: 404 }); + } + + // 상세 정보에 추가 데이터 포함 + const detailProduct = { + ...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")], + }; + + return HttpResponse.json(detailProduct); + }), + + // 카테고리 목록 API + http.get("/api/categories", async () => { + const categories = getUniqueCategories(); + await delay(); + return HttpResponse.json(categories); + }), +]; diff --git a/packages/vanilla/src/pages/HomePage.js b/packages/vanilla/src/pages/HomePage.js index ca08c26c..a4f05f9b 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,6 +7,26 @@ import { PageWrapper } from "./PageWrapper.js"; export const HomePage = withLifecycle( { onMount: () => { + 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("이 코드는 아무것도 없을 때!"); loadProductsAndCategories(); }, watches: [ @@ -17,8 +37,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/router/router.js b/packages/vanilla/src/router/router.js index b3b1d336..9349bae3 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -1,8 +1,7 @@ // 글로벌 라우터 인스턴스 -import { Router } from "../lib"; -import { ServerRouter } from "../lib/ServerRouter.js"; +import { Router, ServerRouter } from "../lib"; import { BASE_URL } from "../constants.js"; -// window 여부에 따라서 clinet router와 server router 분기 -// BASE_URL을 serverRouter에만 전달하는 이유 : 서버 환경에서는 window객체가 없기 때문에 url정보를 불러올 수 없어 미리 명시 -export const router = typeof window !== "undefined" ? new Router() : new ServerRouter(BASE_URL); +const CurrentRouter = typeof window !== "undefined" ? Router : ServerRouter; + +export const router = new CurrentRouter(BASE_URL); 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); From e96cc8bd0e0370adfd6ec2f31c6f4234aac98c2a Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 00:45:50 +0900 Subject: [PATCH 19/46] =?UTF-8?q?Feature=20:=20ssg=20=EC=83=98=ED=94=8C=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/api/productApi.js | 28 ++---- packages/vanilla/src/lib/ServerRouter.js | 8 +- .../vanilla/src/lib/createServerStorage.js | 9 ++ packages/vanilla/src/lib/index.js | 1 + packages/vanilla/src/mocks/serverBrowser.js | 7 ++ packages/vanilla/src/router/router.js | 4 +- packages/vanilla/static-site-generate.js | 87 ++++++++----------- 7 files changed, 66 insertions(+), 78 deletions(-) create mode 100644 packages/vanilla/src/lib/createServerStorage.js create mode 100644 packages/vanilla/src/mocks/serverBrowser.js diff --git a/packages/vanilla/src/api/productApi.js b/packages/vanilla/src/api/productApi.js index b1a1986b..32e422b9 100644 --- a/packages/vanilla/src/api/productApi.js +++ b/packages/vanilla/src/api/productApi.js @@ -1,20 +1,7 @@ // API 기본 URL 설정 -const getApiBaseUrl = async () => { - // Node.js 환경(서버 또는 SSG)인지 확인 - const isServerSide = typeof window === "undefined" || (import.meta.env && import.meta.env.SSR); - - if (isServerSide) { - // 서버 환경에서는 절대 URL 사용 - // 개발 환경: 5173, SSR 서버: 5174 - const port = process.env.PORT || 5174; - - return `http://localhost:${port}`; - } else { - // 클라이언트 환경에서는 상대 URL 사용 - return ""; - } -}; +const BASE_URL = typeof window !== "undefined" ? "" : "http://localhost:5174"; +// SSG 환경에서는 ServerRouter의 함수들을 직접 사용 export async function getProducts(params = {}) { const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; const page = params.current ?? params.page ?? 1; @@ -28,20 +15,17 @@ export async function getProducts(params = {}) { sort, }); - const baseUrl = await getApiBaseUrl(); - const response = await fetch(`${baseUrl}/api/products?${searchParams}`); - + const response = await fetch(`${BASE_URL}/api/products?${searchParams}`); return await response.json(); } export async function getProduct(productId) { - const baseUrl = await getApiBaseUrl(); - const response = await fetch(`${baseUrl}/api/products/${productId}`); + const response = await fetch(`${BASE_URL}/api/products/${productId}`); return await response.json(); } export async function getCategories() { - const baseUrl = await getApiBaseUrl(); - const response = await fetch(`${baseUrl}/api/categories`); + // 클라이언트 환경에서는 기존 API 호출 유지 + 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 index d92dfdb0..3184eb85 100644 --- a/packages/vanilla/src/lib/ServerRouter.js +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -6,19 +6,19 @@ import { BaseRouter } from "./BaseRouter.js"; export class ServerRouter extends BaseRouter { #currentUrl = "/"; #origin = "http://localhost"; + #queryParams = {}; constructor(baseUrl = "") { super(baseUrl); } get query() { - const url = new URL(this.#currentUrl, this.#origin); - return BaseRouter.parseQuery(url.search); + // 서버에서 설정된 쿼리 파라미터 사용 + return this.#queryParams; } set query(newQuery) { - const newUrl = BaseRouter.getUrl(newQuery, this.baseUrl, this.#currentUrl); - this.setUrl(newUrl, this.#origin); + this.#queryParams = { ...newQuery }; } getCurrentUrl() { diff --git a/packages/vanilla/src/lib/createServerStorage.js b/packages/vanilla/src/lib/createServerStorage.js new file mode 100644 index 00000000..4450f73f --- /dev/null +++ b/packages/vanilla/src/lib/createServerStorage.js @@ -0,0 +1,9 @@ +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), + }; +}; diff --git a/packages/vanilla/src/lib/index.js b/packages/vanilla/src/lib/index.js index da63f1df..2d1927e9 100644 --- a/packages/vanilla/src/lib/index.js +++ b/packages/vanilla/src/lib/index.js @@ -1,5 +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/mocks/serverBrowser.js b/packages/vanilla/src/mocks/serverBrowser.js new file mode 100644 index 00000000..4aed3cae --- /dev/null +++ b/packages/vanilla/src/mocks/serverBrowser.js @@ -0,0 +1,7 @@ +// jest.setup.js +import { setupServer } from "msw/node"; +import { handlers } from "./handlers"; + +const mswServer = setupServer(...handlers); + +export { mswServer }; diff --git a/packages/vanilla/src/router/router.js b/packages/vanilla/src/router/router.js index 9349bae3..3b9552ab 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -2,6 +2,4 @@ import { Router, ServerRouter } from "../lib"; import { BASE_URL } from "../constants.js"; -const CurrentRouter = typeof window !== "undefined" ? Router : ServerRouter; - -export const router = new CurrentRouter(BASE_URL); +export const router = typeof window === "undefined" ? new ServerRouter() : new Router(BASE_URL); diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index 0c53e8d8..50994baf 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -1,58 +1,47 @@ -import { writeFileSync, mkdirSync } from "fs"; -import { dirname, join } from "path"; -import { fileURLToPath } from "url"; -import { mockGetProducts } from "./src/main-server.js"; - -// ESM에서 __dirname 대체 -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const DIST_DIR = join(__dirname, "../../dist/vanilla"); - -// 페이지 목록 생성 -async function getPages() { - const products = await mockGetProducts({ limit: 20 }); - return [ - { url: "/", filePath: `${DIST_DIR}/index.html` }, - { url: "/404", filePath: `${DIST_DIR}/404.html` }, - ...products.products.map((p) => ({ - url: `/product/${p.productId}/`, - filePath: `${DIST_DIR}/product/${p.productId}/index.html`, - })), - ]; -} +import fs from "fs"; +import path from "node:path"; +import { createServer } from "vite"; -// HTML 파일 저장 함수 -async function saveHtmlFile(filePath, html) { - // 디렉토리 생성 - const dir = dirname(filePath); - try { - mkdirSync(dir, { recursive: true }); - } catch (err) { - console.error(err); - // 디렉토리가 이미 존재하는 경우 무시 - } +const vite = await createServer({ + server: { middlewareMode: true }, + appType: "custom", +}); - // 파일 저장 - writeFileSync(filePath, html); - console.log(`Generated: ${filePath}`); -} +const { mswServer } = await vite.ssrLoadModule("./src/mocks/serverBrowser.js"); +mswServer.listen({ + onUnhandledRequest: "bypass", +}); +const { render } = await vite.ssrLoadModule("./src/main-server.tsx"); + +const joinDist = (...pathnames) => path.join("../../dist/react", ...pathnames); -async function generateStaticSite() { - console.log("Starting SSG build..."); +const template = fs.readFileSync(joinDist("/index.html"), "utf-8"); - // 2. 페이지 목록 생성 - const pages = await getPages(); - console.log(`Total pages to generate: ${pages.length}`); +async function generateStaticSite(pathname) { + const fullPathname = pathname.endsWith(".html") ? joinDist(pathname) : joinDist(pathname, "/index.html"); + const parsedPath = path.parse(fullPathname); - // 3. 각 페이지 렌더링 + 저장 (TODO: 실제 render 함수 사용) - for (const page of pages) { - const html = `

Static Page: ${page.url}

`; - await saveHtmlFile(page.filePath, html); + const rendered = await render(pathname, {}); + + const html = template + .replace(``, rendered.head ?? "") + .replace(``, rendered.html ?? "") + .replace( + ``, + ``, + ); + + if (!fs.existsSync(parsedPath.dir)) { + fs.mkdirSync(parsedPath.dir, { recursive: true }); } - console.log("SSG build completed!"); + fs.writeFileSync(fullPathname, html); } -// 실행 -generateStaticSite(); +// 상세페이지 생성 +const { getProducts } = await vite.ssrLoadModule("./src/api/productApi.ts"); +const { products } = await getProducts(); +await Promise.all(products.map(async ({ productId }) => await generateStaticSite(`/product/${productId}/`))); + +mswServer.close(); +vite.close(); From 711f087b0c2d3a4935d576be0523e49ae853b9f2 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 01:45:13 +0900 Subject: [PATCH 20/46] =?UTF-8?q?Feature=20:=20server.js=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=EA=B3=BC=20=ED=95=A8=EA=BB=98=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 | 98 ++++++++++++++------------------------ 1 file changed, 37 insertions(+), 61 deletions(-) diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index 005dc7ad..d65e14cd 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,83 +1,59 @@ +import compression from "compression"; import express from "express"; -import fs from "node:fs/promises"; +import fs from "fs"; +import { dirname, join } from "path"; +import sirv from "sirv"; +import { fileURLToPath } from "url"; -// 환경 변수 및 상수 설정 -const isProduction = process.env.NODE_ENV === "production"; -const port = process.env.PORT || 5174; // SSR 포트 -const base = process.env.BASE || (isProduction ? "/front_6th_chapter4-1/vanilla/" : "/"); +// 환경 변수 설정 +const isProd = process.env.NODE_ENV === "production"; +const port = process.env.PORT || 5173; +const baseUrl = process.env.BASE || (isProd ? "/front_6th_chapter4-1/vanilla/" : "/"); -// Express 앱 생성 const app = express(); +app.use(compression()); -// 템플릿과 렌더 함수 변수 -let template; -let render; -let vite; - -// 환경별 설정 -if (!isProduction) { - // 개발 환경: Vite 개발 서버 연동 - console.log("🛠️ 개발 환경 - Vite 설정 중..."); +// 환경 별 정적 파일 서빙 설정 +if (isProd) { + app.use(baseUrl, sirv("dist/vanilla", { dev: false })); +} else { + // vite 개발 서버는 middleware를 사용 const { createServer } = await import("vite"); - vite = await createServer({ + const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", - base, }); - app.use(vite.middlewares); -} else { - // 프로덕션 환경: 압축 및 정적 파일 서빙 - console.log("🏭 프로덕션 미들웨어 설정 중..."); - const compression = (await import("compression")).default; - const sirv = (await import("sirv")).default; - app.use(compression()); - app.use(base, sirv("./dist/vanilla", { extensions: [] })); - // 프로덕션 템플릿 로드 - template = await fs.readFile("./dist/vanilla/index.html", "utf-8"); - render = (await import("./dist/vanilla-ssr/main-server.js")).render; + app.use(vite.middlewares); } -// SSR 렌더링 미들웨어 -app.use("*all", async (req, res) => { - try { - // URL에서 베이스 경로 제거 (정규화) - const url = req.originalUrl.replace(base, ""); - console.log("🌐 SSR 요청:", url); +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); - if (!isProduction) { - // 개발 환경: 매 요청마다 최신 템플릿과 렌더 함수 로드 - template = await fs.readFile("./index.html", "utf-8"); - template = await vite.transformIndexHtml(url, template); - render = (await vite.ssrLoadModule("/src/main-server.js")).render; - } +// 환경 별 html 읽기 +const templateHtml = isProd + ? join(__dirname, baseUrl, "dist/vanilla/index.html") + : join(__dirname, baseUrl, "index.html"); - const rendered = await render(url, req.query); +let template = fs.readFileSync(templateHtml, "utf-8"); - // 초기 데이터 스크립트 생성 (Hydration용) - const initialDataScript = rendered.initialData - ? `` - : ""; +// SSG 렌더링 함수 +const render = async (url) => { + return template.replace("", `
hihi ${url}
`); +}; - // HTML 템플릿에 렌더링 결과 주입 - const html = template - .replace("", rendered.head ?? "") - .replace("", rendered.html ?? "") - .replace("", `${initialDataScript}`); +// 모든 라우트처리 +app.get("*", async (req, res) => { + const url = req.originalUrl.replace(baseUrl, ""); + const { html, head } = await render(url); - res.status(200).set({ "Content-Type": "text/html" }).send(html); - } catch (error) { - // 개발 환경에서 스택 트레이스 정리 - if (!isProduction && vite) { - vite.ssrFixStacktrace(error); - } + // html에 렌더링 결과 포함하여 응답 + const finalHtml = template.replace("", html).replace("", head); - console.error("❌ SSR 에러:", error.stack); - res.status(500).end(error.stack); - } + res.send(finalHtml); }); -// HTTP 서버 시작 +// 서버 시작 app.listen(port, () => { - console.log(`🌐 SSR 서버가 http://localhost:${port} 에서 실행 중입니다`); + console.log(`Server started at http://localhost:${port}`); }); From 993f8863082cfc6e5b381876487f3a825bf33561 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 02:58:38 +0900 Subject: [PATCH 21/46] =?UTF-8?q?Feature=20:=20ssg=20server.js=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/server.js | 80 +++++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index d65e14cd..f13ca5b8 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,59 +1,77 @@ import compression from "compression"; import express from "express"; import fs from "fs"; -import { dirname, join } from "path"; import sirv from "sirv"; -import { fileURLToPath } from "url"; +import { mockServer } from "./src/mocks/server-mock.js"; -// 환경 변수 설정 +// 환경 변수 및 설정 const isProd = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5173; 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(); -app.use(compression()); -// 환경 별 정적 파일 서빙 설정 +// 런타임에 결정되는 변수들 (개발/프로덕션 환경에 따라 달라짐) +let template; +let render; +let vite; + +// MSW 서버 시작 (API 모킹을 위해) +mockServer.listen({ + onUnhandledRequest: "bypass", +}); + +// 환경별 정적 파일 서빙 및 미들웨어 설정 if (isProd) { + // 프로덕션 환경: 빌드된 정적 파일 서빙 + app.use(compression()); app.use(baseUrl, sirv("dist/vanilla", { dev: false })); } else { - // vite 개발 서버는 middleware를 사용 + // 개발 환경: Vite 개발 서버를 미들웨어로 사용 const { createServer } = await import("vite"); - const vite = await createServer({ + vite = await createServer({ server: { middlewareMode: true }, appType: "custom", + baseUrl, }); app.use(vite.middlewares); } -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -// 환경 별 html 읽기 -const templateHtml = isProd - ? join(__dirname, baseUrl, "dist/vanilla/index.html") - : join(__dirname, baseUrl, "index.html"); - -let template = fs.readFileSync(templateHtml, "utf-8"); - -// SSG 렌더링 함수 -const render = async (url) => { - return template.replace("", `
hihi ${url}
`); -}; - -// 모든 라우트처리 +// 모든 라우트를 처리하는 SSR 핸들러 app.get("*", async (req, res) => { - const url = req.originalUrl.replace(baseUrl, ""); - const { html, head } = await render(url); + try { + 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 finalHtml = template.replace("", html).replace("", head); + // HTML 템플릿에 렌더링된 내용 삽입 + const html = template + .replace(``, rendered.head ?? "") + .replace(``, ``) + .replace(``, rendered.html ?? ""); - res.send(finalHtml); + // 클라이언트에 완성된 HTML 응답 + res.status(200).set({ "Content-Type": "text/html" }).send(html); + } catch (error) { + console.error(error); + res.status(500).send("Internal Server Error"); + } }); -// 서버 시작 +// HTTP 서버 시작 app.listen(port, () => { - console.log(`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}`); }); From e236f3a475a7b2d8f216a47ea96427dc8e3b6127 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 03:19:22 +0900 Subject: [PATCH 22/46] =?UTF-8?q?Feature=20:=20ssg=20ServerRouter.js?= =?UTF-8?q?=EC=97=90=20addRoute=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/lib/ServerRouter.js | 77 ++++++++++++++---------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js index 3184eb85..1a7a503f 100644 --- a/packages/vanilla/src/lib/ServerRouter.js +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -1,56 +1,67 @@ /** - * 서버사이드 라우터 + * 서버사이드 라우터 - window 객체가 없는 SSR 환경에서 사용 */ -import { BaseRouter } from "./BaseRouter.js"; - -export class ServerRouter extends BaseRouter { - #currentUrl = "/"; - #origin = "http://localhost"; - #queryParams = {}; +export class ServerRouter { + #routes; + #route; + #baseUrl; + #currentQuery = {}; + // 모든 라우트 설정 초기화 (라우트 저장소, 활성 라우트, url) constructor(baseUrl = "") { - super(baseUrl); + this.#routes = new Map(); + this.#route = null; + this.#baseUrl = baseUrl.replace(/\/$/, ""); } + // 현재 설정된 쿼리 파라미터를 반환 (서버 환경에서는 직접 관리) get query() { - // 서버에서 설정된 쿼리 파라미터 사용 - return this.#queryParams; + return this.#currentQuery; } + // 새 쿼리 파라미터로 URL 생성하고 라우팅 업데이트 set query(newQuery) { - this.#queryParams = { ...newQuery }; + const newUrl = ServerRouter.getUrl(newQuery, this.#baseUrl); + this.push(newUrl); } - getCurrentUrl() { - return this.#currentUrl; + // 현재 라우트의 경로 파라미터 반환 (예: /product/:id에서 {id: "123"}) + get params() { + return this.#route?.params ?? {}; } - getOrigin() { - return this.#origin; + // 현재 매칭된 라우트 정보 반환 + get route() { + return this.#route; } - /** - * 서버 URL 설정 - * @param {string} url - 요청 URL - * @param {string} [origin] - 서버 origin (선택적) - */ - setUrl(url, origin = "http://localhost") { - this.#currentUrl = url; - this.#origin = origin; - this.updateRoute(this.getCurrentUrl()); + // 현재 라우트의 핸들러 함수 반환 + get target() { + return this.#route?.handler; } /** - * 서버사이드에서는 네비게이션 불가 + * 라우트 등록 - URL 패턴과 핸들러 함수를 매핑 + * @param {string} path - 경로 패턴 (예: "/product/:id") + * @param {Function} handler - 라우트 핸들러 함수 */ - push() { - throw new Error("Navigation is not supported in server-side routing"); - } + addRoute(path, handler) { + // 동적 파라미터를 정규식으로 변환하는 과정 + const paramNames = []; + const regexPath = path + .replace(/:\w+/g, (match) => { + paramNames.push(match.slice(1)); // ':id' -> 'id'로 변환해서 저장 + return "([^/]+)"; + }) + .replace(/\//g, "\\/"); - /** - * 라우터 시작 - */ - start() { - this.updateRoute(this.getCurrentUrl()); + const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`); + + // 라우트 정보를 Map에 저장 + this.#routes.set(path, { + regex, + paramNames, + handler, + }); } } From b47bf485a76c5231061f65247fb24ab13fe184fb Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 03:26:32 +0900 Subject: [PATCH 23/46] =?UTF-8?q?Feature=20:=20ssg=20ServerRouter.js?= =?UTF-8?q?=EC=97=90=20findRoute=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/lib/ServerRouter.js | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js index 1a7a503f..8c85b249 100644 --- a/packages/vanilla/src/lib/ServerRouter.js +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -64,4 +64,38 @@ export class ServerRouter { 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; + } } From 4a2b69d33ef116eb4f48eeb67dcafe41702c9dcc Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 03:37:20 +0900 Subject: [PATCH 24/46] =?UTF-8?q?Feature=20:=20ssg=20ServerRouter.js?= =?UTF-8?q?=EC=97=90=20push,=20start=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/lib/ServerRouter.js | 27 +++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js index 8c85b249..0442a837 100644 --- a/packages/vanilla/src/lib/ServerRouter.js +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -40,6 +40,31 @@ export class ServerRouter { 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") @@ -71,7 +96,7 @@ export class ServerRouter { * @param {string} origin - 서버 도메인 (기본값: "http://localhost") * @returns {Object|null} 매칭된 라우트 정보 또는 null */ - findRoute(url = "/", origin = "http://localhost") { + #findRoute(url = "/", origin = "http://localhost") { // URL 객체를 생성해서 pathname 추출 (쿼리스트링, 해시 제외) const { pathname } = new URL(url, origin); From 340221dff1c82702e612198b6b46ebb87974c9f7 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 03:45:08 +0900 Subject: [PATCH 25/46] =?UTF-8?q?Feature=20:=20ssg=20ServerRouter.js?= =?UTF-8?q?=EC=97=90=20static=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/lib/ServerRouter.js | 57 ++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js index 0442a837..b9955008 100644 --- a/packages/vanilla/src/lib/ServerRouter.js +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -123,4 +123,61 @@ export class ServerRouter { 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}` : ""}`; + }; } From 04de40d7ce72af93dd4737a4c24b860cadd98e64 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 04:11:03 +0900 Subject: [PATCH 26/46] =?UTF-8?q?Feature=20:=20ssg=20main-server.js=20rend?= =?UTF-8?q?er=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/main-server.js | 206 +++++++++++++++++++++------- 1 file changed, 155 insertions(+), 51 deletions(-) diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 4f74383f..83358edb 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,61 +1,165 @@ -// ===== 간단한 라우터 ===== import { HomePage, NotFoundPage, ProductDetailPage } from "./pages"; import { router } from "./router"; -import { getProductsOnServer, getUniqueCategories } from "./mocks/server.js"; - -// ===== 라우트 등록 ===== -router.addRoute("/", () => { - const { - products, - pagination: { total: totalCount }, - } = getProductsOnServer(router.query); - const categories = getUniqueCategories(); - - const results = { - products, - categories, - totalCount, - }; - - return { - initialData: results, - html: HomePage(results), - head: "쇼핑몰 홈", - }; -}); -router.addRoute("/product/:id/", () => { - return { - initialData: { products: [] }, - html: ProductDetailPage(), - head: "쇼핑몰 상세페이지", - }; -}); -router.addRoute(".*", () => { - return { - initialData: {}, - html: NotFoundPage(), - head: "페이지 없음", - }; -}); - -// ===== 메인 렌더 함수 ===== -export const render = async (url, query) => { +import { getProducts, getCategories, getProduct } from "./api/productApi.js"; +import { productStore } from "./stores"; +import { PRODUCT_ACTIONS } from "./stores/actionTypes"; + +// 라우터에 페이지별 경로와 컴포넌트 등록 +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 { - router.setUrl(url, "http://localhost"); - router.query = query; - router.start(); - const routeInfo = router.findRoute(url); + // 서버사이드 라우터 시작 + router.start(url, 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; - const result = await routeInfo.handler(routeInfo.params); - console.log("✅ SSR 완료"); + 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); + } - return result; + // 상품 상세페이지용 스토어 설정 + 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) { - console.error("❌ SSR 에러:", error); return { - head: "에러", - html: "
서버 오류가 발생했습니다.
", - initialData: { error: error.message }, + html: `
서버 오류: ${error.message}
`, + head: "서버 오류", + data: JSON.stringify({}), }; } }; From 9a579f372797a3055837a71d3f9be19168e9951f Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 04:17:50 +0900 Subject: [PATCH 27/46] =?UTF-8?q?Feature=20:=20ssg=20Hompage.js=EC=97=90?= =?UTF-8?q?=20ssg=20=EC=84=9C=EB=B2=84=20=EC=A1=B0=EA=B1=B4=EB=B6=80=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/pages/HomePage.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/vanilla/src/pages/HomePage.js b/packages/vanilla/src/pages/HomePage.js index a4f05f9b..fafbaea5 100644 --- a/packages/vanilla/src/pages/HomePage.js +++ b/packages/vanilla/src/pages/HomePage.js @@ -27,7 +27,18 @@ export const HomePage = withLifecycle( return; } console.log("이 코드는 아무것도 없을 때!"); - loadProductsAndCategories(); + + // 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: [ () => { From 1aff91fc3487c4ff9e3cd462511ed2f8fbda3912 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 04:21:32 +0900 Subject: [PATCH 28/46] =?UTF-8?q?Feature=20:=20ssg=20ProductDetailPage.js?= =?UTF-8?q?=EC=97=90=20ssg=20=EC=84=9C=EB=B2=84=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EB=B6=80=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/pages/ProductDetailPage.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/vanilla/src/pages/ProductDetailPage.js b/packages/vanilla/src/pages/ProductDetailPage.js index 73d0ec30..898f9c37 100644 --- a/packages/vanilla/src/pages/ProductDetailPage.js +++ b/packages/vanilla/src/pages/ProductDetailPage.js @@ -237,7 +237,18 @@ function ProductDetail({ product, relatedProducts = [] }) { export const ProductDetailPage = withLifecycle( { onMount: () => { - loadProductDetailForPage(router.params.id); + // SSG 서버 실행 + if (typeof window !== "undefined") { + const { currentProduct, status } = productStore.getState(); + const productId = router.params.id; + // Hydration된 데이터가 있으면 API 호출 스킵 + if (window.__HYDRATED__ && currentProduct && currentProduct.productId === productId && status === "done") { + console.log("✅ SSR 데이터 이미 있어서 API 요청 스킵"); + return; + } + + loadProductDetailForPage(router.params.id); + } }, watches: [() => [router.params.id], () => loadProductDetailForPage(router.params.id)], }, From 4f707097cf55ac65e2cdf14cef293222127ad850 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 04:26:23 +0900 Subject: [PATCH 29/46] =?UTF-8?q?Feature=20:=20ssg=20withLifecycle.js=20wi?= =?UTF-8?q?ndow=20=EC=A1=B0=EA=B1=B4=EB=B6=84=EA=B8=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/router/withLifecycle.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 = []; + } }; // 페이지 언마운트 처리 From 0d2a1034d4ca53c0e2296ea7b9e5daafb2453c5a Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 04:30:22 +0900 Subject: [PATCH 30/46] =?UTF-8?q?Feature=20:=20ssg=20productApi.js=20baseU?= =?UTF-8?q?rl=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/api/productApi.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/vanilla/src/api/productApi.js b/packages/vanilla/src/api/productApi.js index 32e422b9..cf8c1672 100644 --- a/packages/vanilla/src/api/productApi.js +++ b/packages/vanilla/src/api/productApi.js @@ -1,5 +1,13 @@ // API 기본 URL 설정 -const BASE_URL = typeof window !== "undefined" ? "" : "http://localhost:5174"; +const getBaseUrl = () => { + if (typeof window !== "undefined") { + return ""; + } + const prod = process.env.NODE_ENV === "production"; + return prod ? "http://localhost:4174" : "http://localhost:5174"; +}; + +const BASE_URL = getBaseUrl(); // SSG 환경에서는 ServerRouter의 함수들을 직접 사용 export async function getProducts(params = {}) { From 7f9426ade8cce8c20982e3ccf671e24409bd3609 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 04:39:52 +0900 Subject: [PATCH 31/46] =?UTF-8?q?Feature=20:=20ssg=20createStorage.js=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=EC=A7=80=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/lib/createStorage.js | 28 +++++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/vanilla/src/lib/createStorage.js b/packages/vanilla/src/lib/createStorage.js index 08b504f2..f5e78367 100644 --- a/packages/vanilla/src/lib/createStorage.js +++ b/packages/vanilla/src/lib/createStorage.js @@ -1,10 +1,11 @@ /** - * 로컬스토리지 추상화 함수 - * @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" ? memoryStorage() : window.localStorage) => { + // 데이터 조회 const get = () => { try { const item = storage.getItem(key); @@ -15,6 +16,7 @@ export const createStorage = (key, storage = window.localStorage) => { } }; + // 데이터 저장 const set = (value) => { try { storage.setItem(key, JSON.stringify(value)); @@ -23,6 +25,7 @@ export const createStorage = (key, storage = window.localStorage) => { } }; + // 데이터 삭제 const reset = () => { try { storage.removeItem(key); @@ -33,3 +36,18 @@ export const createStorage = (key, storage = window.localStorage) => { return { get, set, reset }; }; + +/** + * 서버 환경용 메모리 스토리지 - localStorage API와 동일한 인터페이스 제공 + * @returns {Object} localStorage와 호환되는 메서드를 가진 객체 + */ +const memoryStorage = () => { + 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(), + }; +}; From de45eac6237265d7dae1ddeff56dac6a2c56b843 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 04:43:20 +0900 Subject: [PATCH 32/46] =?UTF-8?q?Feature=20:=20ssg=20productApi.js?= =?UTF-8?q?=EC=97=90=20=EC=84=A4=EB=AA=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/api/productApi.js | 37 +++++++++++++++++++++----- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/vanilla/src/api/productApi.js b/packages/vanilla/src/api/productApi.js index cf8c1672..78cc5762 100644 --- a/packages/vanilla/src/api/productApi.js +++ b/packages/vanilla/src/api/productApi.js @@ -1,19 +1,33 @@ -// API 기본 URL 설정 +// API 기본 URL을 환경별로 동적 설정 const getBaseUrl = () => { + // 클라이언트 환경: 상대 경로 사용 (같은 도메인의 API 호출) if (typeof window !== "undefined") { - return ""; + return ""; // 브라우저에서는 빈 문자열로 상대 경로 사용 } + + // 서버 환경: 절대 URL 필요 (서버에서 서버로 호출) const prod = process.env.NODE_ENV === "production"; - return prod ? "http://localhost:4174" : "http://localhost:5174"; + return prod ? "http://localhost:4174" : "http://localhost:5174"; // 환경별 포트 설정 }; -const BASE_URL = getBaseUrl(); +const BASE_URL = getBaseUrl(); // 런타임에 환경에 맞는 BASE_URL 결정 -// SSG 환경에서는 ServerRouter의 함수들을 직접 사용 +/** + * 상품 목록 조회 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(), @@ -23,17 +37,28 @@ export async function getProducts(params = {}) { sort, }); + // 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) { + // 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() { - // 클라이언트 환경에서는 기존 API 호출 유지 + // 카테고리는 자주 변경되지 않는 마스터 데이터 const response = await fetch(`${BASE_URL}/api/categories`); return await response.json(); } From cbba6b19e20900cb055e0d2686a84888f33e0f63 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 04:45:57 +0900 Subject: [PATCH 33/46] =?UTF-8?q?Feature=20:=20handlers.js=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/mocks/handlers.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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); From 586c37778a16841fb804387f23560c194f018ee0 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 04:51:38 +0900 Subject: [PATCH 34/46] =?UTF-8?q?Feature=20:=20=EA=B8=B0=EB=B3=B8=20SPA=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=84=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/lib/Router.js | 142 ++++++++++++++++++++++++++--- 1 file changed, 128 insertions(+), 14 deletions(-) diff --git a/packages/vanilla/src/lib/Router.js b/packages/vanilla/src/lib/Router.js index 7907c4a3..2238a878 100644 --- a/packages/vanilla/src/lib/Router.js +++ b/packages/vanilla/src/lib/Router.js @@ -1,48 +1,117 @@ /** - * 클라이언트사이드 SPA 라우터 + * 간단한 SPA 라우터 */ -import { BaseRouter } from "./BaseRouter.js"; +import { createObserver } from "./createObserver.js"; + +export class Router { + #routes; + #route; + #observer = createObserver(); + #baseUrl; -export class Router extends BaseRouter { constructor(baseUrl = "") { - super(baseUrl); + this.#routes = new Map(); + this.#route = null; + this.#baseUrl = baseUrl.replace(/\/$/, ""); window.addEventListener("popstate", () => { - this.updateRoute(this.getCurrentUrl()); + this.#route = this.#findRoute(); + this.#observer.notify(); }); } + get baseUrl() { + return this.#baseUrl; + } + get query() { - return BaseRouter.parseQuery(window.location.search); + return Router.parseQuery(window.location.search); } set query(newQuery) { - const newUrl = BaseRouter.getUrl(newQuery, this.baseUrl, window.location.pathname, window.location.search); + const newUrl = Router.getUrl(newQuery, this.#baseUrl); this.push(newUrl); } - getCurrentUrl() { - return `${window.location.pathname}${window.location.search}`; + get params() { + return this.#route?.params ?? {}; + } + + get route() { + return this.#route; } - getOrigin() { - return window.location.origin; + 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 { - let fullUrl = url.startsWith(this.baseUrl) ? url : this.baseUrl + (url.startsWith("/") ? url : "/" + url); + // 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.updateRoute(fullUrl); + this.#route = this.#findRoute(fullUrl); + this.#observer.notify(); } catch (error) { console.error("라우터 네비게이션 오류:", error); } @@ -52,6 +121,51 @@ export class Router extends BaseRouter { * 라우터 시작 */ start() { - this.updateRoute(this.getCurrentUrl()); + 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}` : ""}`; + }; } From d3f98ae80191024a4af7e38c352d0a103b86f9b7 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 05:20:09 +0900 Subject: [PATCH 35/46] =?UTF-8?q?Fix=20:=20=EC=8B=A4=ED=96=89=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=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 | 8 ++++---- packages/vanilla/src/main-server.js | 2 +- packages/vanilla/src/mocks/handlers.js | 6 +++--- packages/vanilla/src/mocks/serverBrowser.js | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index f13ca5b8..f276a790 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -2,13 +2,13 @@ import compression from "compression"; import express from "express"; import fs from "fs"; import sirv from "sirv"; -import { mockServer } from "./src/mocks/server-mock.js"; +import { mswServer } from "./src/mocks/serverBrowser.js"; // 환경 변수 및 설정 const isProd = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5173; const baseUrl = process.env.BASE || (isProd ? "/front_6th_chapter4-1/vanilla/" : "/"); -const templateHtml = isProd ? await fs.readFile("dist/vanilla/index.html", "utf-8") : ""; +const templateHtml = isProd ? fs.readFileSync("dist/vanilla/index.html", "utf-8") : ""; const app = express(); // 런타임에 결정되는 변수들 (개발/프로덕션 환경에 따라 달라짐) @@ -17,7 +17,7 @@ let render; let vite; // MSW 서버 시작 (API 모킹을 위해) -mockServer.listen({ +mswServer.listen({ onUnhandledRequest: "bypass", }); @@ -39,7 +39,7 @@ if (isProd) { } // 모든 라우트를 처리하는 SSR 핸들러 -app.get("*", async (req, res) => { +app.get("*all", async (req, res) => { try { if (!isProd) { // 개발 환경: 매 요청마다 템플릿을 다시 읽고 변환 diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 83358edb..9a4278c1 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -7,7 +7,7 @@ import { PRODUCT_ACTIONS } from "./stores/actionTypes"; // 라우터에 페이지별 경로와 컴포넌트 등록 router.addRoute("/", HomePage); router.addRoute("/product/:id/", ProductDetailPage); -router.addRoute(".*", NotFoundPage); +router.addRoute("*", NotFoundPage); /** * SSR 렌더링 메인 함수 - 서버에서 HTML을 생성하여 클라이언트로 전송 diff --git a/packages/vanilla/src/mocks/handlers.js b/packages/vanilla/src/mocks/handlers.js index 9cd19b09..9836e0f2 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/mocks/serverBrowser.js b/packages/vanilla/src/mocks/serverBrowser.js index 4aed3cae..1e78a8ee 100644 --- a/packages/vanilla/src/mocks/serverBrowser.js +++ b/packages/vanilla/src/mocks/serverBrowser.js @@ -1,6 +1,6 @@ // jest.setup.js import { setupServer } from "msw/node"; -import { handlers } from "./handlers"; +import { handlers } from "./handlers.js"; const mswServer = setupServer(...handlers); From d42b1c4ba969880f38750723d74edec5eb7e202c Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 05:20:35 +0900 Subject: [PATCH 36/46] =?UTF-8?q?Remove=20:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/lib/BaseRouter.js | 133 ------------------------- 1 file changed, 133 deletions(-) delete mode 100644 packages/vanilla/src/lib/BaseRouter.js diff --git a/packages/vanilla/src/lib/BaseRouter.js b/packages/vanilla/src/lib/BaseRouter.js deleted file mode 100644 index 282c0455..00000000 --- a/packages/vanilla/src/lib/BaseRouter.js +++ /dev/null @@ -1,133 +0,0 @@ -/** - * 기본 라우터 - 공통 기능을 제공하는 추상 클래스 - */ -import { createObserver } from "./createObserver.js"; - -export class BaseRouter { - #routes; - #route; - #observer = createObserver(); - #baseUrl; - - constructor(baseUrl = "") { - this.#routes = new Map(); - this.#route = null; - this.#baseUrl = baseUrl.replace(/\/$/, ""); - } - - get baseUrl() { - return this.#baseUrl; - } - - 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)); - return "([^/]+)"; - }) - .replace(/\//g, "\\/"); - - const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`); - - this.#routes.set(path, { - regex, - paramNames, - handler, - }); - } - - findRoute(url) { - const { pathname } = new URL(url, this.getOrigin()); - 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; - } - - updateRoute(url) { - this.#route = this.findRoute(url); - this.#observer.notify(); - } - - // 추상 메서드들 - 하위 클래스에서 구현 필요 - getCurrentUrl() { - throw new Error("getCurrentUrl must be implemented by subclass"); - } - - getOrigin() { - throw new Error("getOrigin must be implemented by subclass"); - } - - /** - * 쿼리 파라미터를 객체로 파싱 - */ - static parseQuery(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 = "", pathname = "", search = "") { - const currentQuery = BaseRouter.parseQuery(search); - const updatedQuery = { ...currentQuery, ...newQuery }; - - Object.keys(updatedQuery).forEach((key) => { - if (updatedQuery[key] === null || updatedQuery[key] === undefined || updatedQuery[key] === "") { - delete updatedQuery[key]; - } - }); - - const queryString = BaseRouter.stringifyQuery(updatedQuery); - return `${baseUrl}${pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`; - } -} From 54272ae36f2e958c391baa422ea547fdd1ab2c04 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 05:43:31 +0900 Subject: [PATCH 37/46] =?UTF-8?q?Feauture=20:=20static-site-generate.js=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/static-site-generate.js | 90 ++++++++++++++---------- 1 file changed, 54 insertions(+), 36 deletions(-) diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index 50994baf..1c33d53a 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -1,47 +1,65 @@ -import fs from "fs"; -import path from "node:path"; -import { createServer } from "vite"; +// SSG(Static Site Generation) 스크립트 +// 홈페이지와 상품 상세 페이지를 미리 생성하여 정적 파일로 저장 +import fs from "fs/promises"; +import { mswServer } from "./src/mocks/serverBrowser.js"; +import items from "./src/mocks/items.json" with { type: "json" }; -const vite = await createServer({ - server: { middlewareMode: true }, - appType: "custom", -}); +// 서버 사이드 렌더링 함수 가져오기 +const { render } = await import("./dist/vanilla-ssr/main-server.js"); -const { mswServer } = await vite.ssrLoadModule("./src/mocks/serverBrowser.js"); -mswServer.listen({ - onUnhandledRequest: "bypass", -}); -const { render } = await vite.ssrLoadModule("./src/main-server.tsx"); +// 기본 베이스 URL +const BASE = "/front_6th_chapter4-1/vanilla/"; -const joinDist = (...pathnames) => path.join("../../dist/react", ...pathnames); +/** + * 주어진 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 template = fs.readFileSync(joinDist("/index.html"), "utf-8"); + // 템플릿에 렌더링된 내용 삽입 + const result = template + .replace(``, head ?? "") + .replace(``, ``) + .replace(``, html ?? ""); -async function generateStaticSite(pathname) { - const fullPathname = pathname.endsWith(".html") ? joinDist(pathname) : joinDist(pathname, "/index.html"); - const parsedPath = path.parse(fullPathname); + await fs.writeFile(outFile, result, "utf-8"); +} - const rendered = await render(pathname, {}); +/** + * 정적 사이트 생성 메인 함수 + */ +async function generateStaticSite() { + // HTML 템플릿 읽기 + const templatePath = "../../dist/vanilla/index.html"; + const template = await fs.readFile(templatePath, "utf-8"); - const html = template - .replace(``, rendered.head ?? "") - .replace(``, rendered.html ?? "") - .replace( - ``, - ``, - ); + // MSW 서버 시작 (API 요청 처리용) + mswServer.listen({ onUnhandledRequest: "bypass" }); - if (!fs.existsSync(parsedPath.dir)) { - fs.mkdirSync(parsedPath.dir, { recursive: true }); - } + try { + // 홈페이지 생성 + await writeRoute(BASE, template, templatePath); - fs.writeFileSync(fullPathname, html); -} + const productIds = items.slice(1, 10).map((p) => p.productId); -// 상세페이지 생성 -const { getProducts } = await vite.ssrLoadModule("./src/api/productApi.ts"); -const { products } = await getProducts(); -await Promise.all(products.map(async ({ productId }) => await generateStaticSite(`/product/${productId}/`))); + // 각 상품별로 상세 페이지 생성 + 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`); + } + + console.log("✅ SSG 실행 완료"); + } finally { + // MSW 서버 종료 + mswServer.close(); + } +} -mswServer.close(); -vite.close(); +// SSG 실행 +await generateStaticSite(); From 196a02f524c3f170d3ca10ed0b238ad441e822b4 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 06:17:33 +0900 Subject: [PATCH 38/46] =?UTF-8?q?Fix=20:=20build=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/main-server.js | 16 +++++- packages/vanilla/src/mocks/handlers.js | 71 +++++++++++++++++++++++- packages/vanilla/src/router/router.js | 2 +- packages/vanilla/static-site-generate.js | 9 +-- 4 files changed, 88 insertions(+), 10 deletions(-) diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 9a4278c1..35d31e58 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -7,7 +7,7 @@ import { PRODUCT_ACTIONS } from "./stores/actionTypes"; // 라우터에 페이지별 경로와 컴포넌트 등록 router.addRoute("/", HomePage); router.addRoute("/product/:id/", ProductDetailPage); -router.addRoute("*", NotFoundPage); +router.addRoute(".*", NotFoundPage); /** * SSR 렌더링 메인 함수 - 서버에서 HTML을 생성하여 클라이언트로 전송 @@ -17,8 +17,20 @@ router.addRoute("*", NotFoundPage); */ export const render = async (url = "", query) => { try { + // URL 정규화: 베이스 URL이 포함된 경우 제거 + const baseUrl = "/front_6th_chapter4-1/vanilla/"; + let normalizedUrl = url; + + if (url.includes(baseUrl)) { + normalizedUrl = url.replace(baseUrl, "/"); + if (normalizedUrl === "/") normalizedUrl = "/"; + } else if (url === baseUrl.slice(0, -1)) { + // 마지막 슬래시 없는 경우 + normalizedUrl = "/"; + } + // 서버사이드 라우터 시작 - router.start(url, query); + router.start(normalizedUrl, query); const route = router.route; if (!route) { diff --git a/packages/vanilla/src/mocks/handlers.js b/packages/vanilla/src/mocks/handlers.js index 9836e0f2..289d9cc5 100644 --- a/packages/vanilla/src/mocks/handlers.js +++ b/packages/vanilla/src/mocks/handlers.js @@ -63,7 +63,7 @@ function filterProducts(products, query) { } export const handlers = [ - // 상품 목록 API + // 상품 목록 API (상대 경로) http.get("/api/products", async ({ request }) => { const url = new URL(request.url); const page = parseInt(url.searchParams.get("page") ?? url.searchParams.get("current")) || 1; @@ -138,4 +138,73 @@ export const handlers = [ await delay(); return HttpResponse.json(categories); }), + + // 절대 URL 패턴도 처리 (SSG용) + http.get("http://localhost:5174/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; + const search = url.searchParams.get("search") || ""; + const category1 = url.searchParams.get("category1") || ""; + const category2 = url.searchParams.get("category2") || ""; + const sort = url.searchParams.get("sort") || "price_asc"; + + const filteredProducts = filterProducts(items, { + search, + category1, + category2, + sort, + }); + + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const paginatedProducts = filteredProducts.slice(startIndex, endIndex); + + const response = { + 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, + }, + }; + + await delay(); + return HttpResponse.json(response); + }), + + http.get("http://localhost:5174/api/products/:id", ({ params }) => { + const { id } = params; + const product = items.find((item) => item.productId === id); + + if (!product) { + return HttpResponse.json({ error: "Product not found" }, { status: 404 }); + } + + const detailProduct = { + ...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, + images: [product.image, product.image.replace(".jpg", "_2.jpg"), product.image.replace(".jpg", "_3.jpg")], + }; + + return HttpResponse.json(detailProduct); + }), + + http.get("http://localhost:5174/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 3b9552ab..07d2e479 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -2,4 +2,4 @@ import { Router, ServerRouter } from "../lib"; import { BASE_URL } from "../constants.js"; -export const router = typeof window === "undefined" ? new ServerRouter() : new Router(BASE_URL); +export const router = typeof window === "undefined" ? new ServerRouter("") : new Router(BASE_URL); diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index 1c33d53a..db0d76d4 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -7,9 +7,6 @@ import items from "./src/mocks/items.json" with { type: "json" }; // 서버 사이드 렌더링 함수 가져오기 const { render } = await import("./dist/vanilla-ssr/main-server.js"); -// 기본 베이스 URL -const BASE = "/front_6th_chapter4-1/vanilla/"; - /** * 주어진 URL을 렌더링하여 HTML 파일로 생성 * @param {string} url - 렌더링할 URL @@ -41,14 +38,14 @@ async function generateStaticSite() { mswServer.listen({ onUnhandledRequest: "bypass" }); try { - // 홈페이지 생성 - await writeRoute(BASE, template, templatePath); + // 홈페이지 생성 (루트 경로로 전달) + await writeRoute("/", template, templatePath); const productIds = items.slice(1, 10).map((p) => p.productId); // 각 상품별로 상세 페이지 생성 for (const id of productIds) { - const url = `${BASE}product/${id}/`; + const url = `/product/${id}/`; // 베이스 URL 제거, 상대 경로만 사용 const outDir = `../../dist/vanilla/product/${id}`; await fs.mkdir(outDir, { recursive: true }); await writeRoute(url, template, `${outDir}/index.html`); From 2b47affe22dc85be00ad58333ceff2b30d14bac3 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 06:34:09 +0900 Subject: [PATCH 39/46] =?UTF-8?q?Feature=20:=20html=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/index.html | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 @@ - + + From 78775ae6eb88a54653a39a4aa4f41abeb6d21e84 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 07:08:17 +0900 Subject: [PATCH 40/46] =?UTF-8?q?Refactor=20:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/server.js | 12 +-- .../vanilla/src/lib/createServerStorage.js | 5 ++ packages/vanilla/src/lib/createStorage.js | 21 ++--- packages/vanilla/src/main-server.js | 14 +--- packages/vanilla/src/main.js | 58 ++++++++++++-- packages/vanilla/src/mocks/handlers.js | 77 +------------------ .../mocks/{serverBrowser.js => mswServer.js} | 0 .../vanilla/src/pages/ProductDetailPage.js | 14 ++-- packages/vanilla/src/router/router.js | 2 +- packages/vanilla/static-site-generate.js | 8 +- 10 files changed, 86 insertions(+), 125 deletions(-) rename packages/vanilla/src/mocks/{serverBrowser.js => mswServer.js} (100%) diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index f276a790..d03c5234 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,6 +1,6 @@ import compression from "compression"; import express from "express"; -import fs from "fs"; +import fs from "fs/promises"; import sirv from "sirv"; import { mswServer } from "./src/mocks/serverBrowser.js"; @@ -8,12 +8,9 @@ import { mswServer } from "./src/mocks/serverBrowser.js"; const isProd = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5173; const baseUrl = process.env.BASE || (isProd ? "/front_6th_chapter4-1/vanilla/" : "/"); -const templateHtml = isProd ? fs.readFileSync("dist/vanilla/index.html", "utf-8") : ""; +const templateHtml = isProd ? await fs.readFile("dist/vanilla/index.html", "utf-8") : ""; const app = express(); -// 런타임에 결정되는 변수들 (개발/프로덕션 환경에 따라 달라짐) -let template; -let render; let vite; // MSW 서버 시작 (API 모킹을 위해) @@ -39,8 +36,11 @@ if (isProd) { } // 모든 라우트를 처리하는 SSR 핸들러 -app.get("*all", async (req, res) => { +app.get(/^(?!.*\/api).*/, async (req, res) => { try { + let template; + let render; + if (!isProd) { // 개발 환경: 매 요청마다 템플릿을 다시 읽고 변환 template = await fs.readFile("./index.html", "utf-8"); diff --git a/packages/vanilla/src/lib/createServerStorage.js b/packages/vanilla/src/lib/createServerStorage.js index 4450f73f..10cacd4d 100644 --- a/packages/vanilla/src/lib/createServerStorage.js +++ b/packages/vanilla/src/lib/createServerStorage.js @@ -1,3 +1,7 @@ +/** + * 서버 환경용 메모리 스토리지 - localStorage API와 동일한 인터페이스 제공 + * @returns {Object} localStorage와 호환되는 메서드를 가진 객체 + */ export const createServerStorage = () => { const storage = new Map(); @@ -5,5 +9,6 @@ export const createServerStorage = () => { 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 f5e78367..0aef539b 100644 --- a/packages/vanilla/src/lib/createStorage.js +++ b/packages/vanilla/src/lib/createStorage.js @@ -1,10 +1,14 @@ +import { createServerStorage } from "./createServerStorage.js"; /** * 환경별 스토리지 추상화 함수 - 브라우저/서버 환경에서 동일한 인터페이스 제공 * @param {string} key - 스토리지 키 (예: "cart", "user-preferences") * @param {Storage} storage - 스토리지 구현체 (기본값: 환경별 자동 선택) * @returns {Object} { get, set, reset } 스토리지 조작 메서드들 */ -export const createStorage = (key, storage = typeof window === "undefined" ? memoryStorage() : window.localStorage) => { +export const createStorage = ( + key, + storage = typeof window === "undefined" ? createServerStorage() : window.localStorage, +) => { // 데이터 조회 const get = () => { try { @@ -36,18 +40,3 @@ export const createStorage = (key, storage = typeof window === "undefined" ? mem return { get, set, reset }; }; - -/** - * 서버 환경용 메모리 스토리지 - localStorage API와 동일한 인터페이스 제공 - * @returns {Object} localStorage와 호환되는 메서드를 가진 객체 - */ -const memoryStorage = () => { - 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/main-server.js b/packages/vanilla/src/main-server.js index 35d31e58..83358edb 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -17,20 +17,8 @@ router.addRoute(".*", NotFoundPage); */ export const render = async (url = "", query) => { try { - // URL 정규화: 베이스 URL이 포함된 경우 제거 - const baseUrl = "/front_6th_chapter4-1/vanilla/"; - let normalizedUrl = url; - - if (url.includes(baseUrl)) { - normalizedUrl = url.replace(baseUrl, "/"); - if (normalizedUrl === "/") normalizedUrl = "/"; - } else if (url === baseUrl.slice(0, -1)) { - // 마지막 슬래시 없는 경우 - normalizedUrl = "/"; - } - // 서버사이드 라우터 시작 - router.start(normalizedUrl, query); + router.start(url, query); const route = router.route; if (!route) { diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js index dfac3c48..150f7aed 100644 --- a/packages/vanilla/src/main.js +++ b/packages/vanilla/src/main.js @@ -17,18 +17,62 @@ const enableMocking = () => }), ); -function main() { - // 서버 데이터 복원 - if (window.__INITIAL_DATA__) { - const data = window.__INITIAL_DATA__; - if (data.products) productStore.dispatch(PRODUCT_ACTIONS.SETUP, data); - if (data.currentProduct) productStore.dispatch(PRODUCT_ACTIONS.SET_CURRENT_PRODUCT, data); - delete window.__INITIAL_DATA__; +// 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 289d9cc5..9cd19b09 100644 --- a/packages/vanilla/src/mocks/handlers.js +++ b/packages/vanilla/src/mocks/handlers.js @@ -63,8 +63,8 @@ function filterProducts(products, query) { } export const handlers = [ - // 상품 목록 API (상대 경로) - http.get("/api/products", async ({ request }) => { + // 상품 목록 API + 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,76 +133,7 @@ export const handlers = [ }), // 카테고리 목록 API - http.get("/api/categories", async () => { - const categories = getUniqueCategories(); - await delay(); - return HttpResponse.json(categories); - }), - - // 절대 URL 패턴도 처리 (SSG용) - http.get("http://localhost:5174/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; - const search = url.searchParams.get("search") || ""; - const category1 = url.searchParams.get("category1") || ""; - const category2 = url.searchParams.get("category2") || ""; - const sort = url.searchParams.get("sort") || "price_asc"; - - const filteredProducts = filterProducts(items, { - search, - category1, - category2, - sort, - }); - - const startIndex = (page - 1) * limit; - const endIndex = startIndex + limit; - const paginatedProducts = filteredProducts.slice(startIndex, endIndex); - - const response = { - 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, - }, - }; - - await delay(); - return HttpResponse.json(response); - }), - - http.get("http://localhost:5174/api/products/:id", ({ params }) => { - const { id } = params; - const product = items.find((item) => item.productId === id); - - if (!product) { - return HttpResponse.json({ error: "Product not found" }, { status: 404 }); - } - - const detailProduct = { - ...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, - images: [product.image, product.image.replace(".jpg", "_2.jpg"), product.image.replace(".jpg", "_3.jpg")], - }; - - return HttpResponse.json(detailProduct); - }), - - http.get("http://localhost:5174/api/categories", async () => { + http.get("*/api/categories", async () => { const categories = getUniqueCategories(); await delay(); return HttpResponse.json(categories); diff --git a/packages/vanilla/src/mocks/serverBrowser.js b/packages/vanilla/src/mocks/mswServer.js similarity index 100% rename from packages/vanilla/src/mocks/serverBrowser.js rename to packages/vanilla/src/mocks/mswServer.js diff --git a/packages/vanilla/src/pages/ProductDetailPage.js b/packages/vanilla/src/pages/ProductDetailPage.js index 898f9c37..8a4dbf1c 100644 --- a/packages/vanilla/src/pages/ProductDetailPage.js +++ b/packages/vanilla/src/pages/ProductDetailPage.js @@ -239,12 +239,14 @@ export const ProductDetailPage = withLifecycle( onMount: () => { // SSG 서버 실행 if (typeof window !== "undefined") { - const { currentProduct, status } = productStore.getState(); - const productId = router.params.id; - // Hydration된 데이터가 있으면 API 호출 스킵 - if (window.__HYDRATED__ && currentProduct && currentProduct.productId === productId && status === "done") { - console.log("✅ SSR 데이터 이미 있어서 API 요청 스킵"); - return; + 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); diff --git a/packages/vanilla/src/router/router.js b/packages/vanilla/src/router/router.js index 07d2e479..75e7ba3c 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -2,4 +2,4 @@ import { Router, ServerRouter } from "../lib"; import { BASE_URL } from "../constants.js"; -export const router = typeof window === "undefined" ? new ServerRouter("") : new Router(BASE_URL); +export const router = typeof window !== "undefined" ? new Router(BASE_URL) : new ServerRouter(BASE_URL); diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index db0d76d4..e8a32cc5 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -1,12 +1,14 @@ // SSG(Static Site Generation) 스크립트 // 홈페이지와 상품 상세 페이지를 미리 생성하여 정적 파일로 저장 import fs from "fs/promises"; -import { mswServer } from "./src/mocks/serverBrowser.js"; +import { mswServer } from "./src/mocks/mswServer.js"; import items from "./src/mocks/items.json" with { type: "json" }; // 서버 사이드 렌더링 함수 가져오기 const { render } = await import("./dist/vanilla-ssr/main-server.js"); +const BASE = "/front_6th_chapter4-1/vanilla/"; + /** * 주어진 URL을 렌더링하여 HTML 파일로 생성 * @param {string} url - 렌더링할 URL @@ -39,13 +41,13 @@ async function generateStaticSite() { try { // 홈페이지 생성 (루트 경로로 전달) - await writeRoute("/", template, templatePath); + await writeRoute(BASE, template, templatePath); const productIds = items.slice(1, 10).map((p) => p.productId); // 각 상품별로 상세 페이지 생성 for (const id of productIds) { - const url = `/product/${id}/`; // 베이스 URL 제거, 상대 경로만 사용 + const url = `${BASE}/product/${id}/`; const outDir = `../../dist/vanilla/product/${id}`; await fs.mkdir(outDir, { recursive: true }); await writeRoute(url, template, `${outDir}/index.html`); From af52bc10582edc5819c78b6df3cc1e4455ec0acb Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 07:12:17 +0900 Subject: [PATCH 41/46] =?UTF-8?q?Remove=20:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20dependencies=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/package.json b/package.json index 019895f2..27780346 100644 --- a/package.json +++ b/package.json @@ -50,10 +50,5 @@ "typescript-eslint": "^8.36.0", "vite": "npm:rolldown-vite@latest", "vitest": "latest" - }, - "dependencies": { - "compression": "^1.8.1", - "express": "^5.1.0", - "sirv": "^3.0.2" } } From 7b9d0b24a60d9cd00c9a1a288e744d7f64d76516 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 07:26:09 +0900 Subject: [PATCH 42/46] =?UTF-8?q?Remove:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/mocks/server.js | 135 --------------------------- 1 file changed, 135 deletions(-) delete mode 100644 packages/vanilla/src/mocks/server.js diff --git a/packages/vanilla/src/mocks/server.js b/packages/vanilla/src/mocks/server.js deleted file mode 100644 index 992e89ee..00000000 --- a/packages/vanilla/src/mocks/server.js +++ /dev/null @@ -1,135 +0,0 @@ -import { http, HttpResponse } from "msw"; -import items from "./items.json"; - -const delay = async () => await new Promise((resolve) => setTimeout(resolve, 200)); - -// 카테고리 추출 함수 -export 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 getProductsOnServer(query = {}) { - const page = parseInt(query.page ?? query.current) || 1; - const limit = parseInt(query.limit) || 20; - const search = query.search || ""; - const category1 = query.category1 || ""; - const category2 = query.category2 || ""; - const sort = query.sort || "price_asc"; - - // 필터링된 상품들 - 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 const handlers = [ - // 상품 상세 API - http.get("/api/products/:id", ({ params }) => { - const { id } = params; - const product = items.find((item) => item.productId === id); - - if (!product) { - return HttpResponse.json({ error: "Product not found" }, { status: 404 }); - } - - // 상세 정보에 추가 데이터 포함 - const detailProduct = { - ...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")], - }; - - return HttpResponse.json(detailProduct); - }), - - // 카테고리 목록 API - http.get("/api/categories", async () => { - const categories = getUniqueCategories(); - await delay(); - return HttpResponse.json(categories); - }), -]; From 20f9ebd7012f6bfe3e1fe3e057af8fecd9767d86 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 07:27:28 +0900 Subject: [PATCH 43/46] =?UTF-8?q?Chore=20:=20pnpm-lock.yaml=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=9B=90=EB=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pnpm-lock.yaml | 64 +++++++++++++++++--------------------------------- 1 file changed, 22 insertions(+), 42 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64929d2b..766adcbc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,16 +7,6 @@ settings: importers: .: - dependencies: - compression: - specifier: ^1.8.1 - version: 1.8.1 - express: - specifier: ^5.1.0 - version: 5.1.0 - sirv: - specifier: ^3.0.2 - version: 3.0.2 devDependencies: '@babel/core': specifier: latest @@ -111,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.9(@types/react@19.1.12))(@types/react@19.1.12)(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.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/user-event': specifier: latest version: 14.6.1(@testing-library/dom@10.4.1) @@ -120,10 +110,10 @@ importers: version: 24.0.13 '@types/react': specifier: latest - version: 19.1.12 + version: 19.1.11 '@types/react-dom': specifier: latest - version: 19.1.9(@types/react@19.1.12) + version: 19.1.7(@types/react@19.1.11) '@types/use-sync-external-store': specifier: latest version: 1.5.0 @@ -187,16 +177,16 @@ importers: version: 24.0.13 '@types/react': specifier: latest - version: 19.1.12 + version: 19.1.11 '@types/react-dom': specifier: latest - version: 19.1.9(@types/react@19.1.12) + version: 19.1.7(@types/react@19.1.11) '@types/use-sync-external-store': specifier: latest version: 1.5.0 '@vitejs/plugin-react': specifier: latest - version: 5.0.2(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0)) + version: 5.0.1(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0)) compression: specifier: ^1.7.5 version: 1.8.1 @@ -1113,13 +1103,13 @@ packages: '@types/node@24.0.13': resolution: {integrity: sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==} - '@types/react-dom@19.1.9': - resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==} + '@types/react-dom@19.1.7': + resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==} peerDependencies: '@types/react': ^19.0.0 - '@types/react@19.1.12': - resolution: {integrity: sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==} + '@types/react@19.1.11': + resolution: {integrity: sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==} '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -1201,8 +1191,8 @@ packages: peerDependencies: vite: ^4 || ^5 || ^6 || ^7 - '@vitejs/plugin-react@5.0.2': - resolution: {integrity: sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw==} + '@vitejs/plugin-react@5.0.1': + resolution: {integrity: sha512-DE4UNaBXwtVoDJ0ccBdLVjFTWL70NRuWNCxEieTI3lrq9ORB9aOCQEKstwDXBl87NvFdbqh/p7eINGyj0BthJA==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -2671,10 +2661,6 @@ packages: resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} engines: {node: '>=18'} - sirv@3.0.2: - resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} - engines: {node: '>=18'} - slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -3715,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.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/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)': 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.12 - '@types/react-dom': 19.1.9(@types/react@19.1.12) + '@types/react': 19.1.11 + '@types/react-dom': 19.1.7(@types/react@19.1.11) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -3773,11 +3759,11 @@ snapshots: dependencies: undici-types: 7.8.0 - '@types/react-dom@19.1.9(@types/react@19.1.12)': + '@types/react-dom@19.1.7(@types/react@19.1.11)': dependencies: - '@types/react': 19.1.12 + '@types/react': 19.1.11 - '@types/react@19.1.12': + '@types/react@19.1.11': dependencies: csstype: 3.1.3 @@ -3892,12 +3878,12 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - '@vitejs/plugin-react@5.0.2(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0))': + '@vitejs/plugin-react@5.0.1(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.34 + '@rolldown/pluginutils': 1.0.0-beta.32 '@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) @@ -3981,7 +3967,7 @@ snapshots: fflate: 0.8.2 flatted: 3.3.3 pathe: 2.0.3 - sirv: 3.0.2 + sirv: 3.0.1 tinyglobby: 0.2.14 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) @@ -4168,7 +4154,7 @@ snapshots: compressible@2.0.18: dependencies: - mime-db: 1.54.0 + mime-db: 1.52.0 compression@1.8.1: dependencies: @@ -5419,12 +5405,6 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 - sirv@3.0.2: - dependencies: - '@polka/url': 1.0.0-next.29 - mrmime: 2.0.1 - totalist: 3.0.1 - slash@3.0.0: {} slice-ansi@5.0.0: From 713e0e9aac2853f8a24efa194c7047d4fa5678d2 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 07:32:21 +0900 Subject: [PATCH 44/46] =?UTF-8?q?Fix=20:=20build=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index d03c5234..c99ab801 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -2,7 +2,7 @@ import compression from "compression"; import express from "express"; import fs from "fs/promises"; import sirv from "sirv"; -import { mswServer } from "./src/mocks/serverBrowser.js"; +import { mswServer } from "./src/mocks/mswServer.js"; // 환경 변수 및 설정 const isProd = process.env.NODE_ENV === "production"; From 65709376000d2c9597bd706474c87ae07e162c80 Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 08:09:02 +0900 Subject: [PATCH 45/46] =?UTF-8?q?Fix=20:=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EB=AA=BB=EA=B0=80=EC=A0=B8=EC=98=A4=EB=8A=94=20=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/static-site-generate.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index e8a32cc5..1aa96f31 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -43,7 +43,10 @@ async function generateStaticSite() { // 홈페이지 생성 (루트 경로로 전달) await writeRoute(BASE, template, templatePath); - const productIds = items.slice(1, 10).map((p) => p.productId); + // 상품 상세 + const productIds = items.slice(100, 130).map((p) => p.productId); + const testItem = items.find((product) => product.productId === "86940857379"); + productIds.push(testItem.productId); // 각 상품별로 상세 페이지 생성 for (const id of productIds) { From cd668dab4d5d369821ec076d394e0d84bb18b44f Mon Sep 17 00:00:00 2001 From: hmyo2853 Date: Fri, 5 Sep 2025 08:18:43 +0900 Subject: [PATCH 46/46] =?UTF-8?q?Feature=20:=20ssg=20=ED=95=98=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95(=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=86=B5=EA=B3=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/server.js | 5 +++-- packages/vanilla/src/lib/ServerRouter.js | 4 +++- packages/vanilla/src/main-server.js | 9 ++++++++- packages/vanilla/src/router/router.js | 2 +- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index c99ab801..10acf34d 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -20,9 +20,10 @@ mswServer.listen({ // 환경별 정적 파일 서빙 및 미들웨어 설정 if (isProd) { - // 프로덕션 환경: 빌드된 정적 파일 서빙 + // 프로덕션 환경: 빌드된 정적 파일 서빙 (assets만 서빙하도록 제한) app.use(compression()); - app.use(baseUrl, sirv("dist/vanilla", { dev: false })); + 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"); diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js index b9955008..be374bb0 100644 --- a/packages/vanilla/src/lib/ServerRouter.js +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -80,7 +80,9 @@ export class ServerRouter { }) .replace(/\//g, "\\/"); - const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`); + // baseUrl과 regexPath 사이의 중복 슬래시 제거 + const normalizedPath = `${this.#baseUrl}${regexPath}`.replace(/\/+/g, "/"); + const regex = new RegExp(`^${normalizedPath}$`); // 라우트 정보를 Map에 저장 this.#routes.set(path, { diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 83358edb..26300547 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -3,6 +3,7 @@ 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); @@ -17,8 +18,14 @@ router.addRoute(".*", NotFoundPage); */ 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(url, query); + router.start(normalizedUrl, query); const route = router.route; if (!route) { diff --git a/packages/vanilla/src/router/router.js b/packages/vanilla/src/router/router.js index 75e7ba3c..d89a2e91 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -2,4 +2,4 @@ import { Router, ServerRouter } from "../lib"; import { BASE_URL } from "../constants.js"; -export const router = typeof window !== "undefined" ? new Router(BASE_URL) : new ServerRouter(BASE_URL); +export const router = typeof window !== "undefined" ? new Router(BASE_URL) : new ServerRouter("");