tomatopickles404 님의 상세페이지[9팀 권지호] Chapter 4-1 성능 최적화

과제 체크포인트

배포 링크

기본: https://tomatopickles404.github.io/front_6th_chapter4-1/vanilla/ 심화: https://hanghae-plus.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 (상품 상세 페이지들)
  • 빌드 타임 페이지 생성
  • 파일 시스템 기반 배포

구현 과정 돌아보기

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

발제부터 2번 들었습니다. 가장 어려웠던 부분이라기 보다는.. 매 순간이 어려웠습니다. 제가 이해했다고 생각하고 코드를 작성하고 확인하면 매 순간 제 예상과 다른 결과였고, 이를 디버깅 하는 것에 대해 감을 잡는게 어려웠습니다.

제가 어려워하는 이유는 원리를 제대로 이해하고 있지 못하고 있기 때문이라고 생각했고, 어떤 부분을 이해하지 못하는 것인지 파악하기 위해 서버사이드 렌더링 흐름과 프로젝트 구조에 대한 파악을 해야겠다고 생각했습니다.

먼저, 브라우저의 서버 사이드 렌더링의 흐름을 한 번 정리하고 프로젝트의 구조를 이해해보고자 했습니다. 그리고 작성해야 하는 각 파일이 그 흐름 중 어디에서 동작하는지, 어떤 역할을 하는지 분석했습니다.

SSR 기본 흐름

  1. 클라이언트 요청 → 서버에 문서 요청
  2. 서버 렌더링 → React 컴포넌트 실행하여 완전한 HTML 생성
  3. 응답 전송 → 브라우저에 HTML 전송
  4. HTML 로드 → 브라우저가 HTML 렌더링
  5. 하이드레이션 → JS 번들 로드 후 인터랙티브 페이지 완성

진입점 파일들

packages/vanilla/
├── src/
│   ├── main.js              # 클라이언트 엔트리 (하이드레이션 포함)
│   ├── main-server.js       # 서버 엔트리 (SSR)
│   ├── render.js            # 클라이언트 렌더링 로직
│   ├── stores/              # 상태 관리
│   │   ├── productStore.js  # 상품 상태
│   │   ├── cartStore.js     # 장바구니 상태
│   │   └── uiStore.js       # UI 상태
│   ├── services/            # 비즈니스 로직
│   ├── router/              # 라우팅
│   ├── pages/               # 페이지 컴포넌트
│   ├── components/          # UI 컴포넌트
│   └── lib/                 # 유틸리티
├── server.js                # Express SSR 서버
└── static-site-generate.js  # SSG (미완성)
파일역할동작 시점
server.jsExpress SSR 서버서버 시작
main-server.jsSSR 렌더링 함수 제공서버 요청 시
main.js클라이언트 진입점 - 이벤트 등록, 라우터 시작, MSW 설정브라우저 로드 시
render.js렌더링 엔진 - Store 변화 감지하여 자동 리렌더링클라이언트 런타임

vanilla 프로젝트 흐름

1. 서버 시작 (server.js)

  • Express 서버 초기화 및 포트 파인딩
  • 개발/프로덕션 환경별 미들웨어 설정
  • Vite 개발 서버 연동
  • 정적 파일 서빙 설정

2. 사용자 접근

app.use(/^(?!.*\/api).*$/, async (req, res) => {
  try {
    const url = normalizeUrl(req.originalUrl, base);

    // 개발 환경에서만 매 요청마다 최신 모듈 로드 (HMR 지원)
    if (!isProduction) {
      template = await fs.readFile("./index.html", "utf-8");
      template = await vite.transformIndexHtml(url, template);
      render = (await vite.ssrLoadModule("/src/main-server.js")).render;
    }

    // SSR 렌더링
    const rendered = await render(url, req.query);

    // 클라이언트 하이드레이션용 초기 데이터 스크립트 생성
    const initialDataScript = rendered.initialData
      ? `<script>window.__INITIAL_DATA__ = ${JSON.stringify(rendered.initialData)}</script>`
      : "";

    // 렌더링 결과를 HTML 템플릿에 주입
    const html = template
      .replace("<!--app-head-->", rendered.head ?? "")
      .replace("<!--app-html-->", rendered.html ?? "")
      .replace("</head>", `${initialDataScript}</head>`);

    res.status(200).set({ "Content-Type": "text/html" }).send(html);
  } catch (error) {
    // 개발 환경에서 스택 트레이스 정리
    if (!isProduction && vite) {
      vite.ssrFixStacktrace(error);
    }

    console.error("SSR 렌더링 에러:", error.message);
    res.status(500).end(error.stack);
  }
});
  • main-server.js의 render 함수 호출
  • 서버에서 페이지 컴포넌트를 실행하여 HTML 생성
  • 초기 데이터를 window.__INITIAL_DATA__로 주입
  • 완전한 HTML 문서를 브라우저에 전송

