[Typescript] enum vs const enum vs const assertions

이민선(Jasmine)·2024년 1월 6일
1
post-thumbnail

enum의 대체재로는 const enum과 as const를 사용하여 정의하는 const assertions가 있다. 이 3가지를 비교해서 포스팅을 해보려고 한다.

type ScaleType = 'C' | 'D' | 'E' | 'F' | 'G' | 'A' | 'B'

음악에서 사용하는 음계의 타입을 나타낸 것이다. 음계를 배워본 사람이면 익숙할테지만, 아닌 사람도 있을 수 있다.

이 때 오늘 배워볼 enum, const enum, const assertions를 사용하면 key를 명확하게 나타낼 수 있다는 장점이 있다.

// enum
enum Scale {
  Do = 'C',
  Re = 'D',
  Mi = 'E',
  Fa = 'F',
  Sol = 'G',
  La = 'A',
  Ti = 'B'
}
// const enum
const enum Scale {
  Do = 'C',
  Re = 'D',
  Mi = 'E',
  Fa = 'F',
  Sol = 'G',
  La = 'A',
  Ti = 'B'
}
// const assertions
const Scale = {
  Do: "C",
  Re: "D",
  Mi: "E",
  Fa: "F",
  Sol: "G",
  La: "A",
  Ti: "B",
} as const;

union type과 비교했을 때, 위의 셋 다 key를 나타내주니 음악 초심자도 무슨 음계를 의미하는지 곧바로 알 수 있다는 장점이 있다는 건 알겠다. 근데 비슷해보이는데 이 3개가 뭐가 달라서 구분이 되는걸까?

js(ES5)로 컴파일된 이후에 어떻게 될까?

enum의 경우에는 즉시실행함수로 변환되고, const enum의 경우에는 인라인 치환된다.

이게 무슨 뜻인지 하나씩 자세히 살펴보자.

example.ts에 위의 enum 코드를 입력하고

tsc example.ts

를 입력하면 자바스크립트로 컴파일된 파일이 생성된다.

// enum
var Scale;
(function (Scale) {
    Scale["Do"] = "C";
    Scale["Re"] = "D";
    Scale["Mi"] = "E";
    Scale["Fa"] = "F";
    Scale["Sol"] = "G";
    Scale["La"] = "A";
    Scale["Ti"] = "B";
})(Scale || (Scale = {}));

enum의 경우 즉시 실행 함수로 변환된다.
코드량이 꽤 많아지는 걸 볼 수 있다. 뚱뚱하군.
그런데 이건 value가 string이니까 그나마 코드량이 아주 많지는 않다.
만약 value가 숫자형이라면 자바스크립트로 컴파일했을 때 코드량이 더 많아진다.

만약 아래와 같은 코드를 자바스크립트로 컴파일한다면,

enum Scale {
  Do,
  Re,
  Mi,
  Fa,
  Sol,
  La,
  Ti,
}

아래와 같이 변한다.

var Scale;
(function (Scale) {
    Scale[Scale["Do"] = 0] = "Do";
    Scale[Scale["Re"] = 1] = "Re";
    Scale[Scale["Mi"] = 2] = "Mi";
    Scale[Scale["Fa"] = 3] = "Fa";
    Scale[Scale["Sol"] = 4] = "Sol";
    Scale[Scale["La"] = 5] = "La";
    Scale[Scale["Ti"] = 6] = "Ti";
})(Scale || (Scale = {}));

이게 무슨 뜻이냐면,

const Scale = {
    Do: 0,
    Re: 1,
    Mi: 2,
    Fa: 3,
    Sol: 4,
    La: 5,
    Ti: 6,
    0: "Do",
    1: "Re",
    2: "Mi",
    3: "Fa",
    4: "Sol",
    5: "La",
    6: "Ti"
};

이렇게 value가 숫자형일 때는 양방향 매핑이 구현되기 때문에 자바스크립트로 컴파일했을 때 코드량이 더 많아지는 것이다.
양방향 매핑이 필요할 때 쓸 수 있다는 건 장점이 되겠지만, 그만큼 컴파일된 코드량이 많아진다는 건 enum의 단점으로 꼽을 수 있겠다.

그렇다면 const enum은 어떻게 될까?

// const enum
// (어디 갔니..?)

사라진다..

원래 타입스크립트 특: 자바스크립트로 컴파일하면 사라진다. (언어가 아니라 자바스크립트의 superset임을 기억하자.)

사실 enum이 특이한 케이스이고, const enum은 타입스크립트의 타입이므로 자바스크립트로 컴파일하면 사라진다. (다만 tsconfig.json에서 PreserveConstEnums 플래그를 사용하면 const enum이 컴파일 결과에 포함될 수도 있다고 한다.)
enum은 몇 배로 코드량이 늘어났는데, const enum은 아예 사라져버리니 컴파일했을 때 코드가 가벼워지는 건 enum과 비교했을 때 장점이라고 볼 수 있겠다.

