plainToInstance의 한계와 대안

영자이다·2024년 12월 21일
post-thumbnail

들어가며

데이터를 처리할 때 타입스크립트의 타입 안정성을 최대한 활용하려면 어떻게 해야 할까?
많은 개발자들이 편리함을 이유로 class-validatorplainToInstance 함수를 많이 사용한다.
이러한 방식은 많은 경우에 타입의 안정성을 희생시킨다.

이 글에서는 많이 사용하는 plainToInstance의 한계를 살펴보고, DTO를 직접 생성하는 방식과 비교하여 어떤 상황에서 어떤 방식이 더 적합한지 함께 고민해보려고 한다!

plainToInstance란?

plainToInstanceclass-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' }

위에서 발견할 수 있는 문제는 크게 세가지가 있다

1. 타입의 안정성이 떨어진다

plainToInstance런타임에 객체를 변환하기 때문에, 컴파일 타임에 타입 검증이 이루어지지 않아 타입 안정성이 굉장히 떨어진다.

위 예시 코드에서 UserDtoage 프로퍼티 타입은 number이지만 plainObject 에서는 해당 값을 string값으로 할당해주었다. 그럼에도 불구하고 age의 값이 string인 채로 그대로 출력되는 것을 확인할 수 있다.

이처럼 변환 하고자하는 대상 객체에 타입이 불일치하거나, 누락된 속성이 있더라도 컴파일러는 이를 잡아내지 못한다.

2. 불필요한 속성의 포함 가능성

plainToInstance평범한 객체(plain object)를 기반으로 변환하기 때문에, 변환된 인스턴스에 불필요한 속성이 포함될 가능성이 있다.

위 예시 코드에서 UserDto에는 nationality 라는 의도되지 않은 프로퍼티가 plainObject 에 포함되었지만. 해당 속성을 검증하지 않고 그대로 출력하고 있는 것을 확인할 수 있다.

class-transformer는 기본적으로 클래스에서 정의되지 않은 속성을 제거하지 않는다.
경우에 따라, 정의되지 않은 속성을 의도적으로 포함하도록 로직을 짤 수도 있지만.
어찌되었든 이처럼 사전에 정의 되지 않은 속성들이 포함되게 되면 런타임에서 예상치 못한 동작을 일으킬 수 있다.

물론 excludeExtraneousValues 옵션으로 이를 방지할 수 있지만, 해당 옵션을 별도로 설정해야 한다.

3. 외부 데이터에는 여전히 런타임 검증이 필요하다

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-validatorvalidate 함수를 함께 사용하여 런타임 검증을 해야 한다.

DTO 직접 생성 방식

하지만 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 생성자 또는 팩토리 함수를 사용하면 데이터 변환 로직을 한곳에 모아두어, 유지보수와 테스트가 쉬워지고, 데이터 변환 과정을 명확히 볼 수 있어 코드 가독성이 높아지는 방식이기 때문이다!

plainToInstanceclass-transformer 라이브러리에 의존하지만, 직접 DTO를 생성하면 추가적인 라이브러리 의존성을 줄일 수 있다는 것도 장점이다

다만, 외부 데이터를 다룰 때는 여전히 주의가 필요하다. TypeScript의 타입 체크는 컴파일 타임에만 작동하므로, 외부 API나 DB에서 받은 데이터가 실제로 예상한 타입인지는 런타임에 검증해야 한다. 이 경우 zodclass-validator 같은 런타임 검증 라이브러리를 함께 사용하는 것이 좋다.

언제 어떤 방식을 사용해야 할까?

plainToInstance가 적합한 경우

  • 외부 데이터 변환: API 응답이나 DB 조회 결과처럼 런타임에 타입이 확정되지 않는 경우
  • 복잡한 중첩 구조: 깊게 중첩된 객체 구조를 변환해야 할 때
  • 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); // 런타임 검증

DTO 직접 생성이 적합한 경우

  • 내부 데이터 변환: 이미 타입이 보장된 내부 데이터를 변환할 때
  • 명시적 변환이 필요한 경우: 비즈니스 로직과 밀접하게 연관된 데이터 변환
  • 의존성 최소화: 추가 라이브러리 없이 순수 TypeScript만 사용하고 싶을 때
// 내부에서 이미 검증된 데이터
const validatedData = { name: 'John', age: 30 };
const user = new UserDto(validatedData);

마무리

plainToInstance와 DTO 직접 생성 방식은 각각의 분명한 장점과 단점이 있다.
특히 중첩된 데이터 구조같이 복잡한 데이터를 변환/처리해야하는 경우에는 plainToInstance가 간결한 대안이 될 수 있다. 다만 이런 상황에서도 정확한 검증과 타입 안정성을 확보하기 위해 DTO 생성자를 함께 활용하는 방식이 더 바람직하다.

결국 핵심은 상황에 맞게 적절한 방식을 선택하는 것이다.
데이터 변환은 타입 안정성과 유지 보수성을 동시에 고려해야한다.

• 외부 데이터라면 런타임 검증이 필수적이기에 plainToInstance + class-validator 조합이 실용적일 수 있다.
• 반면 내부 데이터나 명시적이고 의도적인 변환이 필요한 경우에는 DTO 직접 생성 방식이 더 안전하고 표현력이 좋다.

plainToInstance이 간편한 방식이긴 하지만, 실무에서는 DTO 직접 생성 방식을 활용하여 타입 안정성과 정확성을 확실히 하는 것이 중요할 수도 있다. 정답은 없다. 각 방식의 한계를 이해하고, 필요하다면 두 방식을 적절히 조합해 사용하는 것이 중요하다.
이런 접근이 간결함과 타입 안정성, 그리고 유지보수성을 모두 만족시키는 더 나은 코드로 이어질 것이다!

0개의 댓글