프로젝트를 진행하면서 코드 리뷰에서 "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 대체 | 간단한 연관 조회 |
'TIL' 카테고리의 다른 글
| Java Enum 활용하기 - 계산기 과제로 이해하는 Enum (0) | 2026.07.24 |
|---|---|
| 싱글톤(Singleton) 패턴이란? (0) | 2026.07.23 |
| [트러블슈팅] 고객 조회 시 주문 통계(총 주문 수 / 총 구매 금액) 추가하기 (0) | 2026.07.14 |
| [Spring] MVC에서 자주 사용하는 객체와 어노테이션 정리 (0) | 2026.07.08 |
| Java 리스트(List) 활용 & 반복문 리팩토링 (0) | 2026.06.23 |