Published on

TS 5.5에서 enum 대신 const 객체+as const 패턴

Authors

프로젝트가 커질수록 enum은 “편해서” 쓰기 시작했다가, 어느 순간부터는 번들 크기·트리셰이킹·런타임 의존성·타입 확장성에서 발목을 잡는 경우가 많습니다. 특히 프론트엔드 번들 환경에서는 enum런타임 코드로 남는다는 점이 계속 비용으로 누적됩니다.

TypeScript 5.5 자체가 enum을 폐기한 것은 아니지만, 최근 타입스크립트 생태계(ESM, 번들러, isolatedModules, 타입-런타임 분리) 흐름에서는 const 객체와 as const를 조합한 패턴이 사실상 표준처럼 자리잡았습니다. 이 글에서는 TS 5.5를 기준으로, enum 대신 const 객체 + as const + 유니온 타입 추출로 모델링하는 방법을 실전 관점에서 정리합니다.

또한 Next.js 같은 SSR/CSR 혼합 환경에서는 런타임 값의 형태가 예상과 다르면 디버깅이 어려운 문제가 생길 수 있는데, 이런 관점에서 “런타임을 최소화하는 타입 설계”가 도움이 됩니다. 관련해서는 Next.js Hydration mismatch 원인 7가지와 해결법도 함께 참고하면 좋습니다.

왜 TS 5.5에서 enum을 다시 보게 되나

1) enum은 타입이 아니라 런타임 코드다

enum은 컴파일 결과물에 객체가 생성됩니다. 즉 타입 선언처럼 보이지만 실제로는 런타임 값이 생깁니다.

  • 번들에 코드가 남음
  • 트리셰이킹이 기대만큼 되지 않음(특히 배럴 export나 공용 모듈에서)
  • 서버/클라이언트 경계에서 값이 섞일 때 추적이 어려움

2) 숫자 enum의 역매핑이 의도치 않은 동작을 만든다

숫자 enum은 역매핑이 생겨 객체가 더 복잡해지고, Object.keys 같은 순회에서 예상치 않은 키가 나오기도 합니다.

3) 타입 확장성과 제네릭 조합이 const 객체가 더 낫다

enum은 “멤버 집합”을 표현하는 데는 좋지만, 값 기반 유니온 타입·키 기반 타입·검증 함수·스키마 생성 같은 패턴으로 확장할 때 const 객체가 더 유연합니다.

기본 패턴: const 객체 + as const + 타입 추출

가장 많이 쓰는 형태는 아래입니다.

export const UserRole = {
  Admin: "admin",
  Editor: "editor",
  Viewer: "viewer",
} as const;

export type UserRole = (typeof UserRole)[keyof typeof UserRole];
  • UserRole(값): 런타임에서 사용할 상수 객체
  • UserRole(타입): 값들의 유니온 타입인 "admin" | "editor" | "viewer"

여기서 핵심은 as const입니다.

  • 없으면 값들이 전부 string으로 widen(확장)되어 유니온이 깨짐
  • 있으면 각 프로퍼티가 리터럴 타입으로 고정

enum과 비교

export enum UserRoleEnum {
  Admin = "admin",
  Editor = "editor",
  Viewer = "viewer",
}

문법은 깔끔하지만, 컴파일 결과물에 UserRoleEnum 객체가 남습니다(런타임 코드). 반면 const 객체는 “필요하면 남기고, 필요 없으면 제거”하기가 상대적으로 쉽고, 번들러 최적화에도 더 우호적입니다.

키 유니온, 값 유니온을 동시에 뽑아 쓰기

실전에서는 “값 유니온”뿐 아니라 “키 유니온”도 자주 필요합니다.

export const PaymentStatus = {
  Pending: "PENDING",
  Paid: "PAID",
  Failed: "FAILED",
} as const;

export type PaymentStatusKey = keyof typeof PaymentStatus;
export type PaymentStatusValue = (typeof PaymentStatus)[PaymentStatusKey];
  • PaymentStatusKey: "Pending" | "Paid" | "Failed"
  • PaymentStatusValue: "PENDING" | "PAID" | "FAILED"

