TIL

JPA N+1 문제란? (왜 생기고 어떻게 해결할까?)

chungmani 2026. 7. 15. 21:19

 

프로젝트를 진행하면서 코드 리뷰에서 "N+1 문제가 발생할 수 있다." 라는 피드백을 받았다.

처음에는 이름만 들어서는 감이 오지 않았다.

 

"도대체 왜 조회가 100번씩 일어난다는 거지?"

그래서 이번 기회에 N+1 문제가 무엇인지, 왜 발생하는지, 어떻게 해결하는지 정리해보았다.


N+1 문제란?

N+1 문제는

데이터를 조회할 때 처음 조회하는 쿼리(1번) 이후에 연관된 데이터를 가져오기 위해 N번의 추가 쿼리가 발생하는 현상이다.

예를 들어 Customer와 Order가 있다고 생각해보자.

Customer
 └── Order (1:N)
 

 

고객 목록을 조회하는 상황이다.

List<Customer> customers = customerRepository.findAll();
 

여기까지는 SQL이 딱 한 번 실행된다.

SELECT * FROM customer;
 

그런데 이후에

for (Customer customer : customers) {
    customer.getOrders();
}
 

처럼 연관된 주문을 조회하면 어떻게 될까?

 

고객이 100명이 있다면

SELECT * FROM customer;
 

한 번 실행되고

SELECT * FROM orders WHERE customer_id = 1;
SELECT * FROM orders WHERE customer_id = 2;
SELECT * FROM orders WHERE customer_id = 3;
...
 

고객 수만큼 또 실행된다.

 

1 + 100 = 101번
 

조회가 발생한다.

이것이 바로 N+1 문제이다.

 


왜 N+1이라는 이름일까?

예를 들어

고객 5명
 

이라면

고객 조회      1번
주문 조회      5번
----------------
총            6번
 

1 + N
 

이라서 N+1 문제라고 부른다.

 


실제 프로젝트에서도 발생했던 코드

우리 프로젝트에서는 고객 목록 조회 시

Page<Customer> customers = customerRepository.searchByKeywordAndStatus(...);

return customers.map(customer ->
    new GetCustomerResponse(
        customer.getId(),
        customer.getName(),
        orderRepository.countByCustomerId(customer.getId()),
        orderRepository.sumTotalPriceByCustomerId(customer.getId())
    )
);
 

이런 코드가 있었다.

겉으로 보기에는 문제가 없어 보인다.

하지만 고객이 100명이라면

고객 조회                 1번

countByCustomerId()     100번

sumTotalPrice()         100번
 

201번
 

쿼리가 발생하게 된다.

코드 리뷰에서 바로 이 부분을 N+1 문제로 지적받았다.

 


왜 문제가 될까?

가장 큰 문제는 성능이다.

예를 들어

고객 10명
 

이면

21번 조회
 

정도라 크게 느껴지지 않을 수도 있다.

하지만

고객 1000명
 

이라면

2001번 조회
 

가 발생한다.

  • DB와 계속 통신
  • 응답속도 증가
  • DB 부하 증가

결국 서비스 성능이 급격히 떨어질 수 있다.

 


해결 방법

대표적으로 사용하는 방법은 다음과 같다.

1. Fetch Join

연관된 데이터를 한 번에 가져오는 방법이다.

@Query("""
SELECT o
FROM Order o
JOIN FETCH o.customer
JOIN FETCH o.product
""")
 

실행되는 SQL도 하나이다.

SELECT ...
FROM orders
JOIN customer
JOIN product
 

장점

  • 가장 많이 사용하는 방법
  • SQL 한 번으로 조회 가능

단점

  • 컬렉션(1:N)에서는 페이징과 함께 사용하기 어렵다.

2. DTO Projection

필요한 데이터만 바로 DTO로 조회한다.

SELECT new CustomerSummaryDto(
    c.id,
    c.name,
    COUNT(o),
    SUM(o.totalPrice)
)
 

Entity를 거치지 않고

DB → DTO
 

로 바로 조회한다.

장점

  • 가장 성능이 좋다.
  • 필요한 데이터만 가져온다.

단점

  • JPQL을 직접 작성해야 한다.

3. EntityGraph

Repository에서 연관 데이터를 함께 조회하도록 지정한다.

 
@EntityGraph(attributePaths = {"customer","product"})
 

Fetch Join보다 코드가 간결한 경우가 많다.

그럼 무조건 Fetch Join을 쓰면 될까?

그렇지는 않다.

예를 들어

Customer (1)

Order (1000)
 

를 Fetch Join하면

Customer 정보도

1000번
 

중복되어 조회된다.

또한

1:N 관계 + 페이징
 

에서는 Fetch Join 사용을 권장하지 않는다.

상황에 따라

  • Fetch Join
  • DTO Projection
  • EntityGraph

중 적절한 방법을 선택해야 한다.

 


이번 프로젝트에서 느낀 점

처음에는 "그냥 Repository 메서드를 여러 번 호출한 것뿐인데?" 라고 생각했다.

하지만 고객 수가 많아질수록

조회 횟수 = 기하급수적으로 증가
 

한다는 점을 이해하고 나니 왜 성능 문제가 되는지 알 수 있었다.

이번 코드 리뷰를 통해 N+1 문제는 JPA를 사용할 때 반드시 알고 있어야 하는 대표적인 성능 이슈라는 것을 배웠다.

 


정리

방식특징추천 상황
Fetch Join 연관 Entity를 한 번에 조회 1:1, N:1 조회
DTO Projection 필요한 데이터만 조회 목록 조회, 통계 조회
EntityGraph Fetch Join 대체 간단한 연관 조회