JPA N+1문제

junto·2024년 3월 2일
0

database

목록 보기
3/7
post-thumbnail

JPA N+1 문제

  • N+1 문제란 어떤 명령을 실행하기 위해 하나의 쿼리가 필요하다고 생각했지만, JPA가 연관된 엔티티를 불러오는 과정에서 추가로 N개의 쿼리가 발생하는 현상을 말한다.

JPA의 기본 fetch 전략

  • fetch 전략이란 JPA에서 엔티티 간 관계를 맺을 때, 관련된 엔티티를 어떻게 불러올지 결정하는 옵션이다. 엔티티 관계에 따라 기본 fetch 전략이 다르다.

  • @ManyToOne, @OneToOne : EAGER(즉시)

    • 기본 fetch 전략이 EAGER로 연관된 엔티티가 즉시 로드된다. 컬렉션의 크기가 크지 않고, 한 엔티티가 다른 엔티티 없이는 의미가 없을 때 유용하다.
  • @OneToMany, @ManyToMany : LAZY(지연)

    • 기본 fetch 전략이 LAZY로 필요한 엔티티만 로드한다. 컬렉션의 크기가 크거나 모든 엔티티가 항상 필요하지 않을 경우에 유용하다.

N+1문제 쿼리로 확인하기

  • 진행중인 프로젝트에서 예약->공간->부동산->호스트 엔티티가 각각 @ManyToOne 단방향 관계인 상황이다. 여기에서 모든 예약 정보를 조회하는 findAll() 메서드를 실행할 때 쿼리가 각각 어떻게 나가는지 알아보자.

1. findAll(), EAGER

hibernate: 
    select
        sr1_0.id,
        sr1_0.end_time,
        sr1_0.fee,
        sr1_0.is_deleted,
        sr1_0.is_user,
        sr1_0.reservation_date,
        sr1_0.space_id,
        sr1_0.start_time,
        sr1_0.user_id 
    from
        space_reservation sr1_0
        
// 예약 N개에 대해서 연관된 컬럼 정보를 찾아오는 쿼리가 실행된다. (N+1문제)
hibernate: 
    select
        s1_0.id,
        s1_0.closing_time,
        s1_0.hourly_rate,
        s1_0.is_deleted,
        s1_0.max_capacity,
        s1_0.opening_time,
        re1_0.id,
        re1_0.dong,
        re1_0.jibun_address,
        re1_0.road_address,
        re1_0.sido,
        re1_0.sigungu,
        re1_0.floor,
        re1_0.has_elevator,
        re1_0.has_parking,
        h1_0.id,
        h1_0.is_deleted,
        h1_0.point,
        h1_0.user_name,
        re1_0.is_deleted,
        s1_0.space_description,
        s1_0.space_name,
        s1_0.space_size,
        s1_0.space_type 
    from
        space s1_0 
    left join
        real_estate re1_0 
            on re1_0.id=s1_0.real_estate_id 
    left join
        host h1_0 
            on h1_0.id=re1_0.host_id 
    where
        s1_0.id=?

2. findAll(), LAZY

Hibernate: 
    select
        sr1_0.id,
        sr1_0.end_time,
        sr1_0.fee,
        sr1_0.is_deleted,
        sr1_0.is_user,
        sr1_0.reservation_date,
        sr1_0.space_id,
        sr1_0.start_time,
        sr1_0.user_id 
    from
        space_reservation sr1_0
  • EAGER일 때, N+1문제가 발생하는 것을 확인할 수 있다. 그렇다면 N+1 해결책 중 하나인 fetch join을 사용해 보자.

3. fetch join, EAGER

@Query("SELECT sr FROM SpaceReservation sr " +
        "JOIN FETCH sr.space s " +
        "JOIN FETCH s.realEstate re " +
        "JOIN FETCH re.host")
List<SpaceReservation> findAllReservations();

Hibernate: 
    select
        sr1_0.id,
        sr1_0.end_time,
        sr1_0.fee,
        sr1_0.is_deleted,
        sr1_0.is_user,
        sr1_0.reservation_date,
        s1_0.id,
        s1_0.closing_time,
        s1_0.hourly_rate,
        s1_0.is_deleted,
        s1_0.max_capacity,
        s1_0.opening_time,
        re1_0.id,
        re1_0.dong,
        re1_0.jibun_address,
        re1_0.road_address,
        re1_0.sido,
        re1_0.sigungu,
        re1_0.floor,
        re1_0.has_elevator,
        re1_0.has_parking,
        h1_0.id,
        h1_0.is_deleted,
        h1_0.point,
        h1_0.user_name,
        re1_0.is_deleted,
        s1_0.space_description,
        s1_0.space_name,
        s1_0.space_size,
        s1_0.space_type,
        sr1_0.start_time,
        sr1_0.user_id 
    from
        space_reservation sr1_0 
    join
        space s1_0 
            on s1_0.id=sr1_0.space_id 
    join
        real_estate re1_0 
            on re1_0.id=s1_0.real_estate_id 
    join
        host h1_0 
            on h1_0.id=re1_0.host_id

