본문 바로가기

개발 지식

DTO란 무엇이고 왜 사용해야 할까?

밑의 예제 코드는 전부 NestJS + TypeOrm 환경에서 작성되었습니다.

트리스티가 Nest Js를 공부하며 남긴 기록입니다. 틀린 내용은 언제든지 말씀해주세요 ~!

 

 

📢 DTO란 무엇인가요?  

 

DTO(Data Transfer Object, 데이터 전송 객체)란 프로세스 간에 데이터를 전달하는 객체를 의미합니다. 말 그대로 데이터를 전송하기 위해 사용하는 객체라서 그 안에 비즈니스 로직 같은 복잡한 코드는 없고 순수하게 전달하고 싶은 데이터만 담겨있습니다.  아래의 그림을 통해 DTO는 주로 클라이언트와 서버가 데이터를 주고받을 때 사용하는 객체임을 알 수 있습니다.

 

https://blog.scottlogic.com/2020/01/03/rethinking-the-java-dto.html

 

 

 

 

글로만 보면 잘 모르겠으니 코드 예제를 통해 한번 알아보겠습니다. 여기 User 엔티티에서 userId로 특정 사용자를 찾는 코드가 있습니다.

 

// 컨트롤러

@Controller("user")
export class UserController {
	constructor(private readonly userService: UserService) {}

	@Get("")
	async getUser(@Query("id") id: string) {
		return this.userService.getUser(id);
	}
}
// 서비스

@Injectable()
export class UserService {
	constructor(
		@InjectRepository(User)
		private readonly userRepository: Repository<User>
	) {}

	async getUser(id: string) {
		return this.userRepository.findOneBy({ id });
	}
}
// 엔티티

@Entity("user")
export class User {
	@PrimaryColumn("varchar", { length: 50, name: "id" })
	id: string;

	@Column("varchar", { nullable: false, name: "pw" })
	pw: string;

	@Column("varchar", { nullable: false, length: 50, name: "name" })
	name: string;

	@CreateDateColumn()
	createdAt: Date;

	@UpdateDateColumn()
	updatedAt: Date;

	@DeleteDateColumn()
	deletedAt: Date;
}

결과가 잘 출력됩니다.

 

컨트롤러에서는 단지 id라는 string 값만 받고, 서비스에서는 userRepository에서 id에 해당하는 데이터만 조회해서 컨트롤러에게 넘겨줍니다. 그럼 컨트롤러가 해당 데이터를 고스란히 클라이언트에게 넘겨주죠. 

 

예제가 너무 단순하니까 좀 더 조건을 걸어보도록 하겠습니다. userId의 길이가 5~ 10자 사이이고, 사용자 이름까지 추가로 검색 조건에 넣고 싶다면 어떻해야 할까요? 그러면 다음과 같이 유효성 검사 코드를 작성해 볼 수 있습니다.

 

// 컨트롤러

@Controller("user")
export class UserController {
	constructor(private readonly userService: UserService) {}

	@Get("")
	async getUser(@Query("id") id: string, @Query("name") name: string) {
		if (id.length >= 5 && id.length <= 10)
			return this.userService.getUser(id, name);
		else throw Error("글자수가 맞지 않습니다.");
	}
}​

 

 

 

여러 개의 파라미터를 받고 유효성 검사 코드까지 작성한 건 좋은데, 지금보다 조건이 늘어나면 @Query() 구문도 늘어날 것이고 if문도 복잡해 질 것입니다. 결국 작성해야 하는 비즈니스 로직보다 컨트롤러 단의 코드가 더 많아지고 복잡해지겠죠.

 

그래서  NestJS에서는 Class-validator데코레이터들을 DTO에 작성해서 간편하게 코드를 작성하고 유효성 검사를 수행합니다.

 

// main

async function bootstrap() {
	const app = await NestFactory.create(AppModule);
	app.useGlobalPipes(new ValidationPipe());
	await app.listen(3000);
}
bootstrap();
// 컨트롤러

