Skip to content

[1팀 천진아] Chapter 4-1 성능최적화: SSR, SSG, Infra#21

Open
totter15 wants to merge 16 commits intohanghae-plus:mainfrom
totter15:main
Open

[1팀 천진아] Chapter 4-1 성능최적화: SSR, SSG, Infra#21
totter15 wants to merge 16 commits intohanghae-plus:mainfrom
totter15:main

Conversation

@totter15
Copy link

@totter15 totter15 commented Dec 15, 2025

과제 체크포인트

배포 링크

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

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

SSR/SSG

SSR이란?

클라이언트요청 -> 서버에서 HTML생성 -> 클라이언트에응답

SSG란?

빌드시점에 HTML을 만들어서 배포. 이후 클라이언트에 해당 빌드 응답.

SSR동작 과정

1. 클라이언트 요청

2. Express 서버 (server.js)

let vite;
if (!prod) {
  const { createServer } = await import("vite");
  vite = await createServer({
    server: { middlewareMode: true },
    appType: "custom",
  });
  app.use(vite.middlewares);
} else {
  const compression = (await import("compression")).default;
  const sirv = (await import("sirv")).default;
  app.use(compression());
  app.use(base, sirv("./dist/vanilla", { extensions: [] }));
}

-comporession: HTTP응답 압축으로 전송데이터 크기감소.
-app.use(compression()): 모든 응답에 압축적용
-sirv: 정적 파일 서빙 미들웨어.
-app.use(base, sirv("./dist/vanilla", { extensions: [] })): base-기본경로, ./dist/vanilla-사용할 정적 파일 디렉토리, { extensions: [] }-확장자 자동추가 비활성화. ['html']로 설정시 /about요청시 /about.html을 찾음. SSR에서 HTML은 동적으로 생성되므로 정적 HTML파일을 자동으로 찾지 않도록함.

프로덕션 환경 (NODE_ENV=production)
  
compression 미들웨어 적용 (모든 응답 압축)
  
sirv 미들웨어 적용 (정적 파일 서빙)
  
