항해플러스 7기 9주차

드엔트론프·2025년 12월 21일

항해플러스

목록 보기
9/9

Chapter 4-1. 성능최적화: SSR, SSG, Infra

  • 9주차의 주제는 SSR, SSG, Infra였다.
  • CSR로 구현된 바닐라 자바스크립트, 리액트의 코드를 SSR, SSG로 바꿔보는 일이었다.
  • 가볍게 혼자 작업해보려다 죽쒀서 발제와 Q&A를 돌려보며 시작을 같이했다.
  • 기본 과제인 바닐라 자바스크립트로 SSR, SSG 구현하는 부분만 성공하게 됐다.

CSR (Client-Side Rendering) - 이전 방식

사용자 요청 → 서버가 빈 HTML 전송 → 브라우저에서 JS 실행 → 데이터 페칭 → 화면 렌더링

특징:

  • 초기 로딩 시 빈 HTML만 전달
  • 모든 렌더링이 브라우저에서 발생
  • SEO에 불리
  • 초기 로딩 시간이 길 수 있음

SSR (Server-Side Rendering) - 현재 방식

(SSG까지 구현했지만, SSR을 현재 방식으로 작성)

사용자 요청 → 서버에서 데이터 페칭 → 서버에서 HTML 생성 → 완성된 HTML 전송 → 브라우저에서 Hydration

특징:

  • 서버에서 완성된 HTML을 전달
  • 초기 로딩 속도 개선
  • SEO에 유리
  • 서버와 클라이언트 모두에서 렌더링 가능

전환 과정의 핵심 개념

1. 이중 렌더링 환경

  • 서버 환경: Node.js에서 실행, window 객체 없음
  • 클라이언트 환경: 브라우저에서 실행, window 객체 있음
  • 코드가 두 환경 모두에서 동작해야 함

2. Hydration (수화)

  • 서버에서 렌더링된 HTML을 브라우저가 받아서
  • 클라이언트 JavaScript가 실행되어 인터랙티브하게 만드는 과정
  • 초기 데이터는 window.__INITIAL_DATA__로 전달

3. Universal/Isomorphic 코드

  • 서버와 클라이언트 모두에서 동작하는 코드
  • 환경에 따라 다른 동작을 하도록 분기 처리

구현 흐름

전체 요청-응답 흐름

1. 사용자가 URL 요청 
   ↓
2. Express 서버가 요청 수신 (server.js)
   ↓
3. URL에서 쿼리 파라미터 파싱 (server.js)
   ↓
4. Vite SSR 모드로 HTML 템플릿 로드 (개발) 또는 빌드된 파일 로드 (프로덕션)
   ↓
5. main-server.js의 render 함수 호출 (url, query 전달)
   ↓
6. 라우트 등록 (/, /product/:id/, .*)
   ↓
7. 쿼리 파라미터를 router.query에 먼저 설정 
   ↓
8. URL에서 pathname만 추출 (쿼리 파라미터 제거, base path 제거) 
   ↓
9. ServerRouter.push(pathname)로 라우팅 처리 
   ↓
10. 페이지 컴포넌트의 getServerSideProps 실행 (서버에서 데이터 페칭)
    - router.query를 사용하여 쿼리 파라미터 기반 데이터 가져오기
    ↓
11. Store에 초기 데이터 주입
    ↓
12. 페이지 컴포넌트 렌더링 (HTML 문자열 생성)
    ↓
13. window.__INITIAL_DATA__ 데이터 형식 변환 (HomePage의 경우) 
    - pagination 제거, totalCount로 변환
    ↓
14. HTML 템플릿에 렌더링 결과 삽입
    - head 태그에 title 삽입
    - window.__INITIAL_DATA__ 스크립트 태그 추가
    ↓
15. 완성된 HTML을 클라이언트에 전송
    ↓
16. 브라우저에서 HTML 수신 및 표시 (이미 데이터가 있음!)
    ↓
17. main.js 실행 (클라이언트 Hydration)
    ↓
18. window.__INITIAL_DATA__에서 데이터 읽어서 Store 초기화
    ↓
19. 클라이언트 라우터로 전환, 인터랙티브 기능 활성화
  • 여기서 재밌게 봐야할 부분은 4번 main-server.js 쪽이다.
