Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
## 과제 체크포인트

### 배포 링크

<!--
배포 링크를 적어주세요
예시: https://<username>.github.io/front-6th-chapter4-1/
Expand Down Expand Up @@ -241,7 +241,7 @@ const pathPattern = path.replace(/:([^/]+)/g, (match, paramName) => {
SSR/SSG 구현과 관련된 구체적인 피드백을 요청해주세요.

구체적인 질문 예시:
- "packages/vanilla/src/main-server.js의 라우터 매개변수 추출 로직에서 정규식 패턴이 복잡한 URL에도 안정적으로 동작할지 검토 부탁드립니다."
- "packages/vanilla/src/entry-server.js의 라우터 매개변수 추출 로직에서 정규식 패턴이 복잡한 URL에도 안정적으로 동작할지 검토 부탁드립니다."
- "React SSR에서 서버와 클라이언트의 상태 동기화 로직이 대용량 데이터에서도 성능상 문제없을지 조언 부탁드립니다."
- "현재 구현한 SSG 빌드 과정이 상품 개수가 1000개 이상으로 늘어날 때도 효율적으로 동작할지, 최적화 방안이 있다면 제안해주세요."
- "TypeScript SSR 모듈의 타입 정의에서 놓친 부분이나 더 안전하게 개선할 수 있는 부분이 있는지 검토해주세요."
Expand Down
3 changes: 3 additions & 0 deletions e2e/createTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ export const createCSRTest = (baseUrl: string) => {

await page.goto(baseUrl);

// 컴포넌트 마운트와 API 호출이 시작될 때까지 잠깐 대기
await page.waitForTimeout(100);

// 로딩 상태 확인
await expect(page.locator("text=카테고리 로딩 중...")).toBeVisible();

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"lint:fix": "pnpm run -r lint:fix",
"tsc": "pnpm run -r tsc",
"prettier:write": "prettier --write ./packages/*/src",
"serve:test": "pnpm -F @hanghae-plus/shopping-vanilla serve:test & pnpm -F @hanghae-plus/shopping-react serve:test",
"serve:test": "concurrently \"pnpm -F @hanghae-plus/shopping-vanilla serve:test\" \"pnpm -F @hanghae-plus/shopping-react serve:test\"",
"test:unit": "pnpm -F @hanghae-plus/lib test",
"test:e2e": "playwright test",
"test:e2e:basic": "playwright test 'basic'",
Expand All @@ -38,6 +38,7 @@
"@types/node": "^24.0.13",
"@vitest/coverage-v8": "latest",
"@vitest/ui": "latest",
"concurrently": "^9.2.1",
"eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
Expand Down
16 changes: 9 additions & 7 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,20 @@
"type": "module",
"scripts": {
"dev": "vite --port 5175",
"dev:ssr": "PORT=5176 node server.js",
"build:client": "rm -rf ./dist/react && vite build --outDir ./dist/react && cp ./dist/react/index.html ./dist/react/404.html",
"build:client-for-ssg": "rm -rf ../../dist/react && vite build --outDir ../../dist/react",
"build:server": "vite build --outDir ./dist/react-ssr --ssr src/main-server.tsx",
"dev:ssr": "cross-env PORT=5176 node server.js",
"build:client": "rimraf ./dist/react && vite build --outDir ./dist/react && cp ./dist/react/index.html ./dist/react/404.html",
"build:client-for-ssg": "rimraf ../../dist/react && vite build --outDir ../../dist/react",
"build:server": "vite build --outDir ./dist/react-ssr --ssr src/entry-server.tsx",
"build:without-ssg": "pnpm run build:client && npm run build:server",
"build:ssg": "pnpm run build:client-for-ssg && node static-site-generate.js",
"build": "npm run build:client && npm run build:server && npm run build:ssg",
"preview:csr": "vite preview --outDir ./dist/react --port 4175",
"preview:csr-with-build": "pnpm run build:client && pnpm run preview:csr",
"preview:ssr": "PORT=4176 NODE_ENV=production node server.js",
"preview:ssr": "cross-env PORT=4176 NODE_ENV=production node server.js",
"preview:ssr-with-build": "pnpm run build:without-ssg && pnpm run preview:ssr",
"preview:ssg": "NODE_ENV=production vite preview --outDir ../../dist/react --port 4179",
"preview:ssg": "cross-env NODE_ENV=production vite preview --outDir ../../dist/react --port 4179",
"preview:ssg-with-build": "pnpm run build:ssg && pnpm run preview:ssg",
"serve:test": "pnpm run build:without-ssg && pnpm run build:ssg && (pnpm run dev & pnpm run dev:ssr & pnpm run preview:csr & pnpm run preview:ssr & pnpm run preview:ssg)",
"serve:test": "concurrently \"pnpm run dev\" \"pnpm run dev:ssr\" \"pnpm run preview:csr\" \"pnpm run preview:ssr\" \"pnpm run preview:ssg\"",
"tsc": "tsc --noEmit",
"lint:fix": "eslint --fix ./src",
"prettier:write": "prettier --write ./src"
Expand All @@ -33,6 +33,7 @@
},
"devDependencies": {
"@babel/core": "latest",
"cross-env": "^7.0.3",
"@babel/plugin-transform-react-jsx": "latest",
"@eslint/js": "^9.16.0",
"@types/react": "latest",
Expand All @@ -47,6 +48,7 @@
"eslint-plugin-prettier": "^5.2.1",
"msw": "^2.10.2",
"prettier": "^3.4.2",
"rimraf": "^6.0.1",
"typescript": "^5.8.3",
"vite": "npm:rolldown-vite@latest",
"express": "^5.1.0",
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/entities/carts/storage/cartStorage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createStorage } from "@hanghae-plus/lib";
import { getStorage } from "../../../utils/serverStorage";
import type { Cart } from "../types";

export const cartStorage = createStorage<{
items: Cart[];
selectedAll: boolean;
}>("shopping_cart");
}>("shopping_cart", getStorage());
3 changes: 3 additions & 0 deletions packages/react/src/entry-server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { render } from "./main-server";

export { render };
109 changes: 107 additions & 2 deletions packages/react/src/main-server.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,109 @@
import { renderToString } from "react-dom/server";
import { ServerRouter } from "./router/ServerRouter";
import { getProductById, getProductsOnServer, getRelatedProducts, getUniqueCategories } from "./mocks/server";
import { HomePage, NotFoundPage, ProductDetailPage } from "./pages";
import { productStore, PRODUCT_ACTIONS } from "./entities/products/productStore";

// 서버 라우터 인스턴스
const serverRouter = new ServerRouter();

/**
* 홈페이지 라우트 - 상품 목록과 카테고리 표시
*/
serverRouter.addRoute("/", () => {
const {
products,
pagination: { total: totalCount },
} = getProductsOnServer(serverRouter.query);
const categories = getUniqueCategories();
const results = { products, categories, totalCount };

// 스토어에 데이터 저장
productStore.dispatch({
type: PRODUCT_ACTIONS.SET_PRODUCTS,
payload: { products, totalCount },
});
productStore.dispatch({
type: PRODUCT_ACTIONS.SET_CATEGORIES,
payload: categories,
});

return {
initialData: results,
html: renderToString(<HomePage />),
head: "<title>쇼핑몰 - 홈</title>",
};
});

/**
* 상품 상세 페이지 라우트 - 상품 정보와 관련 상품 표시
*/
serverRouter.addRoute("/product/:id/", (params?: { id: string }) => {
const product = getProductById(params?.id || "");

// 존재하지 않는 상품인 경우
if (!product) {
return {
initialData: {},
html: renderToString(<NotFoundPage />),
head: "<title>페이지 없음</title>",
};
}

const relatedProducts = getRelatedProducts(product.category2, product.productId);

// 스토어에 데이터 저장
productStore.dispatch({
type: PRODUCT_ACTIONS.SET_CURRENT_PRODUCT,
payload: product,
});
productStore.dispatch({
type: PRODUCT_ACTIONS.SET_RELATED_PRODUCTS,
payload: relatedProducts,
});

return {
initialData: { product, relatedProducts },
html: renderToString(<ProductDetailPage />),
head: `<title>${product.title} - 쇼핑몰</title>`,
};
});

/**
* 404 페이지 - 모든 매칭되지 않은 경로
*/
serverRouter.addRoute(".*", () => ({
initialData: {},
html: renderToString(<NotFoundPage />),
head: "<title>페이지 없음</title>",
}));

/**
* SSR 메인 렌더 함수 - URL과 쿼리를 받아 페이지 렌더링
*/
export const render = async (url: string, query: Record<string, string>) => {
console.log({ url, query });
return "";
try {
// 라우터 설정 및 시작
serverRouter.setUrl(url, "http://localhost");
serverRouter.query = query;
serverRouter.start();

// 라우트 찾기 및 핸들러 실행
const routeInfo = serverRouter.findRoute(url);
if (!routeInfo) {
throw new Error(`Route not found for URL: ${url}`);
}
const result = await routeInfo.handler(routeInfo.params);
console.log("✅ SSR 완료");

return result;
} catch (error) {
console.error("❌ SSR 에러:", error);
// 에러 발생 시 기본 에러 페이지 반환
return {
head: "<title>에러</title>",
html: renderToString(<NotFoundPage />),
initialData: { error: (error as Error).message },
};
}
};
4 changes: 4 additions & 0 deletions packages/react/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { App } from "./App";
import { router } from "./router";
import { BASE_URL } from "./constants.ts";
import { createRoot } from "react-dom/client";
import { initializeStoresFromSSR } from "./utils/hydration";

const enableMocking = () =>
import("./mocks/browser").then(({ worker }) =>
Expand All @@ -14,6 +15,9 @@ const enableMocking = () =>
);

function main() {
// SSR 데이터로 스토어 초기화
initializeStoresFromSSR();

router.start();

const rootElement = document.getElementById("root")!;
Expand Down
138 changes: 138 additions & 0 deletions packages/react/src/mocks/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import items from "./items.json";
import type { StringRecord } from "../types.ts";
import type { Product } from "../entities";

// 카테고리 추출 함수
export function getUniqueCategories() {
const categories: Record<string, Record<string, string | StringRecord>> = {};

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;
}

// 상품을 ID로 가져오는 함수
export function getProductById(productId: string) {
const product = items.find((item) => item.productId === productId);

if (!product) {
return null;
}

// 상세 정보에 추가 데이터 포함
return {
...product,
description: `${product.title}에 대한 상세 설명입니다. ${product.brand} 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.`,
rating: Math.floor(Math.random() * 2) + 4, // 4~5점 랜덤
reviewCount: Math.floor(Math.random() * 1000) + 50, // 50~1050개 랜덤
stock: Math.floor(Math.random() * 100) + 10, // 10~110개 랜덤
images: [product.image, product.image.replace(".jpg", "_2.jpg"), product.image.replace(".jpg", "_3.jpg")],
};
}

// 관련 상품을 가져오는 함수 (같은 category2의 다른 상품들)
export function getRelatedProducts(category2: string, excludeProductId: string, limit = 5) {
if (!category2) return [];

return items
.filter((item) => item.category2 === category2 && item.productId !== excludeProductId)
.slice(0, limit)
.map((item) => ({
...item,
description: `${item.title}에 대한 상세 설명입니다. ${item.brand} 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.`,
rating: Math.floor(Math.random() * 2) + 4,
reviewCount: Math.floor(Math.random() * 1000) + 50,
stock: Math.floor(Math.random() * 100) + 10,
}));
}

// 상품 검색 및 필터링 함수
function filterProducts(products: Product[], query: Record<string, string>) {
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: Record<string, string> = {}) {
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,
},
};
}
Loading
Loading