티스토리 뷰

728x90

 


 

 

JPA 를 사용할 때 항상 N+1 문제를 조심해야한다

그리고 N+1 문제를 해결하기 위해서는 여러가지 방안이 있다

왜 N+1 이 발생하고, 어떻게 해결해 나가는지 코드를 통해 이해를 도와보자

 

1) Entity

기본적으로 위 예시에는 2개의 엔티티가 존재한다

NMember 와 NOrder 가 있고, NMember 는 여러개의 NOrder 를 가짐으로써 두 엔티티의 관계에서
부모: NMember, 자식: NOrder 관계 이다. 그러므로 NMember 기준 @OneToMany, NOrder 기준 @ManyToOne 사용

 

 

코드 수 최소화 및 편의를 위해 Lombok 을 사용하서 개발을 한다

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

    private String name;

    @OneToMany(mappedBy = "member")
    private List<NOrder> orders = new ArrayList<>();

}

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
@Table(name = "n_order")
@Entity
public class NOrder {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String productName;

    private LocalDateTime createAt;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "n_member_id")
    private NMember member;

}

 

위 엔티티 관계는 양방향 관계를 사용하여 맵핑을 하였다

@OneToMany 시 단방향 말고 양방향을 사용해야 한다

위 부분이 궁금하면 이 링크를 참고하자

 

 

위 엔티티를 영속화 하기 위한 레포지토리는 아래 코드와 같다.

public interface NMemberRepository extends JpaRepository<NMember, Long> {
}

public interface NOrderRepository extends JpaRepository<NOrder, Long> {
}

 

 

2) N+1 문제 발생 Service

	@Test
	void insertTest() {
	    // given
		NMember member = null;
		NOrder order =  null;
		for (int i = 0; i < 5000; i++) {
			member = new NMember("현규"+i);
			order = new NOrder("치킨"+i, LocalDateTime.now(), member);
			nMemberRepository.save(member);
			nOrderRepository.save(order);
		}
	}

 

미리 위 쿼리를 생성해두고 진행을 하겠다

 

@Slf4j
@Service
@RequiredArgsConstructor
public class NMemberService {
    private final NMemberRepository nMemberRepository;

    @Transactional(readOnly = true)
    public void getMemberWithOrders() {
        List<NMember> nMemberList = nMemberRepository.findAll();
        log.info("Member : {}", nMemberList.size());

        for (NMember nMember : nMemberList) {
            List<NOrder> orders = nMember.getOrders(); // N+1 발생
            log.info("Member : {} _ OrderCount : {}", nMember.getName(), orders.size());
        }
    }
}

 

위와 같은 코드가 있다

전체 NMember 를 조회하고, 그 Member 별로 주문을 찾는 로직이다

과연 위 로직을 실행시켜보면 쿼리는 어떤식으로 실행될까?

아주 간단한 테스트를 만들었고 위 테스트는 그냥 쿼리가 나가게끔만 작성하였다

    @Test
    @DisplayName("멤버별로 주문을 주문 개수를 조회한다.")
    void getMemberWithOrdersTest() {
        // given & when & then
        nMemberService.getMemberWithOrders();
    }

 

위 테스트를 실행했을 때 쿼리는 아래와 같다

 

Hibernate: 
    select
        n1_0.id,
        n1_0.name 
    from
        n_member n1_0
2025-02-16T13:25:47.197+09:00  INFO 27029 --- [    Test worker] o.h.j.nplus1.service.NMemberService      : Member : 3
Hibernate: 
    select
        o1_0.n_member_id,
        o1_0.id,
        o1_0.create_at,
        o1_0.product_name 
    from
        n_order o1_0 
    where
        o1_0.n_member_id=?
2025-02-16T13:25:47.204+09:00  INFO 27029 --- [    Test worker] o.h.j.nplus1.service.NMemberService      : Member : 현규 _ OrderCount : 7
Hibernate: 
    select
        o1_0.n_member_id,
        o1_0.id,
        o1_0.create_at,
        o1_0.product_name 
    from
        n_order o1_0 
    where
        o1_0.n_member_id=?
