과제 체크포인트
배포 링크
vanilla SSG: https://ckdwns9121.github.io/front_6th_chapter4-1/vanilla/ react SSG: https://ckdwns9121.github.io/front_6th_chapter4-1/react/
기본과제 (Vanilla SSR & SSG)
Express SSR 서버
- Express 미들웨어 기반 서버 구현
- 개발/프로덕션 환경 분기 처리
- HTML 템플릿 치환 (
<!--app-html-->,<!--app-head-->)
서버 사이드 렌더링
- 서버에서 동작하는 Router 구현
- 서버 데이터 프리페칭 (상품 목록, 상품 상세)
- 서버 상태관리 초기화
클라이언트 Hydration
-
window.__INITIAL_DATA__스크립트 주입 - 클라이언트 상태 복원
- 서버-클라이언트 데이터 일치
Static Site Generation
- 동적 라우트 SSG (상품 상세 페이지들)
- 빌드 타임 페이지 생성
- 파일 시스템 기반 배포
심화과제 (React SSR & SSG)
React SSR
-
renderToString서버 렌더링 - TypeScript SSR 모듈 빌드
- Universal React Router (서버/클라이언트 분기)
- React 상태관리 서버 초기화
React Hydration
- Hydration 불일치 방지
- 클라이언트 상태 복원
Static Site Generation
- 동적 라우트 SSG (상품 상세 페이지들)
- 빌드 타임 페이지 생성
- 파일 시스템 기반 배포
구현 과정 돌아보기
가장 어려웠던 부분과 해결 과정
vite로 실행하는것과 node로 실행하는 것의 차이 vite와 node의 실행환경 차이때문에 import문에서 하나하나 확장자를 붙여줘야하는 문제가 생겼다. 그래서 지훈님께서 vite-ssr-vanilla 템플릿을 써보라고 공유해주셨고 이 템플릿으로 개발 모드시에는 Vite의 미들웨어로 번들·핫리로드 지원, 배포 시에는 compression(gzip 압축) + sirv(정적 파일 서비스)로 분기해서 처리할 수 있었다.
구현하면서 새롭게 알게 된 개념
SSR과 SSG를 구현할 때 하이드레이션 동작 원리
하이드레이션은 단순히 서버가 보낸 HTML을 재사용하는 것이 아니라, 서버가 렌더링한 정적 HTML에 클라이언트 측의 JavaScript 로직(이벤트 리스너, 상태 관리)을 연결하여 동적인 웹 페이지로 만드는 과정임을 이해했다. 이걸 코드로 직접 구현해보면서 어떻게 하이드레이션이 이루어지는지 경험해볼 수 있었다.
graph TD
A[서버: API 데이터 수집] --> B[renderToString으로 HTML 생성]
B --> C[HTML에 window.__INITIAL_DATA__ 주입]
C --> D[클라이언트로 전송]
D --> E[main.tsx에서 데이터 읽기]
E --> F[전역 상태에 저장]
F --> G[스토어에 하이드레이션]
G --> H[App.tsx에서 props로 전달]
H --> I[컴포넌트에서 사용]
J[window.__INITIAL_DATA__] --> E
K[직접 접근] --> I
나는 이런 플로우로 구현을 했다.
getServerSnapShop getServerSnapshot 함수는서버 사이드 렌더링(SSR) 환경에서 사용된다. 서버는 클라이언트처럼 subscribe 해서 상태 변화를 감지할 수 없으니, 정적인 상태 스냅샷을 반환해야 한다. 즉, 서버 렌더링 시점에서만 사용되는 getSnapshot 대체용 함수임을 알 수 있었다.
renderToString와 renderToPipeableStream renderToString은 동기적 방식의 렌더링인 반면 renderToPipeableStream은 비동기 스트리밍 방식으로 서버는 HTML 파일을 청크 단위로 쪼개서 클라이언트에 보내는것이다. 이번 과제에서는 renderToPipeableStream를 써보진 못했지만 다음에 기회가 된다면 꼭 공부해서 써보고싶다.
성능 최적화 관점에서의 인사이트
SSR은 사용자가 페이지를 요청할 때마다 서버가 렌더링을 시작하는 방식이다. 이 방식은 항상 최신 데이터를 보여줄 수 있다는 장점이 있지만, 성능과 관련된 두 가지 주요 트레이드오프가 존재한다고 생각한다.
- Time to First Byte 지연: 서버가 모든 데이터를 가져오고 HTML을 완성하기까지 시간이 걸리기 때문에, 사용자는 첫 바이트를 받기까지 기다려야 한다. 이 시간이 길어질수록 사용자는 빈 화면을 오래 보게 된다.
- 서버 부하 증가: 모든 요청에 대해 서버가 렌더링을 처리해야 하므로, 트래픽이 많아지면 서버에 과부하가 걸릴 수 있다. 이런부분은 캐싱을하거나 API 요청을 최적화하는 방식으로 개선해볼 수 있을 것 같다. 그리고 최근에 알게된 사실인데 FE팀도 SSR을 도입한 서비스들은 서버 모니터링을 한다고 들었는데 이런 장애대응이라던지 모니터링 관련된 업무도 해보고싶다.
학습 갈무리
Q1. 현재 구현한 SSR/SSG 아키텍처에서 확장성을 고려할 때 어떤 부분을 개선하시겠습니까?
아키텍쳐에 대한 확장성을 고민하지 못해서 아직 정확하게 어떤부분을 개선해야할 지 잘 떠오르지 않는다. 지금 회고를 쓰면서 드는 생각은 현재는 Express 서버 하나에서 렌더링과 API를 모두 처리하고 있는데, 트래픽이 늘어나면 렌더링 서버와 API 서버를 분리하는 게 좋을 것 같다. 그리고 여러 서버 인스턴스 간 세션 공유를 위해 Redis 같은 외부 스토어를 도입하면 좋지 않을까?
Q2. Express 서버 대신 다른 런타임(Cloudflare Workers, Vercel Edge Functions 등)을 사용한다면 어떤 점을 수정해야 할까요?
Cloudflare Workers, Vercel Edge Functions가 정확하게 뭔지 몰라서 찾아봤는데 엣지 컴퓨팅을 제공하는 서비스라고 배웠다. 즉, 사용자의 요청을 중앙 서버까지 멀리 보내지 않고, 사용자와 가까운 지역의 데이터 센터(=에지 네트워크)에서 바로 실행시켜서 빠른 응답을 주는 방식이라고 한다.
만약 이 SSR 서버를 Cloudflare Workers나 Vercel Edge Functions 같은 Edge 런타임으로 이전한다면, 몇 가지 수정이 필요해보인다. 하지만 어떤점을 수정해야할지 명확하게 잘 모르겠다.
Q3. 현재 구현에서 성능 병목이 될 수 있는 지점은 어디이고, 어떻게 개선하시겠습니까?
아까 위에서 언급한 성능 최적화 관점에서의 인사이트에서의 내용과도 비슷한 맥락인데 SSR의 성능 최적화는 TTFB와 서버 부하를 줄이는 데 초점을 맞춰야 할 것 같은다. 이를 위해 renderToPipeableStream을 활용한 스트리밍 SSR을 도입하거나, 데이터베이스 쿼리를 최적화하고 캐싱 전략을 적용하는 것이 중요할 것 같다.
그리고 실제로 이런 캐싱 전략을 한번 도입해보려고 했는데 시간 관계상 잘 안됐다.
class ClientCacheManager {
private cache = new Map<string, { data: any; timestamp: number }>();
private maxAge = 5 * 60 * 1000; // 5분
get(key: string) {
const item = this.cache.get(key);
if (!item) return null;
if (Date.now() - item.timestamp > this.maxAge) {
this.cache.delete(key);
return null;
}
return item.data;
}
set(key: string, data: any) {
this.cache.set(key, {
data,
timestamp: Date.now()
});
}
}
아마 이런식으로 클라이언트 단 캐시매니저를 구현해보려고 했는데 실제로는 Redis나 Memcached 같은 외부 캐시를 써야겠지만, 개발 환경에서는 이런 간단한 메모리 캐시도 효과적일 것 같다.
Q4. 1000개 이상의 상품 페이지를 SSG로 생성할 때 고려해야 할 사항은 무엇입니까?
현재는 순차적으로 처리하고 있어서 분명 1000개정도 페이지면 엄청 오래걸릴 것 같다. Promiss.all을 활용해서 병렬 처리를 한다던지 배치처리로 개선해야할 것 같다.
또한 한번에 많은 페이지를 메모리에 올려버리면 그 부분에서도 에러가 발생할 수 있을 것 같은데 증분 빌드라던지 변경된 페이지만 재 생성하는 시스템이 필요할 것 같다.
Q5. Hydration 과정에서 사용자가 느낄 수 있는 UX 이슈는 무엇이고, 어떻게 개선할 수 있을까요?
인터렉션이 차단되는 시간 문제가 생길 것 같은데 자바스크립트가 비활성화 되어도 기본 기능은 동작하게 만들고, 하이드레이션이 실패하면 CSR 모드로 자동 전환하는 fallback 로직을 추가해서 대응하면 좋을 것 같다.
혹은 가능하다면 사용자의 이벤트를 캡쳐해서 어떤 큐나 스택에 저장해놓고 하이드레이션이 완료 되면 실행하는 방식도 되지 않을까.??
Q6. 이번 과제에서 학습한 내용을 실제 프로덕션 환경에 적용할 때 추가로 고려해야 할 사항은?
서버 모니터링 기능을 추가해보고싶다. FE 개발자는 단순히 화면만 구현하는게 아닌 인프라나 서버도 중요하다고 언급해주셨다. 실제 서비스에서 서버 렌더링 성능, 로깅 시스템과 같은 지표를 측정할 수 있는 시스템을 구축해보고싶다.
Q7. Next.js 같은 프레임워크 대신 직접 구현한 SSR/SSG의 장단점은 무엇인가요?
학습 효과가 엄청났다. 특히 SSR/SSG의 내부 동작을 깊이 이해할 수 있었고, 이제 Next.js 문서를 읽으면 "아, 이 부분은 내가 구현했던 그 부분이구나" 하고 이해할 수 있을 것 같다는 자신감이 생긴다..!.!
단점은 아마 개발생산성, 유지보수 측면이지 않을까 싶다. 괜히 사람들이 Nextjs나 Nuxt를 쓰는게 아니구나 싶었다. 이번에 기업 면접을 봤었는데 그 회사의 기술 스택에서도 Nextjs를 쓰고있었다. 처음엔 Nextjs의 이점을 잘 살릴 수 없을 것 같은 서비스(?)여서 정말 궁금한 마음에 Nextjs를 왜 쓰고 계시냐고 물어봤었다. 돌아온 답변은 SSR, SEO와 같은 이점이 아니여도 개발자가 React에서 사용할 수 없는 편한 API들을 제공해준다고 말씀해주셨다. 잘 만들어진 도구를 잘 활용해서 쓰는법도 중요한법인것 같다.
but.!! 장인은 도구를 탓하지 않는다는 정신을 이번주차에서 크게 배울 수 잇었다. 만약 Next랑 Nuxt없었으면 어떡할래 .?.? -> 내가만든다!!! 라는 마인드셋을 장착할 수 있었다.
코드 품질 향상
자랑하고 싶은 구현
interface SSRPageComponent<T = {}> extends React.ComponentType<T> {
ssr?: (context: SSRContext) => Promise<any>;
metadata?: (context: { data?: any; params?: Record<string, string> }) => MetaData;
}
- ssr: 페이지가 필요로 하는 초기 데이터를 서버에서 불러오는 메서드
- metadata: 불러온 데이터를 기반으로 SEO에 필요한 메타데이터를 생성하는 메서드
이 구조를 강제함으로써, 페이지마다 SSR/메타데이터 로직을 일관되게 구현할 수 있게 했다.
homePageSSR에서는 API 호출을 묶어서 처리하고, 에러 발생 시에도 최소한의 데이터 구조를 반환하도록 했다. 또한 homePageMetadata에서는 검색 쿼리 유무에 따라 동적으로 메타데이터를 생성하게끔 구현했다.
export const homePageSSR = async ({ query }: SSRContext) => {
try {
console.log("홈페이지 SSR 데이터 로드 시작:", query);
// SSR에서도 클라이언트와 동일한 정렬 기준 사용
const queryWithSort = { ...query, sort: query.sort || "price_asc" };
const [productsResponse, categories] = await Promise.all([fetchProducts(queryWithSort), fetchCategories()]);
console.log("홈페이지 SSR 데이터 로드 완료:", {
productsResponse,
categories,
productsCount: productsResponse?.products?.length || 0,
});
// SSR에서는 로딩 상태 없이 완전한 데이터만 반환
return {
products: productsResponse?.products || [],
categories: categories || {},
totalCount: productsResponse?.pagination?.total || 0,
};
} catch (error) {
console.error("홈페이지 SSR 데이터 로드 실패:", error);
// 에러 발생 시에도 기본 데이터 구조 유지
return {
products: [],
categories: {},
totalCount: 0,
};
}
};
export const homePageMetadata = ({ query }: { query?: Record<string, string> } = {}): MetaData => {
const searchQuery = query?.search;
if (searchQuery) {
return {
title: `"${searchQuery}" 검색 결과 - 쇼핑몰`,
description: `"${searchQuery}" 관련 상품을 찾아보세요`,
keywords: `${searchQuery}, 쇼핑, 검색`,
};
}
return {
title: "쇼핑몰 - 홈",
description: "다양한 상품을 만나보세요",
keywords: "쇼핑, 온라인 쇼핑몰, 상품",
};
};
serverRouteMatches 배열을 만들어 각 라우트별 SSR/Metadata 함수를 명시적으로 매핑했다.
export const serverRouteMatches = [
{
path: "/",
ssr: homePageSSR,
metadata: homePageMetadata,
name: "HomePage",
},
{
path: "/product/:id/",
ssr: productDetailPageSSR,
metadata: productDetailPageMetadata,
name: "ProductDetailPage",
},
{
path: "*",
ssr: notFoundPageSSR,
metadata: notFoundPageMetadata,
name: "NotFoundPage",
},
];
prefetchData와 generateMetadata 함수는 모든 라우트에서 공통적으로 동작할 수 있도록 추상화했다.
// 데이터 프리페칭
async function prefetchData(route: any, params: Record<string, string>, query: Record<string, string>) {
try {
// 페이지 컴포넌트의 SSR 메서드 호출
if (route.handler?.ssr) {
console.log("SSR 데이터 프리페칭 시작:", route.path);
const data = await route.handler.ssr({ params, query } as SSRContext);
console.log("SSR 데이터 프리페칭 완료:", route.path);
return data;
}
// SSR 메서드가 없는 경우 빈 객체 반환
console.log("SSR 메서드가 없는 페이지:", route.path);
return {};
} catch (error) {
console.error("서버 데이터 프리페칭 실패:", error);
return {
loading: false,
error: error instanceof Error ? error.message : "Unknown error",
status: "error",
};
}
}
// 메타 데이터 생성
async function generateMetadata(route: any, params: Record<string, string>, data: any): Promise<MetaData> {
try {
if (route.handler?.metadata) {
return await route.handler.metadata({ data, params });
}
// 기본 메타데이터
return {
title: "쇼핑몰",
description: "온라인 쇼핑몰",
keywords: "쇼핑, 온라인 쇼핑몰",
};
} catch (error) {
console.error("메타데이터 생성 실패:", error);
return {
title: "쇼핑몰",
description: "온라인 쇼핑몰",
keywords: "쇼핑, 온라인 쇼핑몰",
};
}
}
개선하고 싶은 부분
지금 특정 React 컴포넌트에서는 불필요하게 초기상태를 props로 받는 로직을 추가했는데 이 부분을 서버 상태만 관리할 수 있는 중앙 훅으로 분리해서 참조할 수 있게 한다던지 ContextAPI를 활용해서 초기 서버상태를 주입하는 형태로 개선해볼 수 있을 것 같다.
리팩토링 계획
항해 과정 끝나고 시간내서 꼭 리팩토링 해보겠습니다.
학습 연계
다음 학습 목표
ISR은 어떤식으로 구현하면 좋을지 학습해보기
실무 적용 계획
항해 과정이 끝나면 SSG를 활용한 개인블로그를 만들어봐야겠다.
리뷰 받고 싶은 내용
Q1. 서버-클라이언트 데이터 전달 과정에서 타입 안전성을 어떻게 강화할 수 있을까요? 현재 구현에서 서버가 클라이언트로 전달하는 초기 상태 데이터(window.INITIAL_DATA)의 타입을 any나 unknown으로 지정했습니다. 이로 인해 다음과 같은 문제가 발생할 수 있다고 생각합니다.
- 개발 시점 오류: 서버와 클라이언트의 데이터 구조가 불일치할 때 런타임 에러가 발생할 가능성이 높습니다.
- 유지보수 어려움: 데이터 구조가 변경될 때 타입 시스템의 도움을 받지 못해 관련 코드를 일일이 찾아 수정해야 합니다.
- 예측 불가능성: 데이터가 예상치 못한 형태로 전달되어 하이드레이션 불일치나 UI 오류로 이어질 수 있습니다.
이런 부분을 어떻게 개선할 수 있을까요?
Q2. Express 서버 대신 다른 런타임(Cloudflare Workers, Vercel Edge Functions 등)을 사용한다면 어떤 점을 수정해야 할까요? 위에 PR의 질문 내용과도 같은데 제가 엣지 컴퓨팅에 대해선 지식이 없어서 구체적인 답변을 생각하지 못했습니다. 혹시 코치님이라면 이 질문에 대해서 어떤 의견이신지 궁금합니다.
과제 피드백
고생하셨습니다 ㅎㅎ 많은 부분에 대해 잘 고민해주신것 같네요. 말씀해주신것처럼 요즘 관점에서는 renderToPipeableStream 같은 부분에 대해 더 많이 언급이 되고 중요성이 나타나는것 같아서 꼭 공부해보고 적용해보시면 좋을것 같습니다. 자랑하고 싶은 구조에서 언급해주신 것처럼 어느정도 추상화를 통한 강제로 여러 얻을 수 있는 장점들도 잘 누리시는 것 같아서 좋았습니다. SSG를 활용한 블로그 생성도 굉장히 일반적인 방법이니 잘활용해보셔도 좋을것 같고 가장 많이 쓰는 Astro 같은 도구들의 생성 방식들이나 아일랜드 구조들에 대해서도 살펴보면 재밌을 것 같아요 ㅎㅎ
A1. 전에 한번 아고라에 나왔던 내용일 것 같은데요. 제가 서버를 직접 구현한다면 둘의 파이프라인을 만들어서 서버 내부에서 사용하는 interface를 zod같은 도구를 통해 추출해서 FE에서 사용하는 형태로 타입세이프를 유지할 것 같아요. (실제 회사에서도 이렇게 구현해서 쓰고 있구요 ㅎㅎ) 대신 서버와 FE가 분리되어서 작업이 진행이 되는 일반적인 상황에서는 어쩔수 없이 서버측에서 내려오는 각 값에 대한 명세를 기반으로 타입을 만들어서 쓰는게 가장 쉬운 방법이지 않을까 싶습니다. 대신 이때도 zod같은 도구를 활용하면 명세가 깨져 내려오는 응답들을 쉽게 알아차릴수 있어서 반쪽이지만 도움은 될거에요.
A2. 저도 딱 이부분에 대해 엄청 깊게 고민해보거나 타사 서비스를 써본적이 없어서 잘은 모르지만, 사용할 수 있는 노드 함수들에 제한이 있을 수 있는것으로 알 수 있어요.(파일 시스템같이 보안적인 관점에서든지 머든지..) 그리고 패키지 크기같은것들도 제한이 있는 것으로 알고 있는데,, 이 부분은 정확한건 아니여서 한번 찾아보는게 더 정확할 것 같습니다. 설정에 대한 그리고 구현에 대한 부분은 크게 차이가 나지 않고 상황에 따른 장점을 누릴수 있는것으로 알고 있어요 ㅎㅎ
고생하셨고 다음주 마지막주차도 화이팅입니다!