[9주차 회고] 성능 최적화(SSR, SSG, Hydration)

신희원·2025년 9월 18일
2
post-thumbnail

SSR, SSG, Hydration 개념 정리

[SSR, SSG, CSR 이해하기]

SSR (Server-Side Rendering)

정의: 서버에서 HTML을 미리 생성하여 클라이언트에 전달하는 렌더링 방식

특징:

  • 서버에서 완전한 HTML을 생성하여 전송
  • 초기 로딩 속도가 빠름 (FCP 개선)
  • SEO에 유리
  • 서버 부하가 높음

언제 사용하는가:

  • SEO가 중요한 페이지 (블로그, 뉴스, 상품 페이지)
  • 초기 로딩 속도가 중요한 경우
  • 소셜 미디어 공유 시 메타데이터가 필요한 경우

구현 예시:

// packages/vanilla/server.js
app.use("*all", async (req, res) => {
  const rendered = await render(url, req.query);
  const html = template
    .replace(`<!--app-head-->`, rendered.head ?? "")
    .replace(`<!--app-html-->`, rendered.html ?? "")
    .replace(
      `</head>`,
      `<script>window.__INITIAL_DATA__ = ${JSON.stringify(rendered.initialData || {})};</script></head>`,
    );
  res.status(200).set({ "Content-Type": "text/html" }).send(html);
});

SSG (Static Site Generation)

정의: 빌드 타임에 미리 정적 HTML 파일들을 생성하는 렌더링 방식

특징:

  • 빌드 시점에 모든 페이지를 미리 생성
  • CDN 캐싱에 최적화
  • 서버 부하가 거의 없음
  • 동적 데이터 업데이트가 어려움

언제 사용하는가:

  • 콘텐츠가 자주 변경되지 않는 사이트 (블로그, 문서, 랜딩 페이지)
  • 대량의 페이지가 필요한 경우
  • 최고의 성능이 필요한 경우

구현 예시:

// packages/vanilla/static-site-generate.js
const { products } = await getProducts();
generateStaticSite("/", {});
generateStaticSite("/404", {});
for (let i = 0; i < products.length; i++) {
  generateStaticSite(`/product/${products[i].productId}`, {});
}

Hydration

정의: 서버에서 렌더링된 정적 HTML을 클라이언트에서 인터랙티브하게 만드는 과정

왜 사용하는가:

  • 서버 렌더링의 SEO/성능 이점 + 클라이언트의 인터랙티브 기능 결합
  • 초기 로딩 후 JavaScript가 로드되어 이벤트 핸들러 등록
  • 사용자 경험 향상

효과:

  • 초기 로딩 속도 개선: 서버에서 완성된 HTML을 받아 즉시 표시
  • SEO 최적화: 검색 엔진이 완전한 HTML을 크롤링 가능
  • 사용자 경험 향상: 로딩 후 즉시 인터랙션 가능
  • 메타데이터 지원: 소셜 공유 시 올바른 미리보기 표시

구현 예시:

// 서버에서 초기 데이터 주입
window.__INITIAL_DATA__ = ${JSON.stringify(rendered.initialData || {})};

// 클라이언트에서 상태 복원
if (window.__INITIAL_DATA__) {
  productStore.setState(window.__INITIAL_DATA__);
}

React SSR/SSG 직접 구현해보며 느낀 점들

Next.js 없이 React SSR/SSG를 처음부터 만들어보는 프로젝트를 진행했다. 처음에는 "그냥 renderToString 쓰면 끝이겠지?"라고 생각했는데, 막상 구현해보니 생각보다 복잡하고 고려할 것들이 너무 많았다. 이번 회고를 통해 내가 배운 것들과 아직 부족한 부분들을 정리해보려고 한다.

TMI) 처음에 어떻게 구현해보지?? 했을 때 지훈님이 SSR 구현은 PHP 랑 똑같다고 보면 된다구 하셨다. SSG도 첨에 겁먹었는데, 해보니 너무 별거 없었다. (오히려 더 쉬웠음!)

🤔 처음 마주친 문제들

서버에 window가 없다!

