목록으로 돌아가기
Svelte
7분 읽기

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/>이미지·첨부 파일)]
Diagram: graph TD Browser[브라우저] --> CDN[Cloudflare CDN<br/>Edge Cache] CDN --> Workers[Cloudflare Workers<br/>SvelteKit SSR] Workers --> D1[(D1<br/>게시글·메타데이터)] Workers --> R2[(R2<br/>이미지·첨부 파일)]

각 선택지를 비교하면 이렇다.

항목선택대안선택 이유
프레임워크SvelteKitNext.js, Astro번들 사이즈, 문법 간결함
런타임Cloudflare WorkersVercel, Netlify비용, Edge 배포
DBD1 (SQLite)PlanetScale, SupabaseWorkers 네이티브 통합
ORMDrizzle ORMPrismaEdge 환경 호환성
스토리지R2S3Egress 비용 없음
상태 관리Svelte 5 runesZustand, 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_flagsnodejs_als를 쓴다. nodejs_compat의 후속 플래그로, AsyncLocalStorage 등 Node.js 호환 기능을 제공한다.
  • D1에 preview_database_idremote: true를 설정하면 로컬 개발 시에도 Cloudflare D1에 직접 붙는다. 로컬 SQLite 파일이 아니라 실제 D1 인스턴스를 쓰기 때문에, 프로덕션과 동일한 환경에서 개발하는 셈이다.
  • R2도 마찬가지로 preview_bucket_nameremote: 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 네트워크를 직접 구축하면서 배운 내용을 시리즈로 정리할 예정이다.


참고자료

자주 묻는 질문

SvelteKit과 Next.js 중 블로그에 더 적합한 프레임워크는?
SvelteKit은 Next.js 대비 번들 크기가 약 40% 작고, Cloudflare Workers에 네이티브로 배포할 수 있어 엣지 기반 블로그에 유리합니다. 반면 Next.js는 생태계와 플러그인이 더 풍부하므로 Vercel 중심 워크플로우에 적합합니다.
Cloudflare Workers에 SvelteKit을 배포하면 비용이 얼마나 드나요?
Workers 무료 플랜으로 하루 10만 요청까지 커버됩니다. D1 데이터베이스와 R2 스토리지도 무료 티어가 있어 개인 블로그 수준에서는 사실상 무료로 운영할 수 있습니다.
SvelteKit 블로그의 배포 시간은 얼마나 걸리나요?
`wrangler deploy` 기준 약 30초 내외입니다. Cloudflare의 글로벌 엣지 네트워크에 즉시 배포되므로 별도의 빌드 파이프라인 없이도 빠른 배포가 가능합니다.
Svelte 5의 Runes 시스템은 기존 Svelte와 어떻게 다른가요?
Svelte 5는 `$state`, `$derived`, `$effect` 등의 Runes를 도입하여 반응성을 명시적으로 선언합니다. 기존의 암묵적 반응성(`let` 변수 자동 추적) 대신 의도를 코드에 드러내는 방식으로, 대규모 앱에서 예측 가능성이 높아집니다.