[order] JPA N+1 문제 개선: FETCH 조인과 IN
N+1 문제가 발생했다.
주문 시스템을 개발하며 주문 내역 조회 API를 구현했다. 기능이 정상적으로 동작하는 것을 확인한 후, 실제 쿼리가 어떻게 발생하고 있는지 성능을 점검해 보기로 했다.
이를 위해 애플리케이션 속성에 다음 설정을 추가하여 쿼리 발생량을 확인했다.
spring.jpa.show-sql: true/spring.jpa.properties.hibernate.format_sql: true: 실행되는 쿼리를 로그에서 확인spring.jpa.properties.hibernate.generate_statistics=true: Hibernate가 실행한 총 쿼리 개수 및 통계 분석 띄우기
설정 후 주문 내역 조회 API를 테스트해 본 결과, 전형적인 N+1 문제가 발생하고 있음을 확인했다.
기존에 작성했던 로직은 반복문 내부에서 연관된 도메인 엔티티를 건별로 직접 조회하는 방식이었다. 각 레포지토리에는 아래와 같은 단건 조회 메서드가 정의되어 있었고, 이를 반복문 안에서 호출하다 보니 결과적으로 주문 데이터 개수(N)만큼 DB를 찌르는 추가 쿼리가 발생했다.
1
2
3
4
5
6
7
8
9
10
11
12
// 기존 orderService
for (Order order : orders) {
Payment payment = paymentRepository.findByOrder_OrderId(order.getOrderId())
.orElse(null);
Shipping shipping = shippingRepository.findByOrder_OrderId(order.getOrderId())
.orElse(null);
OrderHistoryResponse dto = OrderHistoryResponse.from(order, payment, shipping);
responseList.add(dto);
}
// Repository
Optional<도메인> findByOrder_OrderId(Long orderId);
N+1 문제 개선 과정
불필요한 네트워크 호출을 최소화하고 N+1 문제를 해결하기 위해, DB 쿼리 방식과 애플리케이션 메모리 상의 매핑 방식을 모두 리팩토링했다.
IN 절을 통한 다건 조회
먼저 조회된 Order 객체들로부터 orderId만 추출하여 리스트(orderIds)를 만들고, 이를 활용해 연관된 도메인 엔티티들을 IN 절로 한 번의 쿼리로 가져오도록 변경했다.
Shipping (FETCH JOIN 적용)
Shipping 엔티티는 하위에 ShippingHistory와 1:N 관계를 맺고 있다. 따라서 Shipping을 조회할 때 연관된 이력까지 한 번에 가져오기 위해 JOIN FETCH를 추가했다.
매핑 최적화
조회된 여러 도메인 객체들을 각각의 Order 객체와 매칭해 주어야 했다. 이를 이중 for문으로 처리하면 $O(N^2)$의 성능 저하가 발생하므로, Java Stream API의 Collectors.toMap()을 활용해 HashMap으로 변환했다. Key를 orderId로 설정하여 탐색 시간 복잡도를 $O(1)$로 단축했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// OrderService.java
List<Payment> payments = paymentRepository.findByOrder_OrderIdIn(orderIds);
List<Shipping> shippings = shippingRepository.findByOrder_OrderIdIn(orderIds);
Map<Long, Payment> paymentMap = payments.stream()
.collect(Collectors.toMap(p -> p.getOrder().getOrderId(), p -> p));
Map<Long, Shipping> shippingMap = shippings.stream()
.collect(Collectors.toMap(s -> s.getOrder().getOrderId(), s -> s));
List<OrderHistoryResponse> responseList = new ArrayList<>();
for (Order order : orders) {
Payment payment = paymentMap.get(order.getOrderId());
Shipping shipping = shippingMap.get(order.getOrderId());
OrderHistoryResponse dto = OrderHistoryResponse.from(order, payment, shipping);
responseList.add(dto);
}
return responseList;
추가로 조회된 주문 내역이 없을 때 빈 리스트가 IN절에 들어가면 SQL문법 오류가 발생할 수 있기 때문에 비어있을 땐 즉시 빈 리스트를 반환하도록 했다.
1
if (orderIds.isEmpty()) return new ArrayList<>();
Postman으로 성능 비교 테스트
최적화가 끝난 후, Postman을 통해 더미 데이터를 바탕으로 성능을 비교 측정했다.
GET /api/order/history?memberId=30
1차 테스트, 더미 데이터 4건
결과: 소량의 데이터에서는 시간 차이가 거의 없었고(약 50ms 증가), 오히려 IN 절 처리를 위한 오버헤드로 인해 속도가 미세하게 늘어난 것처럼 보였다. 이는 데이터가 너무 적어 발생한 오차 범위 내의 결과였다.
2차 테스트, 더미 데이터 100건
기존 로직 (N+1 발생): 주문 목록 조회(1) + 결제 조회(100) + 배송 조회(100) + 배송 이력 지연 로딩(100) 등으로 인해 301번의 쿼리 전송 / 1.36s (1360ms) 소요

개선 로직: 주문 상품 조회 + 결제 내역 조회 + 배송 및 이력 조회로 3번의 쿼리 전송 / 462.29ms 소요

