Search

오픈소스 번들러 vite 코드를 읽으며 배운 것들

subtitle
Tags
front-end
Created
2025/05/01
2 more properties
가고싶던 회사에서 번들러에 대한 지식을 많이 요구하는 것 같아 mini bundler도 만들어보고 많이 사용되는 오픈소스 번들러인 vite를 분석해보았다. 어느 환경에서나 동작하기 위한 방법, 빠르게 모듈을 업데이트하기 위한 vite의 노하우들을 배울 수 있었고, 번들러 로직 외에도 많은 것들을 배울 수 있었다. 이래서 오픈소스 코드를 많이 보라고 하는 건가 보다. (하지만 가고싶던 회사는 채용마감 해버렸다. 흑.)

import type과 import를 구분해야 하는 이유

Vite의 서버 초기화 코드에서는 다음과 같이 import type과 일반 import가 명확하게 구분되어 있다.
import type { CommonServerOptions } from '../http' import { httpServerStart, resolveHttpServer, resolveHttpsConfig, setClientErrorHandler, } from '../http'
TypeScript
복사
처음 보면 단순한 스타일 차이처럼 느껴질 수 있지만, 이 작은 습관 하나가 실무에서 꽤 많은 문제를 예방한다. 타입 전용 import와 런타임 import를 구분하지 않으면 다음과 같은 문제가 발생할 수 있다.
빌드 결과물에 불필요한 코드가 포함된다
isolatedModules: true 설정 시 TypeScript가 에러를 낸다
Babel이나 esbuild 같은 트랜스파일러는 타입과 런타임을 구분하지 못한다
HMR 캐시 무효화가 자주 발생한다
Vite는 이런 문제를 미연에 방지하기 위해 내부 코드부터 import type을 철저히 구분하고 있다.

import type은 런타임에서 제거된다

import type은 타입 정보만 가져오고, 컴파일 결과물에서는 완전히 사라진다. 반면, 일반 import는 타입만 사용하는 경우에도 런타임 import 문으로 남아 있을 수 있다. 특히 esbuild나 Babel처럼 타입을 따로 제거해주지 않는 트랜스파일러를 사용하는 경우 문제가 된다.
즉, 단순히 타입만 사용한 것처럼 보여도 빌드 결과물에 실제 import 문이 들어가 불필요한 모듈 의존성을 남기게 된다.

isolatedModules: true일 때 문제

TypeScript에서 isolatedModules: true를 설정하면 각 파일을 독립적으로 처리하게 되는데, 이때 타입 전용 import를 일반 import로 작성하면 에러가 발생한다. 예를 들어, 다음 코드는 문제가 된다.
// 잘못된 예시 (런타임에는 쓰이지 않지만 import로 가져옴) import { SomeType } from './types' const x: SomeType = { ... }
TypeScript
복사
이런 경우 TypeScript는 이 import가 런타임에 필요하다고 판단해 에러를 낸다. 반면, 다음과 같이 작성하면 문제가 없다.
import type { SomeType } from './types' const x: SomeType = { ... }
TypeScript
복사

타입과 런타임을 명확히 나누는 습관

Vite는 퍼포먼스를 중시하는 번들러인 만큼, 타입 시스템과 런타임 코드를 철저히 분리한다. 내부 구현에서도 import type을 적극적으로 활용하고 있으며, 이는 단순한 스타일 가이드가 아닌 구조적 안정성과 빌드 효율을 위한 선택이다.
개인 프로젝트에서는 이런 구분을 생략해도 문제를 느끼지 못할 수 있지만, 규모가 커지거나 빌드 도구를 커스터마이징하기 시작하면 반드시 마주치게 되는 이슈다.
타입 전용 import는 성능 최적화, 빌드 안정성, 트랜스파일러 호환성 등 다양한 면에서 중요한 역할을 한다. Vite처럼 성능을 극단까지 끌어올리는 프로젝트는 작은 차이 하나도 허투루 넘기지 않는것 같다.

Promise.withResolvers()

