본문 바로가기

호기심 천국

TypeScript) enum vs as const

멍 때리는 참새. Unsplash 에 Saad Chaudhry 님이 올림.

들어가는 말

타입스크립트에서 상수의 묶음을 관리하는 방법에는 여러 가지가 있습니다. 그 중에서도 비슷하게 생긴 열거형(enum)과 as const를 이용한 const assertion에 대해 대략적으로 살펴봅시다.

열거형

타입스크립트 공식 문서에서 열거형은 '이름 있는 상수들의 집합을 정의하는 법'이라고 표현합니다.종류는 크게 숫자형과 문자열형 두 가지가 있습니다. 그 외에도 혼합형이나 계산되는(computed) 형태의 열거형이 존재할 수 있지만, 이 글에서는 이 두 가지에 집중해보고자 합니다.

숫자 열거형(numeric enums)

enum Season {
  SPRING,
  SUMMER,
  AUTUMN,
  WINTER,
}

 

숫자 열거형은 만들기가 참 쉽습니다. 그냥 이름만 쓰면 되거든요. 이게 '숫자'로 불리는 이유는 타입스크립트에서 자동으로 위에서부터 각 계절에 0, 1, 2, 3이라는 숫자를 할당해 주기 때문입니다. 만약 시작점을 다르게 잡는다면 거기부터 시작하구요. 공식 문서의 설명에 따르면 각 요소들의 값 자체에는 관심이 없지만, 열거형 내에서 다른 값들과 다르긴 하다는 사실에는 관심이 있을 때 좋다고 합니다. 이게 무슨 말이냐구요? 이건 숫자 열거형의 단점과 함께 보면 이해하기가 쉽습니다.

 

숫자 열거형의 단점은 디버깅에서 나타납니다. 우리가 어떤 변수에 할당된 열거형의 값을 볼 때, 숫자형은 당연하겠지만 숫자로 나옵니다. 0, 1, 2, 3 중 하나니까 일단 다른 값들과 다르다는 사실은 알 수 있습니다. 하지만 이 숫자들이 사실 어떤 계절인지는 알 수 없죠. 애초에 계절을 나타내는 게 맞는지도 모를 수 있습니다. 즉 각 요소들의 값은 무의미하지만, 그 요소들이 서로 다르다는 사실이 의미를 갖는 셈이죠. 그리고 중간에 값이 추가될 경우 숫자 값들이 뒤로 밀려서 이전과는 완전히 다른 값을 갖는다는 것도 문제일 수 있습니다. 각 요소들의 값이 무의미하기 때문에 별 문제가 없을 수도 있지만요.

 

숫자 열거형의 장점이자 문자열 열거형과의 가장 큰 차이는 reverse mapping이 가능하다는 것입니다.

 

enum NumericEnum {
  TEST = 0,
}

enum StringEnum {
  TEST = 'asdf'
}

const test1 = NumericEnum[0];
// @ts-expect-error:  Property 'asdf' does not exist on type 'typeof StringEnum'
const test2 = StringEnum['asdf'];

 

숫자 열거형은 숫자로 키를 찾을 수 있어요. 문자열 열거형은 안 됩니다. 왜 차별을 두었는지는 모르겠지만, 어떻게 차별하는지는 자바스크립트로 컴파일한 결과를 통해서 볼 수 있어요.

 

var NumericEnum;
(function (NumericEnum) {
    NumericEnum[NumericEnum["TEST"] = 0] = "TEST";
})(NumericEnum || (NumericEnum = {}));

var StringEnum;
(function (StringEnum) {
    StringEnum["TEST"] = "asdf";
})(StringEnum || (StringEnum = {}));

 

물론 reverse mapping 이라고 할 수 있을지는 모르겠는데 문자열 열거형도 key와 value를 똑같이 한다면 오류를 피할 수는 있어요.

 

enum StringEnum2 {
  TEST = 'TEST'
}

const test3 = StringEnum['TEST'];

문자열 열거형(string enums)

