블로그 개발기 - coldbrow.me
Astro 5와 Content Collections로 개인 블로그를 직접 만든 이야기.

블로그를 만들기로 했다
그 기록을 둘 곳이 필요했다.
처음에는 그냥 Gatsby 템플릿 가져다가 블로그를 운영했다. 포스팅 몇 개를 거기에 올리고 나니, 어느 순간 이런 생각이 들었다.
“나도 멋있는 나만의 블로그 갖고 싶다.”
Gatsby가 별로라는 얘기는 아니다. — 웹 개발자로 태어난 김에 그냥 내 손으로 만들어 보고 싶어진 것 뿐이다. 어차피 글 쓰는 일이라면, 기왕이면 그걸 굴리는 인프라까지가 내 것이면 더 즐겁지 않을까.
그래서 직접 만들기로 했다. 단, 이번에는 목표를 좀 옹졸하게 잡았다.
글 쓰는 일을 방해하지 않는 최소한의 도구를 만든다.
정적 사이트
가장 먼저 결정한 건 정적 사이트로 가기로 한 것이었다.
빠르다. 모든 페이지가 빌드 시점에 HTML로 그려져 있고, 요청이 오면 nginx가 그걸 그대로 던진다. 글 위주 사이트에서 이 속도를 포기할 이유가 안 보였다.
캐싱이 거저다. 페이지가 사용자별로 달라지지 않으니 통째로 CDN이나 엣지에 캐시해도 된다. Cache-Control 한 줄로 끝나는 종류의 문제다.
운영 부담이 없다. DB가 없으니 패치할 게 없고, 백엔드 프로세스가 없으니 메모리가 안 샌다. 인증도 없다.
이 결정 자체는 별로 어렵지 않았다. 사실 트래픽이 1RPS도 안 나올 블로그에 SSR이니 ISR이니 붙일 이유가 없다. DB도 필요 없다. 로그인은 더더욱 필요 없다.
“필요하면 나중에 추가하면 되지”라고 생각하는 순간 시작도 못 한다는 걸 몇 번 겪어봤다. 그래서 이번에는 반대로 갔다. 있으면 좋은 건 일단 다 빼고 시작한다.
남은 건:
- 글 목록과 글 상세
- 태그
- 검색
- 다크/라이트
이게 다였다.
Astro를 골랐다
Static Site Generator 후보는 익숙한 것들이었다. Next.js, Gatsby Nuxt.js, Astro.js
Next는 풀스택 프레임워크이고, 정적 블로그만 만들기엔 무게가 좀 있다.
Gatsby는 한때 정적 블로그의 표준에 가까웠지만, 올드해보였고 GraphQL을 쓰는 게 과하다고 생각했다.
Nuxt는 Vue 진영이라는 점만으로 우선순위에서 뒤로 갔다 — Vue 를 마지막으로 다룬지 3년도 더 되었다. 굳이 손에 안익는 스택을 선택할 이유는 없었다.
결국 Astro 가 가장 자연스러웠다. 이유는 두 가지였다.
첫째, MDX가 별도 통합 없이 자연스럽게 굴러간다는 점. 글은 마크다운으로 쓰되, 가끔 인터랙티브한 위젯이 필요하면 React 컴포넌트를 그 자리에 던져 넣을 수 있다.
둘째, “필요한 곳에만 JS” 라는 철학. 정적인 페이지에는 JS가 한 줄도 안 가는 게 기본 동작이다. 글 위주 사이트의 정체성과 이 철학이 맞아떨어졌다.
설정도 한 화면이다.
// astro.config.mjs
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import react from '@astrojs/react';
export default defineConfig({
integrations: [mdx(), react()],
markdown: {
shikiConfig: {
theme: 'github-dark',
wrap: false,
},
},
});
이 파일이 짧다는 게 마음에 들었다. 설정 파일이 길어지는 순간 짐이 된다.
Content Collections — 스키마가 곧 계약
Astro의 Content Collections는 이 프로젝트의 중심축이 됐다.
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const posts = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.string(),
tags: z.array(z.string()).default([]),
readMin: z.number().optional(),
draft: z.boolean().default(false),
}),
});
export const collections = { posts };
이 Zod 스키마 한 덩어리가 곧 글의 모양이다. frontmatter에 title이 없으면 빌드가 실패한다. tags에 문자열이 아닌 값이 들어가면 빌드가 실패한다. 컴파일러가 글의 형태를 검사해 주는 셈인데, 내가 신경써야할 것들을 줄여준다.
검색은 빌드타임 JSON 한 장으로 끝
처음엔 검색을 어떻게 할지가 가장 답이 안 나왔다. 클라이언트 검색 라이브러리를 쓸까, 아예 검색을 빼버릴까.
결론은 단순했다. 빌드 시점에 JSON 한 장 떨궈서, 클라이언트가 한 번 받아오면 그걸로 끝.
// src/pages/search.json.ts
import { getCollection } from 'astro:content';
import type { APIRoute } from 'astro';
import { formatDate } from '../utils/format';
export const GET: APIRoute = async () => {
const posts = await getCollection('posts', ({ data }) => !data.draft);
const sorted = posts.sort(
(a, b) => new Date(b.data.pubDate).getTime() - new Date(a.data.pubDate).getTime()
);
const data = sorted.map((post) => ({
slug: post.slug,
title: post.data.title,
tags: post.data.tags,
pubDate: formatDate(post.data.pubDate),
}));
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' },
});
};
draft인 글은 자동으로 빠진다. 필드는 검색에 필요한 네 개만 들어간다. 본문은 아예 안 포함했다 — 글 수십 개 정도면 제목·태그 검색만으로 충분하더라.
검색 페이지에서는 이 JSON을 한 번 fetch해서 그냥 메모리에 들고, 입력에 따라 필터링한다. 그게 전부다. 서버 호출도 없고, 인덱스도 없고, API 키도 없다.
디자인은 CSS 변수로 — Tailwind도 무겁다
요즘 새 프로젝트 시작하면 거의 반사적으로 Tailwind를 깐다. 이번엔 한 번 쉬어가기로 했다.
이유는 별로 거창하지 않다. 글 위주 사이트에서는 클래스 줄이 길어지는 게 거슬렸다. 그리고 “내 블로그 토큰 정도는 내 손으로 짓자”는 옹졸한 마음이 있었다.
(실제로 Tailwind는 무겁지 않다;;)
그래서 디자인 토큰을 직접 정의했다.
:root {
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
/* ... */
--fs-xs: 0.75rem;
--fs-sm: 0.875rem;
--fs-base: 1rem;
--fs-4xl: 2.25rem;
--fg: #111;
--fg-faint: #888;
--border-subtle: #e5e5e5;
}
이름은 Tailwind의 것을 절반쯤 베꼈다. 그 네이밍이 이미 머리에 박혀 있어서 굳이 새로 짓는 게 낭비였다.
코드블록은 Shiki — 런타임 비용 0의 사치
코드블록 하이라이트는 Shiki(github-dark)에 맡겼다.
markdown: {
shikiConfig: {
theme: 'github-dark',
wrap: false,
},
},
Shiki는 VS Code와 같은 토크나이저(TextMate grammar)를 쓰기 때문에 색이 익숙하다. 그리고 결정적으로 — 빌드 타임에 색칠이 끝난다. 페이지에 하이라이터 JS가 끼지 않는다.
블로그를 배포하자
Lightsail
Vercel이나 Netlify는 분명 편하다. GitHub 연결하고, 도메인 붙이고, 끝. 하지만 나만의 서버를 굴리고 싶었고, AWS Lightsail 을 선택했다.
EC2는 네트워크/보안그룹/EIP까지 머리에 다 들고 있어야 해서 잠깐만 한눈팔아도 청구서가 무서워진다.
하지만 Lightsail은 월 정액이라 “이번 달 얼마 나올지”가 머리에 안 든다. 한국 리전에 작은 인스턴스 하나 띄우고, 정적 도메인(고정 IP) 붙이고, 방화벽에서 80/443만 열면 서버 세팅이 끝난다.
서버는 nginx가 익숙했지만 Caddy로
인스턴스 안에서 정적 파일을 서빙할 도구는 처음엔 당연히 nginx로 갈 생각이었다. 회사에서도 사이드 프로젝트에서도 가장 많이 만져본 도구라 손에 익었었다.
그런데 이번엔 Caddy로 갔다. 이유는 두 가지였다.
첫째, HTTPS가 자동이다. nginx에서도 세팅이 가능하지만, 번거롭고 이후에 만료 시점도 관리해주어야 한다. 이는 귀찮은 잡일이 된다. 하지만 Caddy는 Caddyfile에 도메인만 적어두면 첫 요청 시점에 Let’s Encrypt 인증서를 자동으로 발급받고, 만료전에 자동으로 갱신하고, HTTP는 자동으로 HTTPS로 리다이렉트해 준다.
둘째, 설정이 영어 문장처럼 읽힌다. nginx 설정보다 Caddyfile의 짧은 표현이 훨씬 직관적이고 간결하다.
nginx가 순수 성능에서는 근소하게 앞서고, 운영 이력이 길어 안정적이라고 한다. 다만 트래픽이 많이 나오지 않는 사이드 프로젝트 특성상 그 근소한 우위는 잘 체감되지 않고, 반대로 Caddy의 특장점인 자동 HTTPS와 간결한 설정은 더 매력적으로 느껴졌다.
CI/CD
블로그에 기능을 추가하거나 콘텐츠를 발행할 때마다 수동으로 배포하는 불편함을 감수할 이유는 없다.
GitHub Action 을 사용해 배포 파이프라인을 구축했고, rsync로 미는 거 한 줄이다.
# .github/workflows/deploy.yml
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: rsync to server
uses: burnett01/rsync-deployments@7.0.1
with:
switches: -avz --delete
path: dist/
remote_path: /home/ubuntu/build/coldbrow-blog
remote_host: ${{ secrets.SSH_HOST }}
remote_user: ${{ secrets.SSH_USER }}
remote_key: ${{ secrets.SSH_PRIVATE_KEY }}
이게 끝이다. SSH 키는 Actions Secrets에 박아 뒀고, 인스턴스 쪽엔 그 공개키만 등록해 둔 채로 더 안 건드린다.
마무리
Astro는 러닝커브가 거의 없다시피 했다. 설정 파일 한 화면, 컴포넌트는 React, 글은 마크다운 — 깊게 다뤄본 적이 없는 프레임워크인데도 막힌 적이 거의 없었다. 도구를 익히느라 시간을 까먹는 게 사이드 프로젝트가 잘 죽는 이유 중 하나라고 생각하는데, 이번엔 그 이유가 사라졌다.
Lightsail은 이번에 처음 알게 됐다. 그동안 인스턴스가 필요하면 반사적으로 EC2나 GCE를 켰는데, 매번 VPC와 보안그룹과 요금 알람부터 잡고 시작하느라 본 작업 들어가기까지가 길었다. Lightsail은 셋업이 30분도 안 걸렸다 — 인스턴스 고르고, 고정 IP 붙이고, 방화벽에서 80/443 열고 끝. 같은 일을 EC2로 할 때 들였던 시간의 몇 분의 일도 안 됐다.
여기에 GitHub Actions가 빌드와 rsync를 알아서 한다. 결과적으로 이 블로그를 운영하면서 내가 직접 손댈 일이 거의 없다 — 글이 푸시되면 그 다음은 전부 파이프라인이 처리하고, 인프라 쪽은 Lightsail이 알아서 굴러간다.
운영을 위해 신경 쓸 게 없다시피 하다는 게, 만들고 나서 가장 마음에 드는 부분이다.
+ 추가 (2026-04-26)
블로그를 띄우고 운영을 하다 보니, 또 다른 문제가 보였다.
새 파일을 만든다. frontmatter를 적는다 — title, description, pubDate, tags, readMin… 이 중 하나라도 빠뜨리면 빌드가 깨진다. 본문에 이미지를 넣어야 하는데 — 어디에 두지?
public/uploads/? 폴더 구조는 어떻게 잡지? 이 이미지를 재활용하고 싶은데 어디 있었더라?
글 다 쓰고 나면 git add ., git commit -m '...', git push.
귀찮음이 많으니 손이 잘 안간다, 작성하다 만 글만 점점 쌓여간다.
그래서 이걸 한 번 더 깎기로 했다. 글을 관리하는 부분만 떼서 따로 만들고, 그 작은 도구가 배포까지 하면 어떨까.