n+1

zioo·2022년 1월 10일
0

Backend

목록 보기
20/40

N + 1 문제를 알아 보기전에 지연(LAZY) 로딩과 즉시(EAGER) 로딩에 대해 자세히 알아보고자 한다.

지연(LAZY) 로딩 과 즉시(EAGER) 로딩

지연 로딩일 경우 Member 엔티티를 조회할 경우 Team 엔티티는 프록시 객체로 가져온다. 후에 실제 Team 객체를 사용하는 시점에 초기화 된다. DB에 쿼리가 나간다.

ex) getTeam()으로 Team을 조회하면 프록시 객체가 조회된다. getTeam.getXXX()으로 팀의 필드에 접근 할 때, 쿼리가 나간다

이렇게 지연 로딩을 사용한다면, SELECT 쿼리가 따로따로 2번 나간다. 즉 네트워크를 2번 타서 조회가 이루어 진다는 이야기이다.

즉시 로딩일 경우 프록시 객체가 아니라 하나의 쿼리로 Team 객체까지 실제 객체를 가져온다.

즉시 로딩 주의해야 할 점

실무에서는 가급적 지연 로딩만 사용한다. 즉시 로딩은 쓰지 않는다.
즉시 로딩을 적용하면 예상하지 못한 SQL이 발생하고 N + 1 문제를 일으킨다.

@ManyToOne, @OneToOne과 같이 @XXXToOne 어노테이션들은 기본이 즉시 로딩으로 되어있다.

@OneToMany와 @ManyToMany는 기본이 지연 로딩이다.


N+1 문제

연관 관계에서 발생하는 이슈로 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수(n) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오게 된다. 이를 N+1 문제라고 한다.

N+1(1+N) 문제는 ORM을 사용하면 가장 쉽게 접할 수 있는 문제 중에 하나이다.
1번 쿼리를 날렸는데 추가로 N번 더 쿼리문을 날려야 하는 상황을 1+N이라고 불린다.

예시)

SQL 1번으로 100명의 회원을 조회하였는데,

각 회원마다 주문한 상품을 추가로 조회하기 위해 100번의 SQL을 추가로 실행하는 상황을 말한다.

한번 SQL을 실행해서 조회된 결과 수만큼 N번 SQL을 추가로 실행한다고 해서 N+1 문제라 한다.

N+1 문제 원인

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;
}
  • ERD

하위 엔티티를 조회하지 않는 경우

@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 문제 발생!

N+1 문제 해결 방법

패치 조인(Fetch Join)

미리 쿼리로 테이블을 조인해서 가져오기 때문에 Lazy, Eager 두개의 전략에 해당되는 해결법.

n+1 자체가 발생하는 이유가 한쪽 테이블만 조회하고 연결된 다른 테이블은 따로 조회하기 때문이다.
두 테이블을 join 하여 한번에 모든 데이터를 가져올 수 있다면 n+1 문제가 발생하지 않을 것이다.
그렇게 나온 해결 방법이 fetchJoin 방법이다.
두 테이블을 join 하는 쿼리를 작성하는 것이다.

다음과 같이 JPQL을 직접 지정해준다.
일대다 조인이므로 DISTINCT를 이용해 중복을 제거하는 것이 좋다.

@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개인 엔티티를 패치 조인 사용 불가

Batch Size 조절

(권장 되는 방식은 아님)

설정한 Size만큼 데이터를 미리 로딩한다.

batchSize 만큼 미리 로딩해 뒀다가, 사이즈를 초과할 때 다음 SQL을 실행한다.

JPA의 페이징 API 기능처럼 개수가 고정된 데이터를 가져올 때 함께 사용하면 유용하게 사용 가능.
하지만, 글로벌 패치 전략을 즉시 로딩으로 변경해야하는 단점이 존재.

@BatchSize(size = 5)
@OneToMany(mappedBy = "album", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private List<Song> songs = new ArrayList<>();

SUBSELECT

FetchMode를 SUBSELECT로 사용하면 연관된 데이터를 조회할 때 쿼리를 사용해서 N+1 문제를 해결한다.
필요할 때 fetch join을 하는 것이라 생각하면 된다.

결론

  • 즉시 로딩과 지연 로딩 중 지연 로딩을 사용하는 것을 추천한다.
  • 성능 최적화가 필요한 곳에는 JPQL 페치 조인을 사용한다.
  • OneToOne, ManyToOne은 즉시 로딩이 기본이므로 지연 로딩 전략으로 변경해서 사용하도록 한다.

출처
https://skagh.tistory.com/39 [재수강은없다]
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

0개의 댓글