← Posts

DTO · Design Pattern · Spring

DTO

엔티티를 그대로 내보내면 DB 구조가 곧 API 계약이 된다. 계층을 건너는 전용 그릇을 따로 두는 패턴.

목차
  1. 엔티티를 그대로 내보냈더니
  2. DTO는 계층을 건너는 전용 그릇
  3. 테이블이 바뀌면 API 계약도 바뀐다
  4. 요청과 응답은 다른 그릇이다
  5. 변환은 어디서 하나
  6. record로 쓰면 짧아진다
  7. 조회 전용 DTO로 쿼리까지 줄인다
  8. 그릇이 너무 많아질 때
  9. 정리

엔티티를 그대로 내보냈더니

사용자 정보를 주는 API를 만든다. 엔티티가 이미 있으니 그대로 반환하면 끝난다.

java
@GetMapping("/users/{id}")
public User get(@PathVariable Long id) {
    return userService.findById(id);   // 엔티티를 그대로
}

User 엔티티는 이렇게 생겼다.

java
@Entity
public class User {
    @Id private Long id;
    private String email;
    private String password;          // 암호화된 비밀번호
    private LocalDateTime lastLoginAt;

    @OneToMany(fetch = FetchType.LAZY)
    private List<Order> orders;       // 이 사용자의 주문 전부
}

응답을 열어보면 password가 실려 나간다. 암호화됐다 해도 밖으로 나갈 이유가 없는 값이다. 그리고 필드를 하나 추가하는 순간, 아무도 의도하지 않았는데 API 응답이 바뀐다.

새는 것만 문제가 아니다. orders는 지연 로딩이라 실제로 쓸 때 DB에서 꺼내오는데, 응답을 만드는 직렬화기는 필드를 전부 훑는다. 사용자 정보 하나 달라고 했는데 그 사람의 주문이 통째로 딸려 나간다.

참고

이때 무엇을 만나는지는 설정에 갈린다. Spring Boot는 spring.jpa.open-in-view가 기본으로 켜져 있어 영속성 컨텍스트가 응답을 만들 때까지 살아 있다. 그래서 예외 대신 연관 데이터를 꺼내는 쿼리가 줄줄이 나간다. 이 설정을 끄면 직렬화 시점엔 컨텍스트가 이미 닫혀 있어 LazyInitializationException이 난다. 증상은 갈려도 원인은 하나다.

그 원인이 이 글의 출발점이다. DB에 저장하려고 만든 객체를, 밖으로 내보내는 데 그대로 썼다.

DTO는 계층을 건너는 전용 그릇

DTO(Data Transfer Object)는 계층이나 시스템 사이로 데이터를 나르기 위한 객체다. 로직도 없고 매핑 정보도 없다. 필요한 값만 담은 그릇이다.

java
public class UserResponse {
    private final Long id;
    private final String email;
    // 생성자 · getter 생략
}

password가 없다. orders도 없다. 응답에 나갈 것만 있다.

java
@GetMapping("/users/{id}")
public UserResponse get(@PathVariable Long id) {
    User user = userService.findById(id);
    return new UserResponse(user.getId(), user.getEmail());   // 필요한 것만 옮겨 담는다
}

경계에서 그릇을 갈아타는 그림이다.

diagramdiagram

엔티티는 DB 쪽에서 살고, DTO는 바깥으로 나간다. 둘의 경계가 서비스와 컨트롤러 사이 어딘가에 생긴다.

테이블이 바뀌면 API 계약도 바뀐다

DTO를 안 쓰면 무엇을 잃는지가 여기서 분명해진다. 엔티티를 응답으로 쓰는 순간, 테이블 구조가 곧 공개된 API 계약이 된다.

User 테이블에 phone 칼럼을 추가한다고 하자. 순수하게 DB 사정이다. 그런데 엔티티를 그대로 내보내고 있었다면 API 응답에 phone이 튀어나온다. 반대로 emailemailAddress로 리팩터링하면, 클라이언트가 그날 깨진다.

DTO가 있으면 둘이 끊긴다.