가장 기본적인 실수였는데, 클라이언트에서 잘 되던 코드를 서버에서 실행하니까 window is not defined 에러가 계속 났다. 당연히 서버에는 브라우저가 없으니까 window 객체가 없는건데, 이거에 대한 분기처리를 계속 넣어줬다.

// 이런 식으로 환경을 체크해야 했다
if (typeof window !== 'undefined') {
  // 브라우저에서만 실행되는 코드
  window.addEventListener('scroll', handleScroll);
} else {
  // 서버에서만 실행되는 코드
  console.log('서버에서 렌더링 중...');
}

나는 이 경험을 통해 Universal JavaScript라는게 단순히 같은 코드를 공유하는 게 아니라, 환경에 따라 다르게 동작해야 한다는 걸 깨달았다.

📚 새롭게 배운 개념들

ssrLoadModule을 왜 사용하는가?

ssrLoadModule은 Vite가 제공하는 편리한 기능으로, 서버에서 클라이언트용 코드를 실행할 수 있게 해주는 변환 과정을 자동화해준다. 없으면 SSR 구현이 훨씬 복잡해진다.

ssrLoadModule이 필요한 이유 1.ES 모듈을 Node.js에서 직접 실행할 수 없다
// 이렇게 하면 안 된다.
import { render } from './src/main-server.js'; // ❌ 에러 발생

Node.js는 기본적으로 ES 모듈을 직접 import할 수 없다. 특히 TypeScript 파일이나 Vite의 특별한 기능들을 사용한 파일들은 더욱 그렇다.

2.Vite의 변환 과정을 거쳐야 한다.
ssrLoadModule은 Vite가 파일을 다음과 같이 변환해준다

  • TypeScript → JavaScript 변환
  • ES 모듈 → CommonJS 변환
  • Vite의 특별한 기능들 (예: import.meta.env) 처리
  • 의존성 해결

3.개발/프로덕션 환경 분기 처리
코드를 보면

if (!prod) {
  // 개발 환경: Vite가 실시간으로 변환
  render = (await vite.ssrLoadModule("/src/main-server.js")).render;
} else {
  // 프로덕션 환경: 미리 빌드된 파일 사용
  render = (await import("./dist/react-ssr/main-server.js")).render;
}
  • 개발 환경: ssrLoadModule로 실시간 변환
  • 프로덕션 환경: 이미 빌드된 파일을 직접 import

4.실제 사용 예시

// packages/react/server.js에서
const { mswServer } = await vite.ssrLoadModule("./src/mocks/node.ts");
const render = (await vite.ssrLoadModule("/src/main-server.js")).render;

이렇게 하면:

  • TypeScript 파일(.ts)을 JavaScript로 변환
  • ES 모듈 문법을 Node.js가 이해할 수 있게 변환
  • Vite의 특별한 기능들 처리

5.없다면 어떻게 해야 할까?
ssrLoadModule이 없다면:

  • 모든 파일을 수동으로 빌드해야 함
  • TypeScript 컴파일러를 직접 설정해야 함
  • ES 모듈을 CommonJS로 변환해야 함
  • 개발 중에 파일 변경 시마다 수동으로 재빌드해야 함

SSR과 SSG의 명확한 차이점

이론적으로는 알고 있었지만, 직접 구현해보니 차이가 확실히 느껴졌다.

SSR (Server-Side Rendering): 사용자가 페이지를 요청할 때마다 서버에서 HTML을 만든다. 실시간 데이터를 반영할 수 있지만, 매번 서버에서 계산해야 해서 느리고 서버에 부담이 된다.

SSG (Static Site Generation): 빌드할 때 미리 모든 HTML을 만들어둔다.

// static-site-generate.js에서 구현한 SSG
const { products } = await getProducts();
generateStaticSite("/", {});
generateStaticSite("/404", {});

// 모든 상품 페이지를 빌드 시점에 미리 생성
for (let i = 0; i < products.length; i++) {
  generateStaticSite(`/product/${products[i].productId}`, {});
}

나는 이 코드를 작성하면서 SSG의 강력함을 느꼈다. 상품이 1000개든 10000개든 빌드만 한 번 하면 CDN에서 초고속으로 서빙할 수 있으니까.

하지만 단점도 명확했다. 상품 정보가 바뀌면 전체를 다시 빌드해야 한다는 점이었다. 나는 이때 언제 SSR을 쓰고 언제 SSG를 써야 하는지 감이 잡혔다.

