타입과 상수를 효과적으로 연결하기 🔗
Literal type, enum, const enum, as const - User 타입 설계 과정에서의 고민을 담아서
Table Of Contents
- TL;DR
- User 타입을 설계하자
- 초기 설계: (문자열) Literal Type
- 대안 1:
enum
(열거형) - 대안 2:
const enum
(const 열거형) - 대안 3:
as const
- 최종 구현
- 참고
TL;DR
타입과 상수를 연결할 때 Literal Type, enum, const enum, as const 같은 여러 방법을 사용할 수 있다.
Literal Type은 간단하지만 유지보수성이 떨어지고, enum은 코드 가독성을 높일 수 있지만 컴파일된 코드가 증가한다. const enum은 불필요한 객체 생성을 방지해 성능을 개선하지만, 컴파일 후 타입 추론이 어려운 단점이 있다. as const는 리터럴 타입으로 변환하여 코드 가독성과 자동 완성을 높일 수 있으나, 추가적인 코드 작성이 필요할 수 있다.
User 타입을 설계하자
포토그라운드라는 서비스에서는 사용자가 로그인을 하면, Zustand store에 유저의 데이터를 저장해놓고 사용자의 역할(일반 고객/사진작가)이나 선택한 대학에 따라 다른 UI를 보여주게 된다.
이 때, 사용자 데이터를 정확하게 다루기 위해 User
라는 타입을 만들고자 하는데, 이 과정에서 어떤 방법을 사용해야 하는지 고민을 하게 됐다.
초기 설계: (문자열) Literal Type
export interface User { ... gender: 'MALE' | 'FEMALE'; univ: '서강대학교' | '연세대학교' | '이화여자대학교' | '홍익대학교'; role: 'ROLE_CUSTOMER' | 'ROLE_PHOTOGRAPHER'; }
id나 name 등은 적절히 number
, string
등의 원시 타입으로 처리할 수 있다.
그런데 gender
, univ
, role
이라는 필드는 들어갈 수 있는 값들이 매우 한정적이기 때문에 단순 string
타입을 사용하면 타입 안전성이 매우 떨어지게 된다.
gender
의 경우에는 우선 남성과 여성 2가지만 사용하기로 했기 때문에'MALE' | 'FEMALE'
처럼 Literal Type을 사용했다.univ
는 우리 서비스에서 제공하는 학교 4가지를 Literal Type으로 정의했다.role
에는 일반 고객과 사진작가 2가지가 존재하므로'ROLE_CUSTOMER' | 'ROLE_PHOTOGRAPHER'
처럼 역시 Literal Type으로 정의했다.
참고로, 리터럴 타입에 사용된 문자열 값들은 백엔드에서 정의한 값을 그대로 사용했다.
리터럴 타입의 단점
하지만 이런 타입 정의는 코드의 가독성과 유지보수성이 떨어질 수 있다.
예를 들어, 코드 여러 곳에 동일한 문자열을 하드코딩하면, 해당 문자열이 무엇을 의미하는지 한눈에 파악하기 어려워진다. 또한, 나중에 타입이 변경되는 경우, 코드 여러 곳을 직접 수정해야 한다는 번거로움이 있다.
예를 들어, 사용자의 역할에 따라 다른 화면을 렌더링하는 경우
export default function ReservePage() { const { role } = useUserStore(); if (role === "ROLE_PHOTOGRAPHER") { return <TemporaryScreen />; } return <ReserveScreen />; }
처럼 문자열을 직접 비교해야 하는데, 이 코드를 처음 읽는 사람이 보기에는 "ROLE_PHOTOGRAPHER"이라는 문자열이 정확히 무엇을 의미하는지 알기 어려울 수 있다.
또는 작가 검색 시 성별이나 학교에 따라 필터링 하기 위해 GENDER_LIST
및 UNIV_LIST
배열이 팰요한데,
export type Gender = "MALE" | "FEMALE"; export interface GenderOption { value: Gender; label: string; } export const GENDER_LIST: GenderOption[] = [ { value: "MALE", label: "남성" }, { value: "FEMALE", label: "여성" }, ];
여기서도 이렇게 동일한 문자열을 반복적으로 입력해야 한다.
만약에 서버에서 "MALE"이라는 값 대신에 "Male"이나 "male"을 사용하게 되어 프론트에서도 대응해야 하는 경우, 문자열을 일일히 수정해야 한다는 문제가 생긴다.
- 실제로
univ
필드는 "서강대학교" 에서 "SOGANG"으로 바뀐 적이 있다...
(무엇보다 코파일럿같은 툴을 쓰지 않는 이상 문자열 자동완성이 안된다...!)
가장 큰 문제인 유지보수성을 높이기 위해서, 3가지정도의 대안이 있다.
대안 1: enum
(열거형)
enum
은 TypeScript에서 여러 개의 상수를 하나의 그룹으로 정의할 수 있는 기능이다.
- 상수에 의미 있는 이름을 붙일 수 있기 때문에 코드의 의도를 명확하게 표현하기에 좋다.
- JavaScript의 타입 시스템을 확장하는 기능이 아니다!!
열거형에는 숫자 열거형(Numeric enums)와 문자열 열거형(String enums)가 있고, 현재 상황에서는 "MALE"같은 문자열을 이용해야 하기 때문에 문자열 열거형을 사용하게 된다.
enum 사용 예시
실제로 열거형을 사용해서 위 코드를 다시 작성해 보자.
export enum Gender { Male = "MALE", Female = "FEMALE", } export interface GenderOption { value: Gender; label: string; } export const GENDER_LIST: GenderOption[] = [ { value: Gender.Male, label: "남성" }, { value: Gender.Female, label: "여성" }, ];
이렇게 Gender
타입을 열거형으로 바꾸었다.
주의할 점은 위에서 보듯이, Gender
타입의 값을 선언할 때는 항상 문자열이 아니라 enum 객체로 접근해야 한다는 점이다!
- 예를 들어,
{ value: "MALE", label: "남성" }
은 오류를 발생시킨다.
enum의 장점
정리해 보자면, Literal Type에서 enum으로 변경하는 경우, 이런 장점이 있다.
- 의도를 명확하게 표현할 수 있다.
- 예를 들어서, 코드를 읽다가 "ROLE_CUSTOMER"라는 문자열이 등장하면 조금 당황스러울 수 있다. 해당 변수의 타입 선언을 찾아가야 하고, 해당 변수가
Gender
타입을 따르는지 확인해야 할 때도 많다. 또,Gender
타입 정의까지 확인해야 하는 경우가 왕왕 생긴다. - 반면에
Role.Customer
처럼 사용하면Role
이라는 enum 객체를 따라가며 이해하기 쉽다.
- 예를 들어서, 코드를 읽다가 "ROLE_CUSTOMER"라는 문자열이 등장하면 조금 당황스러울 수 있다. 해당 변수의 타입 선언을 찾아가야 하고, 해당 변수가
- 자동 완성이 편하다.
- 대부분의 코딩 툴들은
Role.
까지 입력하면 자동 완성 가능한 값들을 보여주기 때문에 개발 속도가 빨라진다🥰
- 대부분의 코딩 툴들은
enum의 단점
하지만 이런 단점도 존재한다.
- 컴파일 결과물이 증가한다.
- 위에서 언급했듯이 enum은 JS에 없는 TS만의 기능이기 때문에, JS로 컴파일될 때 추가적인 코드가 생성된다.
- enum
Role
을 JS로 컴파일하는 경우,// 컴파일 전 export enum Role { Customer = "ROLE_CUSTOMER", Photographer = "ROLE_PHOTOGRAPHER", }
- 아래와 같은 코드가 추가로 생성된다.
// 컴파일 후 var Role; (function (Role) { Role["Customer"] = "ROLE_CUSTOMER"; Role["Photographer"] = "ROLE_PHOTOGRAPHER"; })((Role = exports.Role || (exports.Role = {})));
- enum
- 위에서 언급했듯이 enum은 JS에 없는 TS만의 기능이기 때문에, JS로 컴파일될 때 추가적인 코드가 생성된다.
대안 2: const enum
(const 열거형)
const enum
을 사용하면, 추가로 생성되는 enum 객체를 피할 수 있다.
const enum 사용 예시
const enum
은 enum 앞에 const
만 붙이면 된다.
const enum Role { Customer = "ROLE_CUSTOMER", Photographer = "ROLE_PHOTOGRAPHER", }
일반적인 열거형과 달리, const enum
은 컴파일 과정에서 enum
객체가 완전히 제거되고, 해당 값이 상수값으로 인라인 처리된다.
즉, JS로 컴파일될 때,
const role = Role.Customer;
라는 코드가 있으면
const role = "ROLE_CUSTOMER";
컴파일된 코드에서는 이렇게 상수값으로 직접 대체된다.
const enum의 장점
- 불필요한 객체 생성 방지
- 컴파일 시 enum 객체가 완전히 제거되므로
enum
을 쓸 때에 비해서 컴파일된 코드가 가벼워지고, 런타임 성능이 개선된다.
- 컴파일 시 enum 객체가 완전히 제거되므로
- 코드 가독성 증가, 자동 완성 가능
Literal Type
에 비해enum
이 가지는 장점을 그대로 가질 수 있다.
const enum의 단점
- 컴파일된 코드에서 타입 추론이 어려움
- 컴파일 시 enum 객체가 완전히 사라지고 정수나 문자열 값이 인라인 처리되기 때문에 해당 값의 타입이 무엇이었는지 알기 어렵다.
대안 3: as const
as const
는 TS에서 문자열이나 객체, 배열 등의 값을 Literal Type으로 변환할 때 사용하는 문법이다.
-
객체 as const
,배열 as const
처럼 사용한다. -
해당 객체의 모든 속성에 string, number같은 일반적인 타입이 아니라 Literal Type의 값이 대입된다.
as const 사용 예시
예를 들어서, as const
없이 그냥 객체로 Role 타입을 선언한 코드가 있다.
const ROLE = { CUSTOMER: "ROLE_CUSTOMER", PHOTOGRAPHER: "ROLE_PHOTOGRAPHER", }; type Role = (typeof ROLE)[keyof typeof ROLE];
typeof ROLE
은
{ CUSTOMER: string; PHOTOGRAPHER: string; }
이 되고,
keyof typeof ROLE
은 "CUSTOMER" | "PHOTOGRAPHER"
이 된다.
typeof ROLE
을 keyof typeof ROLE
로 인덱싱하기 때문에 Role
은 string
타입으로 추론된다.
반면에 as const
를 덧붙여 리터럴 타입으로 변환한 코드
export const ROLE = { CUSTOMER: "ROLE_CUSTOMER", PHOTOGRAPHER: "ROLE_PHOTOGRAPHER", } as const; type Role = (typeof ROLE)[keyof typeof ROLE];
에서 typeof ROLE
은
{ readonly CUSTOMER: "ROLE_CUSTOMER"; readonly PHOTOGRAPHER: "ROLE_PHOTOGRAPHER"; }
이 되고,
keyof typeof ROLE
은 "CUSTOMER" | "PHOTOGRAPHER"
이 된다.
typeof ROLE
을 keyof typeof ROLE
로 인덱싱하기 때문에 Role
은 "ROLE_CUSTOMER" | "ROLE_PHOTOGRAPHER"
타입으로 추론된다!
as const의 장점
- 코드 가독성 증가, 자동 완성 가능
as const
역시 Literal Type에 비하면 코드 가독성, 개발 편의 등의 장점이 있다.
as const의 단점
- 추가적인 코드 추가
- 위 예시에서 볼 수 있듯이, Literal Type을 관리할 때 추가적인 코드 작성이 필요할 수 있다.
최종 구현
우선 Literal Type은 간편하게 사용하기는 좋은데 아직 프로젝트 초기라 값이 이래저래 변경되는 일이 잦아서 사용하기 귀찮고 관리하기 부담스러웠다.
우선 enum
은 컴파일되는 경우 추가적인 코드가 생성되고, 따라서 과도하게 사용하는 경우 성능 저하가 우려된다는 의견이 많은 것 같다.
그래서 const enum
이나 as const
중에서 선택하고 싶었는데, 객체 선언 후 타입을 또 따로 선언해야 하는 as const
보다는 const enum
이 편한 것 같아서 const enum
을 채택했다.
무엇보다도 변수 선언 시
const role = "ROLE_CUSTOMER";
처럼 사용하지 못하고,
import { Role } from "@/types/role"; const role = Role.Customer;
이렇게 enum을 import해서 사용해야 한다는 점이 중앙 집중적이라는 점에서 좋았다.
또한, 포토그라운드 프로젝트는 외부 모듈로 빌드되지 않으므로 컴파일 후 const enum
이 남지 않는다는 단점도 적용되지 않는다고 생각했다.
따라서 최종적으로는 이런 식으로 구현했다!
// types/university.ts export const enum University { Sogang = "서강대학교", Yonsei = "연세대학교", Ewha = "이화여자대학교", Hongik = "홍익대학교", None = "해당없음", } export interface UnivOption { value: University; label: string; } export const UNIV_LIST: readonly UnivOption[] = [ { value: University.Sogang, label: University.Sogang }, { value: University.Yonsei, label: University.Yonsei }, { value: University.Ewha, label: University.Ewha }, { value: University.Hongik, label: University.Hongik }, ] as const;
// types/user.ts import { Gender } from "@/types/gender"; import { University } from "@/types/university"; export const enum Role { Customer = "ROLE_CUSTOMER", Photographer = "ROLE_PHOTOGRAPHER", } export interface User { ... gender: Gender; univ: University; role: Role; }