Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
c2f3ead
chore: gitignore 추가
ldhldh07 Sep 2, 2025
2d98497
feat: express로 html 치환
ldhldh07 Sep 2, 2025
63010e8
feat: html 템플릿 처리해서 렌더하는 동작 개발
ldhldh07 Sep 3, 2025
045be47
feat: 서버 라우터 추가
ldhldh07 Sep 3, 2025
ef7b3fb
refactor: ssr 서버 파이프라인 리팩토링
ldhldh07 Sep 3, 2025
49a1ac5
feat: 메인서버에서 prefetch 및 라우팅 매칭해서 render 리턴
ldhldh07 Sep 3, 2025
e218fd4
feat: 서버 메모리 스토리지 추가
ldhldh07 Sep 3, 2025
e8287f1
feat: 하이드레이션
ldhldh07 Sep 3, 2025
7114760
feat: 첫화면에서 기본 정렬 적용
ldhldh07 Sep 3, 2025
33defc6
fix: 스크롤로 데이터 추가 이후 새로고침하면 중간 데이터만 나오는 이슈
ldhldh07 Sep 3, 2025
7858870
fix: 하이드레이션시 데이터 있으면 새롭게 로딩하지 않도록 설정
ldhldh07 Sep 3, 2025
c496809
fix: 첫 정렬 변경시 반영 안되는 이슈
ldhldh07 Sep 3, 2025
cebe3f0
fix: 모듈 import로 목 데이터 호출
ldhldh07 Sep 4, 2025
e610654
fix: 무한 로딩 이슈
ldhldh07 Sep 4, 2025
195c612
fix: 라우팅 매칭 로직 수정
ldhldh07 Sep 4, 2025
0074871
feat: 상세 입력후 뒤로가기시 리스트 안뜨는 이슈
ldhldh07 Sep 4, 2025
03ec4d4
refactor: 라우터 업데이트 로직 전체 라우팅에 추가
ldhldh07 Sep 4, 2025
1b27aed
feat: 모킹 api 로직 분리
ldhldh07 Sep 4, 2025
5e029d9
feat: getPages 정
ldhldh07 Sep 4, 2025
a96a518
env: 빌드시 static site 생성
ldhldh07 Sep 4, 2025
5559586
feat: main.js 수정
ldhldh07 Sep 4, 2025
5b26835
feat: 메모리 스토리지 추가
ldhldh07 Sep 4, 2025
a63d3f7
feat: useSyncExternalStore 서버 동작 추가
ldhldh07 Sep 4, 2025
88bbe8e
feat: 리액트용 서버 라우터 생성
ldhldh07 Sep 4, 2025
c462286
feat: 홈페이지 데이터 분리
ldhldh07 Sep 4, 2025
e64776d
feat: 라우터 매칭
ldhldh07 Sep 4, 2025
2db0b46
feat: 리액트 미들웨어 서버 개발
ldhldh07 Sep 4, 2025
03b4503
feat: 서버용 라우터 분리
ldhldh07 Sep 4, 2025
a4e79ac
feat: csr에서만 loadProductDetail
ldhldh07 Sep 4, 2025
f48fda8
feat: 초기 데이터 스토어 저장
ldhldh07 Sep 4, 2025
37cbc47
feat: 메타데이터 수정
ldhldh07 Sep 4, 2025
129ecb5
feat: 서버 메모리 스토리지 정의
ldhldh07 Sep 4, 2025
3027e4e
feat: 하이드레이트
ldhldh07 Sep 4, 2025
5cdb8f1
feat: 무한 로딩 수정
ldhldh07 Sep 4, 2025
48009a9
fix: 기본 데이터 로드 안되는 이슈
ldhldh07 Sep 4, 2025
aeb5c97
faet: url 안정화
ldhldh07 Sep 4, 2025
c9e60b5
feat: 쿼리 상태 ui반영
ldhldh07 Sep 4, 2025
ddf4f05
feat: 카테고리 변경시 스토어 반영
ldhldh07 Sep 4, 2025
e8ee647
fix: 검색어 필터 조건 복원 안되는 이슈
ldhldh07 Sep 4, 2025
f11e2c3
fix: 상세 페이지 undefined 처리
ldhldh07 Sep 4, 2025
0a2b141
fix: 하이드레이션 안되는 이슈
ldhldh07 Sep 4, 2025
c9e518e
feat: 동적 메타 태그
ldhldh07 Sep 4, 2025
381d719
feat: ssg 정의
ldhldh07 Sep 4, 2025
282c133
env: 배포시 dist/react 배포
ldhldh07 Sep 4, 2025
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ dist-ssr
/test-results/
/playwright-report/
/coverage/


