N+1 문제 해결 (Fetch Join, @BatchSize 한계 돌파)

양성준·2025년 4월 18일

스프링

목록 보기
33/49

문제 상황 1) 유저 전체 조회

Hibernate: 
    /* <criteria> */ select
        u1_0.id,
        u1_0.created_at,
        u1_0.email,
        u1_0.password,
        u1_0.profile_id,
        u1_0.updated_at,
        u1_0.username 
    from
        users u1_0
Hibernate: 
    select
        bc1_0.id,
        bc1_0.content_type,
        bc1_0.created_at,
        bc1_0.file_name,
        bc1_0.size 
    from
        binary_contents bc1_0 
    where
        bc1_0.id=?
Hibernate: 
    select
        us1_0.id,
        us1_0.created_at,
        us1_0.last_active_at,
        us1_0.updated_at,
        us1_0.user_id 
    from
        user_statuses us1_0 
    where
        us1_0.user_id=?
Hibernate: 
    select
        bc1_0.id,
        bc1_0.content_type,
        bc1_0.created_at,
        bc1_0.file_name,
        bc1_0.size 
    from
        binary_contents bc1_0 
    where
        bc1_0.id=?
Hibernate: 
    select
        us1_0.id,
        us1_0.created_at,
        us1_0.last_active_at,
        us1_0.updated_at,
        us1_0.user_id 
    from
        user_statuses us1_0 
    where
        us1_0.user_id=?
Hibernate: 
    select
        us1_0.id,
        us1_0.created_at,
        us1_0.last_active_at,
        us1_0.updated_at,
        us1_0.user_id 
    from
        user_statuses us1_0 
    where
        us1_0.user_id=?

List<User> users = userRepository.findAll();

public class User {

  @OneToOne
  @JoinColumn(name = "profile_id")
  private BinaryContent profile;

  @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
  private UserStatus userStatus;
}
  • userRepository.findAll로 전체 User 리스트를 조회할 때, findAll 쿼리 1번, 연관관계 관련 쿼리가 각각 N, N번씩 나가는 N+1 문제 발생
  • @OneToOne은 기본적으로 즉시 로딩이지만, 지연 로딩이어도 조회 시점에 쿼리가 나가기 때문에 N+1 문제를 피할 수 없다.

해결 방법

fetch join으로 필요한 데이터를 한번에 join으로 땡겨오기

  @Query("select distinct u from User u left join fetch u.profile left join fetch u.userStatus")
  List<User> findAllFetch();
Hibernate: 
    /* select
        u 
    from
        User u 
    left join
        
    fetch
        u.profile 
    left join
        
    fetch
        u.userStatus */ select
            u1_0.id,
            u1_0.created_at,
            u1_0.email,
            u1_0.password,
            p1_0.id,
            p1_0.content_type,
            p1_0.created_at,
            p1_0.file_name,
            p1_0.size,
            u1_0.updated_at,
            us1_0.id,
            us1_0.created_at,
            us1_0.last_active_at,
            us1_0.updated_at,
            u1_0.username 
        from
            users u1_0 
        left join
            binary_contents p1_0 
                on p1_0.id=u1_0.profile_id 
        left join
            user_statuses us1_0 
                on u1_0.id=us1_0.user_id
  • Fetch join을 사용하면, 연관 객체를 조인해서 한 번에 가져올 수 있다. (user, userstatus, binarycontent 조인)
    • @ToOne이든 @ToMany든 Fetch Join을 사용하지 않는다면, 조회 시점에 select where절로 조회 쿼리가 N개만큼 나감
  • 한번에 필요한 데이터를 join으로 당겨와서 쿼리가 한번만 나가는걸 확인할 수 있다!
  • profile과 userstatus는 nullable이기 때문에 left fetch join 사용
  • JPA가 내부적으로 조인을 수행할 때는 SQL 레벨에서 row가 중복될 수 있어, List< User> 결과에 중복된 User 객체가 포함될 수 있음 -> DISTINCT를 사용하여 결과 매핑 시 중복을 제거 (Fetch join 사용할 때 습관처럼 붙이는게 좋다.)
    • Hibernate 6부터는 내부적으로 DINSTINCT를 자동 적용

문제 상황 2) Message 전체 조회

Hibernate: 
    /* <criteria> */ select
        m1_0.id,
        m1_0.author_id,
        m1_0.channel_id,
        m1_0.content,
        m1_0.created_at,
        m1_0.updated_at 
    from
        messages m1_0 
    left join
        channels c1_0 
            on c1_0.id=m1_0.channel_id 
    where
        c1_0.id=? 
    order by
        m1_0.created_at 
    fetch
        first ? rows only