요청이 오면:
  1. 정적 파일 요청  sirv가 처리 (assets/*.js, *.css )
  2. HTML 요청  아래 app.use("*all")에서 SSR 처리

3. URL 파싱 및 base 제거

app.use("*all", async (req, res) => {
  // 모든 요청을 여기서 처리
  const url = req.originalUrl.replace(base, "");  // base 경로 제거
  // 예: "/front_7th_chapter4-1/vanilla/" → "/"
  
  // 개발/프로덕션 분기
  if (!prod) {
    // 개발: Vite로 실시간 로드
    render = (await vite.ssrLoadModule("./src/main-server.js")).render;
  } else {
    // 프로덕션: 빌드된 파일 사용
    render = (await import("./dist/vanilla-ssr/main-server.js")).render;
  }
  
  const rendered = await render(url);  // SSR 렌더링
  // ...
});

4. main-server.js의 render() 호출

export const render = async (url) => {
  // 1. URL 정규화
  const normalizedUrl = url || "/";
  
  // 2. 새로운 ServerRouter 인스턴스 생성 (요청마다 독립적)
  const router = new ServerRouter("");
  
  // 3. 라우트 등록
  router.addRoute("/", HomePage);
  router.addRoute("/product/:id/", ProductDetailPage);
  router.addRoute(".*", NotFoundPage);
  
  // 4. URL 설정 및 라우트 매칭
  router.setUrl(normalizedUrl);
  router.start();  // 라우트 찾기
  
  // 5. 매칭된 페이지 컴포넌트 가져오기
  const PageComponent = router.target;
  
  // 6. 데이터 프리패칭 (loader 실행)
  let pageData = null;
  if (PageComponent && PageComponent.loader) {
    pageData = await PageComponent.loader(router);
    // 예: HomePage.loader() → API 호출 → 데이터 반환
  }
  
  // 7. 반환값 구성
  return {
    head: `<title>${title || ""}</title>`,
    html: () => PageComponent?.(data, router),  // HTML 생성 함수
    data: data || {},  // Hydration용 초기 데이터
  };
};

5. ServerRouter로 라우트 매칭

class ServerRouter {
  setUrl(url) {
    this.#currentUrl = url;
    // 쿼리 파라미터 파싱
    const urlObj = new URL(url, "http://localhost");
    this.#query = ServerRouter.parseQuery(urlObj.search);
  }
  
  start() {
    this.#route = this.#findRoute(this.#currentUrl);
    // 정규식으로 라우트 매칭
    // 예: "/" → HomePage 매칭
  }
  
  get target() {
    return this.#route?.handler;  // 매칭된 컴포넌트 반환
  }
}

6. PageComponent.loader() 실행 (데이터 프리패칭)

HomePage.loader = async (serverRouter) => {
  // 서버에서 데이터 프리패칭
  const [productsData, categories] = await Promise.all([
    getProducts(serverRouter.query),  // API 호출
    getCategories()
  ]);
  
  return {
    data: {
      products: productsData.products,
      categories,
      totalCount: productsData.pagination.total
    },
    title: "쇼핑몰 - 홈"
  };
};

// HomePage 컴포넌트 (23-53번 줄)
export const HomePage = withLifecycle(
  { onMount: ..., watches: ... },
  (serversideProps, serverRouter) => {
    // SSR에서 전달된 데이터 사용
    const productState = serversideProps || productStore.getState();
    // HTML 문자열 반환
    return PageWrapper({ ... });
  }
);

7. PageComponent() 호출 (HTML 생성)

const { data, title } = pageData ?? {};
  return {
    head: `<title>${title || ""}</title>`,
    html: () => PageComponent?.(data, router),
    data: data || {},
  };

8. 템플릿에 주입 및 응답

const rendered = await render(url);
// rendered = {
//   head: "<title>쇼핑몰 - 홈</title>",
//   html: () => "<div>...</div>",  // 함수
//   data: { products: [...], ... }
// }

const html = template
  .replace(
    `<!--app-head-->`,
    `${rendered.head}\n<script>window.__INITIAL_DATA__ = ${JSON.stringify(rendered.data)}</script>`
  )
  .replace(`<!--app-html-->`, rendered.html());  // 함수 실행하여 HTML 생성

res.send(html);  // 클라이언트에 전송

9. 클라이언트 Hydration

// 클라이언트에서
if (window.__INITIAL_DATA__) {
  // 서버에서 전달된 데이터로 store 초기화
  productStore.dispatch({
    type: PRODUCT_ACTIONS.SETUP,
    payload: window.__INITIAL_DATA__
  });
}

자유롭게 회고하기

진행중 마주친 몇가진 문제 혹은 고민들

server에서 사용될 router 문제

일단 Router함수를 그대로 복사해서 ServerRouter로 만들어주었고, server에서 사용되지 않는 부분들만 수정해주었습니다.
router.push관련 삭제 -> SSR에서는 불필요.
query관련 수정 -> SSR에서 동적으로 query를 받을 필요는 없기에 setUrl로 요청된 url의 query값만 parsing해서 관리해주었습니다.

main-server.js에서 render시 어떤걸 render하면 좋을까?

  1. 순수 js로 되어있는 components파일들을 page에서 처럼 동일하게 작성.
  2. pages에 있는 컴포넌트를 그대로 가져오기.
    => 중복코딩을 막기위해서 2번대로 page컴포넌트를 그대로 가져와주었습니다. serverSide 환경에서 해당 컴포넌트들이 rendering될때 storage나 router에서 client에서만 사용가능한 값들에 대한 예외처리를 추가로 해주었습니다.

아래는 이 결정으로 인해 발생된 또다른 문제들...

withLifecycle에서 mount처리 추가

vanill환경에서 mount는 react의 useEffect와 동일하다 생각해서 server환경에서는 mount가 되지 않게 처리해주었습니다.

prefetching은 어떻게 하지?

prefetching을 위한 함수가 각 페이지별로 있는데 이를 위해 각페이지 컴포넌트에 loader함수를 추가해주었습니다.
loader에서 title,과 data를 return해서 main-server.js에서 컴포넌트 렌더링시 serversideProps로 데이터를 주입받을 수 있게 만들어주었습니다.

//Homepage

HomePage.loader = async (serverRouter) => {
  const [
    {
      products,
      pagination: { total },
    },
    categories,
  ] = await Promise.all([getProducts(serverRouter.query), getCategories()]);
  return { data: { products, categories, totalCount: total }, title: "쇼핑몰 - 홈" };
};
//main-server

 // 페이지 로더 실행 (데이터 프리패칭)
  let pageData = null;
  if (PageComponent && PageComponent.loader) {
    pageData = (await PageComponent.loader(router)) ?? {};
  }

  const { data, title } = pageData ?? {};
  return {
    head: `<title>${title || ""}</title>`,
    html: () => PageComponent?.(data, router), => prefetching한 data, router 주입
    data: data || {},
  };

hydration은 어떻게 하지?

prefeching한 데이터를 <header><script>window__INITIAL_DATA__=DATA</script></header>에 넣습니다.
client가 실행되는 main에서 window.__INITIAL_DATA__가 있을경우, store에 저장하는 로직을 추가해줍니다.

if (window.__INITIAL_DATA__) {
    const data = window.__INITIAL_DATA__;
    if (data.products)
      productStore.dispatch({
        type: PRODUCT_ACTIONS.SETUP,
        payload: {
          products: data.products,
          categories: data.categories,
          totalCount: data.totalCount,
          loading: false,
          status: "done",
        },
      });
}

SSG와 SSR을 서버에서 구분을 어떻게 하지?

서버가 SSR/SSG를 판단하는게 아니라 실행하는 서버 프로그램이 다르다.
SSR: Express(server.js)
SSG: Vite preivew

리뷰 받고 싶은 내용

server와 client에서 render될 컴포넌트를 만들때 저는 client에서 사용되고있던 HomePage, ProductDetailPage를 그대로 와서 server용으로 사용하기 위해 예외처리를 추가했습니다. 이런방식보다 HomePage에서 사용되고있는 html부분만 따로 빼서 컴포넌트를 만들고, 이를 server, client두곳에서 사용하도록 하는게 더 좋은 방법 같기도 합니다. 하지만 react에서는 순수하게 html부분만 컴포넌트로 빼기에는 컴포넌트 내부에서 동작하는 store함수등이 있어서 이를 수정하려면 기존코드를 많이 고쳐야 할것 같습니다. 어떤 방법이 좋은 방식일까요?

@totter15 totter15 changed the title 과제 시작! [1팀 천진아] Chapter 4-1 성능최적화: SSR, SSG, Infra Dec 15, 2025
천진아 added 15 commits December 16, 2025 00:30
- Express 서버에 Vite SSR 미들웨어 통합
- 홈, 상품 상세, 404 페이지 SSR 렌더링 구현
- ServerRouter 클래스 추가: SSR 환경에서 라우팅 지원
- createStorage에 더미 스토리지 추가: localStorage 없는 환경 대응
- router.js에서 환경 감지하여 적절한 Router 인스턴스 생성
- withLifecycle에서 SSR 환경 마운트 방지
- main-server.js 리팩토링: 하드코딩된 URL 처리를 라우팅 시스템으로 전환
- MSW server 인스턴스 추가 (mocks/server.js)
- API 핸들러를 와일드카드 패턴으로 변경 (*/api/...)
- SSR과 CSR 환경 모두 지원
- getBaseUrl 유틸리티 추가
- CSR: 상대 경로, SSR: 절대 경로(localhost:5173)
- productApi에 환경별 baseUrl 적용
- HomePage에 loader 함수 추가
- main-server에서 MSW 서버 시작 및 loader 호출
- 서버에서 페이지 렌더링 전 데이터 미리 로드
- serversideProps로 데이터 전달
- __INITIAL_DATA__에서 서버 데이터 복원
- 서버 데이터가 있으면 불필요한 재요청 방지
- productStore에 초기 상태 주입
- nodemon 추가 (자동 재시작)
- 의존성 버전 업데이트 (vitest, playwright, react 등)
- ProductDetailPage에 loader 함수 추가
- 상품 상세 및 관련 상품 데이터 서버에서 프리페칭
- getRelatedProducts 함수 분리 (서버/클라이언트 공용)
- currentProduct, relatedProducts hydration 지원
SSR 라우터 개선:
- 각 요청마다 독립적인 ServerRouter 인스턴스 생성
- loader 함수에 serverRouter 전달하여 의존성 제거
- 페이지 컴포넌트에 serverRouter/clientRouter 명확히 구분