데이터 하이드레이션(Hydration)이 왜 필요한가?

처음에는 하이드레이션이 뭔지 몰랐다. 서버에서 HTML을 만들었으니 그걸로 끝 아닌가? 라고 생각했다.

그런데 문제가 있었다. 서버에서 만든 HTML은 "정적인 문서"일 뿐이다. 버튼을 클릭해도 반응이 없고, 상태 변화도 없다. React의 인터랙티브한 기능들이 전혀 동작하지 않더라.

그래서 하이드레이션이 필요했다. 서버에서 렌더링한 HTML에 JavaScript를 "주입"해서 살아있는 React 앱으로 만드는 과정이었다.

// 서버에서 데이터를 클라이언트로 전달
window.__INITIAL_DATA__ = ${JSON.stringify(rendered.initialData || {})};

나는 이 과정에서 가장 중요한 것이 서버와 클라이언트의 상태를 정확히 일치시키는 것이라는 걸 배웠다. 조금이라도 다르면 React가 "어? 이거 뭔가 다른데?"라고 생각해서 전체 DOM을 다시 그리더라.

이때 FOUC(Flash of Unstyled Content) 라는 용어도 알게 되었다. 페이지가 로드될 때 스타일이 깜빡이는 현상인데, 하이드레이션을 제대로 하지 않으면 이런 문제가 생긴다.

🏗️ 아키텍처 설계하면서 배운 것들

하이브리드 라우팅의 필요성

처음에는 "모든 페이지를 SSR로 하면 되겠지"라고 생각했다. 하지만 실제로는 그렇지 않았다.

  • 메인 페이지: 자주 변하니까 SSR
  • 상품 페이지: 잘 안 변하니까 SSG
  • 관리자 페이지: 인증이 필요하니까 CSR

나는 페이지의 특성에 따라 렌더링 방식을 다르게 선택해야 한다는 걸 깨달았다.

라우터 아키텍처의 진화

프로젝트를 진행하면서 라우터 구조를 여러 번 리팩토링했다.

처음에는 클라이언트용 라우터만 있었는데, SSR을 구현하면서 서버용 라우터가 따로 필요하다는 걸 알게 되었다. 서버에서는 브라우저의 History API를 사용할 수 없으니까.

그래서 serverRouter.js를 따로 만들었다. 202줄이나 되는 긴 코드였는데, 서버 환경에서의 라우팅 로직이 생각보다 복잡하더라.

// 서버와 클라이언트에서 다른 라우터 로직
if (typeof window === 'undefined') {
  // 서버용 라우터 사용
  router = new ServerRouter(routes);
} else {
  // 클라이언트용 라우터 사용  
  router = new ClientRouter(routes);
}

나는 이 과정에서 Universal Router 패턴의 중요성을 배웠다. 가능한 한 같은 로직을 공유하되, 환경별로 다른 부분은 깔끔하게 분리하는 것이 핵심이었다.

😅 힘들었던 부분들

React renderToString의 한계

가장 당황스러웠던 부분이었다. renderToString동기 함수라는 점 때문에 컴포넌트 안에서 비동기 데이터를 가져올 수 없었다.

// 이런 코드는 동작하지 않는다
function ProductPage() {
  const [product, setProduct] = useState(null);
  
  useEffect(() => {
    // renderToString에서는 useEffect가 실행되지 않음!
    fetchProduct().then(setProduct);
  }, []);
  
  return <div>{product?.name}</div>;
}

그래서 서버에서 미리 데이터를 준비한 후 컴포넌트에 props로 전달하는 방식을 사용해야 했다.

// 서버에서 미리 데이터 준비
const data = await fetchProductData();

// props로 전달
const html = renderToString(
  <ProductProvider productStore={createProductStore(data)}>
    <App />
  </ProductProvider>
);

나는 이때 Next.js의 getServerSidePropsgetStaticProps가 왜 필요한지 완벽하게 이해했다.

Hydration 에러와의 전쟁

서버에서 렌더링한 HTML과 클라이언트에서 렌더링한 결과가 조금만 달라도 React가 화를 내더라.

