diff --git a/packages/vanilla/index.html b/packages/vanilla/index.html index 483a6d5e..56f70659 100644 --- a/packages/vanilla/index.html +++ b/packages/vanilla/index.html @@ -5,18 +5,19 @@ - + + diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index b9a56d98..b91851b7 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,31 +1,71 @@ +import fs from "fs/promises"; import express from "express"; +import { mswServer } from "./src/mocks/serverBrowser.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 templateHtml = prod ? await fs.readFile("./dist/vanilla/index.html", "utf-8") : ""; + const app = express(); -const render = () => { - return `
안녕하세요
`; -}; - -app.get("*all", (req, res) => { - res.send( - ` - - - - - - Vanilla Javascript SSR - - -
${render()}
- - - `.trim(), - ); +mswServer.listen({ onUnhandledRequest: "bypass" }); + +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/vanilla", { extensions: [] })); +} + +// Serve HTML +app.use("*all", async (req, res) => { + try { + let url = req.originalUrl; + if (base !== "/" && url.startsWith(base)) { + url = url.replace(base, ""); + } + if (!url.startsWith("/")) { + url = "/" + url; + } + + /** @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; + } + + const rendered = await render(url, req.query); + + const html = template + .replace(``, rendered.head ?? "") + .replace(``, ``) + .replace(``, rendered.html ?? ""); + + 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 diff --git a/packages/vanilla/src/api/productApi.js b/packages/vanilla/src/api/productApi.js index c2330fbe..519cc69d 100644 --- a/packages/vanilla/src/api/productApi.js +++ b/packages/vanilla/src/api/productApi.js @@ -1,3 +1,5 @@ +const BASE_URL = () => (typeof window === "undefined" ? "http://localhost:5174" : ""); + export async function getProducts(params = {}) { const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; const page = params.current ?? params.page ?? 1; @@ -11,17 +13,17 @@ export async function getProducts(params = {}) { sort, }); - const response = await fetch(`/api/products?${searchParams}`); + const response = await fetch(`${BASE_URL()}/api/products?${searchParams}`); return await response.json(); } export async function getProduct(productId) { - const response = await fetch(`/api/products/${productId}`); + const response = await fetch(`${BASE_URL()}/api/products/${productId}`); return await response.json(); } export async function getCategories() { - const response = await fetch("/api/categories"); + const response = await fetch(`${BASE_URL()}/api/categories`); return await response.json(); } diff --git a/packages/vanilla/src/components/CartModal.js b/packages/vanilla/src/components/CartModal.js index f9695180..bbb947bd 100644 --- a/packages/vanilla/src/components/CartModal.js +++ b/packages/vanilla/src/components/CartModal.js @@ -1,4 +1,4 @@ -import { CartItem } from "./CartItem"; +import { CartItem } from "./CartItem.js"; export function CartModal({ items = [], selectedAll = false, isOpen = false }) { if (!isOpen) { diff --git a/packages/vanilla/src/components/ProductList.js b/packages/vanilla/src/components/ProductList.js index e32e49b1..62b9558e 100644 --- a/packages/vanilla/src/components/ProductList.js +++ b/packages/vanilla/src/components/ProductList.js @@ -1,4 +1,4 @@ -import { ProductCard, ProductCardSkeleton } from "./ProductCard"; +import { ProductCard, ProductCardSkeleton } from "./ProductCard.js"; const loadingSkeleton = Array(6).fill(0).map(ProductCardSkeleton).join(""); 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/constants.js b/packages/vanilla/src/constants.js index 504b29b9..e036e376 100644 --- a/packages/vanilla/src/constants.js +++ b/packages/vanilla/src/constants.js @@ -1 +1,2 @@ -export const BASE_URL = import.meta.env.PROD ? "/front_6th_chapter4-1/vanilla/" : "/"; +export const BASE_URL = + typeof window === "undefined" ? "/" : import.meta.env?.PROD ? "/front_6th_chapter4-1/vanilla/" : "/"; diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js new file mode 100644 index 00000000..73b95abb --- /dev/null +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -0,0 +1,169 @@ +/** + * Server 라우터 + */ +import { createObserver } from "./createObserver.js"; + +export class ServerRouter { + #routes; + #route; + #observer = createObserver(); + #baseUrl; + #serverQuery = {}; + + constructor(baseUrl = "") { + this.#routes = new Map(); + this.#route = null; + this.#baseUrl = baseUrl.replace(/\/$/, ""); + } + + get baseUrl() { + return this.#baseUrl; + } + + get query() { + // 서버 사이드에서는 #serverQuery를 사용, 클라이언트 사이드에서는 URL 파라미터 사용 + if (typeof window === "undefined") { + return this.#serverQuery; + } + return ServerRouter.parseQuery(window.location.search); + } + + set query(newQuery) { + // 서버 사이드에서는 #serverQuery를 설정, 클라이언트 사이드에서는 URL 업데이트 + if (typeof window === "undefined") { + this.#serverQuery = { ...newQuery }; + } else { + const newUrl = ServerRouter.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 = "/", origin = "http://localhost") { + const { pathname } = new URL(url, origin); + for (const [routePath, route] of this.#routes) { + const match = pathname.match(route.regex); + if (match) { + // 매치된 파라미터들을 객체로 변환 + const params = {}; + route.paramNames.forEach((name, index) => { + params[name] = match[index + 1]; + }); + + return { + ...route, + params, + path: routePath, + }; + } + } + return null; + } + + /** + * 네비게이션 실행 + * @param {string} url - 이동할 경로 + */ + push(url = "/") { + try { + // baseUrl이 없으면 자동으로 붙여줌 + let 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.#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, 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/vanilla/src/lib/createStorage.js b/packages/vanilla/src/lib/createStorage.js index 08b504f2..0a91a0c9 100644 --- a/packages/vanilla/src/lib/createStorage.js +++ b/packages/vanilla/src/lib/createStorage.js @@ -1,10 +1,20 @@ +export const memoryStorage = () => { + const storage = new Map(); + + return { + getItem: (key) => storage.get(key), + setItem: (key, value) => storage.set(key, value), + removeItem: (key) => storage.delete(key), + }; +}; + /** * 로컬스토리지 추상화 함수 * @param {string} key - 스토리지 키 * @param {Storage} storage - 기본값은 localStorage * @returns {Object} { get, set, reset } */ -export const createStorage = (key, storage = window.localStorage) => { +export const createStorage = (key, storage = typeof window === "undefined" ? memoryStorage() : window.localStorage) => { const get = () => { try { const item = storage.getItem(key); 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 a598ef30..1ae59e67 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 "./Router"; +export * from "./createObserver.js"; +export * from "./createStore.js"; +export * from "./createStorage.js"; +export * from "./Router.js"; +export * from "./ServerRouter.js"; diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 40b58858..09c99e42 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,4 +1,90 @@ +import { HomePage, NotFoundPage, ProductDetailPageSSR } from "./pages/index.js"; +import { router } from "./router/index.js"; +import { getProductsOnServer, getUniqueCategories, getProductDetailOnServer } from "./mocks/server.js"; + +router.addRoute("/", () => { + const { + products, + pagination: { total: totalCount }, + } = getProductsOnServer(router.query); + const categories = getUniqueCategories(); + + const results = { + products, + categories, + totalCount, + }; + + return { + initialData: results, + html: HomePage(results), + head: "쇼핑몰 - 홈", + }; +}); + +router.addRoute("/product/:id", () => { + const productId = router.params.id; + const productData = getProductDetailOnServer(productId); + + if (!productData) { + return { + initialData: { error: "상품을 찾을 수 없습니다." }, + html: NotFoundPage(), + head: "상품을 찾을 수 없습니다", + }; + } + + const { product, relatedProducts } = productData; + + return { + initialData: { + currentProduct: product, + relatedProducts, + }, + html: ProductDetailPageSSR({ + currentProduct: product, + relatedProducts, + }), + head: `${product.title} - 쇼핑몰`, + }; +}); + +router.addRoute(".*", () => { + return { + initialData: {}, + html: NotFoundPage(), + head: "404", + }; +}); + export const render = async (url, query) => { console.log({ url, query }); - return ""; + try { + // 1. 서버 라우터에 쿼리 설정 (서버 사이드용) + router.query = query; + + // 2. URL로 네비게이션하여 해당 라우트 찾기 + router.push(url); + + // 3. 현재 라우트의 핸들러가 있는지 확인 + if (!router.target) { + throw new Error(`No route found for URL: ${url}`); + } + + // 4. 핸들러 실행하여 렌더링 결과 얻기 + const result = router.target(); + + return { + ...result, + data: JSON.stringify(result.initialData), + }; + } catch (error) { + console.error(error); + return { + initialData: { error: error.message }, + data: JSON.stringify({ error: error.message }), + html: "
에러발생
", + head: "에러", + }; + } }; diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js index 4c3f2765..b02ee958 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 } from "./stores"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => @@ -16,6 +17,26 @@ const enableMocking = () => ); function main() { + // SSR 데이터가 있으면 store에 설정 + if (window.__INITIAL_DATA__) { + const { currentProduct, relatedProducts, error } = window.__INITIAL_DATA__; + if (currentProduct) { + productStore.setState({ + currentProduct, + relatedProducts: relatedProducts || [], + loading: false, + error: null, + }); + } else if (error) { + productStore.setState({ + currentProduct: null, + relatedProducts: [], + loading: false, + error, + }); + } + } + registerAllEvents(); registerGlobalEvents(); loadCartFromStorage(); diff --git a/packages/vanilla/src/mocks/handlers.js b/packages/vanilla/src/mocks/handlers.js index 6e3035e6..9cd19b09 100644 --- a/packages/vanilla/src/mocks/handlers.js +++ b/packages/vanilla/src/mocks/handlers.js @@ -1,5 +1,5 @@ import { http, HttpResponse } from "msw"; -import items from "./items.json"; +import items from "./items.json" with { type: "json" }; const delay = async () => await new Promise((resolve) => setTimeout(resolve, 200)); @@ -64,7 +64,7 @@ function filterProducts(products, query) { export const handlers = [ // 상품 목록 API - http.get("/api/products", async ({ request }) => { + http.get("*/api/products", async ({ request }) => { const url = new URL(request.url); const page = parseInt(url.searchParams.get("page") ?? url.searchParams.get("current")) || 1; const limit = parseInt(url.searchParams.get("limit")) || 20; @@ -111,7 +111,7 @@ export const handlers = [ }), // 상품 상세 API - http.get("/api/products/:id", ({ params }) => { + http.get("*/api/products/:id", ({ params }) => { const { id } = params; const product = items.find((item) => item.productId === id); @@ -133,7 +133,7 @@ export const handlers = [ }), // 카테고리 목록 API - http.get("/api/categories", async () => { + http.get("*/api/categories", async () => { const categories = getUniqueCategories(); await delay(); return HttpResponse.json(categories); diff --git a/packages/vanilla/src/mocks/server.js b/packages/vanilla/src/mocks/server.js new file mode 100644 index 00000000..e7c21166 --- /dev/null +++ b/packages/vanilla/src/mocks/server.js @@ -0,0 +1,135 @@ +// import { http, HttpResponse } from "msw"; +import items from "./items.json" with { type: "json" }; + +// const delay = async () => await new Promise((resolve) => setTimeout(resolve, 200)); + +// 카테고리 추출 함수 +export function getUniqueCategories() { + const categories = {}; + + items.forEach((item) => { + const cat1 = item.category1; + const cat2 = item.category2; + + if (!categories[cat1]) categories[cat1] = {}; + if (cat2 && !categories[cat1][cat2]) categories[cat1][cat2] = {}; + }); + + return categories; +} + +// 상품 검색 및 필터링 함수 +function filterProducts(products, query) { + let filtered = [...products]; + + // 검색어 필터링 + if (query.search) { + const searchTerm = query.search.toLowerCase(); + filtered = filtered.filter( + (item) => item.title.toLowerCase().includes(searchTerm) || item.brand.toLowerCase().includes(searchTerm), + ); + } + + // 카테고리 필터링 + if (query.category1) { + filtered = filtered.filter((item) => item.category1 === query.category1); + } + if (query.category2) { + filtered = filtered.filter((item) => item.category2 === query.category2); + } + + // 정렬 + if (query.sort) { + switch (query.sort) { + case "price_asc": + filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); + break; + case "price_desc": + filtered.sort((a, b) => parseInt(b.lprice) - parseInt(a.lprice)); + break; + case "name_asc": + filtered.sort((a, b) => a.title.localeCompare(b.title, "ko")); + break; + case "name_desc": + filtered.sort((a, b) => b.title.localeCompare(a.title, "ko")); + break; + default: + // 기본은 가격 낮은 순 + filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); + } + } + + return filtered; +} + +export function getProductsOnServer(query = {}) { + const page = parseInt(query.page ?? query.current) || 1; + const limit = parseInt(query.limit) || 20; + const search = query.search || ""; + const category1 = query.category1 || ""; + const category2 = query.category2 || ""; + const sort = query.sort || "price_asc"; + + // 필터링된 상품들 + const filteredProducts = filterProducts(items, { + search, + category1, + category2, + sort, + }); + + // 페이지네이션 + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const paginatedProducts = filteredProducts.slice(startIndex, endIndex); + + // 응답 데이터 + return { + products: paginatedProducts, + pagination: { + page, + limit, + total: filteredProducts.length, + totalPages: Math.ceil(filteredProducts.length / limit), + hasNext: endIndex < filteredProducts.length, + hasPrev: page > 1, + }, + filters: { + search, + category1, + category2, + sort, + }, + }; +} + +// 상품 상세 데이터를 가져오는 함수 +export function getProductDetailOnServer(productId) { + const product = items.find((item) => item.productId === productId); + + if (!product) { + return null; + } + + // 상세 정보에 추가 데이터 포함 + const detailProduct = { + ...product, + description: `${product.title}에 대한 상세 설명입니다. ${product.brand} 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.`, + rating: Math.floor(Math.random() * 2) + 4, // 4~5점 랜덤 + reviewCount: Math.floor(Math.random() * 1000) + 50, // 50~1050개 랜덤 + stock: Math.floor(Math.random() * 100) + 10, // 10~110개 랜덤 + }; + + // 관련 상품 찾기 (같은 카테고리) + const relatedProducts = items + .filter( + (item) => + item.productId !== productId && item.category1 === product.category1 && item.category2 === product.category2, + ) + .slice(0, 20); + + return { + product: detailProduct, + relatedProducts, + }; +} diff --git a/packages/vanilla/src/mocks/serverBrowser.js b/packages/vanilla/src/mocks/serverBrowser.js new file mode 100644 index 00000000..28b28250 --- /dev/null +++ b/packages/vanilla/src/mocks/serverBrowser.js @@ -0,0 +1,4 @@ +import { setupServer } from "msw/node"; +import { handlers } from "./handlers.js"; + +export const mswServer = setupServer(...handlers); diff --git a/packages/vanilla/src/pages/HomePage.js b/packages/vanilla/src/pages/HomePage.js index ca08c26c..5bcf5ee9 100644 --- a/packages/vanilla/src/pages/HomePage.js +++ b/packages/vanilla/src/pages/HomePage.js @@ -1,12 +1,32 @@ -import { ProductList, SearchBar } from "../components"; -import { productStore } from "../stores"; -import { router, withLifecycle } from "../router"; -import { loadProducts, loadProductsAndCategories } from "../services"; +import { ProductList, SearchBar } from "../components/index.js"; +import { productStore, PRODUCT_ACTIONS } from "../stores/index.js"; +import { router, withLifecycle } from "../router/index.js"; +import { loadProducts, loadProductsAndCategories } from "../services/index.js"; 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/pages/NotFoundPage.js b/packages/vanilla/src/pages/NotFoundPage.js index be69ed5f..f4a82a0d 100644 --- a/packages/vanilla/src/pages/NotFoundPage.js +++ b/packages/vanilla/src/pages/NotFoundPage.js @@ -1,5 +1,5 @@ -import { PageWrapper } from "./PageWrapper"; -import { Logo } from "../components"; +import { PageWrapper } from "./PageWrapper.js"; +import { Logo } from "../components/index.js"; export const NotFoundPage = () => PageWrapper({ diff --git a/packages/vanilla/src/pages/PageWrapper.js b/packages/vanilla/src/pages/PageWrapper.js index fc13328e..f1527b63 100644 --- a/packages/vanilla/src/pages/PageWrapper.js +++ b/packages/vanilla/src/pages/PageWrapper.js @@ -1,5 +1,5 @@ -import { cartStore, uiStore } from "../stores"; -import { CartModal, Footer, Toast } from "../components"; +import { cartStore, uiStore } from "../stores/index.js"; +import { CartModal, Footer, Toast } from "../components/index.js"; export const PageWrapper = ({ headerLeft, children }) => { const cart = cartStore.getState(); diff --git a/packages/vanilla/src/pages/ProductDetailPage.js b/packages/vanilla/src/pages/ProductDetailPage.js index 73d0ec30..effc6728 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 { productStore } from "../stores/index.js"; +import { loadProductDetailForPage } from "../services/index.js"; +import { router, withLifecycle } from "../router/index.js"; import { PageWrapper } from "./PageWrapper.js"; const loadingContent = ` @@ -237,7 +237,11 @@ function ProductDetail({ product, relatedProducts = [] }) { export const ProductDetailPage = withLifecycle( { onMount: () => { - loadProductDetailForPage(router.params.id); + // SSR에서 이미 데이터가 로드되었으면 추가 로딩하지 않음 + const state = productStore.getState(); + if (!state.currentProduct || state.currentProduct.productId !== router.params.id) { + loadProductDetailForPage(router.params.id); + } }, watches: [() => [router.params.id], () => loadProductDetailForPage(router.params.id)], }, @@ -247,7 +251,7 @@ export const ProductDetailPage = withLifecycle( return PageWrapper({ headerLeft: `
- +

상품 상세

+
+ `.trim(), + children: error ? ErrorContent({ error }) : ErrorContent({ error: "상품을 찾을 수 없습니다." }), + }); + } + + return PageWrapper({ + headerLeft: ` +
+ +

상품 상세

+
+ `.trim(), + children: ProductDetail({ product, relatedProducts }), + }); +}; diff --git a/packages/vanilla/src/pages/index.js b/packages/vanilla/src/pages/index.js index 1bf01f33..bb674eec 100644 --- a/packages/vanilla/src/pages/index.js +++ b/packages/vanilla/src/pages/index.js @@ -1,3 +1,3 @@ -export * from "./HomePage"; -export * from "./ProductDetailPage"; -export * from "./NotFoundPage"; +export * from "./HomePage.js"; +export * from "./ProductDetailPage.js"; +export * from "./NotFoundPage.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/router/router.js b/packages/vanilla/src/router/router.js index d897ee76..044edd36 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -1,5 +1,5 @@ // 글로벌 라우터 인스턴스 -import { Router } from "../lib"; +import { Router, ServerRouter } from "../lib/index.js"; import { BASE_URL } from "../constants.js"; -export const router = new Router(BASE_URL); +export const router = typeof window !== "undefined" ? new Router(BASE_URL) : new ServerRouter(BASE_URL); 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 8a12e8bd..fbb1ea9c 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 { getCategories, getProduct, getProducts } from "../api/productApi.js"; +import { initialProductState, productStore, PRODUCT_ACTIONS } from "../stores/index.js"; +import { router } from "../router/index.js"; export const loadProductsAndCategories = async () => { router.query = { current: undefined }; // 항상 첫 페이지로 초기화 diff --git a/packages/vanilla/src/storage/cartStorage.js b/packages/vanilla/src/storage/cartStorage.js index 7aa68383..b3bb3754 100644 --- a/packages/vanilla/src/storage/cartStorage.js +++ b/packages/vanilla/src/storage/cartStorage.js @@ -1,3 +1,6 @@ -import { createStorage } from "../lib"; +import { memoryStorage, createStorage } from "../lib/index.js"; -export const cartStorage = createStorage("shopping_cart"); +export const cartStorage = createStorage( + "shopping_cart", + typeof window === "undefined" ? memoryStorage() : window.localStorage, +); diff --git a/packages/vanilla/src/storage/index.js b/packages/vanilla/src/storage/index.js index 122983be..27d82b35 100644 --- a/packages/vanilla/src/storage/index.js +++ b/packages/vanilla/src/storage/index.js @@ -1 +1 @@ -export * from "./cartStorage"; +export * from "./cartStorage.js"; diff --git a/packages/vanilla/src/stores/cartStore.js b/packages/vanilla/src/stores/cartStore.js index fe61f167..cc95a3de 100644 --- a/packages/vanilla/src/stores/cartStore.js +++ b/packages/vanilla/src/stores/cartStore.js @@ -1,5 +1,5 @@ -import { createStore } from "../lib"; -import { CART_ACTIONS } from "./actionTypes"; +import { createStore } from "../lib/index.js"; +import { CART_ACTIONS } from "./actionTypes.js"; import { cartStorage } from "../storage/index.js"; /** diff --git a/packages/vanilla/src/stores/index.js b/packages/vanilla/src/stores/index.js index 36fefd54..2e2c7dda 100644 --- a/packages/vanilla/src/stores/index.js +++ b/packages/vanilla/src/stores/index.js @@ -1,4 +1,4 @@ -export * from "./actionTypes"; -export * from "./productStore"; -export * from "./cartStore"; -export * from "./uiStore"; +export * from "./actionTypes.js"; +export * from "./productStore.js"; +export * from "./cartStore.js"; +export * from "./uiStore.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 스토어 초기 상태 diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index c479f112..0389e650 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -1,19 +1,95 @@ import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; -const render = () => { - return `
안녕하세요
`; -}; +// ES 모듈에서 __dirname 대체 +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// 경로 상수 +const DIST_DIR = path.resolve(__dirname, "../../dist/vanilla"); +const SSR_DIR = path.resolve(__dirname, "src"); + +// 상품 데이터 가져오기 +async function mockGetProducts({ limit = 20 }) { + // 서버 모듈에서 상품 데이터 가져오기 + const serverModule = await import(path.join(SSR_DIR, "mocks/server.js")); + const { getProductsOnServer } = serverModule; + + const result = getProductsOnServer({ limit }); + return result.products; +} + +// 페이지 목록 생성 +async function getPages() { + const products = await mockGetProducts({ limit: 20 }); + + return [ + { url: "/", filePath: path.join(DIST_DIR, "index.html") }, + { url: "/404", filePath: path.join(DIST_DIR, "404.html") }, + ...products.map((p) => ({ + url: `/product/${p.productId}/`, + filePath: path.join(DIST_DIR, `product/${p.productId}/index.html`), + })), + ]; +} + +// HTML 파일 저장 +async function saveHtmlFile(filePath, html) { + // 디렉토리 생성 + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + // 파일 저장 + fs.writeFileSync(filePath, html, "utf-8"); +} async function generateStaticSite() { - // HTML 템플릿 읽기 - const template = fs.readFileSync("../../dist/vanilla/index.html", "utf-8"); + try { + console.log("SSG 시작..."); + + // 1. 템플릿 + SSR 모듈 로드 + const templatePath = path.join(__dirname, "index.html"); + const template = fs.readFileSync(templatePath, "utf-8"); + + // SSR 모듈 동적 import + const ssrModule = await import(path.join(SSR_DIR, "main-server.js")); + const { render } = ssrModule; + + // 2. 페이지 목록 생성 + const pages = await getPages(); + console.log(`${pages.length}개 페이지 생성 예정`); + + // 3. 각 페이지 렌더링 + 저장 + for (let i = 0; i < pages.length; i++) { + const page = pages[i]; + console.log(`[${i + 1}/${pages.length}] ${page.url} 렌더링 중...`); + + try { + // SSR 렌더링 + const result = await render(page.url); + + // HTML 템플릿에 데이터 삽입 + let html = template + .replace("", result.html) + .replace("", result.head || "") + .replace("", ``); - // 어플리케이션 렌더링하기 - const appHtml = render(); + // 파일 저장 + await saveHtmlFile(page.filePath, html); + console.log(`✓ ${page.url} 완료`); + } catch (error) { + console.error(`✗ ${page.url} 렌더링 실패:`, error.message); + } + } - // 결과 HTML 생성하기 - const result = template.replace("", appHtml); - fs.writeFileSync("../../dist/vanilla/index.html", result); + console.log("SSG 완료!"); + } catch (error) { + console.error("SSG 실패:", error); + process.exit(1); + } } // 실행 diff --git a/packages/vanilla/vite.config.js b/packages/vanilla/vite.config.js index 020b5c4a..121267dd 100644 --- a/packages/vanilla/vite.config.js +++ b/packages/vanilla/vite.config.js @@ -2,4 +2,13 @@ import { defineConfig } from "vite"; const base = process.env.NODE_ENV === "production" ? "/front_6th_chapter4-1/vanilla/" : ""; -export default defineConfig({ base }); +export default defineConfig({ + base, + build: { + rollupOptions: { + output: { + manualChunks: undefined, + }, + }, + }, +});