3. 서버사이드 렌더링 상세 과정

export const render = async (url, query) => {
  // 1. 라우터 설정 및 시작
  router.setUrl(url, "http://localhost");
  router.query = query;
  router.start();

  // 2. 라우트 찾기 및 핸들러 실행
  const routeInfo = router.findRoute(url);
  const result = await routeInfo.handler(routeInfo.params);
  
  return result; // { initialData, html, head }
};

4. 클라이언트 하이드레이션 JavaScript 로드 후 상태 복원 및 SPA 활성화

function main() {
  registerAllEvents();
  registerGlobalEvents();
  loadCartFromStorage();
  initRender();
  router.start();
}

if (import.meta.env.MODE !== "test") {
  enableMocking().then(main);
} else {
  main();
}

[하이드레이션 상세 과정]

  • MSW 모킹 활성화
  • 초기 데이터 복원
// pages/HomePage.js:14-28
if (window.__INITIAL_DATA__?.products?.length > 0) {
  const { products, categories, totalCount } = window.__INITIAL_DATA__;
  productStore.dispatch({
    type: PRODUCT_ACTIONS.SETUP,
    payload: { products, categories, totalCount, loading: false, status: "done" }
  });
  return;
}
  • Store 구독 설정

5. 반응형 상태관리 사용자 액션 → Store 변경 → Observer 알림 → 자동 렌더링 6. SPA 라우팅 이후 네비게이션은 CSR로 처리 (페이지 새로고침 없음)

  • 클라이언트에서 URL 변경 감지
  • 라우트 매칭 및 페이지 컴포넌트 실행
  • Store 상태 업데이트 및 자동 렌더링
  • 브라우저 히스토리 API 활용

이렇게 구조와 함께 흐름을 정리 하고 난 뒤에는 과제의 요구사항에 대한 이해가 되었습니다. 같은 이름의 함수가 두 곳에 있어서 혼란스러웠는데, 각 역할이 명확히 구분됨을 이해했습니다.

// server.js - HTTP 서버 레이어 (웹서버 역할)
const rendered = await render(url, req.query);  // main-server.js의 render 호출
const html = template
  .replace("<!--app-html-->", rendered.html)    // 렌더링 결과를 HTML에 주입
  .replace("</head>", `${initialDataScript}</head>`);

// main-server.js - 실제 SSR 로직 (SSR 엔진 역할)
export const render = async (url, query) => {
  router.setUrl(url, "http://localhost");       // 라우팅
  const routeInfo = router.findRoute(url);      
  const result = await routeInfo.handler();     // 컴포넌트 실행 & 데이터 프리페칭
  return { html, head, initialData };           // React → HTML 변환 결과
};

main.js와 main-server.js에 대해 모호했던 것들에 대한 이해가 되었고, 디버깅 할 때도 서버 렌더링 문제인지, 클라이언트 하이드레이션 관련 문제인지를 구분할 수 있었습니다.


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

서버사이드에서의 인스턴스 생성 전략

SSR 환경은 클라이언트/서버에 맞게 각각 다른 라우터를 써야 하는데, 이러한 환경에서 팩토리 패턴은 유용할 수 있습니다.

[현재] 라우터를 싱글톤으로 생성하여 사용하고 있습니다.

const CurrentRouter = typeof window !== "undefined" ? Router : ServerRouter;

export const router = new CurrentRouter(BASE_URL);

[발생한 문제들]

  1. E2E 테스트 동시성 문제 여러 테스트가 동시에 같은 라우터 인스턴스를 공유하여 net::ERR_CONNECTION_REFUSED 및 상태 오염 발생했습니다.
  2. 라우터 상태 공유 문제
  3. 클라이언트 하이드레이션 실패
  • window.__INITIAL_DATA__가 제대로 주입되지 않음
  • SSR이 실패하여 CSR로 fallback

근본적인 원인은 동시성 문제였습니다. 여러 요청이 하나의 라우터 인스턴스 상태를 공유하면서 발생한 문제였습니다.

[동시성 문제가 발생하는 원리] Node.js는 싱글 스레드 이벤트 루프로 동작하지만, 비동기 요청들은 동시에 처리됩니다. 문제는 다음과 같은 상황에서 발생했습니다.

// 전역 싱글톤 라우터
export const router = new CurrentRouter(BASE_URL);

// 동시에 들어온 요청들
// 요청 A: /products 
// 요청 B: /.well-known/something
// 요청 C: /

export const render = async (url, query) => {
  router.setUrl(url, "http://localhost");  // ← 여기서 문제!
  router.query = query;
  router.start();
  // ...
};

