SvelteKit을 Cloudflare Workers에 배포하는 방법

지금 SvelteKit을 Cloudflare에 올리는 방법이 예전과 다르다

SvelteKit을 Cloudflare에 배포하는 방법을 검색하면 오래된 글이 많이 나온다. Pages에 올리라는 글, Workers Sites를 쓰라는 글, @sveltejs/adapter-cloudflare-workers를 쓰라는 글까지 섞여 있는데, 이 중 상당수가 지금 기준으로는 틀렸거나 권장되지 않는 방식이다. 현재 시점 기준으로 정리하면 이렇다.

Cloudflare는 Workers와 Pages 두 플랫폼을 갈수록 하나로 수렴시키고 있다. Workers에 정적 자산(static assets)을 붙이는 기능이 생기면서, 예전에는 Pages로만 가능했던 "정적 파일 + 서버 함수" 조합을 이제 Workers에서도 그대로 할 수 있게 됐다. 그래서 신규 프로젝트라면 Cloudflare는 Pages보다 Workers로 배포하는 걸 기본 경로로 안내하는 추세다. 어댑터 자체는 어느 쪽이든 지원하지만, 이 글은 Workers 기준으로 설명하고 Pages와의 차이도 짚는다.

SvelteKit 쪽에서 쓰는 어댑터 패키지는 @sveltejs/adapter-cloudflare 하나다. 예전엔 Workers용 adapter-cloudflare-workers와 Pages용 adapter-cloudflare가 따로 있었지만, 지금은 통합돼서 이 어댑터 하나로 Workers와 Pages를 둘 다 대상으로 빌드할 수 있다.

설치와 기본 설정

npm i -D @sveltejs/adapter-cloudflare

svelte.config.js에서 기존 adapter-auto나 adapter-node 대신 이걸로 교체한다.

// svelte.config.js
import adapter from '@sveltejs/adapter-cloudflare';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

/** @type {import('@sveltejs/kit').Config} */
const config = {
	preprocess: vitePreprocess(),
	kit: {
		adapter: adapter({
			// wrangler 설정 파일 경로 (기본값은 자동 탐색)
			config: undefined,
			// 로컬 개발 시 platform.env 에뮬레이션 설정
			platformProxy: {
				configPath: undefined,
				environment: undefined,
				persist: undefined
			},
			// 404 발생 시 fallback 방식: 'plaintext' 또는 'spa'
			fallback: 'plaintext'
		})
	}
};

export default config;

fallback 옵션은 프리렌더되지 않은 라우트에 대한 처리 방식을 정한다. SPA처럼 클라이언트 라우팅 위주로 쓸 거라면 'spa'로 바꾸면 된다.

wrangler.jsonc 설정

Workers 대상으로 배포하려면 프로젝트 루트에 wrangler.jsonc(또는 wrangler.json, wrangler.toml)가 있어야 한다. SvelteKit 빌드 산출물을 가리키는 설정은 대략 이런 모양이다.

// wrangler.jsonc
{
	"$schema": "node_modules/wrangler/config-schema.json",
	"name": "my-sveltekit-app",
	"main": ".svelte-kit/cloudflare/_worker.js",
	"compatibility_date": "2024-09-19",
	"compatibility_flags": ["nodejs_als"],
	"assets": {
		"binding": "ASSETS",
		"directory": ".svelte-kit/cloudflare"
	}
}

여기서 compatibility_flagsnodejs_als가 들어가는 이유가 있다. SvelteKit이 요청별 컨텍스트를 추적할 때 Node.js의 AsyncLocalStorage를 사용하는데, Cloudflare Workers 런타임에서 이 API를 쓰려면 이 플래그가 필요하다. 이걸 빠뜨리면 로컬에서는 잘 되다가 실제 배포 후 요청 컨텍스트가 꼬이는 문제가 생길 수 있다.

assets 블록의 directory는 어댑터가 정적 파일을 생성하는 경로(.svelte-kit/cloudflare)를, binding은 워커 코드 안에서 정적 자산에 접근할 때 쓸 바인딩 이름을 지정한다.

D1, KV, R2 같은 리소스를 쓴다면 같은 파일에 바인딩을 추가한다.

