Astro와 Cloudflare Workers로 블로그 만들기
블로그를 직접 만들었다. 도메인 구입부터 배포까지 하루 만에 끝냈는데, 중간중간 막힌 곳이 있었고 그중 몇 개는 검색해도 잘 안 나오는 것들이었다.
만든 순서대로 정리한다. 최종 결과는 이렇다.
마크다운 파일 작성 → git push → 45초 뒤 자동 배포
왜 직접 만들었나
티스토리나 벨로그를 쓰면 오늘 당장 글을 쓸 수 있다. 그럼에도 직접 만든 이유는 하나다. 나중에 하고 싶은 게 생겼을 때 막히지 않기 위해서다.
반대로 WEB, WAS, DB를 직접 올리는 것도 고려했다가 접었다. 만드는 건 어렵지 않은데, 인증서 갱신·보안 패치·백업·장애 대응이 글 쓸 시간을 갉아먹는다. 블로그를 하려는 건데 평소 매일 하는 웹 개발, 서버 관리를 또 하게 될 게 뻔했다.
그래서 이렇게 나눴다.
콘텐츠 마크다운 + git 락인 없음. 프레임워크 갈아타도 파일 그대로
렌더링 Astro (정적) 필요하면 라우트 단위로 SSR 전환 가능
커스텀 기능 별도 API 서비스 나중에 추가하고 싶은 기능이 들어갈 자리
“확장성” 때문에 백엔드를 짊어질 필요가 없다는 게 핵심이었다. 나중에 붙일 기능은 어차피 독립 서비스로 뜨고, 블로그는 그걸 호출만 하면 된다. 그러면 블로그가 동적일 이유가 없다.
프레임워크는 Astro로 정했다. .astro 파일 생김새가 결정적이었다.
---
const posts = await getCollection('blog'); // 위쪽: 자바스크립트
---
<ul>
{posts.map(p => <li><a href={`/blog/${p.id}`}>{p.data.title}</a></li>)}
</ul> <!-- 아래쪽: 그냥 HTML -->
위는 JS, 아래는 HTML. 새로 배울 문법이 사실상 이거 하나다. Hugo가 빌드는 더 빠르지만 Go 템플릿 문법을 따로 익혀야 하고, Next.js는 블로그 하나에 배울 게 너무 많았다.
1단계 — 도메인 정하고 사기
여기서 가장 오래 걸렸다. 사는 데는 5분인데 이름 정하는 데 반나절을 썼다.
가용 여부를 확인하는 건 어렵지 않다. Cloudflare Registrar 검색창에 후보를 치면 구매 가능 여부와 갱신가가 바로 나오고 그 자리에서 살 수 있다. 진짜 문제는 칠 이름이 안 떠오른다는 것이다.
후보를 거를 때 쓴 기준
수십 개를 버리다 보니 기준 비슷한 게 생겼다.
주제를 이름에 넣지 않았다
springtips.com, stockblog.com 같은 형태도 후보에 있었는데 결국 뺐다. 처음엔 명확해 보이지만 다른 주제를 쓰고 싶어질 때마다 어색해질 것 같았기 때문이다. 개인 브랜드형이면 뭘 올려도 어울린다.
짧은 쪽을 골랐다
길면 오타가 나고 기억에도 안 남는다.
구두로 전달될 수 있는가를 중요 포인트로 삼았다
“○○ 닷컴이야”라고 말했을 때 상대가 정확히 칠 수 있는지 봤다. 여기서 걸리면 아무리 마음에 들어도 접었다.
하이픈과 숫자는 아예 후보에서 뺐다
이유는 위와 같다. 말로 전달이 안 된다.
한글 이름을 로마자로 옮길 때 걸리는 것
한국인이 흔히 밟는 함정이 하나 있다. 로마자 표기의 eo, eu 조합은 영어권에서 제대로 안 읽힌다.
jeon → "지온" "존" "제온" 제각각
junwon → "June-won" 무난하게 읽힘
같은 이름이라도 어떤 글자를 쓰느냐에 따라 갈린다. 해외에서도 읽히면 좋겠다 싶어 eo가 들어가는 표기는 후보에서 뺐다.
이름을 만드는 방법들
실제로 다 시도해봤다.
① 쓰던 닉네임 그대로
가장 편하고 짧다. 기존 프로젝트와 이름이 이어지는 것도 장점이다. 대신 포트폴리오 효과는 약해서, 이력서에 따로 링크를 걸어야 한다.
② 실명 로마자
junwondev.com 같은 형태다. 채용 담당자가 도메인만 봐도 누구인지 안다. 대신 실명이 걸린 공간이라 일상 글을 쓸 때 자기검열이 들어간다.
③ 한자 이름의 뜻을 영어로 옮기기
이게 제일 재미있었고, 결국 여기서 나왔다. 이름의 한자를 훈으로 풀어서 영어 대응어를 찾는 방식이다. 이 방법으로 dawn 을 얻었다.
좋은 단어는 이미 다 팔렸다
dawn으로 만들 수 있는 조합을 40개쯤 확인해봤는데 37개가 선점되어 있었다. dawnlog, dawnlab, dawnstack, dawnpath… 짧고 좋은 건 전부 없다.
그래서 결국 합성어로 갔다. 다만 억지로 붙인 것들은 발음도 안 되고 기억에도 안 남아서 계속 버리게 됐다.
원하던 .com이 막혔을 때 택한 방향은 이랬다.
jun-won.com 하이픈 말로 전달되지 않아서 제외
junwon2.com 숫자 같은 이유로 제외
junwondev.com 단어 추가 "준원데브 닷컴" 그대로 입력 가능
하이픈이 특히 위험한 이유는, 하이픈 없는 원본이 이미 남의 것일 때 입소문 트래픽이 전부 그쪽으로 가기 때문이다. 명함, 발표, 이력서 링크 전부 마찬가지다.
갱신비를 보고 .shop을 접었다
원래 가비아에서 구매한 .shop 도메인을 갖고 있었다. 구매 후 1년은 1,000원 수준이라 부담 없이 샀는데, 갱신비는 3~4만원대였다.
첫해 가격은 미끼고 중요한 건 2년차부터다. .site, .online, .store도 같은 구조다.
.com은 연 1.5만원 수준에서 안정적이고 갱신가격이 크게 변하지 않는다. 결국 .com으로 갔다.
결국 Cloudflare Registrar에서 구매
Cloudflare Registrar는 마진 없이 원가로 판다. 첫해만 싸고 갱신에서 회수하는 구조가 아니다.
그리고 여기서 사면 네임서버가 자동으로 Cloudflare로 잡힌다. 나중에 배포할 때 DNS 레코드를 손으로 만들 필요가 없어져서, 결과적으로 이게 제일 편했다.
2단계 — 프로젝트 만들기
npm create astro@latest . -- --template minimal --typescript strict
npm install @astrojs/rss @astrojs/sitemap @astrojs/mdx tailwindcss @tailwindcss/vite
URL 구조를 먼저 고정했다
코드를 쓰기 전에 이것부터 정했다.
/blog/{slug} 글 (날짜를 URL에 넣지 않음)
/tags/{tag} 태그
/about /privacy /contact
프레임워크는 나중에 바꿔도 되지만 URL은 그럴 수 없다고 봤다. 한 번 색인된 뒤 주소가 바뀌면 쌓아둔 검색 순위와 외부 링크가 날아가기 때문이다.
날짜를 뺀 것도 의도적이다. 나중에 글을 수정해도 주소를 유지할 수 있고, 어떤 프레임워크로 옮겨도 재현 가능한 단순한 형태다.
설정은 한 파일에 몰아넣었다
도메인을 아직 안 정한 상태에서 시작했기 때문에, 나중에 한 줄만 고치면 되도록 만들었다.
// src/consts.ts
export const SITE_URL = 'https://dawnscovery.com';
이 값 하나가 사이트맵·RSS·canonical·robots.txt에 전부 반영된다. robots.txt도 정적 파일로 두지 않고 라우트로 만들었다.
// src/pages/robots.txt.ts
export const GET: APIRoute = () =>
new Response(`User-agent: *
Allow: /
Sitemap: ${new URL('sitemap-index.xml', SITE_URL).href}
`, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
실제로 도메인이 나중에 확정됐는데, 정말 한 줄 고치고 끝났다.
Astro 6에서 콘텐츠 API가 바뀌었다
블로그 예제를 검색해서 그대로 붙여넣었더니 빌드가 실패했다. Astro 6부터 entry.slug와 entry.render()가 제거되었는데, 검색 결과 상위에는 아직 옛날 코드가 많다.
// 예전 방식 — 지금은 동작하지 않음
const { Content } = await entry.render();
const url = `/blog/${entry.slug}`;
// 현재 방식
import { getCollection, render } from 'astro:content';
const { Content } = await render(entry);
const url = `/blog/${entry.id}`;
컬렉션 정의 파일 위치도 src/content/config.ts에서 src/content.config.ts로 바뀌었고, 로더를 명시해야 한다.
// src/content.config.ts
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const blog = defineCollection({
loader: glob({ pattern: '**/[^_]*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
검색 결과는 최신 버전을 한참 늦게 따라온다. 버전이 크게 오른 프레임워크에서는 예제보다 공식 문서를 먼저 보는 편이 훨씬 빠르다.
3단계 — GitHub 저장소 연결
저장소를 만들 때 README·.gitignore·라이선스 자동 생성은 전부 체크 해제하는 게 편하다. 하나라도 체크하면 원격에 커밋이 먼저 생겨서, 로컬을 처음 푸시할 때 히스토리가 갈라졌다며 거부당한다.
git init -b main
git add -A
git commit -m "블로그 초기 구성"
git remote add origin https://github.com/<계정>/<저장소>.git
git push -u origin main
Astro 템플릿에 .gitignore가 이미 들어 있어서 node_modules/, dist/, .astro/는 알아서 빠진다.
공개·비공개는 어느 쪽이든 상관없다. Cloudflare가 권한을 받아 가져가는 구조라 비공개로 둬도 배포된다.
4단계 — Cloudflare 연결
Cloudflare 대시보드 → Workers & Pages → Create → Connect to Git 에서 저장소를 고른다. GitHub 권한 승인을 한 번 거치면 목록에 뜬다.
그런데 여기서 예상과 달랐다.
Pages인 줄 알았는데 Workers였다
Cloudflare Pages로 하려고 들어갔는데 대시보드가 Workers 플로우를 띄웠다.
원래 별개 제품이었다. Pages는 정적 호스팅, Workers는 엣지에서 코드 실행. 그런데 Pages에 Functions가 생기고 Workers에 Static Assets가 생기면서 겹쳤고, Cloudflare가 Workers로 통합했다. 대시보드가 신규 프로젝트를 Workers 플로우로 안내한다. 다만 공식 문서에서 Pages를 유지보수 모드라고 밝힌 문구는 찾지 못했으니 여기까지는 내 관찰이다. 지금은 신규 프로젝트에 Workers를 권장하고 Pages는 사실상 유지보수 모드다.
결과적으로 이쪽이 낫다. 지금은 정적 파일만 서빙하지만, 나중에 main을 추가하면 같은 프로젝트에서 특정 경로만 가로챌 수 있다.
export default {
async fetch(request, env) {
const { pathname } = new URL(request.url);
if (pathname.startsWith('/api/')) {
return handleApiRequest(request, env); // 내 코드가 처리
}
return env.ASSETS.fetch(request); // 블로그는 그대로 정적
}
};
빌드 설정
- Build command:
npm run build - Deploy command:
npx wrangler deploy
Deploy command는 기본값이 채워져 있는데 Build command는 비어 있다. 이걸 안 넣으면 dist/가 만들어지지 않아서 “올릴 파일이 없다”며 배포가 실패한다.
wrangler 설정 파일이 저장소에 있어야 한다
Cloudflare는 GitHub 저장소에서 코드를 가져가서 빌드한다. wrangler.jsonc를 로컬에만 만들어두고 푸시를 안 하면 배포가 실패한다.
{
"name": "my-blog",
"compatibility_date": "2026-08-04",
"assets": {
"directory": "./dist",
"not_found_handling": "404-page",
"html_handling": "drop-trailing-slash"
}
}
Worker 스크립트가 없으므로 main 항목은 필요 없다.
html_handling 기본값이 canonical과 어긋난다
이게 제일 찾기 어려웠다.
Astro를 trailingSlash: 'never'로 설정해서 canonical과 사이트맵이 전부 슬래시 없는 주소로 생성된다.
https://dawnscovery.com/about
그런데 Workers Static Assets의 기본값 auto-trailing-slash는 /about을 /about/으로 307 리다이렉트한다. 디렉터리 형태로 빌드된 /about/index.html을 슬래시 붙은 주소로 서빙하기 때문이다.
그대로 뒀다면 사이트맵에 제출한 모든 URL이 리다이렉트를 타게 된다. 색인 초기에 이런 불일치가 있으면 크롤링 예산이 낭비되고 canonical 신호가 흐려진다.
위 설정처럼 drop-trailing-slash로 바꾸면 /about이 200으로 바로 응답하고, /about/으로 들어온 요청이 /about으로 리다이렉트된다. 방향이 맞다.
배포 후에 브라우저 주소창에 슬래시를 붙여서 들어가 보면 바로 확인된다.
https://내도메인.com/blog/글/ ← 이렇게 입력했을 때
https://내도메인.com/blog/글 ← 주소창이 이렇게 바뀌면 정상
슬래시가 그대로 남아 있으면 설정이 반대로 걸린 것이다.
빌드 환경의 Node 버전을 고정했다
Astro 7은 Node 22.12 이상을 요구한다. Cloudflare 빌드 환경의 기본 버전이 낮으면 빌드가 깨지므로 .nvmrc로 고정해뒀다.
22
5단계 — 커스텀 도메인 연결
배포가 끝나면 <프로젝트>.<계정>.workers.dev 형태의 임시 주소가 나온다. 계정마다 무료로 주는 서브도메인이고, 커스텀 도메인은 별도로 한 번 더 연결해야 한다.
Worker 설정 → Domains & Routes → Add → Custom Domain
네임서버가 이미 Cloudflare라면 DNS 레코드가 자동 생성된다.
Custom Domain과 Route는 다르다
www를 붙이려고 *.example.com/* Route를 추가했는데 동작하지 않았다.
- Custom Domain = DNS 레코드 생성 + Worker 연결
- Route = 이미 존재하는 호스트로 오는 요청을 가로채기만 함
Route는 DNS를 만들어주지 않는다. www 앞으로 레코드가 없으니 요청 자체가 도달을 못 한다.
그리고 와일드카드 Route는 모든 서브도메인을 이 Worker로 보낸다. 나중에 api.example.com을 다른 서비스에 쓰려고 하면 이 Route가 먼저 가로채서 원인 찾기 어려운 문제가 된다. 결국 지웠다.
연결했는데 안 열린다면
도메인 연결을 끝냈는데 브라우저에서 DNS_PROBE_FINISHED_NXDOMAIN이 났다. 설정을 잘못했나 싶어 한참 들여다봤는데, 사이트는 멀쩡했다.
이럴 때는 휴대폰에서 와이파이를 끄고 접속해보면 5초 만에 판가름 난다. 폰에서 열리면 사이트 문제가 아니라 내 PC나 공유기의 DNS 문제다.
원인은 레코드가 없던 시점에 조회한 결과가 캐싱된 것이었다. DNS는 “그런 도메인 없다”는 응답도 캐싱한다. 도메인을 사고 연결하기 전에 주소창에 미리 쳐봤다면 그때 캐싱됐을 가능성이 크다. Cloudflare 기준 이 캐시는 30분간 유지된다.
해결은 셋 중 하나다.
- 30분 기다린다
- PC의 DNS를
1.1.1.1이나8.8.8.8로 바꾼다 - 크롬이면
chrome://net-internals/#dns에서 호스트 캐시도 비운다
안 열린다고 설정을 다시 건드렸다면 멀쩡한 걸 헤집을 뻔했다.
6단계 — 이제 글만 쓰면 된다
# src/content/blog/새-글.md 작성 후
git add -A && git commit -m "새 글" && git push
푸시하면 Cloudflare가 알아서 빌드하고 배포한다. 실제로 재봤더니 45초 걸렸다.
비용
연간 고정비는 도메인 갱신비뿐이다.
| 항목 | 무료 한도 | 실제 부담 |
|---|---|---|
| 정적 파일 요청 | 무제한 · 무과금 | 0원 |
| 대역폭 | 무제한 | 0원 |
| 빌드 (CI) | 개인 블로그 규모에서는 무료 한도 안 | 회당 1~2분 |
| SSL 인증서 | 자동 발급·갱신 | 0원 |
| 커스텀 도메인 | — | 0원 |
| 도메인 갱신 | — | 연 1.5만원 |
Cloudflare 문서에 *“Requests to static assets are free and unlimited”*라고 명시되어 있다. 방문자가 늘어도 요금이 오르지 않는다.
Worker 실행 한도(하루 10만 요청)는 실행할 코드가 있을 때만 적용된다. 지금은 main이 없어서 해당되지 않는다.
호스팅을 고르면서 알게 된 건데, Vercel Hobby 플랜은 약관상 비상업적 개인 프로젝트용으로 안내된다(2026년 8월 확인 기준). 광고를 붙일 생각도 있던 터라 모르고 지나쳤으면 곤란할 뻔했다. 약관은 바뀔 수 있으니 실제 적용 전에는 각 사 공식 문서를 확인하는 편이 좋다.
정리
돌아보면 막힌 지점이 대부분 “당연히 이럴 것”이라고 가정한 곳이었다.
- 이름만 떠올리면 도메인은 금방 살 줄 알았다 → 반나절 걸렸다
- Route를 걸면 서브도메인이 연결될 것이다 → Route는 DNS를 만들지 않는다
- 연결했는데 안 열리면 설정이 잘못된 것이다 → 캐시였다
하루면 충분한 작업이고, 한 번 붙여두면 그다음부터는 마크다운 파일 만들고 git push 하는 게 전부다.
앞으로 바꿀 일이 생겨도 URL만 지키면 된다. 그것 말고는 전부 되돌릴 수 있게 만들어뒀다.
참고 자료
비용과 한도 관련 서술은 아래 공식 문서를 근거로 하며, 2026-09-02 기준이다. 요금제는 변경될 수 있으니 실제 적용 전 원문을 확인하는 편이 좋다.