Skip to content

[6팀 장희진] Chapter 4-1 성능 최적화#9

Open
JHeeJinDev wants to merge 37 commits intohanghae-plus:mainfrom
JHeeJinDev:main
Open

[6팀 장희진] Chapter 4-1 성능 최적화#9
JHeeJinDev wants to merge 37 commits intohanghae-plus:mainfrom
JHeeJinDev:main

Conversation

@JHeeJinDev
Copy link

@JHeeJinDev JHeeJinDev commented Sep 1, 2025

과제 체크포인트

배포 링크

https://jheejindev.github.io/front_6th_chapter4-1/react/
https://jheejindev.github.io/front_6th_chapter4-1/vanilla/

기본과제 (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 (상품 상세 페이지들)
  • 빌드 타임 페이지 생성
  • 파일 시스템 기반 배포

구현 과정 돌아보기

가장 어려웠던 부분과 해결 과정

그동안 Next.js를 쓸 때 SSR을 그냥 “이럴 때 쓰는 거구나”, “이렇게 쓰면 되네” 정도로만 이해하고 있었습니다.
사실 서버랑 클라이언트가 어떤 과정을 거쳐서 화면을 만들어내는지는 깊게 고민해본 적도 없었고, 그냥 Next.js가 편하게 제공해주는 기능 중 하나라고만 생각했던 것 같습니다.
그런데 이번 과제를 하면서 단순히 결과만 얻는 것과, 그 결과가 만들어지는 과정을 이해하는 건 전혀 다른 문제라는 걸 깨달았습니다. SSR의 구조와 원리를 하나하나 따라가 보니, 제가 얼마나 겉핥기 식으로만 접근했는지를 알게 됐고, 동시에 더 깊은 이해가 필요하다는 점도 크게 느꼈습니다.

그래서 이번 과제에서 가장 어려웠던 부분은 SSR의 전체적인 동작 과정을 파악하는 것이었습니다.
단순히 코드를 짜는 걸 넘어서, 서버에서 HTML이 생성되고 클라이언트에서 Hydration이 이어지는 흐름을 제대로 이해하는 게 정말 쉽지 않았습니다.
특히 발제를 보면서 이 정도로 이해가 안 된 적은 처음이었는데, 무려 네 번이나 다시 봤지만 결국 머릿속에 남은 건 준일 코치님의 코딩 실력에 대한 감탄뿐이었습니다.
이번 경험 덕분에 단순히 "쓰는 법"이 아니라 "왜 그렇게 동작하는지"를 이해하는 게 얼마나 중요한지 다시 돌아보게 된 것 같습니다.

SSR 동작 과정 단계별 이해

  • 1단계: 서버에서의 초기 요청 처리
app.get("*all", async (req, res) => {
  const rendered = await render(req.originalUrl, req.query);
  // ...
});

사용자가 URL에 접근하면 서버가 먼저 요청을 받고, 해당 URL에 맞는 데이터를 준비해야 한다.

  • 2단계:데이터 프리페칭과 스토어 초기화
if (route.path === "/") {
  const [productsResponse, categories] = await Promise.all([
    getProducts(router.query),
    getCategories()
  ]);
  
  // 스토어에 데이터 설정
  productStore.dispatch({
    type: PRODUCT_ACTIONS.SETUP,
    payload: { products: productsResponse.products, ... }
  });
}

서버에서 미리 데이터를 가져와서 스토어에 저장해야 클라이언트에서도 동일한 상태를 유지할 수 있다.

  • 3단계: React 컴포넌트를 HTML로 변환
const html = renderToString(<App />);

React 컴포넌트가 실제로 HTML 문자열로 변환되는 과정을 직접 경험했습니다. 이 과정에서 컴포넌트의 모든 상태가 HTML에 반영되어야 한다는 것을 깨달았습니다.

  • 4단계:초기 데이터를 HTML에 주입
const html = template
  .replace(`<!--app-head-->`, rendered.head ?? "")
  .replace(`<!--app-data-->`, `<script>window.__INITIAL_DATA__ = ${rendered.data}</script>`)
  .replace(`<!--app-html-->`, rendered.html ?? "");

서버에서 준비한 데이터를 클라이언트로 전달하기 위해 window.__INITIAL_DATA__에 주입하는 것이 핵심.

  • 5단계:클라이언트에서 HTML 수신 및 Hydration
function hydrateFromSSRDataSync() {
  const d = window.__INITIAL_DATA__;
  
  // 서버에서 전달된 데이터로 스토어 초기화
  productStore.dispatch({
    type: PRODUCT_ACTIONS.SETUP,
    payload: d.products ? { products: d.products, ... } : { ... }
  });
}

// React Hydration
hydrateRoot(rootElement, <App />);

클라이언트에서는 서버로부터 받은 HTML과 데이터를 사용해서 React 앱을 활성화(혹은 연결) 시키는 과정이 Hydration이다.

구현하면서 새롭게 알게 된 개념

useSyncExternalStore의 getServerSnapshot의 중요성

export const useStore = <T, S = T>(store: Store<T>, selector: (state: T) => S = defaultSelector<T, S>) => {
  const shallowSelector = useShallowSelector(selector);
  return useSyncExternalStore(
    store.subscribe,
    () => shallowSelector(store.getState()),
    () => shallowSelector(store.getState()), // getServerSnapshot
  );
};

useSyncExternalStore의 세 번째 인자인 getServerSnapshot이 하이드레이션 불일치를 방지하는 핵심이라는 것을 알게 되었습니다. 서버에서 렌더링된 상태와 클라이언트에서 초기 상태가 정확히 일치해야 React가 하이드레이션을 성공적으로 수행할 수 있습니다.

ServerRouter와 Client Router의 차이점

export class ServerRouter<Handler extends (...args: any[]) => any> {
  push(url: string = "/") {
    try {
      this.#route = this.#findRoute(url);
    } catch (error) {
      console.error("라우터 네비게이션 오류:", error);
    }
  }
  
  start(url = "/", query = {}) {
    this.#route = this.#findRoute(url);
    this.#currentQuery = query;
  }
}

서버에서는 브라우저 히스토리 API가 없기 때문에 push 메서드가 실제로는 URL을 변경하지 않고 단순히 라우트 매칭만 수행한다는 것을 알게 되었습니다. 서버 사이드에서는 네비게이션이 불가능하고, 오직 URL 파싱과 라우트 매칭만 가능합니다.

성능 최적화 관점에서의 인사이트

SSG 배치 처리로 메모리 효율성 확보

async function generateStaticSite() {
  // 2) 주요 상품 상세 페이지들 생성
  const productIds = items.slice(100, 130).map((p) => p.productId);
  productIds.push(items.find((product) => product.productId === "86940857379").productId);

  for (const id of productIds) {
    const url = `${BASE}product/${id}/`;
    const outDir = `../../dist/react/product/${id}`;
    await fs.mkdir(outDir, { recursive: true });
    await writeRoute(url, template, `${outDir}/index.html`);
  }
}

성능 인사이트: 전체 상품(수만 개)을 한 번에 SSG로 생성하면 메모리 부족이 발생할 수 있어서, 선택적 배치 처리를 도입했습니다. 인기 상품 31개만 선별하여 생성함으로써 빌드 시간을 단축하고 메모리 사용량을 최적화했습니다.

학습 갈무리

Q1. 현재 구현한 SSR/SSG 아키텍처에서 확장성을 고려할 때 어떤 부분을 개선하시겠습니까?

상태 관리 아키텍처의 중앙화

// 현재: 각 엔티티별로 개별 스토어
export const productStore = createStore(productReducer, initialProductState);
export const cartStore = createStore(cartReducer, initialCartState);

개선 방안:

  • 통합 스토어 패턴 도입으로 상태 간 의존성 관리 개선
  • 상태 정규화를 통해 중복 데이터 제거 (예: 상품 정보가 여러 곳에 중복 저장)
  • 상태 분할 전략으로 필요한 상태만 컴포넌트에 주입

캐싱 전략의 부재

// 현재: 매번 새로운 API 호출
const [productsResponse, categories] = await Promise.all([
  getProducts(router.query), 
  getCategories()
]);

개선 방안:

  • Redis 기반 서버 사이드 캐싱으로 API 응답 캐싱
  • SWR/React Query 도입으로 클라이언트 사이드 캐싱
  • CDN 캐싱 전략으로 정적 자산 최적화

Q2. Express 서버 대신 다른 런타임(Cloudflare Workers, Vercel Edge Functions 등)을 사용한다면 어떤 점을 수정해야 할까요?

현재 코드에서 fs, path, crypto 등을 사용하고 있는데, 이들을 Web Standard API로 대체해야 합니다.

  • fs.readFile → fetch() 또는 KV Store
  • process.env → 런타임별 환경변수 접근 방식
  • Node.js Buffer → TextEncoder/TextDecoder

Cold Start 최적화

  • Edge Functions는 Cold Start 문제가 있어서, 자주 사용되지 않는 컴포넌트들을 미리 컴파일하거나 필수적이지 않은 모듈들을 지연 로딩해야 합니다. React 컴포넌트들도 React.memo를 적극 활용해서 불필요한 리렌더링을 방지해야 할 것 같습니다.

Q3. 현재 구현에서 성능 병목이 될 수 있는 지점은 어디이고, 어떻게 개선하시겠습니까?

동기적 데이터 페칭
main-server.tsx에서 Promise.all을 사용하고 있지만, 캐싱이 없어서 매 요청마다 API를 호출합니다.

const [productsResponse, categories] = await Promise.all([
  getProducts(router.query), 
  getCategories()
]);

카테고리 정보는 자주 변경되지 않으므로 Redis나 메모리 캐시를 도입해서 5분 정도 캐싱하면 API 호출 횟수를 크게 줄일 수 있을 것 같습니다.

Q4. 1000개 이상의 상품 페이지를 SSG로 생성할 때 고려해야 할 사항은 무엇입니까?

빌드 시간 관리

  • 1000개 페이지를 순차적으로 빌드하면 시간이 너무 오래 걸립니다. Worker Threads를 활용한 병렬 빌드나 클러스터링을 도입해야 할 것 같습니다. CPU 코어 수만큼 워커를 생성해서 페이지를 청크 단위로 나누어 처리하는 방식을 생각해봤습니다.

메모리 관리

  • 모든 상품 데이터를 메모리에 로드하면 OOM(Out of Memory) 에러가 발생할 수 있습니다. 빌드 과정에서 메모리 사용량을 모니터링하면서, 임계값을 넘으면 가비지 컬렉션을 강제로 실행하거나 빌드를 일시 중단하는 로직이 필요할 것 같습니다.

증분 빌드

  • 상품 정보가 변경될 때마다 전체 빌드를 다시 하는 것은 비효율적입니다. 각 상품의 해시값을 계산해서 변경된 페이지만 재빌드하는 증분 빌드 시스템을 구축해야 합니다.

CDN 캐시 관리

  • 생성된 정적 파일들을 CDN에 배포할 때 캐시 무효화도 고려해야 합니다. 변경된 페이지들만 선별적으로 캐시를 무효화하는 전략이 필요하고, 배치 처리로 API 호출 횟수도 최적화해야 합니다.

Q5. Hydration 과정에서 사용자가 느낄 수 있는 UX 이슈는 무엇이고, 어떻게 개선할 수 있을까요?

Hydration Mismatch로 인한 깜빡임 현상

// 현재: 서버와 클라이언트 상태 불일치 가능성
function hydrateFromSSRDataSync() {
  if (typeof window === "undefined" || !window.__INITIAL_DATA__ || window.__HYDRATED__) return;
  
  const d = window.__INITIAL_DATA__;
  // 서버에서 전달받은 데이터로 상태 복원
  productStore.dispatch({
    type: PRODUCT_ACTIONS.SETUP,
    payload: { /* ... */ }
  });
}

UX 이슈: 서버에서 렌더링된 HTML과 클라이언트 상태가 다를 때 React가 DOM을 다시 렌더링하면서 깜빡임 발생
개선 방안

  • Skeleton UI 도입으로 로딩 상태 표시
  • Progressive Enhancement로 기본 기능부터 점진적 향상
  • CSS-in-JS 동기화로 스타일 불일치 방지

인터랙션 차단 시간

// 현재: 하이드레이션 완료까지 사용자 입력 차단
function main() {
  router.start();
  hydrateFromSSRDataSync(); // 이 과정에서 지연 발생
  hydrateRoot(rootElement, <App />);
}

UX 이슈: 하이드레이션 완료까지 사용자가 버튼 클릭이나 입력을 할 수 없음
개선 방안

  • Event Delegation으로 하이드레이션 전에도 기본 인터랙션 가능
  • Streaming SSR으로 점진적 하이드레이션

Q6. 이번 과제에서 학습한 내용을 실제 프로덕션 환경에 적용할 때 추가로 고려해야 할 사항은?

모니터링 및 로깅 체계

// 현재: 기본적인 console.error만 사용
} catch (error) {
  console.error(`Error setting storage item for key "${key}":`, error);
}
  • Winston 같은 라이브러리로 로그 레벨을 분리하고, SSR 렌더링 시간, 캐시 히트율, 에러율 등을 추적
  • 렌더링 실패 시 어떤 데이터가 문제였는지 파악할 수 있도록 요청별 고유 ID를 부여하고, 컨텍스트 정보를 함께 로깅

