Skip to content

[5팀 진재윤] Chapter 4-1 성능최적화: SSR, SSG, Infra #18

Open
jy0813 wants to merge 19 commits intohanghae-plus:mainfrom
jy0813:main
Open

[5팀 진재윤] Chapter 4-1 성능최적화: SSR, SSG, Infra #18
jy0813 wants to merge 19 commits intohanghae-plus:mainfrom
jy0813:main

Conversation

@jy0813
Copy link

@jy0813 jy0813 commented Dec 15, 2025

과제 체크포인트

배포 링크

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

아하! 모먼트 (A-ha! Moment)

1. SSR에서 싱글톤 Store는 모든 사용자가 공유된다.

CSR에서 당연하게 쓰던 코드가 SSR에서 치명적인 버그를 일으켰습니다.

// CSR에서는 문제없음 - 각 브라우저 탭이 독립된 JS 컨텍스트
export const productStore = createStore(reducer, initialState);

A 사용자가 "노트북"을 검색하고, B 사용자가 "마우스"를 검색하면 — Node.js는 싱글 스레드지만 비동기로 요청을 처리하기 때문에, A의 렌더링 시점에 B의 검색 결과가 Store에 덮어씌워져 있을 수 있습니다. 실제로 4개 테스트 실패를 고치려다가 14개 실패로 늘어난 것도 이 문제를 이해하지 못하고 Store에 dispatch하려 했기 때문이었습니다.

깨달음: 브라우저에서는 각 탭이 완전히 독립된 JS 컨텍스트지만, Node.js 서버에서는 모든 요청이 같은 메모리를 공유합니다. 해결책은 전역 Store 대신 요청별 Context로 데이터를 격리하는 것이었습니다. Next.js나 Remix 같은 프레임워크들이 복잡한 상태 격리 메커니즘을 갖추고 있는 이유를 이해하게 되었습니다.


2. typeof window 체크는 언제 하느냐가 핵심이다.

window is not defined 에러를 해결하려고 조건문을 추가했는데 여전히 에러가 났습니다.

// 이 코드는 모듈 로드 시점에 즉시 실행됩니다
const router = new Router();  // ← 생성자에서 window.addEventListener 호출

// 조건문으로 감싸도 이미 늦었습니다
if (typeof window !== "undefined") {
  router.start();  // ← new Router()는 이미 위에서 실행됨
}

깨달음: 조건문은 실행 시점에 평가되지만, new Router()모듈 로드 시점에 즉시 실행됩니다. 해결책은 Lazy Initialization 패턴이었습니다.

let _router = null;
export const getRouter = () => {
  if (!_router && typeof window !== "undefined") {
    _router = new Router();  // 처음 호출할 때만 생성
  }
  return _router;
};

모듈 최상위에서 인스턴스를 생성하지 않고, 실제로 필요한 시점에 생성하도록 변경하니 에러가 해결되었습니다.


3. Hydration은 새로 그리는 것 이 아니라 연결하는 것 이다.

SSR로 HTML을 보냈는데 버튼이 클릭되지 않아서 "클라이언트에서 다시 렌더링해야 하나?"라고 생각했습니다.

깨달음: hydrateRoot()는 이미 있는 DOM을 재사용하면서 이벤트 핸들러만 연결합니다. 그래서 SSR HTML과 클라이언트 렌더링 결과가 정확히 일치해야 합니다.

이번 프로젝트에서 겪은 불일치 사례들:

  • SSR에서는 loading: false로 렌더링했는데, 클라이언트 Store 초기값은 loading: true
  • SSR에서는 상품 20개를 렌더링했는데, 클라이언트에서 즉시 재요청하여 로딩 상태로 전환

해결책은 window.__INITIAL_DATA__를 통해 SSR 데이터를 클라이언트로 전달하고, Store 초기화 시 이 데이터를 사용하여 동일한 상태에서 시작하도록 하는 것이었습니다.


4. MSW는 브라우저용과 Node.js용이 완전히 다른 모듈이다.

Mock Service Worker를 사용하고 있었는데, SSR 서버에서 setupWorker is not a function 에러가 발생했습니다.

// ❌ 브라우저용 - Node.js에서 동작 안함
import { setupWorker } from "msw/browser";

// ✅ Node.js용 - SSR 서버에서 사용
import { setupServer } from "msw/node";

깨달음: MSW는 환경별로 완전히 다른 인터셉트 방식을 사용합니다.

환경 모듈 동작 방식
Browser msw/browser Service Worker가 네트워크 요청 가로채기
Node.js msw/node HTTP 클라이언트 레벨에서 인터셉트

handlers 배열은 공유할 수 있지만, setup 함수는 환경에 맞게 선택해야 합니다. 이 구분을 모르면 SSR 서버에서 API 모킹이 동작하지 않습니다.

자유롭게 회고하기

들어가며