{
	// ...위 설정에 이어서
	"kv_namespaces": [
		{ "binding": "MY_KV", "id": "여기에-실제-kv-id" }
	],
	"d1_databases": [
		{ "binding": "DB", "database_name": "my-db", "database_id": "여기에-실제-db-id" }
	],
	"r2_buckets": [
		{ "binding": "MY_BUCKET", "bucket_name": "my-bucket" }
	]
}

platform.env 타입 지정하기

Cloudflare 바인딩(D1, KV, R2 등)은 event.platform.env를 통해 접근한다. 타입스크립트에서 이걸 타입 안전하게 쓰려면 src/app.d.tsApp.Platform 인터페이스를 채워야 한다.

// src/app.d.ts
import type { KVNamespace, D1Database, R2Bucket } from '@cloudflare/workers-types';

declare global {
	namespace App {
		interface Platform {
			env: {
				MY_KV: KVNamespace;
				DB: D1Database;
				MY_BUCKET: R2Bucket;
			};
			cf?: CfProperties;
			ctx: ExecutionContext;
		}
	}
}

export {};

@cloudflare/workers-types는 devDependency로 설치해둬야 한다.

npm i -D @cloudflare/workers-types

이렇게 타입을 지정해두면 서버 로드 함수나 API 라우트에서 다음처럼 자동완성과 타입 체크를 받으며 바인딩을 쓸 수 있다.

// src/routes/api/posts/+server.ts
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';

export const GET: RequestHandler = async ({ platform }) => {
	const db = platform?.env.DB;
	if (!db) {
		return json({ error: 'DB binding not found' }, { status: 500 });
	}

	const { results } = await db.prepare('SELECT * FROM posts LIMIT 10').all();
	return json(results);
};

로컬 개발 중에는 platform.env가 기본적으로 비어 있을 수 있는데, 이건 어댑터의 platformProxy 옵션으로 에뮬레이션할 수 있다. 별도 설정 없이도 vite dev를 실행하면 어댑터가 wrangler 설정을 읽어 로컬 바인딩을 프록시해주지만, 실제 Workers 런타임과 완전히 동일하지는 않다는 점은 감안해야 한다.

로컬 개발: vite dev vs wrangler dev

일반적인 개발 루프에서는 npm run dev(내부적으로 vite dev)로 충분하다. HMR이 되고 대부분의 기능이 정상 동작한다. 다만 이 모드는 실제 Workers 런타임이 아니라 Node 기반 Vite 개발 서버 위에서 Cloudflare 바인딩을 프록시하는 방식이라, Workers 런타임 고유의 제약(예: 특정 Node API 미지원)까지 완벽히 재현하지는 못한다.

실제 배포 환경과 최대한 가깝게 확인하고 싶다면 빌드 후 wrangler로 직접 띄운다.

npm run build
npx wrangler dev

이러면 .svelte-kit/cloudflare 디렉토리에 빌드된 워커 코드를 실제 workerd 런타임(Cloudflare Workers의 실제 실행 엔진)에서 돌려볼 수 있다. HMR은 없지만 배포 전 최종 점검용으로는 이쪽이 훨씬 신뢰도가 높다. D1을 로컬 SQLite로 테스트하고 싶다면 wrangler dev가 자동으로 로컬 D1 인스턴스를 만들어준다.

배포하기

배포는 빌드와 wrangler deploy 두 단계다.

npm run build
npx wrangler deploy

CI에서 자동 배포하려면 wrangler deploy 전에 npm run build가 선행되도록 스크립트를 짜면 된다. package.json에 다음처럼 묶어두는 것도 방법이다.

{
	"scripts": {
		"deploy": "vite build && wrangler deploy"
	}
}

GitHub Actions를 쓴다면 CLOUDFLARE_API_TOKENCLOUDFLARE_ACCOUNT_ID를 시크릿으로 등록하고, wrangler-action 같은 공식 액션으로 배포 단계를 자동화하면 된다.

Pages로 배포하고 싶다면

