블로그를 이전하며
목차
Astro로 쓰던 블로그를 Next.js 16과 마크다운으로 다시 만들었습니다. 요구사항은 단순했습니다. 목록과 아티클, 두 개의 페이지만 있으면 되고, 글은 마크다운으로 쓰고 싶었습니다.
Astro가 더 맞는 도구다
먼저 인정하고 시작하는 게 맞겠습니다. 이런 블로그에는 Astro가 더 어울립니다.
Astro는 페이지를 HTML로만 내보냅니다. 자바스크립트는 정말 필요한 곳에만 따로 심고, 이렇게 심은 조각을 아일랜드(island)라고 부릅니다. 정적인 바다 위에 인터랙션이 필요한 섬 몇 개만 띄우는 그림입니다. 목록과 아티클 두 페이지짜리 블로그의 요구사항과 정확히 일치합니다.
Next.js로 옮긴 이유는 기술적 우위가 아니라 스택을 하나로 줄이고 싶어서입니다. 업무에서 매일 Next.js를 쓰는데, 블로그를 고치려고 앉을 때마다 다른 프레임워크의 규칙을 다시 떠올려야 했습니다. 글을 쓰는 것보다 블로그를 손보는 데 마찰이 크면 결국 둘 다 안 하게 됩니다.
대신 치르는 값이 있습니다. 이 글이 실린 페이지를 열었을 때 브라우저가 실제로 내려받는 양을 재봤습니다.
| 크기 | gzip | |
|---|---|---|
| HTML | 74KB | 14KB |
| 클라이언트 JS | 619KB | 184KB |
이 페이지에서 자바스크립트가 필요한 곳은 헤더의 테마 토글 버튼 하나뿐입니다. Next.js에서는 브라우저에서도 돌아야 하는 파일 맨 위에 'use client'라고 적어 표시하는데, 저장소를 통틀어 그게 붙은 파일이 그 버튼 하나입니다.
나머지는 전부 빌드할 때 미리 만들어 둔 정적 HTML입니다. 그런데 페이지에 브라우저에서 동작하는 부분이 하나라도 있으면, Next.js는 React 본체와 페이지 전환을 담당하는 코드까지 통째로 함께 내려보냅니다. 버튼 하나에 184KB인 셈입니다.
Astro였다면 0에 가까웠을 값입니다. 실제로 테마를 처음 적용하는 일은 이미 <head> 안의 인라인 스크립트 몇 줄로 처리하고 있으니, 토글 버튼도 같은 방식으로 못 만들 이유가 없습니다.
그래도 저는 이 거래가 남는 장사라고 봅니다. 184KB는 방문자가 한 번 받아두면 브라우저가 캐시해 두고 다시 받지 않습니다. 반면 프레임워크를 오갈 때 드는 인지 비용은 글을 쓸 때마다 반복해서 내야 하는 값입니다. 다만 그건 제 사정이지 기술적 근거는 아닙니다. 블로그 하나만 새로 만드는 상황이라면 저는 Astro를 권하겠습니다.
왜 마크다운인가
에디터를 켜고 글을 쓰기까지의 마찰이 적을수록 글을 더 자주 쓰게 됩니다. 마크다운 파일은 저장소 안에 그냥 텍스트로 존재하고, 버전 관리가 되고, 어떤 편집기로든 열립니다.
제목이나 날짜 같은 정보는 파일 맨 위에 --- 세 줄로 감싼 영역에 적어둡니다. 이 영역을 frontmatter라고 부릅니다.
---
title: 블로그를 이전하며
summary: 한 줄 요약
publishedAt: 2026-08-04
---| 항목 | 선택 | 이유 |
|---|---|---|
| 콘텐츠 | 저장소 내 .md | 버전 관리, 마찰 없음 |
| 하이라이팅 | Shiki (빌드 타임) | 클라이언트 JS 0바이트 |
| 배포 | GitHub Pages | 정적이면 충분함 |
마크다운을 HTML로 바꾸기
마크다운 파일 하나가 화면에 나오기까지는 두 단계를 거칩니다. 먼저 gray-matter라는 라이브러리가 위쪽 frontmatter를 떼어내 제목과 날짜를 뽑아냅니다. 남은 본문은 remark와 rehype에 넘깁니다.
remark와 rehype는 마크다운을 HTML로 바꾸는 일을 여러 단계로 나눠 처리하는 도구 모음입니다. remark가 마크다운 단계를, rehype가 HTML 단계를 맡고, 필요한 기능은 플러그인으로 중간에 끼워 넣습니다. 표를 지원하려면 remark-gfm을, 코드에 색을 입히려면 rehype-pretty-code를 끼우는 식입니다.
여기서 첫 번째 함정을 만났습니다.
react-markdown이 기본으로 제공하는 Markdown 컴포넌트는 이 단계들을 동기로 실행합니다. 동기는 결과가 나올 때까지 그 자리에서 기다리는 방식입니다. 그런데 코드 하이라이팅에 쓰는 rehype-pretty-code는 내부적으로 Shiki라는 하이라이터를 씁니다. Shiki는 VS Code와 같은 엔진으로 색을 입히는데, 그러려면 문법 정의와 테마 파일을 먼저 읽어와야 합니다. 파일을 읽어오는 일은 시간이 걸리므로 비동기입니다. 준비되면 알려달라고 해두고 일단 넘어가는 방식입니다.
기다릴 생각이 없는 쪽에 기다려야 하는 일을 밀어 넣은 셈이라, 그대로 조합하면 이런 에러가 납니다.
Error: `runSync` finished async. Use `run` instead"동기로 돌리라고 했는데 중간에 비동기 작업이 끼어들었다"는 뜻입니다.
해결책은 v10이 제공하는 MarkdownAsync입니다. 이름 그대로 기다릴 줄 아는 버전이고, 서버에서만 실행되므로 브라우저로 내려가는 코드도 늘지 않습니다.
import { MarkdownAsync } from "react-markdown";
import rehypePrettyCode from "rehype-pretty-code";
import remarkGfm from "remark-gfm";
export function Markdown({ children }: { children: string }) {
return (
<MarkdownAsync
remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypePrettyCode, { theme: "github-dark-dimmed" }]]}
>
{children}
</MarkdownAsync>
);
}날짜에 숨어 있던 함정
두 번째 함정은 더 조용했습니다.
gray-matter는 frontmatter를 YAML로 읽습니다. 그런데 YAML은 따옴표 없는 2026-08-04를 날짜로 알아보고, 문자열이 아니라 JS Date 객체로 바꿔서 돌려줍니다. 편의 기능이지만 여기서는 반갑지 않습니다. 어떤 글은 문자열, 어떤 글은 Date가 되면 정렬하고 출력하는 코드가 두 경우를 다 신경 써야 합니다.
그래서 파일을 읽어들이는 지점에서 한 번 문자열로 통일해 둡니다. 이후 코드는 항상 "2026-08-04" 형태만 상대하면 됩니다.
function toDateString(value: unknown): string {
if (value instanceof Date) {
return value.toISOString().slice(0, 10);
}
// ...
}화면에 찍을 때는 로케일과 타임존을 코드에 직접 못 박았습니다. 그러지 않으면 기준이 빌드하는 컴퓨터의 설정을 따라갑니다. 제 노트북에서 빌드할 때와 GitHub 서버에서 빌드할 때 날짜가 하루씩 어긋날 수 있다는 뜻입니다. 고정해 두면 어디서 빌드하든 같은 결과가 나옵니다.
페이지는 넘치지 않고, 내용물이 스크롤한다
화면 폭이 좁아졌을 때 가장 자주 깨지는 건 제가 직접 작성한 마크업이 아니라 마크다운이 만들어내는 요소들입니다. 긴 코드 한 줄, 컬럼이 많은 테이블, 원본 크기가 큰 이미지가 대표적입니다.
이런 요소가 화면보다 넓어지면 페이지 전체에 가로 스크롤이 생깁니다. 그러면 글을 읽으려고 세로로 스크롤하는 동안에도 본문이 좌우로 흔들립니다. 넘치는 요소는 페이지를 밀어내는 대신 자기 자신 안에서 스크롤해야 합니다.
| 요소 | 문제 | 처리 |
|---|---|---|
pre | 긴 줄이 페이지를 넓힘 | overflow-x: auto |
table | 컬럼이 많으면 넘침 | 스크롤 래퍼로 감싸기 |
img | 원본 크기 그대로 | width: 100%; height: auto |
| 긴 URL | 단어가 안 쪼개짐 | overflow-wrap: break-word |
코드 블록(pre)은 overflow-x: auto 한 줄로 끝납니다. 긴 줄을 접어서 다음 줄로 넘기는 방법(white-space: pre-wrap)도 있지만, 코드에서는 들여쓰기가 무너져 오히려 읽기 어려워지므로 쓰지 않았습니다.
테이블은 pre와 달리 자기 혼자 스크롤하지 못하므로 감싸줄 요소가 필요합니다. 마크다운 문법에는 그걸 적을 자리가 없으니, 렌더러 쪽에서 table이 나올 때마다 자동으로 넣어줍니다.
table({ children, ...rest }) {
return (
<div className="my-8 overflow-x-auto">
<table {...rest}>{children}</table>
</div>
);
}이미지는 width: 100%; height: auto로 화면에 맞춰 줄이되, 원본의 가로세로 비율을 미리 알려주는 것이 중요합니다. 알려주지 않으면 브라우저는 이미지가 도착하기 전까지 그 자리를 0의 높이로 둡니다. 그러다 이미지가 로드되는 순간 갑자기 자리를 차지하면서 읽고 있던 아래 내용이 밀려 내려갑니다.
정적 사이트에서는 Next의 이미지 최적화 기능을 쓸 수 없으므로, 빌드할 때 public/ 안의 파일을 직접 열어 크기를 읽고 width/height 속성에 넣었습니다. 브라우저는 이 두 값으로 비율을 계산해 자리를 미리 비워둡니다.
const { width, height } = imageSize(fs.readFileSync(file));본문 폭은 넓다고 좋은 게 아닙니다. 한 줄에 글자가 너무 많으면 줄 끝에서 다음 줄 첫머리로 눈이 돌아올 때 자리를 놓칩니다. max-w-3xl 정도에서 폭을 멈추고, 대신 글자 크기만 화면에 따라 조금 키웁니다. 링크와 버튼은 최소 44px을 확보했습니다. 손가락 끝이 화면에 닿는 면적이 그 정도라, 그보다 작으면 정확히 누르기 어렵습니다.
정적 export
배포처가 GitHub Pages라 output: 'export'로 빌드합니다. 이 모드에서는 모든 페이지가 빌드 시점에 HTML 파일로 만들어져 나옵니다. 돌아가는 서버가 없으니, 방문자가 접속한 시점에 서버가 뭔가 해주는 기능은 전부 못 씁니다. 이미지 최적화나 일정 시간마다 페이지를 다시 만들어주는 기능(ISR) 같은 것들입니다.
블로그에는 아무 문제가 되지 않는 제약입니다. 서버도 데이터베이스도 없고, 관리할 것이라곤 CDN에 올라간 파일들뿐입니다.
가장 좋은 인프라는 없는 인프라다.
옮기면서 알게 된 것
이전 블로그는 Astro + MDX였습니다. MDX는 마크다운 안에 컴포넌트를 섞어 쓸 수 있는 형식인데, 한 파일에 글과 코드 블록과 JSX가 뒤엉키는 게 지저분해 보였습니다. 그래서 코드 예제만 따로 뺐습니다. 예제를 .ts 파일에 담아두고, 본문에서는 import해서 렌더하는 방식이었습니다.
// snippets/dockerfile.ts
export const dockerfileSnippet = `
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
`;import { dockerfileSnippet } from "./snippets/dockerfile";
<CodeBlock code={dockerfileSnippet} lang="dockerfile" />정리한 기분이 들었습니다. 그런데 이 분리가 문제의 원인이었습니다.
밖으로 빼는 순간 성격이 바뀐다
코드를 본문에 두면 그건 글입니다. 마크다운이 읽고 화면에 그대로 내보냅니다.
코드를 .ts 파일로 빼면 그건 프로그램의 값이 됩니다. 화면에 나오기 전에 자바스크립트가 먼저 읽습니다. 눈에는 똑같은 코드가 보이지만, 사이에 파서가 하나 더 낀 것입니다.
그리고 자바스크립트에게 백슬래시(\)는 평범한 글자가 아닙니다. "다음 글자는 특별하게 읽어라"는 신호입니다. 그래서 자바스크립트는 문자열을 읽으면서 백슬래시를 자기 몫으로 가져가 버립니다. 제가 파일에 쓴 것과 독자가 화면에서 보는 것이 달라집니다.
| 원래 쓴 것 | 자바스크립트의 해석 | 페이지에 나간 것 |
|---|---|---|
\d, \[ | 모르는 조합이라 백슬래시만 버림 | d, [ |
\" | 따옴표를 감싸주는 표시 | " |
\n | 줄바꿈 문자 | 실제 줄바꿈 |
RUN \ + 줄바꿈 | 다음 줄을 잇는 표시 | 한 줄로 붙음 |
옮겨온 7편 중 6편의 코드가 이렇게 손상돼 있었습니다.
정규식은 의미가 바뀌었습니다. 숫자 하나를 뜻하는 \d가 d가 되면, 숫자를 요구하던 조건이 알파벳 d를 요구하는 조건으로 변합니다. 문법은 멀쩡해 보이는데 동작만 다릅니다.
package.json 예제는 붙여넣으면 에러가 났습니다. JSON에서 문자열 안의 따옴표는 \"로 적어야 하는데, 백슬래시가 사라지면서 따옴표가 엉뚱한 자리에서 문자열을 끝내버렸습니다.
여러 줄짜리 셸 명령은 한 줄로 뭉개졌습니다. 위 예제의 줄 끝 백슬래시는 "이 명령은 다음 줄에 이어진다"는 표시인데, 자바스크립트가 그걸 먼저 가져가면서 여섯 줄이 한 줄로 붙었습니다.
아무도 알려주지 않는다
가장 나쁜 점은 에러가 안 난다는 것이었습니다. 백슬래시가 사라진 결과물도 문자열로서는 완벽히 유효합니다. 타입 체크도 통과하고, 빌드도 성공하고, 페이지도 잘 뜹니다. 발견하려면 올라간 글을 하나하나 열어 원본과 눈으로 대조하는 수밖에 없습니다. 6편이 그 상태로 방치돼 있었던 이유입니다.
물론 그 구조를 유지한 채로도 고칠 수는 있었습니다. String.raw를 붙이거나 백슬래시를 전부 두 개씩 쓰면 됩니다. 하지만 그건 예제를 쓸 때마다 사람이 기억해야 하는 규칙이고, 잊어버려도 아무도 알려주지 않습니다. 사람의 기억력에 정확성을 맡기는 설계입니다.
해결은 다시 안으로 넣는 것
지금은 코드가 본문 안 마크다운 펜스에 그냥 있습니다. 여는 펜스와 닫는 펜스 사이는 있는 그대로의 텍스트일 뿐이라, 자바스크립트를 거치지 않고 화면에 나옵니다.
| 옛 방식 | 지금 | |
|---|---|---|
| 코드가 있는 곳 | .ts 파일, import | .md 파일, 펜스 안 |
| 거치는 파서 | 자바스크립트 → MDX → HTML | 마크다운 → HTML |
\ 다룰 때 | 두 개씩 쓰거나 String.raw | 그냥 씀 |
| 틀렸을 때 | 에러 없음, 조용히 깨짐 | 틀릴 것이 없음 |
설치한 라이브러리도, 켠 옵션도, 붙인 플래그도 없습니다. 파서 한 겹을 없앤 게 전부입니다.
파일이 지저분해 보여서 정리하려던 리팩터링이 원인이었다는 게 이 일의 교훈입니다. 코드를 밖으로 빼면 파일은 깔끔해지지만, 그 코드는 글이기를 그만두고 프로그램의 일부가 됩니다. 보여주기만 할 텍스트라면 남에게 해석시키지 말고 본문에 그냥 두는 편이 낫습니다.
남은 것들
RSS는 그사이 붙였습니다. 태그, 페이지네이션, 목차는 아직 없습니다. 필요해지면 그때 붙이면 됩니다. 지금은 글을 쓰는 게 먼저입니다.