@Controller("user")
export class UserController {
	constructor(private readonly userService: UserService) {}

	@Get("")
	async getUser(@Query() findUserDto: FindUserDto) {
		return this.userService.getUser(findUserDto);
	}
}
// 서비스

@Injectable()
export class UserService {
	constructor(
		@InjectRepository(User)
		private readonly userRepository: Repository<User>
	) {}

	async getUser({ id, name }: FindUserDto) {
		const user = await this.userRepository.findOneBy({ id, name });
		return plainToInstance(FindUserReturnDTO, user);
	}
}
// Request DTO

@Exclude()
export class FindUserDto {
	@Expose()
	@IsString()
	@MinLength(5)
	@MaxLength(10)
	@IsNotEmpty()
	id: string;

	@Expose()
	@IsString()
	@IsNotEmpty()
	name: string;
}
// Response DTO

@Exclude()
export class FindUserReturnDTO {
	@Expose()
	id: string;

	@Expose()
	name: string;
}

 

 

요청(Request)응답(Response) DTO를 선언해줌으로서 데이터를 전송하였고, 결과도 잘 나오는 것을 확인할 수 있습니다. 

 

 

 

 

📢 DTO와 Entity의 분리 - 요청(Request)  DTO 

위의 예제를 통해 저희는 DTO를 사용함으로써 코드량을 줄이고 읽기 쉬워진 것을 확인할 수 있었습니다. 그런데 코드를 보고 있자니 한 가지 의문점이 생깁니다.

 

요청과 응답시 DTO를 만들지 않고 기존에 존재하던 Entity를 사용하면 불필요한 파일을 만들지 않아도 되고 더 간편하지 않을까?

 

 

생각해 보니 그럴듯합니다. 위의 예제에서는 요청과 응답의 경우마다 DTO를 작성해서 사용하였는데요. 굳이 만들지 않고 Entity에 몰아서 작성하는 것도 가능합니다.

 

처음의 코드로 돌아와서 DTO를 Entity 형태로 만들어보겠습니다.

 

// 컨트롤러

@Controller("user")
export class UserController {
	constructor(private readonly userService: UserService) {}

	@Get("")
	async getUser(@Query() user: User) {
		return this.userService.getUser(user);
	}
}
// 엔티티

@Entity("user")
export class User {
	@IsString()
	@MinLength(5)
	@MaxLength(10)
	@IsNotEmpty()
	@PrimaryColumn("varchar", { length: 50, name: "id" })
	id: string;

	@Column("varchar", { nullable: false, name: "pw" })
	pw: string;

	@IsString()
	@IsNotEmpty()
	@Column("varchar", { nullable: false, length: 50, name: "name" })
	name: string;

	@CreateDateColumn()
	createdAt: Date;

	@UpdateDateColumn()
	updatedAt: Date;

	@DeleteDateColumn()
	deletedAt: Date;
}
// 서비스

@Injectable()
export class UserService {
	constructor(
		@InjectRepository(User)
		private readonly userRepository: Repository<User>
	) {}

	async getUser({ id, name }: User) {
		return this.userRepository.findOneBy({ id, name });
	}
}

 

 

 

원래는 FindUserDTO에 있었어야 할 Class-validator 데코레이터들을 엔티티에 넣음으로서 유효성 검사를 하는 코드를 작성하였습니다. 코드를 보니 어떠신가요? FindUserDTO를 작성하지 않았으니 관리해야 할 파일 하나를 없어졌습니다. 이렇게 해도 어쨌든  엔티티DTO로 활용하였으니 기능상에도 별 차이 없고, 관리해야 할 파일이 줄어서 더 좋은 코드를 짠 기분이 듭니다.

 