아래는 Vite의 shared/utils에서 사용된 promiseWithResolvers 유틸 함수다. 이 패턴은 JavaScript 비동기 로직을 더 유연하게 다루기 위한 방식으로, 일반적으로 자주 사용되어 최신 Promise 스펙에 withResolvers로 추가되었다.
export interface PromiseWithResolvers<T> { promise: Promise<T> resolve: (value: T | PromiseLike<T>) => void reject: (reason?: any) => void } export function promiseWithResolvers<T>(): PromiseWithResolvers<T> { let resolve: any let reject: any const promise = new Promise<T>((_resolve, _reject) => { resolve = _resolve reject = _reject }) return { promise, resolve, reject } }
TypeScript
복사

어떤 용도로 쓰이는가?

이 패턴의 핵심 목적은 다음과 같다:
“나중에 resolve/reject를 호출하기 위해 외부에서 Promise 제어권을 보관해두기”
예를 들어, 어떤 조건이 충족되었을 때 수동으로 resolve하거나, 특정 이벤트 발생 시 비동기 처리를 완료해야 할 경우에 쓸 수 있다.

예시: 비동기 초기화

const ready = promiseWithResolvers<void>() // 어떤 이벤트 핸들러에서 초기화 완료 후 호출 window.addEventListener('load', () => { ready.resolve() }) // 이후 어딘가에서 기다림 await ready.promise console.log('초기화 완료')
TypeScript
복사
resolve, reject를 외부에서 직접 호출할 수 있기 때문에, 이벤트나 상태 변화에 따라 비동기 흐름을 제어하는 데 매우 유용하다.
promiseWithResolvers는 다음과 같은 상황에서 사용된다.
특정 작업이 끝날 때까지 기다려야 하는 경우
상태 변화에 따라 resolve()를 호출해야 하는 경우
외부에서 비동기 흐름을 트리거해야 하는 경우 (예: Lazy loading, Queue, Worker sync 등)

path.join 대신 path.posix.join을 쓴 이유는?

다음은 Vite의 클라이언트 관련 내부 경로 alias를 설정하는 코드다.
const clientAlias = [ { find: /^\/?@vite\/env/, replacement: path.posix.join(FS_PREFIX, normalizePath(ENV_ENTRY)), }, { find: /^\/?@vite\/client/, replacement: path.posix.join(FS_PREFIX, normalizePath(CLIENT_ENTRY)), }, ]
TypeScript
복사
여기서 눈에 띄는 부분은 바로 path.join이 아닌 path.posix.join을 사용한 점이다. 대부분의 Node.js 코드에서는 path.join을 쓰는 것이 일반적이지만, Vite가 굳이 path.posix.join을 쓴 이유는 무엇일까?

path.join은 운영체제마다 다르게 동작한다

path.join('a', 'b') // POSIX (Mac/Linux): 'a/b' // Windows: 'a\\b'
TypeScript
복사
Node.js의 path.join은 현재 실행 중인 운영체제(OS)에 따라 경로 구분자를 알아서 맞춰준다. 로컬 파일 시스템 작업에는 매우 편리하다.
그러나 Vite처럼 웹 번들링을 수행하는 도구에서는 이 동작이 문제가 될 수 있다. 브라우저나 개발 서버의 내부 경로는 항상 슬래시(/) 기반이어야 하기 때문이다.

path.posix.join은 항상 /를 쓴다

path.posix.join은 플랫폼에 관계없이 항상 POSIX 스타일(/) 경로를 생성한다.
path.posix.join('a', 'b') // 항상 'a/b' path.win32.join('a', 'b') // 항상 'a\\b'
TypeScript
복사

왜 Vite는 path.posix.join을 썼을까?

Vite는 dev server를 실행할 때, 다음과 같은 내부 가상 경로(alias)를 만든다:
/@vite/client /@vite/env
Plain Text
복사
이 경로들은 운영체제의 파일 시스템 경로가 아니라, 브라우저가 요청하는 URL 경로다. 따라서 여기에는 슬래시(/)를 사용하는 POSIX 스타일이 필수다.
즉, Windows 환경에서 path.join()을 사용했다면 \@vite\client처럼 잘못된 URL이 만들어져 버린다. 이것은 브라우저가 해석할 수 없는 경로다.

normalizePath도 함께 쓰는 이유

