이전에 나는 DAO와 Repository의 차이에 대해 글을 작성한 적이 있다. 하지만 DAO와는 반대로, (다른 글에서도 DTO에 대해 언급했었지만... ㅎㅎ) DTO를 어떻게 이쁘게 활용할 수 있을까?라는 생각을 못한거 같다.
이번에 회사에서 개발을 하면서 DTO를 특정 계층에서만 사용하는게 아닌, Controller, Service, Repository 계층 모두 어떠한 규칙이나 컨벤션 없이 무지성으로 사용하는 부분을 보면서 이게 과연 맞는건가?라는 생각을 했다.
많은 레퍼런스에 따르면 - DTO는 기본적으로 변환 로직이 Service 계층에서 정의되어야 한다는 의견이 많았지만, 나는 조금 다른 생각을 했다.
나는 개발을 하면서 의존성이 강하게 결합되어 머리가 아픈 경험을 여러 번 겪었었다. 그러다보니 최대한 의존성을 떨어뜨리는 개발을 선호하는 편이다. 이 입장에서 생각해보자.
DTO가 Controller를 거쳐 Service 계층까지 넘어온 경우, 아무리 계층간 데이터 교환을 하기 위해 사용하는 객체라고 해도 결국에는 하나의 DTO가 Controller와 Service에 영향을 미치고 있는게 아닌가?
그리고 단순히 별다른 로직없이 Controller가 DTO를 Service에 전달하는 용도가 메인 목적이라면, 굳이 Controller가 필요없는게 아닐까? 결국에는 한 줄짜리 코드인데 이게 과연 의미가 있는 행위일까?
@Get(':id')
async hello(
@Param('id') id: number,
@Query('query') query: string,
@Body() dto: TestDto
) {
return await this.service.world(id, query, dto);
}
아직은 내가 많이 미숙한 개발자라서 가볍게 생각한걸지도 모른다. 하지만 파이프, 가드, 미들웨어 등 전체적인 동작 흐름이 많이 간편해지고 매개변수를 쉽게 전달받을 수 있게 되면서 - 이미 Controller의 역할 그 이상을 대신 수행(?)하고 있기 때문에 Controller의 필요성을 못 느꼈다.
그래... 입력과 관련된 부분을 Controller로 분리해낸 것까지는 이해할 수 있다. 그럼 결국 DTO도 입력과 관련된 부분이니 Service 계층까지 가서는 안되는게 아닌가?
그렇다고 Controller에서 DTO를 풀어해쳐(?) Service 계층으로 전달할 경우에는 코드가 너무 더러워진다.
@Get(':id')
async hello(
@Param('id') id: number,
@Query('query') query: string,
@Body() dto: TestDto
) {
const { email, name, age, gender, ...rest } = dto
return await this.service.world(id, query, email, name, age, gender, {...rest});
}
클린 코드에서도 매개변수는 2개에서 3개까지가 적당하다고 했는데, 이는 권장사항을 완벽하게 무시해버리는 셈아닌가??
그리고 입력값만 다르고 비즈니스 로직이 동일할 경우도 생각해보자. 입력값이 달라져 DTO를 새로 구성해야할 경우 Service 계층도 고쳐야하는 문제가 발생한다. 즉, 유지보수 포인트가 늘어난다는 의미다.
@Get(':id')
async hello(
@Param('id') id: number,
@Query('query') query: string,
@Body() dto: TestDto // -> NewTestDto로 변한다면?
) { // -> Service 계층까지 수정해야하나?
return await this.service.world(id, query, dto);
}
이왕이면 비즈니스 로직을 재활용하는 편이 더 좋지 않은가??
나의 킹리적갓심에 따르면, DTO는 Controller에서 끝나는게 맞는 것 같다. 그리고 Service 계층에 입력값을 전달할 때는 Service 계층에서 사용하는 전용 객체로 변환하여 전달하는 것이다. ORM 상에서 따진다면, Service 계층에 전달하는 객체로 Entity를 생각하면 될 것 같다. (ORM에서 제공해주는 함수에서는 대부분 Entity를 사용하니까!! 😀)
DTO를 Entity로 변환하는 경우에는 별도의 Converter 클래스를 만들어주거나 DTO 객체 내부에 함수를 만들어서 처리하는 방법이 있을 것 같다.
export class TestDto {
// ...
static toEntity() {
// ...
}
}
@Injectable()
export class Converter {
// ...
}
하지만 이 방법 또한 각 DTO마다 만들어줘야하는 번거로움이 있기 때문에, Entity로 변환해주는 제네릭 함수를 만들어주면 될 것 같다.
export function mapDtoToEntity<T, U>(dto: T, entityClass: new () => U): U {
const entity = new entityClass();
Object.assign(entity, dto);
return entity;
}
export function mapEntityToDto<T, U>(entity: T, dtoClass: new () => U): U {
const dto = new dtoClass();
Object.assign(dto, entity);
return dto;
}
Object.assign()
메서드를 이용하여 복사하여 처리하는 방법이 있지만, 내가 좋아하는 방법은 class-transformer
에서 제공해주는 plainToInstance()
메서드를 활용하는거다.
export function mapDtoToEntity<T, U>(dto: T, entityClass: new () => U): U {
return plainToInstance(entityClass, dto);
}
export function mapEntityToDto<T, U>(entity: T, dtoClass: new () => U): U {
return plainToInstance(dtoClass, entity);
}
내가 실제로 테스트를 위해 만든 코드들은 DTO 매핑 테스트 프로젝트를 참고하면 된다.
내가 잘못된 사상(?)을 펼친걸 수도 있다. 하지만 개발에는 명확한 답이 없지 않은가? 해답을 찾아가는 것도 중요하지만, 나만의 방법을 찾아서 걸어가는 방향도 중요하지 않을까...? 🥺