TypeScript에서 문자열 리터럴 유니온 타입 가독성 높이기

2025. 4. 9. 19:29Programming/JavaScript

반응형

 

 

GitHub - zerodice0/youtube_thumbnail_generator

Contribute to zerodice0/youtube_thumbnail_generator development by creating an account on GitHub.

github.com

 

문자열 리터럴 유니온 타입이란?

  TypeScript에서 문자열 리터럴 유니온 타입(String Literal Union Type)은 변수가 특정 문자열 값들 중 하나만 가질 수 있도록 제한하는 타입 시스템 기능이다. 다른 언어의 enum값이랑 비슷한데, Typescript가 제공하는 enum값에는 알려진 문제점들이 여럿 있다고 들어서 되도록이면 enum값 대신 문자열 리터럴 유니온 타입을 사용하고 있었다.

 
  기존에는 queued, processing, completed, failed 네 가지 값만 사용하고 있었지만, 처리 상태를 조금 세분화하고자 processing을 downloading, transcribing, summarizing 총 네 개로 나누고나니 어쩐지 가독성이 떨어지는 느낌이 들었다. 

type JobStatus = 'queued' 
    | 'downloading' 
    | 'transcribing' 
    | 'summarizing' 
    | 'completed' 
    | 'failed';

  이런 식으로 문자열 리터럴 유니온 타입을 사용하면 변수의 값을 특정 문자열들로 제한함으로써 타입 안전성(Type Safety)을 높여주지만, 타입을 정의하고 사용하는 방식에 따라 코드의 가독성과 유지보수성이 크게 달라질 수 있다. 특히 문자열 리터럴 유니온 타입을 사용하면 결국 타입이 문자열이기 때문에 자동완성이 잘 안되고, 요즈음에는 AI로 인해 어느정도 해소가 되긴 하지만 오타로 인한 에러가 발생할 수 있다. 이러한 문제로 인해서 문자열 리터럴 유니온 타입의 가독성과 유지보수성을 높이는 방법에 대해 살펴봤다.

문자열 리터럴 유니온 타입의 가독성을 높이는 방법

1. 타입 별칭(Type Alias) 분리

  가장 간단하게 가독성을 높이는 방법은 다음과 같이 문자열 리터럴 유니온 타입을 별도의 파일로 분리하는 것이다. 이 경우 JobStatus를 사용하는 경우에는 import 구문을 사용해서 참조하게된다.

// lib/types/jobStatus.ts
export type JobStatus = 
  | 'queued' 
  | 'downloading' 
  | 'transcribing' 
  | 'summarizing' 
  | 'completed' 
  | 'failed';

// 사용 예시
import { JobStatus } from '@/lib/types/jobStatus';

export interface Job {
  id: string;
  status: JobStatus;
  // ... 기타 속성들
}

장점:

  • 간단하고 직관적이다
  • 별도의 런타임 오버헤드 없음
  • 타입스크립트 컴파일 후 JavaScript 코드에 영향을 주지 않음

단점:

  • 문자열 상수를 코드 여러 곳에서 하드코딩해야 할 수 있음
  • 타입과 실제 사용 값이 분리됨

2. Enum 사용

  TypeScript는 여러가지 알려진 문제가 존재하긴 하지만, enum을 제공한다. enum을 사용하면 각 상태에 의미 있는 이름을 부여할 수 있다.

export enum JobStatusEnum {
  Queued = 'queued',
  Downloading = 'downloading',
  Transcribing = 'transcribing',
  Summarizing = 'summarizing',
  Completed = 'completed',
  Failed = 'failed',
}

export interface Job {
  id: string;
  status: JobStatusEnum;
  // ... 기타 속성들
}

// 사용 예시
job.status = JobStatusEnum.Downloading;
if (job.status === JobStatusEnum.Completed) {
  // 작업 완료 시 처리
}

장점:

  • 상태 값에 명확한 이름을 부여하여 의도 파악이 쉬움
  • IDE의 자동 완성(Autocomplete) 기능이 잘 동작함
  • 리팩토링 시 변수명 일괄 변경 등이 용이함

단점:

  • 컴파일 후 JavaScript에 enum 객체가 추가되어 번들 크기가 커질 수 있음
  • 런타임 오버헤드가 약간 있을 수 있음
  • TypeScript의 enum은 다른 타입 시스템 기능들과 통합이 완벽하지 않은 경우가 있음