기존에 Pages 프로젝트를 운영 중이거나 Pages 고유 기능(예: 프리뷰 배포 URL, 특정 통합)이 필요하다면 같은 어댑터로 Pages 빌드도 가능하다. Pages 프로젝트 설정에서 빌드 명령을 npm run build, 빌드 출력 디렉토리를 .svelte-kit/cloudflare로 지정하면 된다. 단 Pages 런타임 호환성 플래그 설정에서 nodejs_als를 켜줘야 하는 건 Workers와 동일하다. Pages는 _routes.json으로 어떤 경로가 함수를 거치고 어떤 경로가 정적 파일로 바로 서빙될지 제어하는데, 이 파일 생성은 어댑터의 routes 옵션으로 조정할 수 있다.

adapter({
	routes: {
		include: ['/*'],
		exclude: ['<all>']
	}
})

exclude에 넣을 수 있는 특수 토큰으로 <build>(빌드 산출물), <files>(정적 파일), <redirects>(리다이렉트 규칙), <prerendered>(프리렌더된 페이지), <all>(위 전부)이 있다. include/exclude 규칙은 합쳐서 최대 100개까지 지정 가능하다.

다만 신규 프로젝트라면 굳이 Pages를 고를 이유가 크지 않다. Cloudflare가 플랫폼 방향성을 Workers 쪽으로 통합하고 있어서, 새 기능이나 최신 바인딩 지원이 Workers에 먼저 들어오는 경우가 많다.

자주 막히는 부분

nodejs_als 플래그를 빠뜨리는 경우. SvelteKit이 요청 컨텍스트 추적에 AsyncLocalStorage를 쓰기 때문에 이 플래그 없이 배포하면 런타임 에러가 나거나, 더 안 좋게는 조용히 이상 동작할 수 있다. wrangler.jsonccompatibility_flags 배열을 항상 확인한다.

환경변수와 시크릿 혼동. wrangler.jsoncvars는 평문 환경변수, 실제 민감한 값(API 키 등)은 wrangler secret put으로 등록해야 한다. 로컬에서는 .dev.vars 파일에 넣으면 wrangler dev가 자동으로 읽는다.

# .dev.vars
SECRET_API_KEY=local-test-key

이 파일은 반드시 .gitignore에 추가해야 한다.

빌드 출력 디렉토리 불일치. 어댑터 버전이 올라가면서 출력 디렉토리 이름이 바뀐 적이 있어서, wrangler.jsoncmainassets.directory 경로가 실제 빌드 산출물 위치와 다르면 배포는 성공해도 404만 뜨는 상황이 생긴다. npm run build 직후 .svelte-kit/cloudflare 디렉토리 구조를 한 번 눈으로 확인하는 습관을 들이는 게 좋다.

Node API 미지원으로 인한 빌드 실패. Workers 런타임은 Node.js 전체를 지원하지 않는다. fs, path 같은 일부 Node 내장 모듈은 nodejs_compat 호환성 플래그를 켜야 쓸 수 있고, 그마저도 완전한 구현이 아닌 경우가 있다. 서버 전용 라이브러리를 쓰기 전에 Workers 런타임 호환 여부를 먼저 확인하는 게 안전하다.

이렇게 어댑터 설치, wrangler 설정, 타입 지정, 로컬/배포 두 단계 개발 루프를 갖추면 SvelteKit 프로젝트를 Cloudflare의 글로벌 엣지 네트워크 위에서 서버 사이드 렌더링까지 그대로 돌릴 수 있다. Vercel이나 Netlify 대비 Cloudflare를 선택하는 이유는 대체로 가격과 D1/KV/R2 같은 통합 스토리지인데, 그 이점을 제대로 누리려면 여기서 설명한 바인딩 타입 설정과 wrangler dev 기반의 사전 검증 루틴을 꼭 챙기는 게 좋다.

KV로 세션 저장하기, 실전 예시

바인딩 타입까지 잡았다면 실제로 KV를 활용하는 코드를 하나 보는 게 감이 잡히는 데 도움이 된다. 로그인 세션을 KV에 저장하고 만료시간을 두는 흔한 패턴은 이렇다.

// src/routes/api/session/+server.ts
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';