4. fetch join, LAZY

Hibernate: 
    select
        sr1_0.id,
        sr1_0.end_time,
        sr1_0.fee,
        sr1_0.is_deleted,
        sr1_0.is_user,
        sr1_0.reservation_date,
        s1_0.id,
        s1_0.closing_time,
        s1_0.hourly_rate,
        s1_0.is_deleted,
        s1_0.max_capacity,
        s1_0.opening_time,
        re1_0.id,
        re1_0.dong,
        re1_0.jibun_address,
        re1_0.road_address,
        re1_0.sido,
        re1_0.sigungu,
        re1_0.floor,
        re1_0.has_elevator,
        re1_0.has_parking,
        h1_0.id,
        h1_0.is_deleted,
        h1_0.point,
        h1_0.user_name,
        re1_0.is_deleted,
        s1_0.space_description,
        s1_0.space_name,
        s1_0.space_size,
        s1_0.space_type,
        sr1_0.start_time,
        sr1_0.user_id 
    from
        space_reservation sr1_0 
    join
        space s1_0 
            on s1_0.id=sr1_0.space_id 
    join
        real_estate re1_0 
            on re1_0.id=s1_0.real_estate_id 
    join
        host h1_0 
            on h1_0.id=re1_0.host_id
  • JPQL은 기본 fetch 전략에 영향을 받지 않는다는 것을 볼 수 있다. 즉, 자체 쿼리만을 실행하며 N+1문제가 해결된 것을 볼 수 있다.
  • fetch join은 SQL에서 join의 한 종류가 아니고, 성능 최적화를 위해 JPQL에서 제공하는 기능이다.
  • fetch join이 아니라 join이면 어떻게 동작할까? 연관 엔티티를 조인하는 것은 똑같지만 EAGER일 때는 여전히 연관된 엔티티 정보를 불러오기 위해 N+1문제가 발생하며, LAZY일 때는 필요한 컬럼만 찾는 하나의 쿼리만 실행된다.

N+1 문제 해결 방법

  • Lazy 전략이 N+1문제를 해결하는 건 아니다. 일대다 관계에서 하나의 엔티티를 조회할 때 관련이 있는 수많은 엔티티가 불러오면서 N+1문제가 발생할 수 있다.

1. fetch join

  • 위에서 살펴본 대로 fetch join은 JPA N+1 문제를 해결하기 위한 효과적인 방법 중 하나이다.

2. batch size

  • batch size란 엔티티를 불러올 때 한 번에 가져올 수 있는 최대 수를 지정하는 것이다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "space_id")
@BatchSize(size = 10)
private Space space;
  • 간단히 생각하면 N개의 쿼리가 N/batchSize로 쿼리의 갯수가 줄어든다는 것이다. 그렇다면 배치 사이즈를 굉장히 큰 숫자로 하면 되지 않을까?
    • 배치 사이즈가 클수록 한 번에 많은 양의 메모리가 필요해 어플리케이션 실행에 부담이 크고 배치 사이즈가 작을수록 N+1 문제 해결 효과가 미미하다. 즉, 데이터 접근 패턴을 알고 사용해야 효과적인 방법이다.

3. entity graph

  • 엔티티를 조회할 때 어떤 연관된 엔티티나 속성을 함께 불러올지 세밀하게 제어하는 것이다.
  • 세밀하게 제어할 수 있는 만큼 복잡하고, 단순히 JPA를 사용할 줄 안다고해서 이 기능을 자유자재로 사용하기는 어렵다. 그 이유는 JPQL 쿼리나 Querydsl과 복합적으로 동작하기 때문이다.

fetch join 사용 시 페이징 문제

  • 페이징 문제뿐만 아니라 대규모 데이터를 처리할 때 fetch join은 신중히 사용되어야 한다. 그 이유는 fetch join을 사용하게 되면 연관된 엔티티 정보를 불러오기 때문에 위에서 본 것처럼 결과 집합이 커진다.
  • 하지만 database가 이러한 매핑 정보를 모르기 때문에 페이징 처리(LIMIT, OFFSET)를 할 수 없고 Hibernate는 전체 결과 집합을 메모리에 올려두고 페이징 작업을 하게 된다. 즉, 성능 저하 또는 메모리 부족 문제가 발생할 수 있다는 것이다.

결론

  • 자바 ORM 표준 JPA PROGRAMMING 책에서 추천하는 방법은 EAGER를 사용하지 말고 LAZY만 사용하라는 것이다. 즉시 로딩 전략은 필요하지 않은 엔티티를 모두 불러오기에 성능 최적화가 어렵다. 따라서 기본값이 @OneToMany, @ManyToMany에서는 LAZY를 사용할 것을 권한다.

  • N+1 문제를 해결하기 위해 fetch join, batch size, entity graph를 사용할 수 있다. 대규모 데이터를 처리할 때 fetch join은 메모리 부족 문제를 발생시킬 수 있다.

profile
꾸준하게

0개의 댓글