본문 바로가기
JPA

[JPA] N + 1 문제 1

by doodoom 2022. 9. 28.

이 글을 쓰게된 이유

JPA를 사용하다보면 예상보다 DB 쿼리가 더 많이 나가서 성능에 문제가 생기는 경우가 있다. 이 경우 대부분 개발자도 모르게 N+1문제가 기본적으로 깔려있는 경우가 많다. 그렇다면 왜 이러한 문제가 발생하고 어떻게하면 해결할 수 있는 지 자세하게 알아보자.

N+1문제란?

@Entity
public Class Member {

    @Id @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "member", fetch = FechType.EAGER)
    private List<Order> orders = new ArrayList<Order>();

}
@Entity
@Table(name = "ORDERS")
public Class Order {

    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    private Member member;

}

위와 같은 Entity가 있다고 하자. 주문 정보는 1:N, N:1 양방향 연관관계다. 그리고 회원이 참조하는 주문정보는 Member.orders를 즉시 로딩으로 설정했다.

즉시 로딩과 N+1

특정 회원 하나를 em.find(Member.class, id)를 통해서 조회하면 설정한 주문 정보도 함께 조회된다.
실행된 SQL은 다음과 같다.

select m.*, o.*
from
    member m
outer join orders o on m.id = o.member_id

여기서는 sql을 두번 실행하는 것이 아니라 조인을 사용해서 한 번의 sql로 회원과 주문을 함께 조회했다. 여기까지만 보면 즉시 로딩이 상당히 좋아보인다. 문제는 jpql을 사용해서 여러개의 회원을 조회할 때이다. 다음 코드를 보자.

List<Member> members = 
    em.createQuery("select m from Member m", Member.class)
    .getResultList();

jpql을 실행하면 jpa는 이를 분석해서 sql을 생성한다. 이때는 즉시 로딩과 지연 로딩에 대해서 전혀 신경 쓰지않고 jpql만을 활용한다. 따라서 다음과 같은 단순한 sql이 실행된다.

select * from member

sql의 실행 결과로 먼저 회원 엔티티를 애플리케이션에 로딩한다. 그런데 회원 엔티티와 주문 엔티티가 즉시 로딩으로 설정되어 있으므로 jpq는 주문 컬렉션을 즉시 로딩하려고 다음 sql을 추가로 실행한다.

select * from orders where member_id=?

조회한 회원이 하나면 이렇게 총 2번의 sql을 실행할테지만 만약 여러 명이면 어떻게 될까?

select * from member
select * from orders where member_id=1
select * from orders where member_id=2
select * from orders where member_id=3
...

조회된 Member의 수만큼 추가적으로 쿼리가 나갈 것이다. 그렇게 되면 처음에 member를 조회한 쿼리 1번 + order를 조회하는 쿼리 n번이 나가 총 n+1번이 나가게 된다.

지연 로딩과 n+1

위 같은 문제를 지연 로딩으로 변경함으로서 어느 정도는 해결할 수 있다. 지연 로딩으로 설정하게 되면 사용하는 시점에 쿼리가 나가기 때문에 사용하지 않으면 order를 조회하는 쿼리는 나가지 않는다. 그리고 일부 member의 order만 사용하는 시점이 있다면 그 때에만 쿼리가 나갈 것이다.
하지만 만약 모든 member의 order를 사용하게 되면 위와 똑같이 쿼리가 나가게 될 것이다. 결론적으로 n+1문제를 해결했다고 볼 수 없다.

즉, n+1문제는 즉시 로딩, 지연 로딩 모두 발생할 수 있다. 지금부터 n+1문제를 피할 수 있는 다양한 방법을 알아보자.

N+1문제 해결 방법

패치 조인 사용

n+!문제를 해결하는 가장 일반적인 방법은 jpql의 패치 조인 키워드를 사용하는 것이다. 패치 조인은 sql 조인을 사용해서 연관된 엔티티를 함께 조회한다. 즉, 연관된 엔티티를 모두 가져오는 것이다.(즉시 로딩은 조회 시점에, 지연 로딩은 사용 시점에) 그러므로 n+! 문제가 발생하지 않는다.
패치 조인을 사용하는 jpql을 보자.

select m from Member m join jetch m.orders

실행된 sql은 다음과 같다.

select m.*, o.* from member m
inner join orders o on m.id=0.member_id

하지만 이 예제는 일대다 조인을 했으므로 결과가 늘어나서 중복된 결과가 나타날 수 있다. 이는 jpql의 distinct를 사용해서 중복을 제거할 수 있는데, 이에 대한 설명은 n+! 2편을 보기를 추천한다.

하이버네이트 @BatchSize

하이버네이트가 제공하는 @BatchSize 어노테이션을 사용하면 연관된 엔티티를 조회할 떄 지정한 size만큼 sql의 in절을 사용해서 조회한다. 만약 조회한 회원이 10명인데 size = 5로 지정하면 2번의 sql만 추가로 실행한다.

@Entity
public Class Member {

    @Id @GeneratedValue
    private Long id;

    @BatchSize(size = 5)
    @OneToMany(mappedBy = "member", fetch = FechType.EAGER)
    private List<Order> orders = new ArrayList<Order>();

}

즉시 로딩으로 설정하면 조회 시점에 10건의 데이터를 모두 조회해야 하므로 다음 sql이 두 번 실행된다.
지연 로딩으로 설정하면 지연 로딩된 엔티티를 최초 사용하는 시점에 다음 sql을 실행해서 5건의 데이터를 미리 로딩해둔다. 그리고 6번째 데이터를 사용하면 다음 sql을 추가로 실행한다.

select from orders 
where member_id in (
            ?, ?, ?, ?, ?
)

하이버네이트 @Fetch(FechMode.SUBSELECT)

하이버네이트가 제공하는 @Fetch 어노테이션의 FechMode.SUBSELECT를 활용하면 연관된 데이터를 조회할 때 서브 쿼리를 사용해서 N+1문제를 해결한다.

@Entity
public Class Member {

    @Id @GeneratedValue
    private Long id;

    @Fetch(FechMode.SUBSELECT)
    @OneToMany(mappedBy = "member", fetch = FechType.EAGER)
    private List<Order> orders = new ArrayList<Order>();

}

다음 jpql로 회원 식별자 값이 10을 초과하는 회원을 모두 조회해보자.

select m from Member m where. m.id > 10

즉시 로딩으로 설정하면 조회 시점에, 지연 로딩으로 설정하면 지연 로딩된 엔티티를 사용하는 시점에 다음 sql이 실행된다.

select o from orders o
    where o.member_id in (
        select
            m.id
        from
            member m
        where m.id > 10
    }

정리

김영한님의 jpq 책을 보면 즉시 로딩은 사용하지 말고 지연 로딩만 사용하는 것을 추천한다고 한다. 즉시 로딩 전략은 그럴듯해 보이지만 n+1 문제는 물론이고 비즈니스 로직에 따라 필요하지 않은 엔티티를 로딩해야 하는 상황이 자주 발생하고 outer join만 활용이 가능한 문제점이 있다. 그리고 즉시 로딩의 사장 큰 문제는 성능 최적화가 어렵다는 점이다. 엔티티를 조회하다보면 즉시 로딩이 연속으로 발생해서 전혀 예상하지 못한 sql이 실행될 수 있다. 따라서 모두 지연 로딩으로 설정하고 성능 최적화가 꼭 필요한 곳에는 jpql 패치 조인을 사용하자.