[Spring Boot/JPA] JPQL : 지연 로딩과 N+1 문제 해결

2024. 1. 8. 08:18
반응형

 

 

이번 포스팅에서는 JPA를 이용하여 데이터를 조회할 때의 유의해야 할 내용들에 대해서 다루어 보려고 한다.

 

 


즉시 로딩과 지연 로딩

 

지연 로딩(Lazy Loading)은 엔터티의 관계를 로딩할 때, 실제로 필요한 시점까지 로딩을 지연시키는 방법이다.

이는 데이터베이스에서 필요한 데이터를 가져오는 시점을 늦추어서, 불필요한 데이터를 미리 로딩하지 않도록 한다.

 

  1. 즉시 로딩 (Eager Loading): 엔터티를 조회할 때, 연관된 엔터티의 데이터도 함께 조회하는 방법.
    @ManyToOne, @OneToOne과 같은 관계에 대해 디폴트로 적용되는 방식.
    fetch = FetchType.EAGER는 즉시 로딩을 사용하겠다는 뜻이다.
  2. 지연 로딩 (Lazy Loading): 연관된 엔터티의 데이터는 실제로 사용될 때까지 로딩을 지연시키는 방법.
    즉, 연관된 엔터티를 실제로 접근할 때 해당 데이터를 로딩한다.
    fetch = FetchType.LAZY는 지연 로딩을 나타낸다.

 

@Entity
@Getter
@Setter
public class Music {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "producer_id")
    private Producer producer;

}
@Entity
@Getter
@Setter
public class Producer {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "producer", fetch = FetchType.LAZY)
    private List<Music> musics;

}

 

다음의 Music과 Producer은 N:1 (Many to One) 관계이고, 양방향 관계를 가지고 있다.

이 때 FetchType을 LAZY 혹은 EAGER로 조절하여 즉시 로딩과 지연 로딩을 설정할 수 있다.

 

 

 

@Repository
@RequiredArgsConstructor
public class MusicRepository {
    private final EntityManager em;


    public List<Music> findAll() {
        return em.createQuery("select m from Music m join fetch m.producer p", Music.class
        ).getResultList();
    }


}

 

만약 다음과 같은 쿼리를 실행시킨다고 하였을 때

즉시 로딩의 경우 Producer가 자동으로 함께 조회되겠지만, 지연 로딩의 경우 필요하지 않은 것으로 간주하고 Producer를 제외한 데이터를 가져오게 될 것이다.

 

 

 

 

지연 로딩의 원리

  1. 엔터티 매니저가 엔터티를 로드할 때
    JPA는 프록시 객체 (HibernateProxy) 를 실제 엔터티를 대신해서 반환한다.
    프록시 객체는 실제 엔터티와 동일한 인터페이스를 가지는 껍데기라고 생각하면 된다.

  2. 프록시 객체 사용시점까지는 실제 데이터를 로드하지 않는다
    프록시 객체는 연관된 엔터티에 대한 참조를 가지고 있지만, 그 엔터티의 데이터는 로드하지 않는다. 

  3. 프록시 객체의 메서드 호출 시 실제 데이터 로딩
    프록시 객체의 메서드 중에서 실제 데이터가 필요한 시점(getter 호출 등..)에 데이터베이스에서 실제 데이터를 가져와서 프록시 객체를 실제 객체로 대체한다.

  4. 초기화되면 프록시 객체는 실제 객체로 교체
    한 번 프록시 객체가 초기화되면(실제 데이터가 로드되면), 그 이후에는 프록시 대신에 실제 객체가 사용된다.

 

 

지연 로딩을 사용해야 하는 이유

 

1. 연관 데이터를 필요없는 경우에도 항상 조회해서 성능 문제가 발생할 수 있다.

2. 즉시 로딩은 예상하지 못한 SQL이 발생할 수 있다. 

3. 따라서 대부분의 연관 관계는 Lazy Loading 전략을 사용하는 것이 좋다.

 

 

 

 


N+1 문제

 

N+1 문제는 데이터베이스 조회에서 발생하는 성능 이슈 중 하나로, JPA와 같은 ORM 사용 환경에서 지연 로딩 사용 시 발생할 수 있다.

 

 

 


예를 들어, Music 엔터티를 로드하고, 각 Music에 대한 연관된 Producer들을 로드하는 아래와 같은 상황을 가정해 보자.

public List<Music> findAll() {
    return em.createQuery("select m from Music m join m.producer p", Music.class
    ).getResultList();
}

 

 

 

이상적으로 실행되는 쿼리문 케이스는 다음과 같을 것이다.

select * from music left join producer p on producer_id = p.id;

 

 

 

 

