BBAK-jun 님의 상세페이지[3팀 박준형] Chapter 4-1 성능 최적화

과제 체크포인트

배포 링크

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

구현 과정 돌아보기

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

1. SSR에서 스토어 상태 공유 문제로 인한 e2e 테스트 간섭

문제: e2e 테스트들이 동시에 실행될 때 SSR 환경에서 router 인스턴스와 스토어가 모듈 레벨에서 공유되어 테스트 간 상태가 오염되는 문제가 발생했습니다.

해결 과정:

  • 문제의 근본 원인을 파악: router 인스턴스가 packages/react/src/core/router/instance.ts에서 싱글톤으로 생성되어 공유됨
  • Router Factory 패턴을 시도했지만, 더 간단한 해결책을 선택
  • entry-server.tsx에서 각 SSR 요청마다 router에 라우트를 다시 등록하도록 수정하여 상태 격리 달성

2. SSG 빌드 중 "Vite module runner has been closed" 오류

문제: static-site-generate.ts에서 Vite 인스턴스를 너무 일찍 닫아버려서 SSG 생성 중 모듈 로딩 오류가 발생했습니다.

해결 과정:

// 문제가 있던 코드
await vite.close(); // render 함수 반환 직후 바로 닫음
return mod.render;

// 해결된 코드
return { render: mod.render, vite }; // vite 인스턴스를 함께 반환
// 모든 렌더링 작업 완료 후 finally 블록에서 닫음

3. TypeScript 마이그레이션 중 모듈 해상도 문제

문제: JavaScript에서 TypeScript로 마이그레이션하면서 static-site-generate.jsstatic-site-generate.ts로 변환할 때 모듈 import와 타입 정의 문제가 발생했습니다.

해결 과정:

  • package.json 스크립트를 node에서 tsx로 변경
  • 타입 안전성을 위한 proper typing 추가
  • 에러 핸들링과 리소스 관리 개선

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

SSR 환경에서의 상태 격리의 중요성

  • 모듈 레벨 싱글톤의 위험성: Node.js 환경에서 모듈은 캐시되므로 여러 요청 간에 상태가 공유될 수 있다는 것을 체감했습니다.
  • 테스트 환경에서의 상태 오염: e2e 테스트가 병렬로 실행될 때 공유된 상태로 인해 예측 불가능한 결과가 나올 수 있음을 학습했습니다.
  • 실제 운영 환경에서의 사례: 실제로 회사에서 운용 중인 Nuxt2 서비스에서 Vuex 스토어가 요청별로 격리되지 않고 공유되는 문제를 경험했습니다. 메타프레임워크임에도 불구하고 이런 문제가 발생할 수 있다는 것을 확인했습니다.

Vite의 SSR 모듈 로딩 메커니즘

  • Vite 인스턴스 생명주기: Vite 인스턴스를 너무 일찍 닫으면 이후 모듈 로딩에서 오류가 발생한다는 것을 알게 되었습니다.
  • 리소스 관리의 중요성: SSG 같은 배치 작업에서는 리소스를 적절한 시점에 해제해야 한다는 것을 학습했습니다.

Universal Router 패턴의 복잡성

  • 서버와 클라이언트의 라우터 동작 차이: 같은 라우터 코드가 서버에서는 매번 새로운 컨텍스트를 생성해야 하지만, 클라이언트에서는 싱글톤으로 동작해야 한다는 차이점을 이해했습니다.
  • 동적 라우트 등록: SSR에서 각 요청마다 라우트를 다시 등록하는 것이 상태 격리에 도움이 된다는 것을 학습했습니다.

TypeScript와 Build Tool 통합

  • tsx vs node: TypeScript 파일을 실행할 때 tsx를 사용하면 별도 컴파일 없이 바로 실행할 수 있다는 것을 알게 되었습니다.
  • 모듈 해상도와 타입 정의: SSR 환경에서 모듈 import 시 타입 정의와 런타임 동작이 일치해야 한다는 중요성을 체감했습니다.

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

리소스 관리 최적화

  • Vite 인스턴스 생명주기 관리: SSG 생성 시 Vite 인스턴스를 적절한 시점까지 유지하고 모든 작업 완료 후 정리함으로써 메모리 누수를 방지했습니다.
  • 에러 핸들링과 리소스 정리: try-catch-finally 패턴을 사용하여 오류 발생 시에도 리소스가 적절히 정리되도록 구현했습니다.

상태 관리 최적화

  • 각 요청별 독립적인 스토어: SSR에서 매번 새로운 productStore 인스턴스를 생성하여 메모리 사용량을 최적화하고 상태 오염을 방지했습니다.
  • 라우터 상태 격리: 각 SSR 요청마다 라우트를 재등록하는 오버헤드가 있지만, 상태 격리를 통한 안정성 향상이 더 중요하다고 판단했습니다.

빌드 최적화

  • TypeScript 직접 실행: tsx를 사용하여 별도 컴파일 단계 없이 TypeScript 파일을 직접 실행함으로써 빌드 파이프라인을 단순화했습니다.
  • 에러 처리 개선: SSG 생성 중 개별 페이지 오류가 전체 빌드를 중단시키지 않도록 error boundary를 구현했습니다.

학습 갈무리

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

상태 관리 아키텍처 개선

  • Router Factory 패턴 완전 적용: 현재는 각 요청마다 라우트를 재등록하는 방식인데, 완전한 Router Factory를 구현하여 각 요청마다 독립적인 router 인스턴스를 생성하도록 개선하겠습니다.
  • Context Provider 격리: 각 SSR 요청마다 완전히 독립적인 Context를 생성하여 상태 오염 가능성을 원천 차단하겠습니다.

캐싱 및 성능 최적화

  • SSG 증분 빌드: 현재는 모든 페이지를 매번 재생성하는데, 변경된 데이터에 따라 필요한 페이지만 재생성하는 증분 빌드를 도입하겠습니다.
  • 메모리 기반 캐싱: 자주 요청되는 데이터를 메모리에 캐시하여 API 호출 횟수를 줄이겠습니다.

