개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

공부 › JPA › 웹 애플리케이션과 성능

DTO로 바로 조회하기

엔티티로 조회해 옮기는 것과 DTO로 바로 조회하는 것은 무엇이 다른가

화면에 회원의 이름과 나이만 보여 주면 되는데, 엔티티로 조회하면 회원의 모든 컬럼을 읽어 온 뒤 필요한 값만 DTO로 옮기게 됩니다. JPQL의 new를 쓰면 필요한 값만 골라 DTO로 바로 받을 수 있습니다.

실행으로 대조 · Hibernate 7.4.5와 H2에서 열두 가지 조합을 실행해 SELECT 수, 컬럼 수, 관리 중인 엔티티 수, UPDATE 수를 대조

이 실습에 나오는 개념

DTO
Data Transfer Object의 줄임말로, 계층 사이나 화면으로 값을 실어 나르기 위해 만든 단순한 객체다. 엔티티와 달리 @Entity가 붙지 않고 테이블과 연결되지 않으므로, 영속성 컨텍스트가 관리하지 않으며 값을 바꿔도 DB에 반영되지 않는다. 화면에 필요한 값만 담아 내보내면, 엔티티의 구조가 바뀌어도 응답의 모양이 함께 바뀌지 않고 내보내면 안 되는 값이 섞여 나가는 일도 막을 수 있다. 엔티티를 조회한 뒤 DTO로 옮겨 담을 수도 있고, 쿼리에서 필요한 값만 골라 DTO로 바로 받을 수도 있다.
JPQL 생성자 표현식
JPQL의 select 절에 new 패키지.클래스(값, 값)처럼 적어, 조회 결과를 엔티티가 아닌 다른 클래스의 객체로 바로 받는 문법이다. SQL의 SELECT 절에는 생성자에 넘긴 값에 해당하는 컬럼만 들어가고, 행마다 그 클래스의 생성자가 불린다. 결과는 엔티티가 아니므로 영속성 컨텍스트가 관리하지 않고, 변경 감지의 대상도 아니다. JPA 표준은 클래스 이름을 패키지까지 포함해 적도록 정하며, 넘기는 값의 개수와 타입이 맞는 생성자가 그 클래스에 있어야 한다.
영속성 컨텍스트
엔티티를 보관하고 관리하는 JPA의 논리적 공간이다. 조회하거나 저장한 엔티티는 이곳에 보관되며, JPA는 이곳의 엔티티에 일어난 변화를 추적해 트랜잭션이 끝날 때 DB에 반영한다. 보통 트랜잭션 하나와 생명주기를 같이한다(OSIV가 켜진 웹 요청에서는 요청이 끝날 때까지 유지된다). 1차 캐시, 변경 감지, 쓰기 지연 같은 기능이 이 위에서 동작한다.
변경 감지(Dirty Checking)
영속성 컨텍스트가 관리하는 엔티티의 값이 바뀌면, 플러시할 때(보통 커밋 직전) JPA가 처음 읽었던 상태와 비교해 값이 달라진 엔티티에 대한 UPDATE 문을 자동으로 실행하는 기능이다. Hibernate는 기본적으로 바뀐 컬럼만이 아니라 전체 컬럼을 UPDATE 문에 넣으며, 바뀐 컬럼만 넣으려면 @DynamicUpdate를 쓴다. 따라서 조회한 엔티티의 값을 바꾸기만 하면 별도의 저장 메서드를 호출하지 않아도 DB에 반영된다.
N+1 문제
목록을 조회하는 쿼리 1번이 실행된 뒤, 조회된 N개의 결과마다 연관 데이터를 가져오는 쿼리가 1번씩 총 N번 더 실행되는 문제다. 데이터가 많아질수록 쿼리 수가 비례해 늘어 성능이 크게 떨어진다. 연관 데이터를 한 번의 쿼리로 함께 가져오거나(fetch join) 여러 건을 묶어 가져오는(배치 조회) 방식으로 해결한다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. em.createQuery("select m from Member m"): Member 엔티티를 통째로 조회합니다. SELECT 절에는 회원의 컬럼 6개(id, name, age, email, city, team_id)가 모두 들어갑니다. 읽어 온 회원 4명을 엔티티 객체로 만들어 영속성 컨텍스트에 보관하고, 처음 값을 스냅샷으로 남깁니다. 팀은 지연 로딩이라 아직 읽지 않았습니다.
  2. 회원 1번을 DTO로 옮기기: m.getTeam().getName()으로 팀 이름을 꺼내는 순간, 아직 읽지 않은 팀 1번을 조회하는 SELECT가 따로 나갑니다(지연 로딩). 읽어 온 팀도 영속성 컨텍스트가 관리합니다.
  3. 회원 2번을 DTO로 옮기기: 이 회원의 팀 1번은 앞에서 읽어 영속성 컨텍스트에 있으므로 쿼리가 나가지 않습니다.
  4. 회원 3번을 DTO로 옮기기: m.getTeam().getName()으로 팀 이름을 꺼내는 순간, 아직 읽지 않은 팀 2번을 조회하는 SELECT가 따로 나갑니다(지연 로딩). 읽어 온 팀도 영속성 컨텍스트가 관리합니다.
  5. 회원 4번을 DTO로 옮기기: 이 회원의 팀 2번은 앞에서 읽어 영속성 컨텍스트에 있으므로 쿼리가 나가지 않습니다.
  6. 메서드 종료, 커밋: 커밋 직전에 관리 중인 엔티티 6개를 스냅샷과 비교합니다. 달라진 것이 없어 보낼 SQL이 없습니다. 조회만 하는 화면인데도 스냅샷을 만들어 두고 비교하는 일은 일어납니다.