하지만 코드를 유심히 봐보세요! 지금 저는 id와 name을 사용해서 사용자를 찾는 [Get] /user API를 만들고 있었습니다. 그런데 FindUserDTO 대신 엔티티를 사용한 바람에 id, name 외에도 pw, createdAt, updatedAt, deletedAt 받을 수 있게 돼버렸습니다. 즉, DTO 대신 엔티티를 사용해버려서 기존에 데이터를 받을 상자보다 훨씬 큰 상자가 만들어져 버렸습니다. 이러면 통신에 쓸데없는 오버헤드가 발생할 수도 있습니다.

 

만약 이것이 사소해보인다면 다음의 경우에는 어떨까요? 프론트엔드와 협업하며 swagger를 작성해서 제공해야 하는 경우라고 가정해보겠습니다. 이 경우에도 DTO를 작성하는 대신 엔티티swagger 데코레이터를 추가해서 만들어보겠습니다.

 

// 엔티티
// swagger 코드 추가

@Entity("user")
export class User {
	@IsString()
	@MinLength(5)
	@MaxLength(10)
	@IsNotEmpty()
	@PrimaryColumn("varchar", { length: 50, name: "id" })
	@ApiProperty({ description: "사용자 ID" })
	id: string;

	@Column("varchar", { nullable: false, name: "pw" })
	@ApiProperty({ description: "사용자 PW" })
	pw: string;

	@IsString()
	@IsNotEmpty()
	@Column("varchar", { nullable: false, length: 50, name: "name" })
	@ApiProperty({ description: "사용자 이름" })
	name: string;

	@CreateDateColumn()
	@ApiProperty({ description: "생성일" })
	createdAt: Date;

	@UpdateDateColumn()
	@ApiProperty({ description: "수정일" })
	updatedAt: Date;

	@DeleteDateColumn()
	@ApiProperty({ description: "삭제일" })
	deletedAt: Date;
}

 

 

아까도 말씀드렸듯이 우리가 만들 API는 단지 id와 name으로 사용자를 검색하는 API입니다. 하지만 요청에서 DTO를 작성하지 않고 엔티티에 몰아서 작성하는 바람에 데코레이터가 덕지덕지 붙어버렸습니다. 이렇게 되면 다른 개발자가 해당 엔티티 코드를 읽었을 때 무수히 많은 데코레이터들을 분석하는데에 더 오랜 시간을 투자해야 합니다. 또한 기존에는 그저 데이터베이스의 테이블을 표현하는 정도에만 그쳤던 엔티티 파일이 swaggervalidator 기능까지 관리하다 보니 너무 많은 책임을 떠맡게 됩니다. 

 

swagger를 작성할 때도 문제가 발생하게 됩니다. 분명 id와 name만 받고 싶은데 swagger를 보면 다른 값들도 받을 수 있게 작성된 것을 확인할 수 있습니다. 물론 위에 작성된 엔티티에서 id와 name에 붙은 @ApiProperty를 제외한 나머지 코드를 지우면 id와 name만 표현할 수 있습니다만... 현실은 그렇지 못합니다.

 

만약 무수히 많은 API가 있을 때, 다른 API에서 User 엔티티를 사용하고 있다면 어떻게 될까요? 그리고 API마다 validation 조건이 다르다면? 과연 그때도 자유롭게 엔티티데코레이터를 지웠다 썼다 하면서 코드를 수정할 수 있을까요? 

 

데이터베이스에 어떠한 수정사항도 발생하지 않는다면 모르겠지만 이런 경우는 거의 없습니다. 때문에 엔티티에 변경사항이 생기는 순간, 엔티티를 사용한 다른 모든 API에 영향이 가게 됩니다. swaggervalidator는 어떤 API와 관련있는건지 알아볼 수도 없이 엉망이 되겠죠.

 

이러한 단점이 존재하기에  DTO를 사용하는 것이 좋습니다. 아래의 코드와 위의 코드를 다시 비교해 보세요. DTO는 목적에 맞게 전송받거나 하려는 데이터만 존재하고 필요하다면 validationswagger 코드를 작성할 수 있습니다. 엔티티는 데이터를 표현하기 위한 표현 코드만 존재하니 훨씬 더 이해하기 쉬워졌습니다.

 

