
작성 이유
예전에 스프링, JPA 학습을 하며 클라이언트 응답 시 Entity 가 아닌 DTO 를 반환하는 것에 대해 알게되었다.
프로젝트를 진행하면서, 영한님 강의를 들으면서 그리고 기술 면접을 준비하면서 내가 이 부분에 대해 얼만큼 잘 알고 있는가 ? 를 되돌아보게 되었고, 정리를 좀 해야겠다 싶어서 작성하게 되었다.
DTO 는 왜 사용될까
먼저 DTO 의 정의는 무엇일까 ? DTO 는 Data Transfer Object 의 약자로, 말 그대로 데이터 전송 객체라는 의미를 가지고 있다.
그림과 같은 각 계층에서 사용되며 , 특수한 로직이 없는 순수한 데이터 객체이다.

클라이언트와 Rest API 통신을 할 때 우리는 응답으로 Entity 가 아닌 DTO 를 응답해주는데, 내가 정리한 바로는 아래의 이유와 같았다.
1. 계층의 관심사 구분 및 커스텀한 응답을 위함
Presentation 계층부터 각 계층의 관심사를 먼저 알아보자.
첫 번째, Presentation 계층은 최상단 계층으로써 클라이언트가 보내주는 데이터를 처리하고 그 데이터를 기반으로 화면을 조작하는데 관심사를 두는, 말 그대로 표현 계층이다.
두 번째, Domain 계층은 각종 비즈니스 로직 처리에 관심사을 두는 가장 중요한 계층이다.
세 번째, Data Source 계층은 데이터 베이스와 관련된 작업에 관심사를 두는 계층이며 Repository 계층이라고도 한다. 기본적으로 Presentation, Domain 계층에서 DTO 를 주로 사용하고 Repostiroy 계층에서는 QueryDsl 사용 등 상황에 따라서 DTO 를 사용하기도, 안하기도 한다.
설계가 아무리 잘 되었다 해도 Getter만을 이용해서 원하는 데이터를 표시하기 어려운 경우가 발생할 수 있는데, 이 경우에 Entity와 DTO가 분리되어 있지 않다면 Entity 안에 Presentation 계층을 위한 필드나 로직이 추가되게 되어 객체 설계가 망가지게 된다. 때문에 분리한 DTO에 Presentation 계층 처리를 위한 로직을 추가해서 사용하고, Entity 는 Entity 의 관심사만을 처리하는 역할만을 수행하도록 하는 것이다.
Domain 계층은 각종 비즈니스 로직 처리에 관심사를 두고 있다고 했는데, 관심사가 맞지 않는 문제 이외에도 Entity 를 화면을 그리기 위한 데이터 응답의 용도로 사용하기에는 정말 많은 리스크가 존재한다. 요구사항 등의 변경으로 화면의 API 스펙은 데이터 변경이 잦을 수 밖에 없는데, 여러 클래스에서 사용하는 Entity 를 그때 그때 수정한다 ? 얼마나 사이드 이펙트가 발생할지 생각만해도 정말 어지러워진다😭 또한 어떤 API 스펙에는 유저 이름이 필요가 없고, 어떤 API 스펙에는 유저 나이가 필요없다고 가정했을 때 하나의 Entity 로 그런걸 모두 처리하는건 불가능하기도 하다. 따라서 API 스펙 변경이 잦은 화면을 응답할 때 필요한 데이터를 선별해 만든 맞춤 DTO 를 사용하는 것이다.
2. 가독성
클라이언트가 서버로 값을 보낸다면, Controller 에서는 항상 그 값을 검증하는 과정이 필요하다. @Valid 를 사용한다면 어떨까 ?
@Valid 어노테이션은 Controller 에서만 동작하기 때문에 파라미터에서 사용해 객체를 검증할 수 있는데, 파라미터로 DTO 가 아닌 Entity 가 명시되어있다면 @NotEmpty 와 같은 많은 검증 관련 어노테이션들이 Entity 에 덕지덕지 붙는 상황이 발생하게 된다. 안 그래도 비즈니스 로직이 많이 적혀있는 Entity 가 더욱 비대해지고 가독성이 그만큼 하락한다는 것이다. 따라서 DTO 를 파라미터로 받아 클라이언트의 요청을 바인딩하고 해당 DTO 를 검증한다면 좀 더 가독성을 챙겨갈 수 있다.
3. 순환 참조 예방
JPA 양방향 참조를 사용했을 때 해당 Entity 를 Controller 에서 그대로 반환해버린다면, Entity 가 참조하고 있는 객체가 지연로딩되고 로딩된 객체는 또 다시 본인이 참조하고 있는 객체를 호출하게 된다. 이게 반복되면서 무한 재귀 루프에 빠지는 문제가 생기는 것이다. 최대한 단방향 설계를 지향해야하지만 양방향 참조가 부득이한 상황에서 이런 순환 참조를 예방하기 위해 Controller 에서 Entity 가 아닌 DTO 로 반환하는 것이 안전하다고 할 수 있다.
유연성을 확보한 깔끔한 DTO 반환하기
앞 섹션에서 언급했듯 DTO 를 반환함으로써 우리는 좀 더 커스텀한 응답을 클라이언트로 내려줄 수 있다 !
여기서 주의해야 할 점은 '컬렉션을 그대로 반환하지 말자' 인데 코드로 한 번 살펴보자. Builder 패턴 등 사용할 수 있는게 많았지만 예제이기 때문에 간단하게 유저 조회 API 를 작성해보았다 🙂
먼저 MemberResponse DTO 를 작성.
유저 ID 와 이름이 담겨져있는 응답 DTO 이다.
@AllArgsConstructor
@Data
public class MemberResponse {
private Long id;
private String name;
}
MemberResponse 를 컬렉션 타입으로 감싸진 변수를 가진 MembersReponse DTO 를 생성한다.
@AllArgsConstructor
@Data
public class MembersResponse {
private List<MemberResponse> members;
}
그리고 MemberSerivce 에서 유저 목록을 조회하는 메서드를 만들자.
MemberController 에서 getMembers() 를 호출하면 MembersResponse DTO 가 반환될 것이다.
@Service
public class MemberService {
... 중략
public MembersResponse getMembers() {
List<MemberResponse> memberResponses = memberRepository.findAll().stream()
.map(member -> new MemberResponse(member.getId(), member.getName())
.collect(Collectors.toList());
return new MembersResponse(memberResponses);
}
... 중략
}
마지막으로 Controller 에서 유저 목록 반환 메서드를 추가하자.
서비스에서 getMembers() 를 호출해 List 타입으로 새로운 MembersResponse DTO 객체를 만들고 반환받게 된다. 그걸로 반환하기만 하면 끝 !
@RestController
public class MemberApiController {
... 중략
@GetMapping("/api/v2/members")
public MembersResponse memberV2() {
MembersResponse findMembers = memberService.getMembers();
return new MembersResponse(findMembers.getMembers());
}
... 중략
}
Postman 으로 응답이 어떻게 나가는지 확인해보자.
위의 과정처럼 MembersReponse 로 감싸지 않는다면 아래와 같이 컬렉션 그 자체로 응답이 나갈 것이다.
이렇게 반환하게 되면 요구사항 등의 추가로 인해 API 명세가 바뀔 때 JSON 명세가 깨져버리는 즉, 유연성이 죽어버리는 문제점이 있다.
유저의 명수를 반환하도록 userCount 를 추가한다고 가정하면, MemberResponse DTO 를 수정해야하고 이는 미리 정의한 MemberResponse DTO 의 명세가 깨진다는 것을 의미한다. 따라서 이 방법으로 응답을 하면 좋지 못하다는 것이다.
[
// API 명세 변경으로 인해 유저의 명수를 출력해달라고 한다면 ?
"userCount" : 4,
{
"id": 1,
"name": "hello1",
"address": {
"city": "서울시",
"street": "동작구",
"zipcode": "12345"
},
"orders": []
},
{
"id": 2,
"name": "hello2",
"address": {
"city": "서울시",
"street": "동작구",
"zipcode": "12345"
},
"orders": []
},
{
"id": 3,
"name": "hello3",
"address": {
"city": "서울시",
"street": "동작구",
"zipcode": "12345"
},
"orders": []
},
{
"id": 4,
"name": "hello4",
"address": {
"city": "서울시",
"street": "동작구",
"zipcode": "12345"
},
"orders": []
}
]
그래서 우리는 MemberResponse DTO 를 컬렉션 타입으로 가진 MembersRespone DTO 를 반환함으로써 DTO 명세를 지키고 유연성을 확보할 수 있다. userCount 가 추가되면 MemberResponse DTO 가 아닌, MembersResponse 에 userCount 만 추가하면 되니까 ! 이제 제대로 된 결과를 Postman 에서 확인해보면, MemberResponse 명세 바깥에 members 라는 Object 로 컬렉션을 한번 감싸고 클라이언트로 반환하는 것을 확인할 수 있다.
{
"members": [
{
"id": 1,
"name": "hello1",
"address": {
"city": "서울시",
"street": "동작구",
"zipcode": "12345"
}
},
{
"id": 2,
"name": "hello2",
"address": {
"city": "서울시",
"street": "동작구",
"zipcode": "12345"
}
},
{
"id": 3,
"name": "hello3",
"address": {
"city": "서울시",
"street": "동작구",
"zipcode": "12345"
}
},
{
"id": 4,
"name": "hello4",
"address": {
"city": "서울시",
"street": "동작구",
"zipcode": "12345"
}
}
]
}
조금 더 커스텀하게 응답을 보내주려면 어떻게 해야할까 ?
팀원끼리 충분히 협의를 거쳐 클라이언트가 API 를 호출하고 제대로 동작할 때 '1' 이라는 Code 를 붙여서 응답을 보내기로 하고, 유저 목록 조회 메서드가 호출되면 유저의 명수 또한 함께 응답을 보내기로 했다고 가정해보자. 그렇다면, 새로운 껍데기 객체를 만들어주면 된다.
먼저 껍데기를 담당하는 객체인 GlobalResponse 객체를 생성하자.
data 라는 Object 타입을 받아내야하기 때문에 <T> 를 붙여줬다.
@AllArgsConstructor
@Data
public class GlobalResponse<T> {
private Integer code;
private T data;
}
그리고 MembersResponse DTO 에서 userCount 변수를 추가한다.
이처럼 MemberReesponse DTO API 명세를 깨지 않고 유연하게 응답을 보내줄 수 있는 것을 알 수 있다.
@AllArgsConstructor
@Data
public class MembersResponse {
private int userCount;
private List<MemberResponse> members;
}
나머지는 똑같고, Controller 유저 목록 반환 메서드의 반환 타입과 return 만 조금 수정하면 끝.
@RestController
public class MemberApiController {
... 중략
@GetMapping("/api/v3/members")
public GlobalResponse<MembersResponse> memberV2() {
MembersResponse findMembers = memberService.getMembers();
return new GlobalResponse<>(1, findMembers);
}
... 중략
}
이제 Postman 에서 응답 결과를 확인해보자.
GlobalResponse 라는 껍데기 안에 code, data 둘로 나눠져있고 응답을 성공했으니 code 는 1, data 내부에 MembersResponse DTO 에서 추가한 userCount 및 MemberResponse DTO List 가 커스텀하게 반환되는 것을 확인할 수 있다.
이런 껍데기를 여기서만 쓰는게 아니라, 글로벌한 전체 예외 처리에서도 code = -1 를 반환하게 하는 등의 활용 방안이 있으니 상황에 따라 적절하게 사용하도록 하자 !
{
"code": 1,
"data": {
"userCount": 4,
"members": [
{
"id": 1,
"name": "hello1",
"address": {
"city": "서울시",
"street": "동작구",
"zipcode": "12345"
}
},
{
"id": 2,
"name": "hello2",
"address": {
"city": "서울시",
"street": "동작구",
"zipcode": "12345"
}
},
{
"id": 3,
"name": "hello3",
"address": {
"city": "서울시",
"street": "동작구",
"zipcode": "12345"
}
},
{
"id": 4,
"name": "hello4",
"address": {
"city": "서울시",
"street": "동작구",
"zipcode": "12345"
}
}
]
}
}
'SPRING' 카테고리의 다른 글
Custom Exception 활용법 (0) | 2023.06.10 |
---|---|
[Spring Security] Authentication 인증 처리 과정 알아보기 (0) | 2022.12.17 |
[Spring] GitHub OAuth2.0 구현하기 (웹 버전) (2) | 2022.05.23 |
Spring @Validation 을 통한 검증, 예외처리 도전기 (0) | 2022.04.16 |