이렇게 분리해두면 UI에서는 키를, API payload에서는 값을 쓰는 식으로 역할을 명확히 나눌 수 있습니다.

런타임 검증 함수 만들기: isValue / assertValue

enum은 런타임 객체가 있으니 검증이 쉬워 보이지만, const 객체도 동일하게 가능합니다. 오히려 타입 추론이 더 예쁘게 떨어지는 경우가 많습니다.

export const OrderState = {
  Created: "created",
  Shipped: "shipped",
  Delivered: "delivered",
} as const;

export type OrderState = (typeof OrderState)[keyof typeof OrderState];

const orderStateValues = Object.values(OrderState);

export function isOrderState(v: unknown): v is OrderState {
  return typeof v === "string" && (orderStateValues as readonly string[]).includes(v);
}

export function assertOrderState(v: unknown): asserts v is OrderState {
  if (!isOrderState(v)) {
    throw new Error("Invalid OrderState");
  }
}

포인트는 Object.values의 타입이 완벽히 좁혀지지 않는 경우가 있어, readonly string[] 캐스팅을 최소 범위로 사용하는 것입니다.

TS 5.5에서 더 깔끔해지는 패턴: satisfies로 형태 보장

as const는 리터럴 고정에는 좋지만, “형태가 특정 레코드 구조를 만족하는지”까지는 명시적으로 표현하기 어렵습니다. 이때 satisfies를 함께 쓰면 좋습니다.

예를 들어 “모든 값은 대문자 스네이크 케이스 문자열이어야 한다” 같은 룰을 강제하고 싶다면, 최소한 Record 형태라도 보장할 수 있습니다.

type StatusMap = Record<string, string>;

export const STATUS = {
  Pending: "PENDING",
  Paid: "PAID",
  Failed: "FAILED",
} as const satisfies StatusMap;

export type Status = (typeof STATUS)[keyof typeof STATUS];
  • satisfies는 값의 리터럴 타입을 보존하면서
  • 지정한 타입 제약을 만족하는지 검사합니다

as constsatisfies는 경쟁 관계가 아니라 조합 관계입니다.

흔한 실수 1: as const를 빼서 유니온이 사라짐

const Role = {
  Admin: "admin",
  User: "user",
};

type Role = (typeof Role)[keyof typeof Role];
// 결과: string (원하는 "admin" | "user"가 아님)

해결:

const Role = {
  Admin: "admin",
  User: "user",
} as const;

type Role = (typeof Role)[keyof typeof Role];

흔한 실수 2: 키/값 혼용으로 API에 잘못된 값 전송

UI에서는 "Paid"를 쓰고, 서버에는 "PAID"를 보내야 하는데, 개발 중에 섞이면 버그가 됩니다. 이를 방지하려면 타입을 분리하고 변환 함수를 두는 편이 안전합니다.

export const PaymentStatus = {
  Pending: "PENDING",
  Paid: "PAID",
  Failed: "FAILED",
} as const;

export type PaymentStatusKey = keyof typeof PaymentStatus;
export type PaymentStatusValue = (typeof PaymentStatus)[PaymentStatusKey];

export function toPaymentStatusValue(k: PaymentStatusKey): PaymentStatusValue {
  return PaymentStatus[k];
}

이렇게 하면 API 레이어에서는 PaymentStatusValue만 받도록 강제할 수 있습니다.

enum을 완전히 버리기 어려운 경우와 타협점

1) 외부 라이브러리가 enum을 요구하는 경우

예: 어떤 SDK가 SomeEnum.SomeValue 형태로 받도록 설계된 경우. 이때는 내부 도메인에서는 const 패턴을 쓰고, 경계에서만 매핑하는 것이 좋습니다.

// 내부 도메인
export const Role = {
  Admin: "admin",
  User: "user",
} as const;
export type Role = (typeof Role)[keyof typeof Role];

