Dawnscovery

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 검색창에 후보를 치면 구매 가능 여부와 갱신가가 바로 나오고 그 자리에서 살 수 있습니다. 진짜 문제는 칠 이름이 안 떠오른다는 것입니다.

작명 원칙 네 가지

후보를 수십 개 버리면서 정리된 기준입니다.

1. 주제를 이름에 박지 말 것

springtips.com, stockblog.com 같은 이름은 처음엔 명확해 보이지만 나중에 족쇄가 됩니다. 다른 주제를 쓰고 싶어질 때마다 도메인이 어색해지거든요. 개인 브랜드형으로 가면 뭘 올려도 어울립니다.

2. 짧을수록 강하다

짧은 글자수를 목표로 잡았습니다. 길면 오타가 나고 기억에 안 남습니다.

3. 듣고 칠 수 있어야 한다

이게 제일 중요합니다. “○○ 닷컴이야”라고 말했을 때 상대가 정확히 칠 수 있어야 합니다. 여기서 걸리면 아무리 예뻐도 탈락입니다.

4. 하이픈과 숫자는 금지

이유는 3번과 같습니다. 말로 전달이 안 됩니다.

⚠️ 한글 이름을 로마자로 쓸 때

한국인이 흔히 밟는 함정이 하나 있습니다. 로마자 표기의 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   단어를 붙인다 — "준원데브 닷컴" 그대로 쳐짐

하이픈이 특히 위험한 이유는, 하이픈 없는 원본이 이미 남의 것일 때 입소문 트래픽이 전부 그쪽으로 가기 때문입니다. 명함, 발표, 이력서 링크 전부 마찬가지입니다.

⚠️ 프로모션 TLD의 갱신비

원래 가비아에서 구매한 .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.slugentry.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 & PagesCreateConnect to Git 에서 저장소를 고릅니다. GitHub 권한 승인을 한 번 거치면 목록에 뜹니다.

그런데 여기서 예상과 달랐습니다.

Pages인 줄 알았는데 Workers였다

Cloudflare Pages로 하려고 들어갔는데 대시보드가 Workers 플로우를 띄웠습니다.

원래 별개 제품이었습니다. Pages는 정적 호스팅, Workers는 엣지에서 코드 실행. 그런데 Pages에 Functions가 생기고 Workers에 Static Assets가 생기면서 겹쳤고, Cloudflare가 Workers로 통합했습니다. 지금은 신규 프로젝트에 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 & RoutesAddCustom 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 네거티브 캐시

도메인 연결을 끝냈는데 브라우저에서 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) 3,000분/월 회당 1~2분이라 여유
SSL 인증서 자동 발급·갱신 0원
커스텀 도메인 0원
도메인 갱신 연 1.5만원

Cloudflare 문서에 *“Requests to static assets are free and unlimited”*라고 명시되어 있습니다. 방문자가 늘어도 요금이 오르지 않습니다.

Worker 실행 한도(하루 10만 요청)는 실행할 코드가 있을 때만 적용됩니다. 지금은 main이 없어서 해당되지 않습니다.

호스팅을 고르면서 알게 된 건데, Vercel Hobby 플랜은 상업적 이용을 금지합니다. 광고를 붙일 계획이 있다면 약관을 먼저 확인하는 게 좋습니다. Cloudflare는 그런 제약이 없습니다.


정리

돌아보면 막힌 지점이 대부분 “당연히 이럴 것”이라고 가정한 곳이었습니다.

  • 이름만 떠올리면 도메인은 금방 살 줄 알았다 → 반나절 걸렸다
  • Route를 걸면 서브도메인이 연결될 것이다 → Route는 DNS를 만들지 않는다
  • 연결했는데 안 열리면 설정이 잘못된 것이다 → 캐시였다

하루면 충분한 작업이고, 한 번 붙여두면 그다음부터는 마크다운 파일 만들고 git push 하는 게 전부입니다.

앞으로 바꿀 일이 생겨도 URL만 지키면 됩니다. 그것 말고는 전부 되돌릴 수 있게 만들어뒀습니다.