Skip to content

[4팀 한선민] Chapter 4-1 성능최적화: SSR, SSG, Infra#38

Open
1lmean wants to merge 30 commits intohanghae-plus:mainfrom
1lmean:main
Open

[4팀 한선민] Chapter 4-1 성능최적화: SSR, SSG, Infra#38
1lmean wants to merge 30 commits intohanghae-plus:mainfrom
1lmean:main

Conversation

@1lmean
Copy link

@1lmean 1lmean commented Dec 18, 2025

과제 체크포인트

배포 링크

https://1lmean.github.io/front_7th_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 모듈 빌드 (Vite SSR 빌드 설정)
  • Universal React Router (서버/클라이언트 분기)
  • 서버에서 데이터 프리페칭
  • Context를 통한 데이터 주입 (또는 선택한 방식)

React Hydration

  • hydrateRoot 사용 (createRoot 대신)
  • Hydration 불일치 방지
  • 클라이언트 상태 복원 (__INITIAL_DATA__ 활용)

Static Site Generation

  • 동적 라우트 SSG (상품 상세 페이지들)
  • 빌드 타임 페이지 생성

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

Hydration의 본질을 이해한 순간

Hydration이 무엇인지 처음에는 막연했는데, 서버에서 렌더링한 HTML에 클라이언트 JavaScript를 "붙이는" 과정이라는 걸 이해하고 나니 SSR의 전체 흐름이 명확해졌습니다.

서버에서는 이미 완성된 HTML이 브라우저에 전달되고, 클라이언트에서는 그 HTML에 이벤트 리스너와 상태를 연결하는 과정이라는 것을 깨달았을 때, 왜 서버 HTML과 클라이언트 HTML이 정확히 일치해야 하는지도 자연스럽게 이해할 수 있었습니다.

서버에서 Store vs 데이터만 준비하는 차이

바닐라 SSR을 구현할 때는 서버에서 Redux Store를 생성하고 데이터를 dispatch하는 방식을 채택했는데, 이는 과제의 특성과 상관없이 일반적인 경우에 당연히 상태관리가 필요하다는 판단에 얼떨결에 내린 결론이었습니다. 다만, 구현하고 생각해보니 함수형 컴포넌트 특성상 Store를 Props로 쉽게 전달할 수 있는 장점이 있었다는 생각이 듭니다.

하지만 React SSR을 구현하면서, Hook 기반의 React에서는 Store를 Props로 넘길 수 없다는 한계를 발견했습니다. 그래서 서버에서는 데이터만 준비하고, Context로 주입하는 방식을 선택하게 되었습니다. 이 과정에서 "서버는 데이터만 준비하고, 클라이언트는 그 데이터로 Store를 초기화한다"는 철학이 명확해졌습니다.

자유롭게 회고하기

상태관리 방식 선택에 대한 고민

바닐라 SSR 구현 시, 무심결에 상태관리가 필요하다는 생각이 들어 서버사이드에 리덕스 스토어를 구성하는 방식을 채택했는데, 사실 과제 단에서는 필요없다는 누군가의 말에 동의를 해서 심화과제 할때는 컨텍스트만 사용해보려고 했습니다.

바닐라에서는 함수형 컴포넌트의 특성상 Store를 Props로 넘기기가 쉬워서 서버에서 Store를 생성하고 초기화하는 방식이 자연스러웠습니다. 하지만 React에서는 Hook 규칙 때문에 Store를 Props로 전달할 수 없어서 Context를 사용해야 했고, 이것이 오히려 더 깔끔한 해결책이었습니다.

Vanilla vs React 구현 방식의 차이

두 가지 방식 모두 올바른 선택이었지만, 프레임워크의 특성에 따라 최적의 방법이 달라진다는 점이 흥미로웠습니다. Vanilla는 함수형이고 명시적인 Props 전달이 가능해서 간단했고, React는 Hook 기반이라서 Context가 필요했지만 오히려 React의 표준 패턴을 따르게 되어 더 일관성 있는 코드가 되었습니다.

리뷰 받고 싶은 내용