그렇지만 실제로 실행되는 쿼리문의 형태는 다음의 형태를 띈다.

select * from music;
select * from producer where id = 1;
select * from producer where id = 2;

 

첫 번째 쿼리 수행 : 한 번의 쿼리로 엔터티를 로드

추가 쿼리 수행 : 엔터티에 연관된 엔터티의 개수 만큼의 추가 쿼리를 수행

 

해당 경우에는 부모 엔터티가 중복된 연관 엔터티가 있어서 2번의 추가 쿼리가 실행되었지만,

만약 Music에 연관된 Producer들이 각각 다르다면 최악의 경우 Music 엔터티의 개수 N개만큼의 추가적으로 Producer를 조회하는 쿼리가 실행된다.

 

이를 N+1 문제라고 부른다. N+1 문제는 쿼리 성능에서 문제를 발생시킨다. 

이처럼 불필요한 쿼리의 수행은 통신 비용과 Latency를 증가시켜 성능을 저하시킬 수 있으며 데이터베이스 부하 및 메모리의 낭비가 일어나게 된다.

 

 


Fetch Join

 

Fetch Join은 N+1 문제를 방지하고 성능을 최적화할 수 있는 수단으로서 데이터베이스에서 연관된 엔터티를 함께 로딩하는 방법이다.

Fetch Join을 사용하면 조회 성능을 최적화 할 수 있다.

 

 

 

우리가 이상적으로 원하는 쿼리문은 아래와 같았다. 

select * from music left join producer p on producer_id = p.id;

 

 

JPQL 쿼리문을 다음과 같이 바꾸어보자.

public List<Music> findAll() {
    return em.createQuery("select m from Music m join fetch m.producer p", Music.class)
    .getResultList();
}

 

join fetch는 Fetch Join을 사용하여 연관된 데이터를 조회하겠다는 뜻이다.

 

 

 

그 결과로 한 줄의 쿼리문을 통해 연관된 데이터를 모두 불러올 수 있게 되었다.

 

 


 

DTO로 직접 조회

 

Fetch Join을 사용하지 않는다면, 또 다른 방법으로 Repository에서 엔터티를 DTO로 변환하여 조회하는 것이 있다.

 

 

@Data
public class MusicDto {
    private Long id;
    private String title;
    private Long producerId;
    private String producerName;


    public MusicDto(Long id, String title, Long producerId, String producerName){
        this.id = id;
        this.title = title;
        this.producerId = producerId;
        this.producerName = producerName;

    }
    
    //...
}
@Repository
@RequiredArgsConstructor
public class MusicRepository {
    private final EntityManager em;

    public List<MusicDto> findAllDto() {
        return em.createQuery("select new jpabook.jpashop.dto.MusicDto(m.id, m.title, p.id, p.name) " +
                "from Music m join m.producer p",
                MusicDto.class
        ).getResultList();
    }
}

 

 

위의 코드에서 JPQL을 사용하여 Music 엔터티와 Producer 엔터티를 Join하여 DTO로 변환하여 조회하고 있다.

new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환할 수 있고, 원하는 값을 선택해서 조회할 수 있다.

이를 통해 MusicDto에 필요한 데이터를 한 번에 로드하게 되고 이는 N+1 문제를 방지할 수 있는 또다른 방법이 될 수 있다.

 

해당 방법은 Select 절에서 원하는 데이터를 직접 선택하므로 N+1 문제 방지와 동시에 미비한 최적화를 이루어낼 수 있다.

DTO의 스펙에 맞춘 코드가 Repository에 들어가게 되므로, 재사용성 측면에서 좋지 않을 수 있다.

 

 

 

 

 


결론

 

일반적인 경우 성능 최적화에 따라 코드의 복잡성이 증가하게 된다.
엔티티 조회 방식은 JPA가 많은 부분을 최적화 해주기 때문에, 단순한 코드를 유지하면서, 성능을 최적화 할 수 있다.

DTO 조회 방식은 엔터티 조회 방식 이상으로 성능을 최적화할 수 있지만 코드의 복잡성을 증가시킨다.

두 방식에는 Trade-off가 있으며, 최적화와 코드 복잡성 사이에서의 선택을 해야 한다.

 

1. 즉시 로딩보다는 지연 로딩을 기본으로 삼자.

2. 연관관계의 N+1 문제는 Fetch Join을 통해 최적화할 수 있다.

3. Fetch Join으로 성능 이슈가 해결이 되지 않는다면 DTO로 직접 조회하는 방법을 사용해보자.

 

 

 


Reference : 인프런 김영한님 : API 개발 고급 - 컬렉션 조회 최적화

 

 

반응형

BELATED ARTICLES

more