결과: 결과적으로 301건의 쿼리를 3번으로 줄였으며, 1360ms에서 462ms로 약 66%의 조회 성능 개선을 달성했다.
Q&A
Q1.
IN절은 괄호 안의 해당하는 데이터들을 반환한다. (예:member_id IN (1, 2, 3)이면 1, 2, 3인 것 3개 반환) 그렇다면WHERE member_id <= 3으로 조회해도 될 텐데 왜IN절을 쓰는 걸까?
A1. 조회해야 하는 ID 값들이 1, 2, 3처럼 항상 연속적이라는 보장이 없기 때문이다. 실제 환경에서는 추출된 ID 값이 [1, 17, 30, 31]처럼 불연속적인 경우가 대부분이다. 또한 Spring Data JPA를 사용할 때, 서버에서 가지고 있는 List 형태의 컬렉션 값을 그대로 파라미터로 넘겨주면 프레임워크가 알아서 IN ( ? , ? , ? ) 형태로 쿼리에 바인딩해 주기 때문에 명확성과 가독성 측면에서도 매우 유리하다.
Q2. 프록시라는 개념을 트랜잭션(@Transactional)에서 처음 접했다. 트랜잭션 프록시처럼 시작할 때 열고 끝날 때 닫아주는 역할인 줄 알았는데, JPA에서의 프록시는 또 다른 의미의 가짜 객체라고 하네?
A2. 이름은 같지만 역할과 목적이 다르다.
- Spring AOP 프록시 (트랜잭션): 실제 객체의 메서드를 감싸서, 메서드 실행 전후에 트랜잭션 시작과 트랜잭션 커밋/롤백 등 부가적인 로직을 주입하기 위한 가짜 객체다.
- JPA 프록시 (지연 로딩): 연관된 엔티티의 데이터를 DB에서 당장 가져오지 않고, 가짜 객체를 채워 넣기 위한 용도다. 이후 실제로 그 데이터(필드)에 접근할 때 데이터베이스에 쿼리를 날려 진짜 데이터를 채워 넣는다(초기화).
Q3. 지연 로딩(Lazy Loading)을 했을 때의 장점이 뭐길래 기본으로 사용되는 걸까?
A3. 가장 큰 이유는 불필요한 데이터베이스 조회를 막아 성능을 극대화하기 위함이다. 즉시 로딩(EAGER)을 사용하면 비즈니스 로직에서 연관 데이터가 필요 없는 상황에서도 무조건 데이터를 끌고 오기 때문에 불필요한 로딩이 발생한다. 지연 로딩을 사용하면 코드에서 정말 필요로 할 때만 그 데이터를 조회하므로 쿼리 성능을 높일 수 있다. (참고: JPA에서 @OneToMany, @ManyToMany는 지연 로딩이 기본이지만, @ManyToOne과 @OneToOne은 즉시 로딩이 기본값이므로 실무에서는 성능 저하를 막기 위해 모든 연관 관계를 지연 로딩으로 명시해서 사용하는 것이 권장된다.)
Q4. 어차피 연관 데이터가 전부 다 필요하다면, 엔티티 설정에서 즉시 로딩(EAGER)을 쓰면 굳이
FETCH JOIN을 쓰지 않아도 되는 것 아닐까?
A4. 여기에는 두 가지 치명적인 문제가 있다.
- 전역 설정의 한계: 로딩 방식은 엔티티 클래스 자체에 설정되므로 일부 API에 맞춰 변경할 수 없다. 주문 정보만 필요한 경우에도 무조건 결제/배송 정보를 다 가져오는 비효율이 발생한다.
- JPQL에서의 N+1 문제 발생: 즉시 로딩을 설정해도
findAll()같은 JPQL을 실행할 때는 N+1 문제가 똑같이 발생한다. 부모 엔티티 리스트를 먼저 가져온 뒤, 연관된 엔티티가 EAGER인 것을 보고 그제야 데이터 개수(N)만큼 추가 쿼리를 날리기 때문이다. 반면FETCH JOIN은 처음부터 SQLJOIN문을 사용하여 한 번의 쿼리로 모든 데이터를 가져오므로 N+1 문제를 근본적으로 해결한다.
Q5. 결국
FETCH JOIN도 한 번에 다 가져오고, N+1도 나눠서 다 가져오는 건데 속도 차이가 이렇게 심한 이유가 뭘까? DB에 여러 번 왔다 갔다 하는 게 그렇게 오래 걸리나?
A5. 그렇다. 서버 애플리케이션과 데이터베이스는 물리적으로 분리되어 통신하는 구조다. 따라서 연결을 맺고 끊거나 쿼리를 날릴 때마다 네트워크 왕복 비용으로 인해 대기 시간이 압도적으로 길어진다. 또한 DB 입장에서도 쿼리를 받을 때마다 문법 검색, 실행 계획 수립, 디스크 탐색 등의 과정을 매번 반복해야 하므로 쿼리 처리 비용이 급격히 증가한다. 따라서 조인이나 IN 절을 통해 1번의 쿼리로 묶어서 보내는 것이 성능 면에서 훨씬 유리하다.
회고 및 배운 점
이번 트러블슈팅을 통해 반복문 내부에서 지연 로딩된 객체를 직접 탐색할 때 매번 DB 쿼리가 발생하는 N+1 문제가 네트워크와 DB에 얼마나 큰 부하를 주는지 체감할 수 있었다. 이를 해결하기 위해 도메인 객체를 직접 탐색하며 참조하는 대신, IN 절 쿼리로 필요한 연관 데이터를 한 번에 가져온 뒤 애플리케이션 메모리 단에서 Map을 활용해 직접 조립하는 방식이 훨씬 효율적인 최적화 기법임을 깊이 배웠다.

