Skip to content

실무에서 바로 쓰는 JPA (1편) - N+1 문제 해결

실무에서 바로 쓰는 JPA (1편) - N+1 문제 해결




TL;DR

  • 문제: 주문 목록 조회 시 1개 쿼리 예상했는데 101개 쿼리 발생 (주문 100개 + 각 주문마다 상품 조회)
  • 원인: JPA 지연 로딩 (Lazy Loading) - 연관 엔티티를 실제 사용할 때 개별 조회
  • 해결: Fetch Join, @EntityGraph, Batch Size 조합으로 쿼리 101개 → 2개로 감소
  • 효과: 응답 시간 3초 → 200ms, DB 부하 99% 감소
  • 한계: 페이징 + Fetch Join 불가, 컬렉션 2개 이상 Fetch Join 불가, 모든 상황에 만능 아님


환경

  • 프레임워크: Spring Boot 3.x + JPA
  • DB: MySQL 8.0, InnoDB
  • 데이터 규모: 주문 100건, 각 주문당 상품 평균 5개
  • 쿼리 도구: Hibernate 쿼리 로그, Explain Plan




시리즈 소개

이 글은 “실무에서 바로 쓰는 JPA” 시리즈의 첫 번째 편입니다.

실무에서 JPA를 사용하면서 겪는 대표적인 문제들과 해결 방법을 다룹니다. 이론보다는 실전 경험을 바탕으로, 바로 적용할 수 있는 내용 위주로 구성했습니다.

1편 (현재 글): N+1 문제 해결

연관관계 조회 시 발생하는 성능 문제와 해결 방법을 다룹니다.

2편: 영속성 컨텍스트와 흔한 실수들

JPA의 내부 동작 원리와 실수하기 쉬운 포인트들을 짚어봅니다.

3편: 쿼리 최적화 실전

복잡한 쿼리를 효율적으로 작성하는 노하우를 공유합니다.

4편: 엔티티 설계 마스터하기

유지보수하기 좋은 엔티티 설계 원칙을 다룹니다.

5편: 실무 안티패턴 총정리

실무에서 자주 발생하는 실수와 개선 방법을 총정리합니다.





서론

회사에서 주문 목록 조회 API를 만들고 있었습니다. 간단한 기능이라 금방 끝날 줄 알았는데, 로컬에서 테스트하다가 이상한 점을 발견했습니다.

주문 10개를 조회했는데 SQL 쿼리가 무려 21번이나 실행됐더군요.

-- 1번: 주문 목록 조회
SELECT * FROM orders LIMIT 10;

-- 2~11번: 각 주문의 고객 정보 조회
SELECT * FROM customers WHERE id = ?;
SELECT * FROM customers WHERE id = ?;
...

-- 12~21번: 각 주문의 배송 정보 조회
SELECT * FROM delivery WHERE order_id = ?;
SELECT * FROM delivery WHERE order_id = ?;
...

분명 1개의 쿼리로 해결될 줄 알았는데 말이죠. 이게 바로 JPA를 쓰는 개발자라면 반드시 만나게 되는 N+1 문제입니다.

이번 포스팅에서는 이 문제를 어떻게 해결했는지 공유합니다.





N+1 문제란?

N+1 문제는 연관 관계가 설정된 엔티티를 조회할 때, 조회된 데이터 개수(N)만큼 추가 쿼리가 발생하는 문제입니다.

예를 들어 주문 10개를 조회했다면:

  • 1번: 주문 목록 조회 쿼리
  • N번(10번): 각 주문의 연관 데이터 조회 쿼리

총 11번(1 + 10)의 쿼리가 실행되는 거죠.


JPA는 왜 이렇게 동작할까?

이해하려면 JPA의 지연 로딩(Lazy Loading) 동작 원리를 알아야 합니다.

@Entity
public class Order {
    @Id
    private Long id;

    private String orderNumber;

    @ManyToOne(fetch = FetchType.LAZY)  // 지연 로딩 설정
    private Customer customer;

