[Spring] DTO와 Entity 간의 변환
Spring을 쓴다면 MVC 구조를 사용한다는 것을 전제로 깔고 갈 것이다. 따라서 Controller, Service, Repositoy, DB 순으로 flow가 이동하며, 이 과정에서 entity라는 객체와 DTO라는 객체를 사용한다. 정의를 먼저 살펴보자면, entity는 DB의 row 하나와 매핑되는 객체인 반면, DTO는 Data Transfer Object, 데이터를 옮기는 데 사용하는 객체이다.
DTO의 필요성
DTO의 필요성에 대해서는 말할 필요도 없다.
만약 DTO가 없다고 가정해 보자. 그러면 entity를 사용자에게 노출시켜야 하는데, entity는 DB의 모든 column에 대한 정보를 가지고 있기 때문에 이를 사용자에게 노출시키는 것은 좋지 않다. 또한 entity에 내용이 부족해 추가적인 요청을 해야 할 수도 있고(underfetching), 필요없는 내용이 있어 네트워크 낭비가 일어날 수도 있다.(overfetching)
사용자 요청이 복잡한 경우 (2개 이상 table을 조인해 리턴하는 경우), 해당 entity를 또 만들어야 한다. 사용자 요청이 entity에 영향을 끼치게 되는데, entity는 그 자체로서 이미 핵심 로직을 가지고 있다. 때문에 entity로만 통신할 수는 없다.
Overfetching은 응답받은 정보에 필요없는 값이 있어 네트워크 낭비가 일어나는 것을,
Underfecthing은 응답받은 정보가 부족해서 추가적인 요청을 해야 하는 상황을 의미한다.
DTO를 사용함으로써 encapsulation을 할 수 있고, overfetching/underfetching을 막을 수 있다. validation을 사용해 입력에 대한 검증 로직을 controller와 분리할 수도 있다. 이 때, repository는 entity를 persistence context에 넣고, persistence context에서 entity를 가져오는 조작을 한다. 앞서 살펴봤듯 controller는 사용자에게 DTO를 돌려줘야 한다. 어딘가에서는 entity를 DTO로 변환해 줘야 한다.
어디서 DTO와 entity를 변환해야 하는가?
Repository에서 DTO와 entity를 변환해야 할까?
앞서 살펴봤듯 repository는 persistence context에 관한 조작을 하기 때문에 여기에 변환까지 추가되면 repository의 일이 너무 많아지게 된다. 때문에 일반적인 경우에서는 repository는 entity를 받아 조작하는 것이 좋다고 본다.
예외
단, 몇 가지 예외가 있을 수 있다고 생각한다.
예를 들어 JPQL로 해결할 수 없는 복잡한 쿼리(inline view 같은)를 날리는 경우를 생각해 보자. 예를 들어 A join B join C에 paging도 걸고 filtering도 걸고, ... 이런 상황에 모든 정보를 가져오고 싶다고 생각해 보자. DTO를 사용하지 않았을 때 이 결과가 entity와 매핑되지 않는 경우 Object[]나 Map을 사용해야 한다. 이 경우 repository에서 Object[]로 리턴하게 되고, service도 Object[]를 받아 정보를 파싱해야 하며, 쿼리를 보아야 어떤 위치에 어느 데이터가 있는지 알 수 있다. 이런 경우는 유지보수가 너무 어려워지고, 변환 코드도 중복되기 때문에 DTO를 사용해 리턴하는 방식이 좋은 것 같다.
또한, QueryDSL의 @QueryProtection을 사용하는 경우 DTO가 repository에서 생성되어 나가기도 한다.
일단 repository는 아닌 것 같다.
그러면 남은 것은 service와 controller이다. 일단 service는 비즈니스 로직을 다루는 layer이고, controller는 클라이언트 요청을 받고 service에게 받은 처리 결과를 클라이언트에게 응답하는 역할을 한다.
일단 Spring을 사용하는 이유는 "유연한 확장과 유지보수의 용이성"을 목적으로 가져가는 경우가 대부분일 것이다. 이를 위해서는 dependency를 줄이는 것이 제일 중요하다. dependency가 어떻게 되는지 살펴보자.
dependency가 있는 경우, 하나를 수정하면 연관된 모든 것을 수정해야 하기 때문이다.
Controller에서 변환
controller에서 DTO를 entity로 바꾼다고 해 보자.
- controller가 입력으로 DTO를 받으면 controller 내부에서 entity로 바꾸고 service를 호출한다.
- 이후 service가 리턴한 entity를 DTO로 바꾸어 리턴한다.
그러면 controller는 DTO, entity, service에 의존한다. service는 entity와 repository에 의존하게 된다.
비즈니스 로직을 다루는 service가 특정 DTO에 의존하지 않고, entity에만 의존하기 때문에 service에 대한 재사용성이 높다.
이 경우, controller가 하는 역할에 비즈니스 로직이 섞일 수도 있다! 예를 들어 복잡한 통계를 응답해야 하는 상황을 가정해 보자. 여러 service로부터 entity list를 받아오고 이를 합쳐 DTO를 만들어야 한다고 치면, 이것 자체도 비즈니스 로직이 포함되어 있는 것이다. 그러면 사용자 요청을 담당하는 controller에 추가적인 일이 생긴다. 또한 하나의 controller가 여러 개의 service에 의존하게 된다.
Service에서 변환
service에서 DTO를 entity로 바꾼다고 해 보자.
- controller가 입력으로 받은 DTO를 그대로 service에 넘긴다.
- service 내부에서 DTO를 entity로 변환한 후 비즈니스 로직을 실행하고, 필요 시 repository를 호출한다.
- 이후 service는 repository가 리턴한 entity를 DTO로 변환하고, controller에게 돌려준다.
그러면 controller는 DTO, service에 의존한다. service는 DTO, entity, service에 의존한다.
controller는 받은 DTO를 사용자에게 바로 넘겨주기만 하면 된다. 여러 entity를 합쳐야 하는 복잡한 비즈니스 로직도 service에서 모든 것을 처리한 후 controller로 넘겨주면 된다. 요구사항이 바뀌는 경우를 생각해 보자. controller는 받은 DTO를 그대로 넘겨주기만 하면 되므로 변하지 않을 가능성이 매우 높다.
반면 모든 요청에서 다른 DTO를 사용해야 하기 때문에 API 개수만큼 DTO 개수가 늘어난다는 단점이 있다. (같은 DTO를 사용하는 경우 overfetching이 일어날 수도 있기 때문에 나누는 것이 좋다.) 또한 service가 DTO에 의존하고 있기 때문에 해당 DTO가 아닌 경우 그 service를 쓸 수 없으므로 service에 대한 재사용성이 매우 떨어진다는 단점도 있다.
결론
애플리케이션에 복잡한 로직이 아예 없는 경우는 없다고 봐도 무방하므로, controller에서 entity와 DTO를 변환하는 방식은 비즈니스 로직이 controller로 넘어오게 되므로 별로인 것 같다. 그렇다고 두 번째 방법을 채택하자니 service가 DTO에 의존하기 때문에 service에 대한 재사용성이 떨어진다는 딜레마가 발생한다. 모든 경우에서 딱 좋은 압도적인 하나의 결론이 없다. 때문에 상황에 맞춰 써야 한다. 그러면 어떤 상황에서 어떤 방식을 써야 할까?
만약 복잡한 쿼리가 적거나 없는 소규모 프로젝트라면 controller에서 entity를 변환하더라도 비즈니스 로직이 controller로 오지 않는다. 그러면 controller에서 변환하는 것이 더 좋을 것이다!
반면 복잡한 쿼리가 많고, service를 재사용하지 않을 경우 service에서 변환하는 것이 더 좋을 것이다!
일반적으로는 한 종류의 controller가 하나의 service를 사용하는 경우가 대부분이기 때문에 service에 대한 재사용성을 크게 기대하지 않아도 될 것이다.
다른 방법
진짜 silver bullet은 없나? service의 재사용성/완벽히 분리된 비즈니스 로직 두 가지를 모두 달성할 수 있는 방법은 없을까?
controller가 service에 값을 보낼 때는 DTO를 보내고, 받을 때는 entity를 쓰는 방식은? 음... 이 경우는 두 방식의 단점만 모두 가져온 것 같다. service의 재사용성은 떨어지고, entity를 합쳐야 할 때는 비즈니스 로직이 controller로 오게 된다.
그렇다면 위 방식을 flip해서 controller가 service에 값을 보낼 때는 entity로 변환해서 보내고, 받을 때는 DTO를 받고 리턴하는 형식은 어떨까? 그러면 service의 재사용성도 확보할 수 있고, 비즈니스 로직을 service에 모두 넣을 수 있게 된다! 단... controller가 입력으로 받는 형식과 출력으로 주는 형식이 다를 수 있기 때문에, 하나의 로직에서 2개의 DTO가 필요하게 된다. DTO 관리가 매우 힘들어질 것이다. controller, service가 DTO와 entity에 모두 의존하게 되므로 DTO가 entity와 비슷한 역할을 하게 된다.
DTO-entity mapper
controller와 service 사이에 매핑만 전문으로 다루는 class를 추가하는 방식은 어떨까? 그러면 service는 mapper와 entity에만 의존하고, controller는 mapper와 DTO에만 의존하게 된다. 일단 dependency는 꽤 좋다!
service는 entity에만 의존하므로 service에 대한 재사용성이 좋다! controller는 입력으로 받은 DTO를 mapper에 의존해 entity로 변환하고, service를 호출한다. 복잡한 비즈니스 로직도 service에서 모든 것을 처리한 후 mapper에 의존해 DTO로 바꾼다. service에서 변환하는 방식, controller에서 변환하는 방식 2개의 장점만 모았고, dependency 문제도 해결한 것 같다! entity나 DTO가 바뀌면 mapper만 바꿔주면 되므로 유지보수도 좋을 것이다.
단... mapper class가 매우 비대해질 수 있다는 단점이 있다. 변환이라는 게 말이 간단하지 요청이 조금만 복잡해져도 로직이 생기고, 모든 변환 로직이 mapper에 집중될 경우 크기에서 문제가 생길 것이다.
정리
일반적인 경우 controller와 service에서만 DTO를 사용하되, repository에서 DTO를 반환할 수도 있다.
변환 위치는, case by case이다.
- controller에서 변환: 만약 복잡한 쿼리가 적거나 없는 프로젝트라면 controller에서 entity를 변환하더라도 비즈니스 로직이 controller로 오지 않는다. 그러면 controller에서 변환하는 것이 더 좋을 것이다!
- service에서 변환: 반면 복잡한 쿼리가 많고, service의 재사용을 하지 않을 것이라 예상되는 경우 service에서 변환하는 것이 더 좋을 것이다!
- mapper 사용: 요구사항이 매우 자주 바뀌는 경우 controller와 service 사이에 mapper를 두고 mapper에서 entity와 DTO를 변환하면 유지보수가 간편해진다.
참고: 순환 참조
DTO의 위치를 설정할 때 package dependency가 cycle을 이루지 않게 조심히 설정해야 한다. 아래 2가지 경우를 보자.
service에서 DTO-entity 변환 시, DTO 위치를 controller package에 두었다고 하자. controller package는 service package에 의존하는데, service가 DTO에 의존하기 떄문에 controller package에 의존하게 된다! dependency가 cycle을 이루게 된다. 이렇게 두면 안 된다. DTO 위치를 잘 생각해 두어야 할 것이다!
controller에서 DTO-entity 변환 시, DTO 위치를 controller package 내에 두던, service 내에 두던 큰 문제가 생기지 않는다. 다만 service에서는 DTO를 사용하지 않으므로 controller package에 두는 것이 자연스럽다!