ㅇㅅㅇ

항해플러스 프론트엔드 6기 9주차 회고 본문

항해99 플러스 프론트앤드/10주 과정

항해플러스 프론트엔드 6기 9주차 회고

소 아 2025. 10. 15. 17:09

아홉 번째 도전기 - SSR과 SSG, 서버와 클라이언트의 경계를 넘나들다

이번 과제는 정말 특별한 경험이었습니다. 그동안 Vue나 React Next.js로 사이드 프로젝트를 하면서 SSR/SSG 개념은 알고 있었지만, 실제로 직접 구현해보면서 "이게 어떻게 돌아가는 거지?"라는 궁금증을 해소할 수 있었습니다. 처음에는 renderToString이나 window.__INITIAL_DATA__ 같은 것들을 보니까 "이게 뭐야?" 싶었습니다. Next.js나 Nuxt.js 같은 프레임워크에서는 이런 것들을 알아서 해주니까 신경 쓸 일이 없었는데, 이번 과제에서는 그런 기능들을 직접 구현해보라고 하니까 처음에는 정말 어려웠습니다.
Vanilla JavaScript로는 어느 정도 구현할 수 있었지만, React로 넘어가면서 renderToString이 빈 HTML을 반환하는 문제로 많은 시간을 소모했습니다. 하지만 이런 어려움을 겪으면서 SSR/SSG의 내부 동작 원리를 깊이 이해할 수 있었습니다.
한편, 이번 주에는 기술적인 과제 외에도 팀 차원에서 개인 성장과 생활 리듬을 돌아보는 활동을 함께했습니다. 과제를 진행하기 전 주에 팀원들과 ‘미라클 모닝’을 실천하고, 그 경험을 정리해 발표하는 시간을 가졌습니다. 새벽 6시에 모여 하루의 시작을 나누는 그 시간이 개발 공부만큼이나 뜻깊었습니다.


팀원들과 함께한 미라클 모닝 발표 경험

지난주에는 개인의 관심사와 사이드 프로젝트에 대해 발표했다면, 이번 주는 조금 다른 방식으로 팀원들과 함께한 시간을 공유했습니다. 이번 과제를 시작하기 전 주에 팀원들과 함께 미라클 모닝을 5일간 도전했습니다. 새벽 6시에 만나서 어제 있었던 일, 오늘 할 일, 그리고 오늘의 마음가짐을 나누고 다이어리에 적는 시간이었어요. 그리고 이번 주에는 그 경험을 정리해서 토요일에 발표했습니다.

팀원들과의 소중한 시간

미라클 모닝을 하면서 가장 좋았던 점은 팀원들과 정서적 교류를 나누는 시간이었습니다. 새벽 6시에 만나 어제 있었던 일, 오늘 할 일, 그리고 마음가짐을 공유하며 서로 응원과 위로를 주고받았습니다. 특히 저는 일기에 구직 준비와 항해 관련 사건들을 주로 적었는데, 팀원들과의 소통을 통해 외부에 제 상황을 편하게 나누고 용기를 얻는 소중한 시간이었습니다. 또한, 평소 구직 준비와 항해 병행으로 사고와 표현 능력이 퇴화되는 것 같다고 느꼈는데, 아침마다 글을 쓰고 이야기를 나누는 과정 자체가 이를 예방하는 데 도움이 된다는 것을 깨달았습니다. 덕분에 하루의 시작이 활기차고, 평소 미뤄두던 구직 준비나 학습 계획도 더 체계적으로 세우고 실천하려 노력하게 되었습니다.

발표 준비와 팀워크

이번 주에는 미라클 모닝 경험을 정리하여 발표 자료를 만들고, 토요일에 발표했습니다. 이 경험을 준비하며 저는 굿노트 글씨체가 마음에 들지 않아 자주 사용하는 드로잉 툴로 템플릿을 만드는 등 개인의 성향에 맞는 도구 활용법을 고민할 수 있었습니다. 미라클 모닝 자체가 '단순히 일찍 일어나는 습관을 기르는 것'을 넘어 '하루를 어떻게 시작하느냐가 하루 전체에 영향을 미친다'는 것을 체감하게 했지만, 한 가지 아쉬운 점은 장기적인 습관화의 어려움이었습니다. 팀원들과의 약속 덕분에 새벽 6시 기상이라는 '희귀한 경험'을 했음에도 불구하고, 모임 직후 다시 잠들곤 했으며, 모임이 끝난 지금은 다시 이전의 기상 패턴으로 돌아갔습니다.


과제를 통해 배운 것들

SSR과 SSG의 차이점을 체감하다