    @OneToOne(fetch = FetchType.LAZY)
    private Delivery delivery;
}

FetchType.LAZY는 “필요할 때만 가져오겠다”는 의미입니다.

실제 동작 흐름을 보면:

// 1. 주문 목록 조회
List<Order> orders = orderRepository.findAll();
// → SQL: SELECT * FROM orders;
// 이 시점에는 Order의 id, orderNumber만 조회됨
// customer와 delivery는 프록시 객체로 채워짐

// 2. 반복문에서 연관 데이터 접근
for (Order order : orders) {
    // customer에 접근하는 순간 추가 쿼리 발생
    String customerName = order.getCustomer().getName();
    // → SQL: SELECT * FROM customers WHERE id = ?;

    // delivery에 접근하는 순간 또 추가 쿼리 발생
    String address = order.getDelivery().getAddress();
    // → SQL: SELECT * FROM delivery WHERE order_id = ?;
}

주문이 10개라면 이 반복문이 10번 돌면서 총 20번(고객 10번 + 배송 10번)의 추가 쿼리가 나갑니다.


프록시와 지연 로딩

JPA는 지연 로딩을 구현하기 위해 프록시 객체를 사용합니다.

Order order = orderRepository.findById(1L);

// 이 시점의 customer는 실제 Customer 객체가 아닌 프록시
Customer customer = order.getCustomer();
System.out.println(customer.getClass());
// 출력: Customer$HibernateProxy$...

// 실제 데이터에 접근하는 순간 DB 조회
String name = customer.getName();  // 여기서 쿼리 발생!

프록시는 겉모습만 Customer처럼 보이는 껍데기입니다. 실제 데이터에 접근하기 전까지는 DB 조회를 하지 않다가, .getName() 같은 메서드를 호출하는 순간 쿼리가 나갑니다.


그럼 EAGER 로딩을 쓰면 되지 않나?

처음엔 저도 그렇게 생각했습니다.

@ManyToOne(fetch = FetchType.EAGER)  // 즉시 로딩
private Customer customer;

EAGER로 설정하면 처음부터 조인해서 가져오니까 N+1 문제가 안 생길 것 같죠?

하지만 이건 더 큰 문제를 만듭니다.

// 주문만 필요한 경우에도
Order order = orderRepository.findById(1L);
// → SELECT * FROM orders o
//   LEFT JOIN customers c ON o.customer_id = c.id
//   LEFT JOIN delivery d ON o.id = d.order_id;

// 불필요한 customer, delivery까지 무조건 조회됨

주문 정보만 보고 싶은데 고객, 배송 정보까지 매번 가져오니까 불필요한 데이터 전송이 발생합니다.

그리고 더 치명적인 문제가 있습니다. EAGER가 여러 개 있으면 예상치 못한 조인이 계속 붙습니다.

@Entity
public class Order {
    @ManyToOne(fetch = FetchType.EAGER)
    private Customer customer;  // Customer는 또 다른 EAGER 관계를 가질 수 있음

    @OneToOne(fetch = FetchType.EAGER)
    private Delivery delivery;  // Delivery도 또 다른 EAGER 관계를 가질 수 있음
}

이렇게 되면 조인이 조인을 물고 성능이 급격히 나빠집니다.

💡기본은 LAZY

JPA 공식 문서에서는 기본적으로 LAZY를 권장합니다. EAGER는 예측하지 못한 조인으로 성능을 떨어뜨릴 수 있기 때문입니다.


정리: N+1이 발생하는 이유

  1. JPA는 기본적으로 지연 로딩을 사용 (불필요한 조회 방지)
  2. 연관 엔티티는 프록시로 채워짐 (실제 데이터 X)
  3. 프록시에 접근하는 순간 쿼리 실행 (여기서 N+1 발생)

즉, N+1은 JPA의 설계상 trade-off입니다. 불필요한 조회를 막으려다 보니 필요한 시점에 추가 쿼리가 나가는 거죠.