Vite는 path.posix.join을 사용할 뿐 아니라, join 대상인 ENV_ENTRYCLIENT_ENTRY에도 normalizePath 처리를 거친다. 이는 \/로 바꾸는 유틸 함수로, posix join과의 호환성을 보장한다.
normalizePath('C:\\vite\\client.js') // -> 'C:/vite/client.js'
TypeScript
복사
즉, 내부 파일 경로든, URL 경로든 슬래시 기반으로 일관성 있게 유지한다.

바로 await하지 않고 나중에 하는 이유

Vite의 서버 초기화 코드를 보면, 흥미로운 패턴이 하나 눈에 띈다.
ages/vite/src/node/server/index.ts
// 초기화 작업을 시작하지만 완료를 기다리지 않음 const initPublicFilesPromise = initPublicFiles(config) // 다른 설정 작업 계속 진행... // 나중에 서버가 요청을 처리하기 전에 초기화 완료를 확인 await initPublicFilesPromise
TypeScript
복사
initPublicFiles()는 정적 파일을 준비하는 비동기 함수다. 그런데 곧바로 await하지 않고, 먼저 실행만 시켜놓은 뒤, 필요한 시점까지 기다리지 않는다. 이는 단순한 트릭이 아니라 Vite 전체 초기화 속도를 빠르게 만들기 위한 전략적 선택이다.

왜 나중에 await하는가?

Vite 서버는 초기화 단계에서 여러 작업을 수행한다.
정적 파일 디렉토리 분석 (initPublicFiles)
플러그인 로딩 및 설정
의존성 최적화 분석
파일 시스템 감시자(watcher) 설정
이 작업들은 서로 의존성이 거의 없다. 즉, 굳이 순차적으로 처리할 이유가 없다는 뜻이다. 그래서 Vite는 가능한 한 많은 초기화 작업을 동시에 시작하고, 최소한의 블로킹만 유지하는 방식으로 속도를 극대화한다.

코드 흐름으로 살펴보면

// Step 1: 비동기 작업을 실행만 해두고 const initPublicFilesPromise = initPublicFiles(config) // Step 2: 다른 작업을 동시에 진행 await resolvePlugins(config) setupWatcher() // Step 3: 마지막에만 기다리면 OK await initPublicFilesPromise
TypeScript
복사
이렇게 하면 initPublicFilesresolvePlugins가 동시에 실행되고, 전체 초기화 시간이 훨씬 줄어든다. 특히 initPublicFiles처럼 파일 시스템 I/O가 걸리는 작업이 있다면 효과는 더욱 크다.

실무에서 배울 점

이 패턴은 비동기 초기화 로직을 병렬로 최적화할 수 있는 좋은 예시다. 초기화 단계가 복잡한 서비스(예: 웹 서버, CLI 툴, 게임 엔진 등)를 만들 때 다음과 같은 방식을 적용할 수 있다.
1.
Promise를 먼저 생성하고 실행만 한다 (await은 하지 않음)
2.
병렬 가능한 다른 초기화 로직을 계속 진행한다
3.
진짜 필요한 시점에 await한다

Vite는 왜 fs.watch 대신 chokidar를 쓸까?

Vite는 개발 서버 구동 시 다음과 같이 chokidar.watch를 사용해 다양한 파일을 감시한다.
packages/vite/src/node/server/index.ts
const watcher = watchEnabled ? (chokidar.watch( // config file dependencies and env file might be outside of root [ root, ...config.configFileDependencies, ...getEnvFilesForMode(config.mode, config.envDir), // Watch the public directory explicitly because it might be outside // of the root directory. ...(publicDir && publicFiles ? [publicDir] : []), ], resolvedWatchOptions, ) as FSWatcher) : createNoopWatcher(resolvedWatchOptions)
TypeScript
복사
Node.js에는 기본적으로 fs.watch라는 API가 있다. 그런데도 굳이 외부 의존성인 chokidar를 사용하는 이유는 무엇일까?

1. fs.watch 는 플랫폼마다 다르게 동작하여 일관성이 없다.

Node.js 공식 문서조차 fs.watch에 대해 명확히 경고하고 있다. 플랫폼마다 동작 방식이 다르기 때문이다.
Windows에선 내부적으로 ReadDirectoryChangesW를 사용하고,
macOS에선 FSEvents,
Linux에선 inotify를 사용하는 등 구현 방식이 전부 다르다.
이로 인해 이벤트가 누락되거나 중복되는 문제가 발생하기 쉽다. 특히 실시간 모듈 교체(HMR)에 의존하는 Vite와 같은 도구에겐 작은 이벤트 누락이 큰 버그로 이어질 수 있다.
chokidar는 이런 문제를 해결한다. 내부적으로 OS별 감시 방식을 추상화하고, 일관된 방식으로 이벤트를 제공한다. 따라서 어떤 환경에서도 똑같이 잘 동작하는 감시 시스템을 만들 수 있다.

2. 신뢰성과 안정성이 중요하다

fs.watch는 다음과 같은 실질적인 한계를 가진다.
이벤트 누락 발생 가능성
동일한 파일 변경에 여러 이벤트가 중복 발생
심볼릭 링크, 디렉토리 삭제 등의 예외 처리 부족
반면 chokidar는 다음과 같은 기능으로 안정성을 확보한다.
중복 이벤트 자동 필터링
폴링(fallback) 방식 제공으로 감시 실패 방지
심볼릭 링크 지원
고장 복구를 위한 내부 재시도 로직
Vite는 실제로 다양한 위치의 설정 파일, 환경변수 파일, 퍼블릭 디렉토리 등을 감시한다. 이 모든 파일에 대해 fs.watch만으로 감시 정확도를 보장하려면 더 많은 코드가 필요하게 된다.

3. 더 많은 기능이 필요하다

fs.watch는 기능적으로도 매우 제한적이다. 재귀 감시도 기본적으로 지원하지 않으며, glob 패턴도 사용할 수 없다. 반면, chokidar는 다음과 같은 기능을 제공한다.
재귀 감시 (하위 폴더 포함)
glob 패턴 기반 감시 (src/**/*.ts 등)
감시 대상의 동적 추가/제거
초기 감시 시 'add' 이벤트 발생
Vite는 이러한 기능을 활용해 설정 파일 변경 감지, 환경 파일 추가 감지, 퍼블릭 디렉토리 내부 파일의 변화 추적 등을 구현한다.

ModuleGraph에서 ssr, client 모듈을 분리하는 이유

Vite의 ModuleGraph 구조를 보면, 클라이언트 모듈과 SSR(Server Side Rendering) 모듈이 명확히 나뉘어 관리된다. 겉보기에 동일한 파일이라도, 실행 환경에 따라 별도로 모듈을 관리하고 변환 결과도 다르게 저장한다.
처음에는 다소 과해 보일 수 있는 이 분리는 사실 현대적인 웹 애플리케이션의 요구사항을 충실히 반영한 결정이다. Vite가 왜 이런 구조를 채택했는지 살펴보면, SSR 기반 프로젝트에서 어떤 구조가 필요한지 더 명확히 이해할 수 있다.

1. 서로 다른 실행 환경을 위한 최적화

SSR은 Node.js 환경에서 동작하고, 클라이언트는 브라우저에서 동작한다. 이 둘은 지원하는 API와 제약 조건이 다르다.
브라우저는 window, document, fetch 등을 사용할 수 있지만, fs, process는 사용할 수 없다.
반대로 Node.js는 fs, path, process 등을 사용할 수 있지만 DOM은 없다.
따라서 같은 파일이라도 실행 환경에 따라 전혀 다른 방식으로 처리되어야 한다. Vite는 이를 위해 모듈을 클라이언트용과 SSR용으로 나누어 별도로 최적화한다.

2. 트리 쉐이킹과 번들 최적화

Vite 내부의 mixedModuleGraph.ts를 보면 다음과 같은 코드가 있다.
getModuleById(id: string): ModuleNode | undefined { const clientModule = this._client.getModuleById(id) const ssrModule = this._ssr.getModuleById(id) if (!clientModule && !ssrModule) { return } return this.getBackwardCompatibleModuleNodeDual(clientModule, ssrModule) }
TypeScript
복사
이처럼 동일한 ID의 모듈이라도 클라이언트와 SSR에서 각각 다르게 다룬다.
브라우저에서만 필요한 모듈은 SSR 번들에서 제거할 수 있다.
서버에서만 사용하는 로직은 클라이언트 번들에서 제외할 수 있다.
결과적으로, 각 환경에 불필요한 코드를 포함하지 않게 되어 번들 크기를 줄이고, 성능을 높일 수 있다.

3. 서로 다른 변환 및 처리 방식

get transformResult(): TransformResult | null { return this._clientModule?.transformResult ?? null } get ssrTransformResult(): TransformResult | null { return this._ssrModule?.transformResult ?? null }
TypeScript
복사
Vite는 동일한 모듈에 대해 환경별로 별도의 transform 결과를 저장한다. 이유는 다음과 같다.
클라이언트 코드에는 브라우저 호환성을 위한 polyfill이나 코드 분할 처리가 필요하다.
SSR 코드에는 Node.js 환경에 맞는 변환이나 최적화가 필요하다.
따라서 하나의 변환 결과로 두 환경을 처리할 수는 없다. 이는 Vite가 두 환경을 분리해 모듈 그래프를 유지해야 하는 핵심 이유 중 하나다.

4. 의존성 관리와 HMR(Hot Module Replacement)

HMR(Hot Module Replacement)은 주로 클라이언트 개발에서 중요한 기능이다. 하지만 SSR 환경에서도 모듈 캐시를 적절히 무효화해야 한다. 다음은 관련 코드의 일부다.
invalidateModule( mod: ModuleNode, seen = new Set<ModuleNode>(), timestamp: number = Date.now(), isHmr: boolean = false, /** @internal */ softInvalidate = false, ): void { if (mod._clientModule) { // 클라이언트 모듈 무효화 로직 } if (mod._ssrModule) { // SSR 모듈 무효화 로직 } }
TypeScript
복사
이처럼 변경된 파일이 클라이언트와 SSR 모두에 영향을 줄 수 있기 때문에, 두 모듈을 분리해서 추적하고 각각 개별적으로 무효화 처리해야 한다.

하위호환성을 지키기 위한 Vite의 프록시 패턴

Vite의 내부 구조는 지속적으로 진화하고 있다. 특히 모듈 그래프는 클라이언트용과 SSR용을 분리하여 관리하도록 리팩토링되었지만, 외부에서 사용하는 API는 여전히 단일한 모듈 그래프처럼 보인다.
답은 Vite가 사용하는 프록시 패턴(proxy pattern)에 있다. 아래는 그 대표적인 예시다.
function createBackwardCompatibleModuleSet( moduleGraph: ModuleGraph, prop: ModuleSetNames, module: EnvironmentModuleNode, ): Set<ModuleNode> { return { [Symbol.iterator]() { return this.keys() }, has(key) { if (!key.id) { return false } const keyModule = moduleGraph ._getModuleGraph(module.environment) .getModuleById(key.id) return keyModule !== undefined && module[prop].has(keyModule) }, values() { return this.keys() }, keys() { return mapIterator(module[prop].keys(), (mod) => moduleGraph.getBackwardCompatibleModuleNode(mod), ) }, get size() { return module[prop].size }, forEach(callback, thisArg) { return module[prop].forEach((mod) => { const backwardCompatibleMod = moduleGraph.getBackwardCompatibleModuleNode(mod) callback.call( thisArg, backwardCompatibleMod, backwardCompatibleMod, this, ) }) }, // There are several methods missing. We can implement them if downstream // projects are relying on them: add, clear, delete, difference, intersection, // sDisjointFrom, isSubsetOf, isSupersetOf, symmetricDifference, union } as Set<ModuleNode> }
TypeScript
복사
이 코드는 실제 Set 인스턴스는 아니지만, Set처럼 동작하는 객체를 반환한다. 내부에서는 클라이언트/SSR 모듈을 분리해서 관리하면서도, 외부에서는 마치 단일 Set처럼 보이게 만든다. 이를 통해 내부 구조 변경의 영향이 외부 API에 전달되지 않도록 한다.

Vite는 왜 이런 구조를 선택했을까?

Vite는 내부적으로 ModuleGraph를 클라이언트와 SSR로 분리해 관리한다. 이는 이전에 설명했던 것처럼 각 환경의 실행 방식, 변환 로직, 의존성 구조가 다르기 때문이다.
그러나 Vite의 일부 API는 여전히 Set<ModuleNode> 형태로 작동하는 구조를 기대한다. 예전에는 모듈 그래프가 단일 구조였기에 문제가 없었지만, 구조가 분리되면서 그대로 사용하면 하위 호환성이 깨질 수 있다.
이를 해결하기 위해, Vite는 다음과 같은 방식으로 Set 인터페이스를 “프록시처럼 흉내 낸 객체”를 직접 구현한다:
has(), keys(), forEach()Set에서 기대하는 메서드를 수동으로 구현
각 모듈을 getBackwardCompatibleModuleNode()로 감싸 하위 호환성 유지
실제 내부 모듈이 아닌, 래핑된 추상화 모듈만 외부에 노출

여기서 잠깐, 프록시 패턴이란 무엇인가?

자바스크립트에는 ES6부터 Proxy라는 문법이 도입되어, 객체의 속성 접근을 가로채거나 감시할 수 있게 되었다.
const target = { name: 'vite' } const handler = { get(target, prop) { console.log(`accessing ${prop}`) return target[prop] } } const proxy = new Proxy(target, handler) console.log(proxy.name) // "accessing name" → "vite"
TypeScript
복사
하지만 Vite의 createBackwardCompatibleModuleSet()Proxy 클래스를 직접 사용하지 않고, 프록시 패턴의 철학만 차용한다.
즉, 외부에서 기대하는 인터페이스(Set)를 흉내 내는 중간 레이어 객체를 만들어, 내부 구조를 숨기고, 필요한 후처리를 삽입하며, 기존 API와 호환되도록 한다.

하위 호환성을 유지하는 방법으로서의 프록시 패턴

이처럼 Vite가 구현한 객체는 다음과 같은 역할을 한다:
외부 API와의 일관성 유지: 기존 코드가 .has().forEach() 같은 메서드를 그대로 쓸 수 있다.
중간 변환 삽입: 내부 모듈은 환경별로 다르지만, 외부에는 통합된 추상화 모듈을 제공한다.
구조 변경 은닉: 클라이언트/SSR 분리라는 내부 구현 변경이 외부 사용자에게 드러나지 않는다.
이것은 단순히 예쁜 코드가 아니라, 오픈소스 도구가 진화하면서도 생태계와의 호환성을 유지하는 중요한 전략이다.
Vite는 내부적으로 큰 구조 개편을 하더라도, 외부 API는 그대로 유지한다. 이는 단순한 기술적 디테일이 아니라, 오픈소스 도구로서의 신뢰성과 안정성을 높이는 방법이다.
createBackwardCompatibleModuleSet()은 이 목표를 달성하기 위해 프록시 패턴을 활용한 완충 지대 역할을 한다.
구조는 바뀌었지만 인터페이스는 그대로 유지한다.
라이브러리를 설계하거나 API를 제공할 때 참고하면 좋은 교훈인 것 같다.

서버가 재시작돼도 모든 코드가 계속 잘 작동하는 이유

Vite의 개발 서버를 실행하다 보면 설정 파일을 바꾸거나, 플러그인을 수정하거나, 때때로 오류가 발생해서 서버가 자동으로 “재시작”되기도 한다. 그럼에도, 외부 코드에서는 아무 일도 없었던 것처럼 계속 server를 사용할 수 있다. 그 이유는 바로 프록시(proxy)를 활용한 동적 참조 유지에 있다.
// maintain consistency with the server instance after restarting. const reflexServer = new Proxy(server, { get: (_, property: keyof ViteDevServer) => { return server[property] }, set: (_, property: keyof ViteDevServer, value: never) => { server[property] = value return true }, })
TypeScript
복사

이 프록시는 뭘 하는가?

겉보기엔 아무 일도 하지 않는 것처럼 보인다. 실제로 get, set 트랩 모두 단순히 원래의 server 객체에 작업을 그대로 전달한다.
하지만 중요한 점은 이 프록시가 항상 최신 server 변수를 참조하고 있다는 점이다. 즉, server 변수가 가리키는 대상이 바뀌더라도, reflexServer를 통해 접근하면 항상 최신 서버 인스턴스를 사용할 수 있다.

역할과 목적

maintain consistency with the server instance after restarting.
reflexServer의 코드에 달려있는 주석이다. 서버가 재시작된 이후에도 서버 인스턴스의 일관성을 지킬 수 있다.

서버 재시작의 문제: 참조 불일치

개발 도중 서버가 재시작되면 새로운 서버 인스턴스가 생성된다. 이때 기존 코드에서 const server = ...처럼 참조를 보관하고 있었다면, 그 참조는 더 이상 유효하지 않다. 새로운 인스턴스를 인식하지 못하기 때문에, 예기치 못한 동작이나 오류가 발생할 수 있다.
// 서버 재시작 전: server === oldInstance // 서버 재시작 후: server === newInstance // 그러나 const myServer = server 로 저장해둔 곳은 여전히 oldInstance를 참조 중
TypeScript
복사

프록시로 해결: 동적 참조

Vite는 이 문제를 프록시로 해결한다. reflexServer는 항상 server 변수를 기준으로 동작하기 때문에, 서버가 재시작되어 server = newInstance가 되더라도 기존의 reflexServer는 그대로 최신 서버를 가리키게 된다.
이 구조 덕분에 다음과 같은 장점이 생긴다:
1.
클라이언트 코드가 서버 재시작을 신경 쓸 필요가 없다.
2.
참조를 일일이 업데이트하지 않아도 된다.
3.
서버가 바뀌더라도 전체 시스템은 끊기지 않는다.

이게 가능한 이유: 자바스크립트 Proxy의 동작 방식

JavaScript의 Proxy 객체는 속성 접근(get), 설정(set), 삭제 등 객체와 상호작용하는 모든 작업을 가로채 처리할 수 있다. 이 덕분에 원본 객체가 바뀌더라도, 프록시는 항상 최신 상태로 동작하게 만들 수 있다.
const target = { name: 'old' } let actualTarget = target const proxy = new Proxy({}, { get(_, prop) { return actualTarget[prop] }, set(_, prop, value) { actualTarget[prop] = value return true }, }) // 중간에 실제 대상을 교체해도... actualTarget = { name: 'new' } console.log(proxy.name) // 'new'
TypeScript
복사
Vite의 reflexServer도 이런 방식으로 구현되어 있다.

Vite가 HMR 의존성을 빠르게 분석하는 방법

Vite는 핫 모듈 교체(Hot Module Replacement, HMR)를 매우 빠르고 안정적으로 처리한다. 그 비결 중 하나는 바로 import.meta.hot.accept()를 분석하는 방식에 있다. 대부분의 번들러는 이런 작업을 위해 AST(추상 구문 트리)를 파싱하는 무거운 과정을 거치지만, Vite는 다르다.
lexAcceptedHmrDeps()는 전체 코드를 파싱하지 않고 간단한 문자 레벨 어휘 분석(lexing)만으로 HMR 의존성을 찾아낸다.
hmr.ts - lexAcceptedHmrDeps
/** * Lex import.meta.hot.accept() for accepted deps. * Since hot.accept() can only accept string literals or array of string * literals, we don't really need a heavy @babel/parse call on the entire source. * * @returns selfAccepts */ export function lexAcceptedHmrDeps( code: string, start: number, urls: Set<{ url: string; start: number; end: number }>, ): boolean { let state: LexerState = LexerState.inCall // the state can only be 2 levels deep so no need for a stack let prevState: LexerState = LexerState.inCall let currentDep: string = '' function addDep(index: number) { urls.add({ url: currentDep, start: index - currentDep.length - 1, end: index + 1, }) currentDep = '' } for (let i = start; i < code.length; i++) { const char = code.charAt(i) switch (state) { case LexerState.inCall: case LexerState.inArray: if (char === `'`) { prevState = state state = LexerState.inSingleQuoteString } else if (char === `"`) { prevState = state state = LexerState.inDoubleQuoteString } else if (char === '`') { prevState = state state = LexerState.inTemplateString } else if (whitespaceRE.test(char)) { continue } else { if (state === LexerState.inCall) { if (char === `[`) { state = LexerState.inArray } else { // reaching here means the first arg is neither a string literal // nor an Array literal (direct callback) or there is no arg // in both case this indicates a self-accepting module return true // done } } else { if (char === `]`) { return false // done } else if (char === ',') { continue } else { error(i) } } } break case LexerState.inSingleQuoteString: if (char === `'`) { addDep(i) if (prevState === LexerState.inCall) { // accept('foo', ...) return false } else { state = prevState } } else { currentDep += char } break case LexerState.inDoubleQuoteString: if (char === `"`) { addDep(i) if (prevState === LexerState.inCall) { // accept('foo', ...) return false } else { state = prevState } } else { currentDep += char } break case LexerState.inTemplateString: if (char === '`') { addDep(i) if (prevState === LexerState.inCall) { // accept('foo', ...) return false } else { state = prevState } } else if (char === '$' && code.charAt(i + 1) === '{') { error(i) } else { currentDep += char } break default: throw new Error('unknown import.meta.hot lexer state') } } return false }
TypeScript
복사
주석에서도 알 수 있듯이, Vite는 이 함수로 다음 두 가지를 처리한다:
1.
HMR 의존성 추출
2.
self-accepting 모듈 여부 판단
/** * Lex import.meta.hot.accept() for accepted deps. * Since hot.accept() can only accept string literals or array of string * literals, we don't really need a heavy @babel/parse call on the entire source. * * @returns selfAccepts */
TypeScript
복사

self-accepting이란?

한마디로 의존성이 없는 파일이 변경된 경우 self-accepting이라고 판단하고 그 모듈만 새로 업데이트한다.
핫 모듈 교체에서는 모듈이 스스로 업데이트를 감당할 수 있는지를 확인해야 한다. import.meta.hot.accept()를 보면 두 가지 패턴이 있다:

1. self-accepting

import.meta.hot.accept(() => { console.log('이 모듈이 업데이트되었습니다'); }) // 또는 import.meta.hot.accept();
TypeScript
복사
이 모듈 자신만 업데이트하면 된다. 더 빠르고 안전하다.

2. 의존성 기반 Accepting

import.meta.hot.accept(['./foo.js', './bar.js'], () => { console.log('foo 또는 bar가 변경되었습니다'); });
TypeScript
복사
의존성이 있는 파일도 함께 업데이트해야 한다.

Vite는 어떻게 이걸 구분할까?

lexAcceptedHmrDeps() 함수는 import.meta.hot.accept(...)의 인자를 분석한다. 만약 첫 번째 인자가 문자열이나 배열이면 → 의존성 기반 accept, 그렇지 않으면 → self-accepting이라고 판단한다.
if (char === `[`) { state = LexerState.inArray } else { // 문자열도 배열도 아닌 경우 → self-accepting return true }
TypeScript
복사
이처럼 간단한 문자 기반 로직으로도 충분히 정확하게 동작한다. 이유는 accept()가 콜백, 배열, 문자열 리터럴 등을 받을 수 있다는 명세 덕분이다.
LexerState 참고
const enum LexerState { inCall, inSingleQuoteString, inDoubleQuoteString, inTemplateString, inArray, }
TypeScript
복사

왜 이게 중요한가?

보통 이런 정적 분석은 Babel이나 Acorn 같은 파서로 AST를 만들어 처리한다. 하지만 AST 파싱은 느리다. 특히 수많은 파일을 처리해야 하는 대형 프로젝트에서는 성능에 큰 부담이 된다.
Vite는 이를 우회해서 필요한 정보만 빠르게 추출한다:
특정 패턴만 찾으면 되니 전체 AST는 필요 없다
단순 문자 기반 상태 머신으로 필요한 정보만 추출
모듈이 self-accepting인지 아닌지만 빠르게 판별 가능
결과적으로 HMR 속도가 비약적으로 향상된다

전체 흐름: HMR 처리 과정 요약

1.
파일 변경 감지 (watcher)
2.
변경된 모듈의 코드에서 import.meta.hot.accept() 호출 분석
3.
lexAcceptedHmrDeps()를 통해:
self-accepting이면 해당 모듈만 업데이트
아니라면 의존성을 추적하고 상위 모듈까지 업데이트 전파
4.
필요한 모듈만 새로 불러와서 교체
lexAcceptedHmrDeps() 함수에서 볼 수 있듯이 Vite는 무조건 AST를 파싱하지 않고 더 가벼운 방법을 쓴다.
복잡한 HMR 시스템에서 성능을 확보하기 위해, Vite는 단순하면서도 정확한 방법으로 의존성을 분석하고, self-accepting 모듈 여부를 판단한다. 이러한 설계는 빠른 피드백 루프를 원하는 개발자들의 기대에 정확히 부합한다.
이처럼 HMR도 결국은 속도가 생명이며, 속도는 분석 방법에서 갈린다는 걸 배울 수 있었다.