From 633aad2fb412b142bee58522dd7c4b869098364b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=ED=98=9C?= Date: Tue, 2 Sep 2025 23:46:26 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20SSR=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=86=B5?= =?UTF-8?q?=EA=B3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버용 라우터 및 데이터 프리페칭 구현 - HTML 템플릿 치환 및 초기 데이터 주입 --- packages/vanilla/index.html | 15 ++++---- packages/vanilla/server.js | 75 +++++++++++++++++++++++++++---------- 2 files changed, 63 insertions(+), 27 deletions(-) diff --git a/packages/vanilla/index.html b/packages/vanilla/index.html index 483a6d5e..c610980b 100644 --- a/packages/vanilla/index.html +++ b/packages/vanilla/index.html @@ -5,22 +5,23 @@ - +
+ diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index b9a56d98..14c740bc 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,31 +1,66 @@ +import fs from "node:fs/promises"; import express from "express"; 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 templateHtml = prod ? await fs.readFile("./dist/client/index.html", "utf-8") : ""; + const app = express(); -const render = () => { - return `
안녕하세요
`; -}; - -app.get("*all", (req, res) => { - res.send( - ` - - - - - - Vanilla Javascript SSR - - -
${render()}
- - - `.trim(), - ); +let vite; +// use 는 모든 요청에 대해 실행되고, get 은 특정 요청에 대해 실행 +if (!prod) { + const { createServer } = await import("vite"); + vite = await createServer({ + server: { middlewareMode: true }, + appType: "custom", + base, + }); + app.use(vite.middlewares); +} else { + const compression = (await import("compression")).default; + const sirv = (await import("sirv")).default; + app.use(compression()); + app.use(base, sirv("./dist/client", { extensions: [] })); +} +app.use("*all", async (req, res) => { + try { + const url = req.originalUrl.replace(base, ""); + + /** @type {string} */ + let template; + /** @type {import('./src/main-server.js').render} */ + let render; + if (!prod) { + // Always read fresh template in development + template = await fs.readFile("./index.html", "utf-8"); + template = await vite.transformIndexHtml(url, template); + render = (await vite.ssrLoadModule("/src/main-server.js")).render; + } else { + template = templateHtml; + render = (await import("./dist/vanilla-ssr/main-server.js")).render; + } + + // server에 있는 값을 전달해주기 위해서 인자로 넘겨줘야함. + const query = req.query; + const rendered = await render(url, query); + + const html = template + .replace(``, rendered.head ?? "") + .replace(``, rendered.html ?? "") + .replace( + ``, + ``, + ); + + res.status(200).set({ "Content-Type": "text/html" }).send(html); + } catch (e) { + vite?.ssrFixStacktrace(e); + console.log(e.stack); + res.status(500).end(e.stack); + } }); // Start http server From 9ab5dd007bda435b777b626d01edb5f9409f7ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=ED=98=9C?= Date: Wed, 3 Sep 2025 01:44:37 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20SSG=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=B0=8F=20=EC=84=9C=EB=B2=84=20=ED=99=98=EA=B2=BD=20=ED=98=B8?= =?UTF-8?q?=ED=99=98=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SSG를 통한 정적 페이지 자동 생성 (홈페이지, 404, 상품 상세) - window.__INITIAL_DATA__ 포함하여 Hydration 지원 - 서버 환경에서 createStorage 안전하게 사용하도록 수정 --- packages/vanilla/server.js | 24 +- packages/vanilla/src/lib/createStorage.js | 5 +- packages/vanilla/src/main-server.js | 332 +++++++++++++++++++++- packages/vanilla/src/main.js | 44 +++ packages/vanilla/static-site-generate.js | 111 +++++++- 5 files changed, 496 insertions(+), 20 deletions(-) diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index 14c740bc..50ca9faf 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -2,10 +2,10 @@ import fs from "node:fs/promises"; import express from "express"; const prod = process.env.NODE_ENV === "production"; -const port = process.env.PORT || 5173; +const port = process.env.PORT || 5174; // SSR 서버 포트 const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/"); -const templateHtml = prod ? await fs.readFile("./dist/client/index.html", "utf-8") : ""; +const templateHtml = prod ? await fs.readFile("./dist/vanilla/index.html", "utf-8") : ""; const app = express(); @@ -23,9 +23,23 @@ if (!prod) { const compression = (await import("compression")).default; const sirv = (await import("sirv")).default; app.use(compression()); - app.use(base, sirv("./dist/client", { extensions: [] })); + app.use(base, sirv("./dist/vanilla", { extensions: [] })); } -app.use("*all", async (req, res) => { + +// API 프록시 - 기존 API 서버로 요청 전달 +app.use("/api/*", async (req, res) => { + try { + const apiUrl = `http://localhost:5173${req.url}`; + const response = await fetch(apiUrl); + const data = await response.json(); + res.json(data); + } catch (error) { + console.error("API 프록시 오류:", error); + res.status(500).json({ error: "API 서버 연결 실패" }); + } +}); + +app.use("*", async (req, res) => { try { const url = req.originalUrl.replace(base, ""); @@ -65,5 +79,5 @@ app.use("*all", async (req, res) => { // Start http server app.listen(port, () => { - console.log(`React Server started at http://localhost:${port}`); + console.log(`Vanilla SSR Server started at http://localhost:${port}`); }); diff --git a/packages/vanilla/src/lib/createStorage.js b/packages/vanilla/src/lib/createStorage.js index 08b504f2..73c29694 100644 --- a/packages/vanilla/src/lib/createStorage.js +++ b/packages/vanilla/src/lib/createStorage.js @@ -4,9 +4,10 @@ * @param {Storage} storage - 기본값은 localStorage * @returns {Object} { get, set, reset } */ -export const createStorage = (key, storage = window.localStorage) => { +export const createStorage = (key, storage = typeof window !== "undefined" ? window.localStorage : null) => { const get = () => { try { + if (!storage) return null; const item = storage.getItem(key); return item ? JSON.parse(item) : null; } catch (error) { @@ -17,6 +18,7 @@ export const createStorage = (key, storage = window.localStorage) => { const set = (value) => { try { + if (!storage) return; storage.setItem(key, JSON.stringify(value)); } catch (error) { console.error(`Error setting storage item for key "${key}":`, error); @@ -25,6 +27,7 @@ export const createStorage = (key, storage = window.localStorage) => { const reset = () => { try { + if (!storage) return; storage.removeItem(key); } catch (error) { console.error(`Error removing storage item for key "${key}":`, error); diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 40b58858..b601b76a 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,4 +1,328 @@ -export const render = async (url, query) => { - console.log({ url, query }); - return ""; -}; +// 서버용 렌더링 - 직접 데이터 읽기 +import fs from "fs"; +import path from "path"; + +// 서버용 라우터 (요구사항에 맞게 구현) +class ServerRouter { + constructor() { + this.routes = new Map(); + } + + addRoute(path, handler) { + // :id → ([^/]+) 정규식 변환 + const paramNames = []; + const regexPath = path + .replace(/:\w+/g, (match) => { + paramNames.push(match.slice(1)); + return "([^/]+)"; + }) + .replace(/\//g, "\\/"); + + const regex = new RegExp(`^${regexPath}$`); + + this.routes.set(path, { + regex, + paramNames, + handler, + path, + }); + } + + findRoute(url) { + for (const [, route] of this.routes) { + const match = url.match(route.regex); + if (match) { + const params = {}; + route.paramNames.forEach((name, index) => { + params[name] = match[index + 1]; + }); + + return { + ...route, + params, + }; + } + } + return null; + } +} + +// 서버용 데이터 읽기 (직접 파일에서 읽기) +function getProductsFromFile(query = {}) { + try { + const itemsPath = path.join(process.cwd(), "src/mocks/items.json"); + const items = JSON.parse(fs.readFileSync(itemsPath, "utf-8")); + + let filteredItems = items; + + // 검색 필터링 + if (query.search) { + filteredItems = filteredItems.filter((item) => item.title.toLowerCase().includes(query.search.toLowerCase())); + } + + // 카테고리 필터링 + if (query.category1) { + filteredItems = filteredItems.filter((item) => item.category1 === query.category1); + } + + if (query.category2) { + filteredItems = filteredItems.filter((item) => item.category2 === query.category2); + } + + // 정렬 + if (query.sort === "price_asc") { + filteredItems.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); + } else if (query.sort === "price_desc") { + filteredItems.sort((a, b) => parseInt(b.lprice) - parseInt(a.lprice)); + } else if (query.sort === "name_asc") { + filteredItems.sort((a, b) => a.title.localeCompare(b.title)); + } else if (query.sort === "name_desc") { + filteredItems.sort((a, b) => b.title.localeCompare(a.title)); + } + + // 페이지네이션 + const limit = parseInt(query.limit) || 20; + const page = parseInt(query.page) || 1; + const start = (page - 1) * limit; + const end = start + limit; + + return { + products: filteredItems.slice(start, end), + pagination: { + total: filteredItems.length, + page, + limit, + totalPages: Math.ceil(filteredItems.length / limit), + }, + }; + } catch (error) { + console.error("데이터 읽기 오류:", error); + return { products: [], pagination: { total: 0, page: 1, limit: 20, totalPages: 0 } }; + } +} + +function getProductFromFile(productId) { + try { + const itemsPath = path.join(process.cwd(), "src/mocks/items.json"); + const items = JSON.parse(fs.readFileSync(itemsPath, "utf-8")); + return items.find((item) => item.productId === productId) || null; + } catch (error) { + console.error("상품 데이터 읽기 오류:", error); + return null; + } +} + +function getCategoriesFromFile() { + try { + const itemsPath = path.join(process.cwd(), "src/mocks/items.json"); + const items = JSON.parse(fs.readFileSync(itemsPath, "utf-8")); + + const categories = {}; + items.forEach((item) => { + if (!categories[item.category1]) { + categories[item.category1] = {}; + } + if (!categories[item.category1][item.category2]) { + categories[item.category1][item.category2] = {}; + } + }); + + return categories; + } catch (error) { + console.error("카테고리 데이터 읽기 오류:", error); + return {}; + } +} + +// 서버용 데이터 프리페칭 (요구사항에 맞게 구현) +async function prefetchData(route, params, query = {}) { + try { + if (route?.path === "/") { + // 홈페이지: 상품 목록과 카테고리 로드 + const productsResponse = getProductsFromFile(query); + const categoriesResponse = getCategoriesFromFile(); + + return { + products: productsResponse.products, + categories: categoriesResponse, + totalCount: productsResponse.pagination.total, + loading: false, + status: "done", + }; + } else if (route?.path === "/product/:id/") { + // 상품 상세 페이지: 현재 상품과 관련 상품 로드 + const currentProduct = getProductFromFile(params.id); + + if (currentProduct) { + // 관련 상품 로드 + const relatedResponse = getProductsFromFile({ + category2: currentProduct.category2, + limit: 20, + page: 1, + }); + + const relatedProducts = relatedResponse.products.filter((p) => p.productId !== params.id); + + return { + currentProduct, + relatedProducts, + loading: false, + status: "done", + }; + } + } + + return { + products: [], + totalCount: 0, + currentProduct: null, + relatedProducts: [], + loading: false, + status: "done", + }; + } catch (error) { + console.error("서버 데이터 프리페칭 실패:", error); + return { + products: [], + totalCount: 0, + currentProduct: null, + relatedProducts: [], + loading: false, + error: error.message, + status: "error", + }; + } +} + +// 메타태그 생성 +function generateHead(title, description = "") { + return ` + ${title} + + `; +} + +// 간단한 HTML 생성 (실제 컴포넌트 대신) +function generateHomePageHtml(initialData) { + const { products = [], totalCount = 0 } = initialData; + + return ` +
+

쇼핑몰

+

총 ${totalCount}개 상품

+
+ ${products + .map( + (product) => ` +
+ ${product.title} +

${product.title}

+

${product.lprice}원

+
+ `, + ) + .join("")} +
+
+ `; +} + +function generateProductDetailPageHtml(initialData) { + const { currentProduct } = initialData; + + if (!currentProduct) { + return generateNotFoundPageHtml(); + } + + return ` +
+
+
+ ${currentProduct.title} +
+
+

${currentProduct.title}

+

${currentProduct.lprice}원

+

${currentProduct.mallName}

+ +
+
+
+ `; +} + +function generateNotFoundPageHtml() { + return ` +
+

404

+

페이지를 찾을 수 없습니다.

+ 홈으로 돌아가기 +
+ `; +} + +// 메인 렌더링 함수 (요구사항에 맞게 구현) +export async function render(url, query = {}) { + console.log("🔄 서버 렌더링 시작:", url); + + // 라우터 초기화 및 라우트 등록 + const router = new ServerRouter(); + router.addRoute("/", "home"); + router.addRoute("/product/:id/", "product"); + + // URL 정규화 (쿼리 파라미터 제거) + const normalizedUrl = url.split("?")[0]; + console.log("🔧 정규화된 URL:", normalizedUrl); + + // 쿼리 파라미터 추출 + const queryString = url.includes("?") ? url.split("?")[1] : ""; + const urlQuery = {}; + if (queryString) { + const params = new URLSearchParams(queryString); + for (const [key, value] of params) { + urlQuery[key] = value; + } + } + // 서버에서 전달받은 query와 URL의 query 병합 + const finalQuery = { ...urlQuery, ...query }; + console.log("🔍 쿼리 파라미터:", finalQuery); + + // 라우트 매칭 (빈 URL은 홈페이지로 처리) + let route = router.findRoute(normalizedUrl); + if (!route && (normalizedUrl === "" || normalizedUrl === "/")) { + route = { path: "/", params: {} }; + } + console.log("📍 라우트 매칭:", route); + + // 데이터 프리페칭 + const initialData = await prefetchData(route, route?.params || {}, finalQuery); + console.log("📊 데이터 프리페칭 완료"); + + // HTML 생성 + let html = ""; + let head = ""; + + if (route?.path === "/") { + // 홈페이지 + html = generateHomePageHtml(initialData); + head = generateHead("쇼핑몰 - 홈", "다양한 상품을 만나보세요"); + } else if (route?.path === "/product/:id/") { + // 상품 상세 페이지 + const { currentProduct } = initialData; + + if (currentProduct) { + html = generateProductDetailPageHtml(initialData); + head = generateHead(`${currentProduct.title} - 쇼핑몰`, currentProduct.title); + } else { + html = generateNotFoundPageHtml(); + head = generateHead("페이지를 찾을 수 없습니다 - 쇼핑몰"); + } + } else { + // 404 페이지 + html = generateNotFoundPageHtml(); + head = generateHead("페이지를 찾을 수 없습니다 - 쇼핑몰"); + } + + console.log("✅ 서버 렌더링 완료"); + return { html, head, initialData }; +} diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js index 4c3f2765..7311e182 100644 --- a/packages/vanilla/src/main.js +++ b/packages/vanilla/src/main.js @@ -4,6 +4,7 @@ import { registerAllEvents } from "./events"; import { loadCartFromStorage } from "./services"; import { router } from "./router"; import { BASE_URL } from "./constants.js"; +import { productStore, PRODUCT_ACTIONS } from "./stores"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => @@ -15,7 +16,50 @@ const enableMocking = () => }), ); +// 서버 데이터 복원 (Hydration) +function restoreServerData() { + if (window.__INITIAL_DATA__) { + console.log("🔄 서버 데이터 복원 중:", window.__INITIAL_DATA__); + const data = window.__INITIAL_DATA__; + + if (data.products && data.products.length > 0) { + // 홈페이지 데이터 복원 + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: data.products, + totalCount: data.totalCount, + categories: data.categories, + loading: false, + status: "done", + }, + }); + } + + if (data.currentProduct) { + // 상품 상세 페이지 데이터 복원 + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_CURRENT_PRODUCT, + payload: data.currentProduct, + }); + + if (data.relatedProducts) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_RELATED_PRODUCTS, + payload: data.relatedProducts, + }); + } + } + + delete window.__INITIAL_DATA__; + console.log("✅ 서버 데이터 복원 완료"); + } +} + function main() { + // 서버 데이터 복원을 먼저 실행 + restoreServerData(); + registerAllEvents(); registerGlobalEvents(); loadCartFromStorage(); diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index c479f112..980232ce 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -1,19 +1,110 @@ import fs from "fs"; +import path from "path"; -const render = () => { - return `
안녕하세요
`; -}; +// 서버용 렌더링 함수 import +const { render } = await import("./dist/vanilla-ssr/main-server.js"); + +// 메타태그 생성 함수 +function generateHead(title, description = "") { + return ` + ${title} + + `; +} + +// HTML 템플릿에서 플레이스홀더 교체 +function replacePlaceholders(template, html, head, initialData) { + return template + .replace("", head) + .replace("", html) + .replace("", ``); +} + +// 디렉토리 생성 함수 +function ensureDirectoryExists(filePath) { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} async function generateStaticSite() { - // HTML 템플릿 읽기 - const template = fs.readFileSync("../../dist/vanilla/index.html", "utf-8"); + try { + console.log("🚀 SSG 시작..."); + + // HTML 템플릿 읽기 + const template = fs.readFileSync("../../dist/vanilla/index.html", "utf-8"); + console.log("✅ HTML 템플릿 로드 완료"); + + // 1. 홈페이지 생성 + console.log("📄 홈페이지 생성 중..."); + const homeResult = await render("/", {}); + const homeHtml = replacePlaceholders( + template, + homeResult.html, + generateHead("쇼핑몰 - 홈", "다양한 상품을 만나보세요"), + homeResult.initialData, + ); + fs.writeFileSync("../../dist/vanilla/index.html", homeHtml); + console.log("✅ 홈페이지 생성 완료"); + + // 2. 404 페이지 생성 + console.log("📄 404 페이지 생성 중..."); + const notFoundResult = await render("/404", {}); + const notFoundHtml = replacePlaceholders( + template, + notFoundResult.html, + generateHead("페이지를 찾을 수 없습니다 - 쇼핑몰"), + notFoundResult.initialData, + ); + fs.writeFileSync("../../dist/vanilla/404.html", notFoundHtml); + console.log("✅ 404 페이지 생성 완료"); + + // 3. 상품 상세 페이지들 생성 + console.log("📄 상품 상세 페이지들 생성 중..."); + const { products } = homeResult.initialData; + + if (products && products.length > 0) { + // 상품별 디렉토리 생성 및 HTML 파일 생성 + for (const product of products) { + const productId = product.productId; + const productUrl = `/product/${productId}/`; + + console.log(`📦 상품 ${productId} 페이지 생성 중...`); + + const productResult = await render(productUrl, {}); + + if (productResult.initialData.currentProduct) { + const productHtml = replacePlaceholders( + template, + productResult.html, + generateHead(`${product.title} - 쇼핑몰`, product.title), + productResult.initialData, + ); + + // 상품별 디렉토리 생성 + const productDir = `../../dist/vanilla/product/${productId}`; + const productFilePath = `${productDir}/index.html`; + + // 디렉토리 생성 + ensureDirectoryExists(productFilePath); - // 어플리케이션 렌더링하기 - const appHtml = render(); + // index.html 파일 생성 + fs.writeFileSync(productFilePath, productHtml); + console.log(`✅ 상품 ${productId} 페이지 생성 완료`); + } + } + } - // 결과 HTML 생성하기 - const result = template.replace("", appHtml); - fs.writeFileSync("../../dist/vanilla/index.html", result); + console.log("🎉 SSG 완료!"); + console.log(`📊 생성된 페이지:`); + console.log(` - 홈페이지: ../../dist/vanilla/index.html`); + console.log(` - 404 페이지: ../../dist/vanilla/404.html`); + console.log(` - 상품 상세 페이지: ../../dist/vanilla/product/*/index.html`); + } catch (error) { + console.error("❌ SSG 오류:", error); + throw error; + } } // 실행 From 1dd62c6783291e0a2db7c0283a3b37e8fe86f02e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=ED=98=9C?= Date: Wed, 3 Sep 2025 10:20:54 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20SSG=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EB=A1=9C=EB=94=A9=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/main-server.js | 7 +++++++ packages/vanilla/static-site-generate.js | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index b601b76a..e4d22714 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -26,12 +26,18 @@ class ServerRouter { handler, path, }); + + // 디버깅용 로그 + console.log(`🔧 라우트 등록: ${path} → ${regex}`); } findRoute(url) { + console.log(`🔍 라우트 찾기: ${url}`); for (const [, route] of this.routes) { + console.log(` 테스트: ${url} vs ${route.regex}`); const match = url.match(route.regex); if (match) { + console.log(` ✅ 매칭됨: ${route.path}`); const params = {}; route.paramNames.forEach((name, index) => { params[name] = match[index + 1]; @@ -43,6 +49,7 @@ class ServerRouter { }; } } + console.log(` ❌ 매칭 안됨`); return null; } } diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index 980232ce..6472196f 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -62,7 +62,11 @@ async function generateStaticSite() { // 3. 상품 상세 페이지들 생성 console.log("📄 상품 상세 페이지들 생성 중..."); - const { products } = homeResult.initialData; + + // 모든 상품 데이터 직접 로드 + const itemsPath = path.join(process.cwd(), "src/mocks/items.json"); + const items = JSON.parse(fs.readFileSync(itemsPath, "utf-8")); + const products = items; // 모든 상품 사용 if (products && products.length > 0) { // 상품별 디렉토리 생성 및 HTML 파일 생성 From 86cd0e55ecbf0ff2d28b316f1798fa4d708622c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=ED=98=9C?= Date: Wed, 3 Sep 2025 22:03:08 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20SSR=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=84=B0=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MSW 서버 설정 추가 - 불필요한 요청 처리 추가 - ServerRouter 기반 라우팅 구조로 변경 - 데이터 전달 방식 개선 --- packages/vanilla/server.js | 48 ++-- packages/vanilla/src/main-server.js | 352 ++-------------------------- packages/vanilla/src/main.js | 2 +- 3 files changed, 43 insertions(+), 359 deletions(-) diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index 50ca9faf..77e8fa84 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,16 +1,31 @@ -import fs from "node:fs/promises"; import express from "express"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { mswServer } from "./src/mocks/node.js"; const prod = process.env.NODE_ENV === "production"; -const port = process.env.PORT || 5174; // SSR 서버 포트 +const port = process.env.PORT || 5173; const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/"); const templateHtml = prod ? await fs.readFile("./dist/vanilla/index.html", "utf-8") : ""; const app = express(); +mswServer.listen({ + onUnhandledRequest: "bypass", +}); + +// 불필요한 요청 무시 +app.get("/favicon.ico", (_, res) => { + res.status(204).end(); +}); +app.get("/.well-known/appspecific/com.chrome.devtools.json", (_, res) => { + res.status(204).end(); +}); + +// Add Vite or respective production middlewares +/** @type {import('vite').ViteDevServer | undefined} */ let vite; -// use 는 모든 요청에 대해 실행되고, get 은 특정 요청에 대해 실행 if (!prod) { const { createServer } = await import("vite"); vite = await createServer({ @@ -26,22 +41,11 @@ if (!prod) { app.use(base, sirv("./dist/vanilla", { extensions: [] })); } -// API 프록시 - 기존 API 서버로 요청 전달 -app.use("/api/*", async (req, res) => { - try { - const apiUrl = `http://localhost:5173${req.url}`; - const response = await fetch(apiUrl); - const data = await response.json(); - res.json(data); - } catch (error) { - console.error("API 프록시 오류:", error); - res.status(500).json({ error: "API 서버 연결 실패" }); - } -}); - -app.use("*", async (req, res) => { +// Serve HTML +app.use("*all", async (req, res) => { try { const url = req.originalUrl.replace(base, ""); + const pathname = path.normalize(`/${url.split("?")[0]}`); /** @type {string} */ let template; @@ -57,16 +61,14 @@ app.use("*", async (req, res) => { render = (await import("./dist/vanilla-ssr/main-server.js")).render; } - // server에 있는 값을 전달해주기 위해서 인자로 넘겨줘야함. - const query = req.query; - const rendered = await render(url, query); + const rendered = await render(pathname, req.query); const html = template .replace(``, rendered.head ?? "") .replace(``, rendered.html ?? "") .replace( - ``, - ``, + ``, + ``, ); res.status(200).set({ "Content-Type": "text/html" }).send(html); @@ -79,5 +81,5 @@ app.use("*", async (req, res) => { // Start http server app.listen(port, () => { - console.log(`Vanilla SSR Server started at http://localhost:${port}`); + console.log(`React Server started at http://localhost:${port}`); }); diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index e4d22714..04ca1126 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,335 +1,17 @@ -// 서버용 렌더링 - 직접 데이터 읽기 -import fs from "fs"; -import path from "path"; - -// 서버용 라우터 (요구사항에 맞게 구현) -class ServerRouter { - constructor() { - this.routes = new Map(); - } - - addRoute(path, handler) { - // :id → ([^/]+) 정규식 변환 - const paramNames = []; - const regexPath = path - .replace(/:\w+/g, (match) => { - paramNames.push(match.slice(1)); - return "([^/]+)"; - }) - .replace(/\//g, "\\/"); - - const regex = new RegExp(`^${regexPath}$`); - - this.routes.set(path, { - regex, - paramNames, - handler, - path, - }); - - // 디버깅용 로그 - console.log(`🔧 라우트 등록: ${path} → ${regex}`); - } - - findRoute(url) { - console.log(`🔍 라우트 찾기: ${url}`); - for (const [, route] of this.routes) { - console.log(` 테스트: ${url} vs ${route.regex}`); - const match = url.match(route.regex); - if (match) { - console.log(` ✅ 매칭됨: ${route.path}`); - const params = {}; - route.paramNames.forEach((name, index) => { - params[name] = match[index + 1]; - }); - - return { - ...route, - params, - }; - } - } - console.log(` ❌ 매칭 안됨`); - return null; - } -} - -// 서버용 데이터 읽기 (직접 파일에서 읽기) -function getProductsFromFile(query = {}) { - try { - const itemsPath = path.join(process.cwd(), "src/mocks/items.json"); - const items = JSON.parse(fs.readFileSync(itemsPath, "utf-8")); - - let filteredItems = items; - - // 검색 필터링 - if (query.search) { - filteredItems = filteredItems.filter((item) => item.title.toLowerCase().includes(query.search.toLowerCase())); - } - - // 카테고리 필터링 - if (query.category1) { - filteredItems = filteredItems.filter((item) => item.category1 === query.category1); - } - - if (query.category2) { - filteredItems = filteredItems.filter((item) => item.category2 === query.category2); - } - - // 정렬 - if (query.sort === "price_asc") { - filteredItems.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); - } else if (query.sort === "price_desc") { - filteredItems.sort((a, b) => parseInt(b.lprice) - parseInt(a.lprice)); - } else if (query.sort === "name_asc") { - filteredItems.sort((a, b) => a.title.localeCompare(b.title)); - } else if (query.sort === "name_desc") { - filteredItems.sort((a, b) => b.title.localeCompare(a.title)); - } - - // 페이지네이션 - const limit = parseInt(query.limit) || 20; - const page = parseInt(query.page) || 1; - const start = (page - 1) * limit; - const end = start + limit; - - return { - products: filteredItems.slice(start, end), - pagination: { - total: filteredItems.length, - page, - limit, - totalPages: Math.ceil(filteredItems.length / limit), - }, - }; - } catch (error) { - console.error("데이터 읽기 오류:", error); - return { products: [], pagination: { total: 0, page: 1, limit: 20, totalPages: 0 } }; - } -} - -function getProductFromFile(productId) { - try { - const itemsPath = path.join(process.cwd(), "src/mocks/items.json"); - const items = JSON.parse(fs.readFileSync(itemsPath, "utf-8")); - return items.find((item) => item.productId === productId) || null; - } catch (error) { - console.error("상품 데이터 읽기 오류:", error); - return null; - } -} - -function getCategoriesFromFile() { - try { - const itemsPath = path.join(process.cwd(), "src/mocks/items.json"); - const items = JSON.parse(fs.readFileSync(itemsPath, "utf-8")); - - const categories = {}; - items.forEach((item) => { - if (!categories[item.category1]) { - categories[item.category1] = {}; - } - if (!categories[item.category1][item.category2]) { - categories[item.category1][item.category2] = {}; - } - }); - - return categories; - } catch (error) { - console.error("카테고리 데이터 읽기 오류:", error); - return {}; - } -} - -// 서버용 데이터 프리페칭 (요구사항에 맞게 구현) -async function prefetchData(route, params, query = {}) { - try { - if (route?.path === "/") { - // 홈페이지: 상품 목록과 카테고리 로드 - const productsResponse = getProductsFromFile(query); - const categoriesResponse = getCategoriesFromFile(); - - return { - products: productsResponse.products, - categories: categoriesResponse, - totalCount: productsResponse.pagination.total, - loading: false, - status: "done", - }; - } else if (route?.path === "/product/:id/") { - // 상품 상세 페이지: 현재 상품과 관련 상품 로드 - const currentProduct = getProductFromFile(params.id); - - if (currentProduct) { - // 관련 상품 로드 - const relatedResponse = getProductsFromFile({ - category2: currentProduct.category2, - limit: 20, - page: 1, - }); - - const relatedProducts = relatedResponse.products.filter((p) => p.productId !== params.id); - - return { - currentProduct, - relatedProducts, - loading: false, - status: "done", - }; - } - } - - return { - products: [], - totalCount: 0, - currentProduct: null, - relatedProducts: [], - loading: false, - status: "done", - }; - } catch (error) { - console.error("서버 데이터 프리페칭 실패:", error); - return { - products: [], - totalCount: 0, - currentProduct: null, - relatedProducts: [], - loading: false, - error: error.message, - status: "error", - }; - } -} - -// 메타태그 생성 -function generateHead(title, description = "") { - return ` - ${title} - - `; -} - -// 간단한 HTML 생성 (실제 컴포넌트 대신) -function generateHomePageHtml(initialData) { - const { products = [], totalCount = 0 } = initialData; - - return ` -
-

쇼핑몰

-

총 ${totalCount}개 상품

-
- ${products - .map( - (product) => ` -
- ${product.title} -

${product.title}

-

${product.lprice}원

-
- `, - ) - .join("")} -
-
- `; -} - -function generateProductDetailPageHtml(initialData) { - const { currentProduct } = initialData; - - if (!currentProduct) { - return generateNotFoundPageHtml(); - } - - return ` -
-
-
- ${currentProduct.title} -
-
-

${currentProduct.title}

-

${currentProduct.lprice}원

-

${currentProduct.mallName}

- -
-
-
- `; -} - -function generateNotFoundPageHtml() { - return ` -
-

404

-

페이지를 찾을 수 없습니다.

- 홈으로 돌아가기 -
- `; -} - -// 메인 렌더링 함수 (요구사항에 맞게 구현) -export async function render(url, query = {}) { - console.log("🔄 서버 렌더링 시작:", url); - - // 라우터 초기화 및 라우트 등록 - const router = new ServerRouter(); - router.addRoute("/", "home"); - router.addRoute("/product/:id/", "product"); - - // URL 정규화 (쿼리 파라미터 제거) - const normalizedUrl = url.split("?")[0]; - console.log("🔧 정규화된 URL:", normalizedUrl); - - // 쿼리 파라미터 추출 - const queryString = url.includes("?") ? url.split("?")[1] : ""; - const urlQuery = {}; - if (queryString) { - const params = new URLSearchParams(queryString); - for (const [key, value] of params) { - urlQuery[key] = value; - } - } - // 서버에서 전달받은 query와 URL의 query 병합 - const finalQuery = { ...urlQuery, ...query }; - console.log("🔍 쿼리 파라미터:", finalQuery); - - // 라우트 매칭 (빈 URL은 홈페이지로 처리) - let route = router.findRoute(normalizedUrl); - if (!route && (normalizedUrl === "" || normalizedUrl === "/")) { - route = { path: "/", params: {} }; - } - console.log("📍 라우트 매칭:", route); - - // 데이터 프리페칭 - const initialData = await prefetchData(route, route?.params || {}, finalQuery); - console.log("📊 데이터 프리페칭 완료"); - - // HTML 생성 - let html = ""; - let head = ""; - - if (route?.path === "/") { - // 홈페이지 - html = generateHomePageHtml(initialData); - head = generateHead("쇼핑몰 - 홈", "다양한 상품을 만나보세요"); - } else if (route?.path === "/product/:id/") { - // 상품 상세 페이지 - const { currentProduct } = initialData; - - if (currentProduct) { - html = generateProductDetailPageHtml(initialData); - head = generateHead(`${currentProduct.title} - 쇼핑몰`, currentProduct.title); - } else { - html = generateNotFoundPageHtml(); - head = generateHead("페이지를 찾을 수 없습니다 - 쇼핑몰"); - } - } else { - // 404 페이지 - html = generateNotFoundPageHtml(); - head = generateHead("페이지를 찾을 수 없습니다 - 쇼핑몰"); - } - - console.log("✅ 서버 렌더링 완료"); - return { html, head, initialData }; -} +import { ServerRouter } from "./lib"; +import { routerMatches } from "./router/router"; + +export const render = async (pathname, query) => { + console.log({ pathname, query }); + const router = new ServerRouter(routerMatches); + router.start(pathname, query); + const params = { pathname, query, params: router.params }; + const data = (await router.target?.ssr?.(params)) ?? {}; + const metadata = await router.target?.metadata?.(params); + + return { + head: `${metadata?.title ?? ""}`, + html: router.target({ ...params, data }), + __INITIAL_DATA__: data, + }; +}; diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js index 7311e182..280929dc 100644 --- a/packages/vanilla/src/main.js +++ b/packages/vanilla/src/main.js @@ -67,7 +67,7 @@ function main() { router.start(); } -if (import.meta.env.MODE !== "test") { +if (import.meta.env.MODE !== "test" && typeof window !== "undefined") { enableMocking().then(main); } else { main(); From 45c7ff44eb5ba0e585abb01dfd1b3c2c7555c048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=ED=98=9C?= Date: Thu, 4 Sep 2025 02:50:22 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20SSR/SSG=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EC=8B=A4=ED=96=89=20=ED=99=98=EA=B2=BD=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/server.js | 5 - packages/vanilla/src/lib/BaseRouter.js | 133 ++++++++++++++++++ packages/vanilla/src/lib/Router.js | 142 ++------------------ packages/vanilla/src/lib/ServerRouter.js | 56 ++++++++ packages/vanilla/src/lib/index.js | 1 + packages/vanilla/src/mocks/mockServer.js | 6 + packages/vanilla/src/pages/HomePage.js | 35 ++++- packages/vanilla/src/router/router.js | 6 +- packages/vanilla/src/storage/cartStorage.js | 11 +- 9 files changed, 256 insertions(+), 139 deletions(-) create mode 100644 packages/vanilla/src/lib/BaseRouter.js create mode 100644 packages/vanilla/src/lib/ServerRouter.js create mode 100644 packages/vanilla/src/mocks/mockServer.js diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index 77e8fa84..8a0e4369 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,7 +1,6 @@ import express from "express"; import fs from "node:fs/promises"; import path from "node:path"; -import { mswServer } from "./src/mocks/node.js"; const prod = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5173; @@ -11,10 +10,6 @@ const templateHtml = prod ? await fs.readFile("./dist/vanilla/index.html", "utf- const app = express(); -mswServer.listen({ - onUnhandledRequest: "bypass", -}); - // 불필요한 요청 무시 app.get("/favicon.ico", (_, res) => { res.status(204).end(); 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 new file mode 100644 index 00000000..d92dfdb0 --- /dev/null +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -0,0 +1,56 @@ +/** + * 서버사이드 라우터 + */ +import { BaseRouter } from "./BaseRouter.js"; + +export class ServerRouter extends BaseRouter { + #currentUrl = "/"; + #origin = "http://localhost"; + + constructor(baseUrl = "") { + super(baseUrl); + } + + get query() { + const url = new URL(this.#currentUrl, this.#origin); + return BaseRouter.parseQuery(url.search); + } + + set query(newQuery) { + const newUrl = BaseRouter.getUrl(newQuery, this.baseUrl, this.#currentUrl); + this.setUrl(newUrl, this.#origin); + } + + getCurrentUrl() { + return this.#currentUrl; + } + + getOrigin() { + return this.#origin; + } + + /** + * 서버 URL 설정 + * @param {string} url - 요청 URL + * @param {string} [origin] - 서버 origin (선택적) + */ + setUrl(url, origin = "http://localhost") { + this.#currentUrl = url; + this.#origin = origin; + this.updateRoute(this.getCurrentUrl()); + } + + /** + * 서버사이드에서는 네비게이션 불가 + */ + push() { + throw new Error("Navigation is not supported in server-side routing"); + } + + /** + * 라우터 시작 + */ + start() { + this.updateRoute(this.getCurrentUrl()); + } +} diff --git a/packages/vanilla/src/lib/index.js b/packages/vanilla/src/lib/index.js index a598ef30..da63f1df 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/mocks/mockServer.js b/packages/vanilla/src/mocks/mockServer.js new file mode 100644 index 00000000..f0b5a1d5 --- /dev/null +++ b/packages/vanilla/src/mocks/mockServer.js @@ -0,0 +1,6 @@ +import { setupServer } from "msw/node"; +import { handlers } from "./handlers.js"; + +// MSW 서버 설정 - Node.js 환경에서 API 요청을 가로채기 위한 설정 +// 이 서버는 SSR(Server-Side Rendering) 시 서버에서 발생하는 API 요청을 모킹합니다 +export const mockServer = setupServer(...handlers); 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 d897ee76..9349bae3 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -1,5 +1,7 @@ // 글로벌 라우터 인스턴스 -import { Router } from "../lib"; +import { Router, ServerRouter } from "../lib"; import { BASE_URL } from "../constants.js"; -export const router = new Router(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 3742e4fd2d18e874923b9f2eb7bb692400123482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=ED=98=9C?= Date: Thu, 4 Sep 2025 14:17:39 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20SSR/SSG=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EC=84=9C=EB=B2=84=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=20=EC=9E=91?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/api/productApi.js | 14 +- packages/vanilla/src/lib/ServerRouter.js | 84 +++++++++- packages/vanilla/src/main-server.js | 167 ++++++++++++++++++-- packages/vanilla/src/router/serverRouter.js | 4 + 4 files changed, 248 insertions(+), 21 deletions(-) create mode 100644 packages/vanilla/src/router/serverRouter.js diff --git a/packages/vanilla/src/api/productApi.js b/packages/vanilla/src/api/productApi.js index c2330fbe..e229b1b1 100644 --- a/packages/vanilla/src/api/productApi.js +++ b/packages/vanilla/src/api/productApi.js @@ -1,3 +1,11 @@ +import { isServer } from "../utils/runtime.js"; + +const withBaseUrl = (url) => { + // 서버 환경에서는 절대 경로를 사용해야하기 때문에 임시 baseURL 설정 + // msw 핸들러에서 baseURL 상관 없이 처리함 + return isServer ? new URL(url, `http://localhost`) : url; +}; + export async function getProducts(params = {}) { const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; const page = params.current ?? params.page ?? 1; @@ -11,17 +19,17 @@ export async function getProducts(params = {}) { sort, }); - const response = await fetch(`/api/products?${searchParams}`); + const response = await fetch(withBaseUrl(`/api/products?${searchParams}`)); return await response.json(); } export async function getProduct(productId) { - const response = await fetch(`/api/products/${productId}`); + const response = await fetch(withBaseUrl(`/api/products/${productId}`)); return await response.json(); } export async function getCategories() { - const response = await fetch("/api/categories"); + const response = await fetch(withBaseUrl("/api/categories")); return await response.json(); } diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js index d92dfdb0..944b78e4 100644 --- a/packages/vanilla/src/lib/ServerRouter.js +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -6,6 +6,7 @@ import { BaseRouter } from "./BaseRouter.js"; export class ServerRouter extends BaseRouter { #currentUrl = "/"; #origin = "http://localhost"; + #routes = new Map(); constructor(baseUrl = "") { super(baseUrl); @@ -50,7 +51,86 @@ export class ServerRouter extends BaseRouter { /** * 라우터 시작 */ - start() { - this.updateRoute(this.getCurrentUrl()); + start(url, query = {}) { + this.setUrl(url, this.#origin); + this.query = query; + } + + /** + * 라우트 등록 + */ + addRoute(path, handler) { + this.#routes.set(path, handler); + } + + /** + * 현재 라우트 찾기 + */ + findRoute(url) { + const { pathname } = new URL(url, this.#origin); + + // 정확한 매칭 먼저 시도 + if (this.#routes.has(pathname)) { + return { + path: pathname, + handler: this.#routes.get(pathname), + params: {}, + }; + } + + // 동적 라우트 매칭 + for (const [routePath, handler] of this.#routes) { + if (routePath.includes(":")) { + const paramNames = []; + const regexPath = routePath + .replace(/:\w+/g, (match) => { + paramNames.push(match.slice(1)); + return "([^/]+)"; + }) + .replace(/\//g, "\\/"); + + const regex = new RegExp(`^${regexPath}$`); + const match = pathname.match(regex); + + if (match) { + const params = {}; + paramNames.forEach((name, index) => { + params[name] = match[index + 1]; + }); + + return { + path: routePath, + handler, + params, + }; + } + } + } + + // 와일드카드 매칭 + if (this.#routes.has(".*")) { + return { + path: ".*", + handler: this.#routes.get(".*"), + params: {}, + }; + } + + return null; + } + + /** + * 현재 라우트 정보 가져오기 + */ + get route() { + return this.findRoute(this.#currentUrl); + } + + /** + * 현재 핸들러 가져오기 + */ + get target() { + const route = this.route; + return route ? route.handler : null; } } diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 04ca1126..313b3f16 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,17 +1,152 @@ -import { ServerRouter } from "./lib"; -import { routerMatches } from "./router/router"; - -export const render = async (pathname, query) => { - console.log({ pathname, query }); - const router = new ServerRouter(routerMatches); - router.start(pathname, query); - const params = { pathname, query, params: router.params }; - const data = (await router.target?.ssr?.(params)) ?? {}; - const metadata = await router.target?.metadata?.(params); - - return { - head: `${metadata?.title ?? ""}`, - html: router.target({ ...params, data }), - __INITIAL_DATA__: data, - }; +import { getCategories, getProduct, getProducts } from "./api/productApi"; +import { HomePage, NotFoundPage, ProductDetailPage } from "./pages"; +import { serverRouter } from "./router/serverRouter"; +import { PRODUCT_ACTIONS, productStore } from "./stores"; + +// 서버 라우터 등록 +serverRouter.addRoute("/", HomePage); +serverRouter.addRoute("/product/:id/", ProductDetailPage); +serverRouter.addRoute(".*", NotFoundPage); + +// 스토어 디스패치 헬퍼 함수 +const updateStore = (payload) => { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload, + }); +}; + +// 기본 스토어 상태 생성 함수 +const createBaseStoreState = () => ({ + products: [], + totalCount: 0, + categories: {}, + currentProduct: null, + relatedProducts: [], + loading: false, + error: null, + status: "done", +}); + +/** + * 서버 렌더링 함수 + * @param {string} url - 요청 URL + * @param {Object} query - 요청 쿼리 + * @returns {Promise} - 렌더링 결과 + */ +export const render = async (url, query) => { + try { + serverRouter.start(url, query); + + const route = serverRouter.route; + if (!route) { + return { + head: "페이지를 찾을 수 없습니다", + html: NotFoundPage(), + initialData: {}, + }; + } + + let head; + let initialData; + + // 홈페이지 처리 + if (route.path === "/") { + try { + const [productsResponse, categories] = await Promise.all([getProducts(serverRouter.query), getCategories()]); + + const storeState = { + ...createBaseStoreState(), + products: productsResponse.products || [], + totalCount: productsResponse.pagination?.total || 0, + categories: categories || {}, + }; + + updateStore(storeState); + + head = "쇼핑몰"; + initialData = { + products: storeState.products, + categories: storeState.categories, + totalCount: storeState.totalCount, + }; + } catch (error) { + const errorState = { + ...createBaseStoreState(), + error: error.message, + status: "error", + }; + + updateStore(errorState); + + initialData = { + products: [], + categories: {}, + totalCount: 0, + }; + } + } + // 상품 상세 페이지 처리 + else if (route.path === "/product/:id/") { + const productId = route.params.id; + + try { + const product = await getProduct(productId); + + let relatedProducts = []; + if (product?.category2) { + const relatedResponse = await getProducts({ + category2: product.category2, + limit: 20, + page: 1, + }); + relatedProducts = relatedResponse.products?.filter((p) => p.productId !== productId) || []; + } + + const storeState = { + ...createBaseStoreState(), + currentProduct: product, + relatedProducts, + }; + + updateStore(storeState); + + head = `쇼핑몰 상세 - ${product.title}`; + initialData = { + product, + relatedProducts, + }; + } catch (error) { + const errorState = { + ...createBaseStoreState(), + error: error.message, + status: "error", + }; + + updateStore(errorState); + + initialData = { + product: null, + relatedProducts: [], + }; + } + } + + const PageComponent = serverRouter.target; + const html = PageComponent(); + + return { + html, + head, + initialData, + }; + } catch (error) { + console.error("❌ SSR 에러:", error); + + return { + head: "에러", + html: "
서버 오류가 발생했습니다.
", + initialData: JSON.stringify({ error: error.message }), + }; + } }; diff --git a/packages/vanilla/src/router/serverRouter.js b/packages/vanilla/src/router/serverRouter.js new file mode 100644 index 00000000..a7f2a34b --- /dev/null +++ b/packages/vanilla/src/router/serverRouter.js @@ -0,0 +1,4 @@ +import { ServerRouter } from "../lib/ServerRouter.js"; + +// 서버 전용 라우터 인스턴스 +export const serverRouter = new ServerRouter(); From 7c06d4e8c86f42b8ec359af2ec85ee01dc890d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=ED=98=9C?= Date: Thu, 4 Sep 2025 14:28:41 +0900 Subject: [PATCH 07/14] =?UTF-8?q?fix:=20runtime.js=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9C=BC=EB=A1=9C=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 누락된 src/utils/runtime.js 파일 생성 - 서버/클라이언트 환경 구분 유틸리티 추가 - productApi.js import 에러 해결 --- packages/vanilla/server.js | 2 +- packages/vanilla/src/main-server.js | 2 +- packages/vanilla/src/utils/runtime.js | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 packages/vanilla/src/utils/runtime.js diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index 8a0e4369..e45792c4 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -63,7 +63,7 @@ app.use("*all", async (req, res) => { .replace(``, rendered.html ?? "") .replace( ``, - ``, + ``, ); res.status(200).set({ "Content-Type": "text/html" }).send(html); diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 313b3f16..64580def 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -146,7 +146,7 @@ export const render = async (url, query) => { return { head: "에러", html: "
서버 오류가 발생했습니다.
", - initialData: JSON.stringify({ error: error.message }), + initialData: { error: error.message }, }; } }; diff --git a/packages/vanilla/src/utils/runtime.js b/packages/vanilla/src/utils/runtime.js new file mode 100644 index 00000000..c967b2a8 --- /dev/null +++ b/packages/vanilla/src/utils/runtime.js @@ -0,0 +1,15 @@ +/** + * 런타임 환경 유틸리티 + */ + +// 서버 환경인지 확인 +export const isServer = typeof window === "undefined"; + +// 클라이언트 환경인지 확인 +export const isClient = typeof window !== "undefined"; + +// Node.js 환경인지 확인 +export const isNode = typeof process !== "undefined" && process.versions && process.versions.node; + +// 브라우저 환경인지 확인 +export const isBrowser = typeof window !== "undefined" && typeof document !== "undefined"; From 6b583062b2f721551590577fe71f5f77270cfe3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=ED=98=9C?= Date: Thu, 4 Sep 2025 15:19:15 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat:=20SSR/SSG=20=EA=B0=9C=EC=84=A0=20-?= =?UTF-8?q?=20=EC=8B=A4=EC=A0=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=8F=20=EB=8F=99=EC=A0=81=20=EB=A9=94=ED=83=80?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 환경에서 items.json 데이터 직접 사용하도록 productApi 개선 - 상품 상세 페이지 동적 메타데이터 생성 기능 추가 - 서버사이드 렌더링에서 URL 쿼리 파라미터 처리 개선 - 서버 환경에서 클라이언트 로직 실행 방지하는 환경 체크 추가 - SSG에서 render 함수의 동적 메타데이터 사용하도록 개선 --- packages/vanilla/src/api/productApi.js | 91 ++++++++++++++++++- packages/vanilla/src/main-server.js | 26 +++++- packages/vanilla/src/pages/HomePage.js | 16 ++++ .../vanilla/src/pages/ProductDetailPage.js | 28 +++++- packages/vanilla/static-site-generate.js | 2 +- 5 files changed, 154 insertions(+), 9 deletions(-) diff --git a/packages/vanilla/src/api/productApi.js b/packages/vanilla/src/api/productApi.js index e229b1b1..a7e4e954 100644 --- a/packages/vanilla/src/api/productApi.js +++ b/packages/vanilla/src/api/productApi.js @@ -1,8 +1,20 @@ import { isServer } from "../utils/runtime.js"; +import fs from "node:fs"; +import path from "node:path"; + +// 서버 환경에서 items.json 데이터 로드 +let items = []; +if (isServer) { + try { + const itemsPath = path.join(process.cwd(), "src/mocks/items.json"); + items = JSON.parse(fs.readFileSync(itemsPath, "utf-8")); + } catch (error) { + console.error("items.json 로드 실패:", error); + items = []; + } +} const withBaseUrl = (url) => { - // 서버 환경에서는 절대 경로를 사용해야하기 때문에 임시 baseURL 설정 - // msw 핸들러에서 baseURL 상관 없이 처리함 return isServer ? new URL(url, `http://localhost`) : url; }; @@ -10,6 +22,60 @@ export async function getProducts(params = {}) { const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; const page = params.current ?? params.page ?? 1; + // 서버 환경에서는 items.json 데이터 사용 + if (isServer) { + let filteredProducts = [...items]; + + if (search) { + filteredProducts = filteredProducts.filter((p) => p.title.toLowerCase().includes(search.toLowerCase())); + } + + if (category1) { + filteredProducts = filteredProducts.filter((p) => p.category1 === category1); + } + + if (category2) { + filteredProducts = filteredProducts.filter((p) => p.category2 === category2); + } + + // 정렬 + if (sort) { + switch (sort) { + case "price_asc": + filteredProducts.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); + break; + case "price_desc": + filteredProducts.sort((a, b) => parseInt(b.lprice) - parseInt(a.lprice)); + break; + case "name_asc": + filteredProducts.sort((a, b) => a.title.localeCompare(b.title, "ko")); + break; + case "name_desc": + filteredProducts.sort((a, b) => b.title.localeCompare(a.title, "ko")); + break; + default: + filteredProducts.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); + } + } + + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const paginatedProducts = filteredProducts.slice(startIndex, endIndex); + + return { + products: paginatedProducts, + pagination: { + total: filteredProducts.length, + page, + limit, + totalPages: Math.ceil(filteredProducts.length / limit), + hasNext: endIndex < filteredProducts.length, + hasPrev: page > 1, + }, + }; + } + + // 클라이언트 환경에서는 기존 로직 사용 const searchParams = new URLSearchParams({ page: page.toString(), limit: limit.toString(), @@ -20,16 +86,35 @@ export async function getProducts(params = {}) { }); const response = await fetch(withBaseUrl(`/api/products?${searchParams}`)); - return await response.json(); } export async function getProduct(productId) { + // 서버 환경에서는 items.json 데이터 사용 + if (isServer) { + const product = items.find((p) => p.productId === productId); + return product || null; + } + + // 클라이언트 환경에서는 기존 로직 사용 const response = await fetch(withBaseUrl(`/api/products/${productId}`)); return await response.json(); } export async function getCategories() { + // 서버 환경에서는 items.json에서 카테고리 추출 + if (isServer) { + 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; + } + + // 클라이언트 환경에서는 기존 로직 사용 const response = await fetch(withBaseUrl("/api/categories")); return await response.json(); } diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 64580def..ee78c2ee 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -36,7 +36,17 @@ const createBaseStoreState = () => ({ */ export const render = async (url, query) => { try { - serverRouter.start(url, query); + // URL에서 쿼리 파라미터 추출 + const urlObj = new URL(url, "http://localhost"); + const urlQuery = {}; + urlObj.searchParams.forEach((value, key) => { + urlQuery[key] = value; + }); + + // 전달된 query와 URL의 쿼리를 병합 + const mergedQuery = { ...urlQuery, ...query }; + + serverRouter.start(url, mergedQuery); const route = serverRouter.route; if (!route) { @@ -64,7 +74,10 @@ export const render = async (url, query) => { updateStore(storeState); - head = "쇼핑몰"; + // withLifecycle의 metadata 함수 호출 + const metadata = HomePage.metadata ? HomePage.metadata() : {}; + head = `${metadata.title || "쇼핑몰"} +`; initialData = { products: storeState.products, categories: storeState.categories, @@ -111,7 +124,14 @@ export const render = async (url, query) => { updateStore(storeState); - head = `쇼핑몰 상세 - ${product.title}`; + // 상품 데이터가 있을 때만 동적 메타데이터 생성 + if (product) { + head = `${product.title} - 쇼핑몰 +`; + } else { + head = `상품 상세 - 쇼핑몰 +`; + } initialData = { product, relatedProducts, diff --git a/packages/vanilla/src/pages/HomePage.js b/packages/vanilla/src/pages/HomePage.js index a4f05f9b..9ed193df 100644 --- a/packages/vanilla/src/pages/HomePage.js +++ b/packages/vanilla/src/pages/HomePage.js @@ -36,6 +36,22 @@ export const HomePage = withLifecycle( }, () => loadProducts(true), ], + metadata: () => { + const { search, category1, category2 } = router.query; + let title = "쇼핑몰"; + let description = "다양한 상품을 만나보세요"; + + if (search) { + title = `"${search}" 검색 결과 - 쇼핑몰`; + description = `"${search}" 검색 결과를 확인해보세요.`; + } else if (category1 || category2) { + const category = category2 || category1; + title = `${category} 카테고리 - 쇼핑몰`; + description = `${category} 카테고리의 상품들을 확인해보세요.`; + } + + return { title, description }; + }, }, (props = {}) => { const productState = diff --git a/packages/vanilla/src/pages/ProductDetailPage.js b/packages/vanilla/src/pages/ProductDetailPage.js index 73d0ec30..1cb7ca80 100644 --- a/packages/vanilla/src/pages/ProductDetailPage.js +++ b/packages/vanilla/src/pages/ProductDetailPage.js @@ -237,9 +237,33 @@ function ProductDetail({ product, relatedProducts = [] }) { export const ProductDetailPage = withLifecycle( { onMount: () => { - loadProductDetailForPage(router.params.id); + // 서버 환경에서는 이미 데이터가 로드되어 있으므로 추가 로드하지 않음 + if (typeof window !== "undefined") { + loadProductDetailForPage(router.params.id); + } + }, + watches: [ + () => [router.params.id], + () => { + // 서버 환경에서는 추가 로드하지 않음 + if (typeof window !== "undefined") { + loadProductDetailForPage(router.params.id); + } + }, + ], + metadata: () => { + const { currentProduct: product } = productStore.getState(); + if (product) { + return { + title: `${product.title} - 쇼핑몰`, + description: `${product.title} 상품 정보를 확인해보세요.`, + }; + } + return { + title: "상품 상세 - 쇼핑몰", + description: "상품 정보를 확인해보세요.", + }; }, - watches: [() => [router.params.id], () => loadProductDetailForPage(router.params.id)], }, () => { const { currentProduct: product, relatedProducts = [], error, loading } = productStore.getState(); diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index 6472196f..c1ab2e34 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -82,7 +82,7 @@ async function generateStaticSite() { const productHtml = replacePlaceholders( template, productResult.html, - generateHead(`${product.title} - 쇼핑몰`, product.title), + productResult.head || generateHead(`${product.title} - 쇼핑몰`, product.title), productResult.initialData, ); From 9a650a34ea8ce7247cd22f3ad226da3cb9f44e0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=ED=98=9C?= Date: Thu, 4 Sep 2025 15:45:00 +0900 Subject: [PATCH 09/14] =?UTF-8?q?fix:=20SSR=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EC=A3=BC=EC=9E=85=20=EB=B0=8F=20URL=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - window.__INITIAL_DATA__ 주입 문제 해결 - URL 파라미터 처리 개선 - SSG 동적 메타데이터 생성 개선 --- packages/vanilla/server.js | 2 +- packages/vanilla/src/main-server.js | 6 ++++++ packages/vanilla/static-site-generate.js | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index e45792c4..0428292c 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -62,7 +62,7 @@ app.use("*all", async (req, res) => { .replace(``, rendered.head ?? "") .replace(``, rendered.html ?? "") .replace( - ``, + ``, ``, ); diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index ee78c2ee..17663163 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -48,6 +48,12 @@ export const render = async (url, query) => { serverRouter.start(url, mergedQuery); + // router 객체의 query도 설정 + if (typeof window === "undefined") { + const { router } = await import("./router/router.js"); + router.query = mergedQuery; + } + const route = serverRouter.route; if (!route) { return { diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index c1ab2e34..4393ee03 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -82,7 +82,7 @@ async function generateStaticSite() { const productHtml = replacePlaceholders( template, productResult.html, - productResult.head || generateHead(`${product.title} - 쇼핑몰`, product.title), + productResult.head, productResult.initialData, ); From faf5d3c8dd8f56380330cb64a46aee9c92b0ab71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=ED=98=9C?= Date: Thu, 4 Sep 2025 16:20:07 +0900 Subject: [PATCH 10/14] =?UTF-8?q?fix:=20SSR/SSG=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=20=EB=B0=8F=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ServerRouter query 처리 로직 개선 - main-server.js 라우터 시작 방식 변경 - HomePage 메타데이터 및 초기 상태 설정 개선 - static-site-generate.js 데이터 키 수정 --- packages/vanilla/src/lib/ServerRouter.js | 18 ++++++++++++------ packages/vanilla/src/main-server.js | 3 ++- packages/vanilla/src/pages/HomePage.js | 11 +++++++++++ packages/vanilla/static-site-generate.js | 2 +- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js index 944b78e4..9dcdea37 100644 --- a/packages/vanilla/src/lib/ServerRouter.js +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -7,21 +7,26 @@ export class ServerRouter extends BaseRouter { #currentUrl = "/"; #origin = "http://localhost"; #routes = new Map(); + #query = {}; constructor(baseUrl = "") { super(baseUrl); } + set query(newQuery) { + // 서버사이드에서는 URL 재구성하지 않고 query만 저장 + this.#query = newQuery; + } + get query() { + // query getter도 수정 + if (this.#query) { + return this.#query; + } const url = new URL(this.#currentUrl, this.#origin); return BaseRouter.parseQuery(url.search); } - set query(newQuery) { - const newUrl = BaseRouter.getUrl(newQuery, this.baseUrl, this.#currentUrl); - this.setUrl(newUrl, this.#origin); - } - getCurrentUrl() { return this.#currentUrl; } @@ -89,7 +94,8 @@ export class ServerRouter extends BaseRouter { }) .replace(/\//g, "\\/"); - const regex = new RegExp(`^${regexPath}$`); + // baseUrl을 고려한 정규식 생성 + const regex = new RegExp(`^${this.baseUrl}${regexPath}$`); const match = pathname.match(regex); if (match) { diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 17663163..9098d4ed 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -46,6 +46,7 @@ export const render = async (url, query) => { // 전달된 query와 URL의 쿼리를 병합 const mergedQuery = { ...urlQuery, ...query }; + // 전체 URL을 사용하여 라우터 시작 serverRouter.start(url, mergedQuery); // router 객체의 query도 설정 @@ -82,7 +83,7 @@ export const render = async (url, query) => { // withLifecycle의 metadata 함수 호출 const metadata = HomePage.metadata ? HomePage.metadata() : {}; - head = `${metadata.title || "쇼핑몰"} + head = `${metadata.title || "쇼핑몰 - 홈"} `; initialData = { products: storeState.products, diff --git a/packages/vanilla/src/pages/HomePage.js b/packages/vanilla/src/pages/HomePage.js index 9ed193df..bdb770e5 100644 --- a/packages/vanilla/src/pages/HomePage.js +++ b/packages/vanilla/src/pages/HomePage.js @@ -27,6 +27,17 @@ export const HomePage = withLifecycle( return; } console.log("이 코드는 아무것도 없을 때!"); + // CSR 환경에서 로딩 상태를 보기 위해 초기 상태 설정 + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: [], + categories: {}, + totalCount: 0, + loading: true, + status: "pending", + }, + }); loadProductsAndCategories(); }, watches: [ diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index 4393ee03..d55e31f2 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -78,7 +78,7 @@ async function generateStaticSite() { const productResult = await render(productUrl, {}); - if (productResult.initialData.currentProduct) { + if (productResult.initialData.product) { const productHtml = replacePlaceholders( template, productResult.html, From 34ba7eba84a7c4f1e59bc53e557b6a93963535df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=ED=98=9C?= Date: Thu, 4 Sep 2025 20:24:09 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat:=20SSR=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EB=A1=9C=EB=94=A9=20=EB=B0=8F=20=EB=A0=8C=EB=8D=94=EB=A7=81?= =?UTF-8?q?=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SSR 데이터 로딩 및 목 API 통합 추가 - renderToString을 이용한 서버사이드 렌더링 구현 - 서버 초기 데이터 처리를 위한 App 컴포넌트 업데이트 - 프로덕션 SSR 지원을 위한 server.js 설정 --- packages/react/server.js | 82 +++++++++++++----- packages/react/src/App.tsx | 23 +++++ packages/react/src/main-server.tsx | 56 +++++++++++- packages/react/src/ssr-data.ts | 131 +++++++++++++++++++++++++++++ 4 files changed, 271 insertions(+), 21 deletions(-) create mode 100644 packages/react/src/ssr-data.ts diff --git a/packages/react/server.js b/packages/react/server.js index 6b9430ac..362bcd40 100644 --- a/packages/react/server.js +++ b/packages/react/server.js @@ -1,32 +1,76 @@ import express from "express"; -import { renderToString } from "react-dom/server"; -import { createElement } from "react"; +import fs from "node:fs/promises"; +import path from "node:path"; const prod = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5174; const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/react/" : "/"); +const templateHtml = prod ? await fs.readFile("./dist/react/index.html", "utf-8") : ""; + const app = express(); -app.get("*all", (req, res) => { - res.send( - ` - - - - - - React SSR - - -
${renderToString(createElement("div", null, "안녕하세요"))}
- - - `.trim(), - ); +// 불필요한 요청 무시 +app.get("/favicon.ico", (_, res) => { + res.status(204).end(); +}); +app.get("/.well-known/appspecific/com.chrome.devtools.json", (_, res) => { + res.status(204).end(); +}); + +// Add Vite or respective production middlewares +/** @type {import('vite').ViteDevServer | undefined} */ +let vite; +if (!prod) { + const { createServer } = await import("vite"); + vite = await createServer({ + server: { middlewareMode: true }, + appType: "custom", + base, + }); + app.use(vite.middlewares); +} else { + const compression = (await import("compression")).default; + const sirv = (await import("sirv")).default; + app.use(compression()); + app.use(base, sirv("./dist/react", { extensions: [] })); +} + +// Serve HTML +app.use("*all", async (req, res) => { + try { + const url = req.originalUrl.replace(base, ""); + const pathname = path.normalize(`/${url.split("?")[0]}`); + + let template; + let render; + if (!prod) { + // 개발 환경: Vite로 main-server.tsx 로드 + template = await fs.readFile("./index.html", "utf-8"); + template = await vite.transformIndexHtml(url, template); + render = (await vite.ssrLoadModule("/src/main-server.tsx")).render; + } else { + // 프로덕션 환경: 빌드된 main-server.js 로드 + template = templateHtml; + render = (await import("./dist/react-ssr/main-server.js")).render; + } + + const rendered = await render(pathname, req.query); + + // HTML 템플릿 치환 (React 방식) + const html = template + .replace(``, rendered.head ?? "") + .replace(``, rendered.html ?? "") + .replace(``, ``); + + res.status(200).set({ "Content-Type": "text/html" }).send(html); + } catch (e) { + vite?.ssrFixStacktrace(e); + console.log(e.stack); + res.status(500).end(e.stack); + } }); -// Start http server app.listen(port, () => { console.log(`React Server started at http://localhost:${port}`); }); diff --git a/packages/react/src/App.tsx b/packages/react/src/App.tsx index 36b302ca..d7df4c0e 100644 --- a/packages/react/src/App.tsx +++ b/packages/react/src/App.tsx @@ -19,6 +19,29 @@ const CartInitializer = () => { export const App = () => { const PageComponent = useCurrentPage(); + // 서버에서 전달받은 초기 데이터 확인 + const initialData = + typeof window !== "undefined" ? (window as unknown as Record).__INITIAL_DATA__ : null; + + // 서버 데이터가 있으면 직접 렌더링 + if (initialData && (initialData as Record).products) { + const data = initialData as Record; + return ( +
+

쇼핑몰

+

총 {data.totalCount as string}개

+
+ {(data.products as Array>).map((product) => ( +
+

{product.title as string}

+

{product.lprice as string}원

+
+ ))} +
+
+ ); + } + return ( <> diff --git a/packages/react/src/main-server.tsx b/packages/react/src/main-server.tsx index 611b0a58..ca4af4d9 100644 --- a/packages/react/src/main-server.tsx +++ b/packages/react/src/main-server.tsx @@ -1,4 +1,56 @@ +import { renderToString } from "react-dom/server"; +import { App } from "./App"; +import { loadHomePageData, loadProductDetailData } from "./ssr-data"; +import type { HomePageData, ProductDetailData } from "./ssr-data"; + export const render = async (url: string, query: Record) => { - console.log({ url, query }); - return ""; + try { + // URL에 따라 데이터 로딩 + let initialData: HomePageData | ProductDetailData | Record = {}; + let title = "쇼핑몰"; + + if (url === "/") { + // 홈페이지 데이터 로딩 + const homeData = await loadHomePageData(url, query); + initialData = { + products: homeData.products, + categories: homeData.categories, + totalCount: homeData.totalCount, + url, + query, + }; + title = "쇼핑몰 - 홈"; + } else if (url.startsWith("/product/")) { + // 상품 상세 페이지 데이터 로딩 + const productId = url.split("/product/")[1]?.split("/")[0]; + if (productId) { + const productData = await loadProductDetailData(productId); + if (productData) { + initialData = { + product: productData.product, + relatedProducts: productData.relatedProducts, + url, + query, + }; + title = `${productData.product.title} - 쇼핑몰`; + } + } + } + + // React 컴포넌트를 HTML 문자열로 렌더링 + const html = renderToString(); + + return { + html, + head: `${title}`, + initialData, + }; + } catch (error) { + console.error("SSR Error:", error); + return { + html: "
Error occurred
", + head: "Error", + initialData: {}, + }; + } }; diff --git a/packages/react/src/ssr-data.ts b/packages/react/src/ssr-data.ts new file mode 100644 index 00000000..ebe7607f --- /dev/null +++ b/packages/react/src/ssr-data.ts @@ -0,0 +1,131 @@ +// 서버 데이터 로딩 시스템 +import items from "./mocks/items.json" with { type: "json" }; +import type { Product } from "./entities"; + +export interface Category { + [key: string]: { + [key: string]: { + [key: string]: { + [key: string]: Record; + }; + }; + }; +} + +export interface HomePageData { + products: Product[]; + categories: Category; + totalCount: number; +} + +export interface ProductDetailData { + product: Product; + relatedProducts: Product[]; +} + +// 카테고리 추출 함수 (handlers.ts에서 가져옴) +function getUniqueCategories(): Category { + const categories: Category = {}; + + items.forEach((item: Product) => { + if (!categories[item.category1]) { + categories[item.category1] = {}; + } + if (!categories[item.category1][item.category2]) { + categories[item.category1][item.category2] = {}; + } + if (!categories[item.category1][item.category2][item.category3]) { + categories[item.category1][item.category2][item.category3] = {}; + } + if (!categories[item.category1][item.category2][item.category3][item.category4]) { + categories[item.category1][item.category2][item.category3][item.category4] = {}; + } + }); + + return categories; +} + +// 상품 검색 및 필터링 함수 (handlers.ts에서 가져옴) +function filterProducts(products: Product[], query: Record): Product[] { + let filtered = [...products]; + + // 검색어 필터링 + if (query.search) { + const searchTerm = query.search.toLowerCase(); + filtered = filtered.filter((product) => product.title.toLowerCase().includes(searchTerm)); + } + + // 카테고리 필터링 + if (query.category1) { + filtered = filtered.filter((product) => product.category1 === query.category1); + } + if (query.category2) { + filtered = filtered.filter((product) => product.category2 === query.category2); + } + if (query.category3) { + filtered = filtered.filter((product) => product.category3 === query.category3); + } + if (query.category4) { + filtered = filtered.filter((product) => product.category4 === query.category4); + } + + // 정렬 + 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)); + break; + case "name_desc": + filtered.sort((a, b) => b.title.localeCompare(a.title)); + break; + } + } + + return filtered; +} + +// 상품 ID로 상품 찾기 (handlers.ts에서 가져옴) +function findProductById(productId: string): Product | undefined { + return items.find((item: Product) => item.productId === productId); +} + +// 관련 상품 가져오기 (handlers.ts에서 가져옴) +function getRelatedProducts(currentProductId: string): Product[] { + const currentProduct = findProductById(currentProductId); + if (!currentProduct) return []; + + return items + .filter((item: Product) => item.productId !== currentProductId && item.category1 === currentProduct.category1) + .slice(0, 4); +} + +export async function loadHomePageData(url: string, query: Record): Promise { + const filteredProducts = filterProducts(items as Product[], query); + const categories = getUniqueCategories(); + + return { + products: filteredProducts, + categories, + totalCount: filteredProducts.length, + }; +} + +export async function loadProductDetailData(productId: string): Promise { + const product = findProductById(productId); + if (!product) { + return null; + } + + const relatedProducts = getRelatedProducts(productId); + + return { + product, + relatedProducts, + }; +} From f372da399221fd6ed316efb24c7e2c1a5cb9a3a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=ED=98=9C?= Date: Fri, 5 Sep 2025 01:19:34 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20SSR/SSG=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=84=9C=EB=B2=84=20=EC=84=A4=EC=A0=95=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ServerRouter 클래스 추가로 서버/클라이언트 분기 구현 - window 접근 조건부 처리로 서버사이드 에러 해결 - SSR 서버 설정 및 MSW 모킹 추가 - useRouter 훅이 ServerRouter와 호환되도록 수정 --- packages/lib/src/Router.ts | 2 - packages/lib/src/ServerRouter.ts | 145 +++++++++++++++++++++++++ packages/lib/src/hooks/useRouter.ts | 2 +- packages/lib/src/index.ts | 1 + packages/lib/src/types.ts | 8 ++ packages/react/server.js | 14 ++- packages/react/src/mocks/mockServer.js | 6 + packages/react/src/router/router.ts | 6 +- 8 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 packages/lib/src/ServerRouter.ts create mode 100644 packages/react/src/mocks/mockServer.js diff --git a/packages/lib/src/Router.ts b/packages/lib/src/Router.ts index eb3bd157..c201b1df 100644 --- a/packages/lib/src/Router.ts +++ b/packages/lib/src/Router.ts @@ -10,8 +10,6 @@ interface Route { type QueryPayload = Record; -export type RouterInstance = InstanceType>; - // eslint-disable-next-line @typescript-eslint/no-explicit-any export class Router any> { readonly #routes: Map>; diff --git a/packages/lib/src/ServerRouter.ts b/packages/lib/src/ServerRouter.ts new file mode 100644 index 00000000..f987c431 --- /dev/null +++ b/packages/lib/src/ServerRouter.ts @@ -0,0 +1,145 @@ +import type { AnyFunction, StringRecord } from "./types"; + +interface Route { + regex: RegExp; + paramNames: string[]; + handler: Handler; + params?: StringRecord; +} + +type QueryPayload = Record; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export class ServerRouter any> { + readonly #routes: Map>; + readonly #baseUrl; + + #route: null | (Route & { params: StringRecord; path: string }); + #currentUrl = "/"; + + constructor(baseUrl = "") { + this.#routes = new Map(); + this.#route = null; + this.#baseUrl = baseUrl.replace(/\/$/, ""); + } + + get url() { + return this.#currentUrl; + } + + set url(newUrl: string) { + if (this.#currentUrl.toString() !== newUrl) { + this.#currentUrl = newUrl; + } + } + + get query(): StringRecord { + return ServerRouter.parseQuery(this.#currentUrl); + } + + set query(newQuery: QueryPayload) { + const newUrl = ServerRouter.getUrl(newQuery, this.#currentUrl, this.#baseUrl); + this.push(newUrl); + } + + get params() { + return this.#route?.params ?? {}; + } + + get route() { + return this.#route; + } + + get target() { + return this.#route?.handler; + } + + addRoute(path: string, handler: Handler) { + // 경로 패턴을 정규식으로 변환 + const paramNames: string[] = []; + const regexPath = path + .replace(/:\w+/g, (match) => { + paramNames.push(match.slice(1)); // ':id' -> 'id' + return "([^/]+)"; + }) + .replace(/\//g, "\\/"); + + const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`); + + this.#routes.set(path, { + regex, + paramNames, + handler, + }); + } + + #findRoute(url = this.#baseUrl) { + const { pathname } = new URL(url, "localhost"); + for (const [routePath, route] of this.#routes) { + const match = pathname.match(route.regex); + if (match) { + // 매치된 파라미터들을 객체로 변환 + const params: StringRecord = {}; + route.paramNames.forEach((name, index) => { + params[name] = match[index + 1]; + }); + + return { + ...route, + params, + path: routePath, + }; + } + } + return null; + } + + push(url: string) { + try { + // baseUrl이 없으면 자동으로 붙여줌 + const fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url); + + this.#route = this.#findRoute(fullUrl); + } catch (error) { + console.error("라우터 네비게이션 오류:", error); + } + } + + start() { + this.#route = this.#findRoute(); + } + + static parseQuery = (search = window.location.search) => { + const params = new URLSearchParams(search); + const query: StringRecord = {}; + for (const [key, value] of params) { + query[key] = value; + } + return query; + }; + + static stringifyQuery = (query: QueryPayload) => { + 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: QueryPayload, pathname = "/", baseUrl = "") => { + const currentQuery = ServerRouter.parseQuery(); + const updatedQuery = { ...currentQuery, ...newQuery }; + + // 빈 값들 제거 + Object.keys(updatedQuery).forEach((key) => { + if (updatedQuery[key] === null || updatedQuery[key] === undefined || updatedQuery[key] === "") { + delete updatedQuery[key]; + } + }); + + const queryString = ServerRouter.stringifyQuery(updatedQuery); + return `${baseUrl}${pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`; + }; +} diff --git a/packages/lib/src/hooks/useRouter.ts b/packages/lib/src/hooks/useRouter.ts index 4a40cb5d..2549e82b 100644 --- a/packages/lib/src/hooks/useRouter.ts +++ b/packages/lib/src/hooks/useRouter.ts @@ -1,4 +1,4 @@ -import type { RouterInstance } from "../Router"; +import type { RouterInstance } from "../types"; import type { AnyFunction } from "../types"; import { useSyncExternalStore } from "react"; import { useShallowSelector } from "./useShallowSelector"; diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index 74605597..8acf9807 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -2,6 +2,7 @@ export * from "./createObserver"; export * from "./createStorage"; export * from "./createStore"; export * from "./Router"; +export * from "./ServerRouter"; export { useStore, useStorage, useRouter, useAutoCallback } from "./hooks"; export * from "./equals"; export * from "./types"; diff --git a/packages/lib/src/types.ts b/packages/lib/src/types.ts index 70271ab9..abf65dbf 100644 --- a/packages/lib/src/types.ts +++ b/packages/lib/src/types.ts @@ -1,5 +1,13 @@ +import { Router } from "./Router"; +import type { ServerRouter } from "./ServerRouter"; + export type StringRecord = Record; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnyFunction = (...args: any[]) => any; export type Selector = (state: T) => S; + +export type RouterInstance< + T extends AnyFunction, + R extends typeof Router | typeof ServerRouter, +> = InstanceType; diff --git a/packages/react/server.js b/packages/react/server.js index 362bcd40..302b496d 100644 --- a/packages/react/server.js +++ b/packages/react/server.js @@ -42,26 +42,29 @@ app.use("*all", async (req, res) => { const url = req.originalUrl.replace(base, ""); const pathname = path.normalize(`/${url.split("?")[0]}`); + /** @type {string} */ let template; + /** @type {import('./src/main-server.js').render} */ let render; if (!prod) { - // 개발 환경: Vite로 main-server.tsx 로드 + // Always read fresh template in development template = await fs.readFile("./index.html", "utf-8"); template = await vite.transformIndexHtml(url, template); - render = (await vite.ssrLoadModule("/src/main-server.tsx")).render; + render = (await vite.ssrLoadModule("/src/main-server.js")).render; } else { - // 프로덕션 환경: 빌드된 main-server.js 로드 template = templateHtml; render = (await import("./dist/react-ssr/main-server.js")).render; } const rendered = await render(pathname, req.query); - // HTML 템플릿 치환 (React 방식) const html = template .replace(``, rendered.head ?? "") .replace(``, rendered.html ?? "") - .replace(``, ``); + .replace( + ``, + ``, + ); res.status(200).set({ "Content-Type": "text/html" }).send(html); } catch (e) { @@ -71,6 +74,7 @@ app.use("*all", async (req, res) => { } }); +// Start http server app.listen(port, () => { console.log(`React Server started at http://localhost:${port}`); }); diff --git a/packages/react/src/mocks/mockServer.js b/packages/react/src/mocks/mockServer.js new file mode 100644 index 00000000..f0b5a1d5 --- /dev/null +++ b/packages/react/src/mocks/mockServer.js @@ -0,0 +1,6 @@ +import { setupServer } from "msw/node"; +import { handlers } from "./handlers.js"; + +// MSW 서버 설정 - Node.js 환경에서 API 요청을 가로채기 위한 설정 +// 이 서버는 SSR(Server-Side Rendering) 시 서버에서 발생하는 API 요청을 모킹합니다 +export const mockServer = setupServer(...handlers); diff --git a/packages/react/src/router/router.ts b/packages/react/src/router/router.ts index ddb3a7cd..fcf0fb0c 100644 --- a/packages/react/src/router/router.ts +++ b/packages/react/src/router/router.ts @@ -1,6 +1,8 @@ // 글로벌 라우터 인스턴스 -import { Router } from "@hanghae-plus/lib"; +import { Router, ServerRouter } from "@hanghae-plus/lib"; import { BASE_URL } from "../constants"; import type { FunctionComponent } from "react"; -export const router = new Router(BASE_URL); +const CurrentRouter = typeof window === "undefined" ? ServerRouter : Router; + +export const router = new CurrentRouter(BASE_URL); From 57e793cf7becbca6d2daeef98d7b76559d2007da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=ED=98=9C?= Date: Fri, 5 Sep 2025 03:02:33 +0900 Subject: [PATCH 13/14] =?UTF-8?q?feat:=20SSR/SSG=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20Universal=20Router=20=EB=B0=8F?= =?UTF-8?q?=20Hydration=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ServerRouter 클래스로 서버/클라이언트 분기 구현 - useRouter 훅이 서버/클라이언트 라우터 모두 지원 - window.__INITIAL_DATA__ 기반 Hydration 시스템 구현 - 서버사이드 스토리지 및 스토어 지원 추가 - SSR 데이터 처리 로직 완성 --- packages/lib/src/ServerRouter.ts | 27 ++--- packages/lib/src/hooks/useRouter.ts | 16 ++- packages/lib/src/hooks/useStorage.ts | 2 +- packages/lib/src/hooks/useStore.ts | 6 +- packages/lib/src/types.ts | 2 +- packages/react/src/App.tsx | 26 ++++- .../src/entities/carts/storage/cartStorage.ts | 8 +- .../products/components/ProductCard.tsx | 3 - .../products/components/ProductDetail.tsx | 2 - .../src/entities/products/productUseCase.ts | 13 +++ packages/react/src/main-server.tsx | 15 +++ packages/react/src/main.tsx | 7 +- packages/react/src/mocks/server.js | 100 ++++++++++++++++++ packages/react/src/pages/HomePage.tsx | 15 ++- packages/react/src/types.ts | 7 ++ packages/react/src/utils/index.ts | 1 - packages/react/src/utils/log.ts | 17 --- 17 files changed, 213 insertions(+), 54 deletions(-) create mode 100644 packages/react/src/mocks/server.js delete mode 100644 packages/react/src/utils/log.ts diff --git a/packages/lib/src/ServerRouter.ts b/packages/lib/src/ServerRouter.ts index f987c431..2c24022c 100644 --- a/packages/lib/src/ServerRouter.ts +++ b/packages/lib/src/ServerRouter.ts @@ -1,3 +1,4 @@ +import { createObserver } from "./createObserver"; import type { AnyFunction, StringRecord } from "./types"; interface Route { @@ -13,6 +14,7 @@ type QueryPayload = Record; export class ServerRouter any> { readonly #routes: Map>; readonly #baseUrl; + readonly #observer = createObserver(); #route: null | (Route & { params: StringRecord; path: string }); #currentUrl = "/"; @@ -23,16 +25,6 @@ export class ServerRouter any> { this.#baseUrl = baseUrl.replace(/\/$/, ""); } - get url() { - return this.#currentUrl; - } - - set url(newUrl: string) { - if (this.#currentUrl.toString() !== newUrl) { - this.#currentUrl = newUrl; - } - } - get query(): StringRecord { return ServerRouter.parseQuery(this.#currentUrl); } @@ -54,6 +46,8 @@ export class ServerRouter any> { return this.#route?.handler; } + readonly subscribe = this.#observer.subscribe; + addRoute(path: string, handler: Handler) { // 경로 패턴을 정규식으로 변환 const paramNames: string[] = []; @@ -74,7 +68,7 @@ export class ServerRouter any> { } #findRoute(url = this.#baseUrl) { - const { pathname } = new URL(url, "localhost"); + const { pathname } = new URL(url, "http://localhost"); for (const [routePath, route] of this.#routes) { const match = pathname.match(route.regex); if (match) { @@ -95,21 +89,23 @@ export class ServerRouter any> { } push(url: string) { + this.#currentUrl = url; try { // baseUrl이 없으면 자동으로 붙여줌 const fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url); this.#route = this.#findRoute(fullUrl); + this.#observer.notify(); } catch (error) { console.error("라우터 네비게이션 오류:", error); } } start() { - this.#route = this.#findRoute(); + this.#route = this.#findRoute("/"); } - static parseQuery = (search = window.location.search) => { + static parseQuery = (search: string) => { const params = new URLSearchParams(search); const query: StringRecord = {}; for (const [key, value] of params) { @@ -128,10 +124,7 @@ export class ServerRouter any> { return params.toString(); }; - static getUrl = (newQuery: QueryPayload, pathname = "/", baseUrl = "") => { - const currentQuery = ServerRouter.parseQuery(); - const updatedQuery = { ...currentQuery, ...newQuery }; - + static getUrl = (updatedQuery: QueryPayload, pathname = "/", baseUrl = "") => { // 빈 값들 제거 Object.keys(updatedQuery).forEach((key) => { if (updatedQuery[key] === null || updatedQuery[key] === undefined || updatedQuery[key] === "") { diff --git a/packages/lib/src/hooks/useRouter.ts b/packages/lib/src/hooks/useRouter.ts index 2549e82b..da7713ad 100644 --- a/packages/lib/src/hooks/useRouter.ts +++ b/packages/lib/src/hooks/useRouter.ts @@ -1,11 +1,19 @@ -import type { RouterInstance } from "../types"; -import type { AnyFunction } from "../types"; import { useSyncExternalStore } from "react"; import { useShallowSelector } from "./useShallowSelector"; +import type { AnyFunction, RouterInstance } from "../types"; +import type { Router } from "../Router"; +import type { ServerRouter } from "../ServerRouter"; const defaultSelector = (state: T) => state as unknown as S; -export const useRouter = , S>(router: T, selector = defaultSelector) => { +export const useRouter = , S>( + router: T, + selector = defaultSelector, +) => { const shallowSelector = useShallowSelector(selector); - return useSyncExternalStore(router.subscribe, () => shallowSelector(router)); + return useSyncExternalStore( + router.subscribe, + () => shallowSelector(router), + () => shallowSelector(router), + ); }; diff --git a/packages/lib/src/hooks/useStorage.ts b/packages/lib/src/hooks/useStorage.ts index f620638c..1c51cd7a 100644 --- a/packages/lib/src/hooks/useStorage.ts +++ b/packages/lib/src/hooks/useStorage.ts @@ -4,5 +4,5 @@ import type { createStorage } from "../createStorage"; type Storage = ReturnType>; export const useStorage = (storage: Storage) => { - return useSyncExternalStore(storage.subscribe, storage.get); + return useSyncExternalStore(storage.subscribe, storage.get, storage.get); }; diff --git a/packages/lib/src/hooks/useStore.ts b/packages/lib/src/hooks/useStore.ts index 56fa8800..1613ea83 100644 --- a/packages/lib/src/hooks/useStore.ts +++ b/packages/lib/src/hooks/useStore.ts @@ -8,5 +8,9 @@ const defaultSelector = (state: T) => state as unknown as S; export const useStore = (store: Store, selector: (state: T) => S = defaultSelector) => { const shallowSelector = useShallowSelector(selector); - return useSyncExternalStore(store.subscribe, () => shallowSelector(store.getState())); + return useSyncExternalStore( + store.subscribe, + () => shallowSelector(store.getState()), + () => shallowSelector(store.getState()), + ); }; diff --git a/packages/lib/src/types.ts b/packages/lib/src/types.ts index abf65dbf..32df5c46 100644 --- a/packages/lib/src/types.ts +++ b/packages/lib/src/types.ts @@ -9,5 +9,5 @@ export type Selector = (state: T) => S; export type RouterInstance< T extends AnyFunction, - R extends typeof Router | typeof ServerRouter, + R extends typeof Router | typeof ServerRouter = typeof Router, > = InstanceType; diff --git a/packages/react/src/App.tsx b/packages/react/src/App.tsx index d7df4c0e..fc6f2e76 100644 --- a/packages/react/src/App.tsx +++ b/packages/react/src/App.tsx @@ -1,10 +1,32 @@ import { router, useCurrentPage } from "./router"; import { HomePage, NotFoundPage, ProductDetailPage } from "./pages"; -import { useLoadCartStore } from "./entities"; +import { PRODUCT_ACTIONS, productStore, useLoadCartStore } from "./entities"; import { ModalProvider, ToastProvider } from "./components"; +import { getProducts, getUniqueCategories } from "./mocks/server"; // 홈 페이지 (상품 목록) -router.addRoute("/", HomePage); +router.addRoute("/", () => { + if (typeof window === "undefined") { + const { + products, + pagination: { total: totalCount }, + } = getProducts(); + + const categories = getUniqueCategories(); + + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products, + categories, + totalCount, + loading: false, + status: "done", + }, + }); + } + return HomePage(); +}); router.addRoute("/product/:id/", ProductDetailPage); router.addRoute(".*", NotFoundPage); diff --git a/packages/react/src/entities/carts/storage/cartStorage.ts b/packages/react/src/entities/carts/storage/cartStorage.ts index 6af02204..a027e8c4 100644 --- a/packages/react/src/entities/carts/storage/cartStorage.ts +++ b/packages/react/src/entities/carts/storage/cartStorage.ts @@ -1,7 +1,13 @@ import { createStorage } from "@hanghae-plus/lib"; import type { Cart } from "../types"; +// 강제로 타입 캐스팅? +const storage = + typeof window !== "undefined" + ? window.localStorage + : ({ getItem: () => null, setItem: () => {}, removeItem: () => {} } as unknown as Storage); + export const cartStorage = createStorage<{ items: Cart[]; selectedAll: boolean; -}>("shopping_cart"); +}>("shopping_cart", storage); diff --git a/packages/react/src/entities/products/components/ProductCard.tsx b/packages/react/src/entities/products/components/ProductCard.tsx index 017dc4f5..40a0ef01 100644 --- a/packages/react/src/entities/products/components/ProductCard.tsx +++ b/packages/react/src/entities/products/components/ProductCard.tsx @@ -1,6 +1,5 @@ import { useCartAddCommand } from "../../carts"; import type { Product } from "../types"; -import { log } from "../../../utils"; export function ProductCard({ onClick, ...product }: Product & { onClick: (id: string) => void }) { const addCart = useCartAddCommand(); @@ -10,8 +9,6 @@ export function ProductCard({ onClick, ...product }: Product & { onClick: (id: s const handleClick = () => onClick(productId); - log(`ProductCard: ${productId}`); - return (
) { - log(`ProductDetail: ${product.productId}`); const addToCart = useCartAddCommand(); const { productId, title, image, lprice, brand, category1, category2 } = product; const [cartQuantity, setCartQuantity] = useState(1); diff --git a/packages/react/src/entities/products/productUseCase.ts b/packages/react/src/entities/products/productUseCase.ts index fa967b56..bb11eb19 100644 --- a/packages/react/src/entities/products/productUseCase.ts +++ b/packages/react/src/entities/products/productUseCase.ts @@ -7,7 +7,20 @@ import { isNearBottom } from "../../utils"; const createErrorMessage = (error: unknown, defaultMessage = "알 수 없는 오류 발생") => error instanceof Error ? error.message : defaultMessage; +export const hydrateProduct = () => { + if (window.__INITIAL_DATA__?.product?.length > 0) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { ...initialProductState, ...window.__INITIAL_DATA__.products, loafing: false, status: "done" }, + }); + } +}; + export const loadProductsAndCategories = async () => { + if (window.__INITIAL_DATA__?.product?.length > 0) { + return; + } + router.query = { current: undefined }; // 항상 첫 페이지로 초기화 productStore.dispatch({ type: PRODUCT_ACTIONS.SETUP, diff --git a/packages/react/src/main-server.tsx b/packages/react/src/main-server.tsx index ca4af4d9..65ec4c19 100644 --- a/packages/react/src/main-server.tsx +++ b/packages/react/src/main-server.tsx @@ -2,9 +2,23 @@ import { renderToString } from "react-dom/server"; import { App } from "./App"; import { loadHomePageData, loadProductDetailData } from "./ssr-data"; import type { HomePageData, ProductDetailData } from "./ssr-data"; +import { ServerRouter } from "@hanghae-plus/lib"; +import { HomePage, NotFoundPage, ProductDetailPage } from "./pages"; +// import { productStore } from "./entities"; export const render = async (url: string, query: Record) => { try { + // 서버 라우터 초기화 + const serverRouter = new ServerRouter(); + + // 라우트 등록 + serverRouter.addRoute("/", HomePage); + serverRouter.addRoute("/product/:id/", ProductDetailPage); + serverRouter.addRoute(".*", NotFoundPage); + + // 현재 URL 설정 + serverRouter.push(url); + // URL에 따라 데이터 로딩 let initialData: HomePageData | ProductDetailData | Record = {}; let title = "쇼핑몰"; @@ -12,6 +26,7 @@ export const render = async (url: string, query: Record) => { if (url === "/") { // 홈페이지 데이터 로딩 const homeData = await loadHomePageData(url, query); + // initialData: productStore.getState(); initialData = { products: homeData.products, categories: homeData.categories, diff --git a/packages/react/src/main.tsx b/packages/react/src/main.tsx index 0c5b8a67..de93d5fc 100644 --- a/packages/react/src/main.tsx +++ b/packages/react/src/main.tsx @@ -1,7 +1,8 @@ import { App } from "./App"; import { router } from "./router"; import { BASE_URL } from "./constants.ts"; -import { createRoot } from "react-dom/client"; +import { hydrateRoot } from "react-dom/client"; +import { hydrateProduct } from "./entities/index.ts"; const enableMocking = () => import("./mocks/browser").then(({ worker }) => @@ -15,9 +16,11 @@ const enableMocking = () => function main() { router.start(); + hydrateProduct(); const rootElement = document.getElementById("root")!; - createRoot(rootElement).render(); + // createRoot(rootElement).render(); + hydrateRoot(rootElement, ); // 뭐가 다른지 알아보자 } // 애플리케이션 시작 diff --git a/packages/react/src/mocks/server.js b/packages/react/src/mocks/server.js new file mode 100644 index 00000000..ffcf44b6 --- /dev/null +++ b/packages/react/src/mocks/server.js @@ -0,0 +1,100 @@ +import items from "./items.json" with { type: "json" }; + +// 카테고리 추출 함수 +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 getProducts(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, + }, + }; +} diff --git a/packages/react/src/pages/HomePage.tsx b/packages/react/src/pages/HomePage.tsx index 4edbccc6..ec7e5463 100644 --- a/packages/react/src/pages/HomePage.tsx +++ b/packages/react/src/pages/HomePage.tsx @@ -1,5 +1,12 @@ import { useEffect } from "react"; -import { loadNextProducts, loadProductsAndCategories, ProductList, SearchBar } from "../entities"; +import { + hydrateProduct, + loadNextProducts, + loadProductsAndCategories, + ProductList, + // productStore, + SearchBar, +} from "../entities"; import { PageWrapper } from "./PageWrapper"; const headerLeft = ( @@ -29,7 +36,11 @@ const unregisterScrollHandler = () => { export const HomePage = () => { useEffect(() => { registerScrollHandler(); - loadProductsAndCategories(); + if (window.__INITIAL_DATA__?.productStore.length === 0) { + loadProductsAndCategories(); + } else { + hydrateProduct(); + } return unregisterScrollHandler; }, []); diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index a65dba85..75c133fa 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -1,2 +1,9 @@ export type StringRecord = Record; export type AnyFunction = (...args: unknown[]) => unknown; + +declare global { + interface Window { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + __INITIAL_DATA__?: any; + } +} diff --git a/packages/react/src/utils/index.ts b/packages/react/src/utils/index.ts index 8f590fd9..9069f657 100644 --- a/packages/react/src/utils/index.ts +++ b/packages/react/src/utils/index.ts @@ -1,3 +1,2 @@ export * from "./domUtils"; export * from "./debounce"; -export * from "./log"; diff --git a/packages/react/src/utils/log.ts b/packages/react/src/utils/log.ts deleted file mode 100644 index 00aa2d47..00000000 --- a/packages/react/src/utils/log.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -declare global { - interface Window { - __spyCalls: any[]; - __spyCallsClear: () => void; - } -} - -window.__spyCalls = []; -window.__spyCallsClear = () => { - window.__spyCalls = []; -}; - -export const log: typeof console.log = (...args) => { - window.__spyCalls.push(args); - return console.log(...args); -}; From 7234fb958ed963d97f66dfc0566abf23281ea23d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=ED=98=9C?= Date: Fri, 5 Sep 2025 04:34:24 +0900 Subject: [PATCH 14/14] =?UTF-8?q?index.html=EC=97=90=20=20?= =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=EC=8A=A4=ED=99=80=EB=8D=94=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A3=BC=EC=9E=85=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/react/index.html | 47 ++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/packages/react/index.html b/packages/react/index.html index c93c0168..667b046b 100644 --- a/packages/react/index.html +++ b/packages/react/index.html @@ -1,26 +1,27 @@ - - - - - - - - - -
- - + + + + + + + + + +
+ + +