Rollup의 번들링 과정

이종경·2026년 2월 28일

Vite Deep Dive

목록 보기
3/3

Rollup이란?

Rollup bundling

Rollup은 JavaScript 모듈 번들러로, 여러 모듈을 하나 또는 여러 개의 최적화된 번들 파일로 합쳐주는 도구입니다. ESM을 기반으로 하며, 정적 분석을 통한 Tree-shaking을 핵심 기능으로 제공합니다. 또한 플러그인 생태계가 잘되어 있어 Vite에 채택되어 프로덕션 빌드시 사용되는 번들러입니다.

주요 용어 정리

본격적인 빌드 과정을 알아보기 전에, Rollup 내부에서 사용되는 핵심 용어를 정리해 보겠습니다.

컴포넌트목적핵심 역할
Graph중앙 관리전체 빌드 단계를 조율하고, 모듈 간의 연결 상태를 담은 모듈 그래프를 관리합니다
Module소스 파일 관리소스 코드를 AST로 파싱하고, 파일 내의 의존성을 분석하며, export/import 구문을 관리합니다.
Chunk출력물관된 모듈들을 하나의 파일로 그룹화하고, 생성된 청크들 사이의 의존성을 처리합니다.
ModuleLoader모듈 해석실제 파일을 로드하고, import 된 파일의 경로를 해석하며, 메모리 상에 모듈 인스턴스를 생성합니다.
PluginDriver플러그인 관리빌드 프로세스에 맞춰 플러그인 훅을 실행하고, 여러 플러그인 간의 실행 순서와 동작을 조율합니다.
ExternalModule외부 의존성최종 번들 결과물에 포함시키지 않고 외부 참조로 남겨두는 모듈

번들링 동작 원리

전체적인 빌드 과정은 아래와 같습니다.

rollup bundling process

그리고 이 빌드 과정에서 크게 Build Phase(빌드 단계) 와 Generate Phase(생성 단계) 두 단계로 나뉩니다.

Build Phase

빌드 단계는 최상위 rollup(inputOptions) 함수에 의해 시작되며 Graph.build() 메서드를 통해 수행됩니다. 이 단계가 끝나면 generate 혹은 wirte 를 포함한 bundle 객체를 반환합니다.

// src/Graph.ts
class Graph {
  // 생략
	async build(): Promise<void> {
		timeStart('generate module graph', 2);
		await this.generateModuleGraph(); // 모듈 그래프 생성
		timeEnd('generate module graph', 2);

		timeStart('sort and bind modules', 2);
		this.phase = BuildPhase.ANALYSE;
		this.sortModules(); // 모듈 정렬
		timeEnd('sort and bind modules', 2);

		timeStart('mark included statements', 2);
		this.includeStatements(); // 번들에 포함될 구문을 결정(Tree-shaking)
		timeEnd('mark included statements', 2);

		this.phase = BuildPhase.GENERATE;
	}
	// 생략
}
  1. 모듈 그래프 생성

    module Loader 는 모든 모듈을 로드하고 파싱하는 역할을 담당합니다. 진입점에서 시작하여 각 모듈의 종속성을 파악하고 재귀적으로 로드합니다.각 모듈에 대해 Rollup은 resolvedId, load, transform 플러그인 훅을 순서대로 호출합니다.

    // src/Graph.ts
    class Graph {
      // 생략  
    	private async generateModuleGraph(): Promise<void> {
    		({ entryModules: this.entryModules, implicitEntryModules: this.implicitEntryModules } =
    			await this.moduleLoader.addEntryModules(normalizeEntryModules(this.options.input), true)); // module Loader를 통해 AST 파싱 및 의존성 분석
    		if (this.entryModules.length === 0) {
    			throw new Error('You must supply options.input to rollup');
    		}
    		for (const module of this.modulesById.values()) {
    			module.cacheInfoGetters();
    			if (module instanceof Module) {
    				this.modules.push(module);
    			} else {
    				this.externalModules.push(module);
    			}
    		}
    	}
    	// 생략
    }
  2. 모듈 분석 및 정렬
    전체 모듈 그래프가 완성된 후, sortModules 를 통해 모듈을 정렬합니다. 이는 모듈이 올바른 종속성 순서에 따라 처리되도록 보장합니다.

