Bundler에서의 Enum Tree Shaking 문제

IIFE로 컴파일되는 enum이 번들 크기를 키우는 이유

작성일: 2024. 10. 14최종 수정: 2024. 10. 148 min

개요

TypeScript의 enum을 사용하면 번들 크기가 예상보다 커지는 현상이 발견되었다. 특히 미사용 enum이 최종 번들에 포함되어 tree shaking이 제대로 동작하지 않는 문제였다. 더 흥미로운 점은 이 현상이 번들러마다 다르게 나타난다는 것이다.

문제의 원인

enum이 컴파일되는 방식

문제의 근본 원인은 TypeScript enum의 컴파일 방식에 있다. enum은 JavaScript의 네이티브 기능이 아니기 때문에, TypeScript 컴파일러가 이를 IIFE(즉시 실행 함수)로 변환한다.

typescript
// TypeScript 코드
export enum MOBILE_OS {
  IOS = 'iOS',
  ANDROID = 'Android'
}

위 코드는 다음과 같이 컴파일된다.

javascript
// 컴파일된 JavaScript
export var MOBILE_OS;
(function (MOBILE_OS) {
    MOBILE_OS["IOS"] = "iOS";
    MOBILE_OS["ANDROID"] = "Android";
})(MOBILE_OS || (MOBILE_OS = {}));

왜 IIFE가 문제인가

IIFE는 즉시 실행되는 함수이기 때문에 번들러가 이를 "부수 효과(side effect)가 있는 코드"로 판단한다.

부수 효과(Side Effect)란?
함수가 반환값 외에 외부에 영향을 미치는 모든 동작을 의미한다. 전역 변수를 수정하거나, DOM을 조작하거나, CSS 파일을 import하거나, 이벤트 리스너를 등록하는 것이 대표적이다. 번들러는 부수 효과가 있는 코드를 제거하면 프로그램 동작이 달라질 수 있으므로 함부로 제거할 수 없다.

enum의 IIFE 패턴에서 특히 문제가 되는 부분은 (MOBILE_OS || (MOBILE_OS = {})) 구문이다. 이 코드는 외부 변수 MOBILE_OS를 읽고, 값이 없으면 빈 객체로 초기화한다. 번들러 입장에서는 외부 상태를 변경하는 코드로 보인다. 실제로 MOBILE_OS를 사용하지 않더라도 번들러는 "이 코드를 제거하면 프로그램 동작이 달라질 수 있다"고 판단한다. 그래서 안전을 위해 코드를 그대로 남긴다.

이 문제는 TypeScript의 Issue #27604에서 2018년부터 논의되고 있다. 해결 방법으로 /*#__PURE__*/ 주석을 IIFE 앞에 추가하여 "이 함수는 순수 함수이며 부수 효과가 없다"고 명시하는 방안이 제안되었다. 이렇게 하면 번들러가 안전하게 제거할 수 있지만, 아직 TypeScript 컴파일러 자체에는 반영되지 않았다.

번들러별 동작 비교

세 가지 주요 번들러는 enum tree shaking을 서로 다르게 처리한다.

Vite (Rollup 기반)

Vite의 경우, 미사용 enum과 함수가 완벽히 제거되었다. 최종 빌드에는 실제 사용되는 코드만 포함되어 가장 효율적인 번들 크기를 달성했다.

javascript
// Vite 빌드 결과 - 미사용 ENUM_TEST는 제거됨
const ENUM_COUNT = { ONE: 1, TWO: 2 };
console.log(ENUM_COUNT.ONE);

Webpack

Webpack의 경우, /* unused harmony exports ENUM_TEST, result */ 주석으로 미사용 코드를 표시하지만 실제 코드는 제거하지 않았다. 모듈 캐시 형태로 전체 enum 파일을 require하는 방식을 사용했다.

javascript
// Webpack 빌드 결과 - 미사용 코드도 포함됨
/* unused harmony exports ENUM_TEST, result */
const ENUM_TEST = { ... };  // 사용하지 않지만 남아있음
const ENUM_COUNT = { ... };