구현 방식에 대한 검토

  1. React SSR에서 Context를 통한 데이터 주입 방식

    • 현재 Context를 사용하여 서버에서 데이터를 주입하는 방식을 채택했습니다.
    • 이 방식이 적절한지, 또는 다른 방법(예: Props Drilling, 서버 전용 컴포넌트 분리 등)이 더 나은지 조언 부탁드립니다.
  2. 서버에서 Store 생성 vs 데이터만 준비하는 트레이드오프 및 동시성 이슈

    • Vanilla에서는 서버에서 Store를 생성하고 초기화하는 방식을 사용했고, React에서는 데이터만 준비하고 Context로 주입하는 방식을 사용했습니다.
    • 현재 Vanilla 구현은 요청마다 createStore()로 새로운 Store 인스턴스를 생성하고 있어서 동시성 이슈는 없지만, 초기에는 싱글톤 DB처럼 Redux Store를 사용하려는 의도를 가졌었습니다.
    • 핵심 질문:
      • 만약 서버에서 Store를 싱글톤으로 사용한다면 여러 요청이 같은 Store를 공유하여 동시성 이슈(race condition)가 발생할 수 있을 것 같은데, 현재처럼 요청마다 새 Store를 만드는 방식과 데이터만 준비하는 방식(React) 중 어느 것이 더 안전하고 적절한지 궁금합니다.
      • 클라이언트에서 데이터를 바로 받아온다고 해서 서버 측 동시성 이슈가 해결되는 것은 아닐 텐데, 이는 순수함수 측면에서만 문제가 있는 것일까요, 아니면 실제 동시성 이슈(여러 요청이 같은 Store 인스턴스 공유)도 고려해야 할까요?
    • 각 방식의 장단점과 트레이드오프, 그리고 프로덕션 환경에서의 성능 및 메모리 사용 관점에서 어떤 방식이 더 유리한지 조언 부탁드립니다.

window 객체가 없는 서버 환경에서도 동작하도록
createStorage 함수를 수정했습니다.

- typeof window !== "undefined" 체크 추가
- 서버 환경에서는 mock storage 사용
- cartStorage가 서버에서도 정상 동작
서버 환경에서 window 없이 동작하는 라우터 클래스를
구현했습니다.

- window.location, window.history 의존성 제거
- URL 파싱을 Node.js 환경에서 동작하도록 구현
- query 파라미터 파싱 기능 포함
- 라우트 매칭 및 파라미터 추출 기능
서버와 클라이언트 환경에 따라 적절한 라우터를
사용하도록 분기 처리했습니다.

- window 존재 여부로 환경 판단
- 서버: ServerRouter 사용
- 클라이언트: Router 사용
lib/index.js에 ServerRouter를 export하여
다른 모듈에서 import 가능하도록 했습니다.
main-server.js에서 productReducer를 사용할 수 있도록
export를 추가했습니다.
Express 기반 SSR 서버를 구현했습니다.

- 개발/프로덕션 환경 분기 처리
- Vite SSR 미들웨어 연동 (개발)
- 정적 파일 서빙 (프로덕션)
- HTML 템플릿 치환 (<!--app-head-->, <!--app-html-->)
- initialData 스크립트 주입
main-server.js에 SSR 렌더링 함수를 구현했습니다.

- URL 파싱 및 쿼리 파라미터 추출
- ServerRouter를 통한 라우트 매칭
- 데이터 프리페칭 (상품 목록, 상품 상세)
- SSR-safe 컴포넌트를 통한 HTML 생성
- 초기 데이터 직렬화

데이터 프리페칭 기능:
- 홈페이지: 상품 목록 필터링, 페이지네이션, 카테고리
- 상품 상세: 상품 정보 조회 및 상세 정보 추가
- filterProducts, getUniqueCategories 함수 포함
서버에서 받은 초기 데이터로 클라이언트 store를
초기화하는 hydration 로직을 구현했습니다.

- window.__INITIAL_DATA__에서 데이터 읽기
- productStore에 서버 데이터 주입
- 초기 데이터 사용 후 메모리 정리
HomePage를 서버와 클라이언트에서 모두 사용 가능하도록
분리했습니다.

- HomePageView: SSR-safe (store, query를 props로 받음)
- HomePageClient: 클라이언트 전용 (withLifecycle + router.query)
- 서버에서는 HomePageView 사용
- 클라이언트에서는 HomePageClient 사용
render.js에서 HomePage를 import할 수 있도록
HomePageClient를 HomePage로 re-export했습니다.

- HomePageClient를 HomePage로 별칭 export
- 클라이언트에서 HomePage 사용 가능
상품 상세 페이지를 SSR-safe 컴포넌트와 클라이언트 전용 컴포넌트로 분리했습니다.

