JPA를 사용한다면 N + 1 문제를 고려하면서 사용해야 한다. N + 1 문제 발생 시 성능에 큰 영향을 줄 수 있기 때문에 N + 1 문제가 무엇인지, 어떤 상황에서 발생되는지, 어떻게 해결하면 되는지에 대해 알아보고자 한다.
N + 1 문제를 알아 보기전에 지연(LAZY) 로딩과 즉시(EAGER) 로딩에 대해 자세히 알아보고자 한다.
아래와 같은 연관 관계가 존재한다.
지연 로딩일 경우 Member 엔티티를 조회할 경우 Team 엔티티는 프록시 객체로 가져온다. 후에 실제 Team 객체를 사용하는 시점에 초기화 된다. DB에 쿼리가 나간다.
ex) getTeam()으로 Team을 조회하면 프록시 객체가 조회된다. getTeam.getXXX()으로 팀의 필드에 접근 할 때, 쿼리가 나간다
이렇게 지연 로딩을 사용한다면, SELECT 쿼리가 따로따로 2번 나간다. 즉 네트워크를 2번 타서 조회가 이루어 진다는 이야기이다.
즉시 로딩일 경우 프록시 객체가 아니라 하나의 쿼리로 Team 객체까지 실제 객체를 가져온다.
실무에서는 가급적 지연 로딩만 사용한다. 즉시 로딩은 쓰지 않는다.
즉시 로딩을 적용하면 예상하지 못한 SQL이 발생하고 N + 1 문제를 일으킨다.
@ManyToOne, @OneToOne과 같이 @XXXToOne 어노테이션들은 기본이 즉시 로딩으로 되어있다.
@OneToMany와 @ManyToMany는 기본이 지연 로딩이다.
Spring Data JPA 에서 제공하는 Repository의 findAll(), findById() 등과 같은 메소드를 사용하면 바로 DB에 SQL 쿼리를 날리는 것이 아니다. JPQL 이라는 객체지향 쿼리 언어를 생성, 실행시킨 후 JPA는 이것을 분석해서 SQL을 생성, 실행하는 동작에서 N+1 문제가 발생한다.
N+1 발생하는 경우는 2가지 경우이다.
즉시 로딩으로 데이터를 가져오는 경우.
지연 로딩으로 데이터를 가져온 이후에 가져온 데이터에서 하위 엔티티를 다시 조회하는 경우
ex) 1 : N 관계를 만들기 위해 하나의 앨범(Album)이 많은 노래(Song)을 가질 수 있도록 엔티티를 생성하고 관계를 연결 연결.
@Entity
public class Album {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Column(nullable = false)
private String albumTitle;
@Column(nullable = false)
private String locales;
// @OneToMany(mappedBy = "album", cascade = CascadeType.ALL, fetch = FetchType.EAGER) // 2번 상황
@OneToMany(mappedBy = "album", cascade = CascadeType.ALL, fetch = FetchType.LAZY) // 1번 상황
private List<Song> songs = new ArrayList<>();
}
@Entity
public class Song {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private int track;
@Column(nullable = false)
private int length;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "album_id")
private Album album;
}
@Test
public void N1_쿼리테스트_1() throws Exception{
List<Album> albums = albumRepository.findAll();
}
하위 엔티티에 접근하지 않았기 때문에 앨범만 가져온다.
N+1 문제 발생 !
JPQL에서 동작한 쿼리를 통해서 앨범 데이터를 조회한다. 그 이후에 JPA에서는 글로벌 패치 전략(즉시 로딩)을 보고 앨범의 노래 데이터에 대해서 추가적인 로딩 작업을 진행해 N + 1 문제를 발생 시킨다.
지연 로딩을 하기 위해서는 해당 엔티티(노래)가 영속 상태여야 한다.
보통 Repository에서 리스트로 가져오면 영속이 끊긴 상태로 가져오기 때문에 지연 로딩 테스트시 @Transactional을 꼭 사용해야 한다.
@Test
@Transactional // 테스팅에서 LAZY 전략시 필수
public void N1_쿼리테스트_2() throws Exception{
List<Album> albums = albumRepository.findAll(); // (1) N+1 발생하지 않음
for (Album album : albums) {
System.out.println(album.getSongs().size()); // (2) Song에 접근 N+1 발생.
}
}
처음엔 앨범 리스트만 조회했지만 앨범 엔티티에서 하위 엔티티 노래 엔티티로 접근했기 때문에 지연 로딩이 일어나면서 N+1 문제 발생!
(1) 지연 로딩으로 findAll()실행시 앨범 객체 관련된 정보를 조회.
(2) 여기서 노래 정보를 조회하면, 앨범 객체에 대한 조회는 이미 끝난 상태라서 JOIN으로 쿼리가 생성이 안된다. 단지 앨범 객체에 대한 정보 ID로 조회할 수 밖에 없어서 where song.albumId=? 형식으로 JPQL 쿼리를 생성한다. 이로 인해 매번 조회 쿼리가 생성이 되어 N 번 실행하는 이슈가 발생.
하위 엔티티를 조회하지 않는 경우 즉시 로딩 방식과 동일하게 N+1 문제 발생!
미리 쿼리로 테이블을 조인해서 가져오기 때문에 Lazy, Eager 두개의 전략에 해당되는 해결법.
@Query("select DISTINCT a from Album a join fetch a.songs")
List<Album> findAllJoinFetch();
@Test
@Transactional // 테스팅에서 LAZY 전략시 사용해야 동작
public void FetchJoin_테스트() throws Exception{
List<Album> albums = albumRepository.findAllJoinFetch();
for (Album album : albums) {
System.out.println(album.getSongs().size()); // Song에 접근 !
}
}
결과를 보면 쿼리가 1번 발생하고 미리 앨범과 노래 데이터를 조인해서 가져오는 것을 볼 수 있다.
JPA가 제공하는 Pageable 기능 사용 불가
1 : N 관계가 2개인 엔티티를 패치 조인 사용 불가
(권장 되는 방식은 아님)
설정한 Size만큼 데이터를 미리 로딩한다.
JPA의 페이징 API 기능처럼 개수가 고정된 데이터를 가져올 때 함께 사용하면 유용하게 사용 가능.
하지만, 글로벌 패치 전략을 즉시 로딩으로 변경해야하는 단점이 존재.
@BatchSize(size = 5)
@OneToMany(mappedBy = "album", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private List<Song> songs = new ArrayList<>();
참고자료
https://wwlee94.github.io/category/blog/spring-jpa-n+1-query/,
https://blog.advenoh.pe.kr/database/JPA-N1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EB%B0%A9%EB%B2%95/,
https://ict-nroo.tistory.com/132