'타입스크립트 enum 을 사용하지 않는 방법' 을 사용하지 않는게 좋은 이유

joonseokhu·2022년 10월 17일
55
post-thumbnail

타입스크립트에서 enum은 사용하지 않는게 좋다는 말이 있다. 실제로 근거가 있는 내용이며, (특정 조건에서) 의미가 있는 최적화라는 점에 동의한다.

TypeScript에서 enum을 사용하면 Tree-shaking이 되지 않습니다

https://engineering.linecorp.com/ko/blog/typescript-enum-tree-shaking/

하지만 이 내용은 한편으론 너무 과도하게 받아들여지는 부분이 있다고 생각된다.

enum 키워드 없이 enum 처럼 작동하는 변수를 만드는 방법은 사람이 수작업으로 작업해야 하는 코드의 양을 늘린다. 그렇지만 그에 비해 얻는 효과는 미미하거나 없을 수도 있다.

enum LoLRiftPosition {
  TOP = 'TOP',
  JUNGLE = 'JUNGLE',
  MIDDLE = 'MIDDLE',
  BOTTOM = 'BOTTOM',
  SUPPORT = 'SUPPORT',
}

enum 으로 이렇게 쓰면 될 코드가

const LoLRiftPosition = {
  TOP: 'TOP',
  JUNGLE: 'JUNGLE',
  MIDDLE: 'MIDDLE',
  BOTTOM: 'BOTTOM',
  SUPPORT: 'SUPPORT',
} as const;

type LoLRiftPosition = typeof LoLRiftPosition[keyof typeof LoLRiftPosition];

이렇게 늘어난다. 게다가 어떻게 코드 재활용이 가능하도록 만드는 방법도 없어서 enum을 만들어야 할 때마다 이렇게 생긴 코드를 일일이 써줘야 한다.1

단순히 "작성해야 하는 코드가 길어진다" 도 있지만, 선언하는 객체와 타입의 "이름" 이 네임스페이스 내에서 완전히 같아야 한다는 조건이 붙는다.2 즉 이름에 오타가 나면 제대로 작동을 안하며, 자동으로 작성되는것도 아니므로, 저렇게 enum을 만들고나서 오타는 없는지, 잘 작동은 하는지 꼭 확인해줘야 한다.

과연 위와 같은 길고 복잡한 "union string type" 방법으로 enum 을 대체하는게 내 프로덕트에 유의미한 차이를 줄 수 있을지 내 프로덕트의 상황을 생각해보자.

1. 프론트엔드에서 사용할 코드인가?

enum 을 사용하지 말아야 한다는 내용의 이유는, 번들링된 코드의 용량이 기대하는것보다 조금 더 클 수 있다는 점이다. 프론트엔드용 자바스크립트는 브라우저의 페이지로드마다 브라우저가 자바스크립트 파일을 다운로드 받는다.3 js 파일을 다운로드 받는 시간이 짧아질수록 사용자 입장에서 최초 페이지 진입시 로딩 시간이 줄어드는 효과를 가지는 것이다.

하지만 nodejs 서버는 어떤가? nodejs 에서 자바스크립트 소스코드를 받아서 읽어오는 시점은, nodejs 서버가 배포될 때 말곤 없다. 한번 켜진 nodejs 서버는 모든 js코드가 해당 서버의 메모리에 올라가 있고, 다시 자바스크립트 파일을 읽을 일이 없다.

그래도 파일 크기가 작아지면 배포속도가 빨라지는거 아닌가 하는 생각이 들 수도 있겠지만, 어짜피 빌드된 nodejs 이미지 크기는 수십~수백 메가바이트다. enum 이 수천 수만개쯤 있는 프로젝트면 모르겠는데, 보통 그렇진 않을테니 의미 없다.

