ㅂㄹㄱ

← 목록으로

SvelteKit + Workers + D1 + R2 — 무료 플랜 서버리스 블로그 스택

develop/svelte 7분 조회 381


들어가며

이 블로그의 첫 번째 글이다. 첫 글 주제로 뭘 쓸까 고민하다가, 이 블로그 자체를 어떻게 만들었는지 쓰기로 했다.

본업은 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과 파일 스토리지를 대체할 수 있다. 새 스택을 배우면서 블로그를 만든 과정을 정리한다.


기술 스택 선정

최종 스택은 다음과 같다.

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

항목선택대안선택 이유
프레임워크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 네트워크를 직접 구축하면서 배운 내용을 시리즈로 정리할 예정이다.


참고자료


태그 cloudflare-d1 cloudflare-r2 cloudflare-workers drizzle-orm edge-computing jamstack runes serverless svelte-5 sveltekit typescript wrangler

공유 X 링크드인

FAQ