그럼 const enum이 인라인 치환된다는 건 무슨 의미일까?
아래와 같이 const enum 내의 값을 변수에 할당한 후 컴파일하면,

const enum Scale {
  Do,
  Re,
  Mi,
  Fa,
  Sol,
  La,
  Ti,
}

const secondScale: Scale = Scale.Re;

이렇게 type에 해당하는 부분은 사라지고, Scale.Re로 const enum의 값을 참조한 부분은 숫자로 대체된다. (친절하게 주석도 달아준다.) 이것을 인라인 치환된다고 표현한다.

var secondScale = 1 /* Scale.Re */;

코드량이 확 줄어드는 건 장점이지만, enum과 달리 Object.keys, Object.entries로 순회를 하는 등 런타임에서 동적으로 참조하는 건 불가능하다.
아래 코드는 에러를 내 뿜는다.

const enum Scale {
  Do,
  Re,
  Mi,
  Fa,
  Sol,
  La,
  Ti,
}

for (const note in Scale) { // 에러!!!
  console.log(note);
}

const assertions는 자바스크립트 코드이기 때문에 as const가 사라지고 const가 var로 바뀌는 것 외에는 별로 바뀌는 게 없다. 컴파일 후 용량 측면에서만 봤을 때는 enum보다는 낫다고 할 수 있겠다.

// const assertions
var Scale = {
    Do: "C",
    Re: "D",
    Mi: "E",
    Fa: "F",
    Sol: "G",
    La: "A",
    Ti: "B"
};

tree-shaking이 될까?

enum의 경우에는 tree-shaking이 되지 않는 것으로 알려져있고, const enum은 반대로 tree-shaking이 되는 것으로 알려져있다. (최소한 나에게는?.. 그렇게 알려져있었다)

그런데 검색해본 결과, enum의 경우에는 tree-shaking되는지 여부가 번들러마다 다르고, 번들러의 버전마다도 다르다고 한다.

그래서 한번 실험을 해봤다.
(실험 결과는 환경에 의존할 수 있음을 다시 한 번 밝힙니다.)

// enum.ts
enum Scale {
  Do,
  Re,
  Mi,
  Fa,
  Sol,
  La,
  Ti,
}
export default Scale;

타입스크립트 코드를 자바스크립트로 번들링해서 직접 확인해보는 건 처음해본다. 설정할 게 아주 많군.
webpack.config.js부터 시작해서 tsconfig.json까지 적절하게 설정을 해주어야 번들링이 가능하다. (다음번에는 tsconfig로 포스팅해볼까? 의식의 흐름)

  1. enum.ts에 위의 코드를 작성한다.
  2. App.tsx에서 위의 enum을 import 한다.
  3. enum을 참조할 때와 참조하지 않을 때 각각 build 해본다.
  4. 각각 dist/main.js에 enum이 남아있는지 확인해본다.
