Void로 프로덕션 앱 이전하기: 하루 만의 컷오버 실전 기록
지난번 Void는 Vite 네이티브 배포 플랫폼이라는 글에서 “이게 자바스크립트의 Rails가 될 수 있을까”를 다뤘는데요. 그때는 직접 써보지 않고 개념만 파헤친 소개글이었습니다. 이번엔 다릅니다. TanStack Start + Cloudflare Workers + D1으로 운영 중이던 실서비스를 하루 만에 Void로 컷오버하면서 직접 부딪힌 기록이에요.
결론부터 말씀드리면 코드 변경은 거의 없었고, 빌드는 3배 빨라졌고, 그리고 베타 제품답게 문서에 없는 지뢰를 다섯 개나 밟았습니다. 💥 이 글은 그 지뢰들을 어떻게 역공학으로 풀었는지에 대한 실전 기록입니다.
기준 버전은 Void 0.9.3(Private Beta), Vite 8.0.16, 2026년 6월입니다. 베타 단계라 세부 동작은 빠르게 바뀔 수 있으니 감안하고 읽어주세요.
Void가 어떤 구조인지부터
직접 써보며 파악한 Void의 구조는 세 겹이었습니다.
[Void CLI/플랫폼] ← 배포, 프로비저닝, 도메인, 시크릿, 엣지 라우팅(ISR/redirects)
[voidPlugin (vite)] ← @cloudflare/vite-plugin을 내부에 감싼 상위 레이어
[기존 앱 코드] ← cloudflare:workers env 접근, D1 바인딩 등 그대로 동작
핵심 설계가 꽤 영리한데요. voidPlugin()은 @cloudflare/vite-plugin을 버리는 게 아니라 내부 의존성으로 감쌉니다. 그래서 기존 Workers 앱의 cloudflare:workers env 접근, D1 바인딩 이름, workerd 로컬 에뮬레이션이 전부 그대로 동작해요. 마이그레이션이 “재작성”이 아니라 “레이어 교체”가 되는 거죠. 이 한 가지 설계 덕분에 이전 비용이 확 내려갑니다.
또 하나 특이한 점은 웹 대시보드가 아예 없다는 겁니다. 홈페이지 슬로건이 “No config files. No dashboard clicks”일 정도로 의도적인 CLI 전용 설계예요. 배포 목록, 로그, 롤백, 도메인, 시크릿, DB까지 전부 void CLI로 다룹니다. 예외적으로 DB만은 void db studio로 Drizzle Studio 웹 UI가 뜨고요.
Void에는 두 가지 모드가 있는데요. 하나는 Pages 모드로, Void 자체가 파일 기반 라우팅과 loader/action을 제공하는 메타 프레임워크가 됩니다. 다른 하나는 Framework 모드로, 기존 Vite 메타 프레임워크(TanStack Start, React Router, Astro, SvelteKit, Nuxt 등)를 그대로 얹는 방식이에요. 이미 돌아가는 서비스를 옮기는 거라 이 글은 Framework 모드 기준입니다.
코드 변경은 사실상 세 곳
가장 놀란 부분이 여기였습니다. 실제로 손댄 코드가 세 군데뿐이었어요.
먼저 vite.config.ts입니다. 플러그인 하나만 갈아끼우면 됩니다.
// 변경 전
import { cloudflare } from "@cloudflare/vite-plugin";
plugins: [
cloudflare({ viteEnvironment: { name: "ssr" } }),
tanstackStart(),
react(),
];
// 변경 후
import { voidPlugin } from "void";
plugins: [voidPlugin(), tanstackStart(), react()]; // voidPlugin이 프레임워크 플러그인보다 먼저
다음은 void.json인데요. 처음엔 wrangler.jsonc의 설정을 여기에도 복사했다가 호되게 당했습니다(뒤에 나오는 지뢰 1번). Void는 wrangler.jsonc를 읽어서 병합하기 때문에, 중복으로 선언하면 안 됩니다. 최소한으로는 이거면 충분해요.
{ "name": "daleschool" }
마지막은 wrangler.jsonc입니다. 삭제하는 게 아니라 역할이 바뀝니다. 전에는 “배포 설정의 주인”이었다면, 이제는 “Void가 읽어가는 리소스 선언부 + 로컬 개발 설정”이 돼요. compatibility_date, vars, D1 바인딩 이름은 Void가 배포할 때 병합하고, bun run preview(wrangler dev) 기반 로컬 E2E도 그대로 동작합니다. 단, nodejs_compat 플래그만은 제거해야 하는데 이유는 잠시 후에 설명할게요.
여기서 빼놓을 수 없는 전제 조건이 하나 있습니다. 바로 Vite 8이에요. Void는 Vite 8 전용 API(parseSync)를 쓰기 때문에 Vite 7에서는 설정 로드부터 깨집니다. 그런데 Vite 8로 올리면 번들러가 Rolldown(Rust 기반)으로 바뀌는데, MDX 227개를 빌드 타임에 컴파일하는 콘텐츠 헤비 사이트인 우리 프로젝트에서 프로덕션 빌드가 6.1초에서 1.9초로 줄었습니다. 마이그레이션의 부수 효과치고는 꽤 짭짤하죠.
배포는 정말 한 번에 됩니다 (거의)
이제 배포입니다.
bunx void auth login # GitHub/Google OAuth
bunx void deploy --project myapp # 빌드 → 자산 업로드 → D1 프로비저닝 → 배포
# → https://myapp.void.app
배포 로그를 보면 자산 해싱(변경분만 업로드), R2 업로드, D1 자동 프로비저닝, 워커 업로드가 차례로 일어납니다. 이후 배포는 void project link로 디렉토리에 프로젝트를 연결해두면 --project 없이도 되고요.
GitHub Actions 연동도 명령 두 개로 끝납니다. bunx void init --github로 deploy.yml을 만들고, bunx void auth token으로 배포 토큰을 받아 VOID_TOKEN 시크릿으로 등록하면 main 푸시 시 자동 배포가 완성돼요.
여기까지만 보면 “한 줄 배포”라는 마케팅이 거짓말은 아닙니다. 문제는 그다음이었어요.
베타에서 직접 밟은 지뢰 다섯 개
이 글의 진짜 본론입니다. 문서에 없어서 역공학으로 풀어야 했던 것들이에요. 베타 제품을 프로덕션에 올린다는 게 어떤 의미인지 가장 잘 보여주는 부분이기도 합니다.
지뢰 1: nodejs_compat 중복으로 workerd가 안 뜬다
배포하자마자 이런 에러가 났습니다.
✘ [ERROR] service core:user:myapp: Compatibility flag specified multiple times: nodejs_compat
원인은 이렇습니다. voidPlugin이 nodejs_compat을 자동으로 주입하는데, wrangler.jsonc에도 같은 플래그가 선언돼 있으면 병합된 설정에 플래그가 두 번 들어가서 workerd가 기동 자체를 거부해요. 해법은 간단합니다. wrangler.jsonc의 compatibility_flags에서 nodejs_compat을 빼면 됩니다.
이런 병합 문제를 디버깅할 때 유용한 파일이 하나 있는데요. 빌드 산출물인 dist/server/wrangler.json을 열어보면 Void가 만들어낸 최종 병합 결과를 직접 확인할 수 있습니다. “내가 쓴 설정이 실제로 어떻게 합쳐졌는지”가 궁금할 때 제일 먼저 열어볼 파일이에요.
지뢰 2: 배포하면 CSS가 안 나온다
이게 이번 마이그레이션의 하이라이트였습니다. 배포한 사이트가 스타일 하나 없는 맨 HTML로 떴거든요. 😅 살펴보니 /assets/*.css 요청이 전부 307 리다이렉트(.css → .css/)로 응답하고 있었습니다.
원인은 세 가지 사실이 겹친 합작이었어요.
우선 Void는 모든 요청을 워커로 보냅니다. SSR 앱에는 run_worker_first: ["/**"]가 설정되는데, 기존 Workers에서는 정적 자산이 워커 앞단에서 서빙됐다면 Void 플랫폼에서는 자산 요청까지 워커가 받아요. 그리고 Void가 빌드에 끼워 넣는 래퍼에 자산 폴백이 있긴 한데, 이게 프레임워크가 404를 반환할 때만 동작합니다. 빌드 시 생성되는 .void/tanstack-start-trigger-wrapper.ts를 열어보면 original.fetch() 결과가 404일 때만 env.ASSETS.fetch()를 시도하더라고요. 마지막으로 TanStack Router의 trailingSlash: "always" 설정이 미지의 경로에 404 대신 307을 반환합니다. /assets/x.css를 /assets/x.css/로 리다이렉트해버리니, 폴백 조건인 404가 영영 충족되지 않는 거죠.
더 고약한 건 로컬에서는 재현이 안 된다는 점이었습니다. 로컬 wrangler dev는 자산을 워커보다 먼저 서빙해서(dev에서는 run_worker_first가 제거됩니다) 멀쩡해 보여요. “로컬은 되는데 프로덕션만 깨지는” 전형적인 비대칭이라 더 헤맸습니다.
해법은 TanStack Start의 요청 미들웨어로, 파일 확장자가 있는 경로를 라우터 정규화 전에 404로 끊어주는 것이었어요.
import { createMiddleware, createStart } from "@tanstack/react-start";
const staticAssetGuard = createMiddleware({ type: "request" }).server(
({ request, next }) => {
const { pathname } = new URL(request.url);
const isFileLike = /\.[a-zA-Z0-9]+$/.test(pathname); // 확장자가 있으면 자산으로 간주
const isReadOnly = request.method === "GET" || request.method === "HEAD";
if (isFileLike && isReadOnly && !pathname.startsWith("/api/")) {
return new Response(null, { status: 404 }); // → Void 래퍼가 ASSETS에서 서빙
}
return next();
},
);
export const startInstance = createStart(() => ({
requestMiddleware: [staticAssetGuard],
}));
이렇게 하면 Void 래퍼의 자산 폴백이 동작하고, 페이지용 trailing slash 리다이렉트(/courses → /courses/)는 그대로 유지됩니다.
여담이 하나 있는데요. 처음엔 wrangler.jsonc의 main을 커스텀 엔트리로 바꾸고, 다음엔 src/server.ts 커스텀 서버 엔트리를 만들었는데 둘 다 조용히 무시됐습니다. Void가 TanStack Start의 서버 엔트리를 하드코딩하고 자기 래퍼로 감싸기 때문이에요. 번들에 내 코드가 실제로 들어갔는지 grep으로 확인하지 않았다면 한참 더 헤맸을 겁니다. 베타 도구를 다룰 때는 수정 후에 항상 배포 산출물을 직접 검증하는 습관이 정말 중요하더라고요.
지뢰 3: 시크릿 동기화의 인터랙티브 프롬프트
void secret sync .env는 “Overwrite 8 secrets?” 확인 프롬프트를 띄우는데, 비대화형 플래그가 없어서 자동화가 안 됩니다. 다행히 secret put은 stdin을 받기 때문에 이렇게 우회할 수 있어요.
printf '%s' "$VALUE" | bunx void secret put KEY
지뢰 4: 원격 D1 조작의 제약
원격 D1을 다룰 때 제약이 꽤 많았습니다.
void db execute --remote --file schema.sql # ❌ --file은 --remote와 함께 못 씀
void db execute "여러;문장;" --remote # ❌ 한 번에 한 문장만
void db execute "-- 주석으로 시작하는 SQL" # ❌ --를 CLI 옵션으로 오해
그래서 마이그레이션 SQL을 적용하려면 주석을 제거하고 문장 단위로 쪼개서 하나씩 실행해야 했어요. 또 Void는 마이그레이션 파일을 wrangler 관례인 migrations/가 아니라 db/migrations/에서 찾으니 이것도 헷갈리지 않게 주의해야 합니다.
지뢰 5: 에이전트 설정의 MCP 파일 위치
void init --agents는 Claude Code용 스킬 심볼릭 링크와 MCP 설정, CLAUDE.md 주입까지 해주는 편리한 명령인데요. 문제는 MCP 설정을 .claude/settings.json의 mcpServers 키에 써넣는다는 점입니다. Claude Code는 프로젝트 MCP 서버를 그 파일에서 읽지 않아요. 올바른 위치는 프로젝트 루트의 .mcp.json이라서, 수동으로 옮겨주면 해결됩니다.
데이터 마이그레이션: 기존 D1에서 Void D1로
Void는 자기가 새로 만든 D1을 바인딩합니다. 기존 DB의 database_id를 그대로 이어받는 공식 경로는 아직 없어요. 그래서 데이터 이전은 export 후 문장 단위 import로 진행해야 합니다.
# 1) 기존 D1에서 테이블별 export
wrangler d1 export mydb --remote --no-schema --table users --output users.sql
# 2) Void D1이 비어 있는지 확인 후, FK 순서대로 INSERT 실행
# (users → account → progress 처럼 참조되는 테이블 먼저)
grep "^INSERT" users.sql | while IFS= read -r stmt; do
bunx void db execute "$stmt" --remote
done
Better Auth를 쓰신다면 한 가지가 정말 중요합니다. users와 account 테이블은 반드시 한 쌍으로 옮겨야 해요. account에 OAuth providerAccountId가 들어 있어서, 이게 없으면 사용자가 재로그인할 때 중복 계정이 생깁니다. 반대로 세션 테이블은 옮길 필요가 없어요. 새 AUTH_SECRET으로 기존 세션이 무효화되는 게 정상이고, 보안상으로도 그게 맞습니다.
커스텀 도메인 전환의 함정
도메인 연결 자체는 명령 한 줄입니다.
bunx void domain add example.com
# → 안내: CNAME example.com → cname.void.app + TXT _cf-custom-hostname → <검증 ID>
그런데 DNS 존이 Cloudflare에 있다면 주의할 점이 있어요. Cloudflare는 apex CNAME을 자동으로 평탄화(flattening)해서 외부에서는 A 레코드로 보이게 만듭니다. 그래서 void domain status가 “custom hostname does not CNAME to this zone”이라고 계속 표시되더라도, 실제 검증은 통과할 수 있어요. 상태 메시지만 믿지 말고 dig로 실제 해석 결과를 확인하는 게 안전합니다.
실측한 전환 타임라인은 이랬습니다. TXT 레코드를 추가하는 건 무중단이고요. 구 워커의 Custom Domain을 해제하면 이때 Cloudflare가 관리하던 더미 AAAA(100::) 레코드도 같이 자동 삭제됩니다(직접 지우려 하면 “Record does not exist”가 떠요). 그다음 CNAME을 DNS only로 생성하고 나면, 약 2분 뒤 인증서 발급이 끝나면서 상태가 active — Live로 바뀝니다. 이 과정 중간에 보이는 403이나 404는 고장이 아니에요. DNS는 이미 Void 엣지로 향하는데 호스트네임 검증과 인증서 발급이 끝나기 전이라 엣지가 잠깐 거절하는 정상 단계입니다.
운영은 CLI 한 벌로 수렴합니다
전에는 wrangler CLI와 Cloudflare 대시보드를 오가며 하던 작업들이 전부 한곳으로 모입니다. 같은 형태의 명령이 여러 갈래로 반복되니, 자주 쓰는 것만 표로 정리해둘게요.
| 작업 | 명령 |
|---|---|
| 배포 / 롤백 / 이력 | void deploy / void project rollback / void project status |
| 런타임 로그 | void project logs --level error |
| 시크릿 | void secret put/list/delete/sync |
| DB | void db execute --remote, void db studio, migrate/seed |
| 커스텀 도메인 | void domain add/status/list |
| 엣지 캐시 | void project purge-cache |
여기서 알고 시작해야 할 점이 하나 있는데요. Void가 프로비저닝한 D1과 R2는 내 Cloudflare 대시보드에 보이지 않습니다. Void 플랫폼 인프라에 프로젝트 단위로 격리되어 있어서 관리 창구가 CLI뿐이에요. “내 계정 대시보드 = 내 인프라 전체”라는 익숙한 가정이 깨진다는 걸 미리 인지하고 들어가는 게 좋습니다.
솔직한 평가
하루를 꼬박 쓴 마이그레이션이었는데, 얻은 것과 감수한 것을 정직하게 정리해볼게요.
당장 손에 쥔 것부터 보면, 프로덕션 빌드가 3배 빨라졌고(Rolldown 덕분이죠), 배포, 시크릿, DB, 도메인, 로그가 CLI 한 벌로 모이면서 1인 운영에서 체감하는 단순화가 컸습니다. 코드 변경도 실질적으로는 vite.config 한 줄, void.json 세 줄, start.ts 미들웨어 하나가 전부였고요. 무엇보다 void project rollback에 DNS만 되돌리면 되는 구조라 롤백 안전망이 든든했습니다.
반대로 감수한 것도 분명합니다. 위에서 본 지뢰 다섯 개가 전부 단 한 번의 마이그레이션에서 나왔다는 게 Private Beta의 현실이에요. 리소스 가시성이 CLI로 일원화되면서 대시보드 습관과 단절해야 했고, 문제가 생기면 “wrangler 문제인가 Void 문제인가”를 한 겹 더 파야 하는 디버깅 비용도 생겼습니다. 기존 DB를 직접 바인딩하는 경로가 없어서 데이터를 통째로 이전해야 했던 것도 부담이었고요.
그래서 제가 권하는 전략은 코드 마이그레이션과 트래픽 전환을 분리하는 겁니다. 브랜치에서 전환한 뒤 *.void.app으로 스테이징 검증을 충분히 하고, 확신이 선 후에 DNS를 전환하세요. 이러면 실패해도 CNAME 하나 되돌리는 것으로 끝납니다. 그리고 사용자가 적은 시간대가 마이그레이션의 황금 창입니다. 데이터 이전, 세션 보존, 웹훅 무중단 같은 가장 비싼 문제들이 그 시간엔 “해당 없음”이 되거든요.
마치며
Void가 자바스크립트의 Rails가 될지는 개념을 다룬 글에서 던졌던 질문 그대로 아직 열려 있습니다. 다만 직접 프로덕션을 옮겨보니, 적어도 “Vite 앱을 Cloudflare 위에 올리는 경험”만큼은 베타인데도 놀랄 만큼 매끄러웠어요. 거친 모서리들은 분명 있지만, 전부 역공학으로 풀 수 있는 수준이었고 그 과정에서 빌드 산출물을 열어보는 습관 하나만 익히면 대부분 잡혔습니다.
혹시 비슷한 스택을 옮길 계획이라면, 제가 실제로 따라간 컷오버 체크리스트를 남겨둘게요.
- Vite 8 + voidPlugin 전환, 로컬 빌드/테스트 통과
- wrangler.jsonc에서
nodejs_compat제거 - 정적 자산 서빙 확인 (
curl -I /assets/*.css가 200인지, 307이면 위 미들웨어 적용) -
void deploy후*.void.app스테이징 스모크 테스트 (CSS 포함 확인) - 시크릿 이전 (
secret putstdin 패턴) - D1 스키마 적용 + 데이터 이전 (users + account 한 쌍, FK 순서대로)
- CI 전환 (
void init --github+ VOID_TOKEN) - DNS 전환 (TXT → 구 도메인 연결 해제 → CNAME →
domain statusactive 확인) - 구 리소스 정리 전 백업 (
wrangler d1 export) 후 워커/D1 삭제
Void의 기반이 되는 로컬 개발 툴체인이 궁금하다면 Vite+로 웹 개발 도구 통합하기를, 더 깊은 설정은 Void 공식 문서를 참고하세요.
This work is licensed under
CC BY 4.0