SvelteKit + Workers + D1 + R2 — 무료 플랜 서버리스 블로그 스택
SvelteKit + Cloudflare Workers + D1 + R2 조합으로 서버 관리 없는 블로그를 무료 플랜으로 구축할 수 있다. 기술 스택 선정부터 D1·R2 연동, 배포까지 정리한다.
ㅂㄹㄱ
2026-04-05 ·
들어가며
이 블로그의 첫 번째 글이다. 첫 글 주제로 뭘 쓸까 고민하다가, 이 블로그 자체를 어떻게 만들었는지 쓰기로 했다.
본업은 PHP 개발자다. WordPress로 시작해서, Laravel로 꽤 오래 블로그와 웹 서비스를 만들어왔다. Apache + MySQL + PHP — 이른바 LAMP 스택이 손에 붙어 있다.
근데 사이드 프로젝트로 뭔가 새로운 걸 배우고 싶었다. 매번 PHP-FPM 설정, MySQL 백업, Let's Encrypt 갱신 같은 서버 관리를 반복하는 것도 지겨웠다. 개인 블로그 하나를 새로 만들면서 다른 스택을 익혀보기로 했다.
SvelteKit + Cloudflare Workers 조합이 눈에 들어왔다. Next.js는 개인 블로그에 오버엔지니어링 느낌이었고, Astro도 좋지만 SvelteKit의 간결한 문법이 더 끌렸다.
결론은 SvelteKit이었다. Workers 위에 올리면 서버 관리가 아예 없고, D1과 R2를 붙이면 MySQL과 파일 스토리지를 대체할 수 있다. 새 스택을 배우면서 블로그를 만든 과정을 정리한다.
기술 스택 선정
최종 스택은 다음과 같다.
graph TD
Browser[브라우저] --> CDN[Cloudflare CDN<br/>Edge Cache]
CDN --> Workers[Cloudflare Workers<br/>SvelteKit SSR]
Workers --> D1[(D1<br/>게시글·메타데이터)]
Workers --> R2[(R2<br/>이미지·첨부 파일)]
각 선택지를 비교하면 이렇다.
| 항목 | 선택 | 대안 | 선택 이유 |
|---|---|---|---|
| 프레임워크 | SvelteKit | Next.js, Astro | 번들 사이즈, 문법 간결함 |
| 런타임 | Cloudflare Workers | Vercel, Netlify | 비용, Edge 배포 |
| DB | D1 (SQLite) | PlanetScale, Supabase | Workers 네이티브 통합 |
| ORM | Drizzle ORM | Prisma | Edge 환경 호환성 |
| 스토리지 | R2 | S3 | Egress 비용 없음 |
| 상태 관리 | Svelte 5 runes | Zustand, Pinia | 프레임워크 내장 |
Laravel 쓸 때 Eloquent ORM이 손에 익어서 Prisma가 먼저 눈에 들어왔는데, Workers 환경에서 Prisma Client가 Node.js 런타임에 의존하는 게 문제였다. Drizzle은 Edge 런타임에서 네이티브로 동작한다. Eloquent의 Active Record 패턴과 달리 Drizzle은 쿼리 빌더에 가깝다. 처음엔 어색했지만 타입 추론이 워낙 좋아서 금방 적응했다.
Svelte 5 Runes 시스템
Svelte 5에서 가장 큰 변화는 runes 시스템이다. 기존 $: 반응형 선언이 $state, $derived, $effect로 명시적으로 바뀌었다.
$state — 반응형 상태
<script>
let count = $state(0);
let posts = $state<Post[]>([]);
</script>
<button onclick={() => count++}>
클릭 수: {count}
</button>
기존 let count = 0과 달리 $state로 선언하면 해당 변수가 반응형임을 명시적으로 표현한다. 컴파일러가 추적할 대상을 정확히 알기 때문에 불필요한 재렌더링이 줄어든다.
$derived — 파생 상태
<script>
let posts = $state<Post[]>([]);
let publishedCount = $derived(posts.filter(p => p.published).length);
</script>
$derived는 의존하는 state가 바뀔 때만 재계산된다. $: 문법과 동작은 같지만 의도가 명확하다.
$effect — 사이드 이펙트
<script>
let query = $state('');
$effect(() => {
// query가 바뀔 때마다 실행
console.log('검색어 변경:', query);
});
</script>
useEffect와 비슷하지만 의존성 배열이 없다. $effect 블록 안에서 읽은 $state 값을 자동으로 추적한다.
SvelteKit 라우팅과 데이터 로딩
SvelteKit의 파일 기반 라우팅은 Next.js App Router와 비슷하지만, 데이터 로딩 패턴이 더 명시적이다.
파일 구조
src/routes/
blog/
+page.svelte # 블로그 목록 UI
+page.server.ts # 서버 사이드 데이터 로딩
[slug]/
+page.svelte # 개별 포스트 UI
+page.server.ts # slug 기반 데이터 로딩
+page.server.ts — load 함수
// src/routes/blog/[slug]/+page.server.ts
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { posts } from '$lib/server/schema';
import { eq } from 'drizzle-orm';
export const load: PageServerLoad = async ({ params, platform }) => {
const post = await db(platform?.env.DB)
.select()
.from(posts)
.where(eq(posts.slug, params.slug))
.get();
if (!post) {
error(404, '포스트를 찾을 수 없다.');
}
return { post };
};
platform?.env.DB가 Cloudflare Workers 환경에서 D1 바인딩을 가져오는 방식이다. Vite가 Workers 바인딩을 직접 처리할 수 있어서 bun run dev로 로컬 개발 시에도 D1, R2 바인딩이 그대로 동작한다.
+page.svelte — 데이터 사용
<!-- src/routes/blog/[slug]/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<article>
<h1>{data.post.title}</h1>
<p>{data.post.excerpt}</p>
<div>{@html data.post.content}</div>
</article>
Svelte 5에서는 export let data가 $props()로 바뀌었다. 타입 추론이 더 깔끔하게 된다.
Cloudflare Workers 배포
adapter-cloudflare 설정
bun add -D @sveltejs/adapter-cloudflare
// svelte.config.js
import adapter from '@sveltejs/adapter-cloudflare';
export default {
kit: {
adapter: adapter({
routes: {
include: ['/*'],
exclude: ['<all>']
}
})
}
};
wrangler.jsonc 설정
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "blog-hyochan-site",
"compatibility_date": "2026-03-17",
"compatibility_flags": ["nodejs_als"],
"main": ".svelte-kit/cloudflare/_worker.js",
"assets": {
"binding": "ASSETS",
"directory": ".svelte-kit/cloudflare"
},
"d1_databases": [
{
"binding": "DB",
"database_name": "blog-db",
"database_id": "your-database-id",
"preview_database_id": "your-preview-database-id",
"migrations_dir": "drizzle",
"remote": true
}
],
"r2_buckets": [
{
"binding": "R2_PRIVATE",
"bucket_name": "private-blog",
"preview_bucket_name": "dev-private-blog",
"remote": true
},
{
"binding": "R2_PUBLIC",
"bucket_name": "public-blog",
"preview_bucket_name": "dev-public-blog",
"remote": true
}
]
}
몇 가지 포인트는 다음과 같다.
compatibility_flags에nodejs_als를 쓴다.nodejs_compat의 후속 플래그로, AsyncLocalStorage 등 Node.js 호환 기능을 제공한다.- D1에
preview_database_id와remote: true를 설정하면 로컬 개발 시에도 Cloudflare D1에 직접 붙는다. 로컬 SQLite 파일이 아니라 실제 D1 인스턴스를 쓰기 때문에, 프로덕션과 동일한 환경에서 개발하는 셈이다. - R2도 마찬가지로
preview_bucket_name과remote: true로 실제 R2 버킷에 연결한다. 개발용과 프로덕션용 버킷을 분리해두면 실수로 프로덕션 데이터를 건드릴 일이 없다.
배포
# 로컬 개발 (Vite가 Workers 바인딩 처리)
bun run dev
# 프로덕션 배포
wrangler deploy
로컬에서는 bun run dev로 Vite 개발 서버를 띄우고, 배포할 때만 wrangler deploy를 쓴다. wrangler deploy 한 번으로 Workers 배포와 정적 에셋 업로드가 동시에 이루어진다.
D1 + Drizzle ORM 연동
D1은 SQLite 기반이라 스키마가 단순하다. Drizzle로 스키마를 정의하고 마이그레이션을 관리한다.
// src/lib/server/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
export const posts = sqliteTable('posts', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
excerpt: text('excerpt'),
content: text('content').notNull(),
published: integer('published', { mode: 'boolean' }).default(false),
createdAt: integer('created_at', { mode: 'timestamp' })
});
마이그레이션은 이렇게 적용한다.
# 스키마 변경 → 마이그레이션 SQL 생성
bunx drizzle-kit generate
# D1에 마이그레이션 적용
bunx drizzle-kit migrate
drizzle-kit generate가 스키마 diff를 계산해서 SQL 파일을 만들고, drizzle-kit migrate가 그걸 D1에 적용한다. wrangler d1 migrations apply를 쓸 수도 있지만, Drizzle 쪽에서 마이그레이션을 일원화하는 게 관리가 편하다.
정리
SvelteKit + Cloudflare Workers 조합으로 블로그를 구축한 결과를 정리하면 이렇다.
| 항목 | 결과 |
|---|---|
| Cold Start | 거의 없음 (V8 isolate 기반) |
| 배포 시간 | wrangler deploy 기준 30초 내외 |
| 월 비용 | Workers 무료 플랜으로 커버 (10만 req/day) |
| 번들 사이즈 | Next.js 대비 약 40% 감소 |
| 개발 경험 | bun run dev로 D1/R2 바인딩 직접 연결, 프로덕션과 동일 환경 |
단점도 있다. Workers 런타임은 Node.js가 아니라서 Node.js 전용 패키지를 쓸 수 없다. nodejs_als 같은 호환 플래그로 일부 커버되지만, 라이브러리 선택지가 좁아진다. 또 D1은 아직 읽기 성능이 PlanetScale 같은 전용 DB에 비해 제한적이다. 트래픽이 많은 서비스라면 다시 고려해야 할 수 있다.
개인 블로그나 소규모 서비스라면 이 스택은 충분하다. 비용, 배포 복잡도, 개발 경험 세 가지를 동시에 잡을 수 있는 조합이다.
개발 사이클 자체는 bun run dev → 브라우저 확인으로 익숙한 흐름과 비슷하다. 차이가 가장 크게 느껴지는 지점은 배포다. wrangler deploy 한 줄이면 끝나는 흐름은, 서버 SSH 접속 후 git pull && 의존성 설치 && DB 마이그레이션을 따로 돌리던 LAMP 시절의 다단 콤보와 비교하면 확실히 가볍다.
다음 글 예고
다음 글부터는 이 블로그가 올라가 있는 인프라 이야기를 한다. Cloudflare의 DNS, Anycast, Zero Trust 네트워크를 직접 구축하면서 배운 내용을 시리즈로 정리할 예정이다.