그리고 놓치기 쉬운 또다른 부분은, 보통 nodejs 서버사이드 프로젝트를 번들링해서 쓰지 않는다는 점이다. ts-node나 tsc는 타입스크립트에 1:1 대응하는 자바스크립트와 소스맵을 뽑아줄 뿐, 애초에 트리쉐이킹을 해주지도 않는다. 열심히 트리쉐이킹 최적화를 해봤자 아무도 트리쉐이킹을 해주지 않는다는 슬픈 이야기...

2. 만들어놓고 사용안하는 enum이 많은가?

트리쉐이킹은, 만들어놓고 아무데에서도 쓰지 않는 변수를 찾아서 코드에서 아예 삭제해주는 기능이다. 그리고 enum 키워드를 통한 enum 은 그게 잘 안돼서 문제라는 거고.

바꿔서 말하면, 내가 enum을 작성한 다음, 그걸 어딘가에서 사용한다면, 그 enum은 내가 어떤 식으로 구현했는지 상관없이 애초에 트리쉐이킹 대상조차 아니라는 것이다.

삭제될 일이 없는 코드가 잘 삭제되도록 구현해야 한다니... 뭔가 이상하지 않은가?

3. 3G 이하의 모바일 네트워크 환경, 또는 저사양 휴대폰/컴퓨터에서 사용하는 유저층을 고려해서 개발중인가?

enum 트리쉐이킹으로 줄일수 있는 자바스크립트 파일의 크기는 그리 엄청난 수준이 아니다.
내가 글의 앞부분에서 써두었던 enum 코드의 트랜스파일 결과물은 다음과 같다.

var LoLRiftPosition;
(function (LoLRiftPosition) {
    LoLRiftPosition["TOP"] = "TOP";
    LoLRiftPosition["JUNGLE"] = "JUNGLE";
    LoLRiftPosition["MIDDLE"] = "MIDDLE";
    LoLRiftPosition["BOTTOM"] = "BOTTOM";
    LoLRiftPosition["SUPPORT"] = "SUPPORT";
})(LoLRiftPosition || (LoLRiftPosition = {}));

아주 긴 코드로 변환되었다고 생각되겠지만, 303자 짜리 문자열이므로 대충 300바이트쯤 되는 것이다.

아무 사이트나 켜보고 개발자도구 네트워크 탭을 보자. 다운로드 되는 css/js 파일들, 작아봤자 보통 10Kb정도 된다. 리액트 번들 사이즈는 200Kb 짜리 여러개로 되어있는 경우도 흔할 것이다. 최적화 해봐야 실제로 지워질 일이 있을지 없을지 모르는 코드 0.3Kb 만큼을 줄이기 위해 이미 주어져 있는 문법을 거부하고 훨씬 더 불편한 방식으로 코드를 작성할 가치가 있는가?

어떤 회사 어떤 팀에서는 극한의 수준까지 퍼포먼스 최적화를 고려하며 개발을 하고 있을테고, 이 enum 트리쉐이킹 최적화 방법도 그중 한가지이다. 글의 도입부에서 언급했던 블로그 글은 라인 개발자가 쓴 글이다. 라인은 일본, 동남아를 포함한 아주 많은 국가에서 국민앱 취급을 받는 인기 앱이고, 당연히 그중에는 3G 이하의 저품질 네트워크에서 라인을 이용해야 하는 고객들도, 우리나라에서 7~8년 전쯤에나 썼을법한 저사양 스마트폰을 사용하는 고객들도 많을 거라고 생각된다. 국내 사용자를 타겟으로 하는 서비스보다, 라인은 미세한 퍼포먼스 차이에도 영향을 받는 사용자 수가 많을 것 이라고 어렵지 않게 유추해볼 수 있다. 그런 사용자들을 위해 라인의 프론트엔드/앱 개발자들은 정말 수많은 퍼포먼스 최적화 작업을 하고 있을테고, 타입스크립트의 enum을 쓰지 않는 건 그 수많은 최적화 방법중 하나 아닐까.

마치며 - 개발 효율성과 제품 퍼포먼스의 트레이드오프