문자열 열거형은 숫자 열거형과는 달리 앞의 예제들처럼 key와 value를 하나하나 정성스럽게 수동으로 써 줘야 합니다. 만들 때는 조금 더 불편하죠. 공식 문서에서는 문자열 열거형의 장점을 직렬화가 쉽다는 것이라고 설명합니다. 위키피디아의 설명에 따르면 직렬화란 자료 구조나 객체를 저장 또는 전송할 수 있고, 다른 환경에서도 다시 재구축할 수 있게끔 변환하는 것이라고 합니다. JSON.Stringify()도 하나의 직렬화라고 할 수 있습니다. 자바스크립트 객체를 스토리지에 저장하거나 서버로 전송할 수 있고, 이 값들을 그대로 원본으로 복구할 수도 있으니까요. 하지만 문자열 열거형에서의 직렬화는 이런 기계끼리의 소통을 위한 건 아닌 것 같습니다. 제 생각에는 사람과의 소통을 위한 변환에 더 가깝다고 보여요. 왜냐하면 디버깅 상황에서 숫자 열거형과는 다르게 알아들을 수 있는 문자열이 나오기 때문에 범인을 찾기가 훨씬 쉽기 때문입니다. 피 모 씨는 '만물은 숫자다'라고 했다지만, 숫자를 보고 원래 열거형의 key를 찾는 것보다는 문자열을 보고 원래 열거형에서 key를 찾는 과정이 훨씬 쉽지 않을까요?

번외. 상수 열거형(const enums)

이 친구는 재밌습니다. 위쪽 예제처럼 우리가 열거형을 만들고 자바스크립트로 컴파일하면 열거형은 즉시 실행 함수가 되는데요. 상수 열거형은 그런 지저분한 행동을 하지 않습니다. 본인의 흔적을 싹 지워요.

 

const enum ConstEnum {
  TEST
}

const test4 = ConstEnum.TEST;

 

이런 코드는 아래처럼 바뀝니다.

 

const test4 = 0 /* ConstEnum.TEST */;

 

그냥 열거형이 사용된 부분을 본인의 값으로 박제해 버립니다. 코드 압축이나 메모리와 같은 최적화 측면에서 이점을 가집니다. 그렇다고 무조건 이게 좋은가 하면 그건 또 아닙니다. 하이 리스크 하이 리턴이라고 할 수 있겠네요. 만약 상수 열거형을 .d.ts 에 적고, 그걸 export 하여 여러 코드에서 공유하거나, 라이브러리 외부로 나가야 하는 값일 경우 위험합니다. 사용하는 쪽에서도 같은 value를 갖는 상수 열거형을 여러 개 사용한다면 컴파일 이후 값들이 겹쳐서 예상하지 못한 오류가 생길 수 있거든요. 괜히 성능이 좋은 게 아니었습니다.

const assertion

타입스크립트 초창기부터 함께했던 열거형과 달리 const assertion은 비교적?최근?인 3.4 버전에서 등장했습니다. 이 글에서는 객체에 적용하는 것만을 볼 생각입니다. 객체에 as const를 먹이면 객체는 읽기 전용으로 바뀝니다. 우리가 보통 const를 이용해서 상수를 선언하더라도 참조 타입인 객체의 경우에는 내부 값들을 사실 마음대로 주무를 수 있었는데요. 그걸 타입 차원에서 봉쇄하는 방법입니다.

 

const assertion 의 장점은 자바스크립트 객체와 형태가 같다는 것입니다. 만약 자바스크립트 코드를 타입스크립트로 옮기는 안타까운 경험을 해야 하는 중이라면 충분히 장점이 될 수 있다고 생각합니다. 물론 문제는 객체는 타입이 아니라는 것입니다. 열거형의 구성 요소들은 그 자체가 값이자 타입이 될 수 있지만, 객체의 구성 요소들은 그렇지 않거든요. 그래서 아래처럼 타입으로 다시 바꿔주는 코드가 더 필요하긴 합니다.

 

const SeasonObject = {
  SPRING: 'SPRING',
  SUMMER: 'SUMMER',
  AUTUMN: 'AUTUMN',
  WINTER: 'WINTER',
} as const;

type SeasonObjectType = keyof typeof SeasonObject;

const march: SeasonObjectType = SeasonObject.SPRING;

 

새로운 선언이 필요하다는 것은 새로운 이름이 필요하다는 것, 즉 또다른 창작의 고통을 야기할 수도 있겠군요.

그리고 이 코드는 key와 value를 완전히 똑같이 했기 때문에 객체의 key를 갖는 SeasonObjectType과 객체의 value인 SeasonObjectType.SPRING 이 서로 충돌을 일으키지 않을 뿐이지, 만약 값을 다르게 했다면 문제가 생길 수 있습니다.

enum vs as const

타입스크립트 공식 문서의 입장

공식 문서에서는 'as const로도 충분하다면 열거형이 필요 없을 수 있다'고 합니다. 그리고 가장 큰 이유로 딱 하나를 제시하는데요. 바로 코드베이스를 자바스크립트에 맞출 수 있다는 점이라고 합니다.

 

