- Published on
Spring Boot 3 JPA N+1 폭발 해결 - fetch join·EntityGraph
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 느려졌는데 CPU도 여유롭고, DB도 죽을 만큼 바쁘진 않아 보이는데 응답 시간이 꾸준히 늘어난다면 JPA의 N+1을 의심해볼 만합니다. 특히 Spring Boot 3(= Hibernate 6 계열)로 올라오면서 SQL 로그/통계가 더 잘 보이기도 하고, 페이징/컬렉션 로딩 전략을 잘못 잡았을 때 체감 성능 저하가 더 크게 나타나는 경우가 많습니다.
이 글에서는 Spring Boot 3 환경에서 N+1이 왜 생기는지, 어디서 “폭발”하는지, 그리고 fetch join, @EntityGraph, 배치 로딩(@BatchSize, hibernate.default_batch_fetch_size)로 어떻게 정리하는지까지 실무 중심으로 정리합니다.
참고로 성능 이슈는 원인 추적이 절반입니다. 운영 환경에서 문제가 재현되지 않거나 로그가 부족하면 진단이 길어집니다. 이런 점에서 관측/진단을 체계화하는 습관이 중요한데, 인프라/운영 진단 관점은 systemd 서비스가 자꾸 재시작될 때 7단계 진단 같은 글의 접근 방식도 꽤 도움이 됩니다.
N+1이란 무엇이고, 왜 Spring Boot 3에서 더 자주 체감될까
N+1은 “목록 1번 조회 + 각 행마다 연관 엔티티를 추가로 N번 조회” 패턴입니다. 예를 들어 Order 목록을 100개 가져온 뒤, 각 주문의 member를 접근하는 순간 지연 로딩이 발동하면 member 조회가 100번 더 나가 총 101번 쿼리가 됩니다.
Spring Boot 3/Hibernate 6에서 N+1이 새로 생긴다기보다, 다음 조건에서 더 자주 체감됩니다.
- API 응답 DTO를 만들며 엔티티 그래프를 순회하는 코드가 많음
- Jackson 직렬화 중 프록시 접근으로 지연 로딩이 연쇄적으로 발생
- 페이징 + 컬렉션 로딩을 섞어 잘못된 해결책을 적용(예: 컬렉션
fetch join으로 페이징) toString, 로깅, 디버깅 중 연관 필드 접근으로 의도치 않은 로딩
핵심은 “연관을 언제 로딩할지”를 명시하지 않으면, 대부분의 경우 런타임에 접근 시점에 로딩되며 그게 곧 N+1로 이어진다는 점입니다.
재현용 예제: 전형적인 N+1 폭발 코드
엔티티 모델
아래는 주문-회원-주문상품-상품의 흔한 구조입니다.
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> orderItems = new ArrayList<>();
// getter
}
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
}
@Entity
public class OrderItem {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id")
private Item item;
private int orderPrice;
private int count;
}
@Entity
public class Item {
@Id @GeneratedValue
private Long id;
private String name;
}
문제 코드(서비스/컨트롤러)
@Transactional(readOnly = true)
public List<OrderDto> findOrders() {
List<Order> orders = orderRepository.findAll();
return orders.stream()
.map(o -> new OrderDto(
o.getId(),
o.getMember().getName(), // 여기서 member 지연 로딩
o.getOrderItems().stream() // 여기서 orderItems 지연 로딩
.map(oi -> oi.getItem().getName()) // 여기서 item 지연 로딩
.toList()
))
.toList();
}
findAll()은 주문만 1번getMember()로 주문 개수만큼 추가 쿼리getOrderItems()로 주문 개수만큼 추가 쿼리getItem()로 주문상품 개수만큼 추가 쿼리
데이터가 조금만 늘어도 쿼리가 기하급수적으로 늘어납니다.
1차 처방: fetch join으로 필요한 연관을 “한 번에” 가져오기
fetch join은 연관 엔티티를 즉시 로딩으로 강제하며, SQL 조인으로 한 번에 가져옵니다. 가장 직관적이고, 조회 요구사항이 명확할 때 강력합니다.
ManyToOne/OneToOne은 fetch join이 특히 잘 맞는다
주문 목록에서 회원 이름이 필요하다면 다음처럼 해결합니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select o from Order o join fetch o.member")
List<Order> findAllWithMember();
}
이렇게 하면 Order를 순회하며 getMember()를 호출해도 추가 쿼리가 발생하지 않습니다.
컬렉션 fetch join은 주의: 중복 row와 페이징 문제
Order와 orderItems까지 같이 당겨오면 더 좋아 보이지만, 컬렉션(OneToMany) fetch join은 다음 문제가 생깁니다.
- 조인 결과가
Order기준으로 row가 뻥튀기 됨(주문 1개에 주문상품 5개면 row 5개) - JPA는 객체 그래프를 맞추려고 중복 제거를 수행하지만, DB에서 내려오는 데이터량은 증가
- 컬렉션 fetch join + 페이징(
Pageable)은 정확한 페이징이 깨질 수 있음
그래도 “페이징이 필요 없고”, “상대적으로 작은 집합”이라면 아래처럼 단번에 해결할 수 있습니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select distinct o from Order o " +
"join fetch o.member " +
"join fetch o.orderItems oi " +
"join fetch oi.item")
List<Order> findAllWithItems();
}
여기서 distinct는 JPQL 레벨에서 중복 엔티티를 줄이는 데 도움을 줍니다(단, DB DISTINCT와 동일 의미로만 보면 오해가 생길 수 있습니다).
실무 팁: “목록 화면”은 보통 두 단계가 안전하다
- 1단계:
Order+member정도만 fetch join으로 가져오기 - 2단계: 상세 진입 시에만 컬렉션을 최적화해서 로딩
이렇게 화면/API 요구사항 단위로 조회를 분리하면, 페이징/정렬 요구사항과도 충돌이 줄어듭니다.
2차 처방: @EntityGraph로 선언적으로 로딩 그래프 지정
@EntityGraph는 “이 쿼리에서는 어떤 연관을 함께 로딩할지”를 애너테이션으로 선언합니다. JPQL을 길게 쓰지 않아도 되고, 스프링 데이터 JPA 메서드 네이밍과도 잘 섞입니다.
예시: 주문 목록에서 member만 같이 로딩
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"member"})
List<Order> findByIdGreaterThan(Long id);
}
이 경우 내부적으로 fetch join에 준하는 전략으로 연관을 미리 로딩합니다.
중첩 연관도 가능하지만, 과용은 금물
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"member", "orderItems", "orderItems.item"})
@Query("select o from Order o")
List<Order> findAllGraph();
}
다만 컬렉션을 포함하는 순간 앞서 말한 “row 뻥튀기/페이징 이슈”가 그대로 따라옵니다. @EntityGraph는 편의 기능이지, 컬렉션 fetch join의 물리적 한계를 없애주진 않습니다.
3차 처방: 배치 로딩으로 N+1을 “1+1 또는 1+K”로 완화
fetch join이 항상 정답은 아닙니다.
- 페이징이 필요하다
- 컬렉션까지 한 번에 가져오면 데이터량이 너무 커진다
- 화면에서 연관을 “가끔”만 쓰는데 매번 조인하면 낭비다
이럴 때 배치 로딩이 현실적인 타협안입니다. 지연 로딩은 유지하되, 프록시 초기화 시점에 IN 쿼리로 모아서 가져옵니다.
설정: hibernate.default_batch_fetch_size
application.yml 예시입니다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
이렇게 하면 예를 들어 orders 100개를 순회하며 order.getMember()를 호출할 때, member를 100번 단건 조회하는 대신 IN 절로 묶어서 몇 번에 나눠 가져옵니다(배치 크기와 DB 파라미터 제한에 따라 분할).
엔티티 단위로 @BatchSize 지정
전역 설정이 부담스럽다면 필요한 연관에만 적용할 수도 있습니다.
@Entity
public class Order {
@BatchSize(size = 100)
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> orderItems = new ArrayList<>();
}
@Entity
public class OrderItem {
@BatchSize(size = 100)
@ManyToOne(fetch = FetchType.LAZY)
private Item item;
}
배치 로딩은 “쿼리 수를 줄이되, 조인으로 row가 폭발하는 문제를 피하고 싶을 때” 특히 유효합니다.
Spring Boot 3에서 자주 하는 실수 5가지
1) OSIV에 기대서 컨트롤러에서 엔티티를 막 순회
Open Session In View가 켜져 있으면(기본값은 프로젝트 설정에 따라 다름) 컨트롤러/뷰 렌더링 단계에서도 지연 로딩이 가능해져서, 문제를 더 늦게 발견합니다. API 서버라면 OSIV를 끄고 서비스 계층에서 필요한 연관을 로딩한 뒤 DTO로 반환하는 편이 안전합니다.
spring:
jpa:
open-in-view: false
2) 컬렉션 fetch join으로 페이징을 해결하려고 함
컬렉션을 fetch join한 쿼리에 Pageable을 붙이면 메모리 페이징으로 바뀌거나, 결과가 왜곡될 수 있습니다. 목록은 ManyToOne까지만 fetch join하고, 컬렉션은 배치 로딩/별도 조회로 푸는 쪽이 일반적으로 안전합니다.
3) DTO 변환 중 “무심코” 연관 접근
DTO 생성자가 엔티티 그래프를 깊게 타고 들어가면 그 자체가 N+1 트리거가 됩니다. “이 API는 어디까지 로딩해야 하는가”를 먼저 정하고, 그에 맞는 조회 메서드를 별도로 두는 게 좋습니다.
4) toString()/로깅에서 연관 필드 접근
엔티티에 toString()을 자동 생성해두고 연관 필드를 포함시키면, 로그 한 줄 찍는 순간 쿼리가 우수수 나갈 수 있습니다. 연관은 제외하거나 식별자만 찍는 습관이 필요합니다.
5) “항상 EAGER로 바꾸면 해결”이라는 착각
FetchType.EAGER는 문제를 숨기거나 더 큰 문제(예상치 못한 조인/쿼리)를 만들기 쉽습니다. 기본은 LAZY로 두고, 조회 단위에서 fetch 전략을 제어하는 것이 일반적으로 더 낫습니다.
권장 조합: 상황별로 이렇게 고르면 실패 확률이 낮다
- 목록 API + 페이징 필요
ManyToOne은 fetch join 또는@EntityGraph- 컬렉션은 지연 로딩 + 배치 로딩
- 상세 API(단건) + 연관을 깊게 보여줌
- 컬렉션 포함 fetch join을 고려(데이터량이 통제 가능할 때)
- 또는 “ID 목록 조회 후 2차 조회” 패턴으로 분리
- 관리자/백오피스에서 엑셀 다운로드처럼 대량 출력
- fetch join 남발보다 전용 쿼리/DTO 프로젝션도 검토
- JDBC 템플릿/Querydsl로 필요한 컬럼만 뽑는 것이 더 나을 때가 많음
SQL을 눈으로 확인하는 체크리스트
- SQL 로그를 켜고, API 1회 호출에 쿼리가 몇 번 나가는지 센다
- 동일한 형태의
select ... where id = ?가 반복되면N+1가능성이 높다 IN쿼리로 바뀌는지 확인해 배치 로딩이 먹는지 본다- 조인으로 row가 과도하게 늘어나지 않는지(전송 데이터량)도 함께 본다
운영에서 로그/진단은 성능 최적화의 시작점입니다. 배포 이후 이상 징후를 빨리 잡아내는 관점에서는 Argo CD Sync Failed - drift·Helm 값·RBAC 해결처럼 “상태 불일치”를 빠르게 좁혀가는 접근도 같이 익혀두면 좋습니다.
마무리: N+1은 ‘나쁜 코드’가 아니라 ‘불명확한 로딩 전략’의 결과
Spring Boot 3에서 JPA N+1은 여전히 가장 흔한 성능 함정입니다. 하지만 해결책도 명확합니다.
- 단순하고 확실한 해결:
fetch join - 선언적이고 재사용성 좋은 해결:
@EntityGraph - 페이징/대량 데이터에 현실적인 완화: 배치 로딩(
default_batch_fetch_size,@BatchSize)
중요한 건 “이 API가 어떤 연관을 얼마나 필요로 하는지”를 먼저 정의하고, 그에 맞춰 조회 메서드를 분리하는 것입니다. 그 원칙만 지키면 N+1은 폭발이 아니라, 통제 가능한 비용이 됩니다.
추가로, 데이터베이스 자체의 성능 저하(예: 테이블 bloat, vacuum 지연)가 겹치면 N+1의 체감이 훨씬 커질 수 있습니다. DB 쪽 이슈가 의심된다면 PostgreSQL autovacuum 지연으로 테이블 bloat 해결도 함께 점검해보는 것을 권합니다.