Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3b5b860
init: 커밋 컨벤션 추가
1lmean Dec 16, 2025
ba1a90d
chore(deps): nodemon 패키지 설치
1lmean Dec 16, 2025
13d8386
fix(lib): createStorage 서버 환경 대응
1lmean Dec 17, 2025
56ce4eb
feat(lib): ServerRouter 클래스 구현
1lmean Dec 17, 2025
0f401f9
feat(router): 환경별 라우터 인스턴스 분기 처리
1lmean Dec 17, 2025
1772414
refactor(lib): ServerRouter export 추가
1lmean Dec 17, 2025
245ebd2
refactor(stores): productReducer export 추가
1lmean Dec 17, 2025
30eae09
feat(server): Express SSR 서버 구현
1lmean Dec 17, 2025
e642fd5
feat(ssr): 서버 사이드 렌더링 로직 구현
1lmean Dec 17, 2025
866cb8c
feat(client): SSR hydration 구현
1lmean Dec 17, 2025
316d8bc
refactor(pages): HomePage SSR-safe 컴포넌트 분리
1lmean Dec 17, 2025
5eb652a
fix(pages): HomePage export 추가
1lmean Dec 17, 2025
832b0fa
refactor(pages): ProductDetailPage SSR-safe 컴포넌트 분리
1lmean Dec 17, 2025
16af40c
fix(ssr): SSR title 형식 수정 및 관련 상품 로직 개선
1lmean Dec 17, 2025
8c2756c
feat(ssg): 정적 사이트 생성(SSG) 구현
1lmean Dec 17, 2025
d1206d9
fix(ssg): JSON import에 type 속성 추가
1lmean Dec 17, 2025
033a5b3
fix:
1lmean Dec 18, 2025
e39d88a
feat(react): SSR 구현 및 서버 설정 추가
1lmean Dec 18, 2025
2ddffb9
refactor(react): SSR-safe 컴포넌트 및 유틸리티 수정
1lmean Dec 18, 2025
9f4e85e
feat(react): SSR 기본 구조 및 Hydration 구현
1lmean Dec 18, 2025
da5d467
feat(react): Context 기반 Hydration 불일치 해결
1lmean Dec 18, 2025
b9a97c6
refactor(react): cartStorage SSR-safe로 수정
1lmean Dec 18, 2025
70b6f02
fix(ssr): Router 클래스 서버 사이드 렌더링 환경 지원
1lmean Dec 18, 2025
441659f
fix(react): SSR 호환을 위해 hooks에 getServerSnapshot 추가
1lmean Dec 18, 2025
2b879c1
fix(react): React Hook 규칙 위반 수정
1lmean Dec 18, 2025
9429a1d
fix(react): SSR에서 쿼리 파라미터 처리 개선
1lmean Dec 18, 2025
3c6ca39
fix(react): SearchBar 기본값 처리 개선
1lmean Dec 18, 2025
b6b6111
chore(ci): GitHub Actions 배포 워크플로우 추가
1lmean Dec 18, 2025
8c2e08a
fix(ci): 배포 워크플로우 빌드 경로 수정
1lmean Dec 18, 2025
dfcce23
fix(ci): 배포 워크플로우 루트 index.html 추가
1lmean Dec 18, 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
401 changes: 401 additions & 0 deletions .github/COMMIT_CONVENTION.md

Large diffs are not rendered by default.

82 changes: 82 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: Deploy

on:
push:
branches: [main]

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v2
with:
version: 10

- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: pnpm

- name: Install deps
run: pnpm install

- name: Build
run: pnpm run build

