JPA N+1 문제 및 해결 방법

2024. 2. 3. 15:43JPA

이번 글에서는 JPA의 N+1 문제가 무엇인지 알아보고 N+1 문제가 발생하지 않도록 하기 위한 해결 방법은 무엇인지 설명하겠습니다.

 

1. N+1 문제란 무엇인가

N+1 문제는 JPA를 사용할 때 발생하는 대표적인 문제점 중 하나입니다. 문제의 내용을 설명하면 N+1 문제란 기본 엔티티에 의존하고 있는 연관 엔티티를 로딩할때 기본 엔티티를 조회하는 SQL 외에 연관 엔티티를 조회하는 SQL이 조회된 기본 엔티티의 개수(N)만큼 추가적으로 발생하는 문제를 의미합니다.

 

예를 들어 회원(Member) 엔티티와 주문(Order) 엔티티간에 연관관계가 일대다(1:N) 관계를 맺고 있습니다. 이때 클라이언트는 회원 데이터를 데이터베이스로부터 조회하는 쿼리를 실행합니다. 그렇다면 회원 데이터를 조회하는 JQPL 쿼리는 다음과 같을 것입니다.

select m from Member m;

 

회원 엔티티가 의존하고 있는 연관 엔티티인 주문(Order) 리스트가 즉시 로딩 전략으로 조회한다고 가정할때 위 회원 SQL 조회 쿼리와 별도로 추가적인 주문 조회 쿼리가 내부적으로 다음과 같이 실행될 것입니다. (회원 데이터는 5개라고 가정합니다.)

select o from Order o where o.member.id = 1
select o from Order o where o.member.id = 2
select o from Order o where o.member.id = 3
select o from Order o where o.member.id = 4
select o from Order o where o.member.id = 5

 

위와 같은 예시 결과로 인해서 회원 데이터들을 조회했을 뿐인데 1번의 SQL 쿼리만을 기대했지만 5번의 추가적인 쿼리가 발생하여 총 6번의 SQL 쿼리가 발생된 것을 볼 수 있습니다.

 

위와 같은 예시로 인해서 JPA에서 발생하는 N+1 문제는 엔티티 데이터를 조회하는 과정에서 개발자가 인지하지 못하게 추가적인 SQL이 발생하여 성능을 저하시키는 문제가 발생할 수 있습니다.

 

1.1 즉시 로딩과 N+1 문제

다음 예제는 회원 엔티티와 주문 엔티티간에 일대다 관계를 맺고 있고 회원 엔티티가 의존하고 있는 연관 엔티티인 주문 리스트를 즉시 로딩 전략으로 설정했을때 회원 엔티티를 조회하는 과정에서 어디 부분에서 N+1 문제가 발생하는지 확인해보는 예제입니다.

 

우선은 Member 엔티티와 Order 엔티티를 정의합니다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@Getter
@ToString(exclude = {"orders"})
public class Member {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

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

 Member 엔티티 클래스를 보면 orders라는 이름의 Order 타입 리스트를 가지고 있는 것을 볼 수 있고 @OneToMany 애노테이션을 이용하여 연관관계를 맺고 있는 것을 볼 수 있습니다. 또한 fetch 옵션의 값으로 FetchType.EAGER로 설정하여 Order 리스트를 즉시 로딩 전략(회원 엔티티 조회시 회원 엔티티가 가지고 있는 주문들도 같이 조회)으로 설정하였습니다.

 

@Entity
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@Getter
public class Order {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@ManyToOne
	@JoinColumn(name = "member_id")
	private Member member;
}

 

@Transactional
@Service
public class MemberService {
	@PersistenceContext
	private EntityManager em;

	//...

	public List<Member> find(){
		return em.createQuery("select m from Member m", Member.class)
			.getResultList();
	}
}

MemberService 클래스를 정의하고 find 메소드에서 JPQL을 사용하여 회원 엔티티들을 조회하는 로직을 구현합니다.

 

OrderService와 같은 구현은 일반적인 EntityManager의 save 메소드를 이용하여 db에 저장하는 일반적인 구현이기 때문에 생략하였습니다. 

 

다음은 위 예제들을 실행하는 메인 메소드입니다.

@SpringBootApplication
public class EagerAndNPlus1Example02 implements ApplicationRunner {