Webpack은 usedExports 최적화를 통해 미사용 export를 표시하지만, 실제 제거는 Terser 같은 minifier에게 위임한다. 문제는 Terser가 IIFE의 부수 효과를 확신할 수 없어서 보수적으로 코드를 유지한다는 점이다.

왜 이런 차이가 발생하는가

같은 TypeScript enum 코드를 빌드했는데 결과가 다른 이유를 이해하려면, 두 번들러가 실제로 어떤 코드를 생성했는지 비교해야 한다.

원본 TypeScript 코드

typescript
// enums.ts
export enum USED_ENUM { VALUE_A = 'A', VALUE_B = 'B', VALUE_C = 'C' }
export enum UNUSED_ENUM { VALUE_X = 'X', VALUE_Y = 'Y', VALUE_Z = 'Z' }

// main.ts
import { USED_ENUM } from './enums';
console.log(USED_ENUM.VALUE_A);

Vite가 생성한 코드

javascript
var o=(t=>(t.VALUE_A="A",t.VALUE_B="B",t.VALUE_C="C",t))(o||{});
console.log("USED_ENUM.VALUE_A:",o.VALUE_A);
// UNUSED_ENUM은 어디에도 없음

Webpack이 생성한 코드

javascript
var A,o;
!function(A){A.VALUE_A="A",A.VALUE_B="B",A.VALUE_C="C"}(A||(A={})),
function(A){A.VALUE_X="X",A.VALUE_Y="Y",A.VALUE_Z="Z"}(o||(o={})),
console.log("USED_ENUM.VALUE_A:",A.VALUE_A);
// UNUSED_ENUM(o)도 포함됨

두 번들 모두 enum을 IIFE로 변환했다. 그런데 Vite는 미사용 IIFE를 제거했고, Webpack은 그대로 남겼다. 이 차이는 어디서 오는 걸까?

Rollup/Vite의 전략

Rollup은 "사람이 작성한 것처럼 보이는 최대한 효율적인 번들"을 목표로 한다. ES 모듈의 정적 구조를 분석하여 사용되지 않는 export를 추적하고, 해당 IIFE가 참조되지 않으면 제거한다. 실제로 UNUSED_ENUM은 import되지도, 사용되지도 않았으므로 Rollup은 이 IIFE를 안전하게 제거할 수 있다고 판단했다.

Webpack의 전략

Webpack은 usedExports 최적화로 미사용 export를 표시하지만, 실제 제거는 Terser에게 위임한다. Terser는 IIFE 패턴을 보고 "이 코드가 외부 상태를 변경할 수도 있다"고 보수적으로 판단한다. sideEffects: false를 명시적으로 선언하지 않는 한, 안전을 위해 IIFE를 보존한다.

흥미로운 점은 두 번들러 모두 unusedFunction은 제거했다는 것이다. 일반 함수는 명확히 부수 효과가 없다고 판단할 수 있지만, IIFE는 즉시 실행되므로 더 신중하게 처리한다. Rollup은 이런 경우에도 사용 여부를 추적해서 제거하지만, Webpack/Terser는 보수적으로 유지한다.

bundler 테스트

레퍼런스들에서 설명한 내용이 실제로 그런지 직접 확인해보자. Vite와 Webpack 최신 버전으로 동일한 코드를 빌드하여 번들 결과를 비교한다.

테스트 환경 구성

간단한 테스트 케이스를 작성한다. 여러 개의 enum을 정의하되 일부만 실제로 사용하는 코드다.

image.png image.png

이 코드를 Vite 최신 버전에서 빌드하면 UNUSED_ENUMunusedFunction이 제거되는지 확인할 수 있다.

Vite 빌드 결과 뜯어보기

테스트 환경은 Vite v7.2.2이다. npm run build를 실행하면 다음과 같이 빌드된다.

