항해플러스 9주차, 10주차 회고록
항해플러스 9주차 10주차 회고록입니다.

SSR SSG 이해하기
9주차 10주차 과제는 성능최적화 과제였다. 바닐라와 React로 SSR, SSG를 구현해보고 실제 메타프레임워크들이 어떤식으로 동작하는지 이해할 수 있었다.
우선 SSR, SSG가 왜 등장했는지. 그리고 필요성에 대해서 정리해보자.
전통 서버사이드 렌더링
과거에는 웹페이지를 서버에서 완전히 만들어 클라이언트 단에서 전달하는 서버렌더링 방식이 주류였다. 예를들어 php에서 echo를 사용해 html을 렌더링하거나, JSP도 HTML사이에 자바 코드를 넣어 서버에서 페이지를 만들어 보내는 방식(SSR)으로 웹을 개발했었다.
이 방식의 장점은 브라우저는 단순히 완성된 HTML을 받기만 하면 되므로 첫 화면에 대한 로딩이 빠르고, SEO도 친화적이었다.
하지만 명확한 단점도 존재했다. 페이지 이동마다 새로고침이 발생하는 하드 네비게이션 방식이므로, 사용자 경험이 끊기고 인터랙티브한 기능 구현에도 한계가 있었다.
CSR(Client Side Rendering)의 등장
그렇게 등장한 것이 CSR 방식이다.
브라우저가 서버에서 최소한의 HTML만 받고, 자바스크립트가 실행되면서 화면을 그리는 방식으로, 페이지 전환이 매끄럽고 앱 같은 사용자 경험을 제공할 수 있었다.
하지만 여기서도 명확한 딜레마가 존재했다.
초기 화면 로딩 속도는 느려지고, 검색 엔진은 자바스크립트 실행 전에는 거의 빈 HTML밖에 보지 못해 SEO에 불리했다.
즉, 사용자 경험을 개선하면 서버에서 제공되는 초기 정보가 희생되고, 초기 정보 최적화를 선택하면 사용자 경험이 느려지는 서로 상충하는 문제가 발생한 것이다.
현대 프레임워크와 하이브리드 SSR(Server Side Rendering)
이 딜레마를 해결하기 위해 등장한것이 Next.js나 Nuxt.js와 같은 현대적 메타 프레임워크이다.
이들은 소프트 네비게이션과 SSR/SSG의 선택을 가능하게 하는 하이브리드 접근을 제공한다. 초기 요청시 필요한 페이지만 서버에서 HTML을 생성하고 → 빠르게 화면을 표시해서 SEO를 확보하는 전략이다.
SSG(Static Site Generation)의 등장
하지만 SSR 방식에도 명확한 단점이 존재한다. 바로 서버가 필요하기 때문에 서버의 부하가 발생한다. 트래픽이 많은 사이트는 성능 저하나 비용증가로 이어질 수 있다.
이 문제를 해결하기 위해 SSG 방식이 등장했다.
빌드 시점에 미리 HTML을 생성해서 CDN에 배포하고 → 요청 시 즉시 정적파일을 제공하는 형태이다. 이 방식으로 하면 초기 화면 로딩속도가 매우 빠르고 (사실 로딩이 필요가 없다) 그리고 서버가 없으니까 서버 부하도 최소화한다.
주로 실시간 데이터가 중요하지 않는 블로그나, 뉴스레터 같은 서비스에 쓰인다.
바닐라 자바스크립트로 SSR, SSG 구현해보기
1. 서버라우터
클라이언트에서의 라우팅은 브라우저가 URL 변화를 감지하고, 필요한 컴포넌트를 렌더링해야한다. 서버에서는 어떻게 URL이 변경되면 새로운 페이지를 그려줄까? 바로 서버라우터에서 브라우저가 요청(GET)한 URL을 기반으로 어떤 페이지를 렌더링할 지 결정하면 된다.
ServerRouter
클래스는 서버 환경에 맞는 라우팅 기능을 제공한다.
- 경로 등록(addRoute) → /product/:id 와 같은 동적 라우트 처리
- URL 매칭(findRoute) → 요청 URL과 등록된 라우터를 비교
- 매칭된 라우터의 파라미터 추출
이 요구사항을 아래와 같은 클래스로 구현했다.
export class ServerRouter {
#routes;
#route;
#baseUrl;
constructor(baseUrl = "") {
this.#routes = new Map();
this.#route = null;
this.#baseUrl = baseUrl.replace(/\\/$/, "");
}
addRoute(path, handler) {
const paramNames = [];
const regexPath = path
.replace(/:\\w+/g, (match) => {
paramNames.push(match.slice(1));
return "([^/]+)";
})
.replace(/\\//g, "\\\\/");
const regex = new RegExp(`^${regexPath}$`);
this.#routes.set(path, {
regex,
paramNames,
handler,
});
}
findRoute(url) {
const pathname = url.split("?")[0];
const normalizedPath = pathname.replace(this.#baseUrl, "") || "/";
const routeOrder = ["/", "/product/:id/", ".*"];
for (const routePath of routeOrder) {
const route = this.#routes.get(routePath);
if (route) {
const match = normalizedPath.match(route.regex);
if (match) {
const params = {};
route.paramNames.forEach((name, index) => {
params[name] = match[index + 1];
});
return {
...route,
params,
path: routePath,
};
}
}
}
return null;
}
}
그리고 이런식으로 라우터를 등록해서 활용할 수 있다.
const serverRouter = new ServerRouter();
serverRouter.addRoute("/product/:id/", ProductDetailPage);
serverRouter.start(req.path, req.query);
const route = serverRouter.route;
2. SSR 데이터 프리페칭
서버사이드 렌더링의 핵심은 페이지를 렌더링하기 전 데이터를 미리 가지고 오는 것이다. 이 가지고 온 데이터를 기반으로 서버에서 미리 콘텐츠를 생성해 HTML로 그려야한다.
각 페이지 컴포넌트는 ssr
메서드를 제공하여 서버에서 필요한 데이터를 가지고 올 수 있게 구현했다.
이렇게 ssr
메소드를 강제함으로써 이런 추상화를 통해 다음과 같은 이점을 얻을 수 잇다.
- 데이터 구조 통일: 모든 페이지가 SSR 시점에 동일한 방식으로 데이터를 반환
- 에러 대응: 에러 처리를 동일한 함수 내에서 할 수 있다.
// SSR 메서드 - 로딩 상태 없이 완전한 데이터 반환
HomePageComponent.ssr = async ({ query }) => {
const { getProducts, getCategories } = await import("../api/productApi.js");
try {
// SSR에서도 클라이언트와 동일한 정렬 기준 사용
const queryWithSort = { ...query, sort: query.sort || "price_asc" };
const [productsResponse, categories] = await Promise.all([getProducts(queryWithSort), getCategories()]);
// SSR에서는 로딩 상태 없이 완전한 데이터만 반환
return {
products: productsResponse.products,
categories,
totalCount: productsResponse.pagination.total,
};
} catch (error) {
console.error("홈페이지 SSR 데이터 로드 실패:", error);
// 에러 발생 시에도 기본 데이터 구조 유지
return {
products: [],
categories: {},
totalCount: 0,
};
}
};
서버 라우터는 해당 페이지 컴포넌트의 ssr
메서드를 호출해 데이터를 미리 가지고오게 구현했다.
이 함수는 이후 render
함수에서 호출된다.
async function prefetchData(route, params, query) {
if (route.handler?.ssr) {
const data = await route.handler.ssr({ params, query });
return data;
}
return {};
}
3. 메타데이터 생성
마찬가지로 SEO 최적화를 위해서 각 페이지의 메타데이터도 서버에서 생성한다.
HomePageComponent.metadata = () => ({
title: "쇼핑몰 - 홈",
description: "다양한 상품을 만나보세요",
});
async function generateMetadata(route, params, data) {
try {
if (route.handler?.metadata) {
return await route.handler.metadata({ params, data });
}
// 기본 메타데이터
return {
title: "쇼핑몰",
description: "온라인 쇼핑몰",
};
} catch (error) {
console.error("메타데이터 생성 실패:", error);
return {
title: "쇼핑몰",
description: "온라인 쇼핑몰",
};
}
}
4. 하이드레이션 처리
1. 서버에서 초기 데이터 생성
서버에서는 페이지를 렌더링할 때 __INITIAL_DATA__
를 함께 생성한다.
// packages/vanilla/src/main-server.js
export const render = async (pathname, query = {}) => {
try {
// 1. 라우트 매칭
const serverRouter = new ServerRouter();
registerRoutes(serverRouter);
serverRouter.start(pathname, query);
const route = serverRouter.route;
// 2. 데이터 프리페칭
const data = await prefetchData(route, route.params, query);
// 3. 페이지 컴포넌트 렌더링 - SSR 데이터 전달
const params = { pathname, query, params: route.params, data };
const html = route.handler(params);
return {
head: `<title>${metadata.title}</title>...`,
html,
__INITIAL_DATA__: data, // 🔑 클라이언트로 전달할 초기 데이터
};
} catch (error) {
// 에러 처리...
}
};
2. 클라이언트에서 하이드레이션 실행
클라이언트가 로드되면 main.js
에서 하이드레이션을 실행한다.
// packages/vanilla/src/main.js
function hydrateFromServerData() {
if (window.__INITIAL_DATA__) {
const data = window.__INITIAL_DATA__;
// 상품 스토어 상태 복원 - SSR 데이터가 있으면 즉시 복원
if (data.products || data.categories || data.currentProduct || data.relatedProducts) {
productStore.dispatch({
type: PRODUCT_ACTIONS.SETUP,
payload: {
...data,
loading: false, // SSR 데이터가 있으면 로딩 상태 없음
error: null,
status: "done",
},
});
console.log("클라이언트 하이드레이션 완료 - SSR 데이터로 상태 복원:", {
productsCount: data.products?.length || 0,
categoriesCount: Object.keys(data.categories || {}).length,
loading: false,
});
}
// 초기 데이터 정리
delete window.__INITIAL_DATA__;
}
}
function main() {
// 1. 서버 데이터로 상태 복원 (하이드레이션)
hydrateFromServerData();
// 2. 이벤트 등록
registerAllEvents();
registerGlobalEvents();
// 3. 장바구니 로드
loadCartFromStorage();
// 4. 렌더링 초기화
initRender();
// 5. 라우터 시작
router.start();
}
3. 스토어 상태 복원
productStore
의 SETUP
액션을 통해 SSR 데이터를 클라이언트 상태에 주입한다.
// packages/vanilla/src/stores/productStore.js
const productReducer = (state, action) => {
switch (action.type) {
// ... 다른 액션들
case PRODUCT_ACTIONS.SETUP:
return { ...state, ...action.payload }; // 🔑 SSR 데이터로 상태 완전 교체
default:
return state;
}
};
4. 컴포넌트에서 SSR 데이터 활용
각 페이지 컴포넌트는 withLifecycle
HOC를 통해 SSR 데이터를 받아 처리한다.
이부분은 준일 코치님의 코드인데 조금더 공부할 필요가 있다.
// packages/vanilla/src/pages/HomePage.js
const HomePageComponent = withLifecycle(
{
onMount: () => {
// 클라이언트에서 초기 데이터 로드 (SSR 데이터가 없거나 부족한 경우)
const currentState = productStore.getState();
if (currentState.products.length === 0) {
loadProductsAndCategories();
}
},
watches: [
() => {
const { search, limit, sort, category1, category2 } = router.query;
return [search, limit, sort, category1, category2];
},
() => loadProducts(true),
],
},
({ data, query } = {}) => {
// �� SSR 데이터가 있으면 사용, 없으면 스토어 상태 사용
const productState = data || productStore.getState();
// SSR에서는 params.query 사용, 클라이언트에서는 router.query 사용
const currentQuery = query || router.query;
const { search: searchQuery, limit, sort, category1, category2 } = currentQuery;
const { products, status, error, totalCount, categories } = productState;
const loading = status === "loading_more";
const category = { category1, category2 };
const hasMore = products.length < totalCount;
return PageWrapper({
headerLeft: `<h1 class="text-xl font-bold text-gray-900">
<a href="/" data-link>쇼핑몰</a>
</h1>`,
children: `
<!-- 검색 및 필터 -->
${SearchBar({ searchQuery, limit, sort, category, categories })}
<!-- 상품 목록 -->
<div class="mb-6">
${ProductList({
products,
loading,
error,
totalCount,
hasMore,
})}
</div>
`,
});
},
);
5. 렌더링 시스템 초기화
하이드레이션 후 렌더링 시스템을 초기화하여 상태 변화를 자동으로 감지한다. 이부분 역시 준일코치님 코드인데 좀더 꼼꼼하게 공부해보자.
// packages/vanilla/src/render.js
export function initRender() {
// 각 Store의 변화를 감지하여 자동 렌더링
productStore.subscribe(render);
cartStore.subscribe(render);
uiStore.subscribe(render);
router.subscribe(render);
}
export const render = withBatch(() => {
const rootElement = document.getElementById("root");
if (!rootElement) return;
const PageComponent = router.target;
// App 컴포넌트 렌더링
rootElement.innerHTML = PageComponent();
});
React로 SSR, SSG 구현해보기
마찬가지로 React에서의 SSR도 서버라우터가 필요하다.
대략적인 흐름은 바닐라 자바스크립트와 동일한데 조금 다른 차이점을 작성해보겠다.
renderToString
React에서는 renderToString
또는 renderToStaticMarkup
을 사용해 서버에서 컴포넌트를 HTML 문자열로 렌더링할 수 있다.
import React from "react";
import { renderToString } from "react-dom/server";
import App from "./App";
export function renderPage(props) {
const html = renderToString(<App {...props} />);
return html;
}
getServerSnapShot
getServerSnapshot
은 React 18의 useSyncExternalStore
훅에서 제공하는 세 번째 매개변수로, 서버사이드 렌더링(SSR) 환경에서 외부 스토어의 상태를 안전하게 처리하기 위한 함수이다.
export const useStore = <T, S = T>(
store: Store<T>,
selector: (state: T) => S = defaultSelector<T, S>
) => {
const shallowSelector = useShallowSelector(selector);
const getSnapshot = () => shallowSelector(store.getState());
const getServerSnapshot = () => shallowSelector(store.getState());
return useSyncExternalStore(
store.subscribe,
getSnapshot,
getServerSnapshot
);
};
왜 필요한가?
서버사이드 렌더링에서는 다음과 같은 문제가 발생할 수 있다.
- 서버: 각 요청마다 독립적인 스토어 인스턴스 필요
- 클라이언트: 브라우저에서 지속적으로 변화하는 스토어 상태
- 하이드레이션 불일치: 서버에서 렌더링된 HTML과 클라이언트 초기 렌더링 결과가 다를 수 있음
SSR 컴포넌트의 추상화
이렇게 타입을 강제함으로서 페이지마다 SSR/메타데이터 로직을 일관성 있게 구현할 수 있다.
interface SSRPageComponent<T = {}> extends React.ComponentType<T> {
ssr?: (context: SSRContext) => Promise<any>;
metadata?: (context: { data?: any; params?: Record<string, string> }) => MetaData;
}
React Profiler를 활용하여 성능 최적화
10주차에는 React Profiler를 활용해서 성능을 측정하는 방법을 배웠다. 이전에는 성능 문제가 발견되면 막연히 memo
나 useCallback
과 같은 훅을 적용했는데 지금 생각해보니 정확히 사용하지 않았었다.
그냥 무분별하게 컴포넌트에 메모이제이션을 적용하고 useCallback을 감싸서 사용했지만, 실제로 리렌더링이 일어나는지 아닌지 명확히 체크하지 않고 사용했다. 이번 과제에서는 React Profiler를 활용해 드래그 앤 드롭 시 어떤 컴포넌트가, 왜 리렌더링되는지를 시각적으로 확인하면서 정확한 원인을 분석할 수 있었다. 이 과정에서 DndProvider가 불필요하게 전역 상태에 의존하고 있다는 근본적 문제를 발견했고, 이를 개선해 불필요한 리렌더링을 획기적으로 줄일 수 있었다.
KPT 회고
Keep
- 이제 코드를 작성할 때 항상 추상화 레벨에 대해서 고민하면서 작성하는 습관이 생겼는데 계속 가져가보자.
- 끝까지 과제를 포기하지 않았다.
Problem
- 역시나 시간관리. 매주 이 이슈때문에 스트레스를 받았다. 하지만 지금 돌이켜보니 어쩔 수 없었던거같다.
- 시간관리를 못했어도 스트레스 받아가며 압박감 속에서 과제를 마무리한 나 자신 칭찬
Try
- 계속해서 클린한 코드가 어떤것인지, 확장성이 좋은 코드가 어떤것인지 고민하며 작성하기