// 요청 DTO

export class FindUserDto {
	@IsString()
	@MinLength(5)
	@MaxLength(10)
	@IsNotEmpty()
	@ApiProperty({ description: "사용자 ID" })
	id: string;

	@IsString()
	@IsNotEmpty()
	@ApiProperty({ description: "사용자 이름" })
	name: string;
}
// 엔티티

@Entity("user")
export class User {
	@PrimaryColumn("varchar", { length: 50, name: "id" })
	id: string;

	@Column("varchar", { nullable: false, name: "pw" })
	pw: string;

	@Column("varchar", { nullable: false, length: 50, name: "name" })
	name: string;

	@CreateDateColumn()
	createdAt: Date;

	@UpdateDateColumn()
	updatedAt: Date;

	@DeleteDateColumn()
	deletedAt: Date;
}

 

 

 

 

📢 DTO와 Entity의 분리 - 응답(Response) DTO 

 

좋습니다. 요청시 DTO를 사용하는것이 엔티티를 사용하는것보다 더 좋다는 것을 깨달았습니다. 일단 프로그래머가 관리해야 할 코드가 늘어나긴 했지만 그것보다 좋은 점이 더 많거든요!

 

그렇다면 응답시에는 어떨까요? 이때에도 DTO로 클라이언트에 전송해줘야 할까요? 응답 시만큼은 DTO로 다시 만들어서 주지 않아도 상관없을 것 처럼 보입니다. API마다 요청 & 응답 DTO를 만드는건 코드 중복도 있을 것이고 관리할 것도 엄청 많아지니까요. 

 

응답 시에는 DTO가 필요없을지 예제를 통해 알아보도록 하겠습니다. 이번에는 요청에만 DTO를 사용하고 응답의 경우  User 엔티티 그대로를 반환하도록 하겠습니다.

 

 

// 컨트롤러

@Controller("user")
export class UserController {
	constructor(private readonly userService: UserService) {}

	@Get("")
	async getUser(@Query() findUserDto: FindUserDto) {
		return this.userService.getUser(findUserDto);
	}
}
// 서비스

@Injectable()
export class UserService {
	constructor(
		@InjectRepository(User)
		private readonly userRepository: Repository<User>
	) {}

	async getUser({ id, name }: User) {
		return this.userRepository.findOneBy({ id, name });
	}
}

 

 

postman으로 확인해 보니 데이터를 잘 전달해 주는 것을 확인할 수 있습니다. 그냥 봤을 때는 User 엔티티 그대로를 받아도 아무 문제 없는 것 같아 보입니다. 만약 지금 이렇게 생각하고 있다면 백엔드 개발자 분일지도 모릅니다. 해당 데이터를 프론트엔드 개발자가 받았다고 생각해 봅시다. 아까 위에서 요청 시 DTO대신 엔티티를 사용할 경우 불필요한 데이터를 포함할 수 있다고 했던거 기억하시나요? 그 문제가 정확히 프론트엔드에서 발생합니다. 

 

프론트엔드에서도 id와 name만 필요했는데 필요 없는 pw, createdAt, updatedAt, deletedAt를 같이 전달받게 됩니다. 응답의 크기가 불필요하게 커지는 것이죠. 사소해 보일 수 있으나 엄청 중요한 문제입니다. 지금은 하나의 테이블에서만 데이터를 가져왔지만 만약 join이라도 걸려있다면 데이터가 엄청 커지고 클라이언트 처리 속도에 문제가 생길 수 있습니다. 

 

여기에 그치지 않고 한가지 중요한 문제가 발생했습니다. 혹시 눈치채셨나요? 바로 사용자 데이터중에서도 가장 민감한 비밀번호를 클라이언트에게 전달했다는 것입니다. 아래의 코드처럼 typeorm 옵션 중 하나인 select 옵션을 false로 설정하면 반환값이 User 형태임에도 불구하고 pw는 제외시킬 수 있습니다. 

 