	@Autowired
	private MemberService memberService;

	@Autowired
	private OrderService orderService;

	@Override
	public void run(ApplicationArguments args) throws Exception {
		Member member = Member.builder().build();
		memberService.save(member);

		Order order = Order.builder()
			.member(member)
			.build();
		orderService.save(order);

		List<Member> members = memberService.find();
		System.out.println(members);
	}

	public static void main(String[] args) {
		SpringApplication.run(EagerAndNPlus1Example02.class, args);
	}
}

 

위 예제의 실행 결과는 다음과 같습니다.

Hibernate: 
    /* select
        m 
    from
        Member m */ select
            member0_.id as id1_0_ 
        from
            member member0_
Hibernate: 
    select
        orders0_.member_id as member_i2_1_0_,
        orders0_.id as id1_1_0_,
        orders0_.id as id1_1_1_,
        orders0_.member_id as member_i2_1_1_ 
    from
        orders orders0_ 
    where
        orders0_.member_id=?
[Member(id=1)]

실행 결과를 보면 총 2개의 쿼리가 발생한 것을 볼수 있습니다. 첫번째 쿼리는 회원을 조회하는 쿼리이고 두번째 쿼리는 회원이 가지고 있는 주문을 조회하는 쿼리입니다. 

 

위 실행 결과를 통해서 알 수 있는 사실은 회원 데이터를 조회했을때 회원 데이터의 개수가 100개라면 주문 데이터를 조회하는 쿼리가 100개가 추가적으로 실행될 것이라는 사실입니다. 우리는 위와 같은 예제를 통하여 이 문제가 N+1문제라는 것을 알 수 있습니다.

 

예제의 전체 소스코드는 다음 링크를 참고해주시면 감사하겠습니다.

https://github.com/yonghwankim-dev/jpa/tree/main/src/main/java/com/ch15/class05/step02

 

1.2 지연 로딩과 N+1

다음 예제는 회원 엔티티에서 연관관계인 주문 엔티티들을 로딩할때 로딩전략을 지연로딩(LAZY)으로 설정했을 때 N+1 문제가 어느 시점에서 발생하는지 확인해보는 예제입니다.

 

이전 예제를 기반으로 Member 엔티티의 로딩 전략을 지연 로딩으로 변경합니다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@Getter
@ToString(exclude = {"orders"})
public class Member {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
	@Builder.Default
	private List<Order> orders = new ArrayList<>();
}

 

회원 엔티티들을 조회하는 MemberService의 find 메소드에서 다음과 같이 지연 로딩된 주문 엔티티를 조회하는 것을 구현합니다.

@Transactional
@Service
public class MemberService {
	@PersistenceContext
	private EntityManager em;

	public Long save(Member member){
		em.persist(member);
		return member.getId();
	}

	public List<Member> find(){
		List<Member> members = em.createQuery("select m from Member m", Member.class)
			.getResultList();
		for (Member member : members){
			System.out.println("member.orders.size = " + member.getOrders().size());
		}
		return members;
	}
}

 

메인 메소드를 다음과 같이 구현하고 실행해봅니다.

@SpringBootApplication
public class LazyAndNPlus1Example03 implements ApplicationRunner {

	@Autowired
	private MemberService memberService;

	@Autowired
	private OrderService orderService;

	@Override
	public void run(ApplicationArguments args) throws Exception {
		Member member = Member.builder().build();
		memberService.save(member);
		Member member2 = Member.builder().build();
		memberService.save(member2);

		Order order = Order.builder()
			.member(member)
			.build();
		orderService.save(order);
		Order order2 = Order.builder()
			.member(member2)
			.build();
		orderService.save(order2);

		List<Member> members = memberService.find();
		System.out.println(members);
	}

