typeORM 에서 enum 잘 사용하기

7 minute read
2024-07-04

최근 진행한 토이 프로젝트에서 주문 상태를 나타내기 위해 열거형을 사용하고 있었다.


Order.enum.ts
export enum OrderStatus {
  PENDING = 'PENDING',
  IN_PROGRESS = 'IN_PROGRESS',
  COMPLETED = 'COMPLETED',
}


enum 에서 각 항목을 문자열로 지정하지 않으면 순서대로 정수가 지정되기 때문에 변경에 유연해지지 못한다. 순서를 변경하면 의도치 않게 동작한다.


Enum 을 정수로 지정한다면

Before & After Enum
// BEFORE
enum OrderStatus {
  PENDING,
  IN_PROGRESS,
  COMPLETED,
}
 
// AFTER
enum OrderStatus {
  PENDING,
  COMPLETED,
  TAKE_OUT,
}


원래 기획 단계에선 음료 제조 전 상태인 준비중, 제조 중, 제조 완료 으로 생각했었다. 하지만 실제로는 달랐다. 인원이 충분하지 않아서 음료를 제조할 때 제조 중으로 일일히 바꾸기는 어려웠다.


현실에서는 제조 팀에서 제조 완료 체크는 필수적으로 했고, 음료를 전달하는 팀에서는 이 음료를 전달 완료 체크가 더 중요했었다.


이처럼 열거형 데이터가 바뀐다고 하면, 순서도 변경된다. 기본적으로 열거 항목의 값은 0부터 시작되기 때문에 아래와 같이 취급된다.


OrderStatus.IN_PROGRESS === OrderStatus.COMPLETED  // 동일한 2
OrderStatus.COMPLETED === OrderStatus.TAKE_OUT     // 동일한 3


따라서 Enum 을 사용할 때는 기본값인 정수를 가지게 해선 안된다. typeORM 에서 사용할 때, 문자열을 값을 하나씩 지정해서 Entity 에 적용해주었다.


Order.entity.ts
@Entity({ name: 'orders' })
export class Order {
  // ...
  
  @Column({
    type: 'enum',
    enum: OrderStatus,
    default: OrderStatus.PENDING,
  })
  status: OrderStatus;
  
  // ...
}


이걸로 충분해보일 수 있지만 여기서 enum 을 더 잘 사용하는 방법을 찾게 되었다. (링크)


ts-jenum

이 라이브러리는 타입스크립트에서 enum 을 자바 스타일로 사용할 수 있는 라이브러리다.


단 하나의 문자열만 가질 수 있는 타입스크립트 Enum 보다 각 항목에 여러 상태들을 가질 수 있다.


여러 상태를 가질 수 있는 Enum

OrderStatus.enum.ts
@Enum('code')
export class OrderStatus extends EnumType<OrderStatus>() {
  static readonly PENDING = new OrderStatus('PENDING', '준비중', '🙏');
  static readonly COMPLETED = new OrderStatus('COMPLETED', '제조 완료', '🎉');
  static readonly TAKE_OUT = new OrderStatus('TAKE_OUT', '테이크 아웃', '✅');
  
  
  private constructor(
    readonly _code: string,
    readonly _name: string,
    readonly _emoji: string,
  ) {
    super();
  }
  
  // 식별자 조회를 위한 getter (@Enum 인자)
  get code() {
    return this._code;
  }
  
  // ...
}


기본 enum 에선 code 만 가지고 있었다면, 같은 상태일 때 가지는 name 과 emoji 를 세트로 지정 가능해진다. code 는 내부에서만 사용하는 값이고 클라이언트에는 name 과 emoji 를 노출할 때 따로 지정할 필요가 없어진다.


used by client
function MyComponent() {
  const { data: order } = useOrder(orderId);
  
  return (
    <>
    	<div>{order.status.emoji}</div>
    	<div>{order.status.name}</div>
    </>
  )
}


만약 name 이 변경되었을 때, 서버와 클라이언트 코드 모두 수정이 필요해지지 않고 서버 단에서 수정하면 빠르게 변경 가능해진다.


행위를 가질 수 있는 Enum

그리고 결국 클래스를 사용했기 때문에 행위를 가질 수 있어진다. 즉, 행위에 대한 코드(메소드)를 상태와 같은 곳에 모아둘 수 있다.


즉, 관심사를 분리할 수 있게 된다. 만약 status 에 대한 토스트 문구를 클라이언트에 앞단에 내려 보내야한다고 하자. 쉬운 방법으로는 아래와 같이 Order 도메인에서 작성할 수 있다.