기본적으로 타입스크립트는 자바스크립트의 확대집합(superset)이라 자바스크립트에는 없는 기능들이 있을 수 있습니다. 하지만 타입스크립트의 타입은 사실 실제 동작하는 코드 로직과는 따로 노는 경향이 있습니다. const assertion의 예시가 그걸 잘 보여주는데요. 값과 타입이 서로 다른 곳에 위치합니다. 반대로 말하면 타입 관련된 코드만 싹 빼면 그대로 자바스크립트가 된다고 할 수도 있겠습니다. 이와 달리 열거형은 그 자체로 값이자 타입입니다. 변수에 할당할 수 있으면서 그 변수의 타입을 지정할 수도 있죠. 자바스크립트는 타입이 없어서 타입과 값의 융합이라는 모습을 상상조차 할 수 없습니다. 제 생각에는 이런 열거형의 특징 때문에 const assertion에 비해서 열거형이 자바스크립트 코드베이스와 더 멀다고 판단하지 않았나 싶습니다.

실제 사용할 때 차이

const assertion 은 '값'을 허용합니다. 하지만 enum은 같은 값이라도 본인의 구성원이 아니면 무조건 틀렸다고 하기 때문에 코드 작성 시 타입을 더 좁게 가져갈 수 있을 것 같아요. 반대로 구조적 타입 시스템을 이용해서 다른 객체의 공통 부분을 잇고 싶다면 const assertion 이 더 괜찮을 수도 있겠다는 생각이 듭니다. 근데 사실 큰 차이는 없어서 이건 진짜 취향 문제일 것 같긴 해요.

 

enum SeasonEnumType {
  SPRING = 'SPRING',
  SUMMER = 'SUMMER',
  AUTUMN = 'AUTUMN',
  WINTER = 'WINTER',
}

const SeasonObject = {
  SPRING: 'SPRING',
  SUMMER: 'SUMMER',
  AUTUMN: 'AUTUMN',
  WINTER: 'WINTER',
} as const;

type SeasonObjectType = keyof typeof SeasonObject;

const march1: SeasonObjectType = SeasonObject.SPRING;
const march2: SeasonObjectType = 'SPRING';

const march3: SeasonEnumType = SeasonEnumType.SPRING;
// @ts-expect-error: Type '"SPRING"' is not assignable to type 'SeasonEnumType'.
const march4: SeasonEnumType = 'SPRING';

 

에러를 직접 보고 싶으시다면 타입스크립트 플레이그라운드를 참고해 주세요!

Tree-shaking

사실 구글에 'enum vs as const' 를 검색하면 tree-shaking 을 근거로 열거형의 단점을 설명하는 글들이 굉장히 많습니다. 정말일까요?

 

이 글을 쓰기 위해 사용한 코드는 깃허브 레포지토리에 올려두었습니다.

 

// asConst.ts
export const SeasonObject = {
  SPRING: 'SPRING',
  SUMMER: 'SUMMER',
  AUTUMN: 'AUTUMN',
  WINTER: 'WINTER',
} as const;

export type SeasonObjectType = keyof typeof SeasonObject;

// constEnum.ts
export const enum ConstEnum {
  LOCAL_STORAGE = 'LOCAL_STORAGE',
  SESSION_STORAGE = 'SESSION_STORAGE',
  COOKIE = 'COOKIE'
}

// enum.ts
export enum SeasonEnumType {
  SPRING = 'SPRING',
  SUMMER = 'SUMMER',
  AUTUMN = 'AUTUMN',
  WINTER = 'WINTER',
}

// using.ts
import { SeasonObject, SeasonObjectType } from './asConst';
import { SeasonEnumType } from './enum';
import { ConstEnum } from './constEnum';
const march1: SeasonObjectType = SeasonObject.SPRING;
const march2: SeasonObjectType = 'SPRING';
const march3: SeasonEnumType = SeasonEnumType.SPRING;
// @ts-expect-error: Type '"SPRING"' is not assignable to type 'SeasonEnumType'.
const march4: SeasonEnumType = 'SPRING';
const storage: ConstEnum = ConstEnum.SESSION_STORAGE;
console.log(march1, march2, march3, march4, storage);

// not-using.ts
import { SeasonObject, SeasonObjectType } from './asConst';
import { SeasonEnumType } from './enum';
import { ConstEnum } from './constEnum';
console.log('Do you wanna build a snowman?');

 

먼저 이렇게 다섯 개의 파일을 만들었습니다. using.ts에서는 열거형과 const assertion을 이용한 값들을 열심히 사용합니다. 반대로 not-using.ts는 import는 했지만 실제로 사용하지는 않았습니다.

타입스크립트 컴파일러

먼저 타입스크립트 컴파일러를 이용해서 번들링은 하지 않고 타입스크립트 코드들을 자바스크립트로 옮겼습니다.

 