// 엔티티

@Entity("user")
export class User {

	//...

	@Column("varchar", { nullable: false, name: "pw", select: false })
	pw: string;

	//...
}

 

 

그러나 typeorm의 옵션을 사용해서 pw를 가려서 [Get] /user API가 pw를 반환하지 않도록 임시 조치를 한다고 해도, pw를 반드시 클라이언트에게 전달해야 하는 다른 API에서 User 엔티티 형태로 반환하고 있다면 해당 API까지 영향을 끼치게 됩니다. 그렇게 되면 개발자들은 바뀐 API 명세를 일일이 확인해가며 코드를 다시 수정해야 합니다. 코드를 줄이려는 목적으로 DTO를 만들지 않았다가 오히려 유지보수만 힘들어지게 된 것이죠.

 

또 하나의 중요한 사실은 응답 DTO를 사용하는 대신에 User 엔티티를 클라이언트에 전달해줌으로서, User 엔티티의 설계가 고스란히 노출이 되었다는 점입니다. 예제에서는 단순히 User 엔티티만 존재하기 때문에 치명적으로 보이지 않을 수 있지만, 만약 join 된 데이터를 그대로 노출시킨다면 프로젝트의 데이터베이스 구조가 노출돼 보안 사고가 발생할 수 있습니다. 그리고 클라이언트에게 전송되는 데이터 크기가 엄청나게 커져서 속도에 영향을 미칠 수 있습니다. 해당 API에서 전달받은 쓸대없이 큰 데이터 속에서 필요한 데이터를 골라내는 작업도 무시할 수 없습니다.

 

그렇기 때문에 요청 & 응답시 DTO를 사용해서 클라이언트와 데이터를 주고받는 것이 안전합니다. NestJS Class-validator에서는 plaintoInstance 라는 함수로 손쉽게 DTO에 매핑 할 수 있답니다. 

 

// 서비스

@Injectable()
export class UserService {
	constructor(
		@InjectRepository(User)
		private readonly userRepository: Repository<User>
	) {}

	async getUser({ id, name }: FindUserDto) {
		const user = await this.userRepository.findOneBy({ id, name });
		return plainToInstance(FindUserReturnDTO, user);
	}
}

 

 

 

👾 정리  

 

 

 

엔티티DTO 대신 사용해서 지옥을 경험하지 말자! 필자는 그렇게 했다가 정말 지옥을 경험했다 ㅠㅠ

 

 

 

  1. DTO대신 엔티티를 사용하면 엔티티 구조가 모두 노출될 수 있습니다. DTO를 사용했을 때보다 보안에 취약해질 수 있죠. DTO를 사용함으로서 엔티티 내부 구현을 캡슐화할 수 있습니다.
  2. 클라이언트로 넘겨줘야 할 데이터는 API마다 다를 수 있습니다. 때문에 엔티티를 반환값으로 사용하면 유지보수가 힘들어집니다.
  3. DTO대신 엔티티를 사용하면 클라이언트 요구사항에 엔티티가 영향을 받을 수 있습니다.
  4. 엔티티DTO가 항상 동일한 상황이라면 DTO대신 엔티티를 사용해도 됩니다. 하지만 그런 일은 거의 없습니다.
  5. 요청 & 응답시마다 DTO를 생성하는 것은 엔티티만 사용했을 경우보다 더 많은 코드를 관리해야 합니다. 하지만 엔티티만 썼을 때 발생하는 코드 버그들과 유지보수의 난이도를 생각하면 훨씬 값싼 노동입니다.
  6. DTO를 사용하면 클라이언트에 전달해야 할 데이터의 크기를 조절할 수 있습니다. 엔티티를 반환하면 불필요한 데이터가 클라이언트에 전송될 수 있습니다.
  7. validation, swagger 등의 코드들과 엔티티 코드를 분리할 수 있습니다. 더 깔끔한 코드가 돼서 읽고 관리하기 용이해집니다.