	public static void main(String[] args) {
		SpringApplication.run(LazyAndNPlus1Example03.class, args);
	}
}

위 예제를 보면 2명의 회원이 존재하고 각각의 회원은 1개씩의 주문을 가지고 있습니다.

 

실행 결과는 다음과 같습니다.

Hibernate: 
    /* select
        m 
    from
        Member m */ select
            member0_.id as id1_0_ 
        from
            member member0_
Hibernate: 
    select
        orders0_.member_id as member_i2_1_0_,
        orders0_.id as id1_1_0_,
        orders0_.id as id1_1_1_,
        orders0_.member_id as member_i2_1_1_ 
    from
        orders orders0_ 
    where
        orders0_.member_id=?
member.orders.size = 1
Hibernate: 
    select
        orders0_.member_id as member_i2_1_0_,
        orders0_.id as id1_1_0_,
        orders0_.id as id1_1_1_,
        orders0_.member_id as member_i2_1_1_ 
    from
        orders orders0_ 
    where
        orders0_.member_id=?
member.orders.size = 1
[Member(id=1), Member(id=2)]

실행 결과를 보면 총 3개의 쿼리가 발생한 것을 볼 수 있습니다. 

 

주문 엔티티들을 조회할때 지연 로딩 전략으로 로딩하는 것을 볼 수 있습니다. 이때 실제 주문 엔티티들을 db로부터 조회하는 시점은 다음 시점입니다.

for (Member member : members){
    System.out.println("member.orders.size = " + member.getOrders().size());
}

member.getOrders()를 호출하면 Order 프록시 타입의 리스트를 가져오고 size()를 호출하게되면 영속성 컨텍스트에 Order 엔티티 데이터들이 없기 때문에 db로부터 조회하여 사이즈를 가져오는 것입니다.

 

위 예제를 통하여 알수 있는 사실은 연관 엔티티를 지연 로딩으로 가져오는 경우 호출 시점의 차이만이 있을 뿐이지 실질적으로 연관 엔티티를 실제 조회하는 시점에서 N+1 문제가 발생하는 것은 동일하다는 점입니다.

 

위 예제에 대한 링크는 다음과 같습니다.

https://github.com/yonghwankim-dev/jpa/tree/main/src/main/java/com/ch15/class05/step03

 

다음 장에서는 위와 같은 N+1 문제를 해결하기 위한 3가지 방법을 소개합니다.

 

2. N+1 문제 해결 방법

1장에서 N+1 문제가 어떤 문제이고 예제를 통하여 즉시 로딩이든 지연 로딩을 선택하든 동일한 N+1 문제가 발생한 것을 알아보았습니다. 이번장에서는 N+1 문제를 어떻게 해결하는지 알아보겠습니다. 

 

2.1 JPQL 페치 조인 사용

N+1 문제를 해결하는 첫번째 방법은 JPQL 페치 조인을 사용하는 것입니다. JPQL 페치 조인 방법은 기본 엔티티를 조회시 연관 엔티티 또한 SQL 조인해서 한번에 조회하는 방법입니다.

 

다음 예제는 회원 엔티티를 조회할때 JPQL 페치 조인을 사용하여 조회하는 예제입니다.

@Transactional
@Service
public class MemberService {
	@PersistenceContext
	private EntityManager em;

    // ...

	public List<Member> find(){
		List<Member> members = em.createQuery("select m from Member m join fetch m.orders", Member.class)
			.getResultList();
		for (Member member : members){
			System.out.println("member.orders.size = " + member.getOrders().size());
		}
		return members;
	}
}

위 코드를 보면 JPQL에서 join fetch를 사용하여 연관 엔티티인 주문 엔티티도 같이 조회할 수 있도록 구현하였습니다.

 

메인 메소드는 다음과 같습니다.

@SpringBootApplication
public class LazyAndNPlus1Example04 implements ApplicationRunner {

	@Autowired
	private MemberService memberService;

	@Autowired
	private OrderService orderService;

	@Override
	public void run(ApplicationArguments args) throws Exception {
		Member member = Member.builder().build();
		memberService.save(member);
		Member member2 = Member.builder().build();
		memberService.save(member2);

		Order order = Order.builder()
			.member(member)
			.build();
		orderService.save(order);
		Order order2 = Order.builder()
			.member(member2)
			.build();
		orderService.save(order2);

		List<Member> members = memberService.find();
		System.out.println(members);
	}