export const render = async (url, query) => {
  // 1. 라우트 등록
  router.addRoute("/", HomePage);
  router.addRoute("/product/:id/", ProductDetailPage);
  router.addRoute(".*", NotFoundPage);

  // 2. 쿼리 파라미터를 먼저 설정 (getServerSideProps에서 사용) 
  router.query = query;

  // 3. URL에서 pathname만 추출 
  const urlObj = new URL(url, "http://localhost:3000");
  let pathname = urlObj.pathname;

  // base path 제거
  if (BASE_URL !== "/" && pathname.startsWith(BASE_URL)) {
    pathname = pathname.slice(BASE_URL.length);
    if (!pathname || pathname === "") {
      pathname = "/";
    }
  }

  // 4. 라우터로 경로 매칭 
  router.push(pathname);

  // 5. 매칭된 페이지 컴포넌트 가져오기
  const PageComponent = router.target;

  // 6. 서버에서 데이터 페칭 (getServerSideProps)
  // 이 시점에 router.query가 이미 설정되어 있음
  const initData = await PageComponent.getServerSideProps?.();

  // 7. Store에 초기 데이터 주입
  if (PageComponent === HomePage) {
    productStore.dispatch({
      type: PRODUCT_ACTIONS.SETUP,
      payload: {
        products: initData.products,
        categories: initData.categories,
        totalCount: initData.pagination.total,
        loading: false,
        status: "done",
      },
    });
  }

  // 8. 동적 title 생성
  let pageTitle = "쇼핑몰 - 홈";
  if (PageComponent === ProductDetailPage && initData?.product) {
    pageTitle = `${initData.product.title} - 쇼핑몰`;
  } else if (PageComponent === NotFoundPage) {
    pageTitle = "404 - 쇼핑몰";
  }

  // 9. E2E 테스트 형식에 맞게 데이터 변환
  let dataForClient = initData;
  if (PageComponent === HomePage && initData) {
    dataForClient = {
      products: initData.products,
      categories: initData.categories,
      totalCount: initData.pagination?.total ?? 0,
    };
  }

  // 10. 페이지 컴포넌트 렌더링 (HTML 문자열 반환)
  return {
    head: `<title>${pageTitle}</title>`,
    html: PageComponent(), // HTML 문자열
    data: dataForClient, // 클라이언트로 전달할 데이터
  };
};
  • 여기서 전달되는 라우터는 서버 환경인지 아닌지에 따라 분기되는 라우터이다.
  • 서버에서는 당연하게도 윈도우를 알 수 없어서, 기본 라우터와 윈도우 내용이 없는 서버 라우터로 나누었다.
export const router =
  typeof window !== "undefined"
    ? new Router(BASE_URL) // 클라이언트: 브라우저 라우터
    : new ServerRouter(BASE_URL); // 서버: 서버 라우터
  • 윈도우가 없을 때 서버 라우터로 호출하고,
  • 서버 라우터는 기존에 있던 라우터를 가져왔는데, 그 안에서 윈도우를 찾는 로직을 없앤 서버 라우터다.

마치며

  • CSR의 컴포넌트를 재활용하여 SSR로 구현해보며, Next.js도 이런 식으로 동작하는건가? 싶었다.
  • 초기 값을 어떻게 전달 받아 하이드레이션 하는지를 만들어보며, SSR로 성능최적화 한다는 건 이런 의미인거구나 생각했다.
  • 발제와 Q&A를 따라가며 작성했다.
    • 발제에는 홈 화면에서 어떻게 SSR로 끌어내면 될 지에 대해 알려주었고 Q&A에서는 유니버셜 라우팅에 대해 설명해주었다.
    • 아! 상세 페이지도 이런 식으로 진행하면 되는거구나! 하면서 진행하려다 여러번 막혔던 것 같다.
    • 라우팅과 스토어가 가장 문제였는데, 그래서인지 주말에 모여 랜덤 코드리뷰 할 때에도 해당 부분에 대한 질문이 가장 많았었다.
  • 테오 멘토링을 들으며, SSR이 필요한 프로젝트, 아닌 프로젝트에 대한 이야기와 원론적으로 SSR이 왜 흥하게 됐는지에 대한 많은 이야기를 나눴는데, 이게 마인드맵처럼 생각정리를 할 수 있게 해주었다.
    • 다니고 있는 회사의 프로젝트에 대해 고민하는 계기가 됐달까..

  • 오늘의 사진은 참치다!
  • 생 참치다.
  • 친구 모임에서 생참치라는게 있는가 없는가에 대한 논쟁이 있었는데, 생참치는 있었다..!
  • 입에서 슈ㅜ루룩 녹아버림!
profile
왜? 를 깊게 고민하고 해결하는 사람이 되고 싶은 개발자

2개의 댓글

comment-user-thumbnail
2025년 12월 26일

슈ㅜ루룩!

1개의 답글