[실행 순서]

  1. 요청 A가 router.setUrl("/products")를 호출
  2. 요청 B가 router.setUrl("/.well-known/something")를 호출 → A의 상태 덮어씀
  3. 요청 C가 router.setUrl("/")를 호출 → B의 상태 덮어씀
  4. 각 요청이 router.findRoute()를 호출할 때는 마지막에 설정된 URL만 남아있음

결과적으로,

  • 요청 A는 자신이 설정한 /products가 아닌 /로 라우팅
  • 요청 B는 예상과 다른 결과 받음
  • 하나의 라우터 인스턴스가 여러 요청의 상태를 뒤섞어서 예측 불가능한 동작 발생

근본적인 원인은 상태를 가진 객체를 여러 비동기 요청이 공유하면서 발생하는 전형적인 동시성 문제였습니다.

개선을 시도해볼 수 있는 방법은 여러가지가 있었습니다.

  1. 서버사이드에서 라우터 인스턴스를 개별 생성한다.

  2. 서버사이드에서 msw를 사용하는 비동기 이슈를 발생시키지 않고, 동기적으로 목데이터를 활용하여 데이터를 호출한다.

저는 라우터 인스턴스를 개별 생성하는 것이 더 적절하다고 생각했지만, 병목이 발생하여 과제에는 목데이터를 사용하여 해결했습니다. 따라서 개선 방안에는 라우터 인스턴스를 생성하여 해결하는 과정을 정리해보았습니다.

[개선 방안]

  1. main-server.js 수정
// 기존: 전역 라우터 사용
export const render = async (url, query) => {
  router.setUrl(url, "http://localhost");  // 상태 공유 문제
};

// 개선: 요청마다 새 라우터 생성
export const render = async (url, query) => {
  const router = new ServerRouter(BASE_URL);  // 독립적인 인스턴스
  
  router.addRoute("/", HomePage);
  router.addRoute("/product/:id/", ProductDetailPage);
  router.addRoute(".*", NotFoundPage);
  
  router.setUrl(url, "http://localhost");
  router.query = query;
  router.start();
};
  1. 클라이언트는 싱글톤 유지
// main.js - 클라이언트는 싱글톤 유지 (문제없음)
import { router } from './router/router.js';
function main() {
  registerAllEvents();
  registerGlobalEvents();
  loadCartFromStorage();
  initRender();
  router.start(); // 하나의 브라우저 세션에서는 싱글톤이 적절
}

Next.js, Nuxt.js와 같은 프레임워크들이 요청별로 독립적인 컨텍스트를 생성하는 이유이기도 합니다. Next.js에서 Tanstack Query의 인스턴스를 사용할 때, 왜 SSR에서는 매번 인스턴스를 생성해야 하는지에 대해 생각해보지 않았었는데 이번 과제를 통해 근본적인 원인을 알아가게 되었습니다.

학습 갈무리

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

1. 라우터 확장성 개선

SSR 환경은 클라이언트/서버에 맞게 각각 다른 라우터를 써야 하는데, 이러한 환경에서 팩토리 패턴은 유용할 수 있습니다.

[현재] 라우터를 싱글톤으로 생성하여 사용하고 있습니다.

[개선 방안]

  • RouterFactory 패턴으로 확장성 개선
export class RouterFactory {
  static createRouter(environment, options = {}) {
    const baseConfig = {
      timeout: options.timeout || 5000,
      enableLogging: options.debug || false
    };

    switch (environment) {
      case 'client':
        return new Router({
          ...baseConfig,
          history: window.history,
          enablePushState: true
        });
      
      case 'server':
        return new ServerRouter({
          ...baseConfig,
          enableConcurrency: true,
          memoryLimit: options.memoryLimit || '512mb'
        });
      
      case 'test':
        return new TestRouter({
          ...baseConfig,
          mockHistory: options.mockHistory,
          enableSnapshot: true
        });
        
      case 'worker':
        return new WorkerRouter({
          ...baseConfig,
          messagePort: options.port
        });
    }
  }
}

// 환경별 사용 예시
const devRouter = RouterFactory.createRouter('server', { 
  debug: true, 
  timeout: 10000 
});

const testRouter = RouterFactory.createRouter('test', { 
  mockHistory: mockHistoryInstance 
});
  • 미들웨어와 플러그인 지원
// 환경별로 다른 미들웨어 적용
case 'server':
  const router = new ServerRouter(baseConfig);
  router.use(new SecurityMiddleware());
  router.use(new LoggingMiddleware());
  return router;

case 'client':
  const router = new Router(baseConfig);
  router.use(new AnalyticsMiddleware());
  return router;

