From 6b6504998825e1496ff7ee2697394abddde39ba7 Mon Sep 17 00:00:00 2001 From: jsheo Date: Mon, 1 Sep 2025 22:31:48 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20SSR=20=EB=AA=A8=EB=93=88=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=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 - RouterSSR export 누락 문제 해결 - productApi import 경로 수정 --- packages/vanilla/server.js | 74 +++++++-- packages/vanilla/src/api/productApi.js | 6 +- packages/vanilla/src/lib/createStorage.js | 8 + packages/vanilla/src/lib/index.js | 3 +- packages/vanilla/src/lib/server-router.js | 146 ++++++++++++++++++ packages/vanilla/src/main.js | 8 +- packages/vanilla/src/mocks/handlers.js | 6 +- packages/vanilla/src/mocks/server-browser.js | 6 + packages/vanilla/src/pages/HomePage.js | 14 +- packages/vanilla/src/router/router.js | 4 +- .../vanilla/src/services/productService.js | 3 +- packages/vanilla/src/storage/cartStorage.js | 5 +- 12 files changed, 250 insertions(+), 33 deletions(-) create mode 100644 packages/vanilla/src/lib/server-router.js create mode 100644 packages/vanilla/src/mocks/server-browser.js diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index b9a56d98..a3b11eb9 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,4 +1,5 @@ import express from "express"; +import { getCategories, getProducts } from "./src/api/productApi.js"; const prod = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5173; @@ -6,23 +7,70 @@ const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/") const app = express(); -const render = () => { - return `
안녕하세요
`; +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/client", { extensions: [] })); +} + +const { HomePage } = await vite.ssrLoadModule("./src/pages/HomePage.js"); +const { server } = await vite.ssrLoadModule("./src/mocks/server-browser.js"); +server.listen(); +// 뿌려줄 아이 +const render = async (url, query) => { + console.log({ url, query }); + const [ + { + products, + pagination: { total }, + }, + categories, + ] = await Promise.all([getProducts(query), getCategories()]); + return `${HomePage(url, query, { products, categories, totalCount: total, loading: false, status: "done" })}`; }; -app.get("*all", (req, res) => { +// 호출부 +// get 이라는 메소드로 호출된 애는 아래로 시작하겠다. +// req: 요청 객체 (url, query) +// res: 응답 객체 +app.get("*all", async (req, res) => { res.send( ` - - - - - - Vanilla Javascript SSR - - -
${render()}
- + + + + + + + + + + + +
${await render(req.url, req.query)}
+ + `.trim(), ); diff --git a/packages/vanilla/src/api/productApi.js b/packages/vanilla/src/api/productApi.js index c2330fbe..e20adba4 100644 --- a/packages/vanilla/src/api/productApi.js +++ b/packages/vanilla/src/api/productApi.js @@ -11,17 +11,17 @@ export async function getProducts(params = {}) { sort, }); - const response = await fetch(`/api/products?${searchParams}`); + const response = await fetch(`http://localhost/api/products?${searchParams}`); return await response.json(); } export async function getProduct(productId) { - const response = await fetch(`/api/products/${productId}`); + const response = await fetch(`http://localhost/api/products/${productId}`); return await response.json(); } export async function getCategories() { - const response = await fetch("/api/categories"); + const response = await fetch("http://localhost/api/categories"); return await response.json(); } diff --git a/packages/vanilla/src/lib/createStorage.js b/packages/vanilla/src/lib/createStorage.js index 08b504f2..d68dd5c9 100644 --- a/packages/vanilla/src/lib/createStorage.js +++ b/packages/vanilla/src/lib/createStorage.js @@ -33,3 +33,11 @@ export const createStorage = (key, storage = window.localStorage) => { return { get, set, reset }; }; + +export const createSSRStorage = (key) => { + return { + get: () => null, + set: () => {}, + reset: () => {}, + }; +}; diff --git a/packages/vanilla/src/lib/index.js b/packages/vanilla/src/lib/index.js index a598ef30..6e5ae6d6 100644 --- a/packages/vanilla/src/lib/index.js +++ b/packages/vanilla/src/lib/index.js @@ -1,4 +1,5 @@ export * from "./createObserver"; -export * from "./createStore"; export * from "./createStorage"; +export * from "./createStore"; export * from "./Router"; +export * from "./server-Router"; diff --git a/packages/vanilla/src/lib/server-router.js b/packages/vanilla/src/lib/server-router.js new file mode 100644 index 00000000..315aceb5 --- /dev/null +++ b/packages/vanilla/src/lib/server-router.js @@ -0,0 +1,146 @@ +/** + * 간단한 SPA 라우터 + */ +import { createObserver } from "./createObserver.js"; + +export class RouterSSR { + #routes; + #route; + #observer = createObserver(); + #baseUrl; + + constructor(baseUrl = "") { + this.#routes = new Map(); + this.#route = null; + this.#baseUrl = baseUrl.replace(/\/$/, ""); + + // window.addEventListener("popstate", () => { + // this.#route = this.#findRoute(); + // this.#observer.notify(); + // }); + } + + get baseUrl() { + return this.#baseUrl; + } + + get query() { + // return RouterSSR.parseQuery(); + return {}; + } + + set query(newQuery) { + const newUrl = RouterSSR.getUrl(newQuery, this.#baseUrl); + this.push(newUrl); + } + + get params() { + return this.#route?.params ?? {}; + } + + get route() { + return this.#route; + } + + get target() { + return this.#route?.handler; + } + + subscribe(fn) { + this.#observer.subscribe(fn); + } + + /** + * 라우트 등록 + * @param {string} path - 경로 패턴 (예: "/product/:id") + * @param {Function} handler - 라우트 핸들러 + */ + addRoute(path, handler) { + // 경로 패턴을 정규식으로 변환 + const paramNames = []; + const regexPath = path + .replace(/:\w+/g, (match) => { + paramNames.push(match.slice(1)); // ':id' -> 'id' + return "([^/]+)"; + }) + .replace(/\//g, "\\/"); + + const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`); + + this.#routes.set(path, { + regex, + paramNames, + handler, + }); + } + + #findRoute(url = window.location.pathname) { + const { pathname } = new URL(url, window.location.origin); + for (const [routePath, route] of this.#routes) { + const match = pathname.match(route.regex); + if (match) { + // 매치된 파라미터들을 객체로 변환 + const params = {}; + route.paramNames.forEach((name, index) => { + params[name] = match[index + 1]; + }); + + return { + ...route, + params, + path: routePath, + }; + } + } + return null; + } + + /** + * 네비게이션 실행 + * @param {string} url - 이동할 경로 + */ + push() { + // + } + + /** + * 라우터 시작 + */ + start() { + this.#route = this.#findRoute(); + this.#observer.notify(); + } + + /** + * 쿼리 파라미터를 객체로 파싱 + * @param {string} search - location.search 또는 쿼리 문자열 + * @returns {Object} 파싱된 쿼리 객체 + */ + static parseQuery = (search = "") => { + const params = new URLSearchParams(search); + const query = {}; + for (const [key, value] of params) { + query[key] = value; + } + return query; + }; + + /** + * 객체를 쿼리 문자열로 변환 + * @param {Object} query - 쿼리 객체 + * @returns {string} 쿼리 문자열 + */ + static stringifyQuery = (query) => { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== null && value !== undefined && value !== "") { + params.set(key, String(value)); + } + } + return params.toString(); + }; + + static getUrl = (newQuery, baseUrl = "") => { + // + }; +} diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js index 4c3f2765..3b86db68 100644 --- a/packages/vanilla/src/main.js +++ b/packages/vanilla/src/main.js @@ -1,9 +1,9 @@ -import { registerGlobalEvents } from "./utils"; -import { initRender } from "./render"; +import { BASE_URL } from "./constants.js"; import { registerAllEvents } from "./events"; -import { loadCartFromStorage } from "./services"; +import { initRender } from "./render"; import { router } from "./router"; -import { BASE_URL } from "./constants.js"; +import { loadCartFromStorage } from "./services"; +import { registerGlobalEvents } from "./utils"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => diff --git a/packages/vanilla/src/mocks/handlers.js b/packages/vanilla/src/mocks/handlers.js index 6e3035e6..8d3a1faa 100644 --- a/packages/vanilla/src/mocks/handlers.js +++ b/packages/vanilla/src/mocks/handlers.js @@ -64,7 +64,7 @@ function filterProducts(products, query) { export const handlers = [ // 상품 목록 API - http.get("/api/products", async ({ request }) => { + http.get("*/api/products", async ({ request }) => { const url = new URL(request.url); const page = parseInt(url.searchParams.get("page") ?? url.searchParams.get("current")) || 1; const limit = parseInt(url.searchParams.get("limit")) || 20; @@ -111,7 +111,7 @@ export const handlers = [ }), // 상품 상세 API - http.get("/api/products/:id", ({ params }) => { + http.get("*/api/products/:id", ({ params }) => { const { id } = params; const product = items.find((item) => item.productId === id); @@ -133,7 +133,7 @@ export const handlers = [ }), // 카테고리 목록 API - http.get("/api/categories", async () => { + http.get("*/api/categories", async () => { const categories = getUniqueCategories(); await delay(); return HttpResponse.json(categories); diff --git a/packages/vanilla/src/mocks/server-browser.js b/packages/vanilla/src/mocks/server-browser.js new file mode 100644 index 00000000..d50e43cb --- /dev/null +++ b/packages/vanilla/src/mocks/server-browser.js @@ -0,0 +1,6 @@ +import { setupServer } from "msw/node"; +import { handlers } from "./handlers"; + +const server = setupServer(...handlers); + +export { server }; diff --git a/packages/vanilla/src/pages/HomePage.js b/packages/vanilla/src/pages/HomePage.js index ca08c26c..b96e7fec 100644 --- a/packages/vanilla/src/pages/HomePage.js +++ b/packages/vanilla/src/pages/HomePage.js @@ -1,7 +1,7 @@ import { ProductList, SearchBar } from "../components"; -import { productStore } from "../stores"; import { router, withLifecycle } from "../router"; import { loadProducts, loadProductsAndCategories } from "../services"; +import { productStore } from "../stores"; import { PageWrapper } from "./PageWrapper.js"; export const HomePage = withLifecycle( @@ -17,9 +17,15 @@ export const HomePage = withLifecycle( () => loadProducts(true), ], }, - () => { - const productState = productStore.getState(); - const { search: searchQuery, limit, sort, category1, category2 } = router.query; + (url, query, param) => { + const productState = typeof window !== "undefined" ? productStore.getState() : param; + const { + search: searchQuery, + limit, + sort, + category1, + category2, + } = typeof window !== "undefined" ? router.query : query; const { products, loading, error, totalCount, categories } = productState; const category = { category1, category2 }; const hasMore = products.length < totalCount; diff --git a/packages/vanilla/src/router/router.js b/packages/vanilla/src/router/router.js index d897ee76..d446996b 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -1,5 +1,5 @@ // 글로벌 라우터 인스턴스 -import { Router } from "../lib"; import { BASE_URL } from "../constants.js"; +import { Router, RouterSSR } from "../lib"; -export const router = new Router(BASE_URL); +export const router = typeof window !== "undefined" ? new Router(BASE_URL) : new RouterSSR(); diff --git a/packages/vanilla/src/services/productService.js b/packages/vanilla/src/services/productService.js index 8a12e8bd..159966fe 100644 --- a/packages/vanilla/src/services/productService.js +++ b/packages/vanilla/src/services/productService.js @@ -1,6 +1,6 @@ import { getCategories, getProduct, getProducts } from "../api/productApi"; -import { initialProductState, productStore, PRODUCT_ACTIONS } from "../stores"; import { router } from "../router"; +import { initialProductState, PRODUCT_ACTIONS, productStore } from "../stores"; export const loadProductsAndCategories = async () => { router.query = { current: undefined }; // 항상 첫 페이지로 초기화 @@ -26,6 +26,7 @@ export const loadProductsAndCategories = async () => { productStore.dispatch({ type: PRODUCT_ACTIONS.SETUP, payload: { + // 이 값을 서버에서 똑같이 생성해서 hompage 에 인자로 넣는다. products, categories, totalCount: total, diff --git a/packages/vanilla/src/storage/cartStorage.js b/packages/vanilla/src/storage/cartStorage.js index 7aa68383..388f3571 100644 --- a/packages/vanilla/src/storage/cartStorage.js +++ b/packages/vanilla/src/storage/cartStorage.js @@ -1,3 +1,4 @@ -import { createStorage } from "../lib"; +import { createSSRStorage, createStorage } from "../lib"; -export const cartStorage = createStorage("shopping_cart"); +export const cartStorage = + typeof window !== "undefined" ? createStorage("shopping_cart") : createSSRStorage("shopping_cart"); From 4d6d3a58f448e207839bbe79cd4258371a611797 Mon Sep 17 00:00:00 2001 From: jsheo Date: Tue, 2 Sep 2025 21:21:22 +0900 Subject: [PATCH 2/8] =?UTF-8?q?refactor:=20Express=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=ED=99=94=20=EB=B0=8F=20MSW=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 - 서버 코드를 기능별 모듈로 분리 (config, middleware, errorHandler, template, render) - MSW JSON import 및 모듈 경로 호환성 수정 - SSR 구조 개선 및 유지보수성 향상 --- packages/vanilla/server.js | 105 ++++++------------ packages/vanilla/server/config.js | 25 +++++ packages/vanilla/server/errorHandler.js | 41 +++++++ packages/vanilla/server/middleware.js | 66 +++++++++++ packages/vanilla/server/render.js | 111 +++++++++++++++++++ packages/vanilla/server/template.js | 78 +++++++++++++ packages/vanilla/src/mocks/handlers.js | 8 +- packages/vanilla/src/mocks/server-browser.js | 2 +- 8 files changed, 362 insertions(+), 74 deletions(-) create mode 100644 packages/vanilla/server/config.js create mode 100644 packages/vanilla/server/errorHandler.js create mode 100644 packages/vanilla/server/middleware.js create mode 100644 packages/vanilla/server/render.js create mode 100644 packages/vanilla/server/template.js diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index a3b11eb9..689fe6cf 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,82 +1,43 @@ import express from "express"; -import { getCategories, getProducts } from "./src/api/productApi.js"; +import { getConfig } from "./server/config.js"; +import { asyncHandler, errorHandler, notFoundHandler } from "./server/errorHandler.js"; +import { setupMiddleware } from "./server/middleware.js"; +import { render } from "./server/render.js"; +import { createHTMLTemplate } from "./server/template.js"; -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 config = getConfig(); +const { port, base } = config; const app = express(); -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/client", { extensions: [] })); -} +// 미들웨어 설정 +const vite = await setupMiddleware(app, config); -const { HomePage } = await vite.ssrLoadModule("./src/pages/HomePage.js"); -const { server } = await vite.ssrLoadModule("./src/mocks/server-browser.js"); -server.listen(); -// 뿌려줄 아이 -const render = async (url, query) => { - console.log({ url, query }); - const [ - { - products, - pagination: { total }, - }, - categories, - ] = await Promise.all([getProducts(query), getCategories()]); - return `${HomePage(url, query, { products, categories, totalCount: total, loading: false, status: "done" })}`; -}; +// 라우트 설정 +app.get( + "*all", + asyncHandler(async (req, res) => { + // SSR 렌더링 + const appHtml = await render(req.url, req.query, vite); -// 호출부 -// get 이라는 메소드로 호출된 애는 아래로 시작하겠다. -// req: 요청 객체 (url, query) -// res: 응답 객체 -app.get("*all", async (req, res) => { - res.send( - ` - - - - - - - - - - - -
${await render(req.url, req.query)}
- - - - `.trim(), - ); -}); + // HTML 템플릿 생성 + const html = createHTMLTemplate(appHtml); + + res.send(html); + }), +); + +// 404 에러 처리 +app.use(notFoundHandler); + +// 에러 처리 미들웨어 +app.use(errorHandler); -// 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}`); + console.log(`🌍 Environment: ${process.env.NODE_ENV || "development"}`); + console.log(`📍 Base URL: ${base}`); + console.log(`⏰ Started at: ${new Date().toISOString()}`); }); diff --git a/packages/vanilla/server/config.js b/packages/vanilla/server/config.js new file mode 100644 index 00000000..9682b617 --- /dev/null +++ b/packages/vanilla/server/config.js @@ -0,0 +1,25 @@ +/** + * 서버 설정 + */ +export const config = { + development: { + port: 5173, + base: "/", + enableVite: true, + enableMSW: true, + }, + production: { + port: process.env.PORT || 3000, + base: "/front_6th_chapter4-1/vanilla/", + enableVite: false, + enableMSW: false, + }, +}; + +/** + * 현재 환경 설정 가져오기 + */ +export const getConfig = () => { + const env = process.env.NODE_ENV || "development"; + return config[env]; +}; diff --git a/packages/vanilla/server/errorHandler.js b/packages/vanilla/server/errorHandler.js new file mode 100644 index 00000000..f6ba2d8c --- /dev/null +++ b/packages/vanilla/server/errorHandler.js @@ -0,0 +1,41 @@ +/** + * 에러 처리 미들웨어 + */ +export const errorHandler = (err, req, res, next) => { + console.error("Server Error:", err.stack); + + // 이미 응답이 전송된 경우 + if (res.headersSent) { + return next(err); + } + + // 에러 상태 코드 설정 + const status = err.status || err.statusCode || 500; + + // 프로덕션 환경에서는 상세 에러 정보 숨김 + const message = process.env.NODE_ENV === "production" ? "서버 오류가 발생했습니다." : err.message; + + res.status(status).json({ + error: message, + ...(process.env.NODE_ENV !== "production" && { stack: err.stack }), + }); +}; + +/** + * 404 에러 처리 미들웨어 + */ +export const notFoundHandler = (req, res) => { + res.status(404).json({ + error: "요청한 리소스를 찾을 수 없습니다.", + path: req.path, + }); +}; + +/** + * 비동기 에러 래퍼 + */ +export const asyncHandler = (fn) => { + return (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +}; diff --git a/packages/vanilla/server/middleware.js b/packages/vanilla/server/middleware.js new file mode 100644 index 00000000..b915c702 --- /dev/null +++ b/packages/vanilla/server/middleware.js @@ -0,0 +1,66 @@ +import express from "express"; + +/** + * Express 미들웨어 설정 + */ +export const setupMiddleware = async (app, config) => { + // 기본 미들웨어 + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + let vite; + + // 개발 환경에서만 Vite 미들웨어 사용 + if (config.enableVite) { + vite = await setupViteMiddleware(app, config); + } else { + await setupProductionMiddleware(app, config); + } + + // MSW 서버 설정 (개발 환경에서만) + if (config.enableMSW) { + await setupMSWServer(); + } + + return vite; +}; + +/** + * Vite 개발 미들웨어 설정 + */ +const setupViteMiddleware = async (app, config) => { + const { createServer } = await import("vite"); + const vite = await createServer({ + server: { middlewareMode: true }, + appType: "custom", + base: config.base, + }); + + app.use(vite.middlewares); + return vite; +}; + +/** + * 프로덕션 미들웨어 설정 + */ +const setupProductionMiddleware = async (app, config) => { + const compression = (await import("compression")).default; + const sirv = (await import("sirv")).default; + + app.use(compression()); + app.use(config.base, sirv("./dist/client", { extensions: [] })); +}; + +/** + * MSW 서버 설정 + */ +const setupMSWServer = async () => { + try { + const { server } = await import("../src/mocks/server-browser.js"); + server.listen(); + console.log("✅ MSW 서버가 시작되었습니다."); + } catch (error) { + console.warn("⚠️ MSW 서버 시작 실패:", error.message); + console.log("💡 MSW 없이 서버를 계속 실행합니다."); + } +}; diff --git a/packages/vanilla/server/render.js b/packages/vanilla/server/render.js new file mode 100644 index 00000000..e04f30ed --- /dev/null +++ b/packages/vanilla/server/render.js @@ -0,0 +1,111 @@ +import { getCategories, getProduct, getProducts } from "../src/api/productApi.js"; + +/** + * 서버 사이드 렌더링 함수 + */ +export const render = async (url, query, vite = null) => { + console.log("🚀 SSR Render 시작:", { url, query, timestamp: new Date().toISOString() }); + + try { + // URL에 따라 다른 데이터 로딩 + if (url.startsWith("/product/")) { + console.log("📦 상품 상세 페이지 렌더링"); + return await renderProductDetail(url, query, vite); + } else { + console.log("🏠 홈페이지 렌더링"); + return await renderHomePage(query, vite); + } + } catch (error) { + console.error("❌ SSR Render Error:", error); + return renderErrorPage(error); + } +}; + +/** + * 홈페이지 렌더링 + */ +const renderHomePage = async (query, vite = null) => { + console.log("📊 데이터 페칭 시작:", query); + + const [ + { + products, + pagination: { total }, + }, + categories, + ] = await Promise.all([getProducts(query), getCategories()]); + + console.log("✅ 데이터 페칭 완료:", { + productsCount: products.length, + totalCount: total, + categoriesCount: Object.keys(categories).length, + }); + + // SSR용 HomePage 컴포넌트 동적 import + let HomePage; + if (vite) { + console.log("🔧 Vite SSR 모듈 로딩"); + const module = await vite.ssrLoadModule("./src/pages/HomePage.js"); + HomePage = module.HomePage; + } else { + console.log("📦 일반 모듈 로딩"); + const module = await import("../src/pages/HomePage.js"); + HomePage = module.HomePage; + } + + console.log("🎨 컴포넌트 렌더링 시작"); + const html = HomePage("", query, { + products, + categories, + totalCount: total, + loading: false, + status: "done", + }); + + console.log("✅ SSR 렌더링 완료, HTML 길이:", html.length); + return html; +}; + +/** + * 상품 상세 페이지 렌더링 + */ +const renderProductDetail = async (url, query, vite = null) => { + const productId = url.split("/product/")[1]; + + if (!productId) { + throw new Error("상품 ID가 없습니다."); + } + + const [product, categories] = await Promise.all([getProduct(productId), getCategories()]); + + // SSR용 ProductDetailPage 컴포넌트 동적 import + let ProductDetailPage; + if (vite) { + const module = await vite.ssrLoadModule("./src/pages/ProductDetailPage.js"); + ProductDetailPage = module.ProductDetailPage; + } else { + const module = await import("../src/pages/ProductDetailPage.js"); + ProductDetailPage = module.ProductDetailPage; + } + + return ProductDetailPage(url, query, { + product, + categories, + loading: false, + status: "done", + }); +}; + +/** + * 에러 페이지 렌더링 + */ +const renderErrorPage = (error) => { + return ` +
+
+

오류가 발생했습니다

+

${error.message}

+
+
+ `; +}; diff --git a/packages/vanilla/server/template.js b/packages/vanilla/server/template.js new file mode 100644 index 00000000..83d0c2a5 --- /dev/null +++ b/packages/vanilla/server/template.js @@ -0,0 +1,78 @@ +/** + * HTML 템플릿 생성 및 치환 유틸리티 + */ + +/** + * 기본 HTML 템플릿 + */ +export const createHTMLTemplate = (appHtml, appHead = "", initialData = null) => { + const initialDataScript = initialData + ? `` + : ""; + + // SSR 확인을 위한 메타 정보 + const ssrMeta = ` + + + + + `; + + return ` + + + + + + 쇼핑몰 (SSR) + + ${ssrMeta} + ${appHead} + + + ${initialDataScript} + + + +
${appHtml}
+ + + + `.trim(); +}; + +/** + * 기존 HTML 템플릿에서 치환 + */ +export const replaceHTMLTemplate = (template, appHtml, appHead = "", initialData = null) => { + let result = template; + + // app-html 치환 + if (result.includes("")) { + result = result.replace("", appHtml); + } + + // app-head 치환 + if (result.includes("")) { + result = result.replace("", appHead); + } + + // 초기 데이터 주입 + if (initialData && !result.includes("window.__INITIAL_DATA__")) { + const initialDataScript = ``; + result = result.replace("", ` ${initialDataScript}\n`); + } + + return result; +}; diff --git a/packages/vanilla/src/mocks/handlers.js b/packages/vanilla/src/mocks/handlers.js index 8d3a1faa..5643ffe0 100644 --- a/packages/vanilla/src/mocks/handlers.js +++ b/packages/vanilla/src/mocks/handlers.js @@ -1,5 +1,11 @@ +import { readFileSync } from "fs"; import { http, HttpResponse } from "msw"; -import items from "./items.json"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const items = JSON.parse(readFileSync(join(__dirname, "items.json"), "utf8")); const delay = async () => await new Promise((resolve) => setTimeout(resolve, 200)); diff --git a/packages/vanilla/src/mocks/server-browser.js b/packages/vanilla/src/mocks/server-browser.js index d50e43cb..1099169f 100644 --- a/packages/vanilla/src/mocks/server-browser.js +++ b/packages/vanilla/src/mocks/server-browser.js @@ -1,5 +1,5 @@ import { setupServer } from "msw/node"; -import { handlers } from "./handlers"; +import { handlers } from "./handlers.js"; const server = setupServer(...handlers); From dbeda2677bcd669acfc438408ad7d5792e86c60a Mon Sep 17 00:00:00 2001 From: jsheo Date: Tue, 2 Sep 2025 21:48:32 +0900 Subject: [PATCH 3/8] =?UTF-8?q?test:=20=EC=83=81=EC=84=B8=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=A0=81=EC=9A=A9=20=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/server.js | 10 +- packages/vanilla/server/render.js | 240 +++++++++++++++--- packages/vanilla/server/stateManager.js | 130 ++++++++++ packages/vanilla/src/events.js | 31 ++- packages/vanilla/src/lib/RouterSSR.js | 108 ++++++++ packages/vanilla/src/main.js | 14 +- packages/vanilla/src/mocks/handlers.js | 8 +- .../vanilla/src/services/productService.js | 4 +- packages/vanilla/src/stores/index.js | 72 +++++- packages/vanilla/src/utils/eventUtils.js | 21 +- 10 files changed, 572 insertions(+), 66 deletions(-) create mode 100644 packages/vanilla/server/stateManager.js create mode 100644 packages/vanilla/src/lib/RouterSSR.js diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index 689fe6cf..b292f180 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -2,7 +2,7 @@ import express from "express"; import { getConfig } from "./server/config.js"; import { asyncHandler, errorHandler, notFoundHandler } from "./server/errorHandler.js"; import { setupMiddleware } from "./server/middleware.js"; -import { render } from "./server/render.js"; +import { renderWithInitialData } from "./server/render.js"; import { createHTMLTemplate } from "./server/template.js"; // 설정 가져오기 @@ -18,11 +18,11 @@ const vite = await setupMiddleware(app, config); app.get( "*all", asyncHandler(async (req, res) => { - // SSR 렌더링 - const appHtml = await render(req.url, req.query, vite); + // SSR 렌더링 (초기 데이터 포함) + const { appHtml, initialData } = await renderWithInitialData(req.url, req.query, vite); - // HTML 템플릿 생성 - const html = createHTMLTemplate(appHtml); + // HTML 템플릿 생성 (초기 데이터 주입) + const html = createHTMLTemplate(appHtml, "", initialData); res.send(html); }), diff --git a/packages/vanilla/server/render.js b/packages/vanilla/server/render.js index e04f30ed..bf6eebfc 100644 --- a/packages/vanilla/server/render.js +++ b/packages/vanilla/server/render.js @@ -1,4 +1,4 @@ -import { getCategories, getProduct, getProducts } from "../src/api/productApi.js"; +import { serverStateManager } from "./stateManager.js"; /** * 서버 사이드 렌더링 함수 @@ -7,93 +7,249 @@ export const render = async (url, query, vite = null) => { console.log("🚀 SSR Render 시작:", { url, query, timestamp: new Date().toISOString() }); try { - // URL에 따라 다른 데이터 로딩 - if (url.startsWith("/product/")) { + // URL 파싱 및 라우트 매칭 + const route = matchRoute(url); + console.log("🎯 매칭된 라우트:", route); + + let result; + if (route.type === "product-detail") { console.log("📦 상품 상세 페이지 렌더링"); - return await renderProductDetail(url, query, vite); - } else { + result = await renderProductDetail(route.params.id, query, vite); + } else if (route.type === "home") { console.log("🏠 홈페이지 렌더링"); - return await renderHomePage(query, vite); + result = await renderHomePage(query, vite); + } else { + console.log("❓ 알 수 없는 라우트, 404 페이지 렌더링"); + result = await renderNotFoundPage(vite); } + + return result; } catch (error) { console.error("❌ SSR Render Error:", error); return renderErrorPage(error); } }; +/** + * 서버 사이드 렌더링 함수 (초기 데이터 포함) + */ +export const renderWithInitialData = async (url, query, vite = null) => { + console.log("🚀 SSR Render with Initial Data 시작:", { url, query, timestamp: new Date().toISOString() }); + + try { + // URL 파싱 및 라우트 매칭 + const route = matchRoute(url); + console.log("🎯 매칭된 라우트:", route); + + let appHtml, initialData; + + if (route.type === "product-detail") { + console.log("📦 상품 상세 페이지 렌더링 (초기 데이터 포함)"); + const result = await renderProductDetailWithData(route.params.id, query, vite); + appHtml = result.html; + initialData = result.initialData; + } else if (route.type === "home") { + console.log("🏠 홈페이지 렌더링 (초기 데이터 포함)"); + const result = await renderHomePageWithData(query, vite); + appHtml = result.html; + initialData = result.initialData; + } else { + console.log("❓ 알 수 없는 라우트, 404 페이지 렌더링"); + appHtml = await renderNotFoundPage(vite); + initialData = null; + } + + return { appHtml, initialData }; + } catch (error) { + console.error("❌ SSR Render with Initial Data Error:", error); + return { + appHtml: renderErrorPage(error), + initialData: null, + }; + } +}; + +/** + * URL을 기반으로 라우트 매칭 + */ +const matchRoute = (url) => { + // 상품 상세 페이지 패턴: /product/:id/ + const productDetailMatch = url.match(/^\/product\/([^\/]+)\/?$/); + if (productDetailMatch) { + return { + type: "product-detail", + params: { id: productDetailMatch[1] }, + }; + } + + // 홈페이지 패턴: / 또는 /?query=... + if (url === "/" || url.startsWith("/?")) { + return { + type: "home", + params: {}, + }; + } + + // 매칭되지 않는 경우 + return { + type: "not-found", + params: {}, + }; +}; + /** * 홈페이지 렌더링 */ const renderHomePage = async (query, vite = null) => { - console.log("📊 데이터 페칭 시작:", query); + // 서버 상태 관리자를 통해 상태 초기화 + const state = await serverStateManager.initializeHomeState(query); - const [ - { - products, - pagination: { total }, - }, - categories, - ] = await Promise.all([getProducts(query), getCategories()]); + // SSR용 HomePage 컴포넌트 동적 import + let HomePage; + if (vite) { + console.log("🔧 Vite SSR 모듈 로딩 (HomePage)"); + const module = await vite.ssrLoadModule("./src/pages/HomePage.js"); + HomePage = module.HomePage; + } else { + console.log("📦 일반 모듈 로딩 (HomePage)"); + const module = await import("../src/pages/HomePage.js"); + HomePage = module.HomePage; + } - console.log("✅ 데이터 페칭 완료:", { - productsCount: products.length, - totalCount: total, - categoriesCount: Object.keys(categories).length, - }); + console.log("🎨 홈페이지 컴포넌트 렌더링 시작"); + const html = HomePage("", query, state); + + console.log("✅ 홈페이지 SSR 렌더링 완료, HTML 길이:", html.length); + return html; +}; + +/** + * 홈페이지 렌더링 (초기 데이터 포함) + */ +const renderHomePageWithData = async (query, vite = null) => { + // 서버 상태 관리자를 통해 상태 초기화 + const state = await serverStateManager.initializeHomeState(query); // SSR용 HomePage 컴포넌트 동적 import let HomePage; if (vite) { - console.log("🔧 Vite SSR 모듈 로딩"); + console.log("🔧 Vite SSR 모듈 로딩 (HomePage)"); const module = await vite.ssrLoadModule("./src/pages/HomePage.js"); HomePage = module.HomePage; } else { - console.log("📦 일반 모듈 로딩"); + console.log("📦 일반 모듈 로딩 (HomePage)"); const module = await import("../src/pages/HomePage.js"); HomePage = module.HomePage; } - console.log("🎨 컴포넌트 렌더링 시작"); - const html = HomePage("", query, { - products, - categories, - totalCount: total, - loading: false, - status: "done", - }); + console.log("🎨 홈페이지 컴포넌트 렌더링 시작 (초기 데이터 포함)"); + const html = HomePage("", query, state); - console.log("✅ SSR 렌더링 완료, HTML 길이:", html.length); - return html; + console.log("✅ 홈페이지 SSR 렌더링 완료 (초기 데이터 포함), HTML 길이:", html.length); + + return { + html, + initialData: { + type: "home", + state, + query, + timestamp: new Date().toISOString(), + }, + }; }; /** * 상품 상세 페이지 렌더링 */ -const renderProductDetail = async (url, query, vite = null) => { - const productId = url.split("/product/")[1]; +const renderProductDetail = async (productId, query, vite = null) => { + // 서버 상태 관리자를 통해 상태 초기화 + const state = await serverStateManager.initializeProductDetailState(productId); - if (!productId) { - throw new Error("상품 ID가 없습니다."); + // SSR용 ProductDetailPage 컴포넌트 동적 import + let ProductDetailPage; + if (vite) { + console.log("🔧 Vite SSR 모듈 로딩 (ProductDetailPage)"); + const module = await vite.ssrLoadModule("./src/pages/ProductDetailPage.js"); + ProductDetailPage = module.ProductDetailPage; + } else { + console.log("📦 일반 모듈 로딩 (ProductDetailPage)"); + const module = await import("../src/pages/ProductDetailPage.js"); + ProductDetailPage = module.ProductDetailPage; } - const [product, categories] = await Promise.all([getProduct(productId), getCategories()]); + console.log("🎨 상품 상세 컴포넌트 렌더링 시작"); + const html = ProductDetailPage(`/product/${productId}/`, query, state); + + console.log("✅ 상품 상세 SSR 렌더링 완료, HTML 길이:", html.length); + return html; +}; + +/** + * 상품 상세 페이지 렌더링 (초기 데이터 포함) + */ +const renderProductDetailWithData = async (productId, query, vite = null) => { + // 서버 상태 관리자를 통해 상태 초기화 + const state = await serverStateManager.initializeProductDetailState(productId); // SSR용 ProductDetailPage 컴포넌트 동적 import let ProductDetailPage; if (vite) { + console.log("🔧 Vite SSR 모듈 로딩 (ProductDetailPage)"); const module = await vite.ssrLoadModule("./src/pages/ProductDetailPage.js"); ProductDetailPage = module.ProductDetailPage; } else { + console.log("📦 일반 모듈 로딩 (ProductDetailPage)"); const module = await import("../src/pages/ProductDetailPage.js"); ProductDetailPage = module.ProductDetailPage; } - return ProductDetailPage(url, query, { - product, - categories, - loading: false, - status: "done", - }); + console.log("🎨 상품 상세 컴포넌트 렌더링 시작 (초기 데이터 포함)"); + const html = ProductDetailPage(`/product/${productId}/`, query, state); + + console.log("✅ 상품 상세 SSR 렌더링 완료 (초기 데이터 포함), HTML 길이:", html.length); + + return { + html, + initialData: { + type: "product-detail", + state, + query, + productId, + timestamp: new Date().toISOString(), + }, + }; +}; + +/** + * 404 페이지 렌더링 + */ +const renderNotFoundPage = async (vite = null) => { + console.log("❓ 404 페이지 렌더링"); + + // SSR용 NotFoundPage 컴포넌트 동적 import + let NotFoundPage; + if (vite) { + console.log("🔧 Vite SSR 모듈 로딩 (NotFoundPage)"); + const module = await vite.ssrLoadModule("./src/pages/NotFoundPage.js"); + NotFoundPage = module.NotFoundPage; + } else { + console.log("📦 일반 모듈 로딩 (NotFoundPage)"); + const module = await import("../src/pages/NotFoundPage.js"); + NotFoundPage = module.NotFoundPage; + } + + console.log("🎨 404 컴포넌트 렌더링 시작"); + const html = NotFoundPage( + "", + {}, + { + loading: false, + status: "done", + }, + ); + + console.log("✅ 404 SSR 렌더링 완료, HTML 길이:", html.length); + return html; }; /** diff --git a/packages/vanilla/server/stateManager.js b/packages/vanilla/server/stateManager.js new file mode 100644 index 00000000..ff374ff9 --- /dev/null +++ b/packages/vanilla/server/stateManager.js @@ -0,0 +1,130 @@ +import { getCategories, getProduct, getProducts } from "../src/api/productApi.js"; + +/** + * 서버 상태 관리자 + */ +export class ServerStateManager { + constructor() { + this.cache = new Map(); + this.cacheTimeout = 5 * 60 * 1000; // 5분 캐시 + } + + /** + * 홈페이지 상태 초기화 + */ + async initializeHomeState(query = {}) { + const cacheKey = `home-${JSON.stringify(query)}`; + + // 캐시 확인 + if (this.cache.has(cacheKey)) { + const cached = this.cache.get(cacheKey); + if (Date.now() - cached.timestamp < this.cacheTimeout) { + console.log("📋 홈페이지 상태 캐시 사용"); + return cached.data; + } + } + + console.log("🔄 홈페이지 상태 초기화 시작"); + + try { + const [productsData, categories] = await Promise.all([getProducts(query), getCategories()]); + + const state = { + products: productsData.products, + categories, + pagination: productsData.pagination, + totalCount: productsData.pagination.total, + loading: false, + status: "done", + query, + }; + + // 캐시 저장 + this.cache.set(cacheKey, { + data: state, + timestamp: Date.now(), + }); + + console.log("✅ 홈페이지 상태 초기화 완료:", { + productsCount: state.products.length, + totalCount: state.totalCount, + categoriesCount: Object.keys(state.categories).length, + }); + + return state; + } catch (error) { + console.error("❌ 홈페이지 상태 초기화 실패:", error); + throw error; + } + } + + /** + * 상품 상세 상태 초기화 + */ + async initializeProductDetailState(productId) { + const cacheKey = `product-${productId}`; + + // 캐시 확인 + if (this.cache.has(cacheKey)) { + const cached = this.cache.get(cacheKey); + if (Date.now() - cached.timestamp < this.cacheTimeout) { + console.log("📋 상품 상세 상태 캐시 사용"); + return cached.data; + } + } + + console.log("🔄 상품 상세 상태 초기화 시작:", productId); + + try { + const [product, categories] = await Promise.all([getProduct(productId), getCategories()]); + + const state = { + product, + categories, + loading: false, + status: "done", + productId, + }; + + // 캐시 저장 + this.cache.set(cacheKey, { + data: state, + timestamp: Date.now(), + }); + + console.log("✅ 상품 상세 상태 초기화 완료:", { + productId, + productName: product?.name, + categoriesCount: Object.keys(categories).length, + }); + + return state; + } catch (error) { + console.error("❌ 상품 상세 상태 초기화 실패:", error); + throw error; + } + } + + /** + * 캐시 클리어 + */ + clearCache() { + this.cache.clear(); + console.log("🗑️ 서버 상태 캐시 클리어"); + } + + /** + * 만료된 캐시 정리 + */ + cleanupExpiredCache() { + const now = Date.now(); + for (const [key, value] of this.cache.entries()) { + if (now - value.timestamp >= this.cacheTimeout) { + this.cache.delete(key); + } + } + } +} + +// 전역 상태 관리자 인스턴스 +export const serverStateManager = new ServerStateManager(); diff --git a/packages/vanilla/src/events.js b/packages/vanilla/src/events.js index 4d66284f..1b1af296 100644 --- a/packages/vanilla/src/events.js +++ b/packages/vanilla/src/events.js @@ -1,4 +1,3 @@ -import { addEvent, isNearBottom } from "./utils"; import { router } from "./router"; import { addToCart, @@ -16,7 +15,8 @@ import { toggleCartSelect, updateCartQuantity, } from "./services"; -import { productStore, uiStore, UI_ACTIONS } from "./stores"; +import { productStore, UI_ACTIONS, uiStore } from "./stores"; +import { addEvent, isNearBottom } from "./utils"; /** * 상품 관련 이벤트 등록 @@ -138,14 +138,33 @@ export function registerProductEvents() { * 상품 상세 페이지 관련 이벤트 등록 */ export function registerProductDetailEvents() { - // 상품 클릭 시 상품 상세 페이지로 이동 (이미지 또는 제목) - addEvent("click", ".product-image, .product-info", async (e) => { + // 상품 클릭 시 상품 상세 페이지로 이동 (전체 카드) + addEvent("click", ".product-card", async (e) => { + console.log("🖱️ 상품 카드 클릭 이벤트 발생:", e.target); + + // 장바구니 버튼 클릭은 제외 + if (e.target.classList.contains("add-to-cart-btn") || e.target.closest(".add-to-cart-btn")) { + console.log("🛒 장바구니 버튼 클릭으로 인한 이벤트 무시"); + return; + } + const productCard = e.target.closest(".product-card"); - if (!productCard) return; + console.log("🔍 찾은 상품 카드:", productCard); + + if (!productCard) { + console.log("❌ 상품 카드를 찾을 수 없음"); + return; + } const productId = productCard.getAttribute("data-product-id"); - if (!productId) return; + console.log("🆔 상품 ID:", productId); + + if (!productId) { + console.log("❌ 상품 ID를 찾을 수 없음"); + return; + } + console.log("🚀 상품 상세 페이지로 이동:", `/product/${productId}/`); // 상품 상세 페이지로 이동 router.push(`/product/${productId}/`); }); diff --git a/packages/vanilla/src/lib/RouterSSR.js b/packages/vanilla/src/lib/RouterSSR.js new file mode 100644 index 00000000..a86730b4 --- /dev/null +++ b/packages/vanilla/src/lib/RouterSSR.js @@ -0,0 +1,108 @@ +/** + * 서버 사이드 렌더링용 라우터 + */ +export class RouterSSR { + #routes; + #baseUrl; + + constructor(baseUrl = "") { + this.#routes = new Map(); + this.#baseUrl = baseUrl.replace(/\/$/, ""); + } + + get baseUrl() { + return this.#baseUrl; + } + + get query() { + return {}; + } + + set query(newQuery) { + // SSR에서는 쿼리 변경 불가 + } + + get params() { + return {}; + } + + get route() { + return null; + } + + get target() { + return null; + } + + subscribe(fn) { + // SSR에서는 구독 불가 + } + + /** + * 라우트 등록 (SSR에서는 실제로 사용되지 않음) + * @param {string} path - 경로 패턴 + * @param {Function} handler - 라우트 핸들러 + */ + addRoute(path, handler) { + // SSR에서는 라우트 등록만 하고 실제 매칭은 서버에서 처리 + this.#routes.set(path, handler); + } + + /** + * 네비게이션 (SSR에서는 사용되지 않음) + * @param {string} url - 이동할 경로 + */ + push(url) { + // SSR에서는 네비게이션 불가 + } + + /** + * 라우터 시작 (SSR에서는 사용되지 않음) + */ + start() { + // SSR에서는 시작 불가 + } + + /** + * 쿼리 파라미터를 객체로 파싱 + * @param {string} search - 쿼리 문자열 + * @returns {Object} 파싱된 쿼리 객체 + */ + static parseQuery = (search = "") => { + const params = new URLSearchParams(search); + const query = {}; + for (const [key, value] of params) { + query[key] = value; + } + return query; + }; + + /** + * 객체를 쿼리 문자열로 변환 + * @param {Object} query - 쿼리 객체 + * @returns {string} 쿼리 문자열 + */ + static stringifyQuery = (query) => { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== null && value !== undefined && value !== "") { + params.set(key, String(value)); + } + } + return params.toString(); + }; + + static getUrl = (newQuery, baseUrl = "", pathname = "/") => { + const updatedQuery = { ...newQuery }; + + // 빈 값들 제거 + Object.keys(updatedQuery).forEach((key) => { + if (updatedQuery[key] === null || updatedQuery[key] === undefined || updatedQuery[key] === "") { + delete updatedQuery[key]; + } + }); + + const queryString = RouterSSR.stringifyQuery(updatedQuery); + return `${baseUrl}${pathname}${queryString ? `?${queryString}` : ""}`; + }; +} diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js index 3b86db68..8d1317c1 100644 --- a/packages/vanilla/src/main.js +++ b/packages/vanilla/src/main.js @@ -3,7 +3,8 @@ import { registerAllEvents } from "./events"; import { initRender } from "./render"; import { router } from "./router"; import { loadCartFromStorage } from "./services"; -import { registerGlobalEvents } from "./utils"; +import { initializeFromSSR } from "./stores"; +import { getRegisteredEvents, registerGlobalEvents } from "./utils"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => @@ -16,8 +17,19 @@ const enableMocking = () => ); function main() { + // SSR 초기 데이터가 있으면 상태 초기화 + if (window.__INITIAL_DATA__) { + console.log("🔄 SSR 초기 데이터로 상태 초기화:", window.__INITIAL_DATA__); + initializeFromSSR(window.__INITIAL_DATA__); + } + + // 이벤트 등록 순서 중요: 먼저 이벤트 핸들러들을 등록하고, 그 다음에 전역 이벤트 리스너를 등록 registerAllEvents(); registerGlobalEvents(); + + // 이벤트 등록 상태 확인 (디버깅용) + console.log("📋 등록된 이벤트 핸들러:", getRegisteredEvents()); + loadCartFromStorage(); initRender(); router.start(); diff --git a/packages/vanilla/src/mocks/handlers.js b/packages/vanilla/src/mocks/handlers.js index 5643ffe0..8d3a1faa 100644 --- a/packages/vanilla/src/mocks/handlers.js +++ b/packages/vanilla/src/mocks/handlers.js @@ -1,11 +1,5 @@ -import { readFileSync } from "fs"; import { http, HttpResponse } from "msw"; -import { dirname, join } from "path"; -import { fileURLToPath } from "url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const items = JSON.parse(readFileSync(join(__dirname, "items.json"), "utf8")); +import items from "./items.json"; const delay = async () => await new Promise((resolve) => setTimeout(resolve, 200)); diff --git a/packages/vanilla/src/services/productService.js b/packages/vanilla/src/services/productService.js index 159966fe..8a53d585 100644 --- a/packages/vanilla/src/services/productService.js +++ b/packages/vanilla/src/services/productService.js @@ -124,7 +124,7 @@ export const loadProductDetailForPage = async (productId) => { const currentProduct = productStore.getState().currentProduct; if (productId === currentProduct?.productId) { // 관련 상품 로드 (같은 category2 기준) - if (currentProduct.category2) { + if (currentProduct && currentProduct.category2) { await loadRelatedProducts(currentProduct.category2, productId); } return; @@ -149,7 +149,7 @@ export const loadProductDetailForPage = async (productId) => { }); // 관련 상품 로드 (같은 category2 기준) - if (product.category2) { + if (product && product.category2) { await loadRelatedProducts(product.category2, productId); } } catch (error) { diff --git a/packages/vanilla/src/stores/index.js b/packages/vanilla/src/stores/index.js index 36fefd54..38f879e3 100644 --- a/packages/vanilla/src/stores/index.js +++ b/packages/vanilla/src/stores/index.js @@ -1,4 +1,74 @@ export * from "./actionTypes"; -export * from "./productStore"; export * from "./cartStore"; +export * from "./productStore"; export * from "./uiStore"; + +/** + * SSR 초기 데이터로 상태 초기화 + */ +export const initializeFromSSR = (initialData) => { + console.log("🔄 SSR 초기 데이터로 상태 초기화 시작:", initialData); + + if (!initialData || !initialData.state) { + console.log("⚠️ 유효하지 않은 초기 데이터"); + return; + } + + const { type, state } = initialData; + + if (type === "home") { + console.log("🏠 홈페이지 상태 초기화"); + // productStore와 uiStore 상태 초기화 + if (state.products) { + productStore.dispatch({ + type: "SET_PRODUCTS", + payload: state.products, + }); + } + if (state.categories) { + productStore.dispatch({ + type: "SET_CATEGORIES", + payload: state.categories, + }); + } + if (state.totalCount !== undefined) { + productStore.dispatch({ + type: "SET_TOTAL_COUNT", + payload: state.totalCount, + }); + } + if (state.query) { + productStore.dispatch({ + type: "SET_QUERY", + payload: state.query, + }); + } + } else if (type === "product-detail") { + console.log("📦 상품 상세 상태 초기화"); + if (state.product) { + productStore.dispatch({ + type: "SET_PRODUCT", + payload: state.product, + }); + } + if (state.categories) { + productStore.dispatch({ + type: "SET_CATEGORIES", + payload: state.categories, + }); + } + } + + // UI 상태 초기화 + uiStore.dispatch({ + type: "SET_LOADING", + payload: false, + }); + + uiStore.dispatch({ + type: "SET_STATUS", + payload: "done", + }); + + console.log("✅ SSR 초기 데이터로 상태 초기화 완료"); +}; diff --git a/packages/vanilla/src/utils/eventUtils.js b/packages/vanilla/src/utils/eventUtils.js index d6031d41..17a1e9b4 100644 --- a/packages/vanilla/src/utils/eventUtils.js +++ b/packages/vanilla/src/utils/eventUtils.js @@ -9,14 +9,21 @@ const eventHandlers = {}; */ const handleGlobalEvents = (e) => { const handlers = eventHandlers[e.type]; - if (!handlers) return; + if (!handlers) { + console.log(`🔍 이벤트 핸들러 없음: ${e.type}`, e.target); + return; + } + + console.log(`🎯 이벤트 발생: ${e.type}`, e.target, "등록된 핸들러:", Object.keys(handlers)); // 각 선택자에 대해 확인 for (const [selector, handler] of Object.entries(handlers)) { const targetElement = e.target.closest(selector); + console.log(`🔍 선택자 매칭 시도: ${selector}`, targetElement); // 일치하는 요소가 있으면 핸들러 실행 if (targetElement) { + console.log(`✅ 핸들러 실행: ${selector}`); try { handler(e); } catch (error) { @@ -36,7 +43,9 @@ export const registerGlobalEvents = (() => { return; } - Object.keys(eventHandlers).forEach((eventType) => { + // 모든 이벤트 타입에 대해 전역 이벤트 리스너 등록 + const eventTypes = ["click", "change", "keydown", "keyup", "submit", "scroll"]; + eventTypes.forEach((eventType) => { document.body.addEventListener(eventType, handleGlobalEvents); }); @@ -56,4 +65,12 @@ export const addEvent = (eventType, selector, handler) => { } eventHandlers[eventType][selector] = handler; + console.log(`🎯 이벤트 등록: ${eventType} -> ${selector}`); +}; + +/** + * 등록된 이벤트 핸들러 확인 (디버깅용) + */ +export const getRegisteredEvents = () => { + return eventHandlers; }; From acc98ca41e838def0a78259e4c0b474940030404 Mon Sep 17 00:00:00 2001 From: jsheo Date: Wed, 3 Sep 2025 02:46:32 +0900 Subject: [PATCH 4/8] =?UTF-8?q?docs:=20rules,=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/ssr-ssg-rules.mdc | 45 +++ docs/README.md | 532 ++++++++++++++++++++++++++++++ docs/architecture.md | 550 ++++++++++++++++++++++++++++++++ docs/components-summary.md | 89 ++++++ 4 files changed, 1216 insertions(+) create mode 100644 .cursor/rules/ssr-ssg-rules.mdc create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/components-summary.md diff --git a/.cursor/rules/ssr-ssg-rules.mdc b/.cursor/rules/ssr-ssg-rules.mdc new file mode 100644 index 00000000..d37372cf --- /dev/null +++ b/.cursor/rules/ssr-ssg-rules.mdc @@ -0,0 +1,45 @@ +--- +description: SSR, SSG 행동 강령 +globs: +alwaysApply: true +--- +version: 1 +rules: + # Express Server (server.js) + - require: Implement an Express middleware-based server + hint: "Use middleware pattern, template rendering, hydration entry support" + - require: Handle environment splitting (development vs production) + hint: "Add dev/prod mode checks and configuration" + - require: Inject HTML templates (``, ``) + hint: "Replace placeholders in base HTML template" + + # Server Rendering (main-server.js) + - require: Implement Router that works on the server + hint: "Server-side routing logic should match client routes" + - require: Prefetch server data (product list, product detail) using route params + hint: "Handle async data fetching before rendering" + - require: Initialize and manage server-side store + hint: "Populate store/state with prefetched data for rendering" + + # Static Site Generation (static-site-generate.js) + - require: Generate static pages at build-time + hint: "Run generation script during build process" + - require: Support dynamic routes (e.g., product detail pages) + hint: "Iterate over data to pre-generate dynamic routes" + - require: Write generated pages into the file system for deployment + hint: "Save HTML files under output directory" + + # Client Hydration (main.js) + - require: Inject `window.__INITIAL_DATA__` script + hint: "Serialize server state into HTML for client hydration" + - require: Restore client state using initial server data + hint: "Sync client store with server-injected initial data" + - require: Sync server and client state for consistency + hint: "Ensure no mismatch between rendered HTML and hydrated app" + - require: Load client-side app entry + hint: "Bootstrap SPA after SSR render" + + # Testing (global) + - require: Ensure all implementations pass e2e test with `pnpm run test:e2e:basic` + hint: "Run automated tests to validate SSR, hydration, and SSG behavior" + diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..c1626484 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,532 @@ +# Vanilla JavaScript SSR & SSG 구현 문서 + +## 📋 목차 + +- [프로젝트 개요](#프로젝트-개요) +- [SSR 구현](#ssr-구현) +- [SSG 구현](#ssg-구현) +- [아키텍처](#아키텍처) +- [핵심 라이브러리](#핵심-라이브러리) +- [컴포넌트 시스템](#컴포넌트-시스템) +- [상태 관리](#상태-관리) +- [라우팅 시스템](#라우팅-시스템) +- [이벤트 시스템](#이벤트-시스템) +- [빌드 및 배포](#빌드-및-배포) + +## 🎯 프로젝트 개요 + +이 프로젝트는 **순수 Vanilla JavaScript**로 구현된 쇼핑몰 애플리케이션으로, **SSR(Server-Side Rendering)**과 **SSG(Static Site Generation)**을 모두 지원합니다. + +### ✨ 주요 특징 + +- ✅ **프레임워크 없는** 순수 JavaScript 구현 +- ✅ **SSR & SSG** 동시 지원 +- ✅ **SPA 라우팅** 시스템 +- ✅ **Redux 패턴** 상태 관리 +- ✅ **컴포넌트 기반** 아키텍처 +- ✅ **이벤트 위임** 시스템 +- ✅ **무한 스크롤** 구현 +- ✅ **로컬 스토리지** 동기화 + +## 🖥️ SSR 구현 + +### 1. 서버 구성 (`server.js`) + +```javascript +import express from "express"; + +const app = express(); +const port = process.env.PORT || 5173; + +const render = () => { + return `
안녕하세요
`; +}; + +app.get("*all", (req, res) => { + res.send(` + + + + + + Vanilla Javascript SSR + + +
${render()}
+ + + `.trim()); +}); + +app.listen(port, () => { + console.log(`Server started at http://localhost:${port}`); +}); +``` + +### 2. 서버 렌더링 엔트리 (`main-server.js`) + +```javascript +export const render = async (url, query) => { + console.log({ url, query }); + return ""; +}; +``` + +### 3. SSR 특징 + +- **Express 서버**를 사용한 서버 사이드 렌더링 +- **모든 경로**(`*all`)에 대한 요청 처리 +- **HTML 템플릿**에 렌더링된 컨텐츠 삽입 +- **서버에서 완성된 HTML** 전송 + +## 📄 SSG 구현 + +### 1. 정적 사이트 생성기 (`static-site-generate.js`) + +```javascript +import fs from "fs"; + +const render = () => { + return `
안녕하세요
`; +}; + +async function generateStaticSite() { + // HTML 템플릿 읽기 + const template = fs.readFileSync("../../dist/vanilla/index.html", "utf-8"); + + // 어플리케이션 렌더링하기 + const appHtml = render(); + + // 결과 HTML 생성하기 + const result = template.replace("", appHtml); + fs.writeFileSync("../../dist/vanilla/index.html", result); +} + +// 실행 +generateStaticSite(); +``` + +### 2. SSG 특징 + +- **빌드 시점**에 정적 HTML 파일 생성 +- **템플릿 플레이스홀더** (``) 교체 +- **파일 시스템**에 직접 HTML 파일 저장 +- **CDN 배포** 최적화 + +## 🏗️ 아키텍처 + +### 전체 구조 + +``` +src/ +├── main.js # 클라이언트 엔트리 포인트 +├── main-server.js # 서버 엔트리 포인트 +├── render.js # 렌더링 시스템 +├── events.js # 이벤트 등록 +├── constants.js # 상수 정의 +│ +├── api/ # API 통신 +│ └── productApi.js +│ +├── components/ # UI 컴포넌트 +│ ├── ProductCard.js +│ ├── ProductList.js +│ ├── SearchBar.js +│ ├── CartModal.js +│ ├── Toast.js +│ └── ... +│ +├── pages/ # 페이지 컴포넌트 +│ ├── HomePage.js +│ ├── ProductDetailPage.js +│ ├── NotFoundPage.js +│ └── PageWrapper.js +│ +├── stores/ # 상태 관리 +│ ├── productStore.js +│ ├── cartStore.js +│ ├── uiStore.js +│ └── actionTypes.js +│ +├── services/ # 비즈니스 로직 +│ ├── productService.js +│ └── cartService.js +│ +├── router/ # 라우팅 시스템 +│ ├── router.js +│ └── withLifecycle.js +│ +├── lib/ # 핵심 라이브러리 +│ ├── Router.js +│ ├── createStore.js +│ ├── createObserver.js +│ └── createStorage.js +│ +├── utils/ # 유틸리티 +│ ├── eventUtils.js +│ ├── domUtils.js +│ └── withBatch.js +│ +├── storage/ # 로컬 스토리지 +│ └── cartStorage.js +│ +└── mocks/ # 목업 데이터 + ├── browser.js + ├── handlers.js + └── items.json +``` + +## 🔧 핵심 라이브러리 + +### 1. 옵저버 패턴 (`createObserver.js`) + +```javascript +export const createObserver = () => { + const listeners = new Set(); + const subscribe = (fn) => listeners.add(fn); + const notify = () => listeners.forEach((listener) => listener()); + + return { subscribe, notify }; +}; +``` + +### 2. 스토어 시스템 (`createStore.js`) + +```javascript +export const createStore = (reducer, initialState) => { + const { subscribe, notify } = createObserver(); + let state = initialState; + + const getState = () => state; + + const dispatch = (action) => { + const newState = reducer(state, action); + if (newState !== state) { + state = newState; + notify(); + } + }; + + return { getState, dispatch, subscribe }; +}; +``` + +### 3. 라우터 시스템 (`Router.js`) + +```javascript +export class Router { + #routes = new Map(); + #route = null; + #observer = createObserver(); + + 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 }); + } + + push(url) { + window.history.pushState(null, "", url); + this.#route = this.#findRoute(url); + this.#observer.notify(); + } +} +``` + +### 4. 스토리지 시스템 (`createStorage.js`) + +```javascript +export const createStorage = (key, storage = window.localStorage) => { + const get = () => { + try { + const item = storage.getItem(key); + return item ? JSON.parse(item) : null; + } catch (error) { + console.error(`Error parsing storage item for key "${key}":`, error); + return null; + } + }; + + const set = (value) => { + try { + storage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.error(`Error setting storage item for key "${key}":`, error); + } + }; + + return { get, set, reset: () => storage.removeItem(key) }; +}; +``` + +## 🧩 컴포넌트 시스템 + +### 1. 함수형 컴포넌트 + +```javascript +// ProductCard 컴포넌트 예시 +export function ProductCard(product) { + const { productId, title, image, lprice, brand } = product; + const price = Number(lprice); + + return ` +
+ ${title} +

${title}

+

${brand}

+

${price.toLocaleString()}원

+ +
+ `; +} +``` + +### 2. 페이지 래퍼 패턴 + +```javascript +export const PageWrapper = ({ headerLeft, children }) => { + const cart = cartStore.getState(); + const { cartModal, toast } = uiStore.getState(); + + return ` +
+
${headerLeft}
+
${children}
+ ${CartModal({ ...cart, isOpen: cartModal.isOpen })} + ${Toast(toast)} +
+ `; +}; +``` + +### 3. 라이프사이클 관리 + +```javascript +export const HomePage = withLifecycle( + { + onMount: () => { + loadProductsAndCategories(); + }, + watches: [ + () => { + const { search, limit, sort, category1, category2 } = router.query; + return [search, limit, sort, category1, category2]; + }, + () => loadProducts(true), + ], + }, + () => { + // 컴포넌트 렌더링 로직 + return PageWrapper({ headerLeft, children }); + } +); +``` + +## 📊 상태 관리 + +### 1. Redux 패턴 구현 + +```javascript +// 액션 타입 정의 +export const PRODUCT_ACTIONS = { + SET_PRODUCTS: "products/setProducts", + ADD_PRODUCTS: "products/addProducts", + SET_LOADING: "products/setLoading", + SET_ERROR: "products/setError", +}; + +// 리듀서 구현 +const productReducer = (state, action) => { + switch (action.type) { + case PRODUCT_ACTIONS.SET_PRODUCTS: + return { + ...state, + products: action.payload.products, + totalCount: action.payload.totalCount, + loading: false, + }; + default: + return state; + } +}; + +// 스토어 생성 +export const productStore = createStore(productReducer, initialState); +``` + +### 2. 스토어 구조 + +- **productStore**: 상품 목록, 상세, 카테고리 관리 +- **cartStore**: 장바구니 아이템, 선택 상태 관리 +- **uiStore**: 모달, 토스트, 로딩 상태 관리 + +### 3. 로컬 스토리지 동기화 + +```javascript +export const saveCartToStorage = () => { + try { + const state = cartStore.getState(); + cartStorage.set(state); + } catch (error) { + console.error("장바구니 저장 실패:", error); + } +}; +``` + +## 🛣️ 라우팅 시스템 + +### 1. SPA 라우터 구현 + +```javascript +// 라우트 등록 +router.addRoute("/", HomePage); +router.addRoute("/product/:id/", ProductDetailPage); +router.addRoute(".*", NotFoundPage); + +// 네비게이션 +router.push("/product/123/"); + +// 쿼리 파라미터 관리 +router.query = { search: "키보드", limit: 20 }; +``` + +### 2. 동적 라우팅 + +- **파라미터 추출**: `/product/:id/` → `{ id: "123" }` +- **쿼리 스트링**: `?search=키보드&limit=20` +- **히스토리 API**: `pushState`를 사용한 SPA 네비게이션 + +## ⚡ 이벤트 시스템 + +### 1. 이벤트 위임 패턴 + +```javascript +// 전역 이벤트 핸들러 저장소 +const eventHandlers = {}; + +// 이벤트 위임을 통한 핸들러 추가 +export const addEvent = (eventType, selector, handler) => { + if (!eventHandlers[eventType]) { + eventHandlers[eventType] = {}; + } + eventHandlers[eventType][selector] = handler; +}; + +// 사용 예시 +addEvent("click", ".add-to-cart-btn", (e) => { + const productId = e.target.getAttribute("data-product-id"); + addToCart(productId); +}); +``` + +### 2. 배치 렌더링 + +```javascript +export const withBatch = (fn) => { + let scheduled = false; + + return (...args) => { + if (scheduled) return; + scheduled = true; + + queueMicrotask(() => { + scheduled = false; + fn(...args); + }); + }; +}; +``` + +## 🚀 빌드 및 배포 + +### 1. 빌드 스크립트 + +```json +{ + "scripts": { + "dev": "vite --port 5173", + "dev:ssr": "PORT=5174 node server.js", + "build:client": "vite build --outDir ./dist/vanilla", + "build:server": "vite build --outDir ./dist/vanilla-ssr --ssr src/main-server.js", + "build:ssg": "pnpm run build:client-for-ssg && node static-site-generate.js", + "build": "pnpm run build:client && pnpm run build:server && pnpm run build:ssg" + } +} +``` + +### 2. 배포 방식 + +- **CSR**: 클라이언트 사이드 렌더링 (`preview:csr`) +- **SSR**: 서버 사이드 렌더링 (`preview:ssr`) +- **SSG**: 정적 사이트 생성 (`preview:ssg`) + +### 3. 환경 분리 + +```javascript +const prod = process.env.NODE_ENV === "production"; +const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/"); +``` + +## 🎯 성능 최적화 + +### 1. 무한 스크롤 + +```javascript +// 스크롤 위치 감지 +export const isNearBottom = (threshold = 200) => { + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const windowHeight = window.innerHeight; + const documentHeight = document.documentElement.scrollHeight; + + return scrollTop + windowHeight >= documentHeight - threshold; +}; + +// 무한 스크롤 이벤트 +addEvent("scroll", window, () => { + if (isNearBottom() && hasMore && !loading) { + loadMoreProducts(); + } +}); +``` + +### 2. 이미지 지연 로딩 + +```html +${title} +``` + +### 3. 코드 분할 + +- **동적 임포트**: MSW 모킹 시스템 +- **조건부 로딩**: 테스트 환경 분리 + +## 📚 추가 문서 + +- [아키텍처 상세 가이드](./architecture.md) +- [컴포넌트 시스템 가이드](./components.md) +- [상태 관리 가이드](./state-management.md) +- [라우팅 시스템 가이드](./routing.md) +- [빌드 및 배포 가이드](./build-deploy.md) + +## 🎉 결론 + +이 Vanilla JavaScript 프로젝트는 **프레임워크 없이도** 현대적인 웹 애플리케이션의 모든 기능을 구현할 수 있음을 보여줍니다: + +- ✅ **SSR/SSG** 동시 지원 +- ✅ **컴포넌트 기반** 아키텍처 +- ✅ **상태 관리** 시스템 +- ✅ **SPA 라우팅** +- ✅ **이벤트 위임** +- ✅ **성능 최적화** + +순수 JavaScript의 **가벼움**과 **유연성**을 활용하면서도, React나 Vue.js와 유사한 **개발 경험**을 제공하는 것이 이 프로젝트의 핵심 가치입니다. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..db2a2418 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,550 @@ +# 아키텍처 상세 가이드 + +## 📋 목차 + +- [전체 아키텍처 개요](#전체-아키텍처-개요) +- [레이어별 구조](#레이어별-구조) +- [데이터 플로우](#데이터-플로우) +- [모듈 의존성](#모듈-의존성) +- [디자인 패턴](#디자인-패턴) +- [성능 고려사항](#성능-고려사항) + +## 🏗️ 전체 아키텍처 개요 + +### 아키텍처 다이어그램 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Presentation Layer │ +├─────────────────────────────────────────────────────────────┤ +│ Pages (HomePage, ProductDetailPage, NotFoundPage) │ +│ Components (ProductCard, SearchBar, CartModal, Toast) │ +│ PageWrapper (공통 레이아웃) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Business Logic Layer │ +├─────────────────────────────────────────────────────────────┤ +│ Services (productService, cartService) │ +│ API Layer (productApi) │ +│ Router (라우팅 로직) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ State Management │ +├─────────────────────────────────────────────────────────────┤ +│ Stores (productStore, cartStore, uiStore) │ +│ Redux Pattern (Actions, Reducers) │ +│ Local Storage (cartStorage) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Core Libraries │ +├─────────────────────────────────────────────────────────────┤ +│ createStore, createObserver, Router, createStorage │ +│ Event System (이벤트 위임) │ +│ Utils (DOM 조작, 배치 처리) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 핵심 설계 원칙 + +1. **관심사 분리**: 각 레이어는 명확한 책임을 가짐 +2. **단방향 데이터 플로우**: 데이터는 위에서 아래로 흐름 +3. **컴포지션**: 작은 단위의 함수들을 조합하여 복잡한 기능 구현 +4. **불변성**: 상태 변경 시 새로운 객체 생성 +5. **이벤트 기반**: 느슨한 결합을 위한 이벤트 시스템 + +## 📁 레이어별 구조 + +### 1. Presentation Layer (표현 계층) + +#### Pages +```javascript +// 페이지 컴포넌트 구조 +export const HomePage = withLifecycle( + { + onMount: () => loadProductsAndCategories(), + watches: [() => [router.query], () => loadProducts(true)] + }, + () => { + const state = productStore.getState(); + return PageWrapper({ + headerLeft: `

쇼핑몰

`, + children: `${SearchBar()} ${ProductList()}` + }); + } +); +``` + +#### Components +```javascript +// 재사용 가능한 UI 컴포넌트 +export function ProductCard(product) { + return ` +
+ ${product.title} +

${product.title}

+

${product.brand}

+

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

+ +
+ `; +} +``` + +#### PageWrapper +```javascript +// 공통 레이아웃 컴포넌트 +export const PageWrapper = ({ headerLeft, children }) => { + const cart = cartStore.getState(); + const { cartModal, toast } = uiStore.getState(); + + return ` +
+
${headerLeft}
+
${children}
+ ${CartModal({ ...cart, isOpen: cartModal.isOpen })} + ${Toast(toast)} +
+ `; +}; +``` + +### 2. Business Logic Layer (비즈니스 로직 계층) + +#### Services +```javascript +// 상품 관련 비즈니스 로직 +export const loadProducts = async (resetList = true) => { + try { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { loading: true, status: "pending", error: null } + }); + + const { products, pagination: { total } } = await getProducts(router.query); + + if (resetList) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_PRODUCTS, + payload: { products, totalCount: total } + }); + } else { + productStore.dispatch({ + type: PRODUCT_ACTIONS.ADD_PRODUCTS, + payload: { products, totalCount: total } + }); + } + } catch (error) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_ERROR, + payload: error.message + }); + } +}; +``` + +#### API Layer +```javascript +// API 통신 추상화 +export async function getProducts(params = {}) { + const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; + const page = params.current ?? params.page ?? 1; + + const searchParams = new URLSearchParams({ + page: page.toString(), + limit: limit.toString(), + ...(search && { search }), + ...(category1 && { category1 }), + ...(category2 && { category2 }), + sort, + }); + + const response = await fetch(`/api/products?${searchParams}`); + return await response.json(); +} +``` + +### 3. State Management (상태 관리 계층) + +#### Store 구조 +```javascript +// Redux 패턴 구현 +export const createStore = (reducer, initialState) => { + const { subscribe, notify } = createObserver(); + let state = initialState; + + const getState = () => state; + const dispatch = (action) => { + const newState = reducer(state, action); + if (newState !== state) { + state = newState; + notify(); + } + }; + + return { getState, dispatch, subscribe }; +}; +``` + +#### Reducer 패턴 +```javascript +// 상품 스토어 리듀서 +const productReducer = (state, action) => { + switch (action.type) { + case PRODUCT_ACTIONS.SET_PRODUCTS: + return { + ...state, + products: action.payload.products, + totalCount: action.payload.totalCount, + loading: false, + error: null, + status: "done" + }; + case PRODUCT_ACTIONS.SET_LOADING: + return { ...state, loading: action.payload }; + case PRODUCT_ACTIONS.SET_ERROR: + return { + ...state, + error: action.payload, + loading: false, + status: "done" + }; + default: + return state; + } +}; +``` + +### 4. Core Libraries (핵심 라이브러리 계층) + +#### Observer Pattern +```javascript +// 옵저버 패턴 구현 +export const createObserver = () => { + const listeners = new Set(); + const subscribe = (fn) => listeners.add(fn); + const notify = () => listeners.forEach((listener) => listener()); + + return { subscribe, notify }; +}; +``` + +#### Router System +```javascript +// SPA 라우터 구현 +export class Router { + #routes = new Map(); + #route = null; + #observer = createObserver(); + + 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 }); + } + + push(url) { + window.history.pushState(null, "", url); + this.#route = this.#findRoute(url); + this.#observer.notify(); + } +} +``` + +## 🔄 데이터 플로우 + +### 1. 사용자 액션 → 상태 변경 플로우 + +``` +사용자 클릭 → 이벤트 핸들러 → Service 함수 → Store Dispatch → Reducer → 상태 업데이트 → UI 리렌더링 +``` + +### 2. 상세 플로우 예시 + +```javascript +// 1. 사용자가 장바구니 버튼 클릭 +addEvent("click", ".add-to-cart-btn", (e) => { + const productId = e.target.getAttribute("data-product-id"); + addToCart(productId); // 2. Service 함수 호출 +}); + +// 3. Service에서 Store에 액션 디스패치 +export const addToCart = (product, quantity = 1) => { + cartStore.dispatch({ + type: CART_ACTIONS.ADD_ITEM, + payload: { product, quantity } + }); + + saveCartToStorage(); // 4. 로컬 스토리지 동기화 +}; + +// 5. Reducer에서 상태 업데이트 +const cartReducer = (state, action) => { + switch (action.type) { + case CART_ACTIONS.ADD_ITEM: + return { + ...state, + items: [...state.items, newItem] + }; + } +}; + +// 6. Store 변경 감지하여 UI 리렌더링 +cartStore.subscribe(render); +``` + +### 3. 라우팅 플로우 + +``` +URL 변경 → Router.push() → 히스토리 업데이트 → 라우트 매칭 → 페이지 컴포넌트 렌더링 → 라이프사이클 실행 +``` + +## 🔗 모듈 의존성 + +### 의존성 그래프 + +``` +main.js +├── render.js +│ ├── stores/ +│ ├── router/ +│ └── pages/ +├── events.js +│ ├── services/ +│ └── utils/ +└── utils/ + ├── eventUtils.js + ├── domUtils.js + └── withBatch.js + +pages/ +├── PageWrapper.js +│ ├── stores/ +│ └── components/ +└── HomePage.js + ├── components/ + ├── stores/ + ├── services/ + └── router/ + +services/ +├── productService.js +│ ├── api/ +│ ├── stores/ +│ └── router/ +└── cartService.js + ├── stores/ + └── storage/ + +stores/ +├── productStore.js +│ └── lib/ +├── cartStore.js +│ ├── lib/ +│ └── storage/ +└── uiStore.js + └── lib/ + +lib/ +├── createStore.js +│ └── createObserver.js +├── Router.js +│ └── createObserver.js +└── createStorage.js +``` + +### 순환 의존성 방지 + +1. **단방향 의존성**: 상위 레이어에서 하위 레이어로만 의존 +2. **인터페이스 분리**: 필요한 기능만 노출 +3. **의존성 주입**: 런타임에 의존성 주입 + +## 🎨 디자인 패턴 + +### 1. Observer Pattern (옵저버 패턴) + +```javascript +// 상태 변경 시 구독자들에게 알림 +const { subscribe, notify } = createObserver(); + +// 구독 +productStore.subscribe(render); + +// 알림 +const dispatch = (action) => { + const newState = reducer(state, action); + if (newState !== state) { + state = newState; + notify(); // 모든 구독자에게 알림 + } +}; +``` + +### 2. Redux Pattern (리덕스 패턴) + +```javascript +// 액션 → 리듀서 → 상태 업데이트 +const action = { type: "SET_PRODUCTS", payload: { products, totalCount } }; +const newState = reducer(currentState, action); +``` + +### 3. Event Delegation (이벤트 위임) + +```javascript +// 상위 요소에서 하위 요소의 이벤트 처리 +document.body.addEventListener("click", (e) => { + const target = e.target.closest(".add-to-cart-btn"); + if (target) { + const productId = target.getAttribute("data-product-id"); + addToCart(productId); + } +}); +``` + +### 4. Higher-Order Function (고차 함수) + +```javascript +// 라이프사이클 관리 +export const withLifecycle = ({ onMount, onUnmount, watches }, page) => { + return (...args) => { + // 마운트 로직 + if (wasNewPage) { + mount(page); + } + + // 의존성 감시 + if (lifecycle.watches) { + lifecycle.watches.forEach(([getDeps, callback]) => { + const newDeps = getDeps(); + if (depsChanged(newDeps, oldDeps)) { + callback(); + } + }); + } + + return page(...args); + }; +}; +``` + +### 5. Factory Pattern (팩토리 패턴) + +```javascript +// 스토어 생성 팩토리 +export const createStore = (reducer, initialState) => { + // 스토어 인스턴스 생성 로직 + return { getState, dispatch, subscribe }; +}; + +// 스토리지 생성 팩토리 +export const createStorage = (key, storage = window.localStorage) => { + // 스토리지 인스턴스 생성 로직 + return { get, set, reset }; +}; +``` + +## ⚡ 성능 고려사항 + +### 1. 렌더링 최적화 + +```javascript +// 배치 렌더링으로 불필요한 리렌더링 방지 +export const withBatch = (fn) => { + let scheduled = false; + + return (...args) => { + if (scheduled) return; + scheduled = true; + + queueMicrotask(() => { + scheduled = false; + fn(...args); + }); + }; +}; +``` + +### 2. 메모리 관리 + +```javascript +// WeakMap을 사용한 메모리 누수 방지 +const lifeCycles = new WeakMap(); + +// 이벤트 리스너 정리 +const cleanup = () => { + document.body.removeEventListener("click", handleGlobalEvents); +}; +``` + +### 3. 네트워크 최적화 + +```javascript +// 무한 스크롤로 초기 로딩 시간 단축 +export const loadMoreProducts = async () => { + const state = productStore.getState(); + const hasMore = state.products.length < state.totalCount; + + if (!hasMore || state.loading) return; + + router.query = { current: Number(router.query.current ?? 1) + 1 }; + await loadProducts(false); // 기존 목록에 추가 +}; +``` + +### 4. 이미지 최적화 + +```html + +${title} +``` + +### 5. 코드 분할 + +```javascript +// 동적 임포트로 초기 번들 크기 최적화 +const enableMocking = () => + import("./mocks/browser.js").then(({ worker }) => + worker.start({ + serviceWorker: { url: `${BASE_URL}mockServiceWorker.js` }, + onUnhandledRequest: "bypass" + }) + ); +``` + +## 🔧 확장성 고려사항 + +### 1. 모듈화 + +- 각 기능별로 독립적인 모듈 구성 +- 명확한 인터페이스 정의 +- 느슨한 결합, 강한 응집 + +### 2. 플러그인 시스템 + +```javascript +// 라우터에 미들웨어 추가 가능 +router.use((req, res, next) => { + // 인증, 로깅 등 + next(); +}); +``` + +### 3. 테스트 가능성 + +- 순수 함수 중심 설계 +- 의존성 주입을 통한 모킹 가능 +- 단위 테스트 친화적 구조 + +이러한 아키텍처 설계를 통해 **유지보수성**, **확장성**, **성능**을 모두 고려한 견고한 애플리케이션을 구축할 수 있습니다. diff --git a/docs/components-summary.md b/docs/components-summary.md new file mode 100644 index 00000000..e48cb5b6 --- /dev/null +++ b/docs/components-summary.md @@ -0,0 +1,89 @@ +# 컴포넌트 시스템 요약 + +## 🧩 컴포넌트 구조 + +### 1. 함수형 컴포넌트 패턴 +```javascript +// 기본 구조 +export function ComponentName(props) { + return `
${props.content}
`; +} +``` + +### 2. 주요 컴포넌트들 + +#### ProductCard +- 상품 정보 표시 +- 장바구니 추가 버튼 +- 이미지, 제목, 가격, 브랜드 + +#### ProductList +- 상품 목록 그리드 +- 로딩/에러 상태 처리 +- 무한 스크롤 지원 + +#### SearchBar +- 검색 입력창 +- 카테고리 필터 +- 정렬/개수 선택 + +#### CartModal +- 장바구니 모달 +- 수량 조절 +- 선택/삭제 기능 + +#### Toast +- 알림 메시지 +- 성공/에러/경고 타입 + +### 3. 페이지 래퍼 패턴 +```javascript +export const PageWrapper = ({ headerLeft, children }) => { + return ` +
+
${headerLeft}
+
${children}
+ ${CartModal()} + ${Toast()} +
+ `; +}; +``` + +### 4. 라이프사이클 관리 +```javascript +export const HomePage = withLifecycle( + { + onMount: () => loadData(), + watches: [() => [router.query], () => updateData()] + }, + () => renderComponent() +); +``` + +## 🎯 핵심 특징 + +- **함수형**: 모든 컴포넌트는 함수로 구현 +- **템플릿 리터럴**: HTML 문자열 반환 +- **Props 기반**: 매개변수로 데이터 전달 +- **조합 가능**: 작은 컴포넌트들을 조합하여 복잡한 UI 구성 +- **이벤트 위임**: data-* 속성으로 이벤트 처리 + +## 📝 사용 예시 + +```javascript +// 컴포넌트 사용 +const productCard = ProductCard({ + productId: "123", + title: "상품명", + price: 10000 +}); + +// 페이지에서 조합 +const homePage = PageWrapper({ + headerLeft: `

쇼핑몰

`, + children: `${SearchBar()} ${ProductList()}` +}); +``` + +이런 식으로 간단하고 핵심적인 내용만 포함하여 작성하면 토큰 제한을 피할 수 있습니다. From 66a8ab2c7654ff200178c118475e4a6c6394bb7f Mon Sep 17 00:00:00 2001 From: heojungseok Date: Wed, 3 Sep 2025 16:50:04 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20type=20=EC=A7=80=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/mocks/handlers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vanilla/src/mocks/handlers.js b/packages/vanilla/src/mocks/handlers.js index 8d3a1faa..9cd19b09 100644 --- a/packages/vanilla/src/mocks/handlers.js +++ b/packages/vanilla/src/mocks/handlers.js @@ -1,5 +1,5 @@ import { http, HttpResponse } from "msw"; -import items from "./items.json"; +import items from "./items.json" with { type: "json" }; const delay = async () => await new Promise((resolve) => setTimeout(resolve, 200)); From 99a9f8b2591a34aade7e9865d7d28a396f602db6 Mon Sep 17 00:00:00 2001 From: heojungseok Date: Wed, 3 Sep 2025 17:03:20 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20=EA=B2=BD=EB=A1=9C=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/src/components/index.js | 16 ++++++++-------- packages/vanilla/src/router/index.js | 2 +- packages/vanilla/src/services/cartService.js | 4 ++-- packages/vanilla/src/services/index.js | 4 ++-- packages/vanilla/src/services/productService.js | 6 +++--- packages/vanilla/src/stores/index.js | 10 +++++----- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/vanilla/src/components/index.js b/packages/vanilla/src/components/index.js index ef27b3d5..3e5ad533 100644 --- a/packages/vanilla/src/components/index.js +++ b/packages/vanilla/src/components/index.js @@ -1,8 +1,8 @@ -export * from "./ProductCard"; -export * from "./SearchBar"; -export * from "./ProductList"; -export * from "./CartItem"; -export * from "./CartModal"; -export * from "./Toast"; -export * from "./Logo"; -export * from "./Footer"; +export * from "./ProductCard.js"; +export * from "./SearchBar.js"; +export * from "./ProductList.js"; +export * from "./CartItem.js"; +export * from "./CartModal.js"; +export * from "./Toast.js"; +export * from "./Logo.js"; +export * from "./Footer.js"; diff --git a/packages/vanilla/src/router/index.js b/packages/vanilla/src/router/index.js index f4964f8d..4d84d2cb 100644 --- a/packages/vanilla/src/router/index.js +++ b/packages/vanilla/src/router/index.js @@ -1,2 +1,2 @@ -export * from "./router"; +export * from "./router.js"; export * from "./withLifecycle.js"; diff --git a/packages/vanilla/src/services/cartService.js b/packages/vanilla/src/services/cartService.js index 85f7c5e9..d7adb1bc 100644 --- a/packages/vanilla/src/services/cartService.js +++ b/packages/vanilla/src/services/cartService.js @@ -1,5 +1,5 @@ -import { CART_ACTIONS, cartStore, UI_ACTIONS, uiStore } from "../stores"; -import { cartStorage } from "../storage"; +import { CART_ACTIONS, cartStore, UI_ACTIONS, uiStore } from "../stores/index.js"; +import { cartStorage } from "../storage/index.js"; /** * 로컬스토리지에서 장바구니 데이터 로드 diff --git a/packages/vanilla/src/services/index.js b/packages/vanilla/src/services/index.js index 845d25b4..782661e1 100644 --- a/packages/vanilla/src/services/index.js +++ b/packages/vanilla/src/services/index.js @@ -1,2 +1,2 @@ -export * from "./productService"; -export * from "./cartService"; +export * from "./productService.js"; +export * from "./cartService.js"; diff --git a/packages/vanilla/src/services/productService.js b/packages/vanilla/src/services/productService.js index 8a53d585..418fe413 100644 --- a/packages/vanilla/src/services/productService.js +++ b/packages/vanilla/src/services/productService.js @@ -1,6 +1,6 @@ -import { getCategories, getProduct, getProducts } from "../api/productApi"; -import { router } from "../router"; -import { initialProductState, PRODUCT_ACTIONS, productStore } from "../stores"; +import { getCategories, getProduct, getProducts } from "../api/productApi.js"; +import { router } from "../router/index.js"; +import { initialProductState, PRODUCT_ACTIONS, productStore } from "../stores/index.js"; export const loadProductsAndCategories = async () => { router.query = { current: undefined }; // 항상 첫 페이지로 초기화 diff --git a/packages/vanilla/src/stores/index.js b/packages/vanilla/src/stores/index.js index 38f879e3..b423e5be 100644 --- a/packages/vanilla/src/stores/index.js +++ b/packages/vanilla/src/stores/index.js @@ -1,14 +1,14 @@ -export * from "./actionTypes"; -export * from "./cartStore"; -export * from "./productStore"; -export * from "./uiStore"; +export * from "./actionTypes.js"; +export * from "./cartStore.js"; +export * from "./productStore.js"; +export * from "./uiStore.js"; /** * SSR 초기 데이터로 상태 초기화 */ export const initializeFromSSR = (initialData) => { console.log("🔄 SSR 초기 데이터로 상태 초기화 시작:", initialData); - + console.log(productStore); if (!initialData || !initialData.state) { console.log("⚠️ 유효하지 않은 초기 데이터"); return; From b9bd7d5de6108a3d87d99bdfb8304dfa001afabf Mon Sep 17 00:00:00 2001 From: heojungseok Date: Wed, 3 Sep 2025 17:12:28 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20import=20=EA=B5=AC=EB=AC=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/src/stores/index.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/vanilla/src/stores/index.js b/packages/vanilla/src/stores/index.js index b423e5be..1e81d14c 100644 --- a/packages/vanilla/src/stores/index.js +++ b/packages/vanilla/src/stores/index.js @@ -1,3 +1,6 @@ +import { productStore } from "./productStore.js"; +import { uiStore } from "./uiStore.js"; + export * from "./actionTypes.js"; export * from "./cartStore.js"; export * from "./productStore.js"; From ddcd811eee0efabf08f3cb21bf941601f35c446c Mon Sep 17 00:00:00 2001 From: jsheo Date: Thu, 4 Sep 2025 03:32:42 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20main-server.js=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vanilla/server.js | 6 ++++ packages/vanilla/server/render.js | 29 +++++++++++++++++++ packages/vanilla/src/lib/createStore.js | 2 +- packages/vanilla/src/lib/index.js | 10 +++---- packages/vanilla/src/main-server.js | 6 ++-- .../vanilla/src/pages/ProductDetailPage.js | 16 ++++++++-- packages/vanilla/src/render.js | 6 ++-- packages/vanilla/src/router/router.js | 2 +- packages/vanilla/src/storage/cartStorage.js | 2 +- packages/vanilla/src/stores/cartStore.js | 4 +-- packages/vanilla/src/stores/productStore.js | 4 +-- packages/vanilla/src/stores/uiStore.js | 4 +-- 12 files changed, 68 insertions(+), 23 deletions(-) diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index b292f180..8b6beddb 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -4,11 +4,17 @@ import { asyncHandler, errorHandler, notFoundHandler } from "./server/errorHandl import { setupMiddleware } from "./server/middleware.js"; import { renderWithInitialData } from "./server/render.js"; import { createHTMLTemplate } from "./server/template.js"; +import { server } from "./src/mocks/server-browser.js"; // 설정 가져오기 const config = getConfig(); const { port, base } = config; +// MSW 서버 시작 +server.listen({ + onUnhandledRequest: "bypass", +}); + const app = express(); // 미들웨어 설정 diff --git a/packages/vanilla/server/render.js b/packages/vanilla/server/render.js index bf6eebfc..96159fef 100644 --- a/packages/vanilla/server/render.js +++ b/packages/vanilla/server/render.js @@ -1,3 +1,4 @@ +import { productStore } from "../src/stores/productStore.js"; import { serverStateManager } from "./stateManager.js"; /** @@ -143,6 +144,20 @@ const renderHomePageWithData = async (query, vite = null) => { } console.log("🎨 홈페이지 컴포넌트 렌더링 시작 (초기 데이터 포함)"); + + // 서버 상태를 productStore에 주입 + productStore.dispatch({ + type: "SETUP", + payload: { + products: state.products, + totalCount: state.totalCount, + loading: false, + error: null, + status: "done", + categories: state.categories, + }, + }); + const html = HomePage("", query, state); console.log("✅ 홈페이지 SSR 렌더링 완료 (초기 데이터 포함), HTML 길이:", html.length); @@ -204,6 +219,20 @@ const renderProductDetailWithData = async (productId, query, vite = null) => { } console.log("🎨 상품 상세 컴포넌트 렌더링 시작 (초기 데이터 포함)"); + + // 서버 상태를 productStore에 주입 + productStore.dispatch({ + type: "SETUP", + payload: { + currentProduct: state.product, + relatedProducts: [], + loading: false, + error: null, + status: "done", + categories: state.categories, + }, + }); + const html = ProductDetailPage(`/product/${productId}/`, query, state); console.log("✅ 상품 상세 SSR 렌더링 완료 (초기 데이터 포함), HTML 길이:", html.length); diff --git a/packages/vanilla/src/lib/createStore.js b/packages/vanilla/src/lib/createStore.js index 19c74f82..9337ba3f 100644 --- a/packages/vanilla/src/lib/createStore.js +++ b/packages/vanilla/src/lib/createStore.js @@ -1,4 +1,4 @@ -import { createObserver } from "./createObserver"; +import { createObserver } from "./createObserver.js"; /** * Redux-style Store 생성 함수 diff --git a/packages/vanilla/src/lib/index.js b/packages/vanilla/src/lib/index.js index 6e5ae6d6..cc5805e0 100644 --- a/packages/vanilla/src/lib/index.js +++ b/packages/vanilla/src/lib/index.js @@ -1,5 +1,5 @@ -export * from "./createObserver"; -export * from "./createStorage"; -export * from "./createStore"; -export * from "./Router"; -export * from "./server-Router"; +export * from "./createObserver.js"; +export * from "./createStorage.js"; +export * from "./createStore.js"; +export * from "./Router.js"; +export * from "./server-router.js"; diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 40b58858..1b90d71d 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,4 +1,2 @@ -export const render = async (url, query) => { - console.log({ url, query }); - return ""; -}; +// render.js의 함수들을 re-export +export { render, renderWithInitialData } from "../server/render.js"; diff --git a/packages/vanilla/src/pages/ProductDetailPage.js b/packages/vanilla/src/pages/ProductDetailPage.js index 73d0ec30..e7934e23 100644 --- a/packages/vanilla/src/pages/ProductDetailPage.js +++ b/packages/vanilla/src/pages/ProductDetailPage.js @@ -1,6 +1,6 @@ -import { productStore } from "../stores"; -import { loadProductDetailForPage } from "../services"; import { router, withLifecycle } from "../router"; +import { loadProductDetailForPage } from "../services"; +import { productStore } from "../stores"; import { PageWrapper } from "./PageWrapper.js"; const loadingContent = ` @@ -35,6 +35,18 @@ const ErrorContent = ({ error }) => ` `; function ProductDetail({ product, relatedProducts = [] }) { + // product가 null인 경우 처리 + if (!product) { + return ` +
+
+

상품을 찾을 수 없습니다

+

요청하신 상품이 존재하지 않거나 삭제되었습니다.

+
+
+ `; + } + const { productId, title, diff --git a/packages/vanilla/src/render.js b/packages/vanilla/src/render.js index 87f30f19..0699d4cf 100644 --- a/packages/vanilla/src/render.js +++ b/packages/vanilla/src/render.js @@ -1,6 +1,6 @@ -import { cartStore, productStore, uiStore } from "./stores"; -import { router } from "./router"; -import { HomePage, NotFoundPage, ProductDetailPage } from "./pages"; +import { HomePage, NotFoundPage, ProductDetailPage } from "./pages/index.js"; +import { router } from "./router/index.js"; +import { cartStore, productStore, uiStore } from "./stores/index.js"; import { withBatch } from "./utils"; // 홈 페이지 (상품 목록) diff --git a/packages/vanilla/src/router/router.js b/packages/vanilla/src/router/router.js index d446996b..1e04e904 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -1,5 +1,5 @@ // 글로벌 라우터 인스턴스 import { BASE_URL } from "../constants.js"; -import { Router, RouterSSR } from "../lib"; +import { Router, RouterSSR } from "../lib/index.js"; export const router = typeof window !== "undefined" ? new Router(BASE_URL) : new RouterSSR(); diff --git a/packages/vanilla/src/storage/cartStorage.js b/packages/vanilla/src/storage/cartStorage.js index 388f3571..95f70d3d 100644 --- a/packages/vanilla/src/storage/cartStorage.js +++ b/packages/vanilla/src/storage/cartStorage.js @@ -1,4 +1,4 @@ -import { createSSRStorage, createStorage } from "../lib"; +import { createSSRStorage, createStorage } from "../lib/index.js"; export const cartStorage = typeof window !== "undefined" ? createStorage("shopping_cart") : createSSRStorage("shopping_cart"); diff --git a/packages/vanilla/src/stores/cartStore.js b/packages/vanilla/src/stores/cartStore.js index fe61f167..45bb172c 100644 --- a/packages/vanilla/src/stores/cartStore.js +++ b/packages/vanilla/src/stores/cartStore.js @@ -1,6 +1,6 @@ -import { createStore } from "../lib"; -import { CART_ACTIONS } from "./actionTypes"; +import { createStore } from "../lib/index.js"; import { cartStorage } from "../storage/index.js"; +import { CART_ACTIONS } from "./actionTypes.js"; /** * 장바구니 스토어 초기 상태 diff --git a/packages/vanilla/src/stores/productStore.js b/packages/vanilla/src/stores/productStore.js index 0f39343d..00c9f5c7 100644 --- a/packages/vanilla/src/stores/productStore.js +++ b/packages/vanilla/src/stores/productStore.js @@ -1,5 +1,5 @@ -import { createStore } from "../lib"; -import { PRODUCT_ACTIONS } from "./actionTypes"; +import { createStore } from "../lib/index.js"; +import { PRODUCT_ACTIONS } from "./actionTypes.js"; /** * 상품 스토어 초기 상태 diff --git a/packages/vanilla/src/stores/uiStore.js b/packages/vanilla/src/stores/uiStore.js index 606603d7..0a05f796 100644 --- a/packages/vanilla/src/stores/uiStore.js +++ b/packages/vanilla/src/stores/uiStore.js @@ -1,5 +1,5 @@ -import { createStore } from "../lib"; -import { UI_ACTIONS } from "./actionTypes"; +import { createStore } from "../lib/index.js"; +import { UI_ACTIONS } from "./actionTypes.js"; /** * UI 스토어 초기 상태