이번 과제를 통해 SSR과 SSG의 차이점을 실제 구현을 통해 체감할 수 있었습니다. SSR은 요청이 올 때마다 서버에서 HTML을 생성해서 보내주는 방식이고, SSG는 빌드 타임에 미리 HTML을 생성해두는 방식이었습니다. 처음에는 "왜 굳이 서버에서 HTML을 만들어야 하지?"라는 의문이 들었는데, 실제로 구현해보니 초기 페이지 로드 시간이 확실히 빨라지는 것을 확인할 수 있었습니다.

Universal JavaScript의 어려움

가장 어려웠던 부분은 같은 코드가 서버와 클라이언트에서 다르게 동작해야 한다는 점이었습니다. typeof window !== "undefined" 체크를 통한 환경 분기가 얼마나 중요한지 체감했습니다.

// App.tsx에서 서버 환경일 때만 데이터 로딩
router.addRoute("/", () => {
  if (typeof window === "undefined") {
    const {
      products,
      pagination: { total: totalCount },
    } = getProducts();

    const categories = getUniqueCategories();

    productStore.dispatch({
      type: PRODUCT_ACTIONS.SETUP,
      payload: {
        products,
        categories,
        totalCount,
        loading: false,
        status: "done",
      },
    });
  }
  return HomePage();
});

서버에서는 window 객체가 없기 때문에 클라이언트 전용 코드가 실행되면 에러가 발생합니다. 반대로 클라이언트에서는 서버 전용 코드가 실행되면 문제가 생기죠. 이런 환경 분기를 제대로 처리하지 않으면 renderToString이 빈 HTML을 반환하는 문제가 발생했습니다.

Hydration 과정의 복잡성

Hydration 과정에서 서버에서 렌더링된 HTML과 클라이언트의 Virtual DOM이 정확히 일치해야 한다는 것을 알게 되었습니다. window.__INITIAL_DATA__를 통한 상태 동기화가 Hydration 성공의 핵심이라는 것을 배웠습니다.

// App.tsx에서 서버 데이터 확인 후 렌더링
export const App = () => {
  const PageComponent = useCurrentPage();

  // 서버에서 전달받은 초기 데이터 확인
  const initialData =
    typeof window !== "undefined" ? (window as unknown as Record<string, unknown>).__INITIAL_DATA__ : null;

  // 서버 데이터가 있으면 직접 렌더링
  if (initialData && (initialData as Record<string, unknown>).products) {
    const data = initialData as Record<string, unknown>;
    return (
      <div>
        <h1>쇼핑몰</h1>
        <p>총 {data.totalCount as string}개</p>
        <div>
          {(data.products as Array<Record<string, unknown>>).map((product) => (
            <div key={product.productId as string}>
              <h3>{product.title as string}</h3>
              <p>{product.lprice as string}원</p>
            </div>
          ))}
        </div>
      </div>
    );
  }

  return (
    <>
      <ToastProvider>
        <ModalProvider>{PageComponent ? <PageComponent /> : null}</ModalProvider>
      </ToastProvider>
      <CartInitializer />
    </>
  );
};

처음에는 서버에서 렌더링한 HTML과 클라이언트에서 렌더링한 HTML이 다르면 Hydration 에러가 발생한다는 것을 몰랐습니다. 그래서 서버에서는 데이터를 미리 로딩해서 상태를 초기화하고, 클라이언트에서는 그 상태를 그대로 사용해야 한다는 것을 깨달았습니다.


가장 어려웠던 부분

React SSR에서 renderToString이 빈 HTML을 반환하는 문제

가장 큰 벽이었던 부분입니다. renderToString(<App />)이 서버 환경에서 빈 HTML을 반환하는 문제가 계속 발생했습니다.
원인은 서버와 클라이언트의 렌더링 경로가 달랐기 때문이었습니다. App.tsx에서 서버 환경일 때 직접 getProducts()getUniqueCategories()를 호출하려고 했는데, 이 과정에서 오류가 발생하여 빈 HTML이 반환되었습니다.

해결 과정:

  1. main-server.tsx에서 ServerRouter를 직접 생성하고 라우트를 등록
  2. App.tsx에서 서버 환경일 때 productStore.dispatch로 상태 초기화
  3. window.__INITIAL_DATA__를 활용한 클라이언트 상태 복원

SSG에서 초기 데이터 누락 문제

SSG 빌드는 성공했지만 window.__INITIAL_DATA__가 HTML에 포함되지 않는 문제가 있었습니다. 원인은 HTML 템플릿에 <!--app-data--> 플레이스홀더가 없었기 때문이었습니다. 이 부분을 놓치고 있어서 SSG로 생성된 HTML에 초기 데이터가 포함되지 않았습니다.

<!-- 수정 전 -->
<body class="bg-gray-50">
<div id="root"><!--app-html--></div>
<script type="module" src="/src/main.tsx"></script>
</body>