3. 상수 객체(Const Object) 사용

  as const assertion을 사용한 상수 객체는 enum의 이점을 유지하면서 일부 단점을 보완할 수 있다.

export const JOB_STATUS = {
  QUEUED: 'queued',
  DOWNLOADING: 'downloading',
  TRANSCRIBING: 'transcribing',
  SUMMARIZING: 'summarizing',
  COMPLETED: 'completed',
  FAILED: 'failed',
} as const;

export type JobStatus = typeof JOB_STATUS[keyof typeof JOB_STATUS];

export interface Job {
  id: string;
  status: JobStatus;
  // ... 기타 속성들
}

// 사용 예시
job.status = JOB_STATUS.DOWNLOADING;
if (job.status === JOB_STATUS.COMPLETED) {
  // 작업 완료 시 처리
}

장점:

  • 순수 JavaScript 객체와 문자열 리터럴 타입을 사용하므로 번들 크기가 작음
  • Tree-shaking이 잘 동작함
  • 타입과 값이 함께 정의되어 일관성 유지가 쉬움
  • IDE 자동 완성 지원이 좋음

단점:

  • typeof JOB_STATUS[keyof typeof JOB_STATUS] 와 같은 타입 정의 구문이 복잡해 보일 수 있음
  • 타입스크립트 초보자에게는 이해하기 어려울 수 있음

상수 객체 방식으로 리팩토링하기로 결정한 이유

  상수 객체 패턴을 사용하기로 결정한데는 몇 가지 이유가 있다.

  1. 자동 완성과 타입 안전성:
      리터럴 문자열을 직접 입력하는 방식에서는 오타가 발생하기 쉽고, IDE의 자동 완성 기능이 제한적이었다. 물론 AI가 어느정도 자동 완성을 제안해주므로 예전처럼 심하지는 않지만, AI가 추론을 통해 제시하는 자동 완성이 항상 맞다는 보장이 없었다. 거기에 AI가 잘못된 추론을 하거나 오타가 발생해서 에러가 발생하는 경우, 다시 수정을 해야되는 것도 일이었다. 상수 객체를 사용하게 되면 자동 완성 기능을 통해 사용할 수 있는 모든 상태 값을 리스트 형태로 확인할 수 있다보니, 보다 안정적인 개발 경험을 가질 수 있으리라 판단했다.
  2. 번들 크기 최적화:
      Typescript보다 다른 언어가 익숙한만큼 enum을 생각했으나, 알려진 문제점들 중 번들 크기에 영향을 미친다는 것을 알 수 있었다. 물론 번들 크기가 크게 중요한 프로젝트는 아니었지만, 굳이 문제점을 알고 있고 이런 점을 보완할 수 있는 방법이 있는데 enum을 갖다 쓸 이유가 없었다. as const를 사용한 상수 객체는 타입스크립트 컴파일 후 단순한 객체만 남기 때문에 번들 크기 최적화에 유리하다. 
  3. Tree-shaking 지원:
      번들러의 tree-shaking 기능과 함께 사용할 때 미사용 상수는 최종 번들에서 제외될 수 있어, 크게 신경스지 않아도 번들러에 의한 추가적인 최적화 가능성이 있었다. enum은 이러한 최적화가 항상 보장되지 않으므로, 이런 측면에서도 굳이 enum을 갖다 써야할 이유가 없었다.

  다른 언어에 더 익숙하다보니 자연스럽게 enum을 떠올렸지만, Typescript의 enum은 이미 최적화와 관련된 잘 알려진 문제점들을 가지고 있었다. 상수 객체 방식은 사실상 이러한 enum의 문제점을 보완하면서, 대부분의 장점을 가지고 있으므로 자연스럽게 상수 객체 방식을 선택하게 됐다.

