본문 바로가기
백엔드 개발

#027. TS 타입 좁히기(1) Tagged Union

by iamjoy 2023. 3. 8.

TS는 결국 코드 작업 or 빌드 시에 타입을 관리해주는 것이 가장 중심 아이디어이다보니 타입을 늘리거나 좁히는 작업은 그 중요성이 크다. 그래서 조금 더 자세히 적어보려고 한다.

개념

'타입 넓히기'와 반대로 '타입 좁히기'는 TS가 넓은 타입에서 좁은 타입으로 진행하는 과정을 말한다. 원하지 않는 타입(undefined, null)을 제거하거나 특정 타입만 찾는(filter, 태그된 유니온(tagged union)) 방법으로 구현할 수 있다.

이 중 타입 좁히기 첫번째로 Tagged Union을 두 가지 예시를 통해 이용해 살펴보려고 한다.
Tagged Union은 타입을 좁히는 패턴 중 하나로 명시적인 '태그'를 붙여서 타입을 좁히는 방법이다.
이 타입을 줄이는 패턴은  이펙티브 타입스크립트 28장의 유효한 상태만 표현하는 타입 지향하기와 찰떡 궁합이다.
28장을 간단히 요약하자면 여러가지 상태가 있을 수 있다면 관리하는 변수를 하나로 좁혀서 그것을 기준으로 관리해야한다는 내용인데,
이를 tagged union에 대입해본다면 관리할 상태값을 type <-하나로만 생각하고 관리하면 나머지는 그 type 값에 따라서 자동으로 관리된다고 생각해볼 수 있다. 

책에 있는 tagged union 코드를 가져와보면 아래 예시 코드와 같다.
- 업로드와 다운로드에 필요한 상태 값들이 서로 다르다.
- 이 때 이 두 상태를 관리하는 한 단계 추상화된 type을 생성한다. 여기에서는 AppEvent로 각 다른 두 상태(업로드 다운로드)를 union 타입으로 가진다
- 실제 어떤 이벤트가 일어나는 지를 판단할 때에는 입력된 AppEvent의 type으로 분기를 친다.
- 다시 말해 여기에서 관심을 가지는 상태는 각 이벤트의 'type' 값이다.

이렇게 하면 들어오는 값이 content를 가지는지 아닌지 신경쓰지 않아도 되고, 또는 UploadEvent 나 DownloadEvent 외에 또 다른 이벤트 타입이 오더라도 type으로 분기를 만들어주면 되므로 변화에도 열린 (easy to change) 패턴을 만들 수 있다.

interface UploadEvent {
    type: 'upload';
    filename: string;
    content: string;
}

interface DownloadEvent {
    type: 'download';
    filename: string;
}

type AppEvent = UploadEvent | DownloadEvent;

function handleEvent(event: AppEvent) {
    switch (event.type) {
        case "download":
            console.log(event.filename);
            break;
        case "upload":
            console.log(event.filename, event.content);
            break;
    }
}


실제로 사용된 예시를 두 가지를 사용해서 설명해보려고 한다.

예시 코드 (1) NestJs의 microService의 모듈 타입 주입

[ ✨ NestJS의 microService의 모듈 타입 주입 ]
NestJS는 많이 사용되는 monolithic 한 application architectures 외에도 microservice 형태의 구조도 지원한다. 지원하는 서비스를 microservice로 띄우려면(Instantiate) nestFactory의 createMicroservice() 를 아래와 같이 사용한다. 이 때 어떤 서비스를 띄울지 제너릭 옵션으로 넘겨주는데, 여기에 올 수 있는 여러가지 옵션들을 tagged union 형태로 관리한다.

import { NestFactory } from '@nestjs/core';
import { Transport, MicroserviceOptions } from '@nestjs/microservices';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>( //<-------- 제너릭으로 서비스 옵션 제공
    AppModule,
    {
      transport: Transport.TCP,
    },
  );
  await app.listen();
}
bootstrap();

여기에 들어가는 MicroserviceOptions 은 이 링크에서 확인할 수 있다.

간단히 살펴보면 MicroserviceOption이라는 추상화된 타입이 있고, 이 타입이 다양한 서비스의 옵션 타입들을 유니언으로 가지고 있다. 각 하위타입들은 transport?를 공통으로 가지고 있는데 이게 각 옵션들을 구분하는 구분자이다. 또 공통적으로 options들을 가지고 있는데 이 안에는 서비스마다 필요한 값들을 알아서 가지고 있다. 이렇게 tagged-union으로 구현되어있기 때문에 다양한 서비스들을 구분하는 상태값을 transport 하나로 관리할 수 있으며, 각각이 들고 있는 options 내부 값들은 알아서 관리하게 할 수 있다.

지금은 그렇게 구현되어있지 않지만 이를 이용하면 자동 추론도 가능해진다.

export type MicroserviceOptions =
  | GrpcOptions
  | TcpOptions
  | RedisOptions
  | NatsOptions
  | MqttOptions
  | RmqOptions
  | KafkaOptions
  | CustomStrategy;

/**
 * @publicApi
 */
export interface CustomStrategy {
  strategy: CustomTransportStrategy;
  options?: {};
}

/**
 * @publicApi
 */