실제로 얼마나 느릴까?

저희 팀에서 측정했을 때 이런 결과가 나왔습니다.

주문 100건 조회 성능 비교

⚠️N+1 발생
2.5초
201번 쿼리 실행 (1 + 100 + 100)
⚡️최적화 후
250ms
3번 쿼리 실행
결과약 10배 성능 향상

DB 왕복 시간이 3ms라고 가정하면:

  • N+1 발생: 201번 × 3ms = 603ms (DB 왕복만)
  • 최적화: 3번 × 3ms = 9ms

물론 DB 스펙이나 네트워크 상황에 따라 다르겠지만, 쿼리 횟수 자체가 200배 차이니까 체감상 확실히 느렸습니다.





해결 방법 1: Fetch Join

가장 직관적인 해결 방법입니다. JPQL에서 JOIN FETCH를 사용하면 한 번에 다 가져옵니다.

@Query("SELECT o FROM Order o " +
       "JOIN FETCH o.customer " +
       "JOIN FETCH o.delivery " +
       "WHERE o.status = :status")
List<Order> findAllWithCustomerAndDelivery(@Param("status") OrderStatus status);

실행되는 SQL은 이렇게 됩니다.

SELECT
    o.*,
    c.*,
    d.*
FROM orders o
INNER JOIN customers c ON o.customer_id = c.id
INNER JOIN delivery d ON o.id = d.order_id
WHERE o.status = ?;

쿼리 1번으로 모든 데이터를 가져오니까 N+1 문제가 사라집니다.


Fetch Join의 장단점

장점:

  • 쿼리 1번으로 해결
  • 직관적이고 이해하기 쉬움
  • 대부분의 경우 성능이 좋음

단점:

  • 페이징(Pageable) 사용 시 메모리에서 처리됨 (주의!)
  • 컬렉션 fetch join을 여러 개 쓸 수 없음
  • 카르테시안 곱 발생 가능

페이징 사용 시 주의사항

제가 실수했던 부분인데요.

// Bad - 메모리에서 페이징됨
@Query("SELECT o FROM Order o " +
       "JOIN FETCH o.customer " +
       "JOIN FETCH o.delivery")
Page<Order> findAllWithJoinFetch(Pageable pageable);

이렇게 쓰면 HHH000104 경고가 나옵니다. “firstResult/maxResults specified with collection fetch; applying in memory!”

DB에서 10만건을 다 가져온 후 메모리에서 10건만 선택하는 겁니다. 엄청난 낭비죠.

그래서 페이징이 필요하면 다른 방법을 써야 합니다.





해결 방법 2: @EntityGraph

Spring Data JPA가 제공하는 더 간편한 방법입니다.

@EntityGraph(attributePaths = {"customer", "delivery"})
@Query("SELECT o FROM Order o WHERE o.status = :status")
Page<Order> findAllWithEntityGraph(@Param("status") OrderStatus status, Pageable pageable);

Fetch Join과 동일하게 동작하지만 코드가 더 깔끔합니다.


@EntityGraph의 장점

저는 이게 더 마음에 듭니다.

  • JPQL에 JOIN FETCH를 일일이 쓰지 않아도 됨
  • 메서드 레벨에서 그래프 설정 가능
  • 여러 쿼리 메서드에 재사용 가능

다만 페이징 사용 시 주의사항은 Fetch Join과 동일합니다.





해결 방법 3: Batch Size 설정

컬렉션을 조회할 때 유용한 방법입니다.

@Entity
public class Order {
    @Id
    private Long id;

    @OneToMany(mappedBy = "order")
    @BatchSize(size = 100)
    private List<OrderItem> orderItems;
}

또는 전역 설정으로:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

이렇게 하면 N+1이 발생하긴 하지만, IN 절로 한 번에 여러 개를 가져옵니다.

-- 1번: 주문 목록 조회
SELECT * FROM orders LIMIT 10;

