Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
81c392e
Feature : pnpm install (express, sirv, compression
tooth-is-silver Sep 4, 2025
ddb5935
Feature : 라우터 세팅
tooth-is-silver Sep 4, 2025
968301f
Feature : SSG 서버 라우터 addRoute 로직 추가
tooth-is-silver Sep 4, 2025
ba66a74
Feature : SSG 서버라우터 findRoute 로직 추가
tooth-is-silver Sep 4, 2025
c2f596d
Feature : SSG 서버 라우터 getAllRoutes, parseQuery 로직 추가
tooth-is-silver Sep 4, 2025
2cf6afb
Feature : main-server.js에 서버용 상품 목록 조회 함수 추가
tooth-is-silver Sep 4, 2025
6674650
Feature : main-server.js에 서버용 데이터 조회 함수 추가
tooth-is-silver Sep 4, 2025
dda7038
Feature : main-server.js에 render함수에 라우트 등록 로직 추가
tooth-is-silver Sep 4, 2025
4c4b028
Feature : ssg main-server.js 데이터 프리패칭 함수 추가
tooth-is-silver Sep 4, 2025
bdeb65e
Feature : ssg server.js에 실행 환경 분기처리
tooth-is-silver Sep 4, 2025
ceed94a
Feature : ssg server.js에 렌더링 파이프라인 로직 추가
tooth-is-silver Sep 4, 2025
460e6be
Feature : ssg static-site-generate.js에 gePages, saveHtmlFile, generat…
tooth-is-silver Sep 4, 2025
a41b753
Feature : ssg main.js에 __INITIAL_DATA__세팅 로직 추가
tooth-is-silver Sep 4, 2025
9d1432e
Feature : serverRouter import 방식 변경
tooth-is-silver Sep 4, 2025
96844ef
Feature : msw 서버 세팅
tooth-is-silver Sep 4, 2025
8e99e69
Feature : ssg main-server.js에 productApi 적용
tooth-is-silver Sep 4, 2025
2b59c70
Feature : ssg main-server에서 api로직 제거하고 serverRouter에 api 연결
tooth-is-silver Sep 4, 2025
7562dd4
Feature : 샘플코드로 수정
tooth-is-silver Sep 4, 2025
e96cc8b
Feature : ssg 샘플 코드 적용
tooth-is-silver Sep 4, 2025
711f087
Feature : server.js 설명과 함께 수정
tooth-is-silver Sep 4, 2025
993f886
Feature : ssg server.js 로직 추가
tooth-is-silver Sep 4, 2025
e236f3a
Feature : ssg ServerRouter.js에 addRoute 로직 추가
tooth-is-silver Sep 4, 2025
b47bf48
Feature : ssg ServerRouter.js에 findRoute 로직 추가
tooth-is-silver Sep 4, 2025
4a2b69d
Feature : ssg ServerRouter.js에 push, start 로직 추가
tooth-is-silver Sep 4, 2025
340221d
Feature : ssg ServerRouter.js에 static 메서드 추가
tooth-is-silver Sep 4, 2025
04de40d
Feature : ssg main-server.js render 함수 추가
tooth-is-silver Sep 4, 2025
9a579f3
Feature : ssg Hompage.js에 ssg 서버 조건부 로직 추가
tooth-is-silver Sep 4, 2025
1aff91f
Feature : ssg ProductDetailPage.js에 ssg 서버 조건부 로직 추가
tooth-is-silver Sep 4, 2025
4f70709
Feature : ssg withLifecycle.js window 조건분기 추가
tooth-is-silver Sep 4, 2025
0d2a103
Feature : ssg productApi.js baseUrl 로직 수정
tooth-is-silver Sep 4, 2025
7f9426a
Feature : ssg createStorage.js 서버 메모리 스토리지 로직 추가
tooth-is-silver Sep 4, 2025
de45eac
Feature : ssg productApi.js에 설명 추가
tooth-is-silver Sep 4, 2025
cbba6b1
Feature : handlers.js 오류 수정
tooth-is-silver Sep 4, 2025
586c377
Feature : 기본 SPA 라우터 수정
tooth-is-silver Sep 4, 2025
d3f98ae
Fix : 실행 에러 수정
tooth-is-silver Sep 4, 2025
d42b1c4
Remove : 사용하지 않는 파일 삭제
tooth-is-silver Sep 4, 2025
54272ae
Feauture : static-site-generate.js 로직 수정
tooth-is-silver Sep 4, 2025
196a02f
Fix : build 오류 수정
tooth-is-silver Sep 4, 2025
2b47aff
Feature : html 수정
tooth-is-silver Sep 4, 2025
78775ae
Refactor : 코드 리팩토링
tooth-is-silver Sep 4, 2025
af52bc1
Remove : 필요없는 dependencies 삭제
tooth-is-silver Sep 4, 2025
7b9d0b2
Remove: 필요없는 파일 삭제
tooth-is-silver Sep 4, 2025
20f9ebd
Chore : pnpm-lock.yaml 파일 원복
tooth-is-silver Sep 4, 2025
713e0e9
Fix : build 오류 수정
tooth-is-silver Sep 4, 2025
6570937
Fix : 데이터 못가져오는 이슈 수정
tooth-is-silver Sep 4, 2025
cd668da
Feature : ssg 하이드레이션 오류 수정(테스트 코드 통과)
tooth-is-silver 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
15 changes: 8 additions & 7 deletions packages/vanilla/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,19 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
<!--app-head-->
<link rel="stylesheet" href="/src/styles.css">
<!--app-data-->
<link rel="stylesheet" href="/src/styles.css" />
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#3b82f6',
secondary: '#6b7280'
}
}
}
}
primary: "#3b82f6",
secondary: "#6b7280",
},
},
},
};
</script>
</head>
<body class="bg-gray-50">
Expand Down
94 changes: 69 additions & 25 deletions packages/vanilla/server.js
Original file line number Diff line number Diff line change
@@ -1,34 +1,78 @@
import compression from "compression";
import express from "express";
import fs from "fs/promises";
import sirv from "sirv";
import { mswServer } from "./src/mocks/mswServer.js";