docs/
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "npx playwright show-report",
"prepare": "husky",
"gh-pages": "pnpm run build && gh-pages -d dist"
"gh-pages": "pnpm run build && gh-pages -d dist/react"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
Expand Down
30 changes: 21 additions & 9 deletions packages/lib/src/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ export class Router<Handler extends (...args: any[]) => any> {
return;
}
e.preventDefault();
const url = target.getAttribute("href") ?? target.closest("[data-link]")?.getAttribute("href");
if (url) {
const raw = target.getAttribute("href") ?? target.closest("[data-link]")?.getAttribute("href");
if (raw) {
// baseUrl을 고려하여 절대 경로로 정규화
const url = raw.startsWith(this.#baseUrl) ? raw : this.#baseUrl + (raw.startsWith("/") ? raw : "/" + raw);
this.push(url);
}
});
Expand Down Expand Up @@ -76,7 +78,7 @@ export class Router<Handler extends (...args: any[]) => any> {
})
.replace(/\//g, "\\/");

const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`);
const regex = new RegExp(`^${regexPath}$`);

this.#routes.set(path, {
regex,
Expand All @@ -87,8 +89,11 @@ export class Router<Handler extends (...args: any[]) => any> {

#findRoute(url = window.location.pathname) {
const { pathname } = new URL(url, window.location.origin);
// baseUrl 제거 후 매칭
const withoutBase = pathname.startsWith(this.#baseUrl) ? pathname.slice(this.#baseUrl.length) : pathname;
const normalized = withoutBase === "" ? "/" : withoutBase;
for (const [routePath, route] of this.#routes) {
const match = pathname.match(route.regex);
const match = normalized.match(route.regex);
if (match) {
// 매치된 파라미터들을 객체로 변환
const params: StringRecord = {};
Expand Down Expand Up @@ -151,16 +156,23 @@ export class Router<Handler extends (...args: any[]) => any> {

static getUrl = (newQuery: QueryPayload, baseUrl = "") => {
const currentQuery = Router.parseQuery();
const updatedQuery = { ...currentQuery, ...newQuery };
const updatedQuery: Record<string, unknown> = { ...currentQuery, ...newQuery };

// 빈 값들 제거
// 빈 제거
Object.keys(updatedQuery).forEach((key) => {
if (updatedQuery[key] === null || updatedQuery[key] === undefined || updatedQuery[key] === "") {
const value = updatedQuery[key];
if (value === null || value === undefined || value === "") {
delete updatedQuery[key];
}
});

const queryString = Router.stringifyQuery(updatedQuery);
return `${baseUrl}${window.location.pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`;
const queryString = Router.stringifyQuery(updatedQuery as QueryPayload);

// 경로 정규화: baseUrl 제거 후 항상 슬래시로 시작, 빈 경우 "/"
const pathname = window.location.pathname;
const withoutBase = pathname.startsWith(baseUrl) ? pathname.slice(baseUrl.length) : pathname;
const normalizedPath = withoutBase === "" ? "/" : withoutBase.startsWith("/") ? withoutBase : `/${withoutBase}`;

return `${baseUrl}${normalizedPath}${queryString ? `?${queryString}` : ""}`;
};
}
29 changes: 28 additions & 1 deletion packages/lib/src/createStorage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
import { createObserver } from "./createObserver.ts";

export const createStorage = <T>(key: string, storage = window.localStorage) => {
const createMemoryStorage = (): Storage => {
const store = new Map<string, string>();
return {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(key, value);
},
} as Storage;
};

export const createStorage = <T>(
key: string,
storage: Storage = typeof window !== "undefined" ? window.localStorage : createMemoryStorage(),
) => {
let data: T | null = JSON.parse(storage.getItem(key) ?? "null");
const { subscribe, notify } = createObserver();

Expand Down
8 changes: 6 additions & 2 deletions packages/lib/src/hooks/useRouter.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { useSyncExternalStore } from "react";
import type { RouterInstance } from "../Router";
import type { AnyFunction } from "../types";
import { useSyncExternalStore } from "react";
import { useShallowSelector } from "./useShallowSelector";

const defaultSelector = <T, S = T>(state: T) => state as unknown as S;

export const useRouter = <T extends RouterInstance<AnyFunction>, S>(router: T, selector = defaultSelector<T, S>) => {
const shallowSelector = useShallowSelector(selector);
return useSyncExternalStore(router.subscribe, () => shallowSelector(router));
return useSyncExternalStore(
router.subscribe,
() => shallowSelector(router),
() => shallowSelector(router),
);
};
2 changes: 1 addition & 1 deletion packages/lib/src/hooks/useStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ import type { createStorage } from "../createStorage";
type Storage<T> = ReturnType<typeof createStorage<T>>;

export const useStorage = <T>(storage: Storage<T>) => {
return useSyncExternalStore(storage.subscribe, storage.get);
return useSyncExternalStore(storage.subscribe, storage.get, storage.get);
};
8 changes: 6 additions & 2 deletions packages/lib/src/hooks/useStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { createStore } from "../createStore";
import { useSyncExternalStore } from "react";
import type { createStore } from "../createStore";
import { useShallowSelector } from "./useShallowSelector";

type Store<T> = ReturnType<typeof createStore<T>>;
Expand All @@ -8,5 +8,9 @@ const defaultSelector = <T, S = T>(state: T) => state as unknown as S;

export const useStore = <T, S = T>(store: Store<T>, selector: (state: T) => S = defaultSelector<T, S>) => {
const shallowSelector = useShallowSelector(selector);
return useSyncExternalStore(store.subscribe, () => shallowSelector(store.getState()));
return useSyncExternalStore(
store.subscribe,
() => shallowSelector(store.getState()),
() => shallowSelector(store.getState()),
);
};
82 changes: 64 additions & 18 deletions packages/react/server.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,77 @@
import express from "express";
import { renderToString } from "react-dom/server";
import { createElement } from "react";
import fs from "fs";

const prod = process.env.NODE_ENV === "production";
const port = process.env.PORT || 5174;
const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/react/" : "/");

const app = express();

app.get("*all", (req, res) => {
res.send(
`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React SSR</title>
</head>
<body>
<div id="app">${renderToString(createElement("div", null, "안녕하세요"))}</div>
</body>
</html>
`.trim(),
);
const setupMiddlewares = async () => {
if (!prod) {
const { createServer } = await import("vite");
const viteServer = await createServer({
server: { middlewareMode: true },
appType: "custom",
base,
});
app.use(viteServer.middlewares);
return viteServer;
}
const compression = (await import("compression")).default;
app.use(compression());
return null;
};

const viteServer = await setupMiddlewares();

const templateHtml = prod ? fs.readFileSync("./dist/react/index.html", "utf8") : "";

const get = {
template: async (viteServer, url) => {
if (prod) return templateHtml;

const rawHtml = fs.readFileSync("./index.html", "utf-8");
const transformedHtml = await viteServer.transformIndexHtml(url, rawHtml);
return transformedHtml;
},
render: async (viteServer) => {
if (prod) return (await import("./dist/react-ssr/main-server.js")).render;
return (await viteServer.ssrLoadModule("/src/main-server.tsx")).render;
},
};

app.use(async (request, response, next) => {
try {
const accept = request.headers.accept || "";
if (!String(accept).includes("text/html")) return next();

const url = request.originalUrl.replace(base, "");

const template = await get.template(viteServer, url);
const render = await get.render(viteServer);
const { head, html, initialData, status = 200 } = await render(url, request.query ?? {});

const initialDataScript = initialData
? `<script>window.__INITIAL_DATA__=${JSON.stringify(initialData).replace(/</g, "\\u003c")}</script>`
: "";

const finalHtml = template
.replace(`<!--app-head-->`, head ?? "")
.replace(`<!--app-html-->`, html ?? "")
.replace("</head>", `${initialDataScript}</head>`);

response.status(status).set({ "Content-Type": "text/html" }).send(finalHtml);
} catch (error) {
response.status(500).end(error.stack);
}
});

if (prod) {
const sirv = (await import("sirv")).default;
app.use(base, sirv("./dist/react", { extensions: [] }));
}

// Start http server
app.listen(port, () => {
console.log(`React Server started at http://localhost:${port}`);
Expand Down
16 changes: 16 additions & 0 deletions packages/react/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Categories, Product } from "../entities";
import type { StringRecord } from "../types";
import * as mock from "./mockApi";
import * as real from "./productApi";

const isSSR = typeof window === "undefined";
const isDev = typeof import.meta !== "undefined" && import.meta.env && import.meta.env.DEV;
const useMock = isSSR || !isDev;

export const getProducts = (params: StringRecord = {}) =>
useMock ? mock.getProducts(params) : real.getProducts(params);

export const getProduct = (productId: string): Promise<Product> =>
useMock ? mock.getProduct(productId) : real.getProduct(productId);

export const getCategories = (): Promise<Categories> => (useMock ? mock.getCategories() : real.getCategories());
86 changes: 86 additions & 0 deletions packages/react/src/api/mockApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { Categories, Product } from "../entities";
import type { StringRecord } from "../types";
import items from "../mocks/items.json";

function getUniqueCategories(): Categories {
const categories: Record<string, Record<string, string | StringRecord>> = {};
(items as unknown as Product[]).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: 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);

switch (query.sort) {
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;
case "price_asc":
default:
filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice));
}
return filtered;
}

