정확한 정보 전달이 아닌, 여러 좋은 포스팅들을 보며 적용해보고
개인의 관점에서의 의견 서술입니다. 여러 피드백들을 적극 환영합니다.
요약
트리 쉐이킹(Tree Shaking)은 번들링 시 사용하지 않는 불필요한 코드를 제거하는 최적화 작업을 말한다.
프론트에서의 트리쉐이킹은 번들의 크기를 최소화해서 UX의 향상에 목적이 있다고 하지만 백엔드 관점에서의 최적화는 코드의 안정성, 유지보수 등에 초점이 맞춰지고, 프로젝트의 특성과 요구사항과 등을 고려하는 것이 좋다고 생각한다.
TypeScript4.1에 추가된 Template Literal Type처럼, 명시된 타입들을 조합하는 복잡한 타입 조합이 필요하지 않을 경우, 이넘을 사용하는 것이 어떠한 이넘 값으로 강제되기 때문에 오히려 더 명확한 의도를 전달할 수도 있다.
Nest에서의 요청
@ApiTags("product")
@Controller("/api/v1/product")
export class ProductDetailController implements DetailController {
constructor (private productService: ProductService) {}
@ApiOperation({ summary: "상품 상세" })
@ApiQuery({ name: "no", description: "상품 번호 PK", required: true })
@UseGuards(VisitorGuard)
@Get()
async getProductDetail(@Query("no") no: number) {
return await this.productService.getProductDetail(no);
}
}
위와 같은 컨트롤러에, 상품번호로 요청을 보낼 때, no는 number이 아니여도, NaN으로 비즈니스 로직까지 타고 넘어가서 동작하게 된다.
TypeScript에서 타입을 명시해도, 이는 컴파일 단계에서만 적용되는 타입 어노테이션이다.
실제 런타임에서는 데이터 타입을 변환하거나, 검증해주지 않는다는 소리다.
따라서 API 호출 시 쿼리 파라미터로 전달된 값이 숫자가 아닌 문자여서 서버 측에서 이를 숫자로 변환하려할 때,
런타임은 JavaScript환경이기 때문에 NaN으로 흘러가게 되는 것이다.
그렇기 때문에, 보통은 불필요한 자원 낭비를 피하기위해 Validation Pipe를 사용하거나, 객체로 변환해서 Class-Validator을 사용하곤 한다. 앞 단에서 걸러주지 않으면, 결국 비즈니스 로직에서 DB Connection까지 흘러가서 쿼리 파라미터 에러가 발생할 것이다.
어떤 요청이 컨트롤러에 도달하기 전에, Pipes에서 걸러지냐, NaN으로 비즈니스 끝까지 들어가서 쿼리에서 에러를 뱉어내냐는 꽤나 중요한 문제다. 불필요한 리소스를 낭비할 수 있다.
실제로는 number pk값과 같은 것들은 +를 붙여 number로 캐스팅하여 넘기기도 한다.
나는 예시를 들기위해 아래처럼 커스텀파이프를 만들었다.
실제로 간단한 Number검증은 기본적으로 제공하는 ParseIntPipe를 사용해도 된다.
@ApiTags("product")
@Controller("/api/v1/product")
export class ProductDetailController implements DetailController {
constructor (private productService: ProductService) {}
@ApiOperation({ summary: "상품 상세" })
@ApiQuery({ name: "no", description: "상품 번호 PK", required: true })
@UseGuards(VisitorGuard)
@Get()
async getProductDetail(@Query("no", ValidationNumberPipe) no: number) {
return await this.productService.getProductDetail(no);
}
}
//의존성 주입을 통해 모듈 전역에서도 사용이 가능하다.
export class ValidateNumberPipe implements PipeTransform<string> {
transform(value: string, metadata: ArgumentMetadata): number {
const val = parseInt(value, 10);
if (isNaN(val)) {
throw new BadRequestException('Validation failed');
}
return val;
}
}
종종 위처럼 Pipe의 구현체인 ValidationPipe를 사용하지 않고, 직접 구현해서 사용했었다.
여러 데이터들이 들어와 DTO를 구현해서 요청을 받을 때는, Class-Validator을 사용하기도 한다.
export class UserInfo {
@IsString()
@IsNotEmpty()
@Length(4, 20)
id: string;
@IsString()
@IsNotEmpty()
@Length(4, 20)
pw: string;
@IsString()
@IsNotEmpty()
@Length(10)
name: string;
}
이넘과 리터럴 타입
하지만, 조금 더 특정 데이터만 들어올 수 있게 강제하기위해 이넘타입을 많이 사용했었다.
이 이유에는, 타입스크립트 전에 스프링을 사용했기 때문도 있는 것 같다.
export enum RsvCategory {
DEFAULT = '전체',
WEDDINGHALL = '웨딩홀',
HANBOK = '한복',
DRESS = '예복',
GIFT = '예물',
APPLIANCES = '혼수가전'
}
export enum RsvSort {
DEFAULT = '전체',
COMPLETE = '답변완료',
WAITING = '답변대기'
}
위의 코드들은 비교적 최근에 작업한 프로젝트 중 일부로, 데이터 조회를 위한 쿼리들에 이넘타입을 선언한 모습이다.
라인 기술블로그의 타입스크립트에서 이넘을 사용하지 않는게 좋은 이유 라는 포스팅을 보고나서 확인차 실제로 코드를 작성해보았다.
아래는 각각 이넘과 리터럴타입의 트랜스파일 코드들이다.
enum COIN {
BITCOIN, ALTCOIN
}
enum ALTCOIN {
ETHEREUM, RIPPLE, LITECOIN, DASH, MONERO, ZCASH, NEM, BITCOIN_CASH, DOGECOIN
}
var COIN;
(function (COIN) {
COIN[COIN["BITCOIN"] = 0] = "BITCOIN";
COIN[COIN["ALTCOIN"] = 1] = "ALTCOIN";
})(COIN || (COIN = {}));
var ALTCOIN;
(function (ALTCOIN) {
ALTCOIN[ALTCOIN["ETHEREUM"] = 0] = "ETHEREUM";
ALTCOIN[ALTCOIN["RIPPLE"] = 1] = "RIPPLE";
ALTCOIN[ALTCOIN["LITECOIN"] = 2] = "LITECOIN";
ALTCOIN[ALTCOIN["DASH"] = 3] = "DASH";
ALTCOIN[ALTCOIN["MONERO"] = 4] = "MONERO";
ALTCOIN[ALTCOIN["ZCASH"] = 5] = "ZCASH";
ALTCOIN[ALTCOIN["NEM"] = 6] = "NEM";
ALTCOIN[ALTCOIN["BITCOIN_CASH"] = 7] = "BITCOIN_CASH";
ALTCOIN[ALTCOIN["DOGECOIN"] = 8] = "DOGECOIN";
})(ALTCOIN || (ALTCOIN = {}));
//# sourceMappingURL=coin.enum.js.map
type Coin = 'BITCOIN' | Altcoin;
type Altcoin = 'ETHEREUM' | 'RIPPLE' | 'LITECOIN' | 'DASH' | 'MONERO' | 'ZCASH' | 'NEM' | 'BITCOIN_CASH' | 'DOGECOIN';
let myCoin: Coin = 'BITCOIN';
let myAltcoin: Altcoin = 'ETHEREUM';
let myCoin = 'BITCOIN';
let myAltcoin = 'ETHEREUM';
//# sourceMappingURL=coin.type.js.map
라인의 포스팅에서는, 과도한 이넘 타입의 설정은 번들링 시 코드 최적화에 실패하여 유저에게 최적의 UX를 제공하지 못한다. 라고 해석되었다.
이를 해소하기위해 위처럼 유니온 타입을 사용하길 권장했고, 더 나아가서 유니온타입을 유연하게 사용하기 위해 템플릿 리터럴 타입도 생각해봄직 한 것 같았다.
사용해보기
우선 기존 프로젝트 중 분석에 대한 기능을 담당하는 코드들을 조금 손보기로 했다.
type ReadonlyRecord<K extends string, V> = Readonly<Record<K, V>>;
//step1
export type TStudioConcept = '인물 중심' | '다양한 배경' | '인물 + 배경';
export type TDressMood = '심플함' | '화려함';
export type TDressMaterial = '실크' | '레이스' | '비즈';
export type TMakeupStyle = '과즙/생기' | '깨끗/청초/화사' | '윤곽/음영';
//step2
export type TBudget = '100만원대' | '200만원대' | '300만원대' | '400만원대 이상';
//step3
export type TBodyType = '슬림' | '평균' | '통통';
export type TPeriod = '44사이즈' | '55사이즈' | '66사이즈' | '77사이즈 이상';
//step1
export const StudioConcept: ReadonlyRecord<TStudioConcept, TStudioConcept> = {
'인물 중심': '인물 중심',
'다양한 배경': '다양한 배경',
'인물 + 배경': '인물 + 배경'
};
export const DressMood: ReadonlyRecord<TDressMood, TDressMood> = {
'심플함': '심플함',
'화려함': '화려함'
};
export const DressMaterial: ReadonlyRecord<TDressMaterial, TDressMaterial> = {
'실크': '실크',
'레이스': '레이스',
'비즈': '비즈'
};
export const MakeupStyle: ReadonlyRecord<TMakeupStyle, TMakeupStyle> = {
'과즙/생기': '과즙/생기',
'깨끗/청초/화사': '깨끗/청초/화사',
'윤곽/음영': '윤곽/음영'
};
//step2
export const Budget: ReadonlyRecord<TBudget, TBudget> = {
'100만원대': '100만원대',
'200만원대': '200만원대',
'300만원대': '300만원대',
'400만원대 이상': '400만원대 이상'
};
//step3
export const BodyType: ReadonlyRecord<TBodyType, TBodyType> = {
'슬림': '슬림',
'평균': '평균',
'통통': '통통'
};
export const Period: ReadonlyRecord<TPeriod, TPeriod> = {
'44사이즈': '44사이즈',
'55사이즈': '55사이즈',
'66사이즈': '66사이즈',
'77사이즈 이상': '77사이즈 이상'
};
여기서 ReadonlyRecord타입은, as const처럼 변경 불가능한 읽기전용 객체임을 편하게 명시하기 위함이다. 이넘을 타입으로 변경하려다보니, 프로퍼티 값들을 고정시켜 이넘처럼 사용할 수 있게 하여 일관성과 안정성을 유지하고자 했다.
이제 이를, Reuqest DTO에 각 섹션마다 뿌려주면 되었고, IsEnum을 사용하여 이넘 타입과 똑같이 벨리데이션이 가능했다.
export class AnaylsisStep1 {
@IsNotEmpty()
@IsEnum(StudioConcept)
@ApiProperty({ enum: StudioConceptProperty, description: '스튜디오 컨셉', required: true })
sConcept: TStudioConcept
@IsNotEmpty()
@IsEnum(DressMood)
@ApiProperty({ enum: DressMoodProperty, description: '드레스 분위기', required: true })
dMood: TDressMood;
@IsNotEmpty()
@IsEnum(DressMaterial)
@ApiProperty({ enum: DressMaterialProperty, description: '드레스 소재', required: true })
dMaterial: TDressMaterial;
@IsNotEmpty()
@IsEnum(MakeupStyle)
@ApiProperty({ enum: MakeupStyleProperty, description: '메이크업 스타일', required: true })
mStyle: TMakeupStyle;
}
export class AnaylsisStep2 {
@IsNotEmpty()
@IsEnum(Budget)
@ApiProperty({ enum: BudgetProperty, description: '예산', required: true })
budget: TBudget;
}
export class AnaylsisStep3 {
@IsNotEmpty()
@IsEnum(BodyType)
@ApiProperty({ enum: BodyTypeProperty, description: '몸매 타입', required: true })
bodyType: TBodyType;
@IsNotEmpty()
@IsEnum(Period)
@ApiProperty({ enum: PeriodProperty, description: '체형', required: true })
period: TPeriod;
@IsNotEmpty()
@IsBoolean()
@ApiProperty({ description: '브랜드 인지도', required: true })
bAwareness: boolean;
}
export class StyleAnalysisRequestDto {
@ValidateNested()
@Type(() => AnaylsisStep1)
@IsNotEmpty()
@ApiProperty({ type: AnaylsisStep1 })
step1: AnaylsisStep1;
@ValidateNested()
@Type(() => AnaylsisStep2)
@IsNotEmpty()
@ApiProperty({ type: AnaylsisStep2 })
step2: AnaylsisStep2;
@ValidateNested()
@Type(() => AnaylsisStep3)
@IsNotEmpty()
@ApiProperty({ type: AnaylsisStep3 })
step3: AnaylsisStep3;
}
마무리하며
백엔드 관점에서, 빌드된 서버 코드들이 모두 클라이언트에게 넘어가는 것이 아니기 때문에, 반드시 이넘타입을 변경해야 할까? 라는 생각이 들었다. 프론트 개발에서 중요한 번들 크기 최적화 같은 이슈가, 백엔드에서는 크게 중요하지 않을 수 있다.
Class-Validator이나, Swagger Response등 객체를 반드시 집어넣어야 동작하는 프레임워크의 내장 모듈이나 외부 라이브러리 등을 사용할 때, 단순 이넘을 선언하는 것이 편리했다. 리터럴 타입은 반드시 객체를 한번 더 선언해야만 동작했기 때문이다. 분명 개발 편의성과 효율성에서 차이가 있다.
마지막으로 선호도 차이도 있다. 자바 진영에서 타입스크립트로 넘어왔는데, Class사용이 가능하고, Enum도 마찬가지로 사용이 가능하기 때문에 둘을 선호해서 사용했던 것 같다.
종합해보면, 코드의 안정성과 기타 필수로 갖춰야 할 코드베이스들을 해치지 않으면서, 프로젝트의 성격 / 팀원들의 성향 또는 개인의 성향에 맞게 작성하면 될 것 같다.는 결론이 나왔다.
2주일 정도 구현도 직접 해보고, 포스팅을 위한 고민을 했다. 좋은 포스팅을 여러개 모아서 스토리북처럼 읽어가면서 직접 구현해보면서 생각의 풀을 넓힐 수 있는 좋은 시간이였던 것 같다.
참조
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!