데이터 구조 개선:
- loader 반환값 표준화 (data, title)
- head에 페이지별 title 동적 생성
- 환경변수 PORT 지원

버그 수정:
- HomePage API 응답 매핑 수정 (count → total)
- ProductDetailPage 안전한 옵셔널 체이닝 적용
SSG 구현:
- static-site-generate.js에 실제 SSG 로직 추가
- MSW 서버를 활용한 데이터 프리페칭
- 홈, 404, 상품 상세 페이지 자동 생성
- __INITIAL_DATA__ 주입으로 hydration 지원

ESM 호환성:
- 모든 import 경로에 .js 확장자 명시
- JSON import에 with { type: json } 사용
- MemoryRouter로 SSR 라우팅 지원
- React Server-Side Rendering 구현
- 서버 데이터 클라이언트 hydration
- 페이지별 데이터 프리페칭 (loader)
- MSW를 활용한 SSR API 모킹
라우터 전달:
- main-server에서 serverRouter를 컴포넌트에 전달
- HomePage, ProductDetailPage에 serverRouter prop 추가
- SearchBar에서 serverRouter의 쿼리 파라미터 직접 사용

ProductDetailPage 개선:
- loader 함수 추가 (데이터 프리페칭)
- serversideProps로 서버 데이터 전달
- ProductDetail 컴포넌트에 serversideProps 전달

쿼리 파라미터 처리:
- SSR 환경: serverRouter.query 직접 사용
- CSR 환경: useProductFilter hook 사용
- 조건부로 적절한 소스 선택
서버 개선:
- 불필요한 import 제거 (renderToString, createElement)
- 주석 처리된 코드 제거
- 템플릿 경로 수정 (dist/client -> dist/react)

라우터 개선:
- SSR 환경에서 base URL을 빈 문자열로 설정
- server.js에서 이미 base 제거되므로 순수 경로만 사용
- URL 정규화 추가 (빈 문자열 처리)
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