상수 객체와 타입 추출 이해하기

  3번의 예제 코드로 돌아가서 export type JobStatus = typeof JOB_STATUS[keyof typeof JOB_STATUS]; 라인을 살펴보면, keyof와 typeof 키워드를 사용하고 있으므로 Typescript에 익숙하지 않는 나로써는 이 코드가 어떻게 동작하는지 의뭉스러웠다. 동작을 이해하기 위해 각 단계별로 따라가보며, 어떻게 동작하는지 살펴보기로 했다.

  1. JOB_STATUS 객체 정의
      export const JOB_STATUS = { QUEUED: 'queued', DOWNLOADING: 'downloading', //(생략) } as const;
      여기서 as const는 객체의 모든 속성을 불변으로 만들고, 값을 특정 리터럴 타입으로 설정한다. 
  2. typeof JOB_STATUS
      -> { readonly QUEUED: "queued"; readonly DOWNLOADING: "downloading"; //(생략) }
      typeof JOB_STATUS에서는 타입을 가져오게 되므로, 위와 같은 결과를 가지게 된다.
  3. keyof typeof JOB_STATUS
      -> "QUEUED" | "DOWNLOADING" | ... | "FAILED"
      여기서는 typeof JOB_STATUS의 키를 유니온 타입으로 추출하게 되는데, 위와 같은 결과를 가지게 된다..
  4. typeof JOB_STATUS[keyof typeof JOB_STATUS]
      -> "queued" | "downloading" | ... | "failed"
      여기서는 JOB_STATUS[keyof typeof JOB_STATUS]이 가지고 있는 모든 타입을 유니온으로 추출하므로, 위와 같은 결과를 가지게 된다.
  5. 최종 결과
      -> export type JobStatus = "queued" | "downloading" | ... | "failed";
      결과적으로 type JobStatus는 JOB_STATUS 객체의 모든 값을 허용하는 문자열 리터럴 유니온 타입이 됨과 동시에, JOB_STATUS이 가지고 있는 속성을 통해 문자열 리터럴 유니온 타입을 사용할 수 있게 된다.

리펙토링 결과

프로젝트 내에서 문자열 리터럴 유니온 타입을 상수 객체로 리펙토링한 결과는 다음과 같다.

// lib/modules/events/jobQueue.ts
export const JOB_STATUS = {
  QUEUED: 'queued',
  DOWNLOADING: 'downloading',
  TRANSCRIBING: 'transcribing',
  SUMMARIZING: 'summarizing',
  COMPLETED: 'completed',
  FAILED: 'failed',
} as const;

export type JobStatus = typeof JOB_STATUS[keyof typeof JOB_STATUS];

export interface Job {
  id: string;
  yotubeTitle: string | null;
  youtubeUrl: string;
  thumbnailUrl: string | null;
  audioFilePath: string | null;
  subtitleFilePath: string | null;
  priority: number;
  status: JobStatus;
  createdAt: Date;
  summary: string | null;
}

 

이제 컴포넌트에서 작업 상태를 처리할 때 다음과 같이, 상수 객체가 가지고 있는 속성 값을 통해 처리 상태를 구분할 수 있다.

// components/jobStateCard/jobStateCard.tsx
import { Job, JOB_STATUS } from "@/lib/modules/events/jobQueue";

const JobStateCard = ({ job }: { job: Job }) => {
  // 상태에 따른 조건부 렌더링
  return (
    <div className={styles.container + ' ' + styles[job.status]}>
      {/* ... */}
      {job.status === JOB_STATUS.COMPLETED && (
        <div>작업이 완료되었습니다!</div>
      )}
      {/* ... */}
    </div>
  );
}

어떤 방식을 선택해야 할까?

각 프로젝트 상황에 따라 적절한 방식을 선택하는 것이 중요하다:

  1. 단순하고 재사용성이 낮은 경우: 기본 문자열 리터럴 유니온 타입으로 충분
  2. 여러 곳에서 재사용이 필요한 경우: 타입 별칭을 분리하여 사용
  3. 상태 값에 의미 있는 이름이 필요한 경우:
    • 번들 크기와 퍼포먼스가 중요하다면: 상수 객체 (as const) 방식
    • 간단하고 최적화 문제가 상관이 없다면: enum 방식

  현대 TypeScript 프로젝트에서는 tree-shaking과 번들 크기 최적화를 위해 상수 객체(as const) 방식이 점점 더 선호되는 추세이다. 또한 enum방식은 다른 타입 시스템 기능들과 통합이 완벽하지 않은 경우가 있고, Tree-shaking으로 인한 최적화가 보장되지 않아 잘 선택되지 않는 추세이다. 

참고 자료

반응형