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();
}
}
...
}
'백엔드 개발' 카테고리의 다른 글
#031. 쿼리개선: N*M -> N+M 개선하기 (0) | 2023.04.09 |
---|---|
#030. CORS 에러 원인과 해결 (feat. 서버에서 CORS 테스트 하기) (0) | 2023.04.08 |
#029. 리팩터링: any 보다 unknown 타입 사용하기 (feat. 이중 단언문) (0) | 2023.04.05 |
#028. TS 타입 좁히기(2) Brand 사용해 nominal typing 하기 (0) | 2023.03.22 |
#026. TS 의 타입 넓히기 (0) | 2023.02.07 |