[개선 효과]

  • 각 환경(서버/클라이언트/테스트)에 필요한 설정과 의존성을 한 곳에서 관리할 수 있습니다.
  • 환경이 달라도 동일한 방식으로 라우터 생성 가능합니다.
  • Worker, Electron 등 새로운 실행 환경에 대한 라우터를 switch 문에 추가만 하면 확장 가능합니다.

2. 캐싱 전략 개선

[현재] 현재는 매 요청마다 서버에서 렌더링을 수행하고 있습니다.

[개선 방안] 계층별 캐싱 전략을 적용해 성능과 확장성을 개선할 수 있습니다.

class CacheManager {
  constructor() {
    this.pageCache = new Map();     // 페이지 레벨 캐싱
    this.componentCache = new Map(); // 컴포넌트 레벨 캐싱
    this.dataCache = new Map();     // API 응답 캐싱
  }

  async getOrRender(cacheKey, renderFn, ttl = 3600) {
    if (this.pageCache.has(cacheKey)) {
      return this.pageCache.get(cacheKey);
    }
    
    const result = await renderFn();
    this.pageCache.set(cacheKey, result);
    
    // TTL 적용
    setTimeout(() => this.pageCache.delete(cacheKey), ttl * 1000);
    return result;
  }
}

// 사용 예시
export const render = async (url, query) => {
  const cacheKey = `${url}?${new URLSearchParams(query).toString()}`;
  
  return cacheManager.getOrRender(cacheKey, async () => {
    const router = RouterFactory.createRouter('server', BASE_URL);
    // ... 기존 렌더링 로직
  });
};

[개선 효과]

  • 성능 향상: 동일한 페이지 요청 시 캐시된 결과 즉시 반환
  • 서버 부하 감소: 렌더링 작업 최소화로 CPU 사용량 절약
  • 확장성: 캐시 무효화 전략과 분산 캐시 적용으로 대규모 트래픽 대응
  • SEO 최적화: 빠른 응답 시간으로 검색 엔진 최적화 개선

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

Express는 전통적인 Node.js 서버 환경에서 동작하는 반면, Cloudflare Workers나 Vercel Edge Functions 같은 서버리스 환경은 여러 환경에서 호환되는 웹 표준 API을 기반으로 동작합니다. 따라서 다른 런타임에서도 동작하기 위해서는 Node.js에 의존적인 부분을 웹 표준 API를 기반으로 변경해야 합니다.

수정이 필요한 부분들은 아래와 같습니다.

1. 서버 초기화 방식 변경

[Express]

const app = express();
app.use(express.static('dist'));
app.listen(PORT, () => console.log(`Server running on ${PORT}`));

[서버리스 환경으로 수정]

// Cloudflare Workers
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    // SSR 로직
    return new Response(html, {
      headers: { 'Content-Type': 'text/html' }
    });
  }
};

// Vercel Edge Functions
export default async function handler(request) {
  const url = new URL(request.url);
  // SSR 로직
  return new Response(html);
}

2. 파일 시스템 접근 제거

[기존 문제가 되는 코드]

// server.js - 파일 시스템 의존
template = await fs.readFile("./index.html", "utf-8");

[서버리스 대응으로 수정]

// 빌드 타임에 템플릿을 번들에 포함
import templateContent from './index.html?raw';
const template = templateContent;

// 또는 환경 변수로 주입
const template = env.HTML_TEMPLATE || templateContent;

3. 상태 관리 방식 변경

[Express]

let globalCache = new Map(); // 서버리스에서는 요청 간 공유 안됨

[외부 스토리지로 수정]

// Cloudflare Workers
export default {
  async fetch(request, env, ctx) {
    const cached = await env.CACHE_KV.get(cacheKey);
    if (!cached) {
      const result = await renderPage();
      await env.CACHE_KV.put(cacheKey, JSON.stringify(result));
    }
    return new Response(JSON.parse(cached).html);
  }
};

// Vercel Edge Functions
import { kv } from '@vercel/kv';
const cached = await kv.get(cacheKey);
if (!cached) {
  await kv.setex(cacheKey, 3600, JSON.stringify(result));
}

4. 요청/응답 처리 방식 수정

[Express]

app.get('*', (req, res) => {
  const query = req.query;
  const headers = req.headers;
  res.setHeader('Content-Type', 'text/html');
  res.send(html);
});

[Web API 표준으로 수정]

// 서버리스 환경에서는 Web API 사용
export default async function handler(request) {
  const url = new URL(request.url);
  const query = Object.fromEntries(url.searchParams);
  const headers = Object.fromEntries(request.headers);
  
  return new Response(html, {
    headers: { 'Content-Type': 'text/html' }
  });
}

이렇게 Node.js 런타임에서 동작하는 부분을 웹 표준을 준수하는 코드로 전환하는 것이 핵심입니다.


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

[문제점] 개발 환경에서 매 요청마다 파일을 읽고 모듈을 로드하고 있습니다.