Hibernate: 
    select
        u1_0.id,
        u1_0.created_at,
        u1_0.email,
        u1_0.password,
        p1_0.id,
        p1_0.content_type,
        p1_0.created_at,
        p1_0.file_name,
        p1_0.size,
        u1_0.updated_at,
        us1_0.id,
        us1_0.created_at,
        us1_0.last_active_at,
        us1_0.updated_at,
        u1_0.username 
    from
        users u1_0 
    left join
        binary_contents p1_0 
            on p1_0.id=u1_0.profile_id 
    left join
        user_statuses us1_0 
            on u1_0.id=us1_0.user_id 
    where
        u1_0.id=?
Hibernate: 
    select
        c1_0.id,
        c1_0.created_at,
        c1_0.description,
        c1_0.name,
        c1_0.type,
        c1_0.updated_at 
    from
        channels c1_0 
    where
        c1_0.id=?
Hibernate: 
    select
        u1_0.id,
        u1_0.created_at,
        u1_0.email,
        u1_0.password,
        p1_0.id,
        p1_0.content_type,
        p1_0.created_at,
        p1_0.file_name,
        p1_0.size,
        u1_0.updated_at,
        us1_0.id,
        us1_0.created_at,
        us1_0.last_active_at,
        us1_0.updated_at,
        u1_0.username 
    from
        users u1_0 
    left join
        binary_contents p1_0 
            on p1_0.id=u1_0.profile_id 
    left join
        user_statuses us1_0 
            on u1_0.id=us1_0.user_id 
    where
        u1_0.id=?
Hibernate: 
    select
        u1_0.id,
        u1_0.created_at,
        u1_0.email,
        u1_0.password,
        p1_0.id,
        p1_0.content_type,
        p1_0.created_at,
        p1_0.file_name,
        p1_0.size,
        u1_0.updated_at,
        us1_0.id,
        us1_0.created_at,
        us1_0.last_active_at,
        us1_0.updated_at,
        u1_0.username 
    from
        users u1_0 
    left join
        binary_contents p1_0 
            on p1_0.id=u1_0.profile_id 
    left join
        user_statuses us1_0 
            on u1_0.id=us1_0.user_id 
    where
        u1_0.id=?
Hibernate: 
    select
        a1_0.message_id,
        a1_1.id,
        a1_1.content_type,
        a1_1.created_at,
        a1_1.file_name,
        a1_1.size 
    from
        message_attachments a1_0 
    join
        binary_contents a1_1 
            on a1_1.id=a1_0.attachment_id 
    where
        a1_0.message_id=?
Hibernate: 
    select
        a1_0.message_id,
        a1_1.id,
        a1_1.content_type,
        a1_1.created_at,
        a1_1.file_name,
        a1_1.size 
    from
        message_attachments a1_0 
    join
        binary_contents a1_1 
            on a1_1.id=a1_0.attachment_id 
    where
        a1_0.message_id=?
Hibernate: 
    select
        a1_0.message_id,
        a1_1.id,
        a1_1.content_type,
        a1_1.created_at,
        a1_1.file_name,
        a1_1.size 
    from
        message_attachments a1_0 
    join
        binary_contents a1_1 
            on a1_1.id=a1_0.attachment_id 
    where
        a1_0.message_id=?

public class Message {
  @OneToMany(fetch = FetchType.LAZY)
  @JoinTable(
      name = "message_attachments",
      joinColumns = @JoinColumn(name = "message_id"),
      inverseJoinColumns = @JoinColumn(name = "attachment_id")
  )
  private List<BinaryContent> attachments;

  @ManyToOne
  @JoinColumn(name = "channel_id", nullable = false)
  private Channel channel;

  @ManyToOne
  @JoinColumn(name = "author_id")
  private User author;
  
  List<Message> messages = messageRepository.findAll(); 
  • 메시지 3개를 조회했을 때, 각각 author와 binaryContent를 조회하는 쿼리를 날림 -> N + N + 1 쿼리가 나간다.
  @Query("select distinct m from Message m join fetch m.author left join fetch m.attachments left join fetch m.channel where m.channel.id = :channelId")
  Slice<Message> findAllByChannelIdFetch(@Param("channelId") UUID channelId, Pageable pageable);
  • Fetch Join을 사용해봤지만, 여전히 N+1 문제가 발생한다.

Hibernate 5

  • Fetch Join과 Pagination을 동시에 시도하면 런타임 경고 또는 예외가 발생함
HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
  • 이는 @OneToMany 또는 @ManyToMany 같은 컬렉션 연관관계에 대해 JOIN FETCH를 하면 row 수가 증가하기 때문
  • JPA는 이 상태에서 어떤 기준으로 페이징해야 할지 판단할 수 없어, 결국
    → 모든 row(전체 결과)를 메모리에 올린 뒤, in-memory 페이징 처리함
    → 성능 이점 X, 오히려 OOM 위험

Hibernate 6