이번 프로젝트에서 CSR 기반 쇼핑몰에 SSR과 SSG를 추가 구현하면서, 프론트엔드 개발자로서 "서버에서 JavaScript가 실행된다"는 것이 무엇을 의미하는지 깊이 이해하게 되었습니다. 단순히 Next.js나 Nuxt.js를 사용하면 해결되는 문제처럼 보이지만, 직접 구현해보니 프레임워크가 얼마나 많은 복잡성을 숨기고 있는지 체감할 수 있었습니다.

기술적 도전과 해결

isServer() vs isSSR() - 미묘하지만 중요한 구분

처음에는 "서버 환경이면 SSR 중인 거 아닌가?"라고 생각했습니다. 하지만 두 개념은 다릅니다.

함수 의미 용도
isServer() Node.js에서 실행 중 window 접근 전 체크
isSSR() SSR 렌더링 데이터 소스 분기, lifecycle 스킵

예를 들어 SSG 빌드 스크립트를 실행할 때:

  • isServer()true (Node.js 환경)
  • isSSR() → 페이지를 렌더링할 때만 true, 빌드 스크립트 자체는 false

이 구분이 있어야 빌드 시점과 렌더링 시점의 로직을 정확히 분리할 수 있습니다.

프레임워크를 넘어선 공통 패턴

Vanilla JS SSR을 먼저 구현하고 React SSR을 구현했는데, 같은 문제가 반복되면서 패턴이 보이기 시작했습니다.

SSR 구현의 공통 패턴:

  1. ServerRouter 필요 (window 의존성 제거)
  2. SSR Context로 요청별 데이터 격리
  3. 환경 체크 헬퍼 함수 (isServer, isBrowser, isSSR)
  4. API URL 분기 (서버: 절대 경로, 클라이언트: 상대 경로)
  5. Hydration 데이터 전달 (window.__INITIAL_DATA__)

프레임워크가 달라도 SSR의 근본적인 도전은 동일합니다. 이 패턴들을 이해하고 있으면 Next.js나 Remix 같은 프레임워크를 사용할 때도 내부 동작을 더 잘 이해할 수 있습니다.

실패에서 배운 교훈

4개 → 14개 테스트 실패 사건

URL 인코딩 관련 4개 테스트가 실패하는 상황에서, "Store에 dispatch하면 되겠지"라고 생각하고 코드를 수정했습니다. 결과는 14개 테스트 실패. 동시성 문제를 더 악화시킨 것이었습니다.

교훈: 문제가 있을 때 "빠른 해결책"보다 "원래 어떻게 동작했는지" 먼저 분석해야 합니다. 처음에 작성하였던 코드(main-server-origin.js)를 다시 읽고 나서야 Store를 사용하지 않는 이유를 이해했습니다.

withLifecycle deps 버그

Vanilla JS에서 useEffect와 유사한 의존성 감지 로직을 구현했는데, 첫 마운트 시 deps를 빈 배열로만 초기화하는 실수를 했습니다.

const mount = (page) => {
  lifecycle.mount?.();
  lifecycle.mounted = true;
  lifecycle.deps = [];  // ← 빈 배열로만 초기화!
};

결과적으로 검색어가 변경되어도 상품 목록이 업데이트되지 않았습니다. lifecycle.deps[0]undefined이기 때문에 비교 함수가 항상 "변경 없음"을 반환했습니다.

교훈: React의 useEffect도 내부적으로 이전 deps를 저장해두고 비교합니다. 직접 구현할 때는 이런 "당연한 동작"도 명시적으로 구현해야 합니다.

처음부터 다시 해본다면

1. SSR-First 설계

CSR로 먼저 만들고 SSR을 추가하는 방식은 많은 리팩토링이 필요했습니다. 다음에는 처음부터 SSR을 고려하여 설계하겠습니다.

  • 브라우저 API 접근은 무조건 조건부
  • 전역 상태는 최소화하거나 Context 기반으로
  • 데이터 페칭 로직은 별도 함수로 분리

2. 환경별 테스트

E2E 테스트가 많은 버그를 잡아줬지만, SSR 전용 단위 테스트가 있었다면 더 빨리 발견했을 것입니다.

test("SSR renders without window access", async () => {
  const { html } = await render("/?search=노트북");
  expect(html).toContain("노트북");
});

3. 문서화 습관

이번처럼 에러 → 원인 → 해결 과정을 기록해두면 같은 실수를 반복하지 않습니다. 특히 "왜 이렇게 했는지"를 남겨두면 나중에 코드를 수정할 때 실수를 방지할 수 있습니다.

마무리

SSR 구현은 결국 같은 코드를 다른 환경에서 실행하는 도전이었습니다.

브라우저에서는 당연히 있는 것들이 서버에서는 없고, 서버에서는 당연한 것들이 브라우저에서는 고려할 필요가 없었습니다. 이 간극을 메우는 과정에서 프론트엔드와 백엔드의 경계에 대해 깊이 생각하게 되었습니다.