plain
vite v7.2.2 building client environment for production...
✓ 5 modules transformed.
dist/assets/index-DXZEMrmh.js   1.04 kB │ gzip: 0.58 kB
✓ built in 42ms
image.png

빌드된 JavaScript 파일을 열어보면 다음과 같다.

image.png

번들 파일을 살펴보면 흥미로운 결과를 발견할 수 있다. 31번 라인에 var o=(t=>(t.VALUE_A="A",t.VALUE_B="B",t.VALUE_C="C",t))(o||{}) 형태로 압축된 IIFE가 보이는데, 이것이 USED_ENUM이다. 반면 UNUSED_ENUM은 어디에도 존재하지 않는다. 함수도 마찬가지다. function c(t){return...} 형태로 usedFunction은 남아있지만, unusedFunction은 흔적도 없다.

번들 파일 전체에서 "UNUSED"나 "unused" 문자열을 검색해도 아무것도 나오지 않는다. Vite는 enum에 대한 tree shaking을 완벽하게 수행했다.

Webpack 빌드 결과 뜯어보기

같은 코드를 Webpack v5.102.1에서 빌드해본다. optimization.usedExportsminimize를 모두 활성화한 production 모드다. Vite와 동일한 테스트 환경을 위해 CSS import와 DOM 조작 코드도 포함했다.

plain
> webpack

asset bundle.js 4.47 KiB [emitted] [minimized] (name: main)
webpack 5.102.1 compiled successfully in 517 ms
image.png

빌드된 파일을 열어보면, 대부분의 코드는 style-loader와 CSS 관련 모듈이다. 번들 말미에 실제 enum과 애플리케이션 코드가 위치한다.

image.png

놀랍게도 UNUSED_ENUM이 그대로 남아있다. 번들 파일을 검색하면 VALUE_X, VALUE_Y, VALUE_Z가 모두 포함되어 있음을 확인할 수 있다. unusedFunction은 제거되었지만, enum은 제거되지 않았다.

Webpack은 enum의 IIFE를 제거하지 못했다.

테스트 결과 정리

Vite v7.2.2는 enum tree shaking에 완벽하게 성공했다. 번들 크기는 1.04 kB다. 반면 Webpack v5.102.1은 enum tree shaking에 실패했다. UNUSED_ENUM이 번들에 포함되어 308 bytes를 차지한다. 코드가 단순해서 번들 크기 자체는 작지만 불필요한 enum이 포함되어 있다.

흥미로운 점은 두 번들러 모두 unusedFunction은 제거했다는 것이다. 일반 함수는 tree shaking이 작동하지만, enum의 IIFE 패턴은 Webpack에서 여전히 처리하지 못한다.

왜 Vite는 성공하고 Webpack은 실패하는가

같은 TypeScript enum 코드인데 번들러에 따라 결과가 다른 이유는 무엇일까?

Rollup은 특정 패턴의 IIFE를 인식하고 분석할 수 있다. TypeScript enum이 생성하는 IIFE 패턴은 충분히 예측 가능하므로, Rollup은 이를 "순수한 코드"로 판단하여 미사용 시 제거한다. 반면 Webpack의 Terser는 더 보수적으로 접근하여 IIFE를 부수 효과가 있는 코드로 간주한다.

결론적으로 Vite 환경에서는 enum을 사용해도 번들 크기 걱정을 덜 수 있다. 하지만 Webpack을 사용하는 프로젝트에서는 여전히 Union Types나 const enum으로 전환하는 것이 안전하다.

해결 방법

enum의 tree shaking 문제를 해결하려면 다음 방법들을 고려할 수 있다.

Union Types로 전환

모든 번들러에서 적용되는 방법은 enum 대신 객체와 Union Type을 사용하는 것이다.

typescript
// enum 대신 as const 객체 사용
const MOBILE_OS = {
  IOS: 'iOS',
  ANDROID: 'Android'
} as const;