에러 처리 및 모니터링

  • 구조화된 에러 처리: 현재는 단순한 try-catch 구조인데, 에러 타입별로 다른 처리 전략을 적용할 수 있도록 개선하겠습니다.
  • 성능 메트릭 수집: SSR/SSG 빌드 시간, 메모리 사용량 등의 메트릭을 수집하여 성능 병목 지점을 파악하겠습니다.

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

멀티런타임 지원 프레임워크 도입

Hono 같은 Web Standards 기반 프레임워크 활용

  • Hono는 Web Standards API만을 사용하여 Cloudflare, Fastly, Deno, Bun, AWS, Node.js 등 모든 런타임에서 동작합니다.
  • Express 대신 Hono로 서버를 구현하면 런타임 환경에 구애받지 않습니다:
import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => c.text('Hello Hono!'))
app.get('/api/products', async (c) => {
  // Web Standards 기반 fetch API 사용
  const response = await fetch('https://api.example.com/products')
  return c.json(await response.json())
})

export default app

서버리스 환경 적응

  • 상태 관리 방식 변경: 현재 우리가 해결한 "모듈 레벨 상태 공유" 문제는 서버리스에서는 자연스럽게 해결됩니다. 각 요청이 독립적인 실행 환경에서 처리되기 때문입니다.
  • Cold Start 최적화: 번들 크기를 최소화하고, 필요한 모듈만 동적으로 import하여 초기 실행 시간을 줄여야 합니다.

런타임 API 차이 대응

  • Web Standards API 사용: Node.js 특화 API(fs, path 등) 대신 Web Standards API(fetch, Response, Request 등)를 사용해야 합니다.
  • 환경 변수 접근: process.env 대신 각 플랫폼별 환경 변수 접근 방식을 추상화해야 합니다.

리소스 관리 최적화

  • 메모리 제한: Edge Functions는 메모리 사용량이 제한적이므로, 현재의 Vite 인스턴스 관리 방식을 더욱 효율적으로 개선해야 합니다.
  • 실행 시간 제한: 요청 처리 시간 제한이 있으므로, SSR 렌더링 시간을 최적화해야 합니다.

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

1. SSR에서 매번 라우트 재등록하는 오버헤드

병목 지점: entry-server.tsx에서 각 요청마다 라우트를 재등록하는 작업 개선 방안:

  • Router Factory 패턴을 완전히 구현하여 라우트가 미리 등록된 독립적인 인스턴스를 생성
  • 라우트 설정을 캐시하여 재등록 오버헤드 최소화

2. Vite 인스턴스 생성/해제 비용

병목 지점: SSG 생성 시 Vite 인스턴스의 생성과 해제 과정 개선 방안:

  • Vite 인스턴스 풀링을 통한 재사용
  • 개발 환경에서는 한 번 생성된 인스턴스를 재사용하도록 개선

3. 상품 데이터 직렬화/역직렬화

병목 지점: 서버에서 클라이언트로 전달되는 초기 데이터의 직렬화 과정 개선 방안:

  • 필요한 데이터만 선택적으로 직렬화
  • 압축 알고리즘 적용으로 전송 크기 최적화

4. 메모리 사용량 최적화

병목 지점: 대량의 상품 페이지 SSG 생성 시 메모리 사용량 증가 개선 방안:

  • 배치 처리를 통한 메모리 사용량 제어
  • WeakMap을 활용한 자동 가비지 컬렉션 최적화

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

메모리 관리 최적화

  • 배치 처리: 한 번에 모든 페이지를 생성하지 않고 100-200개씩 배치로 나누어 처리
  • 가비지 컬렉션: 각 배치 완료 후 명시적으로 메모리 정리
  • Vite 인스턴스 재사용: 현재 구현처럼 인스턴스를 재사용하여 생성 비용 최소화

빌드 시간 최적화

  • 병렬 처리: Worker Threads를 활용하여 여러 페이지를 동시에 생성
  • 증분 빌드: 변경된 상품만 재생성하는 로직 구현
  • 캐싱 전략: 렌더링 결과를 캐시하여 불필요한 재생성 방지

실제 구현 예시

async function generateInBatches(pages: Page[], batchSize = 100) {
  for (let i = 0; i < pages.length; i += batchSize) {
    const batch = pages.slice(i, i + batchSize);
    await Promise.all(batch.map(page => generatePage(page)));

    // 배치 완료 후 메모리 정리
    if (global.gc) global.gc();
  }
}

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

주요 UX 이슈들

인터랙션 차단 (Interaction Blocking)

  • Hydration 완료 전까지 사용자 클릭이나 입력이 동작하지 않아 답답함을 느낄 수 있습니다.
  • 특히 버튼 클릭이나 폼 입력 시 반응이 없어 사용자가 여러 번 클릭하는 문제가 발생합니다.

시각적 깜빡임 (Flash of Content)

  • 서버에서 렌더링된 HTML과 클라이언트에서 렌더링되는 결과가 다를 때 화면이 깜빡이는 현상
  • CSS-in-JS나 동적 스타일링 시 특히 자주 발생합니다.

개선 방안

Progressive Enhancement 적용

// 기본 기능은 서버 렌더링으로, 고급 기능은 클라이언트에서 점진적으로 활성화
const ProductCard = ({ product, isHydrated }) => {
  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      {/* 기본 링크는 항상 동작 */}
      <a href={`/product/${product.id}`}>상세보기</a>

      {/* 고급 기능은 Hydration 후 활성화 */}
      {isHydrated && (
        <button onClick={handleQuickView}>빠른 미리보기</button>
      )}
    </div>
  );
};

Skeleton UI와 로딩 상태

  • Hydration 중임을 명확히 표시하여 사용자 기대치 관리
  • 인터랙티브하지 않은 요소들을 시각적으로 구분하여 표시