특히 이런 코드가 문제였다:

// 서버와 클라이언트에서 다른 결과
function CurrentTime() {
  const [time, setTime] = useState(new Date().toString());
  return <div>{time}</div>;
}

서버에서 렌더링할 때의 시간과 클라이언트에서 하이드레이션할 때의 시간이 달라서 에러가 났다.

해결책은 조건부 렌더링이었다:

// 클라이언트에서만 시간 표시
function CurrentTime() {
  const [mounted, setMounted] = useState(false);
  
  useEffect(() => {
    setMounted(true);
  }, []);
  
  if (!mounted) return <div>Loading...</div>;
  
  return <div>{new Date().toString()}</div>;
}

나는 이 경험을 통해 하이드레이션의 엄격함을 깨달았다. 서버와 클라이언트의 결과가 정확히 일치해야 한다는 것이다.

🚀 성능 최적화에서 느낀 점들

빌드 시간 최적화

빌드 시간 최적화
상품이 많아질수록 SSG 빌드 시간이 기하급수적으로 늘어났다. 처음에는 이런 식으로 순차 처리를 했다:

// Before: 순차 처리 (엄청 느림)
await generateStaticSite("/404.html");
await generateStaticSite("/");

const { getProducts } = await vite.ssrLoadModule("./src/api/productApi.ts");
const { products } = await getProducts();

// 상품 하나씩 순차적으로 처리
for (const product of products) {
  await generateStaticSite(`/product/${product.productId}/`);
}

상품이 100개면 100번의 순차 작업이니까 정말 오래 걸렸다. 한 페이지 생성에 0.5초씩 걸린다면 100개는 50초나 걸리는 거였다.
그래서 병렬 처리로 바꿨다:

// After: 병렬 처리 (훨씬 빠름)
await generateStaticSite("/404.html");
await generateStaticSite("/");

const { getProducts } = await vite.ssrLoadModule("./src/api/productApi.ts");
const { products } = await getProducts();

// 모든 상품 페이지를 동시에 생성
await Promise.all(
  products.map(async ({ productId }) => 
    await generateStaticSite(`/product/${productId}/`)
  )
);

나는 이 과정에서 성능 최적화는 trade-off의 연속이라는 걸 배웠다.

🎬 마무리하며

Next.js나 다른 프레임워크를 사용하면 이런 복잡함들을 숨겨준다. 하지만 직접 구현해보니 왜 그런 기능들이 필요한지 몸소 체험할 수 있었다.

특히 이런 것들을 깨달았다:

  • 환경별 분기 처리가 Universal JavaScript의 핵심이다
  • 서버와 클라이언트의 상태 동기화가 생각보다 까다롭다
  • 성능 최적화는 측정 가능한 지표로 검증해야 한다

이번 경험을 통해 나는 프레임워크의 편의성에만 의존하지 않고, 내부 동작 원리를 이해하는 개발자가 되고 싶다는 생각이 들었다.

앞으로는 이번에 배운 내용을 바탕으로 회사 프로젝트에서 더 나은 성능 최적화를 해보고 싶다. 그리고 언젠가는 나만의 작은 프레임워크도 만들어보고 싶다는 욕심이 생겼다. 🚀

TMI) 항상 나를 포기하지 않고 도와주는 팀원분들에게 감사하다.
8주차부터 지쳐가지고 과제를 놓을까 했었는데 8주차에는 휘린님이 직접 버그 봐주시면서 도와주셨고, 9주차에는 지훈님이 과제 시작 전 SSR 강의를 2시간 넘게 해주셨다. 덕분에 9주차에선 과제 내용을 이해 하고 내가 다른 사람들에게 다시 알려주면서 뿌듯함과 자신감이 많이 붙었다. 8주차는 지치고 힘들었다면, 9주차부터는 다시 활력이 생겨 재밌게 과제를 진행했다.
(물론 CI 통과가 안되서 밤새느라 너무 힘들었다.. 빈 커밋하면 해결된다했는데... 정확한 문제점을 몰라서 나는 테스트 코드에 타임아웃을 줘서 통과를 시켰다.)

감사하게도 우수과제까지 받았다. vV

profile
프론트엔드 공부하는 개발자입니다.

0개의 댓글