// 타입 정의
type MOBILE_OS = typeof MOBILE_OS[keyof typeof MOBILE_OS];  // 'iOS' | 'Android'

// 사용 예시
function getDevice(os: MOBILE_OS) {
  return `Your device is ${os}`;
}

getDevice(MOBILE_OS.IOS);  // 타입 체크 완벽하게 작동

이 방식은 컴파일 후 순수한 객체만 남기 때문에 IIFE가 생성되지 않는다.

javascript
// 컴파일 결과
const MOBILE_OS = {
  IOS: 'iOS',
  ANDROID: 'Android'
};

객체는 번들러가 쉽게 분석할 수 있다. 미사용 시 완벽하게 tree shaking되며, Webpack과 Vite 모두에서 작동한다. 타입 안정성도 enum과 동일하게 유지되면서 런타임 overhead도 없다. 컴파일 결과가 간단하고 예측 가능하며, Babel과 TSC 모두 지원한다.

반면 enum보다 문법이 약간 복잡하다는 단점이 있다. 숫자 enum의 역방향 매핑도 불가능하지만, 문자열 enum에서는 어차피 역방향 매핑이 안 되므로 실질적인 제약은 아니다.

const enum 사용

typescript
const enum MOBILE_OS {
  IOS = 'iOS',
  ANDROID = 'Android'
}

console.log(MOBILE_OS.IOS);

const enum은 컴파일 시점에 인라인으로 대체되어 런타임 코드가 전혀 생성되지 않는다.

javascript
// 컴파일 결과 - enum이 완전히 사라짐
console.log("iOS");

완벽한 tree shaking처럼 보이지만 제약이 있다. 런타임 코드가 전혀 생성되지 않고 enum 문법을 그대로 사용할 수 있다는 장점이 있다. 반면 Babel에서 지원하지 않아 TypeScript 전용이며, --isolatedModules 옵션과 호환 문제가 있다. 다른 모듈에서 import할 때 문제가 발생할 수 있고, 값이 인라인되어 있어서 디버깅이 어렵다.

Vite로 번들러 전환

Webpack을 사용 중이라면 Vite로 전환하는 것도 방법이다. 실제 테스트에서 확인했듯이 Vite는 enum tree shaking을 완벽하게 지원한다. 기존 enum 코드를 수정하지 않고도 번들 크기를 최적화할 수 있고 빌드 속도도 향상된다. 다만 프로젝트 마이그레이션 비용이 들고 기존 Webpack 설정을 재구성해야 한다.

권장 전략

새 프로젝트라면 Vite를 사용하는 것이 좋다. Vite는 내부적으로 Rollup을 사용하므로 enum tree shaking이 잘 작동하고 빌드 속도도 빠르다. Vite 환경에서는 enum을 그대로 써도 되지만, Union Types를 쓰면 모든 빌드 툴에서 안전하고 더 명시적이다.

Webpack을 사용하는 기존 프로젝트는 점진적으로 Union Types로 마이그레이션한다. 레거시 프로젝트에서는 const enum을 고려할 수 있으나 제약 사항을 반드시 확인해야 한다.

정리

TypeScript enum은 IIFE로 컴파일되어 번들러가 부수 효과 있는 코드로 판단한다. 이로 인해 미사용 enum이 tree shaking되지 않는 문제가 발생한다.

번들러별로 처리 방식이 다르다:

  • Vite(Rollup): enum tree shaking 완벽 지원
  • Webpack: IIFE를 보수적으로 유지, tree shaking 실패

해결책으로는 Union Types(as const 객체)가 가장 범용적이다. Vite 환경이라면 enum을 그대로 써도 되지만, Webpack 프로젝트에서는 Union Types 전환을 권장한다.

References

Enum tree shaking

TypeScript enum의 Tree-shaking - Line Engineering

TypeScript Issue #27604 - Make generated codes from enum could be minified when not used

Webpack Tree Shaking Guide

Tree-shaking versus dead code elimination by Rich Harris

참고 링크