// App.tsx
import Enum from "./enum";
function App() {
  // enum을 App.tsx에서 참조하고 있다.
  for (const key in Enum) {
    console.log(key);
  }
  //...

홀리몰리,,,
참조하고 있을 때는 예상한대로 dist/main.js에 자바스크립트 파일이 그대로 남아있다.
그럼 참조하고 있지 않을 때에는 tree-shaking이 될까?

import Enum from "./enum";
function App() {
  // for (const key in Enum) {
  //   console.log(key);
  // }
  //...

주석 처리를 하고 main.js를 확인해봤더니 남아있는 enum이 없다. 즉, tree-shaking이 된 것이다!
난 또 무조건 enum은 tree-shaking이 안되는 줄로 알고 있었는데, 눈으로 된다는 것을 직접 확인..!하였다.

물론 여기에서 tree-shaking이 되었다고 enum은 원래 tree-shaking이 된다고 결론을 내릴 순 없다. webpack, RollUp 등 모듈 번들러의 종류나 모듈 번들러의 버전도 tree-shaking에 영향을 미치지만, typescript의 버전이나 tsc의 버전도 영향을 미칠 수 있다고 한다. 그러므로.. enum은 tree-shaking될 수도 있고 안될 수도 있다고 결론을 내리면 되겠다.

const enum의 경우에는 tree-shaking이 된다고 알려져있는데, 공부하다보니 의문이 들었다. 자바스크립트로 컴파일이 되면 인라인 치환되고 const enum은 사라지는데, tree-shaking될 코드 자체도 안남아 있지 않나? 그렇다. 컴파일 후가 됐던 쓰지 않는 코드가 됐던 번들링 이후코드 자체가 안남아있기 때문에 뭉뚱그려서 'enum과 다르게 tree-shaking된다'고 알려져있는 것이었다. (나만 그렇게 알고 있는 거였나..?)

변수에 key 할당 시 type을 어떻게 쓰나요? (union type 처럼 사용하기)

enum과 const enum의 경우, 그 자체를 union type으로 쓸 수 있다.
즉, 아래 2개 코드는 enum이나 const enum의 key를 할당하는 변수에 직접 타입으로 지정할 수 있다.

enum Scale {
  Do = "C",
  Re = "D",
  Mi = "E",
  Fa = "F",
  Sol = "G",
  La = "A",
  Ti = "B",
}
const firstScale: Scale = Scale.Do;
const enum Scale {
  Do = "C",
  Re = "D",
  Mi = "E",
  Fa = "F",
  Sol = "G",
  La = "A",
  Ti = "B",
}
const firstScale: Scale = Scale.Do;

마치 firstScale 변수에 아래 union type을 할당해주는 것과 같다.

type Scale = 'C' | 'D' | 'E' | 'F' | 'G' | 'A' | 'B'

그런데 const assertions를 위와 같은 용도로 사용할 수는 없다. 고로 추가적인 type을 생성해야 하는데..

const Scale = {
  Do: "C",
  Re: "D",
  Mi: "E",
  Fa: "F",
  Sol: "G",
  La: "A",
  Ti: "B",
} as const;

// 이 부분을 추가적으로 지정해줘야 한다. 
type ScaleType = (typeof Scale)[keyof typeof Scale];
const firstScale: ScaleType = Scale.Do;

헉.. 가독성도 안좋고 일일히 사용하기 번거롭게 생겼다.
뭐라는지 한참 쳐다보면서 해석해봤다.
여기서 (typeof Scale)이 object인줄 알고 헤맸는데,
알고보니 as const로 선언했기 때문에 객체 내의 각 프로퍼티 값이 리터럴 타입으로 취급되는 객체 타입 그 자체인 것이다.
즉, typeof Scale은 object가 아니라

const Scale = {
  Do: "C",
  Re: "D",
  Mi: "E",
  Fa: "F",
  Sol: "G",
  La: "A",
  Ti: "B",
}

'Scale 객체의 구조를 기반으로 생성된 객체'라는 타입 그 자체가 되는 것이다!! 즉, 객체 내부의 key들의 각 값들은 리터럴 타입으로 되어 있고, 그 구조를 기반으로 추론된 객체 타입이다. (구조를 기반으로 객체의 타입이 생성된다고 하니, 구조적 타이핑이 떠오른다.)

그럼 이제 해석이 좀 될 것 같다.
keyof로 Scale이라는 타입에서 key만 union으로 뽑아낸다.
즉, 이런 코드를 나타낸 것이라고 이해해볼 수 있다.

type ScaleType = (typeof Scale)['Do' | 'Re' | 'Mi' | 'Fa' | 'Sol' | 'La' | 'Ti' ];

결과적으로,

type ScaleType = "C" | "D" | "E" | "F" | "G" | "A" | "B";

가 되는 것이다.

여튼 typeof Scale이 Scale이라는 타입 그 자체인 걸 뒤늦게 깨달아서 해석하는데 오래 걸렸고, 다시 봐도 이렇게 한 번 해석을 거쳐야 하는 가독성 낮은 코드를 선언할 생각을 하니 갑자기 눈앞이 아득해진다. const assertions를 사용할 때의 단점으로 꼽을 수 밖에 없을 것 같다. (enum이나 const enum을 사용하면 타입으로 바로 지정할 수 있다는 걸 생각하면..)

결론

컴파일 때 용량이나 혹여나 안될수도 있는 tree-shaking만 단편적으로 고려했을 때에는 const enum > const assertions > enum 순으로 좋다고 생각할 수도 있겠다. 하지만 const enum은 아예 컴파일 후 사라져버리는 만큼 동적으로 참조하는 것이 불가능하다는 단점이 있다. 프로젝트에서 동적으로 참조하지 않을 자신이 있으면(?) const enum을 쓰는 것도 좋겠지만, 번들 용량을 극한으로 줄여야 하는 상황이 아니라면 동적 참조의 가능성을 열어두고 작업할 수 있도록 그냥 enum을 사용하는 것도 방법이 되겠다.
물론 const assertions도 동적으로 참조할 수 있고, 컴파일이나 번들링 후 용량이 enum보다 적다는 장점이 있다. 하지만 위에서 본 것처럼 key 자체를 변수에 할당할 때, 해당 변수에 type 자체를 값으로 주는 것이 불가능하다는 단점이 있었다.
따라서 프로젝트에서 구성원 간 충분한 논의를 거치고, 필요에 따라 컨벤션을 정하는 것이 가장 좋겠다.

참고:
https://yogjin.tistory.com/60
https://techblog.woowahan.com/9804/#toc-3

profile
기록에 진심인 개발자 🌿

0개의 댓글