// src/Graph.ts
class Graph {
  // 생략
	private sortModules(): void {
		const { orderedModules, cyclePaths } = analyseModuleExecution(this.entryModules); // 먼저 실행될 모듈 및 순환 참조 모듈 분석
		for (const cyclePath of cyclePaths) {
			this.options.onLog(LOGLEVEL_WARN, logCircularDependency(cyclePath));
		}
		this.modules = orderedModules;
		for (const module of this.modules) {
			module.bindReferences(); // AST들이 실제로 어떤 모듈에서 import되고 어디서 선언되었는지 연결
		}
		this.warnForMissingExports();
	}
	// 생략
}
  1. Tree-Shaking
    모듈이 정렬되면 Rollup은 정적 분석을 수행하여 실제로 사용되는 내보내기(exports)와 구문(statements)이 무엇인지 파악하고, 포함할 항목만 마킹하는 트리 쉐이킹을 진행합니다.
// src/Graph.ts
class Graph {
  // 생략
	private includeStatements(): void {
		const entryModules = [...this.entryModules, ...this.implicitEntryModules];
		for (const module of entryModules) {
			markModuleAndImpureDependenciesAsExecuted(module); // side Effect가 있는 모듈과 없는 모듈 분리
		}
		if (this.options.treeshake) {
			let treeshakingPass = 1;
			this.newlyIncludedVariableInits.clear();
			do { // 트리셰이킹 알고리즘
				timeStart(`treeshaking pass ${treeshakingPass}`, 3);
				this.needsTreeshakingPass = false;
				for (const module of this.modules) {
					if (module.isExecuted) {
						module.hasTreeShakingPassStarted = true;
						if (module.info.moduleSideEffects === 'no-treeshake') {
							module.includeAllInBundle();
						} else {
							module.include();
						}
						for (const entity of this.newlyIncludedVariableInits) {
							this.newlyIncludedVariableInits.delete(entity);
							entity.include(createInclusionContext(), false);
						}
					}
				}
				if (treeshakingPass === 1) {
					// We only include exports after the first pass to avoid issues with
					// the TDZ detection logic
					for (const module of entryModules) {
						if (module.preserveSignature !== false) {
							module.includeAllExports(false);
							this.needsTreeshakingPass = true;
						}
					}
				}
				timeEnd(`treeshaking pass ${treeshakingPass++}`, 3);
			} while (this.needsTreeshakingPass);
		} else {
			for (const module of this.modules) module.includeAllInBundle();
		}
		for (const externalModule of this.externalModules) externalModule.warnUnusedImports();
		for (const module of this.implicitEntryModules) {
			for (const dependant of module.implicitlyLoadedAfter) {
				if (!(dependant.info.isEntry || dependant.isIncluded())) {
					error(logImplicitDependantIsNotIncluded(dependant));
				}
			}
		}
	}
	// 생략
}

Generate Phase

