Post

가상 모듈(Virtual Module) 패턴을 활용한 빌드 메타데이터 및 설정 주입

Vite와 Rollup의 널 바이트(\0) 규약 기반 가상 모듈 패턴을 이해하고, 파일시스템 생성 없이 Git 커밋 해시, 빌드 시간, 환경 메타데이터를 클라이언트 애플리케이션에 안전하게 주입하는 커스텀 플러그인을 구현합니다.

가상 모듈(Virtual Module) 패턴을 활용한 빌드 메타데이터 및 설정 주입

프론트엔드 빌드 파이프라인에서 Git 커밋 해시나 빌드 타임스탬프 같은 메타데이터를 런타임에 전달할 때, 디스크에 임시 JSON 파일을 쓰는 방식은 빌드 아티팩트 오염과 I/O 오버헤드를 유발합니다. 본 글에서는 Vite와 Rollup의 표준 가상 모듈(Virtual Module) 패턴과 널 바이트(\0) 규약을 살펴보고, 인메모리에서 빌드 정보를 합성하여 타입 안전하게 주입하는 커스텀 플러그인을 제작해 봅니다.


1. 배경: 임시 파일 생성 방식의 한계

클라이언트 웹 애플리케이션을 배포할 때, 현재 배포된 버전의 Git SHA, 빌드 시각, 빌드 에이전트 정보, 환경별 기능 플래그(Feature Flags)를 런타임 UI나 에러 트래킹 도구(Sentry 등)에 전달해야 하는 요구사항은 매우 흔합니다.

과거 웹팩이나 스크립트 기반 환경에서는 빌드 직전에 디스크 어딘가에 임시 JSON이나 TS 파일을 생성하는 방식을 주로 사용했습니다.

1
2
# 디스크에 임시 파일을 생성하는 기존 방식의 문제점
git rev-parse HEAD > src/generated-build-info.json

그러나 이 방식은 몇 가지 치명적인 단점이 존재합니다:

  • Git 저장소 상태 오염: .gitignore 관리가 누락될 경우 자동 생성 파일이 커밋 히스토리에 혼입됩니다.
  • 파일시스템 I/O 오버헤드: Docker 컨테이너나 CI/CD 파이프라인 환경에서 불필요한 디스크 쓰기가 발생합니다.
  • HMR 불일치: 개발 서버 구동 중 파일 변경 감지기가 임시 생성 파일을 불필요하게 감지하여 무한 재빌드 루프를 유발할 수 있습니다.

Vite는 Rollup의 가상 모듈(Virtual Module) 메커니즘을 그대로 계승하여, 실제 파일시스템에 존재하지 않는 모듈을 온디맨드 인메모리로 생성하고 ES 모듈로 즉시 제공할 수 있는 깔끔한 해법을 제공합니다.


2. 가상 모듈의 동작 원리와 널 바이트(\0) 컨벤션

가상 모듈은 resolveId와 load라는 두 개의 핵심 Rollup/Vite 생명주기 훅의 유기적인 협업으로 동작합니다.

sequenceDiagram
    autonumber
    actor App as 클라이언트 코드 (main.ts)
    participant Vite as Vite 모듈 해석기
    participant Plugin as 커스텀 플러그인
    participant FS as 파일시스템 (Disk)

    App->>Vite: import meta from 'virtual:build-meta'
    Vite->>Plugin: resolveId('virtual:build-meta')
    Note over Plugin: 가상 모듈 식별자 감지<br/>식별자 앞에 \0 접두사 추가
    Plugin-->>Vite: '\0virtual:build-meta' 반환
    Note over Vite: \0 접두사를 확인하여<br/>파일시스템 탐색(FS) 완전 생략
    Vite->>Plugin: load('\0virtual:build-meta')
    Note over Plugin: Git 해시, 타임스탬프 등<br/>인메모리 JS 코드 생성
    Plugin-->>Vite: 'export const gitCommit = "7b4e9f...";'
    Vite-->>App: 컴파일된 ES 모듈 반환

2.1 왜 널 바이트(\0) 접두사가 필요한가?