export interface GrpcOptions {
  transport?: Transport.GRPC;
  options: {
    url?: string;
    maxSendMessageLength?: number;
    maxReceiveMessageLength?: number;
    maxMetadataSize?: number;
    keepalive?: {
      keepaliveTimeMs?: number;
      keepaliveTimeoutMs?: number;
      ..
      http2MinTimeBetweenPingsMs?: number;
      http2MinPingIntervalWithoutDataMs?: number;
      http2MaxPingStrikes?: number;
    };
    channelOptions?: ChannelOptions;
    credentials?: any;
    ...
    packageDefinition?: any;
    loader?: {
      keepCase?: boolean;
      alternateCommentMode?: boolean;
      longs?: Function;
      oneofs?: boolean;
      ...
      json?: boolean;
      includeDirs?: string[];
    };
  };
}

/**
 * @publicApi
 */
export interface RedisOptions {
  transport?: Transport.REDIS;
  options?: {
    host?: string;
    port?: number;
    retryAttempts?: number;
    retryDelay?: number;
    serializer?: Serializer;
    deserializer?: Deserializer;
  } & IORedisOptions;
}

/**
 * @publicApi
 */
export interface MqttOptions {
  transport?: Transport.MQTT;
  options?: MqttClientOptions & {
    url?: string;
    serializer?: Serializer;
    deserializer?: Deserializer;
    subscribeOptions?: {
      /**
       * The QoS
       */
      qos: QoS;
      /*
       * No local flag
       * */
      nl?: boolean;
      /*
       * Retain as Published flag
       * */
      rap?: boolean;
      /*
       * Retain Handling option
       * */
      rh?: number;
    };
    userProperties?: Record<string, string | string[]>;
  };
}


/**
 * @publicApi
 */
export interface KafkaOptions {
  transport?: Transport.KAFKA;
  options?: {
    /**
     * Defaults to `"-server"` on server side and `"-client"` on client side.
     */
    postfixId?: string;
    client?: KafkaConfig;
    consumer?: ConsumerConfig;
    run?: Omit<ConsumerRunConfig, 'eachBatch' | 'eachMessage'>;
    subscribe?: Omit<ConsumerSubscribeTopic, 'topic'>;
    producer?: ProducerConfig;
    send?: Omit<ProducerRecord, 'topic' | 'messages'>;
    serializer?: Serializer;
    deserializer?: Deserializer;
    parser?: KafkaParserConfig;
    producerOnlyMode?: boolean;
  };
}

 

예시 코드 (2) query 요청에 따라 다른 형태의 response를 내려주는 코드 

[ query 요청에 따라 다른 형태의 response를 내려주는 코드 ]
이건 회사에서 tagged union을 이용해서 구현했던 코드인데, 전체를 가져오지 못하므로 간단히 해당 부분만 설명하려고 한다.

기존의 API가 있고, 이 때 내려주는 스키마는 아래와 같았다. (스키마A)

{
    "statusCode": "OK",
    "message": "",
    "data": {
        "names": [ "student1",  "student2", "student3"]
    }
}

위의 스키마를 아래와 같이 id를 추가해 바꾸려고 하는 상황이었다.(스키마B)

{
    "statusCode": "OK",
    "message": "",
    "data": {
        "skillTags": [
            {
                "id": 64,
                "name": "student1"
            },
            {
                "id": 66,
                "name": "student2"
            },
            {
                "id": 216,
                "name": "student3"
            }
        ]
    }
}


이를 위해 URL 에 query?is-updated=true 구분자를 두어서 요청을 처리할 수 있게 했다. 문제는 내려주는 스키마가 서로 다른 상황이었다. 이를 해결하기 위해 ResponseDto라는 추상화된 DTO를 생성하고 그 하위에 각각 내려주는 스키마를 관리할 수 있는 type 맴버 변수를 추가해주었다. 

// 새 버전을 요청한 API인지 확인하고 그에 맞는 type('original'(기존) | 'renewed'(새버전)) 타입을 돌려준다. 
// 내용물(name과 id)은 공통적으로 주되 타입에 따라 알아서 가져가는 값이 달라진다.

export class FindNameByIdsDto {
  private readonly _isUpdated?: boolean;
  private readonly _names: NameIdsDto[];

  constructor(names: NameIdsDto[], isUpdated?: boolean) {
    this._isUpdated = isUpdated;
    this._names = names;
  }

  get isUpdated(): boolean {
    return !!this._isUpdated;
  }

  get type(): 'origin' | 'renewed' { //<---------- 클라이언트 요청에 따라 type 값을 다르게 준다
    if (this.isUpdated) { 
      return 'renewed';
    }

    return 'origin';
  }

  get names(): NameIdsDto[] {
    return this._names;
  }
}
// responseDto.ts  
  
  class ResponseDto {
  
	  ...
  
      getNamesByType(): dtoA[] | string[] { // <-------- 타입에 따라 리턴되는 타입 두 가지
        switch (this.getType()) { // <-------- type을 유효한 상태로 관리
          case 'renewed':
            return this.getRenewedNames(); // <-------- nestJs의 Options 값처럼 각각의 값은 자기가 가지고 싶은 값을 가질 수 있다
          case 'origin':
            return this.getOriginNames();
          default:
            return this.getOriginNames();
        }
      }
 
	 ...
 
 }