if (!isProduction) {
  template = await fs.readFile("./index.html", "utf-8");
  template = await vite.transformIndexHtml(url, template);
  render = (await vite.ssrLoadModule("/src/main-server.js")).render;
}

[개선 방안]

const moduleCache = new Map();
const templateCache = new Map();

// 개발 환경에서만 매 요청마다 최신 모듈 로드 (HMR 지원)
if (!isProduction) {
  const cacheKey = `template_${url}`;
  
  if (!templateCache.has(cacheKey)) {
    const fileTemplate = await fs.readFile("./index.html", "utf-8");
    const transformedTemplate = await vite.transformIndexHtml(url, fileTemplate);
    templateCache.set(cacheKey, transformedTemplate);
  }
  
  template = templateCache.get(cacheKey);
  
  if (!moduleCache.has('render')) {
    const renderModule = await vite.ssrLoadModule("/src/main-server.js");
    moduleCache.set('render', renderModule.render);
  }
  
  render = moduleCache.get('render');
}
  • 파일 시스템 접근(fs.readFile)은 상대적으로 느린 I/O 작업인데, 캐싱으로 두 번 째 요청부터는 메모리에서 즉시 반환합니다.
  • vite.ssrLoadModule은 모듈 파싱, 변환, 평가 과정을 거치기 때문에 한 번 로드된 모듈을 재사용하여 CPU 사용량 감소할 수 있습니다.
  • vite.transformIndexHtml은 HTML 변환 작업 수행하는데, 이 작업을 재사용하여 비용을 줄일 수 있습니다.

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

SSG는 빌드 타임에 모든 페이지를 미리 생성하여 런타임 성능 최적화와 함께 빠른 응답 속도를 목표로 사용된다고 생각합니다. 따라서 이러한 점에 초점을 맞추어 고려해본다면 아래와 같이 정리할 수 있습니다.

1. 빌드 시간 최적화

빠른 배포와 지속적인 업데이트를 위해서는 빌드 시간이 관리 가능한 범위 내에 있어야 합니다.

[병렬 처리]

const BATCH_SIZE = 50;
const batches = chunk(products, BATCH_SIZE);

for (const batch of batches) {
  await Promise.all(batch.map(product => generateProductPage(product.id)));
}

[증분 빌드] 전체 1000개 상품 중 실제로 변경되는 것은 보통 5-10% 내외입니다. 변경되지 않은 990개 페이지를 매번 재생성하는 것은 비효율적입니다. 따라서,

  • 변경된 상품만 재생성하여 전체 빌드 시간 단축
  • 변경 감지 기반으로 불필요한 재빌드 방지

2. 메모리 사용량 관리

// 문제가 되는 패턴
const allProducts = await db.getAllProducts(); // 1000개 × 10KB = 10MB
const pages = [];

for (const product of allProducts) {
  const html = await renderPage(product); // 렌더링 중 메모리 누적
  pages.push(html); // 생성된 HTML도 메모리에 계속 보관
}
// 최종적으로 100MB+ 메모리 사용 가능

[메모리 관리가 필요한 이유]

  • 각 페이지 렌더링 시 가상 DOM 생성으로 실제 데이터의 5-10배 메모리 사용
  • 템플릿 엔진이 중간 객체들을 생성하며 메모리 사용량 증가
  • 기본 힙 크기 제한(약 1.7GB)에 도달하면 빌드 실패

[개선 방안] 스트리밍 방식으로 데이터 처리

async function generateWithMemoryManagement() {
  const productStream = getProductStream(100); // 100개씩 스트리밍
  
  for await (const productBatch of productStream) {
    await processBatch(productBatch);
    productBatch = null; // 명시적 참조 해제
    
    if (global.gc) global.gc(); // 가비지 컬렉션 강제 실행
  }
}

3. CDN 캐시 무효화 전략

상품 하나가 변경될 때마다 전체 CDN 캐시를 무효화한다면 다음과 같은 문제점이 발생할 수 있습니다.

  • 성능 저하: 다음 사용자 요청 시 모든 페이지가 origin 서버에서 다시 로드
  • 서버 부하: 1000개 페이지가 동시에 캐시 미스로 origin 서버에 몰림
  • 비용 증가: CDN 무효화 요청 횟수에 따른 비용 발생

[선택적 무효화]

// 스마트한 무효화 전략
const invalidationPaths = [
  `/product/${changedProductId}`,           // 변경된 상품 페이지
  `/category/${product.categoryId}`,        // 해당 카테고리 페이지
  '/products',                              // 상품 목록 페이지
  '/sitemap.xml'                           // 사이트맵
];
// 4개 페이지만 무효화하여 996개 페이지의 캐시 효율성 유지