const prod = process.env.NODE_ENV === "production";
// 환경 변수 및 설정
const isProd = process.env.NODE_ENV === "production";
const port = process.env.PORT || 5173;
const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/");

const baseUrl = process.env.BASE || (isProd ? "/front_6th_chapter4-1/vanilla/" : "/");
const templateHtml = isProd ? await fs.readFile("dist/vanilla/index.html", "utf-8") : "";
const app = express();

const render = () => {
return `<div>안녕하세요</div>`;
};

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>Vanilla Javascript SSR</title>
</head>
<body>
<div id="app">${render()}</div>
</body>
</html>
`.trim(),
);
let vite;

// MSW 서버 시작 (API 모킹을 위해)
mswServer.listen({
onUnhandledRequest: "bypass",
});

// 환경별 정적 파일 서빙 및 미들웨어 설정
if (isProd) {
// 프로덕션 환경: 빌드된 정적 파일 서빙 (assets만 서빙하도록 제한)
app.use(compression());
app.use(`${baseUrl}assets`, sirv("dist/vanilla/assets", { dev: false }));
app.use(`${baseUrl}mockServiceWorker.js`, sirv("dist/vanilla", { dev: false }));
} else {
// 개발 환경: Vite 개발 서버를 미들웨어로 사용
const { createServer } = await import("vite");
vite = await createServer({
server: { middlewareMode: true },
appType: "custom",
baseUrl,
});

app.use(vite.middlewares);
}

// 모든 라우트를 처리하는 SSR 핸들러
app.get(/^(?!.*\/api).*/, async (req, res) => {
try {
let template;
let render;

if (!isProd) {
// 개발 환경: 매 요청마다 템플릿을 다시 읽고 변환
template = await fs.readFile("./index.html", "utf-8");
template = await vite.transformIndexHtml(req.originalUrl, template);
render = (await vite.ssrLoadModule("./src/main-server.js")).render;
} else {
// 프로덕션 환경: 미리 로드된 템플릿과 빌드된 모듈 사용
template = templateHtml;
render = (await import("./dist/vanilla-ssr/main-server.js")).render;
}

// SSR 렌더링 실행
const rendered = await render(req.originalUrl, req.query);

// HTML 템플릿에 렌더링된 내용 삽입
const html = template
.replace(`<!--app-head-->`, rendered.head ?? "")
.replace(`<!--app-data-->`, `<script>window.__INITIAL_DATA__ = ${rendered.data}</script>`)
.replace(`<!--app-html-->`, rendered.html ?? "");

// 클라이언트에 완성된 HTML 응답
res.status(200).set({ "Content-Type": "text/html" }).send(html);
} catch (error) {
console.error(error);
res.status(500).send("Internal Server Error");
}
});

// Start http server
// HTTP 서버 시작
app.listen(port, () => {
console.log(`React Server started at http://localhost:${port}`);
console.log(`🚀 Server started at http://localhost:${port}`);
console.log(`📁 Environment: ${isProd ? "production" : "development"}`);
console.log(`📍 Base URL: ${baseUrl}`);
});
45 changes: 41 additions & 4 deletions packages/vanilla/src/api/productApi.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,33 @@
// API 기본 URL을 환경별로 동적 설정
const getBaseUrl = () => {
// 클라이언트 환경: 상대 경로 사용 (같은 도메인의 API 호출)
if (typeof window !== "undefined") {
return ""; // 브라우저에서는 빈 문자열로 상대 경로 사용
}

// 서버 환경: 절대 URL 필요 (서버에서 서버로 호출)
const prod = process.env.NODE_ENV === "production";
return prod ? "http://localhost:4174" : "http://localhost:5174"; // 환경별 포트 설정
};

