JPA N+1 문제, 어디서 터지고 어떻게 막았나

약 2분

주문 목록 API 응답이 느려서 쿼리 로그를 켜 봤더니 요청 한 번에 SQL이 40개 넘게 나가고 있었습니다. 전형적인 N+1이었는데, 알고는 있었지만 실제로 마주치니 원인을 짚는 데 시간이 꽤 걸려서 정리해 둡니다.

문제가 된 코드

@Transactional(readOnly = true)
public List<OrderResponse> findAll() {
    return orderRepository.findAll().stream()
            .map(OrderResponse::from)
            .toList();
}

겉보기에는 문제가 없습니다. 문제는 OrderResponse.from() 안에 있었습니다.

public static OrderResponse from(Order order) {
    return new OrderResponse(
            order.getId(),
            order.getStatus(),
            // 여기서 Member를 건드리는 순간 프록시가 깨지며 SELECT가 나갑니다
            order.getMember().getName()
    );
}

OrderMember@ManyToOne(fetch = LAZY) 관계입니다. findAll() 로 주문 20건을 가져올 때 SQL은 1번 나가지만, 각 주문의 getMember().getName() 을 호출하는 순간 멤버를 가져오는 SELECT가 건당 한 번씩 추가로 나갑니다. 그래서 1 + 20 = 21번입니다.

해결 1: fetch join

가장 직접적인 방법은 처음부터 조인해서 한 번에 가져오는 것입니다.

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o JOIN FETCH o.member")
    List<Order> findAllWithMember();
}

이러면 SQL이 1번으로 줄어듭니다.

SELECT o.id, o.status, o.created_at, m.id, m.name
FROM orders o
INNER JOIN member m ON m.id = o.member_id;

다만 fetch join은 컬렉션(@OneToMany)에는 조심해서 써야 합니다. 컬렉션을 fetch join 하면 결과 행이 곱해지면서 페이징이 깨집니다. 하이버네이트가 경고 로그를 남기고 메모리에서 페이징을 시도하는데, 데이터가 많으면 그대로 사고입니다.

해결 2: batch fetch size

컬렉션까지 얽혀 있다면 이쪽이 더 안전합니다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

이 설정을 켜면 하이버네이트가 지연 로딩을 건당 SELECT로 처리하지 않고 IN 절로 묶어서 가져옵니다.

SELECT * FROM member WHERE id IN (1, 2, 3, ..., 20);

쿼리 수가 N개에서 N / batch_size 로 줄어듭니다. 전역 설정 하나로 대부분의 N+1을 완화할 수 있어서, 저는 이걸 먼저 깔아두고 문제가 남는 지점만 fetch join으로 잡는 쪽이 낫다고 판단했습니다.

정리

방법쿼리 수페이징언제
그대로1 + N가능쓰면 안 됨
fetch join1컬렉션이면 깨짐ToOne 관계
batch size1 + N/100가능컬렉션 포함, 기본값으로

가장 중요한 건 쿼리 로그를 켜 두는 것이었습니다. 안 보면 모릅니다.

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace