Vite 6 Environment API로 브라우저·워커·에지의 모듈 그래프를 분리하고 커스텀 환경 플러그인 만들기
Vite 6.x 기준, 2026년 9월 시점에 공식 문서를 확인해 작성한 글입니다.
Vite 5까지 플러그인을 작성하다 보면 어느 순간 ssr: boolean 파라미터 하나로 두 세계를 구분하는 게 불편하다는 걸 느낍니다. 저도 처음엔 "뭐, client랑 ssr 두 가지면 충분하지 않나?" 싶었는데, Cloudflare Workers에 배포하는 풀스택 앱을 만들면서 생각이 달라졌습니다. 개발 서버에서는 Node.js로 SSR 코드를 돌리고, 프로덕션에서는 workerd(Cloudflare의 Workers 런타임)에서 돌리니까 "개발에서는 되는데 배포하면 안 된다"는 상황이 반복됐거든요.
Vite 6의 Environment API는 바로 이 문제를 구조적으로 해결하기 위해 등장했습니다. 단일 Vite 개발 서버 안에서 브라우저, 서비스 워커, SSR 서버, 에지 함수 같은 여러 런타임 환경을 동시에 구동할 수 있고, 각 환경이 독립된 모듈 그래프를 갖습니다. 그리고 플러그인은 this.environment로 현재 어느 환경에서 실행 중인지 알 수 있어서 ssr 불리언 분기보다 훨씬 명확한 코드를 쓸 수 있습니다.
이 글에서는 Environment API가 왜 지금 이 구조로 설계됐는지, 커스텀 환경을 어떻게 정의하고 플러그인에서 어떻게 활용하는지, 실무에서 조심해야 할 지점은 무엇인지를 다룹니다. Vite 공식 문서는 이 API를 여전히 실험적(Experimental)으로 표기하고, Move to Per-environment APIs 문서에서 "추후 릴리스에서 API가 변경될 수 있으니 프로덕션 사용 전 안정화를 기다리도록" 안내하고 있다는 점을 미리 밝혀두겠습니다.
Vite 5에서 모듈 그래프가 어떻게 섞였는지
단일 ModuleGraph의 한계
Vite 5까지는 server.moduleGraph 하나가 모든 걸 담당했습니다. 내부적으로 각 ModuleNode에 clientImportedModules와 ssrImportedModules 두 목록을 같이 유지했는데, 이 구조가 생각보다 많은 문제를 만들었습니다.
예를 들어 utils.ts가 client에서도, SSR에서도 임포트된다면 하나의 노드 안에 양쪽 상태가 공존합니다. HMR 업데이트가 발생했을 때 무효화 범위를 계산하거나, 캐시를 관리하거나, 변환된 소스를 저장할 때 두 환경의 경계가 모호해집니다. 실제로 SSR용으로만 변환된 코드가 client 모듈 그래프에 섞여서 디버깅이 한참 걸렸던 경험이 있습니다.
Vite 6에서는 이 구조를 완전히 뒤집었습니다. 각 환경이 자체 ModuleGraph 인스턴스를 소유하며, 변환된 소스 코드와 의존 관계는 해당 환경의 그래프에만 기록됩니다. 환경 간 모듈 상태가 격리되니까 "A 환경에서 변환된 코드가 B 환경 캐시에 영향을 주는" 류의 버그가 원천 차단됩니다.
DevEnvironment와 BuildEnvironment
개발 시에는 각 환경이 DevEnvironment 클래스의 인스턴스로 표현됩니다. 빌드 시에는 BuildEnvironment가 사용되고, 하나의 프로세스 안에서 각 환경의 빌드가 순차적으로 진행됩니다. 기본으로 client와 ssr 두 환경이 제공되며, environments 설정으로 추가 환경을 등록하는 방식입니다.
커스텀 환경 정의하기
vite.config.ts에서 환경 선언
// vite.config.ts
import { defineConfig } from 'vite'
import { cloudflare } from '@cloudflare/vite-plugin'
export default defineConfig({
plugins: [cloudflare()],
environments: {
client: {
// 브라우저 기본값 — 별도 설정 없어도 됨
},
serviceWorker: {
consumer: 'client',
build: {
outDir: 'dist/sw',
},
},
edge: {
resolve: {
conditions: ['workerd', 'worker'],
},
},
},
})serviceWorker 환경은 consumer: 'client'로 설정했습니다. 이 값은 해당 환경의 산출물이 브라우저에서 로드된다는 뜻으로, Vite가 기본 resolve 조건에 browser export condition을 활성화하고, 산출물을 optimizeDeps의 client 파이프라인과 유사한 방식으로 처리합니다. 반대로 consumer: 'server'(기본값)인 환경은 Node/워커 계열 조건이 우선 적용됩니다. edge 환경은 여기에 workerd와 worker resolve condition을 명시적으로 추가해 Cloudflare Workers에 특화된 모듈 해석이 이뤄지도록 합니다.
환경 간 관계 이해하기
각 환경은 독립적인 모듈 그래프와 변환 파이프라인을 가지지만, 하나의 Vite 개발 서버 프로세스 안에서 공존합니다. consumer 값은 환경 정의에 부여되는 속성이지 런타임 간 데이터 흐름이 아니므로, 위 다이어그램에서는 노드 라벨로만 표기했습니다. Cloudflare 플러그인처럼 실제 Workers 런타임(workerd)을 개발 서버에서 직접 구동하는 방식이 가능한 이유가 바로 이 구조입니다.
환경을 인식하는 플러그인 작성하기
this.environment로 분기하기
기존 ssr: boolean 패턴과 새 패턴을 비교해보면 차이가 명확합니다.
// Vite 5 방식 — ssr 불리언으로만 분기
const oldPlugin = () => ({
name: 'old-style-plugin',
transform(code, id, options) {
if (options?.ssr) {
// SSR 처리
} else {
// 브라우저 처리
}
},
})
// Vite 6 방식 — this.environment로 분기
const newPlugin = () => ({
name: 'env-aware-plugin',
transform(code, id) {
const envName = this.environment.name
if (envName === 'client') {
// 브라우저 전용 처리
} else if (envName === 'ssr') {
// Node.js SSR 전용 처리
} else if (envName === 'edge') {
// 에지 런타임 전용 처리
}
},
})ssr: boolean으로는 두 가지만 구분할 수 있지만, this.environment.name은 등록된 환경 수만큼 구분할 수 있습니다. 세 번째 환경, 네 번째 환경이 생겨도 플러그인 시그니처를 바꿀 필요가 없습니다.
configEnvironment 훅으로 환경별 설정 제어
const edgePlugin = () => ({
name: 'edge-config-plugin',
configEnvironment(name, _options) {
if (name === 'edge') {
return {
resolve: {
conditions: ['workerd', 'worker', 'browser'],
},
build: {
target: 'es2022',
},
}
}
},
transform(code, _id) {
if (this.environment.name === 'edge') {
// process.env 참조를 상수로 교체하는 등 edge 런타임에 맞는 변환
return code.replace(/process\.env\.NODE_ENV/g, '"production"')
}
},
})configEnvironment(name, options) 훅은 환경 이름과 현재까지 병합된 환경 옵션을 받아서 추가 설정을 반환합니다. 이 글의 다른 예시들에서도 두 번째 파라미터를 통일해 _options로 표기했습니다.
hotUpdate 훅으로 HMR 커스터마이징
hotUpdate 훅의 옵션 객체에는 file, timestamp, modules, read, server, type이 포함되며, 현재 환경은 옵션이 아닌 훅 컨텍스트의 this.environment로 접근합니다. 아래 예시는 이 규약을 따릅니다.
const customHmrPlugin = () => ({
name: 'custom-hmr-plugin',
hotUpdate({ modules, timestamp }) {
const environment = this.environment
if (environment.name !== 'client') return
const affectedModules = modules.filter(
(m) => m.id?.includes('/components/')
)
if (affectedModules.length > 0) {
environment.hot.send({
type: 'custom',
event: 'component-updated',
data: {
timestamp,
modules: affectedModules.map((m) => m.id),
},
})
}
},
})this.environment.name으로 어느 환경의 HMR인지 구분하면, 특정 환경에서만 동작하는 HMR 로직을 깔끔하게 분리할 수 있습니다.
플러그인 상태 관리 패턴 변경
이 부분이 마이그레이션할 때 자주 놓치는 지점입니다. 기존 플러그인이 모듈 레벨 변수로 상태를 관리했다면, 환경별로 독립된 상태가 필요한 경우 WeakMap<Environment, State> 패턴으로 바꿔야 합니다.
import { createHash } from 'node:crypto'
// 기존 — 모든 환경이 상태를 공유해버림
const cache = new Map<string, string>()
const oldCachePlugin = () => ({
name: 'old-cache-plugin',
transform(code, id) {
if (cache.has(id)) return cache.get(id)
const result = heavyTransform(code)
cache.set(id, result)
return result
},
})
// Vite 6 — 환경별로 캐시를 분리
const envCachePlugin = () => {
const cacheByEnv = new WeakMap<object, Map<string, string>>()
return {
name: 'env-cache-plugin',
transform(code, id) {
const env = this.environment
if (!cacheByEnv.has(env)) {
cacheByEnv.set(env, new Map())
}
const cache = cacheByEnv.get(env)!
if (cache.has(id)) return cache.get(id)
const result = heavyTransform(code)
cache.set(id, result)
return result
},
}
}
function heavyTransform(code: string): string {
// 개념적 예시: 파일 상단에 빌드 해시 배너를 삽입
const hash = createHash('sha256').update(code).digest('hex').slice(0, 8)
return `/* build:${hash} */\n${code.replace(/__DEV__/g, 'false')}`
}환경 인스턴스를 WeakMap 키로 쓰면 환경이 사라질 때 자동으로 GC되어 메모리 누수도 방지됩니다.
Cloudflare Workers 환경을 실제로 연결하기
@cloudflare/vite-plugin은 Environment API를 활용한 대표적 실사용 예입니다. 공식 Changelog 기준 2025년 4월 GA로 전환됐고, 개발 중 코드를 실제 workerd 런타임 위에서 실행합니다.
// vite.config.ts
import { defineConfig } from 'vite'
import { cloudflare } from '@cloudflare/vite-plugin'
export default defineConfig({
plugins: [cloudflare()],
})이 한 줄로 Workers 환경이 Vite 개발 서버에 통합되고, HMR도 지원됩니다. 개발 중 Node.js로 돌리다가 배포 시 workerd에서 동작 차이가 나는 문제를 구조적으로 완화하는 방식입니다.
플러그인 내부에서 Workers 환경을 감지하고 싶다면, 자신이 등록·조회 대상으로 삼는 환경 키를 config와 정확히 일치시켜야 합니다. 앞선 예시에서 environment 이름을 edge로 정의했다면 플러그인 쪽도 edge로 맞춰야 합니다.
const edgeAwarePlugin = () => ({
name: 'edge-aware-plugin',
configEnvironment(name, _options) {
if (name === 'edge') {
return {
resolve: {
conditions: ['workerd', 'worker'],
},
}
}
},
transform(_code, _id) {
if (this.environment.name === 'edge') {
// Workers 런타임에 맞는 코드 변환
}
},
})만약 별도 플러그인(예: @cloudflare/vite-plugin)이 등록하는 환경 이름을 쓰려면 해당 플러그인이 등록하는 실제 키(예: worker)를 확인해 사용해야 합니다. 이름을 임의로 가정하고 코드를 짜면 조건문이 영영 참이 되지 않는 버그를 만들기 쉽습니다.
플러그인 마이그레이션 판단 기준
Vite 팀은 마이그레이션 문서에서 공개 배포되는 플러그인 라이브러리라면 당장 전환하지 말고 API가 안정되길 기다리도록 권고합니다. 내부 프로젝트용 플러그인이라면 점진적으로 시작해볼 수 있습니다.
트레이드오프 정리
| 항목 | 내용 |
|---|---|
| 프로덕션 패리티 | 개발 서버에서 실제 런타임(workerd, Deno, Bun 등)을 구동해 dev/prod 동작 차이를 줄일 수 있음 |
| 모듈 그래프 격리 | 환경 간 모듈 상태가 섞이지 않아 디버깅이 쉬워지고 의존성 누출 방지 |
| 단일 프로세스 빌드 | 플러그인 재사용성과 설정 일원화가 쉬워지는 대신, 환경 빌드가 순차 진행되어 병렬 빌드는 별도 오케스트레이션 없이는 어려움 |
| 하위 호환성 | SPA/MPA를 운영 중이라면 큰 코드 변경 없이 Vite 6로 마이그레이션 가능. ssr 불리언 레거시 API도 당분간 유지 |
| API 안정성 | 공식 문서 기준 실험적 단계. 안정화 전까지 시그니처 변경 가능성 있음(참고: Move to Per-environment APIs) |
| 마이그레이션 복잡도 | server.moduleGraph, ssr 불리언, server.transformRequest 등 기존 API 사용 플러그인은 수정 필요 |
| 개념적 복잡성 | 환경이 늘어날수록 설정 관리, 환경 간 의존 관계, 빌드 순서 고려 요소가 증가 |
| 플러그인 상태 관리 | 모듈 레벨 공유 상태 → WeakMap<Environment, State> 패턴으로 전환 필요 |
솔직히 말하면, 공개 NPM 패키지로 배포하는 플러그인이라면 지금 당장 Environment API로 전환하는 건 부담이 있습니다. 다운스트림 프로젝트들의 실험 기간을 거쳐 API가 안정화된 뒤 결정하는 편이 낫습니다. 반면 내부 툴이나 프레임워크를 직접 만들고 있다면, 지금이 API에 익숙해지기 좋은 시점입니다.
흔히 빠지는 실수들
ssr 불리언을 this.environment.name === 'ssr'로 단순 치환하는 경우: 새로운 환경(edge, serviceWorker 등)이 추가될 때 플러그인이 그 환경에서 어떻게 동작해야 할지 명시적으로 고민해야 합니다. 기존 else 브랜치가 예상치 못한 환경을 처리하지 않도록 주의하세요.
플러그인 상태를 모듈 레벨에서 공유하는 경우: client와 edge가 같은 캐시를 공유하면 변환된 코드가 잘못된 환경으로 흘러들 수 있습니다. 환경별 상태 분리는 처음부터 설계에 넣는 게 훨씬 낫습니다.
환경 이름 표기가 config와 플러그인 사이에서 어긋나는 경우: edge로 정의한 환경을 플러그인에서 worker로 체크하면 조건이 결코 참이 되지 않습니다. 상수로 뽑아 재사용하거나, 최소한 config와 플러그인 코드를 나란히 두고 확인하는 습관이 필요합니다.
configEnvironment 반환값의 병합 방식을 임의로 가정하는 경우: 실제 병합 규칙은 Vite 내부 구현과 공식 문서를 확인하세요. 예상과 다르게 특정 옵션이 덮어써질 수 있습니다.
마무리하며 — 지금 실험해볼 수 있는 것들
Environment API는 Vite를 단순한 번들러가 아니라 여러 런타임을 조율하는 개발 플랫폼으로 확장하는 방향의 전환점입니다. 브라우저, 서비스 워커, SSR, 에지 함수를 단일 개발 서버에서 각자의 모듈 그래프로 분리해 운영하면, 개발 환경과 프로덕션의 간극이 줄어듭니다. Cloudflare 플러그인이 GA로 나온 것과, Vite 6 채택 사례가 프레임워크 층에서 늘어나는 흐름을 보면 방향성은 분명합니다. (Astro의 경우 Astro 5 릴리스 노트에서 Vite 6 채택을 명시했고, Astro 이후 버전의 Environment API 활용 범위는 각 릴리스 노트를 직접 확인하는 편이 정확합니다.)
당장 손을 대볼 수 있는 실험 아이디어를 세 가지 정도 정리해두면 감각 잡기에 좋습니다.
- 지금 유지 중인 내부 Vite 플러그인 하나를 골라
options.ssr대신this.environment.name을 로그로 찍어보고, 어떤 환경에서 언제 호출되는지 실측하기. environments.edge를 config에 추가하고resolve.conditions에workerd를 넣어봤을 때 기존 import 그래프가 어떻게 재해석되는지 확인하기.WeakMap<Environment, Map<string, string>>캐시 패턴을 적용한 뒤, 두 환경에서 같은 파일을 요청했을 때 캐시가 분리되는지 dev 서버 재시작 없이 검증하기.
이 세 가지만 돌려봐도 ssr 불리언 시절과의 차이가 손끝에 남습니다. 공개 라이브러리를 유지보수하는 분이라면 안정화까지 기다리되, 그 사이에 개념과 실제 훅 동작을 익혀두면 API가 굳어졌을 때 마이그레이션이 훨씬 수월합니다.
참고 자료
- Environment API | Vite 공식 문서
- Environment API for Plugins | Vite 공식 문서
- Environment API for Runtimes | Vite 공식 문서
- Using Environment Instances | Vite 공식 문서
- Move to Per-environment APIs | Vite 마이그레이션 가이드
- Vite 6.0 is out! | Vite 공식 블로그
- Migration from v5 | Vite
- Vite Environments · Cloudflare Workers 공식 문서
- Just use Vite… with the Workers runtime | Cloudflare Blog
- The Cloudflare Vite plugin is now Generally Available | Cloudflare Changelog
- Astro 5 릴리스 노트
- Why Vite 6 is a groundbreaking release | Vike 블로그
- Environment API · vitejs/vite GitHub Discussion #16358