2025-02-16T13:25:47.207+09:00  INFO 27029 --- [    Test worker] o.h.j.nplus1.service.NMemberService      : Member : 재훈 _ OrderCount : 0
Hibernate: 
    select
        o1_0.n_member_id,
        o1_0.id,
        o1_0.create_at,
        o1_0.product_name 
    from
        n_order o1_0 
    where
        o1_0.n_member_id=?
2025-02-16T13:25:47.209+09:00  INFO 27029 --- [    Test worker] o.h.j.nplus1.service.NMemberService      : Member : 연성 _ OrderCount : 0

 

위 쿼리를 보면 나는 사실상 쿼리를 1번 날린것 같은데 쿼리가 3개가 더 날라갔다

이게 바로 N+1 문제이다

 

 

근본적으로 엔티티에서 연관관계 매핑시 FetchType 이 Lazy 일 경우 필요할 때 만 연관관계 관련 쿼리가 발생하는 경우가 N+1 이다.

List nMemberList = nMemberRepository.findAll();

 

위 로직은 전체 Member 만 조회한다. 위 쿼리는 Join 을 통하여 Order 엔티티 정보까지 가져오지 않는다

 


이유는 위에도 말했듯이 근본적인 이유는 FetchType 이 Lazy 이기 때문이다

그러므로 List<NOrder> orders = nMember.getOrders(); // N+1 발생 생각지 못한 쿼리가 발생하게 된다.

 

기본적으로 N+1 발생시 Entity 필드 구성을 처음으로 체크를 잘해야 한다

연관관계 매핑이 어떤식으로 되어있는지 체크를 하는게 위 문제를 해결하기 위한 기본이라고 생각한다

 

 

해결방법1) Lazy -> Eager

만약 Lazy 가 아니라 Eager 을 사용시 N+1 문제 자체는 해결될 수 있으나, 다른 기능에서 엔티티 조회시 불필요한 Join 이 계속 발생할 수가 있다

일시적으로는 해결된것 처럼 보여도 어디선가 또 다른 문제를 야기할 것이라고 생각한다

또 다른 문제라고 하면 '카타시안 곱' 문제 가 발생할 수 있음.

그러므로 위 방법은 N+1 자체는 해결할 수 있으나 다른 문제가 생길 수 있으므로 좋은 해결 방법은 아니라고 생각함

 

해결방법2) @EntityGraph 사용

@EntityGraph 사용시 JPA 가 내부적으로 Left outer join 을 사용하여 한 번의 쿼리로 연관 객체 데이터를 가져온다

fetchJoin 과 동작 방식은 비슷하다.

사용 방법은 아래와 같다

public interface NMemberRepository extends JpaRepository<NMember, Long> {

    @EntityGraph(attributePaths = "n_order") 
    @Query("SELECT m FROM NMember m")
    List<NMember> findAllWithOrders();
}

 

레포지토리에서 메소드를 만들고 JPQL 을 통하여 쿼리를 직접 작성 후 위 어노테이션을 통해 연관 데이터를 함께 조회하게 선언해준다

현재는 데이터를 5000개 기준으로 위 메소드를 적용하였을 때와 적용안했을 때의 차이를 보자

 

적용전

 

적용후

 

 

데이터가 5000건임에도 불구하고 N+1 문제가 발생하지 않아 쿼리 성능이 벌써 개선되었다

하지만 조금의 단점이라고 하면, 관계형 코드를 작성하기 위해 JPA 를 사용하지만, JPQL 을 직접 작성한다는점? 이다

위에 대한 대안은 아래서 설명하겠다.

 

해결방법3) JPQL FetchJoin & QueryDSL FetchJoin 사용

fetchJoin 은 queryDSL 또는 JPQL 을 통한 네이티브 쿼리를 통해 사용할 수 있다

FetchJoin 은 말 그대로 연관된 엔티티를 한번의 Join 을 통하여 가져오는 것을 의미한다

아래는 2가지 예시를 둘다 들어보려고 한다

  • JPQL FetchJoin 은 @EntityGraph 와 비슷한 방식이다
      @Query("SELECT m FROM Member m JOIN FETCH m.orders")
      List<Member> findAllWithOrders();

