[JS] Vanilla JS와 Serverless로 SSR 사이트 만들기 - SSR

thru·2024년 2월 26일
2

혼자 JS, Serverless 갖고 놀기 - 서버 사이드 렌더링편


소개

서버 사이드 렌더링(SSR)을 위해선 각 페이지 api가 완전한 HTML 문서를 응답으로 보내줄 수 있어야한다.

다만 SSR이라고 해서 무조건 페이지가 이동할 때마다 새로운 HTML으로 refresh될 필요는 없다. 이는 너무 전통적 방식의 SSR이고 최근에는 로딩 시에도 문서 정보를 볼 수 있다는 SSR의 이점만 채택하고 클라이언트 사이드 렌더링(CSR)과 연계한다.

처음에 프론트 코드가 로딩된 이후 페이지 이동이 발생하면 서버에서 받은 HTML을 토대로 내부 HTML만 스위칭하도록 구현한다.


API

서버에서 API는 Express로 간단하게 설정한다. 단순히 HTML text를 status code 200과 함께 응답하기만 하는 코드이다.

import { Router } from 'express';

const router = Router();

router.get('/profile', (req, res) => {
  res.status(200).send(baseModel(profile));
});

export default router;

서버 세팅법은 세팅편에서 설명한다


Base Model

위 코드 send에서 사용하고 있는 baseModel은 HTML text를 만들어서 반환하는 역할을 한다.

export const baseModel = (component) => `
  <!DOCTYPE html>
  <html lang="kr">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width" />
      <title>${ROUTE_TITLE[component.id]}</title>
      <link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css" />
      <link rel="stylesheet" href="./src/normalize.css"/>
      <link rel="stylesheet" href="./src/style.css"/>
      ${getStyleTag(component.id)}
    </head>
    <body>
      <div id="app" class="app">
        ${sidebar}
        <main id="main" class="main">
          ${component.content}
        </main>
        ${scrollIndicator}
      </div>
      <script src="./src/main.js" type="module"></script>
    </body>
  </html>
`;

text를 그대로 브라우저에 전달하면 렌더링이 될 수 있도록 헤더의 스타일 태그와 메타 태그, 바디의 스크립트를 포함한다.

타이틀과 일부 스타일 및 메인의 내용은 컴포넌트마다 다르게 표시되어야 하므로 인자에 component로 나타난 컴포넌트별 모델을 전달 받아 표시한다.

sidebarscrollIndicator의 경우 어떤 페이지에서도 공통적으로 표시할 일종의 Layout 컴포넌트이다.

appmain의 경우 hydrate를 위해 id가 설정되어있다.

위 HTML text는 엄밀히 말하면 뷰 모델이다. 다만 이 프로젝트에서 모델은 상호작용 없이 단순한 편이고, 모델에서 뷰 모델 변환은 HTML 형식으로 나타내는 것 뿐이라 모델과 뷰 모델을 엄격하게 구분하지 않았다.


컴포넌트 모델

export const profile = {
  id: '/profile',
  content: `
    <div id="profile" class="profile">
      <section class="profile__intro">
        <img src="${IMAGE_URL.PROFILE}" alt="프로필 이미지" class="profile__image" />
        <p class="profile__intro-text">원리를 어쩌구~</p>
      </section>
      <section class="profile__text">
        <p>소개 관련 내용</p>
      </section>
    </div>
  `,
};

컴포넌트도 HTML 형식으로 작성한다.


메인 스위칭

첫 페이지 진입 때는 HTML에 써있는 대로 브라우저가 알아서 표시하고 JS파일과 CSS파일을 로딩한다. 이후 페이지 이동 때는 HTML을 변경하고 스타일 태그도 헤더에 추가해줘야 한다.

async loadPageData(pathname) {
  try {
    this.loadPageStylesheet(pathname);
    const response = await fetch(getUrl(pathname));

    if (!response.ok) {
      throw new Error('something wrong');
    }

    const responseHtml = await response.text();
    const mainInnerHTML = responseHtml.split('<main id="main" class="main">')[1].split('</main>')[0];
    
    this.setHtmlData(mainInnerHTML);
    this.setPathname(pathname);
  } catch (err) {
    console.error(err);
  }
}

loadPageStylesheet(pathname) {
  if (document.head.querySelector(getStyleTagId(pathname))) return;

  const styleTag = getStyleTag(pathname);
  document.head.insertAdjacentHTML('beforeend', styleTag);
}

페이지 데이터를 받으면 <main>태그 내부 text를 분리해서 저장한다. 저장한 text는 이후 현제 DOM의 <main> 내부 HTML과 스위칭해서 사용한다.

스타일 태그도 HTML에서 분리한 뒤 헤더에 추가하는 방식을 사용할 수도 있다. 하지만 그러면 서버에 요청이 HTML 파일에서 한 번, CSS 파일에서 한 번, 총 두 번이 이루어진다. 그 결과로 로딩 시간도 두 배가 될 뿐더러 CSS 파일이 로딩되기 전까지 몬생긴 쌩짜 HTML이 노출된다.

이를 방지하기 위해 스타일 태그는 HTML 요청을 하기 전에 document head에 먼저 삽입해서 로딩이 HTML 요청과 동시에 일어날 수 있도록 한다.


메인 컴포넌트에서는 저장한 HTML로 <main>태그 내부를 바꾸고 자식 컴포넌트도 페이지에 맞는 걸로 마운트 및 언마운트한다.

if (curComponent) {
  curComponent.instance?.unmount();
  curComponent.instance = null;
}

const parser = new DOMParser();
const newPageNode = parser.parseFromString(
  this.props.mainInner,
  'text/html',
);

this.$target.replaceChildren(newPageNode.body.childNodes[0]);

nextComponent.instance = this.addChild(
  nextComponent.constructor,
  pathnameToId(this.refs.curPagename),
  componentProps,
);
profile
프론트 공부 중

0개의 댓글