Selective Hydration

  • 중요한 인터랙션 요소부터 우선적으로 Hydrate
  • React 18의 Suspense를 활용한 부분적 Hydration 구현

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

메타프레임워크의 함정: Nuxt2 Vuex 스토어 공유 사례

image

실제 운영 환경에서 발생한 문제

  • Nuxt2를 사용한 서비스에서 Vuex 스토어가 요청별로 격리되지 않고 서버에서 공유되는 문제 발생
  • 사용자 A의 개인정보가 사용자 B에게 노출되거나, 로그인 상태가 다른 사용자에게 공유되는 심각한 보안 이슈
  • 메타프레임워크를 사용한다고 해서 이런 문제가 자동으로 해결되는 것은 아니라는 교훈

Nuxt2에서 발생하는 이유

// 잘못된 예시 - Nuxt2에서 흔히 발생하는 패턴
// store/index.js
export const state = () => ({
  user: null,
  isLoggedIn: false
})

// 문제: 서버에서 이 스토어 인스턴스가 공유될 수 있음

해결 방안

  • Nuxt3로 마이그레이션: Pinia 사용으로 상태 격리 개선
  • 또는 Nuxt2에서 스토어 초기화 로직 개선:
// 개선된 방식
// nuxt.config.js
export default {
  // SSR에서 스토어를 매번 새로 생성하도록 설정
  ssr: true,
  mode: 'universal'
}

// store/index.js - 각 요청마다 새로운 상태로 초기화
export const actions = {
  nuxtServerInit({ commit }, { req }) {
    // 서버에서 각 요청마다 스토어 초기화
    commit('RESET_STATE')
  }
}

교훈

  • 메타프레임워크도 완벽하지 않다: 잘 알려진 프레임워크라도 SSR 환경에서 상태 격리 문제가 발생할 수 있음
  • 철저한 테스트 필요: 동시 사용자 환경에서의 상태 격리 테스트가 필수
  • 직접 구현의 가치: 이번 과제처럼 직접 구현하면서 이런 문제를 미리 인지하고 해결할 수 있음

Cold Start 최적화 전략

서버리스 환경에서의 Cold Start 해결 방안

Google Cloud Run의 경우 3가지 해결책이 있습니다:

image
  1. Min Instance 설정: 최소 인스턴스 수를 유지하여 Cold Start 방지

    • 비용이 발생하지만 가장 안정적인 방법
    • 트래픽 패턴을 분석하여 적절한 최소 인스턴스 수 결정
  2. 스케줄러를 통한 주기적 호출: Cloud Scheduler로 정기적으로 서비스 호출

    // Cloud Scheduler에서 매 분마다 호출
    app.get('/health', (c) => c.text('OK'))
    
  3. SIGTERM 무한 루프: 인스턴스 종료 시 자기 자신을 호출

    process.on('SIGTERM', async () => {
      // 자기 자신을 호출하여 새 인스턴스 생성
      await fetch(process.env.SERVICE_URL + '/health')
    })
    

성능 측정 및 부하 테스트 환경

실시간 성능 모니터링 대시보드

// 성능 메트릭 수집 미들웨어
const performanceMiddleware = async (c, next) => {
  const start = Date.now()
  const memoryBefore = process.memoryUsage()

  await next()

  const duration = Date.now() - start
  const memoryAfter = process.memoryUsage()

  // 메트릭 전송 (Prometheus, DataDog 등)
  metrics.histogram('ssr_render_time', duration)
  metrics.gauge('memory_usage', memoryAfter.heapUsed - memoryBefore.heapUsed)
  metrics.counter('requests_total').inc({
    method: c.req.method,
    status: c.res.status
  })
}

상용환경 유사 테스트 환경 구축

  1. 데이터베이스 스냅샷 활용: 실제 상용 데이터의 익명화된 스냅샷으로 테스트 DB 구성
  2. 부하 테스트 시나리오: 실제 사용자 패턴을 모방한 트래픽 시뮬레이션
    # K6를 사용한 부하 테스트 예시
    k6 run --vus 100 --duration 5m load-test.js
    

RPS/TPS 측정 및 최적화

  • 목표 성능 지표:
    • SSR 응답 시간: < 200ms (95th percentile)
    • RPS: > 1000 requests/second
    • 메모리 사용량: < 512MB per instance
  • 병목 지점 식별: DB 쿼리, 렌더링, 네트워크 I/O 각각의 성능 측정
  • 점진적 최적화: 각 개선사항의 성능 영향도 측정

모니터링 및 로깅 체계

  • 성능 메트릭: SSR 렌더링 시간, 메모리 사용량, Cold Start 빈도 추적
  • 에러 추적: Sentry나 DataDog 같은 APM 도구로 실시간 에러 모니터링
  • 사용자 경험 메트릭: Core Web Vitals (LCP, FID, CLS) 측정

보안 고려사항

  • CSP (Content Security Policy): XSS 공격 방지를 위한 엄격한 CSP 설정
  • CSRF 보호: 상태 변경 요청에 대한 CSRF 토큰 검증
  • 환경 변수 관리: 민감한 정보는 안전한 시크릿 관리 시스템 사용

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

장점

깊은 학습과 이해

  • SSR/SSG의 내부 동작 원리를 직접 체험하며 깊이 있는 이해를 얻을 수 있었습니다.
  • 문제 발생 시 근본 원인을 파악하고 해결할 수 있는 능력을 기를 수 있었습니다.

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

  • 프로젝트의 특수한 요구사항에 맞춰 자유롭게 구현할 수 있습니다.
  • 불필요한 기능 없이 필요한 것만 구현하여 번들 크기를 최적화할 수 있습니다.

기술적 제약 없음

  • 특정 프레임워크의 규칙이나 제약에 얽매이지 않고 최적의 솔루션을 구현할 수 있습니다.

단점