Rollup과 Vite 생태계에서는 가상 모듈 ID 앞에 ASCII 널 문자(\0)를 붙이는 표준 컨벤션이 존재합니다.

  • 다른 플러그인의 간섭 방지: 파일 경로를 분석하는 다른 플러그인(예: node-resolve, alias)이 해당 모듈을 실제 파일시스템 경로로 착각하고 접근하려는 시도를 사전에 차단합니다.
  • 파일시스템 탐색 바이패스: Vite 내부 모듈 리졸버는 \0으로 시작하는 모듈 ID를 만나면 디스크 경로 탐색을 건너뛰고 오직 플러그인의 load 훅 체인으로만 처리를 넘깁니다.

일반적으로 사용자에게 노출되는 import 경로는 virtual: 접두사를 사용하고, 내부적으로 플러그인이 처리할 때는 \0을 붙여 내부 ID로 관리합니다.


3. 실습: virtual:build-meta 커스텀 플러그인 구현

이제 Git 정보와 빌드 시점의 환경 정보를 캡슐화하여 제공하는 완성형 커스텀 플러그인을 작성해 보겠습니다.

3.1 플러그인 코드 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// plugins/vite-plugin-build-meta.ts
import type { Plugin } from 'vite';
import { execSync } from 'node:child_process';

export interface BuildMetaOptions {
  features?: Record<string, boolean>;
}

export function buildMetaPlugin(options: BuildMetaOptions = {}): Plugin {
  // 사용자가 import할 가상 모듈의 공개 ID
  const virtualModuleId = 'virtual:build-meta';
  // 플러그인 내부에서 식별할 널 바이트 접두사 ID
  const resolvedVirtualModuleId = '\0' + virtualModuleId;

  // Git 정보 안전 조회 헬퍼
  const getGitInfo = () => {
    try {
      const commit = execSync('git rev-parse --short HEAD').toString().trim();
      const branch = execSync('git rev-parse --abbrev-ref HEAD').toString().trim();
      return { commit, branch };
    } catch {
      return { commit: 'unknown-commit', branch: 'unknown-branch' };
    }
  };

  return {
    name: 'vite-plugin-build-meta',

    // 1. 가상 모듈 경로 해석
    resolveId(id) {
      if (id === virtualModuleId) {
        return resolvedVirtualModuleId;
      }
      return null;
    },

    // 2. 가상 모듈 소스코드 인메모리 생성
    load(id) {
      if (id === resolvedVirtualModuleId) {
        const { commit, branch } = getGitInfo();
        const buildTime = new Date().toISOString();
        const nodeVersion = process.version;

        const metadata = {
          gitCommit: commit,
          gitBranch: branch,
          buildTimestamp: buildTime,
          builtBy: process.env.USER || 'ci-runner',
          nodeVersion: nodeVersion,
          viteVersion: '5.4.8',
          environment: process.env.NODE_ENV || 'production',
          features: options.features || {
            analytics: true,
            debugPanel: false,
            mockApi: false,
          },
        };

        // 트리 쉐이킹이 가능하도록 개별 named export와 default export를 함께 생성
        return `
          export const gitCommit = ${JSON.stringify(metadata.gitCommit)};
          export const gitBranch = ${JSON.stringify(metadata.gitBranch)};
          export const buildTimestamp = ${JSON.stringify(metadata.buildTimestamp)};
          export const builtBy = ${JSON.stringify(metadata.builtBy)};
          export const nodeVersion = ${JSON.stringify(metadata.nodeVersion)};
          export const features = ${JSON.stringify(metadata.features)};

          const buildMeta = ${JSON.stringify(metadata)};
          export default buildMeta;
        `;
      }
      return null;
    },
  };
}

3.2 TypeScript 타입 선언 (virtual-modules.d.ts)

가상 모듈은 물리적 파일이 없으므로, TypeScript 컴파일러(tsc)가 모듈을 인식할 수 있도록 앰비언트 모듈(Ambient Module) 선언을 추가해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// src/virtual-modules.d.ts
declare module 'virtual:build-meta' {
  export interface BuildFeatures {
    analytics: boolean;
    debugPanel: boolean;
    mockApi: boolean;
    [key: string]: boolean;
  }

  export interface BuildMeta {
    gitCommit: string;
    gitBranch: string;
    buildTimestamp: string;
    builtBy: string;
    nodeVersion: string;
    viteVersion: string;
    environment: string;
    features: BuildFeatures;
  }

