본문 바로가기

IT

JPA N+1 문제 1분 이해하기

728x90

JPA로 애플리케이션을 개발할 때 성능상 가장 주의해야 하는 것이 N+1 문제이다.

JPA를 사용하는 다양한 개발자분들이 이를 사용하다보면 한번 쯤은 꼭 겪게 되는 문제일 것이다.

 

간단한 예제를 통해서 쉽게 N+1 문제란 무엇인지 이해하고 JPA를 사용하는 과정에서 어떤 이슈가 있을지 파악해보고 이를 적용해보는 시간을 가지면 좋을 듯 하다.

 

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;
    
    @OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
    private List<Order> orders = new ArrayList<Order>();
    ...
}
@Entity
@Table(name = "ORDERS")
public class Order {

    @Id @GeneratedValue
    private Long id;
    
    @ManyToOne
    private Member member;
    ...
}

이 간단한 예제를 보면 회원(Member)과 주문(Order) 사이에는 1:N 그리고 N:1 양방향 연관 관계를 가진다. 그리고 회원이 참조하는 주문정보인 Member.orders를 즉시 로딩(EAGER)으로 설정했다. 

더보기
더보기
JPA의 글로벌 페치 전략 기본 값은 다음과 같다.

@OneToOne, @ManyToOne : 기본 페치 전략은 즉시 로딩(EAGER)
@OneToMany, @ManyToMany: 기본 페치 전략은 지연 로딩(LAZY) 

쉽게 말해서 즉시 로딩은 그 즉시 바로 로딩하는 것이고 지연 로딩은 호출될 때 까지 지연되었다가 로딩한다고 생각하면 된다. 기본적으로 Lazy라는 용어는 JPA의 페치 전략을 포함하여 lazyconnectiondatasourceproxy 등 다양하게 쓰이니 처음 들어본다면 이참에 익숙해지는 것을 추천한다.

특정 회원 하나를 em.find() 메소드로 조회하면 즉시 로딩으로 설정한 주문정보도 함께 조회된다. 

 

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을 사용할 때 문제가 발생한다.

더보기
더보기

SQL은 데이터베이스 테이블을 대상으로 쿼리하는 것이라면 
JPQL은 엔티티 객체를 대상으로 쿼리, 쉽게 이야기해서 클래스와 필드를 대상으로 쿼리

 

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

JPQL을 실행하면 JPA는 이것을 분석해서 SQL을 생성한다. 이때는 즉시 로딩과 지연 로딩에 대해서 전혀 신경을 쓰지 않고 JPQL만 사용해서 SQL을 생성하기에 다음과 같은 SQL이 실행된다.

SELECT * FROM MEMBER;

SQL의 실행 결과로 먼저 회원 엔티티를 애플리케이션에 로딩한다. 여기서 회원과 연관된 주문 컬렉션이 즉시 로딩으로 걸려있어 JPA는 주문 컬렉션을 즉시 로딩하려고 다음 SQL을 추가로 실행한다.

SELECT * FROM ORDERS WHERE MEMBER_ID=?;

조회된 회원이 한 명이라면 두개의 쿼리를 실행하겠지만 5명이면 10개의 쿼리, 1000명이면 2000개의 쿼리가 실행되는 것이다.

이처럼 예상치 못한 SQL이 추가로 실행되는 이슈를 N+1 문제라고 한다.

 

어떻게 해결하면 좋을까?

지연 로딩으로 페치 전략을 바꾼다면 해결이 될까? 지연 로딩은 해당 필드가 조회될 때까지 지연된 상태에서 호출되면 그 때 SQL을 생성해 실행하는 전략이다. 지연 로딩의 경우 Member를 조회하면 Member.orders를 호출하기 전까지 orders에는 프록시 객체가 자리 잡고 있을 것이다. 프록시 객체가 뭔지 모른다면 동작을 대신 해주는 가짜 객체라고 일단 생각해두고 넘어가자. 구글링하면 많은 자료가 나오니 참고하면 좋을 것 같다. 

 

아무튼 지연 로딩으로 해결이 될까? member에서 order를 호출하는 간단한 예시를 들어보자.