개발 시간과 비용

  • 기본적인 기능부터 모두 구현해야 하므로 개발 시간이 오래 걸립니다.
  • 검증된 솔루션이 아니므로 예상치 못한 버그나 이슈가 발생할 수 있습니다.

유지보수 부담

  • 모든 코드를 직접 관리해야 하므로 유지보수 비용이 높습니다.
  • 보안 업데이트나 성능 개선을 모두 직접 해야 합니다.

생태계 지원 부족

  • 커뮤니티 지원이나 플러그인 생태계의 혜택을 받기 어렵습니다.
  • 문제 해결을 위한 레퍼런스가 부족합니다.

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

코드 품질 향상

자랑하고 싶은 구현

1. SSG 빌드 시 리소스 관리 최적화

Vite 인스턴스 생명주기를 적절히 관리하여 메모리 누수를 방지하는 구조를 구현했습니다:

async function loadRender() {
  const builtEntry = path.join(SSR_DIR, "entry-server.tsx");
  if (fs.existsSync(builtEntry)) {
    const mod = await import(pathToFileURL(builtEntry).href);
    if (typeof mod.render !== "function") throw new Error("SSR 모듈에 render가 없습니다.");
    return { render: mod.render, vite: null };
  }

  const vite = await createViteServer({
    server: { middlewareMode: true },
    appType: "custom",
  });
  try {
    const mod = await vite.ssrLoadModule("/src/entry-server.tsx");
    if (typeof mod.render !== "function") throw new Error("SSR 모듈에 render가 없습니다.");
    // Vite 인스턴스를 반환하여 나중에 닫을 수 있도록 함
    return { render: mod.render, vite };
  } catch (e) {
    await vite.close();
    throw e;
  }
}

이 방식으로 빌드된 파일이 있을 때는 Vite 인스턴스를 생성하지 않고, 개발 시에만 필요할 때 생성하여 모든 작업 완료 후 정리하도록 했습니다.

2. 실제 운영 환경 문제를 고려한 상태 격리 구현

실제 회사에서 겪은 Nuxt2 Vuex 스토어 공유 문제를 바탕으로, 처음부터 상태 격리를 염두에 두고 구현했습니다:

// entry-server.tsx에서 각 요청마다 독립적인 인스턴스 생성
export const render = async (url: string) => {
  const router = await import("./core/router/instance").then((module) => module.router);

  // 매번 새로운 라우트 등록으로 상태 격리
  router.addRoute("/", Home.PageComponent, {
    getServerSideProps: Home.getServerSideProps,
    generateMetaData: Home.generateMetaData,
  });

  // 매번 새로운 productStore 인스턴스 생성
  const productStore = createProductStore({...});
};

실제 운영 환경의 교훈을 반영한 설계

  • 메타프레임워크도 완벽하지 않다는 인식 하에 처음부터 상태 격리 고려
  • 각 SSR 요청마다 완전히 독립적인 컨텍스트 생성
  • 동시 사용자 환경에서의 안전성을 최우선으로 고려

개선하고 싶은 부분

1. SSR 상태 격리 방식의 근본적 개선

현재는 각 요청마다 라우트를 재등록하는 방식으로 임시 해결했지만, 이는 성능상 오버헤드가 있습니다. Router Factory 패턴을 완전히 구현하여 각 요청마다 독립적인 router 인스턴스를 생성하는 방식으로 개선하고 싶습니다.

// 현재 방식 (임시 해결)
router.addRoute("/", Home.PageComponent, { ... });
router.addRoute("/product/:id/", ProductDetail.PageComponent, { ... });

// 개선하고 싶은 방식
const createSSRRouter = () => {
  const router = new UniversalRouter(BASE_URL);
  // 라우트 설정을 미리 등록한 독립적인 인스턴스 반환
  return setupRoutes(router);
};

2. 에러 처리의 세분화

현재는 단순한 try-catch 구조로 모든 에러를 동일하게 처리하고 있는데, 에러 타입별로 다른 처리 전략을 적용하고 싶습니다:

  • 네트워크 에러: 재시도 로직
  • 렌더링 에러: Fallback 페이지 제공
  • 데이터 에러: 기본값으로 대체

3. 성능 모니터링 시스템 구축

SSR/SSG 과정에서 발생하는 성능 메트릭을 수집하고 분석할 수 있는 시스템을 구축하고 싶습니다:

  • 렌더링 시간 측정
  • 메모리 사용량 추적
  • API 호출 횟수 및 응답 시간 모니터링

리팩토링 계획

1. Router 아키텍처 개선

  • Router Factory 패턴: 각 SSR 요청마다 독립적인 router 인스턴스 생성
  • 타입 안전성 강화: 라우트 매개변수와 컴포넌트 props 간의 타입 일관성 보장

2. 성능 최적화 우선순위

  • 캐싱 전략: 렌더링 결과와 API 응답 캐싱
  • 번들 최적화: 코드 스플리팅으로 초기 로딩 시간 단축

학습 연계

다음 학습 목표

1. Streaming SSR 구현 방안

React 18 Suspense 기반 스트리밍

// entry-server.tsx에서 스트리밍 렌더링
import { renderToPipeableStream } from 'react-dom/server';

export const renderStream = (url: string) => {
  return new Promise((resolve, reject) => {
    const { pipe, abort } = renderToPipeableStream(
      <App url={url} />,
      {
        bootstrapScripts: ['/assets/client.js'],
        onShellReady() {
          // 초기 HTML shell을 즉시 스트리밍
          resolve(pipe);
        },
        onAllReady() {
          // 모든 Suspense boundary가 완료됨
        },
        onError(error) {
          reject(error);
        }
      }
    );
  });
};

// 컴포넌트에서 Suspense 활용
function ProductPage({ productId }) {
  return (
    <div>
      <h1>상품 페이지</h1>
      <Suspense fallback={<ProductSkeleton />}>
        <ProductDetail productId={productId} />
      </Suspense>
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews productId={productId} />
      </Suspense>
    </div>
  );
}

