Spring에서는 웹 애플리케이션을 만들다 보면 사용자로부터 요청을 받고, 그에 대한 응답을 주는 구조가 기본이다. 이때 데이터를 주고받기 위해 DTO (Data Transfer Object) 라는 개념이 자주 등장한다.
처음 개발을 시작하면 DTO 없이도 동작은 잘 된다. 하지만 기능이 많아지고 화면이 늘어나면 어느 순간부터 설계가 복잡해지고, 문제가 생기기 시작한다.
이 글에서는 DTO가 왜 필요한지, 그리고 사용하지 않으면 어떤 문제가 생기는지를 단계적으로 정리한다.
1. DTO란 무엇인가?
DTO는 Data Transfer Object의 약자로, 말 그대로 데이터를 전달하기 위한 객체다.
- 요청(Request) 데이터를 받을 때,
- 응답(Response) 데이터를 보낼 때,
- 혹은 시스템 내부 계층 간 데이터를 전달할 때
딱 필요한 필드만 담아서 쓰는 용도로 만든 객체다.
예를 들어 회원 가입 요청에서 username과 password만 받는 경우, 아래처럼 DTO를 만든다:
public class SignupRequestDto {
private String username;
private String password;
}
이 객체에는 비즈니스 로직은 없다. 그냥 데이터만 담는다. 말하자면, “화면에 필요한 만큼만 딱 전달하는 전용 상자”다.
2. 엔티티만 가지고 시작하면 생기는 문제들
(1) 구조가 그대로 API에 노출된다
처음에는 User 같은 엔티티 하나 만들어 놓고, 그걸 그대로 응답이나 요청에 써도 잘 된다.
@PostMapping("/users")
public User create(@RequestBody User user) {
return userRepository.save(user);
}
하지만 시간이 지나면서 User 엔티티에 내부적인 필드가 하나둘 늘어나기 시작한다.
예를 들어:
@Entity
public class User {
private String username;
private String password;
private String email;
private String internalNote; // 내부용 메모
private LocalDateTime createdAt;
}
여기서 문제가 생긴다.
- 프론트엔드에 응답을 줄 때, internalNote, createdAt, 심지어 password까지도 JSON으로 다 보내버릴 수 있다.
- 이건 보안 문제이자, 역할 분리 실패다.
이를 막기 위해 @JsonIgnore 등을 쓰기 시작하면,
도메인 객체 안에 프론트단을 의식한 설정이 하나둘 들어오기 시작한다.
결국 비즈니스 모델이 외부 응답 형식에 끌려다니게 된다.
(2) 요청마다 필요한 데이터가 다르다
모든 화면이 같은 정보를 필요로 하진 않는다.
예를 들어 User 관련 API를 보면:
- 회원가입: username, password만 받으면 됨
- 마이페이지: username, email, 가입일 정도 보여주면 됨
- 관리자 조회: username, email, 가입일, 권한, 상태 등 더 많은 정보 필요
이런 다양한 상황을 하나의 User 엔티티로 다 처리하려고 하면,
결국 필드 하나로 여러 상황을 동시에 맞춰야 하는 무리수가 생긴다.
“이 필드는 가입할 땐 필요하지만, 수정할 땐 null이어도 된다.”
“이건 관리자만 봐야 하니까 숨기자.”
이런 조건이 늘어날수록 엔티티는 점점 지저분해지고,
기능을 추가할 때마다 JSON 응답이 깨지거나, 검증 로직이 꼬이게 된다.
3. 요청마다 유효성 검사가 달라질 수 있다
Spring에서는 @Valid, @NotBlank, @Email 같은 어노테이션으로 요청값을 검증할 수 있다.
그런데 엔티티에 검증 조건을 붙이면 상황에 따라 쓸 수 없는 경우가 생긴다.
예를 들어:
- 회원가입 시에는 username과 password가 꼭 있어야 한다.
- 정보 수정 시에는 password는 선택일 수 있다.
이걸 하나의 User 엔티티로 해결하려 하면 복잡한 조건 분기나 @ValidationGroups 같은 기능까지 써야 한다.
하지만 DTO를 상황별로 나누면 그냥 각각에 맞는 조건만 붙이면 된다.
public class SignupRequestDto {
@NotBlank
private String username;
@NotBlank
private String password;
}
public class UserUpdateRequestDto {
private String password; // 선택 입력
}
요청 목적이 다르면 구조도 달라야 한다.
DTO는 그걸 자연스럽게 분리해주는 도구다.
4. 필요한 데이터만 조회하고 성능도 조절할 수 있다
엔티티를 직접 반환하면 연관된 모든 객체까지 함께 로딩되는 경우가 많다.
특히 @OneToMany, @ManyToOne 관계가 얽혀 있으면, 조회할 때 불필요한 데이터까지 다 끌려온다.
이건 N+1 문제로 이어질 수 있다.
그런데 DTO는 필요한 필드만 골라서 쿼리할 수 있다.
예를 들어 JPQL에서 아래처럼 DTO로 직접 결과를 매핑할 수 있다:
SELECT new com.example.UserDto(u.username, u.email)
FROM User u
이렇게 하면 정말 필요한 데이터만 가져오고,
JSON으로 응답할 때도 딱 정해진 구조로 내려갈 수 있다.
쿼리 최적화, 성능 조절, 응답 구조 제어가 모두 쉬워진다
마무리하며
처음엔 엔티티 하나로 요청과 응답을 모두 처리하는 게 더 단순하고 효율적으로 느껴졌다.
굳이 DTO를 만들어야 하나 싶었고, 실제로도 초반 개발에선 잘 굴러갔다.
그런데 기능이 하나둘 늘어나고, 화면마다 요구하는 데이터가 달라지기 시작하면서 구조가 흐트러지기 시작했다.
엔티티에 점점 API 응답용 설정이 섞이고, 어디선가 필드 하나만 바꿔도 프론트 에러가 나는 상황이 생겼다.
결국, 요청과 응답의 책임을 분리해야 할 필요성을 느꼈고, 그 중심에 DTO가 있었다.
지금은 구조가 단순하더라도, 확장 가능성을 생각하면 처음부터 DTO를 염두에 두고 설계하는 게 더 낫다고 생각하게 됐다.
이후부터는 “되도록이면 분리하고, 꼭 필요할 때만 합친다”는 기준을 가지고 코드를 보는 습관이 생겼다.
지금 설계가 편한 것보다, 나중에 유지보수하기 쉬운 쪽을 택하는 게 결국 더 나은 선택이라는 걸 한 번 더 체감하게 됐다.
다음 장에서는 지연 로딩과 조회 성능 최적화에 대해 알아볼 것이다.
감사합니다.
'스프링' 카테고리의 다른 글
| [스프링] 컬렉션 조회 최적화 (4) | 2025.07.31 |
|---|---|
| [스프링] 지연 로딩과 조회 성능 최적화 (3) | 2025.07.27 |
| [스프링] JPA에서 제공하는 쿼리 방법 (1) | 2025.07.26 |
| [스프링] JPA 상속관계 매핑 (7) | 2025.07.25 |
| [스프링] JPA 연관관계 매핑 (2) | 2025.07.25 |