에러 핸들링 및 Fallback 전략

// 현재: 기본적인 try-catch만 사용
try {
  const [productsResponse, categories] = await Promise.all([
    getProducts(router.query), 
    getCategories()
  ]);
} catch (dataError: any) {
  // 단순한 에러 상태 설정
  initialData.error = dataError.message ?? "서버 오류";
}
  • API 타임아웃 시 캐시된 버전 제공
  • 특정 상품 페이지 오류 시 유사 상품 추천
  • 전체 서비스 장애 시 정적 에러 페이지 표시

Q7. Next.js 같은 프레임워크 대신 직접 구현한 SSR/SSG의 장단점은 무엇인가요?

1.깊은 이해와 학습 효과

// 직접 구현으로 SSR의 핵심 개념 이해
export const render = async (url: string, query: Record<string, string>) => {
  router.start(url, query);
  const route = router.route;
  // 데이터 페칭, 상태 초기화, 렌더링 과정을 직접 제어
  const html = renderToString(<App />);
  return { html, head, data: JSON.stringify(initialData) };
};

SSR의 내부 동작 원리를 정확히 이해할 수 있었습니다. renderToString이 어떻게 동작하는지, hydration 과정에서 무슨 일이 일어나는지 직접 경험할 수 있었습니다.

2.완전한 커스터마이징 자유도