스트리밍의 장점

  • 초기 HTML이 빠르게 전송되어 사용자가 즉시 콘텐츠를 볼 수 있음
  • 무거운 데이터 로딩이 완료되기 전에도 페이지 구조 표시 가능
  • TTFB(Time to First Byte) 개선

2. ISR (Incremental Static Regeneration) 구현

캐시 기반 ISR 전략

// ISR 구현을 위한 캐시 매니저
class ISRCache {
  private cache = new Map();
  private revalidateQueue = new Set();

  async get(key: string, revalidateAfter: number) {
    const cached = this.cache.get(key);

    if (!cached) {
      // 캐시 없음 - 새로 생성
      return this.generateAndCache(key);
    }

    const isStale = Date.now() - cached.timestamp > revalidateAfter;

    if (isStale && !this.revalidateQueue.has(key)) {
      // 백그라운드에서 재생성 (stale-while-revalidate)
      this.revalidateQueue.add(key);
      this.generateAndCache(key).finally(() => {
        this.revalidateQueue.delete(key);
      });
    }

    return cached.content;
  }

  private async generateAndCache(key: string) {
    const content = await this.generateContent(key);
    this.cache.set(key, {
      content,
      timestamp: Date.now()
    });
    return content;
  }
}

// 사용 예시
app.get('/product/:id', async (c) => {
  const productId = c.req.param('id');
  const cacheKey = `product-${productId}`;

  const html = await isrCache.get(cacheKey, 3600000); // 1시간 후 재검증
  return c.html(html);
});

ISR의 핵심 개념

  • Stale-While-Revalidate: 기존 캐시된 콘텐츠를 서빙하면서 백그라운드에서 새로 생성
  • On-Demand Revalidation: 특정 이벤트(데이터 업데이트 등) 발생 시 즉시 재생성
  • 캐시 무효화: 관련 데이터 변경 시 해당 페이지들의 캐시 삭제

CMS 연동 시나리오

  1. CMS에서 레슨권 정보 수정 → 웹훅 트리거
  2. 무효화 API 호출 → 해당 레슨권 캐시 삭제
  3. 다음 사용자 요청 → 새로운 데이터로 페이지 재생성
  4. 이후 요청들 → 캐시된 최신 페이지 서빙

예상 효과

  • 성능: 캐시된 페이지로 빠른 로딩 (< 100ms)
  • 최신성: CMS 변경 시 즉시 반영
  • 비용 절약: 불필요한 재생성 최소화

실무 적용 계획

리뷰 받고 싶은 내용

1. SSR 상태 격리 방식

현재 entry-server.tsx에서 각 요청마다 라우트를 재등록하는 방식으로 상태 격리를 구현했는데, 이 방식이 성능상 문제가 없는지, 더 효율적인 대안이 있는지 확인 부탁드립니다.

// packages/react/src/entry-server.tsx
router
  .addRoute("/", Home.PageComponent, {
    getServerSideProps: Home.getServerSideProps,
    generateMetaData: Home.generateMetaData,
  })
  .addRoute("/product/:id/", ProductDetail.PageComponent, {
    getServerSideProps: ProductDetail.getServerSideProps,
    generateMetaData: ProductDetail.generateMetaData,
  });

2. Vite 인스턴스 생명주기 관리

static-site-generate.ts에서 Vite 인스턴스를 관리하는 방식이 대용량 SSG 생성 시에도 안정적으로 동작할지, 메모리 누수 가능성은 없는지 확인 부탁드립니다.

3. Universal Router 패턴

현재 구현한 Universal Router가 복잡한 라우팅 시나리오(중첩 라우트, 동적 임포트 등)에서도 안정적으로 동작할지, 개선할 부분이 있는지 의견 부탁드립니다.

4. 타입 안전성 개선

TypeScript 마이그레이션 과정에서 타입 정의가 불완전한 부분이나, 더 타입 안전하게 개선할 수 있는 부분이 있는지 검토 부탁드립니다.

5. 성능 최적화 우선순위

현재 식별한 성능 병목 지점들 중에서 실제 프로덕션 환경에서 우선적으로 개선해야 할 부분은 무엇인지, 효과적인 최적화 전략에 대한 의견 부탁드립니다.

6. 부하 테스트 및 성능 측정 전략

실제 상용 환경과 유사한 조건에서 SSR/SSG 성능을 측정하기 위한 부하 테스트 환경 구축 시 고려해야 할 사항들과, RPS/TPS 목표 수치 설정에 대한 의견 부탁드립니다. 특히 데이터베이스 부하와 프론트엔드 렌더링 부하를 동시에 테스트할 때의 모범 사례가 궁금합니다.

7. 레슨권 페이지 ISR 구현 전략

레슨권 구매 전시페이지에 ISR을 적용할 계획인데, CMS 웹훅을 통한 캐시 무효화 전략이 적절한지, 그리고 24시간 캐시 유지 시간이 비즈니스 요구사항에 맞는지 검토 부탁드립니다. 또한 레슨권별로 개별 캐시 무효화하는 방식 외에 더 효율적인 캐시 관리 방안이 있는지 의견 부탁드립니다.