JPQL 끝 쪽에 JOIN FETCH 가 있다, 위 메소드가 날리는 쿼리에 대한 성능은 아래와 같다.

 

 

  • QueryDSL FetchJoin
    JPQL FetchJoin 과 동일한 기능이지만, 동적 쿼리를 작성하는데 유용하므로 QueryDSL FetchJoin 사용을 권장한다.

 

public interface CustomNMemberRepository {
    List<NMember> findAllWithNOrders();
}

@RequiredArgsConstructor
@Repository
public class CustomNMemberRepositoryImpl implements CustomNMemberRepository{

    private final JPAQueryFactory jpaQueryFactory;
    private static final QNMember qnMember = QNMember.nMember;
    private static final QNOrder qnOrder = QNOrder.nOrder;

    @Override
    public List<NMember> findAllWithNOrders () {
        return jpaQueryFactory.selectFrom(qnMember)
            .leftJoin(qnMember.orders, qnOrder)
            .fetchJoin()
            .fetch()
            ;
    }

}

 

결과는 위와 같다

 

해결방법4) BatchSize 사용

위 기능은 일정 개수의 연관된 엔티티를 한 번의 IN 절 쿼리로 조회하는 역할을 한다

 

BatchSize 적용 방법

1) 특정 Entity 에서 직접 적용

    @BatchSize(size = 10)  // BatchSize 적용
    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<>();
Hibernate: 
    select
        o1_0.n_member_id,
        o1_0.id,
        o1_0.create_at,
        o1_0.product_name 
    from
        n_order o1_0 
    where
        o1_0.n_member_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

 


위 쿼리가 주기적으로 계속 나간다.

IN 으로 묶어서 쿼리를 실행하므로 N+1 보다는 성능이 좋지만, 당연하게도 N+1 문제를 완벽하게 해결하지 못한다

 

2) Global 설정으로 @BatchSize 조절하기

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 10

 

위 설정을 적용하면 LazyLoading 연관 필드에 대헤 @BatchSize(size=10) 과 동일한 효과가 적용됨

위 설정은 모든 엔티티에 적용되므로 위에 방법처럼 엔티티 하나 하나 찾아서 @BatchSize 를 설정할 필요가 없다

하지만 위 방법은 완벽하게 해결하는 방법이 아닌, 완화하는 기법이다

그러므로 적잘한 BatchSize 조절을 하여야 한다

 

  • size 값이 너무 작으면? → 여러 번의 IN 절 쿼리가 실행됨 (최적화 효과가 적음)
  • size 값이 너무 크면? → IN 절의 값이 너무 많아져서 성능이 떨어질 수 있음
  • 적절한 Batch Size 값을 찾아야 함 (보통 10~100 사이로 조절)

 

최종 정리

  • 간단한 경우 → @EntityGraph 사용
  • 정적 쿼리 → JPQL FETCH JOIN
  • 동적 쿼리가 필요할 때 → QueryDSL fetchJoin()
  • Fetch Join 데이터가 너무 많을 때, 복잡한 연관관계가 많아 Fetch Join 이 비효율적일 때 -> BatchSize 조절

 

필자는 실무에서 연관관계가 엮인 것이 많아 위 방법들 중에 상황에 맞게 유연하게 선택을 해서 사용한다

주로 사용하는 방법은 queryDSL 에 FetchJoin 을 많이 사용하기는 하지만, 어떠한 상황에서는 @EntityGraph 를 사용할 때도 있다

중요한건 상황에 맞게 유연하게 대처하는 것이다.

 

 

code : https://github.com/Hyeonqz/jpa-best-example/tree/master/src/main/java/org/hyeonqz/jpabestexample/nplus1

 

jpa-best-example/src/main/java/org/hyeonqz/jpabestexample/nplus1 at master · Hyeonqz/jpa-best-example

JPA 모범 사례를 직접 연구해본다. Contribute to Hyeonqz/jpa-best-example development by creating an account on GitHub.

github.com

 

 

728x90
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크