- Published on
Spring Boot JPA N+1 최적화 - Fetch Join·BatchSize
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 성능 이슈를 추적하다 보면 의외로 애플리케이션 코드가 아니라 ORM이 만들어내는 SQL 패턴에서 병목이 터지는 경우가 많습니다. 그중 대표가 JPA의 N+1 문제입니다. 개발 환경에서는 데이터가 적어 티가 안 나지만, 운영에서 트래픽과 데이터가 늘면 SELECT가 폭발하며 DB 커넥션, CPU, 네트워크를 동시에 갉아먹습니다.
이 글은 Spring Boot + Hibernate(JPA) 기준으로 N+1이 발생하는 조건을 정확히 짚고, 가장 실무적인 해결책인 fetch join과 @BatchSize(또는 Hibernate default_batch_fetch_size)를 어떻게 조합해야 안전한지 정리합니다.
운영 장애 원인 분석 관점의 글이 필요하다면, 비슷한 “원인 분해 + 체크리스트” 스타일로 작성된 Spring Security JWT 401 원인 - Bearer·aud·clock skew도 함께 참고하면 좋습니다.
N+1 문제란 무엇인가
N+1은 “부모를 조회하는 쿼리 1번 + 연관된 자식을 조회하는 쿼리 N번”이 발생하는 패턴입니다. JPA에서는 연관관계가 LAZY(지연 로딩)일 때 특히 자주 발생합니다.
예를 들어 Order가 Member(다대일), OrderItem(일대다)을 가진다고 가정해 봅시다.
예제 엔티티
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> orderItems = new ArrayList<>();
}
@Entity
public class OrderItem {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
private Item item;
}
N+1이 터지는 전형적인 코드
List<Order> orders = orderRepository.findAll();
for (Order order : orders) {
// 여기서 member 접근 시점에 order 수만큼 추가 쿼리 발생 가능
String name = order.getMember().getName();
}
findAll()이 Order를 한 번 조회하고, 루프에서 getMember()가 호출될 때마다 Member를 가져오기 위한 쿼리가 추가로 실행됩니다.
N+1이 발생하는 핵심 조건
1) 연관관계가 LAZY이고
지연 로딩은 기본적으로 프록시로 두고, 실제 접근 시점에 SQL을 날립니다.
2) 영속성 컨텍스트 범위 안에서 접근이 일어나며
트랜잭션 안에서 프록시를 초기화할 수 있어 쿼리가 실행됩니다.
3) 조회 결과가 컬렉션이거나 다수의 부모 row를 포함할 때
부모가 많을수록 추가 쿼리도 선형으로 늘어납니다.
해결책 1: Fetch Join으로 한 번에 가져오기
fetch join은 JPQL에서 연관 엔티티를 함께 조인하여 “즉시 로딩”으로 바꾸는 강력한 방법입니다. N+1을 원천적으로 차단할 수 있습니다.
다대일, 일대일은 fetch join을 우선 고려
다대일은 row 수가 늘지 않거나(부모 기준) 상대적으로 안전합니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select o from Order o join fetch o.member")
List<Order> findAllWithMember();
}
이렇게 조회하면 Order와 Member가 한 번의 쿼리로 로딩됩니다.
일대다 컬렉션 fetch join의 함정: 중복 row
Order와 OrderItem을 함께 fetch join하면 DB 결과는 조인으로 인해 row가 늘어납니다. 예를 들어 주문 1개에 아이템 3개면 주문 row가 3번 반복됩니다.
이를 JPA는 엔티티 식별자로 중복을 제거해 주지만, SQL 관점에서는 결과 row가 많아져 비용이 커질 수 있습니다.
컬렉션 fetch join 시 distinct 사용
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select distinct o from Order o join fetch o.orderItems")
List<Order> findAllWithItems();
}
JPQL의 distinct는 SQL DISTINCT뿐 아니라 “엔티티 중복 제거”에도 영향을 줍니다.
컬렉션 fetch join과 페이징은 같이 쓰기 어렵다
가장 중요한 제약입니다. 컬렉션을 fetch join한 상태에서 Pageable을 적용하면 Hibernate가 메모리에서 페이징을 하거나(경고 로그), 결과가 왜곡될 수 있습니다.
즉, 아래 같은 코드는 운영에서 위험합니다.
@Query("select distinct o from Order o join fetch o.orderItems")
Page<Order> findPageWithItems(Pageable pageable);
컬렉션 페이징이 필요한 경우는 fetch join 대신 @BatchSize(또는 배치 페치 사이즈 설정)가 더 실전적입니다.
해결책 2: Batch Fetching으로 쿼리 수를 줄이기
@BatchSize는 지연 로딩을 유지하되, 프록시 초기화 시 여러 건을 IN 조건으로 묶어서 가져오게 합니다.
즉 N+1을 1 + N에서 1 + (N / batchSize) 수준으로 줄입니다.
글로벌 설정: default_batch_fetch_size
Spring Boot에서 Hibernate 속성으로 설정하는 방식이 운영에서는 가장 관리가 쉽습니다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
이 설정은 다대일, 일대다 모두에 적용될 수 있어 “전반적인 N+1 완화”에 효과적입니다.
엔티티별 설정: @BatchSize
특정 연관관계에만 적용하고 싶다면 @BatchSize를 사용합니다.
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
@BatchSize(size = 100)
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> orderItems = new ArrayList<>();
}
또는 컬렉션이 아니라 엔티티(다대일 대상) 쪽에 걸 수도 있습니다.
@Entity
@BatchSize(size = 100)
public class Member {
@Id @GeneratedValue
private Long id;
}
BatchSize가 실제로 만드는 SQL 패턴
주문을 100개 조회했고, 각 주문의 member를 접근한다고 합시다.
- 배치 미적용:
member조회 쿼리 100번 default_batch_fetch_size=100:member조회가where id in (...)형태로 1번 또는 몇 번으로 합쳐짐
이 방식은 컬렉션 페이징이 필요할 때 특히 강력합니다.
Fetch Join vs BatchSize: 선택 기준
fetch join이 더 좋은 경우
- 화면/API가 특정 연관을 반드시 필요로 함
- 결과 건수가 제한적임(예: 단건 상세 조회)
- 다대일, 일대일 위주
예: 주문 상세에서 Order + Member는 fetch join으로 한 번에 가져오는 편이 깔끔합니다.
BatchSize가 더 좋은 경우
- 목록 API에서 페이징이 필요함
- 컬렉션 연관(일대다)을 함께 보여줘야 함
- fetch join으로 row 폭증이 우려됨
예: 주문 목록 Page<Order>를 가져오고, 각 주문의 아이템 개수나 일부 아이템 정보가 필요할 때.
실전 조합 패턴: ToOne은 fetch join, 컬렉션은 BatchSize
가장 흔한 최적화 조합은 다음입니다.
ToOne(다대일/일대일)은 fetch join으로 즉시 로딩- 컬렉션(
ToMany)은 지연 로딩 유지 + 배치 페치로 완화
예시: 주문 목록 API
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select o from Order o join fetch o.member")
List<Order> findAllWithMember();
}
그리고 컬렉션은 default_batch_fetch_size 또는 @BatchSize로 최적화합니다.
이렇게 하면
- 주문과 회원은 한 번에
- 주문 아이템은 접근 시 배치로
가 되어, 쿼리 수와 데이터 폭증을 균형 있게 제어할 수 있습니다.
DTO 직접 조회로 N+1을 구조적으로 회피하기
화면/API가 엔티티 그래프 전체를 필요로 하지 않는다면, JPQL에서 DTO로 바로 조회하는 것이 더 효율적일 때가 많습니다.
예: 주문 목록에 필요한 필드만 조회
public record OrderRow(Long orderId, String memberName) {}
public interface OrderQueryRepository {
@Query("select new com.example.OrderRow(o.id, m.name) " +
"from Order o join o.member m")
List<OrderRow> findOrderRows();
}
이 방식은
- 영속성 컨텍스트 관리 비용이 줄고
- 불필요한 연관 로딩이 사라지며
- N+1이 설계상 발생하기 어려워집니다.
다만 변경 감지가 필요하거나 복잡한 도메인 로직을 엔티티에 위임하는 구조라면, DTO 조회는 읽기 전용 쿼리 레이어에서 제한적으로 쓰는 편이 안전합니다.
N+1 진단 체크리스트
1) SQL 로그로 패턴을 먼저 확인
운영에서는 APM이 가장 좋지만, 최소한 로컬/스테이징에서 SQL 로그로 select 반복 패턴을 확인해야 합니다.
spring:
jpa:
properties:
hibernate:
format_sql: true
use_sql_comments: true
logging:
level:
org.hibernate.SQL: debug
파라미터까지 보고 싶다면 다음 로거를 추가로 켭니다(환경에 따라 로그가 매우 많아질 수 있음).
logging:
level:
org.hibernate.orm.jdbc.bind: trace
2) 트랜잭션 경계와 OSIV를 점검
OSIV(Open Session In View)가 켜져 있으면 컨트롤러/뷰 렌더링 단계에서 지연 로딩이 발생해 N+1이 “더 늦게” 터질 수 있습니다. 원인 파악이 어려워지므로, API 서버라면 OSIV를 끄고 서비스 계층에서 필요한 연관을 명시적으로 로딩하는 전략을 권장합니다.
spring:
jpa:
open-in-view: false
3) 컬렉션 fetch join을 남발하지 않았는지 확인
- 페이징이 필요한데 컬렉션 fetch join을 사용
distinct없이 조인으로 row 폭증- 여러 컬렉션을 한 번에 fetch join 시도
이 조합은 성능과 정확성 모두에서 위험합니다.
결론
JPA N+1 최적화는 “무조건 fetch join”이 아니라, 조회 목적과 데이터 형태에 맞춰 도구를 나누는 게 핵심입니다.
- 단건 상세나
ToOne위주의 조회는fetch join으로 단번에 해결 - 목록 + 페이징 + 컬렉션 접근이 섞이면
default_batch_fetch_size또는@BatchSize로 쿼리 수를 줄이고, 필요한 경우 DTO 조회로 읽기 모델을 분리
이 원칙만 지켜도 운영에서 흔히 보는 SELECT 폭발을 상당 부분 예방할 수 있습니다.