들어가는 말
타입스크립트에서 상수의 묶음을 관리하는 방법에는 여러 가지가 있습니다. 그 중에서도 비슷하게 생긴 열거형(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 관점에서 소개합니다
'호기심 천국' 카테고리의 다른 글
TanStack Query) staleTime 'Infinity' 는 어떻게 아는걸까? (0) | 2024.04.06 |
---|---|
리액트와 대수적 효과는 무슨 관계일까? (1) | 2024.03.30 |
리액트 동시성이란 (4) | 2024.03.09 |
CSS) border vs outline (0) | 2024.02.23 |
React.ReactNode vs JSX.Element (1) | 2024.02.09 |