https://github.com/HomoEfficio/dev-tips/blob/master/DTO와%20Bean%20Validation.md 에 이어지는 글이라고 봐도 좋을 것 같다.
먼저 이 글에 사용된 DTO 라는 용어는 흔히 통용되고 있긴 하지만 이런 문맥에서는 잘못 사용되고 있다는 점을 밝혀둔다. 왜 적절하지 않은지는 맨 아래에 기술한다.
제목이나 글 자체를 수정하지 않은 이유는 흔히 통용되고 있으니 검색도 DTO 로 할 것이고, 그렇게 들어온 사람들에게 올바른 정보를 제공할 기회를 놓치고 싶지 않아서다(라고 썼지만 이런 걸 낚시라고도.. 하지만 개인적인 욕심은 없으니 이타적인 선한 낚시라고 하자 =3=3)
DTO(Data Transfer Object)가 왜 필요한지 또는 어떤 역할을 하는지는 앞의 링크 글에 잘 나와 있는데 요약하면 다음과 같다.
DTO = Domain Information + View Information
DTO가 View Information을 포함하고 View와 맞닿아 의사소통하는 덕분에, Domain 객체는 View에 구애받지 않고, 순수하게 도메인 로직만을 담당하는 객체로 살아갈 수 있다.
하지만 View Information이 별로 필요하지 않다면, 굳이 DTO을 일부러 따로 만들 필요 없이 Domain 객체만 사용해도 괜찮다. 어차피 View는 DTO인지 Domain 객체인지 알지도 못하고 알 필요도 없다. 그저 View를 그리는데 필요한 정보만 모두 담겨있다면 무엇이든 상관없다.
Domain 객체만으로 모두 커버할 수 있다면 가장 단순하고 우아한 해법일 것이다. 하지만 View는 기대만큼 단순하지 않기 마련이다. 그래서 View Information이 필요하고 결국 DTO가 필요한 경우가 많다.
DTO가 있어야 하는 상황이라면, 프론트엔드의 View에는 결국 DTO가 전달되고, 백엔드의 데이터 저장소에는 결국 Domain 객체가 전달된다. 따라서 중간의 어딘가에서는 DTO와 Domain 객체가 서로 변환되는 지점이 있어야 한다. 그게 어디일까?
컨트롤러냐 서비스냐를 얘기하기 전에 살짝 용어부터 정하고 가자.
웹 애플리케이션에서 컨트롤러가 지칭하는 것에는 별다른 혼동이 없다. 그냥 시스템 외부로 노출되는 api 라고 생각하면 된다.
반면에 서비스는 굉장히 여러곳에서 사용되는 용어라서 미묘한 혼동이 있을 수 있다.
여기에서 말하는 서비스는 컨트롤러 바로 뒤에서 트랜잭션 관리나 이벤트 처리를 포함해서 타 시스템 연계를 담당하는 역할을 맡는 응용 서비스(Application Service)다. DDD 에서 말하는 도메인 서비스와는 다르다.
다시 본론으로 돌아와서 DTO와 Domain 객체가 서로 변환되는 지점은 어디일까?
처음에는 컨트롤러냐 서비스냐가 고민이었다. 컨트롤러 메서드의 인자로 DTO가 사용되는 점을 감안하면 컨트롤러에 변환 로직을 두는 게 나을 것 같다. 하지만, DTO를 Domain 객체로 만들 때는 Repository를 통한 조회가 필요할 때가 종종 있다. 컨트롤러가 응용 서비스를 거치지 않고 Repository에 접근하는 것은 계층 구조를 위반하므로 일반적으로 권장되지는 않는다. 따라서 이런 경우에는 컨트롤러에서 변환할 수가 없다. 그래서 서비스에서 DTO - Domain 객체 변환을 담당하게 했다.
좀 지나서는 별도로 Converter로 빼는 게 좋을 것 같아서 별도의 Converter 계층을 두고 변환 로직을 모두 Converter에 담고, 각 Converter를 서비스에 주입해서 서비스가 DTO - Domain 객체간 변환을 Converter에게 위임해서 처리하게 했다.
더 지나고 보니 방향마다 다르게 보는 것이 낫다는 생각이 들었다.
Domain 객체 -> DTO -> View 방향의 흐름에서는 필요한 모든 Domain 객체를 서비스에서 Repository를 통해 얻어올 수 있고, DTO에 전달할 데이터를 Domain 객체가 모두 가지고 있으므로, Domain 객체를 파라미터로 받는 Converter의 메서드에서 View 관련 데이터(ex SelectBox에 들어갈 특정 데이터 목록 등)를 추가하고 DTO로 변환 하는데 아무 어려움이 없다.
따라서 Converter에서는 서비스로부터 호출될 때 Domain 객체만 인자로 넘겨받는다면 독립적으로 변환 처리가 가능하다.
또는 토비님이 주신 의견인데 별도의 컨버터를 두지 않고 변환 로직을 아예 DTO 안에 두고, 서비스가 Domain 객체를 DTO 안에 주입해주고 DTO를 반환하고, 컨트롤러가 DTO에서 필요한 데이터를 빼낼 때 변환 로직이 실행되는 방식도 좋아 보인다. 직접적인 변환은 컨트롤러가 호출할 때 실행되기는 하지만 DTO를 생성하는 위치는 서비스 계층이고, Domain 객체가 컨트롤러에 반환되는 일은 없으므로, 이 경우에도 굳이 계층을 가리자면 DTO 가 생성되는 지점을 기준으로 서비스 계층으로 분류하는 것이 맞다고 생각한다.
이 방향의 DTO 변환을 컨트롤러 계층에서 한다면 다음과 같은 단점이 있다.
- 클라이언트에 반환할 필요가 없는 데이터까지 Domain 객체에 포함되어 컨트롤러 계층에 까지 넘어온다.
- Domain 객체가 컨트롤러에 공개되므로, 컨트롤러가 서비스 계층을 건너뛰고 직접 Domain 객체 메서드르 호출할 수 있으므로 응용 로직이 컨트롤러에 스며들 수 있다.
- 여러 Domain 객체로부터 조합되는 DTO의 경우 컨트롤러 계층에서 조합해야 하며 결국 응용 로직이 컨트롤러에 스며든다.
- 여러 Domain 객체를 조회하는 서비스를 각각 호출해야 하므로 의존하는 서비스의 갯수가 늘어날 수 있다.
- JPA를 사용할 때,
- OSIV를 비활성화하면 session 이 서비스 계층에서 종료되므로, 컨트롤러 계층에서 DTO로 변환하다가 lazy initialization exception 이 발생할 수 있다.
- 양방향 참조가 포함된 객체를 JSON 직렬화 하다가 StackOverflow 가 발생할 수 있다.
반면에 이 방향의 DTO 변환을 서비스 계층에서 할 때 단점은
- DTO는 클라이언트게 반환되는 정보니까 아무래도 클라이언트 쪽 지식을 담게 마련인데, 서비스 계층에서 DTO로 변환하면,
- 서비스 계층이 클라이언트 쪽 지식을 알아야 하는 상황이 올 수 있다.
- DTO가 변경되면 서비스 계층을 변경할 일도 생길 수 있다.
정도가 떠오른다.
하지만 서비스 계층에서는 DTO 생성 후에 DTO의 메서드를 호출하는 일은 거의 없다. TO 자체는 대체로 data shuttle 역할을 할 뿐이라서 생성 로직 외에 다른 로직이 포함되는 경우는 거의 없기 때문이다. 또한 클라이언트 쪽 요구사항이 변경되어 DTO 변경으로 이어지고 이것이 서비스 계층에까지 영향을 미치더라도 이 변경은 굉장히 선형적이고 추적도 쉬워서 작업 난이도나 영향 범위도 크지 않다. 따라서 실질적인 악영향은 그다지 커보이지 않는다.
하지만 View -> DTO -> Domain 객체 방향의 흐름에서는 View에서 전달받은 정보만으로 Domain 객체를 구성할 수가 없다. 쉽게 말해 View에서는 서버에 ID만 전달하기도 하는데, ID만으로는 Domain 객체를 구성할 수 없으니 ID 외의 정보를 Repository를 통해 조회한 후에나 Domain 객체를 구성할 수 있다.
따라서 Converter에서 독립적으로 처리가 불가능하고 Repository 계층에 의존하게 된다. Converter가 Repository에 직접 접근하게 하면서까지 DTO -> Domain 객체 변환을 Converter에서 처리해야하는 걸까? 아니라고 생각한다. 그러느니 DTO -> Domain 객체 변환 책임을 서비스에게 넘기는 것이 낫다고 본다.
이 방향의 DTO 변환을 컨트롤러 계층에서 하는 건 사실 상 불가능하기도 하다. 앞서 설명한 것처럼 서비스 계층에 DTO 가 아닌 도메인 객체를 넘겨줘야 하는데, 컨트롤러는 스스로 Domain 객체를 구성할 능력이 없기 때문이다.
정리하면 다음과 같다.
Domain 객체 <-> DTO 변환은 컨트롤러 계층이 아니라 서비스 계층에서 처리하는 것이 타당하다.
- Domain 객체 -> DTO 의 변환은 Converter에서 담당하고, Converter를 서비스에 주입해서 서비스 계층에서 Converter를 호출해서 처리
- 또는 아예 DTO 내에 변환 로직을 두고 DTO가 Domain 객체를 생성자로 주입 받아서 DTO 내에서 변환 - 이 경우에도 DTO가 생성되는 지점을 기준으로 서비스 계층에서 처리한다고 분류.
- ex) 정적 메서드에 변환 로직을 담고
XXXOut.fromEntity(entity)
와 같이 호출해서 DTO로 변환DTO -> Domain 객체의 변환은 서비스의 private 메서드에서 처리
또는 아예 DTO 내에 변환 로직을 두고yyyIn.toEntity()
와 같이 호출해서 Domain 객체로 변환
원래 이 글의 목적은 이름이 뭐가 됐든 Domain 객체와 화면에 보여질 데이터 사이에 존재하는 중간체를 어디에서 만드는 게 적절하냐는 얘기였다. 하지만 DTO 라는 용어의 적절성도 이 참에 한 번 짚고 넘어가는 게 좋겠다.
이게 이름만의 문제는 아닌 것이, 위 글에서도 그렇게 썼지만 웹 클라이언트 -> 서버, 서버 -> 웹 클라이언트 두 가지 방향에서의 중간체를 모두 DTO 라는 한 가지 용어로 잘못 지칭하다 보니, 객체도 하나만 만들어서 두 가지 용도에 사용하다가 엄청난 고생길로 인도하는 실질적인 병폐도 있기 때문이다.
둘은 책임이 다르다. 그러니 잘못 사용되고 있는 이름과 재사용이라는 유혹 때문에 하나의 객체로 두 가지 용도에 사용하면서 고생하고 있다면 지금 즉시 두 가지로 분리하자. 이름은? 아몰랑 그냥 XXXReq, YYYRes 정도로 =3=3
지금까지 쓴 것처럼 DTO(Data Transfer Object)는 클라이언트(특히 웹 클라이언트)와 서버 간에 데이터를 주고 받을 때 사용되는 셔틀 같은 객체를 지칭하는 것으로 통용되고 있다.
DTO가 셔틀 역할을 하는 것은 맞지만, 마틴 파울러의 글에 따르면 단순한 셔틀이 아니라, 서버가 여러 번의 개별 요청을 받아서 회신해야 할 정보 중에서 함께 사용되는 것들을 DTO에 함께 담아 한 번에 회신함으로써 비싼 원격 호출 횟수를 줄이는 데 주목적이 있다. 아래 그림을 보면 더 쉽게 와닿을 것이다. DTO를 쓰지 않았다면 Album 조회, Artist 조회 이렇게 두 번 조회 요청을 날려야 하는데, 이걸 DTO에 모두 담아서 반환한다면 한 번 요청, 한 번 회신으로 끝낼 수 있다.
그림 출처: https://martinfowler.com/eaaCatalog/dataTransferObject.html
그래서 일단 웹 클라이언트가 서버로 데이터를 보내는 방향(View -> DTO -> Domain 방향)에서 셔틀 역할을 하는 객체에게 DTO 라는 용어를 사용하는 것은 적절하지 않다.
서버가 웹 클라이언트로 데이터를 보내는 방향(Domain -> DTO -> View 방향)에서는 DTO 라는 용어가 적절할 때도 있고 그렇지 않을 때도 있다. 예를 들어 화면에서 필요한 주문자 정보 일부와 주문 정보 일부, 주문 상품 정보 일부, 배송 상태 정보를 서버가 하나의 객체에 담아서 웹 클라이언트에 반환할 때는 DTO 라고 할 수 있다. 하지만 서버가 그저 회원 상세 정보를 웹 클라이언트에게 반환할 때는 DTO 라는 용어는 적절하지 않다.
이규원님이 'View에 사용될 목적의 응답 계약은 ViewModel 이란 패턴이 더 적절할 것 같다'는 의견을 주셨는데 ViewModel을 잘 몰라서 쉽게 설명을 못 하겠다. ViewModel을 알려면 MVC, MVP, PM, MVVM 등을 알아봐야 하는데, 이런 건 노력은 많이 들고 결실은 크지 않을 것 같아서 피하고자 한다. 어디에선가 쉽게 써진 자료를 우연히 발견해서 조금이라도 알게 되면 그때나 업데이트를.. ㅋㅋ
그럼 DTO 대신에 뭐라고 불러야 할까? 솔직히 모르겠다.
개인적으로는 위에 잠시 예시로 나온 것처럼 클라이언트로 부터 들어오는 객체는 코드상으로도 In 이라는 접미사를 붙이고 인객체라고 부르고, 서버로부터 나가는 클라이언트쪽으로 나가는 객체는 Out 이라는 접미사를 붙이고 아웃객체라고 부르고 있다.
- DTO 변환을 어디에서 하냐에 대해 ChatGPT 와의 질의응답 링크도 추가한다. 이 글에 나온 것 이상의 내용은 없고 결론도 서비스 계층이 더 적절하다는 얘기.