<!-- 수정 후 -->
<body class="bg-gray-50">
<div id="root"><!--app-html--></div>
<!--app-data-->
<script type="module" src="/src/main.tsx"></script>
</body>

해결: index.html<!--app-data--> 플레이스홀더를 추가하여 SSG 스크립트에서 초기 데이터를 주입할 수 있도록 수정했습니다.


새롭게 알게 된 개념

renderToString의 제약사항

renderToString은 동기적이라 비동기 작업은 사전에 완료해야 한다는 제약이 있었습니다. 서버에서 React 컴포넌트를 렌더링할 때는 상태가 미리 초기화되어야 한다는 것을 배웠습니다.

// main-server.tsx에서 서버 렌더링 함수
export const render = async (url: string, query: Record<string, string>) => {
  try {
    // 서버 라우터 초기화
    const serverRouter = new ServerRouter();

    // 라우트 등록
    serverRouter.addRoute("/", HomePage);
    serverRouter.addRoute("/product/:id/", ProductDetailPage);
    serverRouter.addRoute(".*", NotFoundPage);

    // 현재 URL 설정
    serverRouter.push(url);

    // URL에 따라 데이터 로딩
    let initialData = {};
    let title = "쇼핑몰";

    if (url === "/") {
      const homeData = await loadHomePageData(url, query);
      initialData = {
        products: homeData.products,
        categories: homeData.categories,
        totalCount: homeData.totalCount,
        url,
        query,
      };
      title = "쇼핑몰 - 홈";
    }

    // React 컴포넌트를 HTML 문자열로 렌더링
    const html = renderToString(<App />);

    return {
      html,
      head: `<title>${title}</title>`,
      initialData,
    };
  } catch (error) {
    console.error("SSR Error:", error);
    return {
      html: "<div>Error occurred</div>",
      head: "<title>Error</title>",
      initialData: {},
    };
  }
};

처음에는 renderToString 안에서 비동기 작업을 하려고 했는데, 이는 불가능하다는 것을 알게 되었습니다. 그래서 서버에서 데이터를 미리 로딩하고 상태를 초기화한 후에 renderToString을 호출해야 한다는 것을 깨달았습니다.

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

서버에서 렌더링한 상태와 클라이언트에서 복원한 상태가 정확히 일치해야 한다는 것을 알게 되었습니다. window.__INITIAL_DATA__를 통해 서버에서 클라이언트로 상태를 전달하는 방식이 핵심이었습니다.


KPT 회고

Keep (계속 유지하고 싶은 점)

  • 직접 고민하고 찾아보는 과정을 통한 기술 내부 원리 깊이 있는 이해
  • Universal JavaScript 핵심을 파악하기 위한 체계적인 디버깅 과정
  • 작은 단위 커밋을 통한 명확한 변경 이력 기록 습관
  • 미라클 모닝 활동으로 다진 팀원 간의 긍정적인 소통 및 상호 응원

Problem (개선이 필요한 문제점)

  • React SSR 구현 시 renderToString 빈 HTML 반환 문제 해결에 시간 과다 소모
  • 심화 과제 목표였던 완전한 SSG 구현 미달성
  • try-catch에 의존하는 에러 처리로 에러 타입별 Fallback 페이지 제공 불가
  • 미라클 모닝 등 긍정적 활동의 장기적 생활 습관화 실패
  • Hydration 과정에서의 서버-클라이언트 상태 동기화 복잡성

Try (앞으로 시도해 볼 점)

  • 이번 과제 지식을 활용한 완전한 SSG 구현 재도전
  • 에러 타입별 Fallback 페이지 제공을 위한 에러 처리 로직 개선
  • 대용량 데이터 처리 시 스트리밍 방식 도입 검토를 통한 성능 최적화
  • AI 도구 활용 시 토론 및 논의를 병행하는 주도적 학습 유지
  • 생활 습관 형성을 위한 장기 지속 가능성 중심의 체계적 접근 적용
  • 배운 내용 정리 후 개인 프로젝트 즉시 적용 및 기록

추천인 코드

제 후기를 보고 프로그램에 관심이 생기셨다면, 감사한 마음으로 수강료 할인을 받을 수 있는 추천인 코드를 드립니다.
수강 신청 시 추천인 코드 WlHJDO 입력하면 20만 원 할인이 적용되며, 자세한 커리큘럼과 일정은 아래 링크에서 확인하실 수 있습니다.
추천인 코드 : WlHJDO

https://hanghae99.spartacodingclub.kr/plus/fe

 

항해 플러스 | 도전을 넘어 개발자 커리어 도약으로

빅테크 시니어 코치진이 사수가 되어 드립니다. 시니어 개발자의 멘토링, 실무 포트폴리오, 동료 개발자들과의 인사이트 나눔까지.

hanghae99.spartaclub.kr