const delay = (ms = 50) => new Promise((r) => setTimeout(r, ms));

export async function getProducts(params: StringRecord = {}) {
const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params;
const page = Number(params.current ?? params.page ?? 1);
const filtered = filterProducts(items as unknown as Product[], { search, category1, category2, sort });
const start = (page - 1) * Number(limit);
const end = start + Number(limit);
const products = filtered.slice(start, end);
await delay();
return {
products,
pagination: {
page,
limit: Number(limit),
total: filtered.length,
totalPages: Math.ceil(filtered.length / Number(limit)),
hasNext: end < filtered.length,
hasPrev: page > 1,
},
filters: { search, category1, category2, sort },
};
}

export async function getProduct(productId: string): Promise<Product> {
await delay();
const product = (items as unknown as Product[]).find((i) => i.productId === productId);
if (!product) throw new Error("Product not found");
return {
...product,
description: `${product.title}에 대한 상세 설명입니다. ${product.brand} 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.`,
rating: Math.floor(Math.random() * 2) + 4,
reviewCount: Math.floor(Math.random() * 1000) + 50,
stock: Math.floor(Math.random() * 100) + 10,
images: [product.image, product.image.replace(".jpg", "_2.jpg"), product.image.replace(".jpg", "_3.jpg")],
} as Product;
}

export async function getCategories(): Promise<Categories> {
await delay();
return getUniqueCategories();
}
7 changes: 4 additions & 3 deletions packages/react/src/api/productApi.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// 상품 목록 조회
import type { Categories, Product } from "../entities";
import type { StringRecord } from "../types.ts";
import { BASE_URL } from "../constants.ts";

interface ProductsResponse {
products: Product[];
Expand Down Expand Up @@ -33,19 +34,19 @@ export async function getProducts(params: StringRecord = {}): Promise<ProductsRe
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: string): Promise<Product> {
const response = await fetch(`/api/products/${productId}`);
const response = await fetch(`${BASE_URL}api/products/${productId}`);
return await response.json();
}

// 카테고리 목록 조회
export async function getCategories(): Promise<Categories> {
const response = await fetch("/api/categories");
const response = await fetch(`${BASE_URL}api/categories`);
return await response.json();
}
Loading