Articles

한 페이지 앱에 글 페이지를 붙일 때 빌드 시점에 HTML로 만들기

리액트로 만든 한 페이지 앱은 검색 로봇에게 빈 화면으로 보여요. 이 사이트의 글 페이지를 프레임워크를 바꾸지 않고 정적 HTML로 만든 방법을 정리했어요.

지금 읽고 계신 이 페이지는 리액트로 그려진 것이 아니에요. 빌드할 때 마크다운 파일을 읽어서 만든 평범한 HTML 파일이에요. 팀 홈페이지는 Vite와 리액트로 만든 한 페이지 앱인데, 글 페이지만 다른 방식으로 만든 이유와 방법을 정리했어요.

한 페이지 앱은 처음에 비어 있어요

한 페이지 앱의 HTML을 열어 보면 내용이 거의 없어요.

<body>
  <div id="root"></div>
  <script type="module" src="/src/main.tsx"></script>
</body>

화면에 보이는 모든 것은 자바스크립트가 실행된 뒤에 만들어져요. 사람이 브라우저로 볼 때는 문제가 없지만, 페이지를 읽어 가는 프로그램에게는 사정이 달라요.

  • 검색 로봇 중에는 자바스크립트를 실행하지 않거나, 실행하더라도 나중으로 미루는 것이 있어요.
  • 메신저에 링크를 붙였을 때 뜨는 미리보기 카드는 자바스크립트를 실행하지 않고 HTML의 meta 태그만 읽어요.
  • 주소가 달라도 같은 HTML 파일이 돌아오니, 모든 페이지의 제목과 설명이 똑같아요.

프로젝트 목록을 보여주는 첫 화면은 이래도 괜찮았어요. 하지만 글은 달라요. 글은 검색으로 찾아 들어오는 것이고, 링크로 공유되는 것이에요. 읽히지 않는 글은 쓴 의미가 없어요.

선택지

방법은 여러 가지가 있어요.

방법 좋은 점 걸리는 점
서버에서 그려 주는 프레임워크로 옮기기 가장 정석이에요 사이트 전체를 다시 만들어야 해요
빌드한 뒤 브라우저를 띄워 화면을 저장하기 기존 코드를 그대로 써요 빌드가 느리고 무거워요
글 페이지만 빌드할 때 HTML로 만들기 가볍고 단순해요 글 페이지의 틀을 따로 만들어야 해요

세 번째를 골랐어요. 글 페이지에는 움직이는 요소가 필요 없어요. 제목과 본문과 링크가 전부예요. 리액트가 할 일이 없는 페이지를 굳이 리액트로 그릴 이유가 없었어요.

Vite 플러그인 하나로 해결했어요

Vite는 빌드 과정의 여러 지점에 끼어들 수 있는 플러그인 구조를 갖고 있어요. 빌드 결과물을 내보내기 직전에 파일을 추가하는 지점이 있어서, 거기서 글마다 HTML 파일을 하나씩 만들어 넣어요.

export default function staticPages(): Plugin {
  return {
    name: 'static-pages',
    generateBundle() {
      const { pages } = buildPages(root)
      for (const page of pages) {
        this.emitFile({
          type: 'asset',
          fileName: `${page.path.slice(1)}index.html`,
          source: page.html,
        })
      }
    },
  }
}

/articles/글이름/이라는 주소는 articles/글이름/index.html이라는 파일이 돼요. 정적 파일을 서빙하는 곳이라면 어디든 폴더 안의 index.html을 그 폴더 주소로 열어 주기 때문에, 서버 설정을 건드릴 필요가 없어요.

글은 마크다운 파일로 보관해요

글 하나가 파일 하나예요. 파일 맨 위에 제목과 설명을 적고, 그 아래에 본문을 써요.

---
title: 글 제목
description: 목록과 검색 결과에 보이는 한두 문장
---

본문은 여기서부터 써요.

파일 이름 앞에는 01-, 02-처럼 숫자를 붙여요. 이 숫자가 목록의 순서를 정하고, 주소에는 숫자를 뗀 나머지가 쓰여요. 글을 추가하려면 파일 하나를 넣으면 돼요. 코드는 고치지 않아요.

플러그인은 폴더의 파일을 읽어서 머리말과 본문을 나누고, 본문을 HTML로 바꿔요.

const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(raw)
const meta: Record<string, string> = {}
for (const line of (match?.[1] ?? '').split('\n')) {
  const colon = line.indexOf(':')
  if (colon > 0) meta[line.slice(0, colon).trim()] = line.slice(colon + 1).trim()
}