const BASE_URL = getBaseUrl(); // 런타임에 환경에 맞는 BASE_URL 결정

/**
* 상품 목록 조회 API - 검색, 필터링, 페이징 지원
* @param {Object} params - 쿼리 파라미터 객체
* @param {number} params.limit - 페이지당 상품 수 (기본값: 20)
* @param {string} params.search - 검색 키워드
* @param {string} params.category1 - 1차 카테고리 필터
* @param {string} params.category2 - 2차 카테고리 필터
* @param {string} params.sort - 정렬 방식 (기본값: "price_asc")
* @param {number} params.current|params.page - 현재 페이지 번호
* @returns {Promise<Object>} {products: Array, pagination: Object} 형태의 응답
*/
export async function getProducts(params = {}) {
const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params;
const page = params.current ?? params.page ?? 1;

// URL 쿼리 파라미터 구성 (빈 값들은 자동으로 제외됨)
const searchParams = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
Expand All @@ -11,17 +37,28 @@ export async function getProducts(params = {}) {
sort,
});

const response = await fetch(`/api/products?${searchParams}`);

// API 호출 및 JSON 응답 파싱
const response = await fetch(`${BASE_URL}/api/products?${searchParams}`);
return await response.json();
}

/**
* 특정 상품의 상세 정보 조회
* @param {string} productId - 조회할 상품의 고유 ID
* @returns {Promise<Object>} 상품 상세 정보 객체
*/
export async function getProduct(productId) {
const response = await fetch(`/api/products/${productId}`);
// RESTful API 패턴: GET /api/products/{id}
const response = await fetch(`${BASE_URL}/api/products/${productId}`);
return await response.json();
}