// 필요한 기능만 구현하여 번들 크기 최적화
const memoryStorage = () => {
  const storage = new Map(); // Map 사용으로 성능 최적화
  return { /* ... */ };
};

라우팅 로직, 캐싱 전략, 빌드 과정을 모두 직접 구현할 수 있어서 프로젝트 특성에 맞게 최적화할 수 있습니다.

3.번들 크기
필요한 기능만 구현하므로 Next.js보다 작은 번들 크기를 유지할 수 있습니다.

Q8. Next.js 를 이용하여 SSG 방식으로 배포하려면 어떻게 해야 좋을까요?

App Router 활용
Next.js의 App Router를 사용해서 폴더 기반 라우팅으로 변경하고, generateStaticParams와 generateMetadata를 활용해서 동적 상품 페이지들을 빌드 시점에 생성할 수 있습니다.

export async function generateStaticParams() {
  const products = await getProducts({ limit: '1000' });
  
  return products.products.map((product) => ({
    id: product.productId,
  }));
}

export async function generateMetadata({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id);
  
  return {
    title: `${product.title} - 쇼핑몰`,
    description: product.brand,
  };
}

Incremental Static Regeneration (ISR)
모든 상품 페이지를 빌드 시점에 생성하는 것보다는, 인기 상품들만 미리 생성하고 나머지는 ISR로 처리하는 것이 효율적일 것 같습니다.

