항해 플러스 9주차

지리·2025년 12월 21일

이번주차는 성능개선 주차로 SSR/SSG를 직접 구현해보는 주차였다.

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

KPT

Keep

이제 한주밖에 남지 않았다..! 사실 마지막주에 크리스마스가 있어서 좀 고비긴하다...! 일단 해왔던대로 저녁시간에 과제 열심히하기!

Problem

basic과제 할때는 그래도 개념하나하나를 좀더 이해하면서 과제를 했는데 시간이 촉박해지니 hard때는 basic때 했던거 붙여넣으면서 AI에게 많이 시켰던듯.매번 좀더 구조에 대해 깊게 고민하지 못하고 있어서 아쉬움이 남는다.

Try

마지막 주차엔 좀더 고민을 많이 해보려고 의식적으로 노력하기

profile
공부한것들, 경험한 것들을 기록하려 노력합니다✨

0개의 댓글