	public static void main(String[] args) {
		SpringApplication.run(LazyAndNPlus1Example04.class, args);
	}
}

 

실행 결과는 다음과 같습니다.

Hibernate: 
    /* select
        m 
    from
        Member m 
    join
        fetch m.orders */ select
            member0_.id as id1_0_0_,
            orders1_.id as id1_1_1_,
            orders1_.member_id as member_i2_1_1_,
            orders1_.member_id as member_i2_1_0__,
            orders1_.id as id1_1_0__ 
        from
            member member0_ 
        inner join
            orders orders1_ 
                on member0_.id=orders1_.member_id
member.orders.size = 1
member.orders.size = 1
[Member(id=1), Member(id=2)]

위 실행 결과를 보면 단한 번의 회원 조회 쿼리를 이용하여 회원과 회원이 가지고 있는 주문 엔티티 데이터도 조회한 것을 볼 수 있습니다.

 

위 예제를 통하여 알 수 있었던 것은 N+1 문제를 해결하기 위해서 JPQL 페치 조인을 사용하여 해결할 수 있었다는 점입니다.

 

예제에 대한 링크는 다음과 같습니다.

https://github.com/yonghwankim-dev/jpa/tree/main/src/main/java/com/ch15/class05/step04

 

2.2 하이버네이트 @BatchSize 사용

N+1 문제를 해결하는 다른 방법은 하이버네이트가 제공하는 org.hibernate.annoations.BatchSize 라는 애노테이션을 사용하는 방법이 있습니다. 이 방법은 연관 엔티티를 조회할 때 지정한 size만큼 SQL의 IN절을 사용해서 조회하는 방법입니다.

 

다음 예제는 연관 주문 엔티티 리스트에 @BatchSize를 적용하여 실행해보는 예제입니다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@Getter
@ToString(exclude = {"orders"})
public class Member {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

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

위 코드에서 @BatchSize(size.= 5)로 설정하면 Order 엔티티를 별도의 SQL에서 조회할때 위 문제에서 발생했었던 N개의 쿼리가 추가적으로 발생하는 것이 아닌 SQL IN절에 Member의 id를 5개만큼 넣어서 5명의 회원에 대한 Order 엔티티 데이터를 조회하는 것입니다.

 

@SpringBootApplication
public class BatchSizeExample implements ApplicationRunner {

	@Autowired
	private MemberService memberService;

	@Autowired
	private OrderService orderService;

	@Override
	public void run(ApplicationArguments args) throws Exception {
		for (int i = 0; i < 10; i++){
			Member member = Member.builder().build();
			memberService.save(member);

			Order order = Order.builder()
				.member(member)
				.build();
			orderService.save(order);
		}
		List<Member> members = memberService.find();
		System.out.println(members);
	}