export const revalidate = 3600; // 1시간마다 재검증

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id);
  return <ProductDetail {...product} />;
}

코드 품질 향상

자랑하고 싶은 구현

개선하고 싶은 부분

  1. 성능 최적화
    무한스크롤에서 메모리 누수를 방지하기 위한 가상화나 아이템 수 제한 로직을 추가하고 싶습니다. 또한 이미지 지연 로딩과 placeholder를 구현해서 초기 로딩 속도를 개선하고 싶습니다.

  2. 테스트 코드
    현재 테스트 코드가 부족한 상태입니다. 특히 SSR 렌더링 로직이나 상태 관리 부분에 대한 단위 테스트와 통합 테스트를 추가해야 합니다.

리팩토링 계획

  1. 관심사 분리
    현재 productUseCase.ts에서 비즈니스 로직과 API 호출이 섞여있는데, 이를 분리하고 싶습니다.
// 현재: 하나의 파일에 모든 로직
export const loadProducts = async (resetList = true) => {
  // API 호출 + 상태 업데이트 + 에러 처리
};

// 개선 계획: 레이어 분리
// services/productService.ts - API 호출만 담당
// useCases/productUseCase.ts - 비즈니스 로직만 담당
// stores/productStore.ts - 상태 관리만 담당
  1. 컴포넌트 분해
    ProductDetail 컴포넌트가 너무 크므로, 브레드크럼, 이미지 뷰어, 수량 선택기 등으로 분해해서 재사용성을 높이고 싶습니다.