  export const gitCommit: string;
  export const gitBranch: string;
  export const buildTimestamp: string;
  export const builtBy: string;
  export const nodeVersion: string;
  export const features: BuildFeatures;

  const buildMeta: BuildMeta;
  export default buildMeta;
}

tsconfig.json의 include에 src/virtual-modules.d.ts가 포함되어 있는지 확인합니다.


4. 애플리케이션 연동 및 실행 결과 검증

4.1 설정 등록 및 클라이언트 사용

vite.config.ts에 플러그인을 등록합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// vite.config.ts
import { defineConfig } from 'vite';
import { buildMetaPlugin } from './plugins/vite-plugin-build-meta';

export default defineConfig({
  plugins: [
    buildMetaPlugin({
      features: {
        analytics: true,
        debugPanel: false,
        mockApi: false,
      },
    }),
  ],
});

이제 애플리케이션 코드 어디서나 일반 라이브러리처럼 import하여 사용할 수 있습니다.

1
2
3
4
5
6
// src/main.ts
import buildMeta, { gitCommit, buildTimestamp } from 'virtual:build-meta';

console.log('[runtime] Loading virtual module: virtual:build-meta');
console.log(`Current Commit: ${gitCommit} (Built at: ${buildTimestamp})`);
console.table(buildMeta);

4.2 런타임 실행 결과 확인

Node SSR 환경이나 클라이언트 콘솔에서 애플리케이션을 구동하면 가상 모듈에서 생성된 객체가 완벽하게 평가되어 출력됩니다.

가상 모듈 virtual:build-meta 런타임 실행 결과

별도의 HTTP 요청이나 디스크 읽기 없이, 가상 모듈에 정의된 메타데이터가 12ms 이내의 극소 시간으로 초기화되는 것을 확인할 수 있습니다.

4.3 프로덕션 빌드 번들 및 트리 쉐이킹 검증

vite build를 실행하여 가상 모듈이 실제 번들 결과물에 어떻게 포함되는지 확인합니다.

프로덕션 번들 내 가상 모듈 상수 인라인화 및 트리 쉐이킹 결과 검증

  • Rollup의 트리 쉐이킹 엔진은 가상 모듈을 일반 자바스크립트 모듈과 동일하게 취급합니다.
  • 사용되지 않는 필드는 번들에서 완전히 제거(DCE)되며, 사용된 상수 값들은 dist/assets/index-*.js 청크 내에 불필요한 래핑 없이 즉시 인라인화됩니다.
  • 가상 모듈 크기는 원시 284바이트 수준이며, 런타임 통신 비용 0으로 정적 주입됩니다.

5. 실무 모범 사례

  1. virtual: 네임스페이스 통일: 팀 내에서 커스텀 플러그인을 개발할 때는 가상 모듈 ID에 반드시 virtual: 접두사를 붙여 일반 NPM 패키지나 로컬 상대경로와의 네이밍 충돌을 예방합니다.
  2. 동적 파라미터형 가상 모듈: virtual:icon?name=check와 같이 쿼리 스트링을 파싱하여 동적으로 SVG 코드를 생성하는 아이콘 가상 모듈 패턴으로도 확장이 가능합니다.
  3. Rollup 캐시 무효화 주의: 개발 서버 구동 중 Git 브랜치가 바뀌거나 파일이 변경되어 가상 모듈 내용을 갱신해야 하는 경우, server.moduleGraph.getModuleById(resolvedVirtualModuleId)를 통해 모듈 그래프 노드를 무효화하고 HMR을 트리거해야 합니다.

6. 마치며

Vite의 가상 모듈 패턴은 파일시스템을 깨끗하게 유지하면서도 컴파일 시점의 정적 데이터와 환경 설정을 애플리케이션에 주입할 수 있는 가장 우아한 아키텍처입니다.

다음 포스트에서는 Vite 개발 서버의 내부 Connect 미들웨어 스택을 확장하여, 로컬 환경에서 백엔드 없이 즉시 응답을 내려주는 무서버 Mock API 플러그인을 구현해 보겠습니다.

This post is licensed under CC BY 4.0 by the author.