- ProductDetailPageView: SSR-safe 컴포넌트 (store, params를 props로 받음)
- ProductDetailPage: 클라이언트 전용 컴포넌트 (withLifecycle 사용)
- main-server.js에서 ProductDetailPageView 사용하도록 변경
- 홈페이지 title을 '쇼핑몰 - 홈' 형식으로 변경
- 상품 상세 페이지 title을 '상품명 - 쇼핑몰' 형식으로 변경
- 관련 상품 찾기 로직 개선 (productId 타입 변환 및 fallback 추가)
- 같은 카테고리의 상품을 관련 상품으로 표시
- main-server.js의 render 함수를 사용하여 모든 페이지 정적 생성
- 홈페이지 및 모든 상품 상세 페이지를 HTML 파일로 생성
- build:ssg 스크립트에 build:server 단계 추가
- Node.js v22에서 JSON import 시 명시적 type 속성 필요
- static-site-generate.js에서 items.json import 수정
- Vite 개발 서버 통합 및 프로덕션 빌드 지원
- Express 서버에 SSR 렌더링 로직 추가
- 압축 및 정적 파일 서빙 설정
- 필요한 의존성 추가 (express, compression, nodemon)
- log.ts: window 사용을 조건부 처리하여 서버에서 안전하게 동작하도록 수정
- ModalProvider: document.body 접근을 조건부 처리하여 SSR-safe로 변경
- ToastProvider: document.body 접근을 조건부 처리하여 SSR-safe로 변경
- main-server.tsx: 기본 render 함수 구현 (renderToString 사용)
- server.js: Vite SSR 통합 및 HTML 템플릿 치환 로직 추가
- main-server.tsx: store 없이 데이터만 준비하여 반환
- 라우트 매칭 및 데이터 프리페칭 로직 구현
- React 컴포넌트 서버 렌더링 (renderToString)
- main.tsx: __INITIAL_DATA__로 클라이언트 store 초기화
- vite-env.d.ts: window.__INITIAL_DATA__ 타입 정의 추가
- ProductStoreContext 생성: 서버에서 데이터 주입용 Context
- useProductStore 수정: Context 우선 사용, 없으면 기존 store 사용
- main-server.tsx: Context Provider로 데이터 주입하여 렌더링
- 서버와 클라이언트가 동일한 데이터로 렌더링하여 Hydration 불일치 해결
- 서버에서는 라우트 매칭 결과로 페이지 컴포넌트 직접 렌더링
- lazy initialization으로 변경하여 서버에서 window.localStorage 접근 방지
- 서버에서는 빈 storage 객체 반환, 클라이언트에서만 실제 localStorage 사용
- 모듈 레벨에서 createStorage 호출 제거하여 SSR 에러 해결
Router 클래스가 서버 환경에서도 동작하도록 수정했습니다.
- window/document 접근을 환경 체크로 감싸서 서버에서 에러 방지
- 이벤트 리스너는 클라이언트에서만 등록
- 서버에서는 라우트 매칭과 쿼리 파싱만 수행

React SSR hydration을 위해 createRoot를 hydrateRoot로 변경했습니다.
useSyncExternalStore를 사용하는 hooks들에 SSR을 위한
getServerSnapshot 파라미터를 추가하여 React 18+ SSR 요구사항 충족

- useProductStore: productStore에 getServerSnapshot 제공
- useCartStoreSelector: cartStore에 getServerSnapshot 제공
- useLoadCartStore: cartStore에 getServerSnapshot 제공
- useRouterQuery: router.query에 getServerSnapshot 제공
- useRouterParams: router.params에 getServerSnapshot 제공
- useCurrentPage: router.target에 getServerSnapshot 제공
useSyncExternalStore를 조건부 호출하지 않도록 수정
Hook은 항상 같은 순서로 호출되어야 하므로,
useSyncExternalStore를 항상 호출하고 Context가 있으면 우선 사용하도록 변경
- useRouterQuery에서 SSR 시 전역 변수로 URL 읽어서 쿼리 파싱
- main-server에서 router import 및 SSR URL 전역 변수 설정 추가
- 브랜드 필드 null 체크 추가
- 타입 안정성 개선 (as const, 타입 명시)
- 미사용 변수 제거
- sort, limit, searchQuery의 기본값 명시적 처리
- undefined/null 값으로 인한 오류 방지
- defaultValue에 항상 유효한 값 전달
GitHub Pages에 정적 사이트 배포 자동화
dist/vanilla, dist/react 폴더 구조에 맞춰 404.html 생성 경로 수정
- 루트에 프로젝트 선택 페이지 추가
- vanilla/react 각 폴더의 404.html 생성
- 404 에러 해결
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