  • Hibernate 6부터는 Fetch Join + Pageable이 문법적으로 허용됨 → 실제로 LIMIT, OFFSET이 포함된 SQL이 나감
    • 하지만 1:N 관계에서 Fetch Join을 할 경우 row 수가 여전히 곱해지기 때문에
      LIMIT 10을 걸어도, row 단위로 잘려서 Message 단위 페이징이 깨질 수 있음
      → 일부 메시지는 중간에 잘리거나, hasNext가 틀려질 수 있음
  • 따라서 기술적으로는 가능하지만, 실무에서는 여전히 위험한 방식임
  • X:1 관계인 경우에는 Fetch Join을 사용해도 row 수가 늘어나지 않기 때문에, 페이징과 함께 사용할 수 있다!

지금 같은 경우에는 Hibernate 6를 사용하기 때문에, Fetch Join + Pagination이 실행되긴 하지만 N+1 문제가 해결되지 않음
(아무리 찾아봐도 왜 그런지는 나오지 않았지만, 결과가 여전히 N+1)

=> 이럴 때 @BatchSize를 사용하면 N+1로 인한 성능 저하를 방지할 수 있다.
(김영한 쌤 말씀으로는, 페이징 + @toMany 조회의 유일한 방법이라 하심)

해결 방법

한계 돌파 (@BatchSize)

  • @ToOne 관계는 컬렉션이 아니기 때문에 (row가 1:1), Paging + Fetch Join이 가능하지만, @ToMany 관계는 불가능!
    • 대신, @ToMany 관계는 Paging이 없다면 Fetch Join으로 해결 가능 (row 수가 늘어나도, Limit이 없어 잘려나가지 않음)
  • 1+N+N...을 1+1+1로 만들 수 있는 굉장한 최적화 수단이다.
  • 컬렉션 (@ToMany)를 지연로딩으로 조회 + 지연 로딩 성능 최적화를 위해 @BatchSize 적용
    (글로벌 세팅의 경우 hibernate.default_batch_fetch_size)
    -> 컬렉션이나 프록시 객체를 한꺼번에 설정한 사이즈만큼 IN 쿼리로 조회
    • EAGER 로딩이 경우에는 효과가 없으므로, LAZY 로딩 설정 필수
    • LAZY로 연결된 데이터를 조회할 때, BatchSize만큼 영속성 컨텍스트에 있는 여러 프록시 객체를 한 번에 IN 쿼리로 가져온다.
      • LAZY 로딩의 경우, 연관된 객체를 프록시로만 영속성 컨텍스트에 저장해놓고, 실제 조회 시점에 쿼리를 날려 조회하는데,
      • 이 때, 프록시를 초기화할 때 IN 쿼리로 한번에 조회하여 초기화해줌.
        (User가 10번 조회될 것 같으면 연관된 프록시 객체 다 IN쿼리로 한번에 조회)
  • N+1이 발생하는 연관관계 필드 위에 @BatchSize(size = ?)를 달면 세부적으로 설정 가능, 보통은 yml에 글로벌 설정해준다.
spring:
  jpa:
    properties:
      hibernate.default_batch_fetch_size: 100
Hibernate: 
    /* <criteria> */ select
        m1_0.id,
        m1_0.author_id,
        m1_0.channel_id,
        m1_0.content,
        m1_0.created_at,
        m1_0.updated_at 
    from
        messages m1_0 
    left join
        channels c1_0 
            on c1_0.id=m1_0.channel_id 
    where
        c1_0.id=? 
    order by
        m1_0.created_at 
    fetch
        first ? rows only
Hibernate: 
    select
        u1_0.id,
        u1_0.created_at,
        u1_0.email,
        u1_0.password,
        p1_0.id,
        p1_0.content_type,
        p1_0.created_at,
        p1_0.file_name,
        p1_0.size,
        u1_0.updated_at,
        us1_0.id,
        us1_0.created_at,
        us1_0.last_active_at,
        us1_0.updated_at,
        u1_0.username 
    from
        users u1_0 
    left join
        binary_contents p1_0 
            on p1_0.id=u1_0.profile_id 
    left join
        user_statuses us1_0 
            on u1_0.id=us1_0.user_id 
    where
        u1_0.id = any (?)
Hibernate: 
    select
        c1_0.id,
        c1_0.created_at,
        c1_0.description,
        c1_0.name,
        c1_0.type,
        c1_0.updated_at 
    from
        channels c1_0 
    where
        c1_0.id=?
Hibernate: 
    select
        a1_0.message_id,
        a1_1.id,
        a1_1.content_type,
        a1_1.created_at,
        a1_1.file_name,
        a1_1.size 
    from
        message_attachments a1_0 
    join
        binary_contents a1_1 
            on a1_1.id=a1_0.attachment_id 
    where
        a1_0.message_id = any (?)
  • 메시지를 조회할 때, 연관된 필드들을 전부 IN 쿼리로 한번에 조회해온다! 1+1+1+....

=> @ToOne / @ToMany의 경우 Fetch Join으로 해결, @ToMany + Pagination의 경우 지연로딩 + 한계돌파 (또는 EntityGraph)로 해결하자!

profile
백엔드 개발자

0개의 댓글