[개선 효과]

  • 응답 속도 유지: 변경되지 않은 페이지들은 여전히 CDN에서 빠르게 제공
  • 서버 부하 분산: 일부 페이지만 origin 서버에서 로드하므로 트래픽 분산
  • 점진적 업데이트: 변경 사항이 필요한 부분만 즉시 반영

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

Hydration 과정에서 사용자가 느낄 수 있는 UX 이슈는 다음과 같습니다.

1. 깜빡임 현상 (Flash of Unstyled Content)

SSR로 생성된 HTML과 클라이언트에서 하이드레이션된 결과가 일치하지 않을 때 발생합니다.

[개선 방안]

if (window.__INITIAL_DATA__?.products?.length > 0) {
  // 서버에서 전달받은 초기 데이터로 즉시 상태 설정
  productStore.dispatch({
    type: PRODUCT_ACTIONS.SETUP,
    payload: { ...window.__INITIAL_DATA__, loading: false }
  });
  return; // 추가 API 호출 방지
}

2. 상호작용 불가 기간 (Uncanny Valley)

사용자는 버튼이나 링크를 볼 수 있지만 클릭해도 반응하지 않는 기간이 존재합니다.

[개선 방안]

  • 로딩 인디케이터로 상태 명확화
  • 점진적 개선(Progressive Enhancement) 적용

3. 레이아웃 시프트 (Layout Shift)

동적 콘텐츠 로드 시 페이지 레이아웃이 갑자기 변경되는 현상입니다.

[개선 방안]

  • 스켈레톤 UI로 레이아웃 대체

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

현재 에러 로깅이 부족하여 프로덕션 환경에서는 해당 부분이 보완되어야 합니다.

} catch (error) {
  // 개발 환경에서 스택 트레이스 정리
  if (!isProduction && vite) {
    vite.ssrFixStacktrace(error);
  }

  console.error("SSR 렌더링 에러:", error.message);
  res.status(500).end(error.stack); // ❌ 프로덕션에서 스택 노출
}
  • 프로덕션에서도 스택 트레이스가 노출되고 있음
  • 에러 분류 및 적절한 응답 코드 부족
  • 에러 로깅 및 모니터링 부재
// productService.js:36-42 
} catch (error) {
  productStore.dispatch({
    type: PRODUCT_ACTIONS.SET_ERROR,
    payload: error.message,
  });
  throw error; // ❌ 에러를 다시 던져서 처리되지 않음
}

사용자에게 적절한 에러 메세지 제공도 부족합니다.

[개선 방안] 서버, 클라이언트 사이드에 맞는 에러 핸들링을 강화하고 에러 바운더리 및 폴백 UI를 적용합니다.

// 프로덕션 환경 설정
const isProduction = process.env.NODE_ENV === 'production';

// 에러 핸들러 초기화
const errorHandler = new ErrorHandler();
const clientErrorHandler = new ClientErrorHandler();
const errorBoundary = new ErrorBoundary();
const logger = new ErrorLogger();

// 전역 에러 핸들러 등록
window.addEventListener('error', (event) => {
  errorBoundary.handleError(event.error, 'Global', {
    filename: event.filename,
    lineno: event.lineno,
    colno: event.colno
  });
});

window.addEventListener('unhandledrejection', (event) => {
  errorBoundary.handleError(event.reason, 'Promise', {
    type: 'unhandledrejection'
  });
});

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

[장점]

  1. 커스텀 자유도
  • 프로젝트 요구사항에 맞는 적합한 방식으로 구현할 수 있다
  • 불필요한 기능을 제거하여 번들 크기를 줄일 수 있다
  • 특정 비즈니스 로직에 특화된 렌더링 전략을 적용해볼 수 있다
  1. 의존성 제어
  • 외부 라이브러리 의존도를 최소화할 수 있다
  • 보안 취약점 노출 범위를 축소할 수 있다
  • 업데이트 및 호환성 이슈에 자유롭다

[단점]

  1. 개발 및 유지보수 비용
  2. 안정성과 검증 부족
  3. 개발도구, 디버깅 환경 부족

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

App Router 방식에서는 dynamic 옵션을 export하여 렌더링 방식을 제어할 수 있습니다.

1. SSG 페이지 생성

// app/products/page.js
export const dynamic = 'force-static';

export default async function ProductsPage() {
  const products = await fetchProducts();
  
  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

이 밖에도 다양한 dynamic 옵션들이 있습니다. 기본값은 auto입니다.

// dynamic 옵션들
export const dynamic = 'auto';          // 기본값: 자동 판단
export const dynamic = 'force-dynamic'; // 강제 SSR
export const dynamic = 'force-static';  // 강제 SSG
export const dynamic = 'error';         // 동적 함수 사용 시 에러
  • ISR(Incremental Static Regeneration) 설정
export const revalidate = 3600; // ISR: 1시간마다 재생성

// 또는 fetch 레벨에서 설정
const products = await fetch('/api/products', {
  next: { revalidate: 3600 }
});

2. 빌드

next.config.js 설정

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'export',        // 정적 파일로 출력
  trailingSlash: true,     // URL 끝에 슬래시 추가
  
  // 또는 서버리스 환경에서 ISR 활용
  // output: 'standalone'
};