// 경계 어댑터
export function toSdkRole(role: Role) {
  switch (role) {
    case Role.Admin:
      return "SDK_ADMIN";
    case Role.User:
      return "SDK_USER";
  }
}

2) 숫자 기반 플래그 비트마스크가 필요한 경우

이건 enum이 여전히 편할 수 있습니다. 다만 가능하면 명시적 상수(const)로도 충분합니다.

export const Permission = {
  Read: 1,
  Write: 2,
  Execute: 4,
} as const;

export type Permission = (typeof Permission)[keyof typeof Permission];

export function hasPermission(mask: number, p: Permission) {
  return (mask & p) === p;
}

마이그레이션 전략: enum에서 const 객체로 안전하게 옮기기

레거시 코드에서 enum을 한 번에 없애기는 어렵습니다. 안전한 순서는 아래가 현실적입니다.

  1. 기존 enum과 동일한 값의 const 객체를 새로 만든다
  2. 새 코드부터는 const 타입을 사용한다
  3. 핵심 경로(도메인/DTO/유효성 검사)부터 점진적으로 치환한다
  4. 마지막에 enum을 제거한다

예시:

// legacy
export enum LegacyStatus {
  Active = "active",
  Inactive = "inactive",
}

// new
export const Status = {
  Active: "active",
  Inactive: "inactive",
} as const;

export type Status = (typeof Status)[keyof typeof Status];

// 호환 레이어
export function legacyToNew(s: LegacyStatus): Status {
  return s;
}

값이 동일한 string literal이라면 변환 비용이 사실상 0에 가깝습니다.

Next.js/번들 관점에서의 실익

enum의 런타임 객체는 생각보다 자주 “원치 않는 공유 상태”처럼 느껴질 때가 있습니다. 특히 SSR과 CSR이 섞인 환경에서 값이 직렬화/역직렬화되거나, 모듈 초기화 순서에 민감한 코드가 끼어들면 디버깅 난이도가 급상승합니다.

반면 const 객체 패턴은

  • 타입은 컴파일 타임에서만 사용
  • 런타임 값은 단순 객체(또는 아예 인라인 문자열)

로 정리되기 때문에, “타입을 위해 런타임을 추가하는” 상황을 줄이는 데 도움이 됩니다. SSR/CSR 경계 디버깅 이슈가 잦다면 Next.js Hydration mismatch 원인 7가지와 해결법에서 말하는 것처럼 “서버와 클라이언트의 결과가 달라지는 지점”을 최소화하는 전략과도 결이 같습니다.

또한 CI에서 번들 최적화나 캐시 효율을 챙기는 팀이라면, 빌드 산출물의 변동 폭이 줄어드는 것이 장점이 될 수 있습니다. 관련해서는 GitHub Actions 캐시 미스 원인 7가지와 해결도 같이 보면, 작은 코드 패턴 차이가 캐시 히트율에 간접적으로 영향을 줄 수 있다는 감을 잡는 데 도움이 됩니다.

결론: TS 5.5에서의 권장안

  • 단순 상수 집합 표현은 enum보다 const 객체 + as const가 더 번들 친화적이고 타입 확장성이 좋습니다.
  • 키/값 유니온을 동시에 뽑아 쓸 수 있어 API 계층과 UI 계층을 분리하기 쉽습니다.
  • satisfies를 조합하면 “리터럴 타입 유지”와 “형태 제약”을 동시에 가져갈 수 있습니다.
  • 레거시 enum은 경계에서만 유지하고 내부 도메인은 const 패턴으로 점진 전환하는 것이 현실적인 마이그레이션 전략입니다.

실무에서 중요한 건 문법 취향이 아니라, 타입 안정성과 런타임 비용을 어떻게 균형 있게 줄이느냐입니다. TS 5.5 환경에서는 그 균형점이 const 객체 패턴 쪽으로 더 명확히 이동했다고 보는 편이 안전합니다.