학습 연계

다음 학습 목표

  1. Serverless 환경에서의 SSR 최적화
    현재 Express 서버 기반 구현을 Cloudflare Workers나 Vercel Edge Functions로 마이그레이션하면서 Cold Start 최적화와 메모리 제약 대응 방안을 학습하고 싶습니다. 특히 현재 구현의 vite.ssrLoadModule 같은 동적 모듈 로딩을 Edge Runtime에서 어떻게 최적화할 수 있는지 궁금합니다.

  2. GraphQL과 SSR/SSG 연동 방안
    현재 REST API 기반의 데이터 페칭을 GraphQL로 전환하여 N+1 쿼리 문제를 해결하고, DataLoader 패턴을 활용한 효율적인 데이터 로딩을 구현해보고 싶습니다.

  3. React 18의 Concurrent Features 활용
    현재 renderToString을 사용한 동기적 렌더링을 Streaming SSR과 Suspense를 활용한 점진적 렌더링으로 개선하여 사용자 경험을 향상시키는 방법을 학습하고 싶습니다.

실무 적용 계획

  1. 랜딩 페이지 SSG 적용

  2. 이벤트 랜딩 페이지들을 SSG로 구현해서 SEO 점수를 개선하고, CDN 캐싱을 통해 로딩 속도를 향상시키고 싶습니다.

  3. 상태 관리 개선
    현재 Redux Toolkit를 사용하고 있는데, 이번에 구현한 것처럼 더 간단한 커스텀 스토어로 리팩토링을 검토해보고 싶습니다. 특히 보일러플레이트 코드가 많은 부분들을 개선할 수 있을 것 같습니다.

리뷰 받고 싶은 내용

이번 과제는 SSR과 SSG를 직접 구현해보는 것이었는데, 확실히 쉽지 않았습니다.
특히 SSR의 전체적인 동작 과정과 SSG의 차이를 실제 코드로 구현해보는 과정에서 많이 헤맸습니다.
그래서 리뷰받고 싶은 내용을 구체적으로 정리하기는 어려웠던거 같습니다 ㅠㅠ

  1. 이번 과제 주제와 관련해서 면접에서는 어떤 질문들이 나올 수 있을지
  2. 또한, 이번 과제에서는 충분히 구현하지 못했지만, 항해가 끝난 후 개인적으로 시간을 내어 더 도전적으로 시도해볼 수 있는 부분에는 무엇이 있을지 궁금합니다. 예를 들어 SSR과 SSG를 실제 서비스 수준에서 적용하거나, Hydration 과정 최적화, 서버 사이드 데이터 처리와 캐싱 전략 등과 관련해서 어떤 부분을 공부하고 실습하면 좋을까요?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant