node:vfs — Node.js에 가상 파일 시스템이 필요한 이유
Node.js에서 파일 시스템을 모킹해본 적이 있다면, 그 고통을 안다. memfs로 fs 모듈을 패치하면 파일 읽기/쓰기는 가상으로 돌아가지만, require()나 import()로 모듈을 불러오는 순간 진짜 파일 시스템을 뒤진다. 모듈 리졸버가 패치된 fs를 무시하기 때문이다. Matteo Collina가 14,000줄짜리 PR로 node:vfs 모듈을 Node.js 코어에 제안한 이유가 여기에 있다.
node:vfs가 해결하려는 4가지 문제
비유하면 Node.js의 파일 시스템은 건물의 배관 같다. 벽 밖에서 보이는 수도꼭지(fs 모듈)만 교체하면 될 것 같지만, 실제로는 벽 안쪽 배관(모듈 리졸버, 네이티브 모듈)까지 전부 연결되어 있어서 하나만 바꿀 수가 없다.
| 문제 | 설명 | 현재 상태 |
|---|---|---|
| SEA 에셋 배포 | Single Executable App에 정적 파일을 포함시키기 어려움 | 별도 번들링 필요 |
| 테스트 격리 | fs 모킹이 모듈 리졸버에 적용되지 않음 | memfs로 불완전 패치 |
| 멀티테넌트 샌드박싱 | 여러 사용자 코드를 격리된 환경에서 실행 | 컨테이너 수준 격리 필요 |
| 런타임 코드 생성 | AI 에이전트가 생성한 코드를 안전하게 실행 | 임시 파일 작성 후 삭제 |
핵심 — 왜 npm 패키지로는 안 되는가
memfs나 unionfs 같은 기존 도구가 있지 않냐고 물을 수 있다. 문제는 이것들이 fs API만 감싸는 얇은 래퍼라는 점이다. Node.js의 모듈 시스템(require, import)은 내부적으로 C++ 레벨에서 파일을 읽는다. 사용자 영역의 패치가 닿지 않는 곳이다.
[💡 잠깐! 이 용어는?] SEA(Single Executable Application): Node.js 애플리케이션을 하나의 실행 파일로 패키징하는 기능이다. 별도의 Node.js 설치 없이 바이너리 하나로 배포할 수 있다.
아키텍처 — 3개 레이어로 구성된다
node:vfs는 세 개의 레이어로 설계되어 있다. 비유하면 컴퓨터의 파일 시스템 계층과 비슷하다. 물리 디스크(Provider) → 마운트 포인트(Mounting) → 오버레이(Overlay)로 추상화가 올라간다.
1. Provider 레이어 — 데이터의 원천
파일 데이터가 실제로 어디에 저장되어 있는지를 결정하는 계층이다.
| Provider | 저장소 | 용도 |
|---|---|---|
MemoryProvider | 메모리(Map) | 테스트, 임시 파일 |
SEAProvider | SEA 바이너리 내부 | 단일 실행 파일 배포 |
VirtualProvider | JavaScript 객체 | 동적 코드 생성 |
SqliteProvider | SQLite DB | 영속적 가상 FS |
RealFSProvider | 실제 디스크 (샌드박싱) | 경로 제한된 실제 파일 접근 |
2. Mounting 레이어 — 경로에 붙이기
리눅스에서 USB를 /mnt/usb에 마운트하는 것과 같다. 가상 파일 시스템을 특정 경로에 연결한다.
import { mount, unmount, MemoryProvider } from 'node:vfs'
const provider = new MemoryProvider()
provider.writeFileSync('/hello.js', `
export function greet(name) {
return 'Hello, ' + name
}
`)
mount('/virtual', provider)
const { greet } = await import('/virtual/hello.js')
console.log(greet('Node.js')) // "Hello, Node.js"
unmount('/virtual')이 코드에서 /virtual/hello.js는 디스크에 존재하지 않는다. 메모리에만 있다. 그런데 import()가 정상 동작한다. 이것이 코어 모듈이어야만 가능한 이유다. 모듈 리졸버가 마운트된 VFS를 인식하기 때문이다.
3. Overlay 모드 — 실제 FS 위에 덮어씌우기
오버레이 모드는 실제 파일 시스템 위에 가상 레이어를 얹는다. 가상 레이어에 파일이 있으면 그걸 반환하고, 없으면 실제 파일 시스템으로 폴스루한다.
import { mount, MemoryProvider } from 'node:vfs'
const overlay = new MemoryProvider()
overlay.writeFileSync('/app/config.json', JSON.stringify({
database: 'test-db',
debug: true
}))
mount('/app', overlay, { overlay: true })
// /app/config.json → 가상 파일 반환 (오버레이)
// /app/index.js → 실제 디스크 파일 반환 (폴스루)비유하면 투명 필름에 수정 사항을 적어서 원본 문서 위에 올려놓는 것이다. 수정한 부분만 필름에서 읽고, 나머지는 원본을 그대로 본다.
코어에 들어가야 하는 5가지 이유
| 이유 | 설명 |
|---|---|
| 모듈 해석 | require()/import()가 VFS를 인식하려면 C++ 레벨 통합 필요 |
| 비공개 API | 모듈 캐시, 내부 경로 해석 등 공개되지 않은 API 접근 필요 |
| 전역 fs 패치 | 모든 fs 호출이 VFS를 거치려면 런타임 수준 후킹 필요 |
| 네이티브 모듈 | .node 파일(C++ 애드온)도 VFS에서 로드해야 함 |
| 모듈 캐시 | 같은 경로의 모듈이 VFS와 실제 FS에서 충돌하지 않도록 관리 |
실전 패턴 — 테스트 격리
using 키워드를 활용하면 테스트 종료 시 자동으로 VFS가 정리된다. 파일 시스템 상태가 테스트 간에 누출되지 않는다.
[💡 잠깐! 이 용어는?]
using 키워드: ECMAScript의 Explicit Resource Management 제안(TC39 Stage 3)이다. using으로 선언한 리소스는 블록을 벗어날 때 자동으로 [Symbol.dispose]()가 호출되어 정리된다.
import { describe, it, assert } from 'node:test'
import { mount, MemoryProvider } from 'node:vfs'
describe('config loader', () => {
it('기본 설정을 반환한다', async () => {
const provider = new MemoryProvider()
provider.writeFileSync('/app/config.json', JSON.stringify({
port: 3000,
env: 'test'
}))
using cleanup = mount('/app', provider, { overlay: true })
const { loadConfig } = await import('/app/config-loader.js')
const config = loadConfig()
assert.strictEqual(config.port, 3000)
assert.strictEqual(config.env, 'test')
// cleanup 자동 호출 — 마운트 해제, 모듈 캐시 초기화
})
it('환경 변수 오버라이드를 적용한다', async () => {
const provider = new MemoryProvider()
provider.writeFileSync('/app/config.json', JSON.stringify({
port: 8080,
env: 'staging'
}))
using cleanup = mount('/app', provider, { overlay: true })
const { loadConfig } = await import('/app/config-loader.js')
const config = loadConfig()
assert.strictEqual(config.port, 8080)
// 이전 테스트의 상태가 전혀 남아있지 않다
})
})각 테스트가 독립된 가상 파일 시스템에서 실행되므로, 테스트 순서에 의존하는 문제가 원천적으로 사라진다.
실전 패턴 — AI 에이전트 코드 생성
AI 에이전트가 코드를 생성하고 실행해야 하는 시나리오에서 VFS는 샌드박스 역할을 한다. 생성된 코드가 실제 파일 시스템에 접근하지 못하도록 격리할 수 있다.
import { mount, MemoryProvider } from 'node:vfs'
async function executeGeneratedCode(code) {
const provider = new MemoryProvider()
provider.writeFileSync('/sandbox/generated.js', code)
provider.writeFileSync('/sandbox/package.json', JSON.stringify({
name: 'sandbox',
type: 'module'
}))
using cleanup = mount('/sandbox', provider)
try {
const module = await import('/sandbox/generated.js')
return { success: true, result: module.default }
} catch (error) {
return { success: false, error: error.message }
}
// cleanup 자동 호출 — 가상 파일 전부 제거
}
const result = await executeGeneratedCode(`
export default function calculate() {
return Array.from({ length: 10 }, (_, i) => i * i)
}
`)임시 파일을 디스크에 쓰고 지우는 기존 방식과 비교하면, 디스크 I/O가 없고 정리 실패 위험도 없다. using 키워드가 블록 종료 시점에 확실하게 정리해준다.
@platformatic/vfs — 코어 머지 전의 대안
node:vfs PR이 코어에 머지되기까지는 시간이 걸린다. 그동안 Platformatic 팀이 npm 패키지로 동일한 인터페이스를 제공하고 있다. 코어 API와 최대한 호환되도록 설계되어 있어서, 나중에 전환 비용이 적다.
npm install @platformatic/vfsimport { mount, MemoryProvider } from '@platformatic/vfs'
const provider = new MemoryProvider()
provider.writeFileSync('/data/hello.txt', 'Hello from VFS')
mount('/data', provider)다만 npm 패키지로는 모듈 리졸버 통합이 불가능하다는 한계가 있다. fs.readFile은 가상으로 동작하지만, import('/data/module.js')는 실제 파일 시스템을 참조한다. 이 차이가 코어 통합의 필요성을 다시 한번 보여준다.
정리
- Node.js에는 모듈 리졸버까지 포함하는 가상 파일 시스템이 필요하다.
memfs같은 npm 패키지는fsAPI만 감싸기 때문에 불완전하다. node:vfs는 Provider(데이터 원천) → Mounting(경로 연결) → Overlay(폴스루) 3계층 아키텍처로 설계되었다.- 테스트 격리, SEA 에셋 번들링, 멀티테넌트 샌드박싱, AI 코드 실행이 주요 사용 사례다.
using키워드와 조합하면 리소스 정리가 자동화되어 테스트 격리가 깔끔해진다.- 코어 머지 전까지는
@platformatic/vfs로 미리 경험해볼 수 있다.
14,000줄짜리 PR이라는 규모가 말해주듯, 이건 단순한 유틸리티가 아니라 Node.js의 파일 시스템 인프라를 근본적으로 확장하는 작업이다.
참고:
- Matteo Collina의 원문: https://blog.platformatic.dev/why-nodejs-needs-a-virtual-file-system
- node:vfs PR: https://github.com/nodejs/node/pull/57804
- @platformatic/vfs: https://github.com/platformatic/vfs
관심 있을 만한 포스트
Naver FE News 2026년 4월 — 49MB 웹 페이지부터 Temporal Stage 4까지
Naver FE News 2026년 4월호에서 프론트엔드 개발자가 주목할 6가지 소식을 선별해 정리한다.
envscan — .env.example을 손으로 관리하지 말자
코드에서 process.env 참조를 스캔해 .env.example을 자동 생성하는 envscan의 접근 방식과 기존 도구들과의 차이를 정리한다.
Bun이 빠른 건 맞다 — 그런데 당신의 이벤트 루프가 문제다
Bun으로 바꿔도 p99가 개선되지 않는 이유. 런타임 선택보다 먼저 봐야 할 진짜 병목 지점들.
Bun vs Node.js vs Deno — 뭐가 다른지, 그래서 뭘 쓰면 좋은지 (2026 기준)
런타임 3대장 비교: 호환성(Node), 속도/번들(Bun), 올인원/보안(Deno). 팀/프로덕트 상황별 선택 기준과 체크리스트까지 정리.
Action-Reducer-State의 귀환 — 프론트엔드 패턴이 서버를 점령한 이유
프론트엔드에서 익숙한 Redux의 Action-Reducer-State 패턴을 서버 사이드에 적용한 당근마켓의 이벤트 소싱 라이브러리 Ventyd를 분석한다.
SVG 아이콘 — 코드 배포 없이 프로덕트 팀이 직접 관리하는 법
CSS mask-image와 S3를 조합해 개발자 개입 없이 아이콘을 교체하는 패턴을 소개한다.
VS Code 1.116 — 에이전트 디버깅, 포그라운드 터미널, 내장 Copilot
2026년 4월 VS Code 1.116이 에이전트 경험, 터미널, Chat UX, 내장 브라우저를 개선한 핵심 변경사항을 정리한다.
VS Code 에이전트 — 실전 개발에서 쓸 수 있게 만드는 세 가지 축
VS Code 1.110이 도입한 컨텍스트 관리, 에이전트 제어, 확장성 기능이 AI 에이전트를 실무에 투입 가능하게 만든 방식을 분석한다.