standalone 모드란?

Next.js에서 제공하는 배포 옵션 중 하나로, 서버가 필요한 기능들을 포함하면서도 독립적으로 실행 가능한 애플리케이션을 생성하는 방식 빌드 시 .next/standalone 폴더에 Node.js 서버와 필요한 모든 의존성이 포함된 독립적인 애플리케이션이 생성됩니다.

언제 사용하는가?

  • ISR(Incremental Static Regeneration) 필요
  • API Routes 사용
  • 동적 렌더링이 필요한 페이지 존재
  • 서버 사이드 기능이 필요한 경우

3. 배포

정적 호스팅으로 배포합니다.

  • GitHub Pages
  • AWS S3 + CloudFront
  • Netlify
  • Vercel (정적 모드)
// package.json
{
  "scripts": {
    "build:static": "next build",
    "deploy:s3": "aws s3 sync out/ s3://your-bucket --delete",
    "deploy:netlify": "netlify deploy --prod --dir=out"
  }
}

코드 품질 향상

개선하고 싶은 부분

라우터 방식 변경

앞서 언급했던 라우터 인스턴스 생성 방식을 클라이언트/서버에 맞는 방식으로 개선하여 동시성 문제를 해결하고자 합니다.

App에서 fallback 처리

[문제] CSR 빌드 서버에서의 빈 화면 React 패키지에서 빌드한 서버를 구현하는 것에 병목이 가장 심했습니다. 로컬 서버에서는 csr이 잘 렌더링 되는데 빌드한 서버(preview)에서는 네트워크는 이상이 없고 빈 화면이 나와 원인을 디버깅하기가 어려웠습니다. 제가 알아낸 결론은 렌더링할 컴포넌트가 포함되지 않은 상태로 빌드되어 빈 화면이 나오는 것이라고 판단했고, 현재는 csr 테스트코드 통과를 위해 App에서 컴포넌트를 분기처리를 하는 방식으로 테스트 코드를 통과 시켰습니다. 이 방식은 적절한 방식은 아니라고 생각되기도 하고, 제가 파악한 원인도 정확한 원인이 아닐 수 있을 것 같아서 이부분에 대한 원인을 찾아서 해결하고 싶습니다.

[축약한 현재 구조]

// App.tsx
export const App = () => {
  const PageComponent = useCurrentPage();
  const query = useRouterQuery();

  return (
    <>
      <ToastProvider>
        <ModalProvider>
          {PageComponent ? (
            <PageComponent />
          ) : (
            <HomePage
              searchQuery={query.search}
              limit={query.limit}
              sort={query.sort}
              category1={query.category1}
              category2={query.category2}
            />
          )}
        </ModalProvider>
      </ToastProvider>
      <CartInitializer />
    </>
  );
};

// HomePage.tsx
export const HomePage: FC<HomePageProps> = ({ 
  searchQuery, limit, sort, category1, category2 
} = {}) => {
  // props로 받은 데이터 사용
  return (
    <PageWrapper headerLeft={headerLeft}>
      <SearchBar
        initialSearchQuery={searchQuery}
        initialLimit={limit}
        // ...
      />
    </PageWrapper>
  );
};

학습 연계

다음 학습 목표

구현하면서 알게 되었던 동작 원리나 개선 포인트들을 학습하면서 Next.js에서는 이런 점들을 어떻게 풀어냈을지 궁금해졌습니다. Next.js가 제공하는 렌더링 최적화 전략들(스트리밍이나 Partial Prerendering와 같은)이나 구현 방식에 대해 마인드맵을 그려보며 복습해보고, 이 과제에서 구현한 방식과 비교해보면 조금 더 정리가 잘 될 것 같습니다.

리뷰 받고 싶은 내용

  1. 이 과제를 다시 한 번 수행 한다면 추가로 구현해보면 도움이 될만한 것들을 추천해주실 수 있으실까요?! 과제를 확장해서 같이 경험하면 좋을 것 같은 것이 있다면 추천해주시면 감사하겠습니다!

  2. 앞에서 언급한 개선하고 싶은 부분의

App에서 fallback 처리