/**
* 전체 카테고리 목록 조회 (1차, 2차 카테고리 포함)
* @returns {Promise<Object>} 카테고리 트리 구조 객체
*/
export async function getCategories() {
const response = await fetch("/api/categories");
// 카테고리는 자주 변경되지 않는 마스터 데이터
const response = await fetch(`${BASE_URL}/api/categories`);
return await response.json();
}
185 changes: 185 additions & 0 deletions packages/vanilla/src/lib/ServerRouter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* 서버사이드 라우터 - window 객체가 없는 SSR 환경에서 사용
*/
export class ServerRouter {
#routes;
#route;
#baseUrl;
#currentQuery = {};

// 모든 라우트 설정 초기화 (라우트 저장소, 활성 라우트, url)
constructor(baseUrl = "") {
this.#routes = new Map();
this.#route = null;
this.#baseUrl = baseUrl.replace(/\/$/, "");
}

// 현재 설정된 쿼리 파라미터를 반환 (서버 환경에서는 직접 관리)
get query() {
return this.#currentQuery;
}

// 새 쿼리 파라미터로 URL 생성하고 라우팅 업데이트
set query(newQuery) {
const newUrl = ServerRouter.getUrl(newQuery, this.#baseUrl);
this.push(newUrl);
}

// 현재 라우트의 경로 파라미터 반환 (예: /product/:id에서 {id: "123"})
get params() {
return this.#route?.params ?? {};
}

// 현재 매칭된 라우트 정보 반환
get route() {
return this.#route;
}

// 현재 라우트의 핸들러 함수 반환
get target() {
return this.#route?.handler;
}

/**
* 서버사이드 네비게이션 실행 - URL 변경 시 호출
* (브라우저와 달리 히스토리 API 사용하지 않고 내부 상태만 업데이트)
* @param {string} url - 이동할 경로 (기본값: "/")
*/
push(url = "/") {
try {
// 주어진 URL에 매칭되는 라우트를 찾아서 현재 라우트로 설정
this.#route = this.#findRoute(url);
} catch (error) {
console.error("서버 네비게이션 오류:", error);
}
}

/**
* 서버 라우터 초기화 및 시작 - SSR 렌더링 시작점에서 호출
* 쿼리 파라미터를 설정하고 url에 매칭되는 라우트 적용
* @param {string} url - 초기 URL 경로 (기본값: "/")
* @param {object} query - 초기 쿼리 파라미터 객체 (기본값: {})
*/
start(url = "/", query = {}) {
this.#currentQuery = query;
this.#route = this.#findRoute(url);
}

/**
* 라우트 등록 - URL 패턴과 핸들러 함수를 매핑
* @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, "\\/");

// baseUrl과 regexPath 사이의 중복 슬래시 제거
const normalizedPath = `${this.#baseUrl}${regexPath}`.replace(/\/+/g, "/");
const regex = new RegExp(`^${normalizedPath}$`);

// 라우트 정보를 Map에 저장
this.#routes.set(path, {
regex,
paramNames,
handler,
});
}

/**
* 주어진 URL과 매칭되는 라우트를 찾아서 반환
* @param {string} url - 매칭할 URL (기본값: "/")
* @param {string} origin - 서버 도메인 (기본값: "http://localhost")
* @returns {Object|null} 매칭된 라우트 정보 또는 null
*/
#findRoute(url = "/", origin = "http://localhost") {
// URL 객체를 생성해서 pathname 추출 (쿼리스트링, 해시 제외)
const { pathname } = new URL(url, origin);

// 등록된 모든 라우트를 순회하며 매칭 확인
for (const [routePath, route] of this.#routes) {
const match = pathname.match(route.regex); // 정규식으로 URL 패턴 매칭

if (match) {
// 매칭된 동적 파라미터들을 객체로 변환
// 예: /product/123 → {id: "123"}
const params = {};
route.paramNames.forEach((name, index) => {
params[name] = match[index + 1];
});

// 라우트 정보와 추출된 파라미터를 함께 반환
return {
...route,
params,
path: routePath,
};
}
}

return null;
}

/**
* 쿼리 파라미터 문자열을 객체로 파싱
* @param {string} search - location.search 또는 쿼리 문자열 (예: "?page=1&limit=20")
* @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 - 쿼리 객체 (예: {page: 1, search: "test"})
* @returns {string} 쿼리 문자열 ("page=1&search=test" 형태)
*/
static stringifyQuery = (query) => {
const params = new URLSearchParams();

for (const [key, value] of Object.entries(query)) {
// null, undefined, 빈 문자열이 아닌 값들만 추가
if (value !== null && value !== undefined && value !== "") {
params.set(key, String(value));
}
}
return params.toString();
};

/**
* 새로운 쿼리 파라미터와 기존 쿼리를 병합하여 완전한 URL 생성
* @param {Object} newQuery - 새로 추가할 쿼리 객체
* @param {string} pathname - 경로
* @param {string} baseUrl - 베이스 URL
* @returns {string} 완성된 URL (예: "/products?page=2&search=test")
*/
static getUrl = (newQuery, pathname = "/", baseUrl = "") => {
// 현재 쿼리 파라미터 가져오기
const currentQuery = ServerRouter.parseQuery();
// 기존 쿼리에 새 쿼리 병합
const updatedQuery = { ...currentQuery, ...newQuery };

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

const queryString = ServerRouter.stringifyQuery(updatedQuery);
// 최종 URL 조합: baseUrl + pathname + queryString
return `${baseUrl}${pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`;
};
}
14 changes: 14 additions & 0 deletions packages/vanilla/src/lib/createServerStorage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* 서버 환경용 메모리 스토리지 - localStorage API와 동일한 인터페이스 제공
* @returns {Object} localStorage와 호환되는 메서드를 가진 객체
*/
export const createServerStorage = () => {
const storage = new Map();

return {
getItem: (key) => storage.get(key),
setItem: (key, value) => storage.set(key, value),
removeItem: (key) => storage.delete(key),
clear: () => storage.clear(),
};
};
Loading
Loading