
데이터를 처리할 때 타입스크립트의 타입 안정성을 최대한 활용하려면 어떻게 해야 할까?
많은 개발자들이 편리함을 이유로 class-validator의 plainToInstance 함수를 많이 사용한다.
이러한 방식은 많은 경우에 타입의 안정성을 희생시킨다.
이 글에서는 많이 사용하는 plainToInstance의 한계를 살펴보고, DTO를 직접 생성하는 방식과 비교하여 어떤 상황에서 어떤 방식이 더 적합한지 함께 고민해보려고 한다!
plainToInstance란?plainToInstance는 class-transformer 라이브러리에서 제공하는 함수로, 평범한 JavaScript 객체(plain object)를 특정 클래스의 인스턴스로 변환시켜주는 역할을 한다.
즉, plainToInstance를 사용한다면 리터럴 객체를 쉽고 간편하게 클래스 인스턴스로 바꾸어주는 것이다.
import { plainToInstance } from 'class-transformer';
class UserDto {
name!: string;
age!: number;
}
const plainObject = { name: 'John', age: 30 };
const user = plainToInstance(UserDto, plainObject);
console.log(user instanceof UserDto); // true
console.log(user); // UserDto { name: 'John', age: 30 }
위 코드를 보면 plainToInstance가 단순 객체를 UserDto 클래스의 인스턴스로 변환하는 것을 알 수 있다.
하지만, 이렇게 간편한 방식에는 자칫 예상치 못한 문제를 초래할 수 있다.
한번 plainToInstance의 한계를 살펴보도록 하자!
plainToInstance의 한계위 코드의 plainObject를 아래처럼 조금 수정해보자
const plainObject = { name: 'John', age: '30', nationality: 'South Korea'};
const user = plainToInstance(UserDto, plainObject);
console.log(user);
// UserDto { name: 'John', age: '30', nationality: 'South Korea' }
위에서 발견할 수 있는 문제는 크게 세가지가 있다
plainToInstance는 런타임에 객체를 변환하기 때문에, 컴파일 타임에 타입 검증이 이루어지지 않아 타입 안정성이 굉장히 떨어진다.
위 예시 코드에서 UserDto의 age 프로퍼티 타입은 number이지만 plainObject 에서는 해당 값을 string값으로 할당해주었다. 그럼에도 불구하고 age의 값이 string인 채로 그대로 출력되는 것을 확인할 수 있다.
이처럼 변환 하고자하는 대상 객체에 타입이 불일치하거나, 누락된 속성이 있더라도 컴파일러는 이를 잡아내지 못한다.
plainToInstance는 평범한 객체(plain object)를 기반으로 변환하기 때문에, 변환된 인스턴스에 불필요한 속성이 포함될 가능성이 있다.
위 예시 코드에서 UserDto에는 nationality 라는 의도되지 않은 프로퍼티가 plainObject 에 포함되었지만. 해당 속성을 검증하지 않고 그대로 출력하고 있는 것을 확인할 수 있다.
class-transformer는 기본적으로 클래스에서 정의되지 않은 속성을 제거하지 않는다.
경우에 따라, 정의되지 않은 속성을 의도적으로 포함하도록 로직을 짤 수도 있지만.
어찌되었든 이처럼 사전에 정의 되지 않은 속성들이 포함되게 되면 런타임에서 예상치 못한 동작을 일으킬 수 있다.
물론 excludeExtraneousValues 옵션으로 이를 방지할 수 있지만, 해당 옵션을 별도로 설정해야 한다.
plainToInstance를 사용할 때 class-validator와 함께 사용하는 경우가 많다. 하지만 여기서 중요한 점은, 외부에서 들어오는 데이터(API 응답, DB 조회 결과 등)는 TypeScript의 타입 체크만으로는 안전하지 않다는 것이다.
// API에서 받은 데이터
const apiResponse = await fetch('/api/user').then((r) => r.json());
const user = plainToInstance(UserDto, apiResponse);
// apiResponse.age가 실제로는 string일 수도 있지만 컴파일 에러가 나지 않음
이런 경우 class-validator의 validate 함수를 함께 사용하여 런타임 검증을 해야 한다.
하지만 DTO를 직접 생성하는 방식을 사용하면, plainToInstance 의 문제들을 해결할 수 있게 된다
class UserDto {
constructor(props: { name: string; age: number }) {
this.name = props.name;
this.age = props.age;
}
name: string;
age: number;
}
const plainObject = { name: 'John', age: 30, nationality: 'South Korea' };
const user = new UserDto2(plainObject);
console.log(user); // { name: 'John', age: 30 }
위 코드에서 알 수 있듯, DTO를 생성하는 과정에서 데이터 매핑을 명시적으로 처리하기 때문에, 불필요한 속성 제거와 타입 검증이 자동으로 이루어진다.
만일 데이터가 잘못된 타입일 경우라면, 컴파일 타임에 즉시 오류를 발견할 수 있다.
const plainObject = { name: 'John', age: '30', nationality: 'South Korea' };
/** Compile Error
Argument of type '{ name: string; age: string; nationality: string; }' is not assignable to parameter of type '{ name: string; age: number; }'.
Types of property 'age' are incompatible.
Type 'string' is not assignable to type 'number'.
*/
뿐만아니라, 이런 방식은 유지보수성과 가독성을 높이는데도 도움이 된다
DTO 생성자 또는 팩토리 함수를 사용하면 데이터 변환 로직을 한곳에 모아두어, 유지보수와 테스트가 쉬워지고, 데이터 변환 과정을 명확히 볼 수 있어 코드 가독성이 높아지는 방식이기 때문이다!
plainToInstance는 class-transformer 라이브러리에 의존하지만, 직접 DTO를 생성하면 추가적인 라이브러리 의존성을 줄일 수 있다는 것도 장점이다
다만, 외부 데이터를 다룰 때는 여전히 주의가 필요하다. TypeScript의 타입 체크는 컴파일 타임에만 작동하므로, 외부 API나 DB에서 받은 데이터가 실제로 예상한 타입인지는 런타임에 검증해야 한다. 이 경우 zod나 class-validator 같은 런타임 검증 라이브러리를 함께 사용하는 것이 좋다.
plainToInstance가 적합한 경우class-validator와의 조합: 런타임 검증이 필요한 경우 validate 함수와 함께 사용import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
const apiResponse = await fetch('/api/user').then((r) => r.json());
const user = plainToInstance(UserDto, apiResponse);
const errors = await validate(user); // 런타임 검증
// 내부에서 이미 검증된 데이터
const validatedData = { name: 'John', age: 30 };
const user = new UserDto(validatedData);
plainToInstance와 DTO 직접 생성 방식은 각각의 분명한 장점과 단점이 있다.
특히 중첩된 데이터 구조같이 복잡한 데이터를 변환/처리해야하는 경우에는 plainToInstance가 간결한 대안이 될 수 있다. 다만 이런 상황에서도 정확한 검증과 타입 안정성을 확보하기 위해 DTO 생성자를 함께 활용하는 방식이 더 바람직하다.
결국 핵심은 상황에 맞게 적절한 방식을 선택하는 것이다.
데이터 변환은 타입 안정성과 유지 보수성을 동시에 고려해야한다.
• 외부 데이터라면 런타임 검증이 필수적이기에 plainToInstance + class-validator 조합이 실용적일 수 있다.
• 반면 내부 데이터나 명시적이고 의도적인 변환이 필요한 경우에는 DTO 직접 생성 방식이 더 안전하고 표현력이 좋다.
plainToInstance이 간편한 방식이긴 하지만, 실무에서는 DTO 직접 생성 방식을 활용하여 타입 안정성과 정확성을 확실히 하는 것이 중요할 수도 있다. 정답은 없다. 각 방식의 한계를 이해하고, 필요하다면 두 방식을 적절히 조합해 사용하는 것이 중요하다.
이런 접근이 간결함과 타입 안정성, 그리고 유지보수성을 모두 만족시키는 더 나은 코드로 이어질 것이다!