에서 언급한 문제의 원인을 알고 싶습니다. 주된 병목은 로컬 서버에서 csr, ssr가 잘 동작한다면 그대로 빌드한 서버에서도 동일하게 화면이 잘 나올 것이라고 예상-> 빈 화면 에서의 디버깅이었습니다. 이런 현상에 대한 원인은 주로 어떤 것인지, 가장 먼저 어떤 부분을 생각해야할까요? 그리고 이런 상황에서 디버깅을 어떤 순서대로 수행해야할 지 감을 잡는 것이 어려웠습니다!

과제 피드백

수고했습니다. 이번 과제는 SSR과 SSG를 직접 구현해보면서 렌더링 전략의 차이점과 성능 최적화 방법을 체험하는데 목적이 있었습니다.

발제를 두 번 들으며 매 순간이 어려웠다고 솔직하게 표현해주셨는데, 그 과정에서 SSR 흐름과 프로젝트 구조를 체계적으로 정리하신 부분이 매우 좋았어요. 특히 클라이언트 요청부터 하이드레이션까지의 1~6단계 정리와 각 파일들의 역할을 표로 정리한 것이 명확했습니다.

동시성 문제를 발견하고 분석한 과정이 깊이 있었네요. "하나의 라우터 인스턴스가 여러 요청의 상태를 뒤섞어서 예측 불가능한 동작 발생"이라는 핵심을 정확히 파악하셨어요. 싱글톤 패턴이 서버 환경에서 어떤 문제를 일으키는지 실제로 경험해보신 거죠.

언제나 회고나 리뷰에 진심인게 느껴져서 좋네요. Q1-Q8까지의 세부적인 내용에 대해서 잘 작성을 해주는 것 같아요. 앞으로도 면접이나 정말 내것으로 만들기 위해서 지금 회고하면서 적어 본 내용을 실제 육성으로 말해보거나 누군가에게 설명해보는 연습까지 해보면 좋겠네요

Q) 이 과제를 다시 한 번 수행 한다면 추가로 구현해보면 도움이 될만한 것들을 추천해주실 수 있으실까요?!
과제를 확장해서 같이 경험하면 좋을 것 같은 것이 있다면 추천해주시면 감사하겠습니다!

=> 인증이나 쿠키, 서버-클라이언트간 상태 공유 등 서버와 클라이언트 간의 데이터 교환이나 기능등을 로그인과 같은 인증을 구현해보면서 메타 프레임워크가 어떤 식으로 동작하는지 만들어보면 훨씬 더 큰 깊이를 알 수 있을거에요

=> 그 밖에 Next.js나 Remix등에서 공통적으로 제공하는 개념적 기능들에 대해서 저건 어떻게 만들었을까? 왜 만들었을까? 에 대해서 고민하면서 한번 만들어 보심을 추천합니다 :)

Q) 1 앞에서 언급한 개선하고 싶은 부분의 App에서 fallback 처리 에서 언급한 문제의 원인을 알고 싶습니다.
주된 병목은 로컬 서버에서 csr, ssr가 잘 동작한다면 그대로 빌드한 서버에서도 동일하게 화면이 잘 나올 것이라고 예상-> 빈 화면 에서의 디버깅이었습니다. 이런 현상에 대한 원인은 주로 어떤 것인지, 가장 먼저 어떤 부분을 생각해야할까요? 그리고 이런 상황에서 디버깅을 어떤 순서대로 수행해야할 지 감을 잡는 것이 어려웠습니다!

=> 크롬 디버그 툴이라는 정말 막강한 디버그 툴의 도움을 받으면서 개발을 하다가 SSR로 넘어오게 되면 잘 보이지도 않는 콘솔에 의지하면서 디버깅을 해야하는 경험이 정말 힘들죠. 브라우저는 분명 먹통인데 알수 있는 정보는 없고 말에요 ㅎㅎ

=> 같은 코드를 사용하지만 환경이 다르다 보니 환경에 의한 차이로 실제 중간값이 다른 경우인 경우가 많아서 문제가 되는 경우가 많죠. 그리고 이제부터 어디가 문제인지를 찾고 예상할 수 있는 능력이 점점 더 중요해진답니다.

=> 일반적으로야 모든 개발자가 그러하듯이 콘솔 로그를 보면서 문제가 생길만한 곳을 코드를 보며 예측하면서 run & fix를 하죠. 정 안되겠다 싶을때 제가 주로 하는 방법은 문제가 예상되는 곳을 주석처리하거나 skip하고 하드코딩을 해서 어떻게 해서는 정상동작하는 환경을 만들고 하나씩 붙여가는 식으로 확인을 하곤 합니다. (혹은 커밋을 뒤로 돌려가면서요). 일단 정상동작을 만들고 나면 마음이 편하기도 하고 하나씩 밟아가면서 터지는 곳을 발견할 수 있으니까요. 지호도 지호 나름대로의 방법을 찾아가 보기를 바래요.

수고많았습니다! 마지막 과제도 화이팅입니다