	public static void main(String[] args) {
		SpringApplication.run(BatchSizeExample.class, args);
	}
}

위 코드는 10명의 회원을 생성하고 각각의 회원마다 1개의 주문을 가지고 있는 상태에서 10명의 회원을 전부 조회하는 예제입니다.

 

실행 결과는 다음과 같습니다.

Hibernate: 
    /* select
        m 
    from
        Member m */ select
            member0_.id as id1_0_ 
        from
            member member0_
Hibernate: 
    /* load one-to-many com.ch15.class05.step05.Member.orders */ select
        orders0_.member_id as member_i2_1_1_,
        orders0_.id as id1_1_1_,
        orders0_.id as id1_1_0_,
        orders0_.member_id as member_i2_1_0_ 
    from
        orders orders0_ 
    where
        orders0_.member_id in (
            ?, ?, ?, ?, ?
        )
Hibernate: 
    /* load one-to-many com.ch15.class05.step05.Member.orders */ select
        orders0_.member_id as member_i2_1_1_,
        orders0_.id as id1_1_1_,
        orders0_.id as id1_1_0_,
        orders0_.member_id as member_i2_1_0_ 
    from
        orders orders0_ 
    where
        orders0_.member_id in (
            ?, ?, ?, ?, ?
        )
member.orders.size = 1
member.orders.size = 1
member.orders.size = 1
member.orders.size = 1
member.orders.size = 1
member.orders.size = 1
member.orders.size = 1
member.orders.size = 1
member.orders.size = 1
member.orders.size = 1
[Member(id=1), Member(id=2), Member(id=3), Member(id=4), Member(id=5), Member(id=6), Member(id=7), Member(id=8), Member(id=9), Member(id=10)]

실행 결과를 보면 회원 데이터 전부를 검색하는 쿼리 1개, 주문 데이터를 검색하는 쿼리가 2개인 것을 볼 수 있습니다. 그중에서 주목할 점은 주문 데이터 조회시 조건문으로 IN 절을 사용하였고 IN절에 들어가는 데이터는 Member의 id인 것을 볼 수 있습니다. @BatchSize의 값을 5로 설정하였기 때문에 5명의 회원씩 쪼개어 실행된 것을 볼수 있습니다.

 

위 예제를 통하여 알 수 있는 사실은 @BatchSize 애노테이션을 사용하면 특정 size만큼 회원 데이터의 개수를 쪼깨어 실행하여 N+1 문제를 해결할 수 있다는 점입니다.

 

위 예제에 대한 링크는 다음과 같습니다.

https://github.com/yonghwankim-dev/jpa/tree/main/src/main/java/com/ch15/class05/step05

 

2.3 하이버네이트 @Fetch(FetchMode.SUBSELECT) 사용

N+1 문제를 해결하기 위한 마지막 방법은 하이버네이트가 제공하는 org.hibernate.annotations.Fetch 애노테이션에 FetchMode를 SUBSELECT로 사용하는 방법입니다. 해당 방법을 사용하면 연관 엔티티를 조회할 때 서브 쿼리를 사용해서 N+1 문제가 발생하는 것을 막아줍니다.

 

다음 예제는 연관 주문 엔티티 리스트에 @Fetch(FetchMode.SUBSELECT)를 적용하여 서브 쿼리를 사용한 예제입니다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@Getter
@ToString(exclude = {"orders"})
public class Member {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

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

위 코드를 보면 orders 리스트에 @Fetch(FetchMode.SUBSELECT)를 적용한 것을 볼 수 있습니다. 위 애노테이션 설정시 연관 엔티티 조회시 서브 쿼리를 사용해서 조회합니다.

 

실행 결과는 다음과 같습니다.

Hibernate: 
    /* select
        m 
    from
        Member m */ select
            member0_.id as id1_0_ 
        from
            member member0_
Hibernate: 
    /* load one-to-many com.ch15.class05.step06.Member.orders */ select
        orders0_.member_id as member_i2_1_1_,
        orders0_.id as id1_1_1_,
        orders0_.id as id1_1_0_,
        orders0_.member_id as member_i2_1_0_ 
    from
        orders orders0_ 
    where
        orders0_.member_id in (
            select
                member0_.id 
            from
                member member0_
        )
member.orders.size = 1
member.orders.size = 1
member.orders.size = 1
member.orders.size = 1
member.orders.size = 1
member.orders.size = 1
member.orders.size = 1
member.orders.size = 1
member.orders.size = 1
member.orders.size = 1
[Member(id=1), Member(id=2), Member(id=3), Member(id=4), Member(id=5), Member(id=6), Member(id=7), Member(id=8), Member(id=9), Member(id=10)]

위 실행 결과를 보면 연관 주문 엔티티를 조회시 where 절에 member.id 컬럼을 대상으로 IN 절에 서브 쿼리의 결과를 넣어서 조회하는 것을 볼 수 있습니다.

 

위 예제를 통하여 알 수 있는 사실은 @Fetch(FetchMode.SUBSELECT) 애노테이션을 사용하게 되면 연관 엔티티 조회시 서브 쿼리를 사용하여 N+1 문제가 발생하는 것을 막을 수 있다는 것을 알게 되었습니다.

 

위 예제에 대한 링크는 다음과 같습니다.

https://github.com/yonghwankim-dev/jpa/tree/main/src/main/java/com/ch15/class05/step06

 

2.4 N+1 정리

연관 관계 엔티티를 로딩하는 전략으로 즉시 로딩과 지연 로딩 전략이 있는데 추천하는 방법은 지연 로딩을 사용하고 성능 최적화가 필요한 곳에는 JPQL 페치 조인을 사용하는 것입니다. 

 

글로벌 페치 전략으로 즉시 로딩을 사용하게 되면 N+1 문제와 비즈니스 로직에 따라 필요하지 않은 연관 엔티티까지 로딩해야 하는 상황이 자주 발생할 수 있습니다.

 

References

자바 ORM 표준 JPA 프로그래밍
source code : https://github.com/yonghwankim-dev/jpa/tree/main/src/main/java/com/ch15/class05