-- 2번: 주문 아이템 한 번에 조회 (IN 절 사용)
SELECT * FROM order_items
WHERE order_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

10+10 = 20번이 아니라 1+1 = 2번의 쿼리로 줄어듭니다.


Batch Size 활용 시점

저희 팀에서는 이런 경우에 사용합니다.

  • 컬렉션을 여러 개 Fetch Join할 수 없을 때
  • 페이징이 필요한 경우
  • 적절한 크기(100~1000)로 설정하면 괜찮은 성능




실무에서 마주한 문제들

문제 1: 컬렉션 2개 이상 Fetch Join

// Bad - MultipleBagFetchException 발생
@Query("SELECT o FROM Order o " +
       "JOIN FETCH o.orderItems " +
       "JOIN FETCH o.coupons")
List<Order> findAllWithItemsAndCoupons();

하이버네이트는 컬렉션을 2개 이상 Fetch Join하는 걸 허용하지 않습니다. 카르테시안 곱이 발생해서 데이터가 뻥튀기되기 때문입니다.

해결: 한 개는 Fetch Join, 나머지는 Batch Size 사용

@Query("SELECT o FROM Order o " +
       "JOIN FETCH o.orderItems")
List<Order> findAllWithItems();

// coupons는 Batch Size로 처리
@Entity
public class Order {
    @OneToMany(mappedBy = "order")
    @BatchSize(size = 100)
    private List<Coupon> coupons;
}

문제 2: 페이징 + Fetch Join

앞에서 말했듯이 페이징과 Fetch Join을 같이 쓰면 메모리에서 처리됩니다.

해결 1: Batch Size 사용

@Query("SELECT o FROM Order o WHERE o.status = :status")
Page<Order> findAllByStatus(@Param("status") OrderStatus status, Pageable pageable);

// 연관 엔티티는 Batch Size로
spring.jpa.properties.hibernate.default_batch_fetch_size=100

해결 2: DTO 프로젝션 사용

@Query("SELECT new com.example.OrderDto(" +
       "o.id, c.name, d.address) " +
       "FROM Order o " +
       "JOIN o.customer c " +
       "JOIN o.delivery d")
Page<OrderDto> findAllAsDto(Pageable pageable);

DTO로 바로 매핑하면 페이징도 정상 작동하고 쿼리도 1번입니다. 다만 엔티티가 아니라 DTO를 쓴다는 단점이 있죠.





실전 가이드

어떤 방법을 선택할까?

저는 이런 기준으로 선택합니다.

1. 단순 조회 + 페이징 불필요 → Fetch Join

@Query("SELECT o FROM Order o " +
       "JOIN FETCH o.customer " +
       "JOIN FETCH o.delivery")
List<Order> findAllWithDetails();

2. 페이징 필요 → Batch Size

spring.jpa.properties.hibernate.default_batch_fetch_size=100

3. 컬렉션 여러 개 → Fetch Join + Batch Size 조합

// 한 개는 Fetch Join
@Query("SELECT o FROM Order o JOIN FETCH o.orderItems")
List<Order> findAllWithItems();

// 나머지는 Batch Size
@BatchSize(size = 100)
private List<Coupon> coupons;

4. 성능이 정말 중요 → DTO 프로젝션

@Query("SELECT new OrderSummaryDto(...) FROM Order o JOIN ...")
Page<OrderSummaryDto> findSummaries(Pageable pageable);

성능 측정은 필수

저희 팀에서 측정한 실제 결과입니다.

최적화 방법별 성능 비교

⚠️N+1 발생
2.5초
쿼리 폭발
⚡️DTO 프로젝션
180ms
가장 빠름
결과Fetch Join(200ms), Batch Size(250ms)도 우수함

당연히 환경마다 다르겠지만, 적어도 10배 이상 차이가 났습니다. 꼭 프로파일링하고 적용하세요.





주의할 점

1. 과도한 최적화 금지

모든 쿼리에 Fetch Join을 붙일 필요는 없습니다. 조회 빈도가 낮거나 데이터가 적다면 Lazy Loading 그대로 써도 됩니다.

