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

한칙촉·2025년 9월 15일
post-thumbnail

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

WIL

우선 개념 정리를 해보자..!

✅ CSR (Client Side Rendering)

서버는 기본적으로 빈 HTML + JS 파일을 내려주고 브라우저가 JS를 실행해 화면을 그리는 방식

  • 초기 로딩 속도가 상대적으로 느림 (JS를 실행한 후 화면이 보이기 때문)
  • 첫 화면은 늦게 뜨지만, 이후 페이지 전환은 빠름

✅ SSR (Server Side Rendering)

서버가 요청을 받을 때마다 완성된 HTML을 만들어 내려주고, 브라우저가 바로 렌더링하는 방식

  • 첫 화면 로딩 속도가 빠름
  • SEO에 유리
  • 요청마다 HTML을 생성해야 하므로 서버 부하 증가

✅ SSG (Static Site Generation)

빌드 시점에 HTML을 미리 생성해두고, 사용자가 접속하면 즉시 정적 파일을 내려주는 방식

  • 빠른 응답 속도
  • SEO에 최적화
  • 빌드 시점 이후의 데이터 반영이 어려움

✅ Hydration

클라이언트에서 JS가 실행되면서 기존 HTML에 이벤트 바인딩을 입히는 과정

  • 빠른 첫 화면 제공
  • 페이지가 크고 복잡할수록 하이드레이션 지연 문제 발생

1. Express SSR 서버

const prod = process.env.NODE_ENV === "production";
const app = express();

// node 환경에서 msw 서버 세팅
server.listen({
  onUnhandledRequest: "bypass",
});

let vite;

if (!prod) { // prod를 사용한 환경 분기 처리
  const { createServer } = await import("vite");
  
  // 개발 환경 - 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: [],
    }),
  );
}

app.use("*all", async (req, res) => {
    // ~
  
    const rendered = await render(url, req.query);

    // HTML 템플릿 치환
    const html = template
      .replace(`<!--app-head-->`, rendered.head ?? "")
      .replace(`<!--app-html-->`, rendered.html ?? "")
      .replace(
        `<!--app-initial-data-->`,
        `<script>window.__INITIAL_DATA__ = ${JSON.stringify(rendered.initialData)}</script>`,
      );

    res.status(200).set({ "Content-Type": "text/html" }).send(html);
  } catch (e) {
    vite?.ssrFixStacktrace(e);
    console.log(e.stack);
    res.status(500).end(e.stack);
  }
});

// Start http server
app.listen(port, () => {
  console.log(`React Server started at http://localhost:${port}`);
});
  • Express 기반 서버 구축
  • prod를 사용한 개발/프로덕션 환경 분기 처리
  • HTML 템플릿 치환
    : vite 미들웨어를 개발 환경에 적용하고, prod에서는 sirv로 정적 파일 제공, 모든 요청에서 render 결과를 HTML 템플릿에 치환해 클라이언트로 전달

2. 서버 사이드 렌더링

import { Router, ServerRouter } from "../lib";
import { BASE_URL } from "../constants.js";

export const router = typeof window !== "undefined" ? new Router(BASE_URL) : new ServerRouter("");
router.addRoute("/", HomePage);
router.addRoute("/product/:id/", ProductDetailPage);

export async function render(url, query = {}) {
  // ~
  
  const { path, params } = matched;

  let initialData;

  // 서버 데이터 프리페칭
  if (path === "/") {
    initialData = await fetchProductsDataSSR(query);
  } else if (path === "/product/:id/") {
    initialData = await fetchProductDataSSR(params.id);
  }

  let pageTitle;
  let pageHtml;

  // 페이지 상태 결정 및 HTML 렌더링
  if (path === "/") {
    pageTitle = "쇼핑몰 - 홈";
    pageHtml = HomePage({ initialData, query });
  } else if (path === "/product/:id/") {
    pageTitle = initialData?.currentProduct?.title ? `${initialData?.currentProduct?.title} - 쇼핑몰` : "쇼핑몰";
    pageHtml = ProductDetailPage({ initialData });
  } else {
    pageHtml = NotFoundPage();
  }

  // 렌더링 결과 반환 (initialData - 하이드레이션에 필요한 초기 상태)
  return {
    head: `<title>${pageTitle}</title>`,
    html: pageHtml,
    initialData,
  };
}
  • 서버에서 동작하는 Router 구현
  • 서버 데이터 프리페칭
  • 서버 상태관리 초기화
    : 요청 url에 따라 fetchProductDataSSR 함수를 통해 데이터를 미리 가져오고 initialData를 페이지 컴포넌트에 전달하여 서버에서 HTML 렌더링