머리말을 읽는 라이브러리를 따로 쓰지 않았어요. 필요한 것이 이름: 값 두 줄뿐이라 직접 읽는 편이 간단했어요.

페이지마다 다른 제목과 설명

정적 HTML로 만들면 페이지마다 head를 다르게 채울 수 있어요. 글마다 아래 내용이 들어가요.

  • 글 제목이 들어간 title
  • 글 설명이 들어간 description
  • 이 글의 대표 주소를 알려 주는 canonical
  • 링크 미리보기 카드에 쓰이는 og:title, og:description, og:url
  • 이 페이지가 글이라는 것을 검색 엔진에 알려 주는 구조화 데이터

제목과 설명은 마크다운 파일에서 온 글자라, HTML에 넣기 전에 꺾쇠와 따옴표 같은 글자를 바꿔 줘요. 제목에 <가 들어 있어도 페이지가 깨지지 않게 하려는 것이에요.

사이트맵도 같이 만들어요

글 목록을 이미 알고 있으니 사이트맵도 같은 자리에서 만들어요.

function sitemap(pages: Page[]) {
  const urls = ['/', ...pages.map((page) => page.path)]
    .map((path) => `  <url>\n    <loc>${SITE}${path}</loc>\n  </url>`)
    .join('\n')
  return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`
}

전에는 사이트맵을 손으로 적은 파일로 두고 있었어요. 글을 추가할 때마다 사이트맵도 고쳐야 한다면 언젠가 반드시 빠뜨려요. 같은 목록에서 둘 다 만들면 어긋날 일이 없어요.

개발할 때도 똑같이 보여야 해요

빌드할 때만 파일이 생기면 개발 서버에서는 글 페이지를 볼 수 없어요. 글을 고칠 때마다 빌드해서 확인하는 것은 번거로워요.

그래서 같은 플러그인이 개발 서버에도 끼어들어요. 글 주소로 요청이 오면 그 자리에서 HTML을 만들어 돌려줘요.

configureServer(server) {
  server.middlewares.use((req, res, next) => {
    const path = (req.url ?? '').split('?')[0]
    const page = buildPages(root).pages.find((item) => item.path === path)
    if (!page) return next()
    res.setHeader('Content-Type', 'text/html; charset=utf-8')
    res.end(page.html)
  })
}

페이지를 만드는 함수 하나를 빌드와 개발 서버가 같이 써요. 그래서 개발 중에 본 화면과 배포된 화면이 달라질 수 없어요. 마크다운 파일을 고치면 브라우저가 알아서 새로 고쳐지게도 해 뒀어요.

첫 화면에도 글 목록을 넘겨줘요

리액트로 그리는 첫 화면에도 최근 글 목록이 나와요. 이 목록을 따로 관리하면 또 어긋나니, 플러그인이 글 목록을 가상 모듈로 내보내요.

import { articles } from 'virtual:articles'

실제로는 없는 파일인데, 플러그인이 이 이름의 요청을 가로채서 글의 주소, 제목, 설명만 담은 코드를 돌려줘요. 본문은 넣지 않아서 첫 화면이 무거워지지 않아요.

포기한 것

이 방식에도 대가가 있어요.

  • 글 페이지와 첫 화면이 서로 다른 방식으로 만들어져서, 상단 메뉴 같은 공통 요소를 두 군데에 써야 해요.
  • 첫 화면에서 글로 넘어갈 때 페이지를 새로 불러와요. 한 페이지 앱의 매끄러운 화면 전환은 없어요.
  • 글 안에 움직이는 요소를 넣기 어려워요.

글이 수백 개로 늘거나 글 안에 복잡한 기능이 필요해지면 제대로 된 프레임워크로 옮기는 것이 맞아요. 지금 규모에서는 플러그인 파일 하나로 충분했어요.

정리

  • 한 페이지 앱의 HTML은 비어 있어서, 읽어 가는 프로그램에게는 내용이 보이지 않아요.
  • 움직임이 필요 없는 페이지는 빌드할 때 HTML로 만들어 두는 것이 가장 가벼워요.
  • 글 목록 하나에서 페이지, 사이트맵, 첫 화면의 목록을 모두 만들면 서로 어긋나지 않아요.
  • 빌드와 개발 서버가 같은 함수를 쓰게 하면 확인하기 쉬워요.
다음 글 팀 프로젝트 기여도를 대화로 풀어 주는 서비스를 만든 이유