# 루트 index.html 생성 (vanilla로 리다이렉트)
- name: Create root index.html
run: |
mkdir -p dist
cat > dist/index.html << 'EOF'
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>프로젝트 선택</title>
<style>
body { font-family: Arial, sans-serif; padding: 50px; text-align: center; }
h1 { margin-bottom: 30px; }
a { display: inline-block; margin: 10px; padding: 15px 30px; background: #007bff; color: white; text-decoration: none; border-radius: 5px; }
a:hover { background: #0056b3; }
</style>
</head>
<body>
<h1>프로젝트 선택</h1>
<a href="/front_7th_chapter4-1/vanilla/">Vanilla SSR</a>
<a href="/front_7th_chapter4-1/react/">React SSR</a>
</body>
</html>
EOF

# 404.html 생성 - SPA 라우팅 지원
- name: Create 404.html for SPA routing
run: |
cp dist/index.html dist/404.html
if [ -f dist/vanilla/index.html ]; then
cp dist/vanilla/index.html dist/vanilla/404.html
fi
if [ -f dist/react/index.html ]; then
cp dist/react/index.html dist/react/404.html
fi

- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: dist

deploy:
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}

steps:
- id: deployment
uses: actions/deploy-pages@v4
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@types/node": "^24.0.13",
"@vitest/coverage-v8": "latest",
"@vitest/ui": "latest",
"concurrently": "latest",
"eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
Expand All @@ -49,7 +50,6 @@
"typescript": "^5.8.3",
"typescript-eslint": "^8.36.0",
"vite": "npm:rolldown-vite@latest",
"vitest": "latest",
"concurrently": "latest"
"vitest": "latest"
}
}
82 changes: 58 additions & 24 deletions packages/lib/src/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,25 +25,33 @@ export class Router<Handler extends (...args: any[]) => any> {
this.#route = null;
this.#baseUrl = baseUrl.replace(/\/$/, "");

window.addEventListener("popstate", () => {
this.#route = this.#findRoute();
this.#observer.notify();
});

document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (!target?.closest("[data-link]")) {
return;
}
e.preventDefault();
const url = target.getAttribute("href") ?? target.closest("[data-link]")?.getAttribute("href");
if (url) {
this.push(url);
// 클라이언트에서만 이벤트 리스너 등록 (서버에서는 window/document가 없음)
if (typeof window !== "undefined") {
window.addEventListener("popstate", () => {
this.#route = this.#findRoute();
this.#observer.notify();
});

if (typeof document !== "undefined") {
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (!target?.closest("[data-link]")) {
return;
}
e.preventDefault();
const url = target.getAttribute("href") ?? target.closest("[data-link]")?.getAttribute("href");
if (url) {
this.push(url);
}
});
}
});
}
}

get query(): StringRecord {
if (typeof window === "undefined") {
return {};
}
return Router.parseQuery(window.location.search);
}

Expand Down Expand Up @@ -85,8 +93,16 @@ export class Router<Handler extends (...args: any[]) => any> {
});
}

#findRoute(url = window.location.pathname) {
const { pathname } = new URL(url, window.location.origin);
#findRoute(url?: string) {
// 서버에서는 url을 필수로 받아야 함
if (!url) {
if (typeof window === "undefined") {
return null;
}
url = window.location.pathname;
}
const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
const { pathname } = new URL(url, origin);
for (const [routePath, route] of this.#routes) {
const match = pathname.match(route.regex);
if (match) {
Expand All @@ -111,11 +127,14 @@ export class Router<Handler extends (...args: any[]) => any> {
// baseUrl이 없으면 자동으로 붙여줌
const fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url);

const prevFullUrl = `${window.location.pathname}${window.location.search}`;
// 클라이언트에서만 히스토리 업데이트 (서버에서는 window.history가 없음)
if (typeof window !== "undefined") {
const prevFullUrl = `${window.location.pathname}${window.location.search}`;

// 히스토리 업데이트
if (prevFullUrl !== fullUrl) {
window.history.pushState(null, "", fullUrl);
// 히스토리 업데이트
if (prevFullUrl !== fullUrl) {
window.history.pushState(null, "", fullUrl);
}
}

this.#route = this.#findRoute(fullUrl);
Expand All @@ -130,7 +149,14 @@ export class Router<Handler extends (...args: any[]) => any> {
this.#observer.notify();
}

static parseQuery = (search = window.location.search) => {
static parseQuery = (search?: string) => {
// 서버에서는 search 파라미터를 명시적으로 받아야 함
if (!search) {
if (typeof window === "undefined") {
return {};
}
search = window.location.search;
}
const params = new URLSearchParams(search);
const query: StringRecord = {};
for (const [key, value] of params) {
Expand All @@ -149,7 +175,7 @@ export class Router<Handler extends (...args: any[]) => any> {
return params.toString();
};

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

Expand All @@ -161,6 +187,14 @@ export class Router<Handler extends (...args: any[]) => any> {
});

const queryString = Router.stringifyQuery(updatedQuery);
return `${baseUrl}${window.location.pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`;
// 서버에서는 pathname을 파라미터로 받아야 함
if (!pathname) {
if (typeof window === "undefined") {
pathname = "/";
} else {
pathname = window.location.pathname;
}
}
return `${baseUrl}${pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`;
};
}
21 changes: 11 additions & 10 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,31 +28,32 @@
"prettier:write": "prettier --write ./src"
},
"dependencies": {
"@hanghae-plus/lib": "workspace:*",
"react": "latest",
"react-dom": "latest",
"@hanghae-plus/lib": "workspace:*"
"react-dom": "latest"
},
"devDependencies": {
"@babel/core": "latest",
"@babel/plugin-transform-react-jsx": "latest",
"@eslint/js": "^9.16.0",
"@types/node": "^24.0.13",
"@types/react": "latest",
"@types/react-dom": "latest",
"@types/use-sync-external-store": "latest",
"@vitejs/plugin-react": "latest",
"@types/node": "^24.0.13",
"compression": "^1.7.5",
"concurrently": "latest",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"express": "^5.1.0",
"msw": "^2.10.2",
"nodemon": "^3.1.11",
"prettier": "^3.4.2",
"typescript": "^5.8.3",
"vite": "npm:rolldown-vite@latest",
"express": "^5.1.0",
"compression": "^1.7.5",
"sirv": "^3.0.0",
"concurrently": "latest"
"typescript": "^5.8.3",
"vite": "npm:rolldown-vite@latest"
}
}
72 changes: 54 additions & 18 deletions packages/react/server.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,65 @@
import fs from "node:fs/promises";
import express from "express";
import { renderToString } from "react-dom/server";
import { createElement } from "react";

const prod = process.env.NODE_ENV === "production";
const port = process.env.PORT || 5174;
const base = process.env.BASE || (prod ? "/front_7th_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(),
);
let vite;
let templateHtml = "";

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;

templateHtml = await fs.readFile("./dist/react/index.html", "utf-8");

app.use(compression());
app.use(base, sirv("./dist/react", { extensions: [] }));
}