사실 모바일 환경 성능최적화중 가장 효과가 큰 부분은 enum을 쓰고 안쓰고의 문제보단, 이미지나 영상 관련된게 아닐까 생각된다. 사용자가 업로드하는 이미지를 비롯해서 수많은 이미지들을 업로드 직후 여러 해상도로 변환해서 저장하고, 사용자 환경이나 선택에 맞게 원본화질이 아닌 적당한 화질로 보여주고, 그조차도 lazy-loading 되게 처리하는 것 말이다.

물론 이미지 퍼포먼스 최적화는 말은 쉽지만 아주 힘든 작업이다. 프론트엔드 개발자 단독으로 진행하기도 어렵고 전사 차원에서 저사양 환경 사용자를 고려한 제품 개발을 위해 회사의 리소스가 추가적으로 소모될 것을 감수해야 한다. 라인 앱의 코드를 본 적은 없지만, 이미 그들은 이미지에 대한 최적화 작업도 해두었을거라고 조심스럽게 예상해본다. 적용했을 때 큰 효과를 보는 최적화들은 이미 처리하고, 아주 조금이라도 최적화 될 수 있는 모든 경우들을 찾아서 하나하나 적용해가다보니, 잘 사용하고 있던 언어의 특정 문법을 사용하지 않겠다고 결정하는 단계까지 온게 아닐까.

하지만 우리 대부분은 라인 개발자는 아니고, 우리가 다니고 있는 회사 우리가 속한 팀이 라인같은 곳은 아니다. 당장 개발해야 하는 기능이 쌓여있고, 나와 팀의 시간적, 금전적 리소스는 한정되어 있다. enum 대신 union type 을 쓴다는건 미세한 정적파일 크기 변화와, 저걸 작성해서 생기는 코드의 복잡성, 오타가능성 등의 개발리소스를 서로 맞바꾸겠다는 뜻이다.

브라우저가 받는 정적 파일 중 가장 비중이 큰건 이미지 파일, 폰트파일이며 이것만 잘 최적화되도록 해놔도 얻게 되는 퍼포먼스적 효과는 매우 크다. 업로드 되는 이미지에 대해 사이즈 변환 처리까진 아니더라도, 자주 사용되는 메인페이지 내 이미지에 대해 srcset 을 적용하고 고용량의 png 대신 jpg 나 webp를 사용해 볼 수도 있다. 폰트의 글리프를 제한한 서브셋으로 빌드해 첨부하고, 웹폰트 파일이 ttf 나 eot 로만 되어있다면 woff로도 첨부하고 eot,ttf는 fallback 으로 사용하도록 설정하는 것도 효과가 크다. #

작은 최적화에 연연하기보다, 그것보다 훨씬 효과가 큰 일에 집중하자. 문법을 포기해가며 해야 하는 극한의 최적화는 내 프로젝트가 글로벌 서비스가 되었을 때 고민해도 늦지 않다.


추가 내용

enum reverse mapping 과 enum 요소 순회

enum 의 역-index (정식 명칭은 enum element reverse mapping) 때문에 enum 값 순회가 어려워서 enum 대신 객체를 쓰는게 맞는것 같다는 의견을 받아서, 추가로 조사한 내용을 글에도 공유한다.

먼저 enum reverse mapping 은 다음과 같이, enum이 객체로 변환될 때 키 뿐 아니라 값도 키로 할당돼서, enum 객체에서 키 뿐 아니라 값으로도 접근가능하게 되는걸 말한다.

enum Fruit {
  apple,
  banana,
}
// 변환결과는 다음과 동등하다.
const Fruit = {
  apple: 0,
  banana: 1,
  '0': 'apple',
  '1': 'banana',
}

이렇게 변환되면, enum 을 객체처럼 사용하면서 enum 에 들어있는 키 목록 (또는 값 목록)을 배열로 뽑아내려 할때 문제가 된다.

enum Fruit {
  apple,
  banana,
}

Object.keys(Fruit);
// ['apple', 'banana'] 일거라 생각되지만
// ['0', '1', 'apple', 'banana'] 실제론 이렇게 나온다.

