이번에는 코드 통일성의 시작과 끝 부분인 Serialization을 정리하고 넘어가보려고 합니다.
Serialization은 서버에서 이용하는 값을 클라이언트에서 필요한, 편한 값으로 변경하는 것으로 이해하시면 됩니다.
Nest.js 에서 Serialization은
1. appplication에 Pipe와 Interceptor를 설정해둔 상태에서
2. Controller handler에서 타입을 명시해두는 방식으로 진행됩니다.
// 1. appplication에 Pipe와 Interceptor를 설정해둔 상태에서
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
...
}),
);
app.useGlobalInterceptors(
new ClassSerializerInterceptor(app.get(Reflector))
)
await app.listen(3000);
}
bootstrap();
// 2. Controller handler에서 타입을 명시해두는 방식으로 진행됩니다.
// order.controller.ts
class OrderController {
@Post('/')
async orderOne(@Body() dto: CreateOrderDTO) { // Req
...
return new CreateOrderResDto(); // Res / 인스턴스로 전달되어야만 serialization이 작동됩니다.
}
}
Request Serialization
요청 데이터 serialization의 핵심 목표는 nest 앱에서 이용되는 값 타입 혹은 값 형식으로 변경시키는 것입니다.
개별 값들이 "같은 타입, 같은 형식"으로 운영되어야만 소프트웨어의 안정성이 보장됩니다. 그리고 타입, 형식이 바뀐다면 "무조건 한곳에서만" 바뀌어야합니다. 보통 DTO 계층에서만 바뀌는게 좋습니다.
1. QueryString 값 변환
queryString은 이름과 같이 모두 스트링이기때문에 타입에 맞게 변환을 시켜주어야합니다.
저희는 기본 util 함수를 만들어두고 개별 Requset DTO에서 사용하고 있습니다.
Util 함수들
interface ToNumberOptions {
default?: number;
min?: number;
max?: number;
}
export function toLowerCase(value: string): string {
return value.toLowerCase();
}
export function trim(value: string): string {
return value.trim();
}
export function toDate(value: string): Date {
return new Date(value);
}
export function toBoolean(value: string): boolean {
value = value.toLowerCase();
return value === 'true' || value === '1' ? true : false;
}
export function toNumber(value: string, opts: ToNumberOptions = {}): number {
let newValue: number = Number.parseInt(value || String(opts.default), 10);
if (Number.isNaN(newValue)) {
newValue = opts.default;
}
if (opts.min) {
if (newValue < opts.min) {
newValue = opts.min;
}
if (newValue > opts.max) {
newValue = opts.max;
}
}
return newValue;
}
아래와 같이 이용합니다.
export class OrderQueryDto {
@Transform(({ value }) => toNumber(value, { default: 1, min: 1 }))
@IsNumber()
@IsOptional()
page: number = 1;
@Transform(({ value }) => toBoolean(value))
@IsBoolean()
@IsOptional()
withSomething: boolean = false;
}
2. 데이터 형식 변환
클라이언트에서 이용하는 데이터와 서버내에서 이용하는 데이터의 형식이 다른 경우는 꽤나 흔합니다. 대표적으로 클라이언트와 서버에서 이용하는 날짜, 시간 형식이 다를 때가 있습니다. 간단한 상황으로 String으로 들어오는 시간 데이터를 ISO 형식으로 바꿀수도 있습니다.
export class TimeConditionDto {
@IsString()
@Transform(({ value }) => new Date(value).toISOString()})
fromTime: string; // ISO 8601 형식의 문자열로 변환되므로 string으로 타입을 설정합니다.
}
3. 값 senitization
DTO의 제일큰 존재 이유가 인풋 값을 점검하는 것이겠죠.
DTO 클래스 내에서 class-validator를 이용하여 들어온 값들을 점검한 뒤에 가능한 값이라면 controller handler로 넘겨줍니다. 올바르지않다면 Exception을 발생시킵니다.
export class GetEmploymentFilterDto {
@IsOptional()
@IsArray()
addresses?: KOREA_GOON_GOO_QUERY_TYPE[];
@ApiPropertyOptional({
example: [SALARY.DAY, SALARY.YEAR],
description: `possible values: ${Object.values(SALARY)}`,
})
@IsEnum(SALARY, { each: true })
@IsOptional()
@IsArray()
types?: SALARY_TYPE[];
Response Serialization
1. 클라이언트에게 보이면 안되는 값 제외시키기
흔히들 아시는 password 값은 당연히 클라이언트에게 전달하면 안되겠죠. 그럴땐 @Exclude() 데코레이터를 이용합니다.
그리고 api는 모두 네트워크 비용이 들기때문에 데이터 크기가 작을수록 좋습니다. 이 또한 @Exclude()를 이용하여 응답 객체에서 제거해줍니다.
간혹 귀찮다는 이유로 엔티티의 데이터를 다 넘겨주는 사람들이 꽤 있습니다. "프론트에서 알아서 골라서 써라." 마인드이신분들이 간혹있죠..
클라이언트에서 필요하겠다 싶은 것만 전달해주셔야 프론트분들이 훨씬 편해집니다. "~ 데이터 빠졌는데요?" 요청받기 싫으셔도 타이트하게 응닶 값을 주어야 나중을 위해 좋습니다. 나중에 API 수정할일이 있을텐데 백엔드 개발자 본인을 위해서도 좋습니다.
2. 서버에서 이용하는 데이터 형식과 클라이언트에서 보여줄 데이터 형식 변경.
디비에는 최소한의 데이터가 저장되어야합니다. 그렇기때문에 보통 클라이언트에서 필요한 데이터가 디비에 저장된 데이터보다 많습니다. 그 데이터들은 디비에 있는 데이터를 변경하거나 연산해서 생성하죠. 그 작업또한 Response DTO에서 진행합니다. 다만 비지니스 로직이 들어가면 안됩니다. 아래와 같은 경우입니다.
@Expose()
get citizenStatus(): CitizenStatus {
const birthDate = new Date(this.dateOfBirth).getFullYear();
const currentYear = new Date().getFullYear();
const age = currentYear - birthDate;
if (age < 18) {
return 'Child';
} else if (age > 18 && age < 60) {
return 'Adult';
} else {
return 'Senior';
}
}
cetizenStatus를 평가하는 것은 비지니스 로직이기때문에 서비스 레이어에서 진행되어야합니다.