엔티티DTO
누구를 위한 것DB 테이블 매핑계층 · 시스템 사이 전달
언제 바뀌나저장 구조가 바뀔 때화면 · API 계약이 바뀔 때
바뀌면 영향받는 곳저장 로직클라이언트

바뀌는 이유가 애초에 다르다. 다른 이유로 바뀌는 것을 한 객체가 겸하면, 한쪽 사정이 다른 쪽을 흔든다. 엔티티가 ORM의 매핑에 묶인 객체라는 걸 생각하면, 그걸 그대로 밖에 내놓는 게 왜 위험한지 보인다.

요청과 응답은 다른 그릇이다

DTO를 하나 만들어 요청과 응답에 같이 쓰고 싶어진다. 필드가 비슷하니까. 그런데 둘은 필요한 게 다르다.

java
// 요청: 클라이언트가 주는 것
public class UserCreateRequest {
    @NotBlank @Email
    private String email;

    @NotBlank @Size(min = 8)
    private String password;      // 받기는 받는다
}
java
// 응답: 서버가 주는 것
public class UserResponse {
    private Long id;              // 서버가 만든 값
    private String email;
    private LocalDateTime createdAt;
}

password는 요청에만, idcreatedAt은 응답에만 있다. 하나로 합치면 요청에 id를 받는 이상한 계약이 되거나, 응답에 password 자리가 남는다.

그리고 검증이 붙는 자리가 요청 쪽이다.

java
@PostMapping("/users")
public UserResponse create(@RequestBody @Valid UserCreateRequest req) {
    // 여기 도달했으면 email 형식과 password 길이는 이미 통과했다
    return userService.create(req.getEmail(), req.getPassword());
}

@Valid가 컨트롤러 입구에서 막아주니, 서비스는 형식이 맞는 값만 받는다. 엔티티에 이 검증을 달면 저장 규칙과 입력 규칙이 한 클래스에서 섞인다.

변환은 어디서 하나

엔티티를 DTO로 바꾸는 코드를 어디에 둘 것인가. 실무에서 자주 갈리는 지점이다.

앞에서 두 방식을 이미 하나씩 써봤다. 첫 예제는 컨트롤러가 UserResponse를 만들었고, 방금 본 등록 예제는 서비스가 만들어 돌려줬다. 둘 다 돌아가지만 사는 곳이 다르다.

컨트롤러에서 바꾸면 서비스가 엔티티를 반환한다. 서비스는 웹을 모른 채로 남고, 응답 모양은 컨트롤러가 정한다. 다만 지연 로딩 필드를 컨트롤러에서 건드리면 앞에서 본 문제를 만난다. 트랜잭션 경계 밖이라 쿼리가 더 나가거나, OSIV를 껐다면 예외가 난다.

서비스에서 바꾸면 서비스가 DTO를 반환한다. 트랜잭션 안에서 필요한 값을 다 꺼내 담으니 지연 로딩 문제가 사라진다. 대신 서비스가 “밖에 어떤 모양으로 나가는지”를 알게 된다.

diagramdiagram

정답은 없지만 기준은 있다. 엔티티가 어디까지 나가는지를 팀이 한 줄로 답할 수 있으면 된다. 그 선이 그어져 있지 않으면 방금 이 글이 그랬듯 어떤 서비스는 엔티티를, 어떤 서비스는 DTO를 반환하는 상태가 된다. 서비스 계층이 얇게 유지되는지도 이 선택에 딸려 온다.

지연 로딩 필드를 응답에 담아야 한다면 서비스에서 변환하는 쪽이 안전하다. 트랜잭션 안에서 필요한 값만 꺼내 DTO에 담고 나면, 그 뒤로는 DB와 무관한 평범한 객체다. 무엇이 언제 조회되는지가 코드에 드러나는 것도 이 방식의 몫이다.

record로 쓰면 짧아진다

DTO는 값을 담기만 하니 코드가 늘 비슷하다. 필드, 생성자, getter, equals. Java 16부터는 record로 한 줄이면 된다.

java
public record UserResponse(Long id, String email, LocalDateTime createdAt) { }