하지만 enum 을 문자열로만 사용한다면 문제가 없다. enum의 문자열 요소는 reverse mapping 을 하지 않는다.

enum Fruit {
  apple = 'iphone',
  banana = 'banana',
}
// 만약 문자열 enum 요소도 reverse mapping 된다면 다음처럼 나왔겠지만
const Fruit = {
  apple: 'iphone',
  iphone: 'apple',
  banana: 'banana',
}
// 다행히 실제로는 그렇게 작동하지 않는다.
const Fruit {
  apple: 'iphone',
  banana: 'banana',
}

이런 작동방식은 문자열 enum 문법이 타입스크립트에 처음 도입되던 2017년부터 의도적으로 디자인된 부분이고4 미래에도 바뀔 가능성이 없다. 정말 다행이다.

enum 요소 순회시 각 요소의 타입

enum 을 순회할 때 순회될 요소에 대해 타입 자동추론이 안된다는 의견을 받아 답변을 추가한다. 결론부터 말하자면, 자동 추론 된다.

자동추론이 안되는 경우는, 순회를 Object.keys() 로 하려고 하기 때문이다.

위와 같이 enum 을 Object.keys() 로 호출하면 문자열 배열 타입으로 추론된다.

enum은 런타임에서 키-값 쌍의 객체이긴 하지만, 멘탈모델은 변하지 않는 문자열 들의 집합 이다. 객체형태를 띄는건 어디까지나 사용하기 쉽고 익숙한 방법을 제공하기 위함일 뿐이다. 타입스크립트의 enum 타입에서 에서 의미를 가지는 데이터는 enum 객체의 에 해당한다. enum에서 키는 enum의 알짜 데이터인 에 접근하기 위한 핸들일 뿐이다.

enum 객체의 요소의 값을 순회하기 위해 Object.values() 로 접근하면, 이렇게 제대로 타입이 추론되는 모습을 확인할 수 있다.

enum의 값에 긴 한글 문자열을 대입하면 base64로 인코딩 되는 문제

개인적으로 enum 을 오용하는 사례라고 생각한다. 앞서 말했듯 enum 은 변하지 않는 문자열 들의 집합 로써 활용되어야 한다. enum의 값으로 자연어를 넣는다는 것은, 그게 나의 앱 외부에서 사용자에게 노출되는 용도의 문자(소위 말하는 "사용자 문구")를 의도했다고밖에 볼 수 없다. 사용자 문구는 언제든 수정될 수 있다는 전제하에 모든 개발이 이루어져야 한다고 생각된다.

enum 은 2가지 이상의 경우의 수를 가지는 상황을 정의하기 위한 타입이다. 시스템 내에서 분기 또는 스위치 등에 사용하는 값으로만 사용할때만 enum을 정의해 사용하는게 바람직하다.

만약 enum과 1:1 대응이 필요한 사용자 문구가 필요하다면, enum에 대한 mapped-type 으로 정의된 일반 객체를 만들어 저장하는게 맞다고 생각한다.

예를들어 신용카드 결제요청 후 결제실패시 에러코드를 받아 그걸 실패메세지로 바꿔서 출력해야 하는 상황을 가정해보자. 다음 코드처럼 작성하는게 enum과 대응하는 사용자문구를 출력하는 올바른 방법이라고 본다.

// 카드사가 주는 에러코드 목록.
// 에러코드는 웬만하면 갑자기 바뀌거나 하지는 않을거라고 예상할 수 있기에,
// enum 타입으로 작성할 수 있다.
enum TransactionFailReason {
  INVALID_CARD_NUMBER = 'INVALID_CARD_NUMBER',
  WRONG_PIN = 'WRONG_PIN',
  WRONG_EXP = 'WRONG_EXP',
  WRONG_CVS = 'WRONG_CVS',
  LIMIT_EXCEEDED = 'LIMIT_EXCEEDED',
  VOID_CARD = 'VOID_CARD',
  CONTACT_TO_ISSUER = 'CONTACT_TO_ISSUER',
}

