Skip to content

[4팀 정한슬] Chapter 4-1 성능최적화: SSR, SSG, Infra - #32

Open
hanseul524 wants to merge 14 commits into
hanghae-plus:mainfrom
hanseul524:main
Open

hanseul524 wants to merge 14 commits into
hanghae-plus:mainfrom
hanseul524:main

Conversation

@hanseul524

@hanseul524 hanseul524 commented Dec 17, 2025

Copy link
Copy Markdown

과제 체크포인트

배포 링크

기본과제 (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)

CSR - SSR - SSG: 언제 HTML을 만드는가!

simple ssr 코드를 클론받아서 블로그 설명과 함께 단계별로 진행하면서 CSR - SSR - SSG 의 기본적인 흐름을 익힐 수 있었습니다. 제가 느끼기엔 기본 - 심화 - 초심화 같은 느낌의 과제였습니다. 처음에 simple ssr은 간단해서 흐름 파악하기도 쉽고 어떤 시점에 생성되는지 .. 이런것들을 이해했다고 생각하고 과제로 넘어왔는데 이해한게 아니었다는 것을 알게되었습니다 ..^^..

CSR

  • 실행 시점: 브라우저 런타임
  • HTML 생성 흐름: 빈 HTML 받음 -> JS 다운로드 -> 브라우저에서 DOM 생성

SSR

  • 실행 시점: 서버 요청 시점 (런타임, fetch, ajax 같은 서버 통신 후에..)
  • HTML 생성 흐름: 클라이언트 요청 -> 서버에서 데이터 받아옴 -> HTML 생성 -> 클라이언트 전송

SSG

  • 실행 시점: 빌드 된 파일 바로 읽음
  • HTML 생성 흐름: 빌드 시점에 모든 페이지가 정적 HTML로 미리 생성

Hydration: 서버와 클라이언트의 상태 동기화

이번 과제에서 가장 신기했던 개념이 Hydration이었습니다. 제가 경험해보지 못한 새로운 개념이었습니다. Hydration = 서버와 클라이언트의 상태 동기화 JSP에서는 서버와 클라이언트가 완전히 분리된 세계였습니다. 서버는 HTML을 만들고, 클라이언트는 그걸 받아서 표시하고, 필요하면 다시 요청. Modern SSR에서는 서버와 클라이언트가 같은 상태를 공유합니다:

  1. 서버가 데이터를 조회해서 HTML 생성
  2. 그 데이터를 window.__INITIAL_DATA__로 클라이언트에 전달
  3. 클라이언트는 서버와 똑같은 상태로 시작
  4. 이후 SPA처럼 동작

Hydraiton의 핵심은 서버가 만든 HTML은 그대로 유지하고, 이벤트만 붙이는것 이라고 생각했습니다.

// CSR 방식: DOM을 새로 만듬
const root = document.getElementById('root');
root.innerHTML = ''; // 기존 HTML 삭제
root.appendChild(createProductElement(product)); // 새로 생성

// SSR + Hydration: 기존 HTML 재사용
const root = document.getElementById('root');
// HTML은 그대로 두고
// 이벤트만 붙임
const button = root.querySelector('button');
button.addEventListener('click', handleClick);

Router 분리

각 렌더링 방식은 실행 환경과 실행 시점이 다르기 때문에 구분을 해주어야 하고 처음에 전역 상태로 두었는데 이게 나중에 큰 업보가 되어서 돌아왔습니다. 쩝 .. Router를 다 사용하니 전역에 두고 구분해서 호출해주면 된다고 생각했는데 이런 상태를 전역로 두면 데이터가 섞이고 실행 환경마다 생명주기가 달라 생성해줘야 하는 시점이 그때 그때 다르다는 걸 꺠달았습니다. 처음에 정리가 잘 되지 않아서 콘솔에 로그를 남발해서 언제 요청 하고, 언제 생성해주는지 확인했습니다ㅠㅠ

// ❌ 전역 인스턴스 (이렇게 하면 망함)
const router = new ServerRouter();

export async function render(url) {
  router.push(url); // 여러 요청이 같은 인스턴스 공유
}

// ✅ 요청마다 생성
export async function render(url) {
  const router = new ServerRouter(); // 매번 새로 생성
  router.push(url);
}

자유롭게 회고하기

  • 저는 실무에서 JSP 기반으로 일을 했기 때문에 SSR 환경이 익숙했습니다. (한줄 알았씁니다 ...) 그러나 어느 시점에 HTML을 생성하는지서버 <-> 클라이언트 사이의 흐름을 제대로 이해하지 못한 채로 그냥 사용하고 있었던걸 이번 과제를 하면서 많이 깨달았습니다. JSP에서는 항상 서버 환경에서만 실행되었기 때문에 같은 코드가 서버/클라이언트 두 환경에서 실행될 수 있다는 개념 자체가 생소했습니다.

