이번주차는 성능개선 주차로 SSR/SSG를 직접 구현해보는 주차였다.
클라이언트요청 -> 서버에서 HTML생성 -> 클라이언트에응답
빌드시점에 HTML을 만들어서 배포. 이후 클라이언트에 해당 빌드 응답.
↓
let vite;
if (!prod) {
const { createServer } = await import("vite");
vite = await createServer({
server: { middlewareMode: true },
appType: "custom",
});
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: [] }));
}
-comporession: HTTP응답 압축으로 전송데이터 크기감소.
-app.use(compression()): 모든 응답에 압축적용
-sirv: 정적 파일 서빙 미들웨어.
-app.use(base, sirv("./dist/vanilla", { extensions: [] })): base-기본경로, ./dist/vanilla-사용할 정적 파일 디렉토리, { extensions: [] }-확장자 자동추가 비활성화. ['html']로 설정시 /about요청시 /about.html을 찾음. SSR에서 HTML은 동적으로 생성되므로 정적 HTML파일을 자동으로 찾지 않도록함.
프로덕션 환경 (NODE_ENV=production)
↓
compression 미들웨어 적용 (모든 응답 압축)
↓
sirv 미들웨어 적용 (정적 파일 서빙)
↓
요청이 오면:
1. 정적 파일 요청 → sirv가 처리 (assets/*.js, *.css 등)
2. HTML 요청 → 아래 app.use("*all")에서 SSR 처리
app.use("*all", async (req, res) => {
// 모든 요청을 여기서 처리
const url = req.originalUrl.replace(base, ""); // base 경로 제거
// 예: "/front_7th_chapter4-1/vanilla/" → "/"
// 개발/프로덕션 분기
if (!prod) {
// 개발: Vite로 실시간 로드
render = (await vite.ssrLoadModule("./src/main-server.js")).render;
} else {
// 프로덕션: 빌드된 파일 사용
render = (await import("./dist/vanilla-ssr/main-server.js")).render;
}
const rendered = await render(url); // SSR 렌더링
// ...
});
↓
export const render = async (url) => {
// 1. URL 정규화
const normalizedUrl = url || "/";
// 2. 새로운 ServerRouter 인스턴스 생성 (요청마다 독립적)
const router = new ServerRouter("");
// 3. 라우트 등록
router.addRoute("/", HomePage);
router.addRoute("/product/:id/", ProductDetailPage);
router.addRoute(".*", NotFoundPage);
// 4. URL 설정 및 라우트 매칭
router.setUrl(normalizedUrl);
router.start(); // 라우트 찾기
// 5. 매칭된 페이지 컴포넌트 가져오기
const PageComponent = router.target;
// 6. 데이터 프리패칭 (loader 실행)
let pageData = null;
if (PageComponent && PageComponent.loader) {
pageData = await PageComponent.loader(router);
// 예: HomePage.loader() → API 호출 → 데이터 반환
}
// 7. 반환값 구성
return {
head: `<title>${title || ""}</title>`,
html: () => PageComponent?.(data, router), // HTML 생성 함수
data: data || {}, // Hydration용 초기 데이터
};
};
↓
class ServerRouter {
setUrl(url) {
this.#currentUrl = url;
// 쿼리 파라미터 파싱
const urlObj = new URL(url, "http://localhost");
this.#query = ServerRouter.parseQuery(urlObj.search);
}
start() {
this.#route = this.#findRoute(this.#currentUrl);
// 정규식으로 라우트 매칭
// 예: "/" → HomePage 매칭
}
get target() {
return this.#route?.handler; // 매칭된 컴포넌트 반환
}
}
↓
HomePage.loader = async (serverRouter) => {
// 서버에서 데이터 프리패칭
const [productsData, categories] = await Promise.all([
getProducts(serverRouter.query), // API 호출
getCategories()
]);
return {
data: {
products: productsData.products,
categories,
totalCount: productsData.pagination.total
},
title: "쇼핑몰 - 홈"
};
};
// HomePage 컴포넌트 (23-53번 줄)
export const HomePage = withLifecycle(
{ onMount: ..., watches: ... },
(serversideProps, serverRouter) => {
// SSR에서 전달된 데이터 사용
const productState = serversideProps || productStore.getState();
// HTML 문자열 반환
return PageWrapper({ ... });
}
);
↓
const { data, title } = pageData ?? {};
return {
head: `<title>${title || ""}</title>`,
html: () => PageComponent?.(data, router),
data: data || {},
};
↓
const rendered = await render(url);
// rendered = {
// head: "<title>쇼핑몰 - 홈</title>",
// html: () => "<div>...</div>", // 함수
// data: { products: [...], ... }
// }
const html = template
.replace(
`<!--app-head-->`,
`${rendered.head}\n<script>window.__INITIAL_DATA__ = ${JSON.stringify(rendered.data)}</script>`
)
.replace(`<!--app-html-->`, rendered.html()); // 함수 실행하여 HTML 생성
res.send(html); // 클라이언트에 전송
↓
// 클라이언트에서
if (window.__INITIAL_DATA__) {
// 서버에서 전달된 데이터로 store 초기화
productStore.dispatch({
type: PRODUCT_ACTIONS.SETUP,
payload: window.__INITIAL_DATA__
});
}
일단 Router함수를 그대로 복사해서 ServerRouter로 만들어주었고, server에서 사용되지 않는 부분들만 수정.
router.push관련 삭제 -> SSR에서는 불필요.
query관련 수정 -> SSR에서 동적으로 query를 받을 필요는 없기에 setUrl로 요청된 url의 query값만 parsing해서 관리.
아래는 이 결정으로 인해 발생된 또다른 문제들...
vanill환경에서 mount는 react의 useEffect와 동일하다 생각해서 server환경에서는 mount가 되지 않게 처리.
prefetching을 위한 함수가 각 페이지별로 있는데 이를 위해 각페이지 컴포넌트에 loader함수를 추가.
loader에서 title,과 data를 return해서 main-server.js에서 컴포넌트 렌더링시 serversideProps로 데이터를 주입받을 수 있게 수정.
//Homepage
HomePage.loader = async (serverRouter) => {
const [
{
products,
pagination: { total },
},
categories,
] = await Promise.all([getProducts(serverRouter.query), getCategories()]);
return { data: { products, categories, totalCount: total }, title: "쇼핑몰 - 홈" };
};
//main-server
// 페이지 로더 실행 (데이터 프리패칭)
let pageData = null;
if (PageComponent && PageComponent.loader) {
pageData = (await PageComponent.loader(router)) ?? {};
}
const { data, title } = pageData ?? {};
return {
head: `<title>${title || ""}</title>`,
html: () => PageComponent?.(data, router), => prefetching한 data, router 주입
data: data || {},
};
prefeching한 데이터를 <header><script>window__INITIAL_DATA__=DATA</script></header>에 추가.
client가 실행되는 main에서 window.INITIAL_DATA가 있을경우, store에 저장하는 로직을 추가.
if (window.__INITIAL_DATA__) {
const data = window.__INITIAL_DATA__;
if (data.products)
productStore.dispatch({
type: PRODUCT_ACTIONS.SETUP,
payload: {
products: data.products,
categories: data.categories,
totalCount: data.totalCount,
loading: false,
status: "done",
},
});
}
서버가 SSR/SSG를 판단하는게 아니라 실행하는 서버 프로그램이 다르다.
SSR: Express(server.js)
SSG: Vite preivew
이제 한주밖에 남지 않았다..! 사실 마지막주에 크리스마스가 있어서 좀 고비긴하다...! 일단 해왔던대로 저녁시간에 과제 열심히하기!
basic과제 할때는 그래도 개념하나하나를 좀더 이해하면서 과제를 했는데 시간이 촉박해지니 hard때는 basic때 했던거 붙여넣으면서 AI에게 많이 시켰던듯.매번 좀더 구조에 대해 깊게 고민하지 못하고 있어서 아쉬움이 남는다.
마지막 주차엔 좀더 고민을 많이 해보려고 의식적으로 노력하기