class Bundle {
	// 생략
	async generate(isWrite: boolean): Promise<OutputBundle> {
			timeStart('GENERATE', 1);
			const outputBundleBase: OutputBundle = Object.create(null);
			const outputBundle = getOutputBundle(outputBundleBase);
			this.pluginDriver.setOutputBundle(outputBundle, this.outputOptions);
	
			try {
				timeStart('initialize render', 2);
	
				await this.pluginDriver.hookParallel('renderStart', [this.outputOptions, this.inputOptions]);
	
				timeEnd('initialize render', 2);
				timeStart('generate chunks', 2);
	
				const getHashPlaceholder = getHashPlaceholderGenerator();
				const chunks = await this.generateChunks(outputBundle, getHashPlaceholder); // 실제 출력될 청크 (청크 할당/청크 연결)
				if (chunks.length > 1) {
					validateOptionsForMultiChunkOutput(this.outputOptions, this.inputOptions.onLog); // IIFE, UMD 등 멀티 청크 출력을 지원하지 않는 포맷에 대해 유효성 검사
				}
				this.pluginDriver.setChunkInformation(this.facadeChunkByModule);
				for (const chunk of chunks) {
					chunk.generateExports();
					chunk.inlineTransitiveImports();
				}
	
				timeEnd('generate chunks', 2);
	

				await renderChunks( // AST였던 각 청크를 실제 자바스크립트 문자열 코드로 렌더링 및 소스맵 결합
					chunks,
					outputBundle,
					this.pluginDriver,
					this.outputOptions,
					this.inputOptions.onLog
				);
			} catch (error_: any) {
				await this.pluginDriver.hookParallel('renderError', [error_]);
				throw error_;
			}
	
			removeUnreferencedAssets(outputBundle); // 참조되지 않은 에셋 정리
	
			timeStart('generate bundle', 2);
	
			await this.pluginDriver.hookSeq('generateBundle', [
				this.outputOptions,
				outputBundle as OutputBundle,
				isWrite
			]);
			this.finaliseAssets(outputBundle);
	
			timeEnd('generate bundle', 2);
			timeEnd('GENERATE', 1);
			return outputBundleBase;
		}
	// 생략
	private async generateChunks(
		bundle: OutputBundleWithPlaceholders,
		getHashPlaceholder: HashPlaceholderGenerator
	): Promise<Chunk[]> {
		const { experimentalMinChunkSize, inlineDynamicImports, manualChunks, preserveModules } =
			this.outputOptions; // 청크 분할 전략
		const manualChunkAliasByEntry = // 청크 설정
			typeof manualChunks === 'object'
				? await this.addManualChunks(manualChunks) // 객체 설정
				: this.assignManualChunks(manualChunks); // 함수 설정
		const snippets = getGenerateCodeSnippets(this.outputOptions);
		const includedModules = getIncludedModules(this.graph.modulesById);
		const inputBase = commondir(getAbsoluteEntryModulePaths(includedModules, preserveModules));
		const externalChunkByModule = getExternalChunkByModule(
			this.graph.modulesById,
			this.outputOptions,
			inputBase
		);
		const executableModule = inlineDynamicImports // 청크 할당
			? [{ alias: null, modules: includedModules }] // 모든 모듈을 1개의 거대한 청크로 병합
			: preserveModules
				? includedModules.map(module => ({ alias: null, modules: [module] })) // 모든 모듈을 각각 1:1로 쪼개어 원본 파일 구조 유지
				: getChunkAssignments( // 엔트리 포인트, 동적 임포트, manualChunks 규칙에 따라 그룹핑
						this.graph.entryModules,
						manualChunkAliasByEntry,
						experimentalMinChunkSize,
						this.inputOptions.onLog
					);
		const chunks: Chunk[] = new Array(executableModule.length);
		const chunkByModule = new Map<Module, Chunk>();
		let index = 0;
		for (const { alias, modules } of executableModule) {
			sortByExecutionOrder(modules); // 청크내 모듈 순서 정렬
			const chunk = new Chunk(
				modules,
				this.inputOptions,
				this.outputOptions,
				this.unsetOptions,
				this.pluginDriver,
				this.graph.modulesById,
				chunkByModule,
				externalChunkByModule,
				this.facadeChunkByModule,
				this.includedNamespaces,
				alias,
				getHashPlaceholder,
				bundle,
				inputBase,
				snippets
			);
			chunks[index++] = chunk;
		}
		for (const chunk of chunks) {
			chunk.link(); // 청크간 연결
		}
		const facades: Chunk[] = [];
		for (const chunk of chunks) {
			facades.push(...chunk.generateFacades());
		}
		return [...chunks, ...facades];
	}
	// 생략
}
  1. 청크 할당 (Chunk Assignment)

    Rollup은 어떤 모듈이 어떤 청크에 속할지 결정합니다. 진입 모듈은 각각 고유한 청크를 할당받으며, 공통으로 사용되는 종속성은 별도의 청크로 분리될 수 있습니다.

  2. 청크 연결 (Chunk Linking)

    청크 할당 후, Rollup은 청크 간의 가져오기(imports) 및 내보내기(exports) 관계를 설정하고 여러 청크에 걸쳐 변수명이 충돌하지 않도록 조정(deconflicting)하여 청크들을 서로 연결합니다.

  3. 청크 렌더링 (Chunk Rendering)
    Chunk는 자신이 포함한 모듈들을 최종 출력 코드로 렌더링합니다. 이 단계에서 renderChunk와 같은 플러그인 훅이 호출됩니다.

AST 파싱 방식

Rollup은 WebAssembly(Rust)내에서 AST 파싱을 수행합니다. 현재 Rollup의 대부분은 여전히 TypeScript 기반이므로, 파싱된 결과물을 JavaScript 표현으로 변환해야 합니다. 효율적인 처리를 위해 Rust에서 AST 결과를 JSON 객체로 직렬화하여 복사하여 JS로 넘기는 대신 바이너리 버퍼를 생성하여 TypeScript로 전달합니다.

버퍼로의 변환은 주로 SWC기반의 converter.rs에서 처리되며, 버퍼가 ESTree 형식을 따르도록 합니다.

마치며

지금까지 Rollup이 어떻게 소스코드를 분석하고, 다시 하나의 파일로 만들어내는지, 그리고 성능을 끌어올리기 위해 내부적으로 어떤 파서를 쓰고 있는지 가볍게 살펴보았습니다.

사실 Vite 팀은 차세대 Rollup으로 Rolldown을 개발하고 있는데, 아직 Vite 8이 안정화 되기 전까지는 Vite 8 이전 버전이 사용될 것 같아서 Rollup을 잘 활용해보기 위해 다음 글에서는 Rollup의 AST 파싱 방식을 보다 깊이 알아보려고 합니다.

profile
잘 하고 싶어요

0개의 댓글