JSP 환경에서는

  • 서블릿 컨테이너가 알아서 Request Scope 관리
  • 각 요청마다 독립된 JSP 인스턴스 생성
  • session, request 객체가 자동으로 격리됨
  • 프레임워크가 환경 분리를 해줌 (그래서 신경쓸 필요 없음..)

트러블 슈팅 (겁나 많은 ...)

express 버전 호환성 문제

  1. 증상
  • SSR 개발 서버 실행 시 에러 발생
  • CSR 모드는 정상 작동
TypeError: Missing parameter name at 1: https://git.new/pathToRegexpError
    at name (.../path-to-regexp@8.2.0/node_modules/path-to-regexp/dist/index.js:73:19)
  1. 삽질의 여정 (ㅠㅠ)
  1. ESM 모듈 확장자 문제라고 생각 → 모든 import에 .js 추가 → 실패
  2. window is not defined 에러라고 생각 → localStorage, import.meta.env 조건부 처리 → 실패
  3. 클라이언트 Router가 서버에서 로드된다고 생각 → 동적 import 사용 → 실패
  4. Vite가 MSW를 자동 로드한다고 생각 → Vite 설정 수정, 캐시 삭제 → 실패
  5. MSW handlers가 로드된다고 생각 → console.log 추가해서 확인 → MSW 로드 안 됨 확인

-> 모든 시점에 디버깅을 해보다가 Express 문제인걸 확인했습니다.

  • Express v5는 내부 라우팅에 path-to-regexp 사용
  • 프로젝트의 MSW가 의존하는 path-to-regexp@8.2.0과 Express v5 간 호환성 이슈
  • Express 초기화 시점에 path-to-regexp 로드하면서 에러 발생

Store 주입 패턴에 대한 문제
다 구현후에 E2E 테스트를 자신만만하게 돌렸는데 띨롱 .. 다 실패해버리는 크나큰 문제가 있었습니다. 그 중 구조적인 문제로 main-server.js에 작성했던 코드들이었습니다. 컴포넌트가 전역 store를 참조하도록 작성했는데, SSR은 요청별로 독립적인 store가 필요합니다.

// before
// main-server.js
const serverProductStore = createStore(productReducer, initialProductState);
await prefetchData(serverProductStore, ...);

// HomePage.js
const productState = productStore.getState();

// after
  // HTML 생성 할 때 서버 store 주입
  const PageComponent = router.target || NotFoundPage;
  const html = PageComponent
    ? PageComponent({
        productStore: serverProductStore,
        cartStore: serverCartStore,
      })
    : "";
// 나머지 로직
};

페이지 컴포넌트가 store를 파라미터로 받도록 수정해서 SSR은 서버 store를 주입하고 CSR은 파라미터 없이 호출하게 되어 전역 store를 사용하도록 했습니다.

export const HomePage = withLifecycle(
  {
    onMount: () => {
        // ...
      }
    },
    watches: [
      // ...
    ],
  },
  (stores) => {  // stores 파라미터를 받도록 추가
    // SSR: stores가 전달됨 → 서버 store 사용
    // CSR: stores가 undefined → 전역 store 사용 (fallback)
    const currentProductStore = stores?.productStore || productStore;
    const productState = currentProductStore.getState();
    
    const { search: searchQuery, limit, sort, category1, category2 } = router.query || {};
    const { products, loading, error, totalCount, categories } = productState;
    const category = { category1, category2 };
    const hasMore = products.length < totalCount;

    return PageWrapper({
      // ...
    });
  },
);

리뷰 받고 싶은 내용

Q1. SSG 라우팅 문제

  • SSG 렌더링시 routermain-server.js에서 import 되는 시점에 이미 생성되어서 BASE_URL이 그 시점에 결정되어서 자꾸 상세페이지 -> 홈페이지로 보내는 문제가 있었습니다ㅠㅠ 그래서 static-site-generate.js에서 강제로 먼저 경로를 설정하도록 했는데 이방향이 틀린것 같아서 더 좋은 방법이 있을까요?
process.env.NODE_ENV = "production";

import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const OUTPUT_DIR = path.resolve(__dirname, "../../dist/vanilla");
const items = JSON.parse(fs.readFileSync("./src/mocks/items.json", "utf-8"));

const { render } = await import("./src/main-server.js");
const { BASE_URL } = await import("./src/constants.js");

const TEMPLATE = fs.readFileSync(path.join(OUTPUT_DIR, "index.html"), "utf-8");

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