// asConst.js
export const SeasonObject = {
    SPRING: 'SPRING',
    SUMMER: 'SUMMER',
    AUTUMN: 'AUTUMN',
    WINTER: 'WINTER',
};

// constEnum.js
export {};

// enum.js
export var SeasonEnumType;
(function (SeasonEnumType) {
    SeasonEnumType["SPRING"] = "SPRING";
    SeasonEnumType["SUMMER"] = "SUMMER";
    SeasonEnumType["AUTUMN"] = "AUTUMN";
    SeasonEnumType["WINTER"] = "WINTER";
})(SeasonEnumType || (SeasonEnumType = {}));

// using.js
import { SeasonObject } from './asConst';
import { SeasonEnumType } from './enum';
const march1 = SeasonObject.SPRING;
const march2 = 'SPRING';
const march3 = SeasonEnumType.SPRING;
const march4 = 'SPRING';
const storage = "SESSION_STORAGE";
console.log(march1, march2, march3, march4, storage);

//not-using.js
console.log('Do you wanna build a snowman?');
export {};

 

주목할 점은 크게 세 가지입니다. 첫 번째는 열거형의 트랜스파일 결과인데요. 즉시 실행 함수로 바뀐다는 것을 알 수 있습니다. 두 번째는 const enum 의 결과인데요. using.js의 storage 변수가 열거형의 값으로 하드코딩되어 있다는 것을 확인할 수 있습니다. 마지막은 not-using.js 인데요. 이미 사용하지 않는 import 결과는 제거되어 있다는 것을 확인할 수 있습니다. 즉 타입스크립트 컴파일러를 사용하면 이미 사용되지 않는 코드들은 제거되어 사용하지 않는 import된 코드를 없애 주기 때문에 enum 의 tree-shaking 문제는 딱히 없어 보입니다.

rollup

rollup을 이용해서 타입스크립트 파일을 자바스크립트로 번들링해 보았습니다.

 

// using.js
const SeasonObject = {
    SPRING: 'SPRING',
    SUMMER: 'SUMMER',
    AUTUMN: 'AUTUMN',
    WINTER: 'WINTER',
};

var SeasonEnumType;
(function (SeasonEnumType) {
    SeasonEnumType["SPRING"] = "SPRING";
    SeasonEnumType["SUMMER"] = "SUMMER";
    SeasonEnumType["AUTUMN"] = "AUTUMN";
    SeasonEnumType["WINTER"] = "WINTER";
})(SeasonEnumType || (SeasonEnumType = {}));

const march1 = SeasonObject.SPRING;
const march2 = 'SPRING';
const march3 = SeasonEnumType.SPRING;
const march4 = 'SPRING';
const storage = "SESSION_STORAGE";
console.log(march1, march2, march3, march4, storage);

// not-using.js
console.log('Do you wanna build a snowman?');

 

not-using.js에서 import했지만 사용하지 않은 SeasonObject 객체와 SeasonEnumType을 만드는 즉시 실행 함수 둘 다 잘 제거되었습니다.

esbuild

// using.js
"use strict";
(() => {
  // src/asConst.ts
  var SeasonObject = {
    SPRING: "SPRING",
    SUMMER: "SUMMER",
    AUTUMN: "AUTUMN",
    WINTER: "WINTER"
  };

  // src/using.ts
  var march1 = SeasonObject.SPRING;
  var march2 = "SPRING";
  var march3 = "SPRING" /* SPRING */;
  var march4 = "SPRING";
  var storage = "SESSION_STORAGE" /* SESSION_STORAGE */;
  console.log(march1, march2, march3, march4, storage);
})();

// not-using.js
"use strict";
(() => {
  // src/not-using.ts
  console.log("Do you wanna build a snowman?");
})();

 

위의 rollup과 마찬가지로 not-using.js에서 import했지만 사용하지 않은 SeasonObject 객체와 SeasonEnumType을 만드는 즉시 실행 함수 둘 다 잘 제거되었습니다. 특이한 점은 march3 변수가 "SPRING"이라는 실제 값으로 대체되었다는 점인데요. 제가 사용한 것은 그냥 열거형이었지만 const enum 처럼 대체해 주는 것을 확인할 수 있습니다. 런타임에서는 const enum과 enum을 구분할 수 없고, march3 자체가 .d.ts에 있는 것도 아니며 외부로 export되는 것도 아니라 이렇게 된 것 같아요(추측임). 어떻게 보면 번들러에서 나름의 최적화를 수행해 줬다고 할 수 있겠습니다. 반대로 SeaconObejct는 객체이기 때문에 살아남은 모습인데요. 이 실험의 결과만 놓고 봤을 때는 오히려 enum이 최적화를 잘 했다고 보이긴 합니다.