현재 구현 및 고민사항:

  1. SSG vs ISR 선택 배경

    • 초기에는 SSG로 해결하려 했으나, 레슨권 정보가 자주 업데이트되는 특성상 ISR이 더 적합하다고 판단
    • CMS 툴에서 변경될 때마다 무조건 재빌드하는 것은 비효율적이라 생각
    • 하지만 회사에서 개선해봤었던 Nuxt2 서비스에서 ISR 관련 문제를 겪은 경험이 있어 신중하게 접근 중
  2. 캐시 전략 관련 고민

    • 가격 정보 변경 시 캐시 무효화 타이밍 (실시간 vs 배치)
    • 재고가 있는 레슨권의 경우 실시간 재고 반영 vs 캐시 우선 전략
    • 대량의 레슨권(1000개 이상)에서 ISR 재생성 비용 최적화 방안
    • CDN 캐시와 ISR 캐시의 동기화 전략
    • 사용자별 맞춤 추천 레슨권의 캐시 관리
  3. 실제 마주한 문제점들

    • CMS 툴에서 변경될 때마다 무조건 재빌드하면 비용이 많이 발생
    • 그날 마감숙제에서 생긴했던 캐시무효화 API를 쓰나 툴이라고
    • CMS 툴에서 변경됐을때 무효화 트리거시키는 부분은나 만들면 되지않을까 싶음
  4. 현재 고려 중인 해결 방안

    • 레슨권 데이터 변경 유형별 캐시 전략 차별화:
      // 변경 유형별 캐시 정책 설정
      const cachePolicy = {
        price: { revalidate: 'realtime' },    // 가격: 실시간 갱신
        description: { revalidate: 'batch' },  // 설명: 배치 처리
        stock: { revalidate: 'hybrid' }        // 재고: 하이브리드
      }
      
    • 캐시 무효화 우선순위 시스템 도입:
      // 우선순위 기반 캐시 무효화
      const invalidationPriority = {
        HIGH: ['price', 'stock'],           // 즉시 무효화
        MEDIUM: ['description', 'images'],  // 배치 무효화
        LOW: ['reviews', 'ratings']         // 정기 무효화
      }
      
  5. 검토 요청사항

    • 위와 같은 캐시 전략이 실제 운영환경에서 효과적일지
    • 하이브리드 캐시 전략 (일부는 실시간, 일부는 배치)의 구현 복잡도 대비 효과
    • 대규모 레슨권 데이터에서의 성능과 비용 최적화 방안
    • 사용자 경험을 해치지 않으면서도 시스템 부하를 최소화할 수 있는 균형점

과제 피드백

와 준형님 과제를 이렇게까지 잘 해주실줄은 생각도 못했네요! 너무 멋집니다!

관련해서 질문도 많이 주셨지만 질문들이 모두 저도고민이 많이 필요한 질문들인 것 같아요. 시간이 많이 필요할 것 같습니다 ㅜㅜ 나중에 준일 코치님 솔루션 코드를 보면서 비교를 해보시면 정답은 아니더라도 코치는 어떻게 생각했는지 알 수 있지 않을까 싶습니다. 근데 준형님이 너무 과제를 잘해주셔서 더이상 뭐를 제안드릴것이 있나 싶을 정도에요. 다시한번 멘토링 노트를 뒤적여볼 정도입니다.

그런데 오히려 제가 궁금한 것이 있는데 AI도구는 사용하신건가요? 사용하지 않으신건가요? 이게 일주일만에 가능한건가요? ㅎㅎ 정말 놀랍네요

수고하셨습니다. 자신있게 BP후보로 올립니다.

아무래도 신경쓰여서 준일님에게 과제의 의도에 맞게 답변 부탁드리니 아래와 같이 장문으로 정리해주셨습니다!!

(1) SSR 상태 격리 방식의 적절성 검토

현재 entry-server.tsx에서 각 요청마다 라우트를 재등록하는 방식으로 상태 격리를 구현했는데, 이 방식이 성능상 문제가 없는지, 더 효율적인 대안이 있는지 검토 부탁드립니다.

라우터를 등록하는 부분 자체는 성능에 영향을 많이 주진 않는답니다 ㅎㅎ 개선을 한다고 해도 효과가 미미하달까.. 다만 지금 router를 dynamic import 로 불러오고 있는데 이렇게 하더라도 자연스럽게 import된 결과물이 캐시되기 때문에, 매번 똑같은 인스턴스를 가르키게 되어요. router context로 만들어서 관리하는 의미가... 없는거죠 ㅠ

이런 방식으로 만들어서 관리할 수 있지 않을까 싶네요!

Router Factory

// router-factory.ts
export function createSSRRouter(): UniversalRouter {
  const router = new UniversalRouter(BASE_URL);
  
  // 라우트 설정을 미리 구성된 템플릿에서 복사
  ROUTE_CONFIGS.forEach(config => {
    router.addRoute(config.path, config.component, config.options);
  });
  
  return router;
}

// entry-server.tsx
export const render = async (url: string) => {
  const router = createSSRRouter(); // 매번 새로운 인스턴스
  const productStore = createProductStore();
  
  // 이제 라우트 재등록 불필요
  return await router.resolve(url);
};

Context Isolation 패턴

// ssr-context.ts
export interface SSRContext {
  router: UniversalRouter;
  store: ProductStore;
  request: {
    url: string;
    headers: Record<string, string>;
  };
}

export function createSSRContext(url: string): SSRContext {
  return {
    router: createSSRRouter(),
    store: createProductStore(),
    request: { url, headers: {} }
  };
}

(2) Vite 인스턴스 생명주기 관리의 안정성

static-site-generate.ts에서 Vite 인스턴스를 관리하는 방식이 대용량 SSG 생성 시에도 안정적으로 동작할지, 메모리 누수 가능성은 없는지 확인 부탁드립니다.

음... 저는 굳이 vite를 사용해야하나? 라는 의견인데요, ssg는 "빌드된 CSR 결과물"이 필요하기 때문에, csr로 미리 빌드해놓고 빌드된걸 가져다 사용하는 방식으로 하면 vite를 사용할 필요가 없다고 생각해요. 그리고 이런 과정으로 만들어야 더 정확하게 만들어질 수 있지 않나!? 라는 생각이 드네요!

개발 테스트를 위해 vite 인스턴스를 사용할 수 있으나, 대용량 생성을 개발모드에서 하는건 위험하지 않을까요!?

결론은... vite를 아예 안 쓰는 방향으로 만들어야한다고 생각합니다.

(3) Universal Router 패턴의 확장성

현재 구현한 Universal Router가 복잡한 라우팅 시나리오(중첩 라우트, 동적 임포트 등)에서도 안정적으로 동작할지, 개선할 부분이 있는지 조언 부탁드립니다.