// 에러코드에 대응하는 메세지들은 enum을 키로, 메세지를 값으로 가지는 객체를 만들어 정의하자.
const transactionFailReasonMessage: Record<TransactionFailReason, string> = {
  [TransactionFailReason.INVALID_CARD_NUMBER]: '카드번호가 일치하지 않습니다.',
  [TransactionFailReason.WRONG_PIN]: '입력된 PIN이 일치하지 않습니다.',
  [TransactionFailReason.WRONG_EXP]: '입력된 유효기간이 일치하지 않습니다.',
  [TransactionFailReason.WRONG_CVS]: '입력된 CVS 번호가 일치하지 않습니다.',
  [TransactionFailReason.LIMIT_EXCEEDED]: '한도초과',
  [TransactionFailReason.VOID_CARD]: '거래정지된 카드입니다.',
  [TransactionFailReason.CONTACT_TO_ISSUER]: '카드사 문의요망',
}

// 내가 카드사로부터 기대한 에러코드의 경우의 수에 해당하지 않는 특수한 상황에는
// 이런 메세지를 띄우도록 하자
const UNKNOWN_TRANSACTION_ERROR_MESSAGE = '알 수 없는 에러가 발생해 결제에 실패했습니다.'

// 에러코드에 대한 enum타입 가드 함수.
// 카드사가 정상적인 에러코드를 준게 맞는지 확인하는 절차를 이 함수를 통해 수행할 수 있다.
// 타입 가드의 리턴값이 true 일 경우,
// 해당 타입은 자동으로 단언(inferred)되어 다음 평가(evaluation)에서 자동추론된다.
const isTransactionFailReason = (code: string): code is TransactionFailReason => {
  return Object.values(TransactionFailReason).includes(code as TransactionFailReason);
}

// 카드사로부터 특정 에러 코드를 받는 상황에서, 메세지를 출력해야 할 때:
// 이렇게 작성한다.
const getTransactionErrorMessage = (errorCode: TransactionFailReason | string) => {
  return isTransactionFailReason(errorCode)
    ? transactionFailReasonMessage[errorCode]
    : UNKNOWN_TRANSACTION_ERROR_MESSAGE;
}

이렇게 작성할 경우, 에러코드가 변경되거나 추가되는 아주 특별한 상황만 아니라면 enum은 수정될 일이 없다. enum 값이 base64로 변환되거나 잘릴 걱정도 할 필요도 없고, 에러문구 수정을 위해 enum을 고칠 일도 생기지 않는다.


1. Objects vs Enums, typescriptlang.org
2. 객체와 union string type 이 같은 이름일 필요는 없지만, 그렇게 되면 enum 타입의 완벽한 대체제로써 기능하지는 않게 된다. 둘을 한 공간에서 각각 서로 같은 이름으로 선언함으로써, 값을 참조할땐 객체의 타입이 사용되고, 타입을 참조할땐 union string 타입이 사용되도록 하는 일종의 꼼수다.
3. js파일이 캐싱되면 매번 다운로드받지 않아도 될 수도 있다는 부분은 일단 무시하자.
4. "string valued members have no reverse mapping." , String valued members in enums , pull request of github.com

profile
풀스택 집요정

5개의 댓글

comment-user-thumbnail
2022년 10월 18일

보통 enum을 안 써야하는 이유에 대해서 쓴 글들만 읽었는데 유익한 포스팅 감사합니다. 자주 와서 정독하겠읍니다...

답글 달기
comment-user-thumbnail
2022년 10월 21일

막연하게 생각만 하고 있었는데
이렇게 잘 정리된 글로 보니 너무 좋네요
즐겁게 읽고 갑니다

답글 달기
comment-user-thumbnail
2022년 10월 23일

좋은 글이네요!

답글 달기
comment-user-thumbnail
2022년 10월 26일

캬 .. 감사합니다

답글 달기
comment-user-thumbnail
2022년 12월 23일

eunm 좋아하는 사람으로써 정독하게되는 글이네요. 정말 잘봤습니다

답글 달기