과제 체크포인트
배포 링크
기본 : https://jeongmingi123.github.io/front_6th_chapter4-1/vanilla/ 심화 : https://jeongmingi123.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 (상품 상세 페이지들)
- 빌드 타임 페이지 생성
- 파일 시스템 기반 배포
구현 과정 돌아보기
가장 어려웠던 부분과 해결 과정
// productUseCase.ts
export const loadProductsAndCategories = async () => {
router.query = { current: undefined }; // 항상 첫 페이지로 초기화
productStore.dispatch({
type: PRODUCT_ACTIONS.SETUP,
payload: {
...initialProductState,
loading: true,
status: "pending",
},
});
심화과제인 react 구현중에, router.query = { current: undefined }; 로 인해 렌더링 이슈가 발생하였고, 해당 코드를 지우면서 timeout이 계속 발생하여 react 심화과제 CSR 렌더링 이슈가 발생하여 CSR react 테스트 코드가 다 실패하였습니다.
router.query = { current: undefined };를 제거하면 라우터의 초기 상태가 빈 객체 {}가 되고, API 호출 시 current 파라미터가 전달되지 않아 서버와 동일한 기본값(1)이 사용됨. SSR과 CSR의 결과가 일치하게 되어 하이드레이션 이슈가 해결되었음. (이걸로 한 6시간쓴듯..)
구현하면서 새롭게 알게 된 개념
성능 최적화 관점에서의 인사이트
사실 과제 이해하는데 오래걸려서 .. 성능 최적화까지는 하지 못했음
학습 갈무리
Q1. 현재 구현한 SSR/SSG 아키텍처에서 확장성을 고려할 때 어떤 부분을 개선하시겠습니까?
현재 아키텍처의 한계점
- 단일 서버 의존성: Express 서버 하나에 모든 SSR 로직이 집중되어 있어 수평 확장이 어려움
- 메모리 기반 상태 관리: 서버 재시작 시 모든 상태가 초기화됨
- 동기식 렌더링: 모든 페이지가 순차적으로 렌더링되어 병목 발생
- 캐싱 전략 부재: 동일한 요청에 대한 캐싱 메커니즘 없음
개선 방안
-
마이크로서비스 아키텍처 도입
- API 서버와 렌더링 서버 분리
- 각 서비스별 독립적인 스케일링
- 서비스 간 통신을 위한 gRPC 또는 HTTP/2 활용
-
캐싱 계층 구축
// Redis 기반 페이지 캐싱 const cacheKey = `page:${url}:${JSON.stringify(query)}`; const cached = await redis.get(cacheKey); if (cached) return JSON.parse(cached); const rendered = await render(url, query); await redis.setex(cacheKey, 300, JSON.stringify(rendered)); // 5분 캐시 -
비동기 렌더링 및 스트리밍
// React 18의 Suspense와 스트리밍 활용 const stream = renderToPipeableStream(React.createElement(App), { onShellReady() { res.setHeader('Content-Type', 'text/html'); stream.pipe(res); } }); -
CDN 통합
- 정적 자산과 SSG 페이지를 CDN에 배포
- Edge Computing을 통한 지역별 최적화
Q2. Express 서버 대신 다른 런타임(Cloudflare Workers, Vercel Edge Functions 등)을 사용한다면 어떤 점을 수정해야 할까요?
Cloudflare Workers로 마이그레이션 시 고려사항
-
런타임 제약사항
// 현재: Node.js API 사용 import fs from "node:fs/promises"; // Workers: KV Storage 사용 const data = await env.PRODUCTS_KV.get("products"); -
Cold Start 최적화
// 번들 크기 최소화 import { renderToString } from "react-dom/server"; // 필요한 부분만 import하여 번들 크기 줄이기 // 웜업 전략 export default { async fetch(request, env, ctx) { // 핫 패스 최적화 if (request.url.includes('/api/health')) { return new Response('OK'); } // ... 렌더링 로직 } }; -
메모리 제한 대응
// 대용량 데이터 처리 시 스트리밍 활용 const stream = new ReadableStream({ start(controller) { // 청크 단위로 데이터 전송 controller.enqueue(chunk); } });
Vercel Edge Functions 활용
-
Edge Runtime 최적화
export const config = { runtime: 'edge', regions: ['iad1', 'sfo1'] // 지역별 배포 }; export default async function handler(req) { // Edge에서 실행되는 최적화된 렌더링 const html = await renderToStaticMarkup(Component); return new Response(html, { headers: { 'Content-Type': 'text/html' } }); } -
ISR (Incremental Static Regeneration) 활용
// 빌드 타임 + 런타임 재생성 export async function getStaticProps() { return { props: { data }, revalidate: 60 // 60초마다 재생성 }; }
Q3. 현재 구현에서 성능 병목이 될 수 있는 지점은 어디이고, 어떻게 개선하시겠습니까?
주요 병목 지점
-
서버 렌더링 시 CPU 집약적 작업
// 현재: 모든 상품을 메모리에서 필터링 const filteredProducts = filterProducts(items, query); // 개선: 데이터베이스 쿼리 최적화 const products = await db.products .where('title', 'like', `%${search}%`) .orderBy('price', sort === 'price_asc' ? 'asc' : 'desc') .limit(limit) .offset(offset); -
메모리 누수 위험
// 현재: 전역 변수로 데이터 보관 let template; let render; // 개선: WeakMap 활용 및 메모리 관리 const renderCache = new WeakMap(); const templateCache = new Map(); // 주기적 캐시 정리 setInterval(() => { if (templateCache.size > 100) { templateCache.clear(); } }, 60000); -
번들 크기 최적화
// 현재: 전체 라이브러리 import import { renderToString } from "react-dom/server"; // 개선: Tree Shaking 및 코드 스플리팅 const { renderToString } = await import("react-dom/server"); // Vite 설정 최적화 export default defineConfig({ build: { rollupOptions: { output: { manualChunks: { vendor: ['react', 'react-dom'], utils: ['lodash', 'date-fns'] } } } } });
성능 모니터링 도입
// 성능 메트릭 수집
const performanceObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`${entry.name}: ${entry.duration}ms`);
}
});
performanceObserver.observe({ entryTypes: ['measure'] });
Q4. 1000개 이상의 상품 페이지를 SSG로 생성할 때 고려해야 할 사항은 무엇입니까?
빌드 시간 최적화
-
병렬 처리 구현
// 현재: 순차 처리 for (const productId of productIds) { await generateProductPage(productId); } // 개선: 병렬 처리 (배치 단위) const BATCH_SIZE = 10; const batches = chunk(productIds, BATCH_SIZE); for (const batch of batches) { await Promise.all( batch.map(productId => generateProductPage(productId)) ); } -
증분 빌드 구현
// 변경된 상품만 재빌드 const lastBuildTime = await getLastBuildTime(); const changedProducts = await getChangedProducts(lastBuildTime); if (changedProducts.length > 0) { await generateProductPages(changedProducts); } -
메모리 사용량 관리
// 스트리밍 방식으로 파일 생성 const writeStream = fs.createWriteStream(outputPath); for (const product of products) { const html = await renderProduct(product); writeStream.write(html); // 메모리 압박 시 가비지 컬렉션 유도 if (process.memoryUsage().heapUsed > 500 * 1024 * 1024) { global.gc && global.gc(); } }
CDN 캐시 무효화 전략
// 상품 업데이트 시 관련 페이지 무효화
async function invalidateProductCache(productId) {
const paths = [
`/product/${productId}/`,
`/`, // 홈페이지도 무효화 (관련 상품 목록)
];
await Promise.all(
paths.map(path => cdn.purge(path))
);
}
부분 재빌드 구현
// 상품별 의존성 그래프 관리
const dependencyGraph = {
'product-1': ['home', 'category-electronics'],
'product-2': ['home', 'category-clothing']
};
async function rebuildAffectedPages(changedProductId) {
const affectedPages = dependencyGraph[changedProductId] || [];
await Promise.all(
affectedPages.map(page => rebuildPage(page))
);
}
Q5. Hydration 과정에서 사용자가 느낄 수 있는 UX 이슈는 무엇이고, 어떻게 개선할 수 있을까요?
주요 UX 이슈
-
인터랙션 차단 시간
// 현재: 전체 앱이 하이드레이션 완료까지 대기 // 개선: Progressive Enhancement const ProgressiveApp = () => { const [isHydrated, setIsHydrated] = useState(false); useEffect(() => { setIsHydrated(true); }, []); return ( <div> {/* 서버 렌더링된 콘텐츠는 즉시 표시 */} <StaticContent /> {isHydrated && <InteractiveContent />} </div> ); }; -
레이아웃 시프트 (CLS) 방지
// Skeleton UI로 공간 확보 const ProductCardSkeleton = () => ( <div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"> <div className="aspect-square bg-gray-200 animate-pulse" /> <div className="p-3"> <div className="h-4 bg-gray-200 rounded animate-pulse mb-2" /> <div className="h-3 bg-gray-200 rounded animate-pulse mb-2" /> <div className="h-6 bg-gray-200 rounded animate-pulse" /> </div> </div> ); -
로딩 상태 표시
// 하이드레이션 진행 상태 표시 const HydrationProgress = () => { const [progress, setProgress] = useState(0); useEffect(() => { const interval = setInterval(() => { setProgress(prev => Math.min(prev + 10, 90)); }, 100); return () => clearInterval(interval); }, []); return ( <div className="fixed top-0 left-0 w-full h-1 bg-gray-200"> <div className="h-full bg-blue-600 transition-all duration-300" style={{ width: `${progress}%` }} /> </div> ); };
Islands Architecture 적용
// 필요한 부분만 하이드레이션
const ProductCard = ({ product, isInteractive = false }) => {
if (!isInteractive) {
// 정적 렌더링
return <StaticProductCard product={product} />;
}
// 인터랙티브 렌더링
return <InteractiveProductCard product={product} />;
};
// 클라이언트에서 필요한 카드만 하이드레이션
document.addEventListener('DOMContentLoaded', () => {
const visibleCards = document.querySelectorAll('.product-card[data-in-viewport="true"]');
visibleCards.forEach(card => {
hydrateCard(card);
});
});
Q6. 이번 과제에서 학습한 내용을 실제 프로덕션 환경에 적용할 때 추가로 고려해야 할 사항은?
모니터링 및 로깅 체계
-
성능 모니터링
// Core Web Vitals 측정 import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals'; getCLS(console.log); getFID(console.log); getFCP(console.log); getLCP(console.log); getTTFB(console.log); // 서버 성능 모니터링 const serverMetrics = { renderTime: Date.now() - startTime, memoryUsage: process.memoryUsage(), cpuUsage: process.cpuUsage() }; await metricsCollector.record('ssr_performance', serverMetrics); -
에러 핸들링 및 Fallback 전략
// Circuit Breaker 패턴 class RenderService { constructor() { this.failureCount = 0; this.lastFailureTime = 0; this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN } async render(url, query) { if (this.state === 'OPEN') { if (Date.now() - this.lastFailureTime > 60000) { this.state = 'HALF_OPEN'; } else { return this.getFallbackResponse(); } } try { const result = await this.doRender(url, query); this.onSuccess(); return result; } catch (error) { this.onFailure(); return this.getFallbackResponse(); } } } -
A/B 테스트 적용
// 기능 플래그 기반 A/B 테스트 const FeatureFlags = { NEW_PRODUCT_LAYOUT: 'new_product_layout', ENHANCED_SEARCH: 'enhanced_search' }; const renderWithFeatureFlags = async (url, query, userId) => { const flags = await featureFlagService.getFlags(userId); if (flags[FeatureFlags.NEW_PRODUCT_LAYOUT]) { return renderWithNewLayout(url, query); } return renderWithLegacyLayout(url, query); };
보안 고려사항
-
XSS 방지
// 서버 렌더링 시 XSS 방지 import { escape } from 'html-escaper'; const safeRender = (content) => { return escape(content); }; -
CSP (Content Security Policy) 설정
// Express에서 CSP 헤더 설정 app.use((req, res, next) => { res.setHeader('Content-Security-Policy', "default-src 'self'; " + "script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https:;" ); next(); });
Q7. Next.js 같은 프레임워크 대신 직접 구현한 SSR/SSG의 장단점은 무엇인가요?
직접 구현의 장점
-
학습 효과와 깊은 이해
- SSR/SSG의 내부 동작 원리를 직접 경험
- 성능 최적화 포인트를 정확히 파악
- 문제 발생 시 근본 원인 추적 가능
-
커스터마이징 자유도
// 특수한 요구사항에 맞는 최적화 가능 const customRender = async (url, query) => { // 특정 페이지는 다른 렌더링 전략 적용 if (url.includes('/admin')) { return renderWithAdminLayout(url, query); } // 일반 페이지는 표준 렌더링 return renderWithStandardLayout(url, query); }; -
번들 크기 최적화
- 필요한 기능만 구현하여 번들 크기 최소화
- 프레임워크의 불필요한 기능 제거 가능
직접 구현의 단점
-
유지보수 비용
- 버그 수정 및 보안 패치를 직접 관리해야 함
- 새로운 기능 추가 시 프레임워크 대비 개발 시간 증가
-
생태계와 커뮤니티 지원 부족
- 플러그인, 미들웨어 등이 제한적
- 문제 해결 시 커뮤니티 지원 부족
-
안정성과 검증 부족
- 다양한 엣지 케이스에 대한 검증 필요
- 프로덕션 환경에서의 안정성 확보 어려움
하이브리드 접근법
// Next.js의 장점을 활용하면서 커스터마이징
// next.config.js
module.exports = {
experimental: {
// 커스텀 렌더링 로직 추가
customServer: true,
},
// 필요한 기능만 선택적 사용
webpack: (config) => {
// 커스텀 웹팩 설정
return config;
}
};
Q8. Next.js를 이용하여 SSG 방식으로 배포하려면 어떻게 해야 좋을까요?
Next.js SSG 최적화 설정
-
getStaticProps와 getStaticPaths 활용
// pages/products/[id].js export async function getStaticPaths() { // 모든 상품 ID 가져오기 const products = await fetchAllProducts(); const paths = products.map(product => ({ params: { id: product.id } })); return { paths, fallback: 'blocking' // 새로운 상품은 런타임에 생성 }; } export async function getStaticProps({ params }) { const product = await fetchProduct(params.id); return { props: { product }, revalidate: 3600 // 1시간마다 재생성 }; } -
ISR (Incremental Static Regeneration) 활용
// pages/index.js export async function getStaticProps() { const products = await fetchProducts(); return { props: { products }, revalidate: 60 // 60초마다 재생성 }; } -
빌드 최적화
// next.config.js module.exports = { // 이미지 최적화 images: { domains: ['example.com'], formats: ['image/webp', 'image/avif'] }, // 번들 분석 webpack: (config, { isServer }) => { if (!isServer) { config.resolve.fallback = { ...config.resolve.fallback, fs: false }; } return config; }, // 압축 compress: true, // 실험적 기능 experimental: { optimizeCss: true, optimizePackageImports: ['react-icons'] } };
배포 전략
-
Vercel 배포
# vercel.json { "builds": [ { "src": "package.json", "use": "@vercel/next" } ], "functions": { "pages/api/**/*.js": { "maxDuration": 30 } } } -
다른 플랫폼 배포
# Dockerfile FROM node:18-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci --only=production FROM node:18-alpine AS builder WORKDIR /app COPY . . COPY /app/node_modules ./node_modules RUN npm run build FROM node:18-alpine AS runner WORKDIR /app ENV NODE_ENV production COPY /app/public ./public COPY /app/.next/standalone ./ COPY /app/.next/static ./.next/static EXPOSE 3000 CMD ["node", "server.js"] -
CDN 설정
// next.config.js module.exports = { assetPrefix: process.env.NODE_ENV === 'production' ? 'https://cdn.example.com' : '', // 정적 파일 캐싱 async headers() { return [ { source: '/static/:path*', headers: [ { key: 'Cache-Control', value: 'public, max-age=31536000, immutable' } ] } ]; } };
성능 모니터링
// pages/_app.js
import { Analytics } from '@vercel/analytics/react';
export default function App({ Component, pageProps }) {
return (
<>
<Component {...pageProps} />
<Analytics />
</>
);
}
// 성능 측정
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
function sendToAnalytics(metric) {
// Vercel Analytics 또는 다른 분석 도구로 전송
analytics.track('web-vital', metric);
}
getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getFCP(sendToAnalytics);
getLCP(sendToAnalytics);
getTTFB(sendToAnalytics);
코드 품질 향상
자랑하고 싶은 구현
없음
개선하고 싶은 부분
이번에 과제를 하면서, 에러가 어떻게 발생하는지 제대로 처리를 못해서 에러 처리를 제대로 못한거같습니다. 로깅을 추가하고, 에러 페이지 컴포넌트 및 에러 타입에 따른 응답에 대한 내용도 더 추가할 것 같습니다. 또한 환경 설정함수나 유틸 함수도 적절하게 분리를 못해서 코드가 난잡해지고 중복 현상도 많이 발생했던거 같아 다음과 같이 바꿀 것 같습니다.
-
구조적 분리 환경 설정 분리: config/ 폴더에 환경별 설정 파일 분리 미들웨어 분리: SSR 렌더링 로직을 별도 미들웨어 파일로 분리 유틸리티 함수 분리: URL 정규화, 에러 처리 등을 별도 모듈로 분리
-
에러 처리 개선 에러 타입별 처리: 404, 500 등 에러 타입에 따른 다른 응답 에러 로깅: 구조화된 로깅 시스템 추가 도입 에러 페이지: 에러 페이지 컴포넌트 추가
리팩토링 계획
ServerRouter.ts 부분을 함수형으로 리팩토링을 진행할 것 같습니다.
/**
* 서버사이드 라우터
*/
import { BaseRouter } from "./BaseRouter.js";
export class ServerRouter extends BaseRouter {
#currentUrl = "/";
#origin = "http://localhost";
#query = {};
constructor(baseUrl = "") {
super(baseUrl);
}
get query() {
return this.#query;
}
set query(newQuery) {
// 서버사이드에서는 Express에서 이미 파싱된 쿼리 객체를 직접 저장
this.#query = newQuery || {};
}
getCurrentUrl() {
return this.#currentUrl;
}
getOrigin() {
return this.#origin;
}
/**
* 서버 URL 설정
* @param {string} url - 요청 URL
* @param {string} [origin] - 서버 origin (선택적)
*/
setUrl(url, origin = "http://localhost") {
this.#currentUrl = url;
this.#origin = origin;
this.updateRoute(this.getCurrentUrl());
}
/**
* 서버사이드에서는 네비게이션 불가
*/
push() {
throw new Error("Navigation is not supported in server-side routing");
}
/**
* 라우터 시작
*/
start() {
this.updateRoute(this.getCurrentUrl());
}
}
개인적으로 클래스도 좋아하지만, typescript는 함수형으로 많이 만들기에 다음과 같이 변경할 것 같습니다.
const createServerRouter = (baseUrl = "") => {
// 상태 (클로저로 캡슐화)
let currentUrl = "/";
let origin = "http://localhost";
let query = {};
const baseRouter = createBaseRouter(baseUrl);
return {
// getter/setter 함수들
getQuery: () => query,
setQuery: (newQuery) => { query = newQuery || {}; },
getCurrentUrl: () => currentUrl,
getOrigin: () => origin,
setUrl: (url, originParam) => { /* 구현 */ },
push: () => { throw new Error("..."); },
start: () => { /* 구현 */ }
};
};
학습 연계
다음 학습 목표
Google Lighthouse와 같은 것을 사용하여, 사용자가 페이지를 실제로 보고 상호작용까지의 과정을 한번 측정해보고싶습니다. 이런 것을 사용했을 때 얼마나 사용자가 성능 체감이 되는지 한번 체크를 해보고싶습니다.
실무 적용 계획
회사에서 이전에 만든 인수인계 사이트를 SSR, SSG를 적용하여 마이그레이션 하여 만들어 보고 싶습니다. SSG와 SSR을 적절히 사용하여, 이 보다 웹 속도처리부분에서 팀원들에게 향상된 부분을 보여주고 싶음.
리뷰 받고 싶은 내용
// productUseCase.ts
export const loadProductsAndCategories = async () => {
router.query = { current: undefined }; // 항상 첫 페이지로 초기화
productStore.dispatch({
type: PRODUCT_ACTIONS.SETUP,
payload: {
...initialProductState,
loading: true,
status: "pending",
},
});
심화과제 react 구현중에 처음에는 다 CSR 관련 테스트코드가 통과했으나, SSR 및 SSG 구현하고 나니까 CSR (port 5175번) dev 테스트 코드만 전체 실패되었습니다.
그리하여, loadProductsAndCategories 함수에서 router.query = { current: undefined }; 로 인해 테스트 코드가 계속 렌더링 이슈가 발생했다고 생각했습니다.
이 후 router.query = { current: undefined }를 제거하였고 다음과 같이 생각했습니다.
- 라우터의 초기 상태가 빈 객체 {}가 됨.
- API 호출 시 current 파라미터가 전달되지 않아 서버와 동일한 기본값이 사용 됨
- SSR과 CSR의 결과가 일치하게 되어 하이드레이션 이슈가 해결됨.
이는 초기 상태의 일관성을 똑같이 만들어서 서버와 클라이언트 간의 렌더링 결과를 동일하게 만든다고 생각하였는데 이게 맞을까요??
과제 피드백
안녕하세요 민기님! 9주차 과제 잘 진행해주셨네요 ㅎㅎ 고생하셨습니다!!
이 부분의 경우 CSR 테스트이기 때문에 SSR과는 전혀 관련이 없어요..! 에초에 실행하는것도 vite 개발서버라서, ssr과 엮일일이 없답니다 ㅠㅠ
여튼 그래서 원인에 대해 이야기를 해보자면 초기 코드의 router의 경우 { current: undefined } 로 할당해도 query 값 자체는 빈 객체가 됩니다. 그런데 라우터를 개선하는 과정에서 query를 계산하는 방식이 달라져서 undefined가 잔존하게 된가 아닌가 싶네요!
한 번 솔루션 코드 참고해보시면 좋을 것 같아요!