현재 구현은 기본적인 라우팅 요구사항을 충족하지만, 복잡한 시나리오에서는 확장이 필요합니다. 몇 가지 예시를 제시해볼게요!

중첩 라우트 지원

interface RouteConfig {
  path: string;
  component: ComponentType;
  children?: RouteConfig[];
  guards?: RouteGuard[];
  meta?: RouteMeta;
}

class EnhancedUniversalRouter extends UniversalRouter {
  addNestedRoute(config: RouteConfig): void {
    this.addRoute(config.path, config.component, {
      getServerSideProps: config.getServerSideProps,
      generateMetaData: config.generateMetaData,
      children: config.children,
    });
    
    // 중첩된 자식 라우트들도 재귀적으로 등록
    if (config.children) {
      config.children.forEach(child => {
        const childPath = `${config.path}${child.path}`;
        this.addNestedRoute({ ...child, path: childPath });
      });
    }
  }
}

... (135줄 남음) 접기 message.txt 11KB shiren — 오후 5:45 헉........... 이렇게까지 감사합니다!!!! 준일님!!  ᓚ₍ ^. ̫ .^₎ 황준일 junil.hwang

(1) SSR 상태 격리 방식의 적절성 검토

현재 entry-server.tsx에서 각 요청마다 라우트를 재등록하는 방식으로 상태 격리를 구현했는데, 이 방식이 성능상 문제가 없는지, 더 효율적인 대안이 있는지 검토 부탁드립니다.

라우터를 등록하는 부분 자체는 성능에 영향을 많이 주진 않는답니다 ㅎㅎ 개선을 한다고 해도 효과가 미미하달까.. 다만 지금 router를 dynamic import 로 불러오고 있는데 이렇게 하더라도 자연스럽게 import된 결과물이 캐시되기 때문에, 매번 똑같은 인스턴스를 가르키게 되어요. router context로 만들어서 관리하는 의미가... 없는거죠 ㅠ

이런 방식으로 만들어서 관리할 수 있지 않을까 싶네요!

Router Factory

// router-factory.ts
export function createSSRRouter(): UniversalRouter {
  const router = new UniversalRouter(BASE_URL);
  
  // 라우트 설정을 미리 구성된 템플릿에서 복사
  ROUTE_CONFIGS.forEach(config => {
    router.addRoute(config.path, config.component, config.options);
  });
  
  return router;
}

// entry-server.tsx
export const render = async (url: string) => {
  const router = createSSRRouter(); // 매번 새로운 인스턴스
  const productStore = createProductStore();
  
  // 이제 라우트 재등록 불필요
  return await router.resolve(url);
};

Context Isolation 패턴

// ssr-context.ts
export interface SSRContext {
  router: UniversalRouter;
  store: ProductStore;
  request: {
    url: string;
    headers: Record<string, string>;
  };
}

export function createSSRContext(url: string): SSRContext {
  return {
    router: createSSRRouter(),
    store: createProductStore(),
    request: { url, headers: {} }
  };
}

(2) Vite 인스턴스 생명주기 관리의 안정성

static-site-generate.ts에서 Vite 인스턴스를 관리하는 방식이 대용량 SSG 생성 시에도 안정적으로 동작할지, 메모리 누수 가능성은 없는지 확인 부탁드립니다.

음... 저는 굳이 vite를 사용해야하나? 라는 의견인데요, ssg는 "빌드된 CSR 결과물"이 필요하기 때문에, csr로 미리 빌드해놓고 빌드된걸 가져다 사용하는 방식으로 하면 vite를 사용할 필요가 없다고 생각해요. 그리고 이런 과정으로 만들어야 더 정확하게 만들어질 수 있지 않나!? 라는 생각이 드네요!

개발 테스트를 위해 vite 인스턴스를 사용할 수 있으나, 대용량 생성을 개발모드에서 하는건 위험하지 않을까요!?

결론은... vite를 아예 안 쓰는 방향으로 만들어야한다고 생각합니다.

(3) Universal Router 패턴의 확장성

현재 구현한 Universal Router가 복잡한 라우팅 시나리오(중첩 라우트, 동적 임포트 등)에서도 안정적으로 동작할지, 개선할 부분이 있는지 조언 부탁드립니다.

현재 구현은 기본적인 라우팅 요구사항을 충족하지만, 복잡한 시나리오에서는 확장이 필요합니다. 몇 가지 예시를 제시해볼게요!

중첩 라우트 지원

interface RouteConfig {
  path: string;
  component: ComponentType;
  children?: RouteConfig[];
  guards?: RouteGuard[];
  meta?: RouteMeta;
}

class EnhancedUniversalRouter extends UniversalRouter {
  addNestedRoute(config: RouteConfig): void {
    this.addRoute(config.path, config.component, {
      getServerSideProps: config.getServerSideProps,
      generateMetaData: config.generateMetaData,
      children: config.children,
    });
    
    // 중첩된 자식 라우트들도 재귀적으로 등록
    if (config.children) {
      config.children.forEach(child => {
        const childPath = `${config.path}${child.path}`;
        this.addNestedRoute({ ...child, path: childPath });
      });
    }
  }
}

동적 임포트 지원

interface LazyRouteConfig {
  path: string;
  component: () => Promise<{ default: ComponentType }>;
  getServerSideProps?: () => Promise<any>;
}

class LazyRouter extends EnhancedUniversalRouter {
  async addLazyRoute(config: LazyRouteConfig): Promise<void> {
    const componentPromise = config.component();
    
    this.addRoute(config.path, async (context) => {
      const { default: Component } = await componentPromise;
      return Component(context);
    }, {
      getServerSideProps: config.getServerSideProps,
    });
  }
}

라우트 가드 및 미들웨어

type RouteGuard = (context: RouteContext) => boolean | Promise<boolean>;

interface RouteContext {
  path: string;
  params: Record<string, string>;
  query: Record<string, string>;
  headers: Record<string, string>;
}