webpack + babel

// using.js
/******/ (() => { // webpackBootstrap
/******/     "use strict";
var __webpack_exports__ = {};

;// CONCATENATED MODULE: ./src/asConst.ts
var SeasonObject = {
  SPRING: 'SPRING',
  SUMMER: 'SUMMER',
  AUTUMN: 'AUTUMN',
  WINTER: 'WINTER'
};
;// CONCATENATED MODULE: ./src/enum.ts
var SeasonEnumType = /*#__PURE__*/function (SeasonEnumType) {
  SeasonEnumType["SPRING"] = "SPRING";
  SeasonEnumType["SUMMER"] = "SUMMER";
  SeasonEnumType["AUTUMN"] = "AUTUMN";
  SeasonEnumType["WINTER"] = "WINTER";
  return SeasonEnumType;
}({});
;// CONCATENATED MODULE: ./src/constEnum.ts
var ConstEnum = /*#__PURE__*/function (ConstEnum) {
  ConstEnum["LOCAL_STORAGE"] = "LOCAL_STORAGE";
  ConstEnum["SESSION_STORAGE"] = "SESSION_STORAGE";
  ConstEnum["COOKIE"] = "COOKIE";
  return ConstEnum;
}({});
;// CONCATENATED MODULE: ./src/using.ts



var march1 = SeasonObject.SPRING;
var march2 = 'SPRING';
var march3 = SeasonEnumType.SPRING;
// @ts-expect-error: Type '"SPRING"' is not assignable to type 'SeasonEnumType'.
var march4 = 'SPRING';
var storage = ConstEnum.SESSION_STORAGE;
console.log(march1, march2, march3, march4, storage);
/******/ })()
;

// not-using.js
/******/ (() => { // webpackBootstrap
/******/     "use strict";
var __webpack_exports__ = {};
console.log('Do you wanna build a snowman?');

/******/ })()
;

 

웹팩은 /******/ 이걸 좋아해서 결과물이 좀 지저분하지만 not-using에서 사용하지 않는 import는 즉시 실행 함수든 객체든 잘 제거했다는 것을 확인할 수 있습니다. 추가적으로 using에서 const enum이었던 SESSION_STORAGE 의 const 설정은 무시된 것 같네요. 아마 추가로 플러그인을 설치해야 해결할 수 있는 문제 같습니다.

결론

사실 구글에 enum과 as const를 검색해 보면 tree-shaking 관점에서 열거형이 불리하다는 글이 굉장히 많습니다. 하지만 저는 "글쎄요"라는 답을 하고 싶습니다. 단순한 unused import 의 문제는 타입스크립트 컴파일러(tsc) 선에서 처리가 가능하고, 그게 아니더라도 그 정도는 번들러에서 최적화를 잘 해준다고 생각합니다. 따라서 "tree-shaking 관점에서 const assertion 이 더 유리하니까 열거형은 피해야겠다"는 말은 근거가 부족한 것으로 보여요.

마무리

그래서 뭐가 제일 좋을까요? 그래도 제 블로그니까 일기장마냥 제 의견만 적겠습니다. 일단 숫자 열거형은 위험해서 쓰지 않습니다. 만들기가 진짜 편하고 좋긴 한데, 숫자 자동 할당이라는 특성 때문에 순서가 바뀌면 값이 바뀌고, 런타임에서 바뀐 순서와 값으로 인해 문제가 생긴다면 그걸 한 번에 알아채기 힘들 것 같아요. 문자열 열거형과 const assertion은 사실 팀 컨벤션에 맞추는 게 좋을 것 같아요. 최적화를 생각한다면 tree-shaking보다는 (리액트를 사용한다는 가정 하에) 렌더링 최적화가 더 효과가 크다고 생각하고, 열거형과 const assertion 사이의 감동적인 tree-shaking 차이는 없으니까요. 그럼에도 불구하고 제가 그 컨벤션을 정해야 하는 순간이 온다면 저는 값과 타입을 분리해서 생각할 수 있는 const assertion을 사용하겠습니다.

 

혹시 enum과 const assertion에 대한 의견이 있으시다면 댓글 꼭 부탁드려요. 저도 실험하고 글을 쓰면서 '이게 맞나?'라는 생각을 너무 많이 했거든요😢

참고 자료

TypeScript: Handbook - Enums
TypeScript: Documentation - TypeScript 3.4
TypeScript enum을 사용하지 않는 게 좋은 이유를 Tree-shaking 관점에서 소개합니다

Github - WaiNaat/enum-vs-const-assertion