생성자와 접근자(id()·email())가 자동으로 생기고, 필드는 전부 불변이다. DTO는 만들어서 넘기면 끝이라 불변이 자연스럽다.

정적 팩터리를 하나 붙이면 변환 코드도 한 곳에 모인다.

java
public record UserResponse(Long id, String email, LocalDateTime createdAt) {

    public static UserResponse from(User user) {
        return new UserResponse(user.getId(), user.getEmail(), user.getCreatedAt());
    }
}
java
return UserResponse.from(user);
참고

엔티티에는 record를 쓰지 않는다. JPA 엔티티는 기본 생성자가 필요하고 필드가 바뀔 수 있어야 하는데, record는 둘 다 안 된다. DTO만 불변으로 두는 것이 여기서도 자연스럽다.

조회 전용 DTO로 쿼리까지 줄인다

지금까지는 엔티티를 가져온 뒤 옮겨 담았다. 그런데 목록 화면에 idemail만 쓴다면, 엔티티 전체를 읽어올 이유가 없다.

JPA는 조회 결과를 DTO로 바로 받는 길을 열어준다.

java
public interface UserRepository extends JpaRepository<User, Long> {

    @Query("select new com.example.dto.UserSummary(u.id, u.email) from User u")
    List<UserSummary> findSummaries();
}

나가는 SQL이 달라진다.

sql
-- 엔티티로 읽을 때
select u.id, u.email, u.password, u.last_login_at, ... from users u

-- DTO로 읽을 때
select u.id, u.email from users u

읽는 칼럼이 줄고, 영속성 컨텍스트가 관리할 엔티티도 안 생긴다. 변경 감지 대상이 아니니 스냅샷도 안 뜬다. 목록처럼 “보여주기만 하는” 조회에서 이 차이가 커진다.

여기서 DTO의 성격이 하나 더 드러난다. DTO는 응답 모양을 맞추는 도구이기도 하지만, 필요한 데이터만 읽겠다는 선언이기도 하다.

주의

조회 전용 DTO로 받은 객체는 영속 상태가 아니다. 값을 바꿔도 DB에 반영되지 않는다. 화면에 뿌리는 용도로만 쓰고, 수정이 필요하면 엔티티로 조회한다.

그릇이 너무 많아질 때

DTO를 쓰다 보면 클래스가 불어난다. 사용자 하나에 UserCreateRequest·UserUpdateRequest·UserResponse·UserSummary가 생긴다. “이럴 거면 엔티티 하나로 쓰는 게 낫지 않나” 싶어지는 순간이 온다.

기준은 바뀌는 이유다. 같은 이유로 같이 바뀌는 것끼리는 하나로 두고, 다른 이유로 바뀌는 것은 가른다.

  • 목록과 상세가 늘 같은 필드를 쓴다면 굳이 둘로 나눌 필요가 없다
  • 관리자 응답과 일반 사용자 응답은 공개 범위가 다르다. 합치면 언젠가 한쪽 필드가 반대쪽으로 샌다

변환 코드가 지겨우면 MapStruct 같은 매퍼로 줄일 수 있다. 다만 그건 손이 덜 가게 하는 도구지, 그릇을 나눌지 말지를 대신 정해주지는 않는다.

정리

DTO란계층·시스템 사이로 데이터를 나르는 전용 객체. 로직도 매핑도 없다
왜 쓰나엔티티를 그대로 내보내면 DB 구조가 곧 API 계약이 된다
무엇을 막나비밀번호 같은 필드 노출 · 연관 데이터가 통째로 딸려 나가는 것 · 스키마 변경이 클라이언트로 새는 것
요청과 응답다른 그릇. 검증(@Valid)은 요청 DTO에 붙는다
변환 위치컨트롤러 vs 서비스. 지연 로딩이 얽히면 서비스가 안전하다
조회 전용 DTO필요한 칼럼만 SELECT한다. 대신 영속 상태가 아니다

밖으로 내보낼 모양과 저장할 모양을 갈라놓는 것, 그게 DTO가 하는 일이다.

관련 글