목차
JPA로 개발을 하다 보면 repository.findById()로 엔티티를 조회하고 서비스 계층에서 DTO로 변환하는 코드를 습관처럼 작성한다.
@GetMapping("/users/{id}")
public UserResponseDto getUserById(@PathVariable Long id) {
return userService.findUser(id); // 서비스가 DTO를 반환
}
그런데 예전으로 돌아가서 생각해보면 이런 생각이 들었던것 같다.
"그냥 엔티티를 반환하면 편한데 굳이 DTO를 만들어야 하나?"
물론 지금은 "당연히 안 되지"라고 생각하겠지만 "내가 생각하지 못했던 다른 이유가 더 있지는 않을까" 라는 생각이 들어 찾아보게 되었다. 엔티티를 직접 반환하면 정확히 어떤 문제가 발생하는지 알아보자.
1. 계층 분리의 핵심
스프링의 계층형 아키텍처(Controller - Service - Repository)는 각자의 책임이 명확히 분리되어 있다. DTO는 계층 사이, 특히 외부와 내부사이의 데이터를 운반하는 객체다.
엔티티는 DB 테이블과 1:1로 매핑되는 말 그대로 데이터베이스 그 자체다. 반면 DTO는 API의 명세서다. 많은 개발자가 이 둘을 혼용하면서 문제가 시작된다. 엔티티를 API 응답으로 쓴다는 것은 DB 스키마와 API 명세가 하나로 합쳐진다는 뜻이다.
2. 엔티티 직접 반환은 왜 위험할까?
엔티티 직접 반환의 위험성을 이해하기 위해 이 방식이 적용되지 않은 DTO의 필요성을 확인해보자.
2.1. 예시
엔티티를 API 응답으로 그대로 반환하는 것은 편의점 알바생이 "나이 확인을 위해 신분증 보여주세요"라고 하자, 내 지갑을 통째로 넘겨버리는 것과 같다.
알바생(클라이언트)은 이름과 생년월일만 확인하면 된다. 하지만 알바생은 내 지갑(엔티티)을 통해 아래 정보를 전부 알게 된다.
- 민감 정보: 내 주민등록번호 뒷자리, 카드 비밀번호 메모 (password, socialSecurityNumber 등)
- 불필요한 정보: 내 주소, 명함, 현금 보유액(address, internalId, userLevel 등)
- 관계: 내 가족사진 (@OneToMany 연관 엔티티)
문제점:
- 강한 결합: 내가 지갑(엔티티) 구조를 바꾸면 알바생(클라이언트)도 내 지갑을 보는 방식을 바꿔야 한다. 또한 API 명세가 DB 스키마에 종속된다.
- 보안 취약성: 알바생이 알아서는 안 될 민감 정보(password)까지 노출된다.
2.2. 강하게 결합된 코드
User와 Post가 1:N 관계를 맺고 있는 상황을 코드로 구현해 보자.
// 회원
@Entity
public class User {
@Id @GeneratedValue
private Long id;
private String name;
private String email;
private String password; // 민감 정보
// 연관관계
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Post> posts = new ArrayList<>();
// ... Getters ...
}
// 게시글
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user; // 상호 참조
// ... Getters ...
}
(DTO가 없는 컨트롤러)
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserRepository userRepository;
@GetMapping("/users/{id}")
public User getUserById(@PathVariable Long id) {
// [문제점]
// User 엔티티가 API 명세 그 자체가 되었다.
return userRepository.findById(id).orElse(null);
}
}
이 코드는 앞서 비유한 지갑 통째로 넘기기와 같은 문제를 가지며 아래와 같은 문제점들을 유발한다.
문제점 1: JSON 변환 라이브러리가 User를 JSON으로 바꾸려다 StackOverflowError를 만난다.
1. Jackson이 User 객체 변환 시작
2. posts 필드 발견 -> Post 객체 변환 시도
3. Post 객체 내부의 user 필드 발견 -> User 객체 변환 시도
4. User 객체 내부의 posts 필드 발견... (무한 반복) @JsonIgnore 등으로 자리를 채울수는 있지만 엔티티가 API 응답 구조까지 신경 써야 하는 문제를 유발한다.
문제점 2: 만약 @JsonIgnore로 무한 루프를 피했더라도 지연 로딩 때문에 문제가 발생한다.
1. 컨트롤러가 userRepository.findById(id) 호출
2. @Transactional이 없으므로 User 엔티티를 조회한 직후 DB 세션이 종료된다.
3. 컨트롤러가 User 객체를 반환하면 Jackson이 JSON으로 변환 시작
4. user.getPosts()에 접근 (이 필드가 LAZY 로딩일 경우)
5. 이 시점에는 트랜잭션이 이미 종료되어 세션이 닫혀 있다. 즉 컨트롤러 단에서 Jackson이 지연 로딩된 필드(posts)에 접근하면서 이미 닫힌 세션에서 데이터를 가져오라고 하게 되어 LazyInitializationException이 발생한다.
참고로 서비스 계층에 @Transactional(readOnly = true)가 걸려 있을 경우 DTO 변환까지는 세션이 열려 있으므로 이 예외는 발생하지 않는다. FetchType.EAGER로 바꾸면 이 예외는 피할 수 있지만 N+1 문제를 유발하게 된다.
문제점 3: API 구조의 DB 종속
User 엔티티에는 password처럼 외부에 노출되면 안되는 민감 정보가 포함된다. @JsonIgnore를 깜빡하는 순간 사용자 비밀번호가 API 응답에 그대로 노출되는 사고가 발생한다. 또한 DB 컬럼명을 name에서 username으로 바꾸면 별도의 DTO 없이 엔티티를 직렬화하는 API는 그대로 영향을 받아 JSON 필드명까지 함께 바뀌게 된다. @JsonProperty 등으로 일시적으로 제어할 수는 있지만 근본적으로 DB 스키마와 API 응답이 강하게 결합된 상태다.
3. DTO를 통한 문제 해결
DTO는 API 명세라는 중간계층을 도입해 이 모든 문제를 해결한다.
3.1. 명함만 건네기
문제를 해결한 방식은 지갑을 통째로 주는 대신 '이름: 홍길동, 연락처: 010-xxxx-xxxx' 라고 필요한 정보만 적은 명함(DTO)을 건네는 것이다.
- 내 지갑 구조가 바뀌어도(DB 스키마 변경) 명함 정보만 유지되면 알바생(클라이언트)은 아무 문제 없이 일할 수 있다.
- 주민번호 같은 민감 정보는 애초에 명함에 적지 않는다.
3.2. DTO를 통한 결합도 낮추기
1단계: API 명세(DTO) 정의
- 클라이언트에게 정확히 필요한 데이터만 정의한 클래스를 만든다.
// 1. API 응답 규격(DTO) 정의
@Getter
public class UserResponseDto {
private String name;
private String email;
// (password, posts 등 민감/불필요한 정보는 아예 없음)
// 2. 엔티티를 DTO로 변환하는 책임은 보통 DTO나 서비스 계층이 가진다.
public UserResponseDto(User user) {
this.name = user.getName();
this.email = user.getEmail();
}
}
2단계: 서비스 계층에서 변환 및 반환
- 컨트롤러는 엔티티를 몰라도 된다. 서비스 계층이 엔티티 조회와 DTO 변환을 모두 책임진다.
// Service
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@Transactional(readOnly = true) // Lazy 로딩을 위해
public UserResponseDto findUser(Long id) {
// 1. 엔티티 조회 (DB 세션 유지)
User user = userRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
// 2. 엔티티 -> DTO 변환 (세션이 살아있을 때 필요한 데이터 조회)
return new UserResponseDto(user);
}
}
// Controller
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("/users/{id}")
public UserResponseDto getUserById(@PathVariable Long id) {
// 1. 서비스에게 DTO를 요청
// 2. 받은 DTO를 그대로 반환
return userService.findUser(id);
}
}
UserService는 이제 비즈니스 로직과 API 응답 준비라는 책임에만 집중한다. 엔티티는 DB와의 통신에만 사용되며 컨트롤러 밖으로 나가지 않는다.
4. DTO 변환
DTO를 사용하기로 결정했다면 어떻게 변환할지 효율적인 방법을 선택해야 한다.
4.1. (기본) 생성자 또는 빌더 패턴
위 예시처럼 서비스 계층에서 new UserResponseDto(user)를 호출하는 가장 단순하고 명확한 방법이다. 대부분의 경우 이 방법으로 충분하며 코드가 명시적이다.
4.2. (최적화) DTO 프로젝션
조회 성능에 더 민감한 경우 아예 DB에서부터 DTO 형태로 조회하는 DTO 프로젝션을 사용할 수 있다. DTO 프로젝션은 필요한 컬럼만 조회해 전송량을 줄여주지만 연관 엔티티를 함께 접근할 경우 N+1 문제는 여전히 발생할 수 있다. 따라서 성능 최적화를 위해서는 fetch join이나 @BatchSize 설정 등을 함께 사용하는 것이 좋다.
// Repository
@Query("SELECT new com.example.dto.UserResponseDto(u.name, u.email) " +
"FROM User u WHERE u.id = :id")
Optional<UserResponseDto> findUserDtoById(@Param("id") Long id);
// Service
public UserResponseDto findUser(Long id) {
return userRepository.findUserDtoById(id)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
}
5. 결론
정리하자면 DTO를 도입하는것은 단순한 개인 선택의 문제가 아니라 구조적 안정성과 확장성을 확보하기 위한 설계라고 생각한다. DTO를 통해 아래와 같은 이점을 얻을 수 있다.
- 관심사의 분리: 엔티티(DB 영속성)와 DTO(API 응답)의 책임을 명확히 분리한다.
- 낮은 결합도: API 명세가 DB 스키마 변경에 영향을 받지 않는다. (API 안정성)
- 보안성 확보: password 같은 민감 정보 노출을 차단한다.
- 오류 방지: 무한 루프나 LazyInitializationException 같은 JPA의 예외를 컴파일 시점에 방지할 수 있다.
결국 DTO의 사용은 단순히 귀찮은 추가 작업이 아니라 안정적이고 유지보수가 가능한 설계를 하기 위해 필요한 핵심 요소라고 생각한다.
'공부메모 & 오류해결 > Spring Boot' 카테고리의 다른 글
| [JPA] N+1 문제의 원인과 3가지 해결 방안 (0) | 2025.11.06 |
|---|---|
| [Spring] 제어의 역전(Inversion of Control, IoC) 이란 무엇일까? (1) | 2025.11.05 |
| [Spring] 의존성 주입(Dependency injection, DI) 이란 무엇일까? (0) | 2025.11.04 |
| [Spring Boot] 실시간검색어 구현하기 (3) | 2025.07.13 |
| [Spring Boot] Gzip 압축을 통해 로딩 성능 최적화 하기 (0) | 2025.01.14 |
남건욱's 공부기록