3. 클라이언트 Hydration

const createMemoryStorage = () => {
  let value = {};

  return {
    getItem: (key) => (key in value ? value[key] : null),
    setItem: (key, value) => {
      value[key] = value;
    },
    removeItem: (key) => {
      delete value[key];
    },
    clear: () => {
      value = {};
    },
  };
};

const memoryStorage = createMemoryStorage();

// 브라우저 환경이 아닐 때를 위한 메모리 저장 storage 추가
export const createStorage = (key, storage = typeof window === "undefined" ? memoryStorage : window.localStorage) => {
export const HomePage = withLifecycle(
  {
    onMount: () => {
      if (typeof window === "undefined") return;
      loadProductsAndCategories(); // csr
    },
    watches: [
      [
        () => {
          const { search, limit, sort, category1, category2 } = router.query;
          return [search, limit, sort, category1, category2];
        },
        () => {
          loadProducts(true);
        },
      ],
    ],
  },
  // 서버에서 렌더링한 초기 데이터를 클라이언트에서 가져옴
  ({ initialData = window.__INITIAL_DATA__, query = router.query } = {}) => {
    if (!productStore.getState().products.length && initialData) {
      // initialData를 전역 상태에 넣어 SSR와 클라이언트 상태를 일치시킴
      productStore.dispatch({
        type: PRODUCT_ACTIONS.SETUP,
        payload: initialData,
      });
    }

    // SSR - initialData, CSR - store
    const productState = typeof window === "undefined" ? initialData : productStore.getState();

    const {
      search: searchQuery,
      limit,
      sort,
      category1,
      category2,
    } = typeof window === "undefined" ? query : router.query;


    return (
      // ~
    )
  } 
)
  • window.__INITIAL_DATA__ 스크립트 주입
  • 클라이언트 상태 복원
  • 서버-클라이언트 데이터 일치
    : 서버에서 내려준 initialData를 클라이언트 store에 넣어 SSR과 동일한 상태를 복원하고, onMount에서 필요한 CSR API 호출로 상태를 최신화

4. Static Site Generation

async function generateStaticSite(url, query) {

  const rendered = await render(url, 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>
      `,
    );

  if (url == "/404") {
    fs.writeFileSync("../../dist/vanilla/404.html", html);
  } else {
    // 지정한 경로에 폴더가 없으면 생성
    if (!fs.existsSync(`../../dist/vanilla${url}`)) {
      fs.mkdirSync(`../../dist/vanilla${url}`, { recursive: true });
    }
    // 렌더링된 HTML을 해당 폴더 안에 index.html 파일로 저장 - 정적 배포 가능한 파일
    fs.writeFileSync(`../../dist/vanilla${url}/index.html`, html);
  }
}

const { products } = await getProducts();

// 홈 페이지와 404 페이지를 빌드 타임에 미리 생성
await generateStaticSite("/", {});
await generateStaticSite("/404", {});
// 상품 목록을 순회하여 각 상품 페이지를 url 기준으로 HTML 생성
for (let i = 0; i < products.length; i++) {
  await generateStaticSite(`/product/${products[i].productId}/`, {});
}

vite.close();
  • 동적 라우트 SSG (상품 상세 페이지들)
  • 빌드 타임 페이지 생성
  • 파일 시스템 기반 배포
    : render로 HTML을 생성하고, url 별 폴더에 index.html로 저장하며 홈 페이지와 404 페이지는 빌드 시점에 미리 생성

KPT

Keep

으음

Problem

여기에 적어도 되는건진 모르겠지만..

윈도우에서는 한번에 여러 서버를 띄우는게 정상 작동하지 않아 라이브러리를 2개 추가한 뒤 스크립트 명령어를 수정해줘야 했다.. 이를 알기 전엔 테스트가 돌아가지 않는게 내가 코드를 잘못 건드려서 그런건지 이유를 알 수가 없어서 너무 스트레스를 받았다.

항해 매니저님께서 직접 원격 제어로 도와주신 덕분에 테스트는 돌아가게 되었으나.. 이미 의욕을 잃은 나는 기본 과제만 마무리하고 심화 과제는 통과하지 못했다.. ^^;


그리고 다음날 맥북 삼

Try

심화 과제 꼭 시도해보기..!!

profile
빙글빙글돌아가는..

2개의 댓글

comment-user-thumbnail
2025년 9월 19일

맥북 유저 되신걸 환영해요~~ 🙌

1개의 답글