app.use("*all", async (req, res) => {
try {
const url = req.originalUrl.replace(base, "");

let template;
let render;

if (!prod) {
template = await fs.readFile("./index.html", "utf-8");
template = await vite.transformIndexHtml(url, template);
render = (await vite.ssrLoadModule("/src/main-server.tsx")).render;
} else {
template = templateHtml;
render = (await import("./dist/react-ssr/main-server.js")).render;
}

const { html: appHtml, head, state } = await render(url);

let html = template.replace(`<!--app-head-->`, head ?? "").replace(`<!--app-html-->`, appHtml ?? "");

// __INITIAL_DATA__ 스크립트를 </body> 앞에 주입
if (state) {
const stateScript = `<script>window.__INITIAL_DATA__ = ${JSON.stringify(state).replace(/</g, "\\u003c")}</script>`;
html = html.replace(`</body>`, `${stateScript}</body>`);
}

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
Expand Down
5 changes: 4 additions & 1 deletion packages/react/src/components/modal/ModalProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ export const ModalProvider = memo(({ children }: PropsWithChildren) => {
return (
<ModalContext value={contextValue}>
{children}
{content && createPortal(<Modal>{content}</Modal>, document.body)}
{content &&
typeof document !== "undefined" &&
document.body &&
createPortal(<Modal>{content}</Modal>, document.body)}
</ModalContext>
);
});
2 changes: 1 addition & 1 deletion packages/react/src/components/toast/ToastProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const ToastProvider = memo(({ children }: PropsWithChildren) => {
<ToastCommandContext value={commandValue}>
<ToastStateContext value={state}>
{children}
{visible && createPortal(<Toast />, document.body)}
{visible && typeof document !== "undefined" && document.body && createPortal(<Toast />, document.body)}
</ToastStateContext>
</ToastCommandContext>
);
Expand Down
14 changes: 9 additions & 5 deletions packages/react/src/entities/carts/hooks/useCartAddCommand.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { useCallback } from "react";
import { useToastCommand } from "../../../components";
import { useAutoCallback } from "@hanghae-plus/lib";
import type { Product } from "../../products";
import { addToCart } from "../cartUseCase";

export const useCartAddCommand = () => {
const toast = useToastCommand();
return useAutoCallback((product: Product, quantity = 1) => {
addToCart(product, quantity);
toast.show("장바구니에 추가되었습니다", "success");
});
// useAutoCallback 대신 useCallback 사용 (SSR 호환)
return useCallback(
(product: Product, quantity = 1) => {
addToCart(product, quantity);
toast.show("장바구니에 추가되었습니다", "success");
},
[toast],
);
};
Loading
Loading