Next.js 같은 프레임워크를 사용하면 이 모든 복잡성을 추상화해주지만, 직접 구현해본 경험은 프레임워크의 동작 원리를 이해하고 문제를 디버깅하는 데 큰 도움이 됩니다. 앞으로 SSR 관련 이슈가 발생하면, 이번에 배운 패턴들을 떠올리며 더 빠르게 해결할 수 있을 것입니다.

리뷰 받고 싶은 내용

  1. static-site-generate.js에서 SSG 빌드 시 모든 상품 페이지를 순차적으로 생성하고 있습니다. 현재는 약 20개 상품으로 테스트 중인데, 상품이 1000개 이상으로 늘어났을 때 빌드 시간과 메모리 사용량이 걱정됩니다.
  • 병렬 렌더링(Promise.all + chunking)으로 개선 가능할지
  • 증분 빌드(ISR) 패턴을 적용할 수 있는지
  • 빌드 시점에 페이지 수가 많을 때 권장되는 최적화 방안
  1. ssrContext.ts에서 전역 변수 let ssrContext를 사용하여 요청별 상태를 관리하고 있습니다.
let ssrContext: SSRContext | null = null;                                                                                                  
                                                                                                                                           
export const setSSRContext = (ctx: SSRContext | null): void => {                                                                           
  ssrContext = ctx;                                                                                                                        
};                                                                                                                                         

현재는 try-finally로 렌더링 후 컨텍스트를 클리어하고 있지만, Node.js의 비동기 특성상 동시 요청이 들어오면 컨텍스트가 덮어씌워질 위험이 있지 않은지 검토 부탁드립니다.

예를 들어 A 요청의 렌더링 중에 B 요청이 setSSRContext()를 호출하면, A의 컴포넌트가 B의 데이터를 읽게 되는 것 아닌지 우려되고, 회고에서 "싱글톤 Store는 모든 사용자가 공유된다"를 핵심 깨달음으로 다뤘는데, 현재 SSR Context 구현도 동일한 패턴의 위험이 있는 것 같습니다. 요청별로 Context를 격리하는 더 안전한 방식이 있다면 조언 부탁드립니다.

…ontext() 함수routerHelper.js: getQuery(), getParams() SSR 호환 헬퍼serverRouter.js: 서버용 라우터 구현index.js: 새 모듈 re-export
…refetchData() 직접 반환server.js: render(url) 시그니처로 변경static-site-generate.js: SSG 빌드 호환성 수정동시성 문제 해결: 싱글톤 Store 대신 요청별 컨텍스트 사용
…DetailPage: 동일 패턴 적용SSR에서는 컨텍스트 데이터, CSR에서는 Store 데이터 사용
…e hooks 스킵router.js: SSR 환경 감지 통합render.js: 렌더링 로직 정리
…productService.js: 서비스 레이어 정리mocks: MSW 서버 설정 lib: 유틸리티 및 ServerRouter 추가
  - ssrContext: isSSR(), getSSRData(), setSSRContext() 함수 추가
  - ServerRouter: 서버 환경용 라우터 구현
  - MSW server: Node.js 환경 mock 서버 설정
  - handlers: SSR/CSR 환경 분기 처리
  - main-server.tsx: renderToString 기반 SSR 렌더링
  - ssr-data.ts: 홈페이지/상품상세 데이터 프리페칭
  - productApi: SSR 환경 API 호출 지원 (절대 URL)
  - 관련 상품 SSR 로딩 추가
  - productStore: window.__INITIAL_DATA__ hydration 지원
  - useProductStore: SSR Context 데이터 직접 반환
  - cartStorage: lazy initialization으로 SSR 호환
  - main.tsx: hydrateRoot 조건부 실행
  - router.ts: SSR 환경 감지 및 분기 처리
  - useCurrentPage: SSR Context params 활용
  - useRouterParams/Query: SSR 환경 fallback 처리
  - App.tsx: SSR 환경 lifecycle hooks 스킵
  - server.js: Express SSR 서버 (MSW 통합)
  - static-site-generate.js: 22개 정적 페이지 생성
  - package.json: SSR/SSG 빌드 스크립트 추가
  - preview:ssr tsx 런타임 사용
  - log.ts: window.__spyCalls 조건부 초기화
  - domUtils.ts: isNearBottom SSR guard 추가
  - useRouter, useStore에 세 번째 인자(getServerSnapshot) 추가
  - SSR hydration 시 'Missing getServerSnapshot' 에러 해결
  - 서버/클라이언트 간 상태 일관성 보장
  - HomePage: '쇼핑몰 - 홈' 타이틀 설정
  - ProductDetailPage: 상품명 기반 타이틀 업데이트
  - vanilla 버전과 동일한 UX 제공
  - main.tsx: Store 로드 전 SSR 감지 로직을 동적 import로 분리
  - productStore: hydration 디버그 로그 추가
  - productUseCase: SSR 데이터 존재 시 fetch 스킵 로직 추가
  - productUseCase: router → getRouter() 변경으로 null 체크 처리
  - useProductFilter: 첫 렌더링 시 SSR 데이터 있으면 loadProducts 스킵
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