class GuardedRouter extends LazyRouter {
  async resolve(url: string): Promise<string> {
    const match = this.findRoute(url);
    
    if (match?.guards) {
      for (const guard of match.guards) {
        const canActivate = await guard(match.context);
        if (!canActivate) {
          throw new Error(`Route access denied: ${url}`);
        }
      }
    }
    
    return super.resolve(url);
  }
}

권장 확장 구조

// routes/index.ts
export const routeConfigs: RouteConfig[] = [
  {
    path: '/',
    component: Home.PageComponent,
    getServerSideProps: Home.getServerSideProps,
    generateMetaData: Home.generateMetaData,
  },
  {
    path: '/product',
    component: ProductLayout,
    children: [
      {
        path: '/:id',
        component: ProductDetail.PageComponent,
        getServerSideProps: ProductDetail.getServerSideProps,
        guards: [authGuard, productExistsGuard],
      },
      {
        path: '/:id/reviews',
        component: () => import('./ProductReviews').then(m => m.default),
      }
    ],
  },
];

사실 기존에 잘 작성된 라우터를 참고해보면 좋습니다 ㅎㅎ react-router 를 참고해본다거나..!?

(4) 타입 안전성 개선 방안

TypeScript 마이그레이션 과정에서 타입 정의가 불완전한 부분이나, 더 타입 안전하게 개선할 수 있는 부분이 있는지 검토해주세요.

음... 이건 AI가 남긴 것 같은 질문이네요 ㅋㅋ 전체적인 내용을 훑어보기는 어려운것 같아요. 준형님께서 개선하고 싶은 타입을 언급해주시면 이에 대한 답변은 드릴 수 있을 것 같습니다. 이야기하자면 끝이 없다보니...

(5) 성능 최적화 우선순위

현재 식별한 성능 병목 지점들 중에서 실제 프로덕션 환경에서 우선적으로 개선해야 할 부분은 무엇인지, 효과적인 최적화 전략에 대한 조언 부탁드립니다.

성능 개선이라기보단, 첫 번째 질문에서 언급한 router에 대한 부분과 ssg를 사용할 때 vite를 쓰는 부분을 개선해야한다고 생각해요.

  1. 라우터 등록 오버헤드는 큰 이슈는 아닌 것 같아서 패스해도 무방해보입니다.
  2. ssg 생성시 vite를 아예 안 쓰도록 해주세요. 빌드된걸 사용하면 고민할 필요가 없는 문제일 것 같아요.
  3. 상품 직렬화는 성능개선이라기보단 설계와 가까운 부분인데, 이것도 nextjs 같은 친구들이 어떤방식으로 사용하고 있는지 참고해보면 좋겠습니다.
  4. 대량의 상품 페이지 SSG 생성 시 메모리 사용량 증가는 사실 당연한 것 같아요 ㅎㅎ 메모리 사용량을 줄이려면 10개씩 묶어서 생성한다거나 혹은 정말 필요한 페이지만 ssg로 만들어서 사용하는 방법이 있답니다! 모든 페이지를 만들어서 사용할 필요가 없어요..! 이건 비즈니스에 대한 전략이 필요하다고 생각해요.

(6) 부하 테스트 및 성능 측정 전략

실제 상용 환경과 유사한 조건에서 SSR/SSG 성능을 측정하기 위한 부하 테스트 환경 구축 시 고려해야 할 사항들과, RPS/TPS 목표 수치 설정에 대한 조언 부탁드립니다. 특히 데이터베이스 부하와 프론트엔드 렌더링 부하를 동시에 테스트할 때의 모범 사례가 궁금합니다.

프론트엔드에서 DB 부하를 테스트할 필요가 있을까요? 이건 별도의 테스트가 필요하다고 생각해요. 프론트엔드 렌더링 부하의 경우에는... 사실 매번 렌더링을 해서 사용하기보단 보통 캐시를 사용하고 실시간으로 구성이 필요하다면 SSR이 아니라 CSR로 구성해야 한다고 생각합니다.

RPS/TPS 에 대한 수치는 https://partnerjun.tistory.com/95 이 블로그를 참고해주세요!

인스턴스 하나가 얼만큼의 트래픽을 버텨낼 수 있는지 측정한 다음에 Scale in / out 을 하는 방식으로 관리할 수 있을 것 같습니다.

그리고 모든 페이지에 대해 SSR을 할 것인지, SSG를 할 것인지에 대한 의사결정도 필요해요. 단순히 성능최적화 해야지! 라는 전략보단... 어떤 페이지에 대해 어떤 목적으로 최적화를 할지에 대한 의사결정이 선행되어야할 것 같습니다.

(7) 레슨권 페이지 ISR 구현 전략 검토

레슨권 구매 전시페이지에 ISR을 적용할 계획인데, CMS 웹훅을 통한 캐시 무효화 전략이 적절한지, 그리고 24시간 캐시 유지 시간이 비즈니스 요구사항에 맞는지 검토 부탁드립니다. 또한 레슨권별로 개별 캐시 무효화하는 방식 외에 더 효율적인 캐시 관리 방안이 있는지 조언해주세요.

고정되는 부분과 고정되지 않는 부분을 고려해서 일부분은 Client에서 API로 불러와서 그리도록 하면 어떨까 싶어요. 제안해주신 것 처럼 CMS 툴에서 초기화한 다음에 다시 빌드하는 방법도 좋을 것 같은데, 아예 매번 빌드를 하기보다 api 캐시만 갱신하고 (데이터만 다시 생성해주고), 그 다음에 API를 호출해서 client 에서 렌더링하면 ssg의 장점과 csr의 장점을 함께 누릴 수 있을 것 같네요!

아니면 만들어야 하는 페이지가 많지 않다면 그냥 대략 30 ~ 60초마다 SSR에서 만들어서 캐싱해주면 어떨까 싶기도합니다! 캐싱된 결과물을 레디스에 올려놓고 사용한다거나!?