for (Member member : members){
	//지연 로딩 초기화
    System.out.println("member = " + member.getOrders().size());
}

주문 컬렉션을 초기화하는 수 만큼 Orders를 조회하는 SQL이 실행될 것이다. 회원이 10명이라면 10개의 SQL이 실행된다.

 

방법 1 .페치 조인

N+1 문제를 해결하는 가장 일반적인 방법은 페치 조인을 사용하는 것이다.

페치 조인은 SQL 조인을 사용해서 연관된 엔티티를 함께 조회하는 것이다. 

더보기
더보기

[ 일반 Join ]
- Fetch Join과 달리 연관 Entity에 Join을 걸어도 실제 쿼리에서 SELECT 하는 Entity는 오직 JPQL에서 조회하는 주체가 되는 Entity만 조회하여 영속화

- 조회의 주체가 되는 Entity만 SELECT 해서 영속화하기 때문에 데이터는 필요하지 않지만 연관 Entity가 검색 조건에는 필요한 경우에 주로 사용한다.

[ Fetch Join ]
- 조회의 주체가 되는 Entity 이외에 Fetch Join이 걸린 연관 Entity도 함께 SELECT 하여 모두 영속화

- Fetch Join이 걸린 Entity 모두 영속화하기 때문에 FetchType이 LAZY인 Entity를 참조하더라도 이미 영속성 컨텍스트 안에 들어있기 때문에 따로 쿼리가 실행되지 않은 채로 N+1 문제가 해결됨

페치 조인을 사용하는 JPQL을 보자.

 

select m from Member m join fetch m.orders

실행된 SQL은 다음과 같다.

SELECT M.*, O.* FROM MEBER M INNER JOIN ORDERS O ON M.ID=O.MEMBER_ID;

일대다 조인을 했으므로 결과가 늘어나서 중복된 결과가 날 수 있으므로 JPQL의 DISTINCT를 사용해서 중복을 제거하는 것이 좋다. 

 

방법 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 = FetchType.EAGER)
    private List<Order> orders = new ArrayList<Order>();
    ...
}

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

SELECT * FROM ORDERS WHERE MEMBER_ID IN ( ?, ?, ?, ?, ?);

 

방법 3. @Fetch(FetchMode.SUBSELECT)

하이버네이트가 제공하는 페치모드를 SUBSELECT로 사용하면 연관된 데이터를 조회할 때 서브 쿼리를 사용해서 N+1 문제를 해결할 수있다.

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;
    
    @Fetch(FetchMode.SUBSELECT)
    @OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
    private List<Order> orders = new ArrayList<Order>();
    ...
}

JPQL로 Member 식별자인 id 값이 10을 초과하는 회원을 조회해보면

select m from Member m where m.id > 10

즉시 로딩 또는 지연로딩을 사용하여 엔티티를 사용하는 시점에 생성된 SQL을 살펴보면

SELECT O FROM ORDERS O WHER O.MEMBER_ID IN(

    SELECT M.ID FROM MEMBER M WHERE M.ID > 10

);

서브 쿼리를 생성해서 N+1 문제를 해결할 수 있다.

 

즉시 로딩은 성능 최적화가 어렵다. 엔티티를 조회하다보면 즉시 로딩이 연속으로 발생해서 전혀 예상하지 못한 SQL이 실행 될 수 있다. 따라서 모두 지연 로딩으로 설정하고 성능 최적화가 꼭 필요한 곳에는 JPQL 페치 조인을 사용하자.

따라서 기본 값인 즉시 로딩은 OneToOne, ManyToOne은 LAZY로 설정해두자.

 

쉬운 이해를 돕기 위해 아주 간단한 예제를 들어 회원과 주문에 대한 일대다 양방향 관계에서의 JPA N+1 문제와 해결 방법을 살펴보았다. 다음 포스팅에는 더 딮한 상황에서의 N+1 문제를 살펴보자. 

 

 

 

 

 

 

 

참고 자료 - 자바 ORM 표준 프로그래밍 | 저자 김영한