실행 결과

코드

@Entity
class Member {
    @Id Long id;
    String name;
    int age;
    String email;
    String city;
    @ManyToOne(fetch = FetchType.LAZY)
    Team team;             // Team에는 id, name, region이 있다
}

// 화면에 내보낼 값만 담는 클래스. 엔티티가 아니다 (패키지는 dtoquery)
public class MemberDto {
    private String name;
    private int age;
    private String teamName;
    public MemberDto(String name, int age, String teamName) {
        this.name = name; this.age = age; this.teamName = teamName;
    }
    public void setName(String name) { this.name = name; }
}

@Transactional
public List<MemberDto> list() {
    List<Member> members = em.createQuery(
        "select m from Member m", Member.class)
        .getResultList();

    List<MemberDto> dtos = new ArrayList<>();
    for (Member m : members) {
        dtos.add(new MemberDto(m.getName(), m.getAge(), m.getTeam().getName()));
    }
    return dtos;
}   // 메서드가 끝나면 커밋

이 모형의 전제

열두 가지 조합을 이 PC의 Hibernate 7.4.5와 H2 데이터베이스에서 화면과 같은 구조의 코드로 실제 실행해, 나간 SELECT의 수와 대상 테이블, 첫 SELECT의 컬럼 수, 영속성 컨텍스트가 관리하는 엔티티의 수, 커밋할 때 나간 UPDATE의 수, DB에 남은 이름을 화면과 대조했습니다. 코드 전체가 하나의 트랜잭션(영속성 컨텍스트) 안에서 실행된다고 보았습니다. 화면의 SQL은 읽기 쉽게 줄인 것이고, 실제 문장의 모양과 컬럼 순서는 Hibernate 버전에 따라 다릅니다. fetch join에서 컬럼이 9개가 아니라 8개인 것은 Hibernate 7.4.5가 회원의 team_id를 따로 읽지 않았기 때문이며, 버전에 따라 달라질 수 있습니다. 팀을 읽는 추가 쿼리의 수는 아직 읽지 않은 서로 다른 팀의 수(이 예에서는 2)이고, 배치 크기 설정이 없을 때의 값입니다. JPA 표준은 new 뒤에 패키지 이름까지 포함한 클래스 이름을 적도록 정합니다. Hibernate 7.4.5에서 실행해 보니, createQuery에 결과 타입으로 MemberDto.class를 함께 넘기면 패키지 이름을 빼도 실행됐고, 결과 타입 없이 클래스 이름만 적으면 클래스를 찾지 못해 예외(SemanticException)가 났습니다. 패키지 이름을 빼도 되는 것은 표준이 보장하는 동작이 아닙니다. 스프링 데이터 JPA가 인터페이스나 record로 필요한 값만 받게 해 주는 기능(프로젝션)은 이 실습에서 다루지 않았습니다.

앞 실습: 재고 차감과 락

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요