class Order {
  status: OrderStatus
  
  getMessageWithEmoji() {
    return `${this.status.emoji} ${this.status.name} 입니다!`;
  }
}


크게 문제가 없어보이지만, OrderStatus 에 대한 내부 프로퍼티를 알아야 한다. 또한 OrderStatus 의 구성요소가 바뀌게 된다면 Order 도 같이 수정해야하기 때문에 변경에 유연하지 못해진다.


이를 개선하기 위해선 Order 에서 OrderStatus 에 메시지를 보내서 객체 간 협력 으로 해결할 수 있다.


OrderStatus method
@Enum('code')
export class OrderStatus extends EnumType<OrderStatus>() {
  // ...
  
  get name() {
    return this._name;
  }
  
  get emoji() {
    return this._emoji;
  }
  
  getMessageWithEmoji() {
    return `${this.emoji} ${this.name} 입니다!`;
  }
}


같은 행위를 하는 메소드를 OrderStatus 에 위치시키고 Order 객체는 이를 호출하기만 하면 된다.


Order.entity.ts
class Order {
  status: OrderStatus
  
  getStatusMessage() {
    return this.status.getMessageWithEmoji();
  }
}


typeORM 과 ts-jenum 같이 사용하기

단순 enum 보다


같이 사용해야하는 typeORM 의 transformer 가 있다.


시스템 간 차이를 위한 transformer

transformer 는 어플리케이션 레벨에서의 타입과 데이터베이스 레벨에서의 타입 간 차이를 위한 변환을 담당한다.


image-20240705122846478


코드에서는 ValueTransformer 인터페이스를 구현해주면 된다.


OrderStatus.transformer.ts
// 'PENDING' | 'COMPLETED' | 'TAKE_OUT'
type OrderStatusEnum = EnumConstNames<typeof OrderStatus>;
 
export class OrderStatusTransformer implements ValueTransformer {
  to(entityValue: OrderStatus): OrderStatusEnum {
    return entityValue.enumName as OrderStatusEnum; // enumName 이 string 으로 추론되기 때문
  }
 
  from(databaseValue: OrderStatusEnum): OrderStatus {
    return OrderStatus.valueOf(databaseValue);
  }
}
 


어플리케이션 레벨에서는 ts-jenum 을 사용하고 데이터베이스에 적재할 때는 문자열로 사용하기 위한 변환 클래스다.


적용할 때는 아래와 같이 적용하면 된다.


@Column({
  type: 'enum',
  enum: OrderStatus.values(),  // ['PENDING', 'COMPLETED', 'TAKE_OUT']
  transformer: new OrderStatusTransformer(),
  default: OrderStatus.PENDING.enumName,     // 'PENDING'
})
status: OrderStatus = OrderStatus.PENDING;


마무리

이렇게 typeORM 에서 열거형을 잘 다루기 위해 ts-jenum 을 사용하는 방법에 대해 알아보았다.


단순하게 라이브러리 사용법을 넘어서 왜 쓰는가에 대해 고민할 수 있는 좋은 시간이었다. Order 와 OrderStatus 는 서로 긴밀한 관계를 가지지만, 서로의 관심사에 따라 코드를 잘 배치하면 변경에 유연함 을 얻을 수 있었다.


그리고 여기선 다루지 않았지만, MySQL 에서 enum 타입을 사용할 때, 내부적으로 정수로 저장된다고 한다. (링크) 그리고 처음 알게 된 건 enum 이 RDBMS 에서 표준이 아니란 사실이었다.


다른 타입으로 varchar 으로 사용하는 방법도 있지만, 한글을 저장하기 위해 설정하는 문자 세트 utf8mb4 인 경우에는 문자당 최대 4바이트를 저장하는 varchar 는 enum 타입보다 저장공간을 많이 사용하는 단점이 있었다.


그리고 strict mode 를 사용하지 않을 때, enum 에 없는 값을 넣으면 빈값이 저장되는 사이드 이펙트도 존재했기 때문에 이를 인지하고 사용하거나 안전하게 strict mode 를 사용해야하는 부분도 추가적으로 알게 되었다.


여러 방법으로 문제를 개선할 수 있다면, 현재 상황에서 가장 적합한 장점을 취하고 단점을 최소화하는 선택지를 고민하는 습관을 기르면 좋을 것 같다.


#database