export const POST: RequestHandler = async ({ request, platform, cookies }) => {
	const kv = platform?.env.MY_KV;
	if (!kv) {
		return json({ error: 'KV binding not found' }, { status: 500 });
	}

	const { userId } = await request.json();
	const sessionId = crypto.randomUUID();

	// 만료시간을 초 단위로 지정 (여기서는 7일)
	await kv.put(`session:${sessionId}`, JSON.stringify({ userId }), {
		expirationTtl: 60 * 60 * 24 * 7,
	});

	cookies.set('session_id', sessionId, {
		path: '/',
		httpOnly: true,
		secure: true,
		maxAge: 60 * 60 * 24 * 7,
	});

	return json({ ok: true });
};

hooks.server.ts에서 요청마다 세션을 검증하는 코드를 붙이면 인증 미들웨어가 완성된다.

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
	const sessionId = event.cookies.get('session_id');
	const kv = event.platform?.env.MY_KV;

	if (sessionId && kv) {
		const raw = await kv.get(`session:${sessionId}`);
		if (raw) {
			event.locals.user = JSON.parse(raw);
		}
	}

	return resolve(event);
};

event.locals에 타입을 붙이고 싶다면 src/app.d.tsApp.Locals 인터페이스에도 필드를 추가해두면 된다. KV는 강한 일관성이 아니라 최종적 일관성(eventual consistency) 모델이라, 쓰기 직후 다른 리전에서 즉시 같은 값을 읽지 못할 수 있다는 점은 세션처럼 지연에 민감한 데이터를 다룰 때 염두에 둬야 한다. 엄격한 일관성이 필요하면 KV 대신 D1이나 Durable Objects를 쓰는 게 맞다.

이미지와 정적 자산 다루기

Workers의 assets 바인딩으로 서빙되는 정적 파일은 static/ 디렉토리에 두면 빌드 시 자동으로 포함된다. 다만 이미지 최적화(리사이징, WebP 변환)는 SvelteKit 자체 기능이 아니라 Cloudflare Images 같은 별도 서비스를 붙이거나, <img> 태그에 Cloudflare의 이미지 리사이징 URL 파라미터를 직접 붙이는 방식으로 처리해야 한다. Vercel의 next/image처럼 프레임워크에 내장된 자동 최적화를 기대하고 왔다면 이 부분은 별도로 설정해야 한다는 점을 미리 알아두는 게 좋다.

CI/CD 파이프라인 구성하기

매번 로컬에서 wrangler deploy를 손으로 치는 대신 GitHub Actions로 자동화해두면 팀 협업이 훨씬 편해진다. 기본적인 워크플로는 이렇다.

# .github/workflows/deploy.yml
name: Deploy to Cloudflare Workers

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run build
      - name: Deploy
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

CLOUDFLARE_API_TOKEN은 Cloudflare 대시보드에서 "Edit Cloudflare Workers" 권한을 가진 토큰으로 발급받아 GitHub 저장소 시크릿에 등록한다. 계정 전체 권한을 주는 Global API Key 대신, 필요한 권한만 부여한 스코프 토큰을 쓰는 게 안전하다. 브랜치별로 스테이징/프로덕션 환경을 나누고 싶다면 wrangler.jsoncenv 블록을 추가해서 환경별 바인딩 값을 분리할 수도 있다.

{
	"name": "my-app",
	// ...공통 설정
	"env": {
		"staging": {
			"vars": { "ENVIRONMENT": "staging" },
			"kv_namespaces": [{ "binding": "MY_KV", "id": "staging-kv-id" }]
		},
		"production": {
			"vars": { "ENVIRONMENT": "production" },
			"kv_namespaces": [{ "binding": "MY_KV", "id": "prod-kv-id" }]
		}
	}
}

배포 시 wrangler deploy --env staging처럼 환경을 지정하면 해당 블록의 설정이 기본 설정 위에 병합돼 적용된다.

정리

SvelteKit과 Cloudflare 조합에서 가장 자주 막히는 지점은 결국 세 가지로 좁혀진다. nodejs_als 플래그 누락, platform.env 타입 미설정으로 인한 타입 에러, 그리고 빌드 산출물 경로 불일치다. 이 세 가지만 정확히 짚고 넘어가면 나머지는 일반적인 SvelteKit 개발과 크게 다르지 않다. 여기에 KV/D1 바인딩과 CI/CD 자동 배포까지 갖추면, 별도 서버 없이 엣지에서 SSR까지 처리하는 완전한 프로덕션 파이프라인이 완성된다.

koenjaesfr