저희 팀도 관리자 기능 중 일부는 그냥 둡니다. 하루에 몇 번 안 쓰는 기능이니까요.


2. 카르테시안 곱 주의

컬렉션을 여러 개 Fetch Join하면 데이터가 뻥튀기됩니다.

// Order 10개, 각 Order당 Item 3개, Coupon 2개
// 결과: 10 × 3 × 2 = 60개 행이 반환됨

DISTINCT를 쓰면 해결되긴 하지만, 여전히 DB에서는 60개 행을 읽습니다.


3. Batch Size 크기 조절

너무 크면 메모리 문제, 너무 작으면 쿼리 횟수 증가. 저희는 100~1000 사이에서 테스트했고, 보통 100으로 설정합니다.





시스템 점검 체크리스트

저도 배포 전에 이 항목들을 꼭 확인합니다. JPA N+1 문제를 해결한다면 참고하시면 좋을 것 같습니다.

  • Fetch Join 적용: 연관관계 조회 시 Fetch Join 또는 @EntityGraph를 사용했는가?
  • Batch Size 설정: 컬렉션 조회 시 default_batch_fetch_size를 설정했는가? (100~1000 권장)
  • 페이징 주의: Fetch Join + Paging 조합을 피하거나, Batch Size로 대체했는가?
  • 쿼리 로그 확인: 실제로 몇 개의 쿼리가 발생하는지 로그로 확인했는가?
  • 성능 측정: 개선 전후의 응답 시간을 측정했는가? (JMeter, nGrinder 등)



결론

JPA N+1 문제는 피할 수 없습니다. Lazy Loading을 쓰는 이상 언젠가 만나게 되죠.

저도 처음엔 당황했습니다. “왜 쿼리가 이렇게 많이 나가지?” 하면서 로그를 한참 들여다봤거든요.

하지만 이제는 이렇게 생각합니다.

N+1은 문제가 아니라 trade-off입니다. Lazy Loading으로 불필요한 조회를 피하는 대신, 필요할 때 추가 쿼리가 나가는 거죠.

중요한 건 언제 최적화할지 판단하는 것입니다.

저희 팀은 이런 기준으로 판단합니다.

  • 호출 빈도가 높은가?
  • 응답 시간이 느린가? (1초 이상)
  • 데이터 양이 많은가? (100개 이상)

이 중 하나라도 해당하면 최적화를 고려합니다.

그리고 최적화할 때는 꼭 측정합니다. “느낌”이 아니라 “숫자”로 판단해야 합니다.

저도 한 번은 Fetch Join을 적용했는데 오히려 느려진 적이 있었습니다. Join할 테이블이 너무 커서 DB 부하가 늘어났더군요. 그땐 Batch Size가 더 나았습니다.

정답은 없습니다. 상황에 따라 다릅니다.

신규 프로젝트라면 일단 Batch Size를 전역 설정해두고, 병목이 발견되면 그때 Fetch Join이나 DTO 프로젝션을 고려하세요.


다음 편 예고:

N+1 문제를 해결했다고 해서 끝이 아닙니다. 다음 편에서는 JPA의 핵심인 영속성 컨텍스트와 실무에서 자주 하는 실수들을 다룹니다. save()를 언제 써야 하는지, 변경 감지는 어떻게 동작하는지, EAGER vs LAZY는 어떻게 선택해야 하는지 등 실전에서 헷갈리는 내용들을 정리했습니다.

다음: 2편 - 영속성 컨텍스트와 흔한 실수들





참고:

https://docs.jboss.org/hibernate/orm/5.4/userguide/html_single/Hibernate_User_Guide.html
https://vladmihalcea.com/n-plus-1-query-problem/
https://www.baeldung.com/jpa-n-plus-1-problem




읽어주셔서 감사합니다.🖐


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#소개 페이지#Blog OpenSource Github#Blog OpenSource Demo Site