한 페이지 앱에 글 페이지를 붙일 때 빌드 시점에 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로 만들어 두는 것이 가장 가벼워요.
- 글 목록 하나에서 페이지, 사이트맵, 첫 화면의 목록을 모두 만들면 서로 어긋나지 않아요.
- 빌드와 개발 서버가 같은 함수를 쓰게 하면 확인하기 쉬워요.