- Published on
Spring Boot JPA N+1 해결 - fetch join·EntityGraph
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 성능 튜닝을 하다 보면 “쿼리는 한 번만 날렸는데 왜 DB 로그엔 수십 번이 찍히지?” 같은 상황을 자주 마주칩니다. Spring Boot + JPA(Hibernate)에서 대표적인 원인이 바로 N+1 문제입니다. 이 글에서는 N+1이 발생하는 정확한 메커니즘을 짚고, 실무에서 가장 많이 쓰는 해결책인 fetch join 과 EntityGraph 를 중심으로, 페이징/중복/컬렉션 로딩 한계까지 안전하게 정리합니다.
또한 트랜잭션 경계가 꼬이면 LAZY 로딩이 예상치 못하게 동작하거나, 반대로 서비스 레이어에서 트랜잭션이 적용되지 않아 디버깅이 어려워질 수 있습니다. 관련해서는 Spring Boot 3.2에서 @Transactional 무시되는 7가지도 함께 참고하면 좋습니다.
N+1 문제란 무엇인가
N+1은 “부모 엔티티 목록을 조회하는 쿼리 1번” + “각 부모에 대해 연관 엔티티를 로딩하는 쿼리 N번”이 추가로 발생하는 현상입니다.
예를 들어 Order 가 Member 를 ManyToOne(fetch = LAZY)로 참조하고, 화면에서 주문 목록과 함께 주문자 이름을 보여주려 한다고 가정해 봅시다.
- 주문 목록 조회:
select * from orders ...(1번) - 각 주문의
order.getMember().getName()접근 시점에 멤버 조회가 주문 수만큼 발생: N번
왜 LAZY인데도 문제가 되나
LAZY는 “바로 로딩하지 않는다”일 뿐, “절대 로딩하지 않는다”가 아닙니다. 프록시가 실제 필드 접근 시점에 초기화되면서 추가 쿼리가 나갑니다.
특히 다음 패턴에서 N+1이 잘 터집니다.
- 컨트롤러/서비스에서 엔티티를 그대로 반환하거나 DTO 변환 과정에서 연관 필드를 건드리는 경우
toString()/로깅/디버거가 프록시 필드를 건드리는 경우- 템플릿 렌더링(JSP/Thymeleaf)에서 연관 접근이 발생하는 경우
재현 예제: 주문 목록에서 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;
}
문제를 만드는 조회 코드
public List<OrderDto> listOrders() {
List<Order> orders = orderRepository.findAll();
return orders.stream()
.map(o -> new OrderDto(
o.getId(),
o.getMember().getName() // 여기서 주문 수만큼 추가 쿼리
))
.toList();
}
findAll()은 주문만 가져오고, getMember() 접근 시점에 멤버를 로딩하므로 N+1이 발생합니다.
해결 1: fetch join으로 한 번에 당겨오기
fetch join은 JPQL에서 연관 엔티티를 “같은 쿼리로 조인해서 즉시 로딩”하도록 강제하는 방법입니다.
ManyToOne/OneToOne은 fetch join이 가장 깔끔함
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select o from Order o join fetch o.member")
List<Order> findAllWithMember();
}
이제 DTO 변환 시 o.getMember().getName()을 호출해도 추가 쿼리가 발생하지 않습니다.
컬렉션(OneToMany) fetch join의 함정: 중복과 페이징
Order 와 orderItems를 함께 가져오고 싶어서 다음을 작성하면 어떨까요.
@Query("select o from Order o join fetch o.orderItems")
List<Order> findAllWithItems();
이 경우 SQL은 주문과 주문아이템이 조인된 결과를 반환하므로, 주문이 아이템 개수만큼 “행이 뻥튀기”됩니다.
- JPA는 영속성 컨텍스트에서 같은
Order식별자를 하나로 합치지만 - 결과 리스트는 중복
Order가 섞여 나올 수 있고 - 무엇보다
Pageable페이징이 사실상 깨집니다(조인으로 행 수가 늘어나 DB 레벨 페이징이 의미가 없어짐)
중복 제거는 distinct로 완화
@Query("select distinct o from Order o join fetch o.orderItems")
List<Order> findAllDistinctWithItems();
JPQL의 distinct는 SQL distinct + JPA 레벨 중복 제거 힌트 역할을 합니다. 다만 페이징 문제는 여전히 남습니다.
페이징이 필요할 때의 권장 패턴
컬렉션까지 한 번에 페치 조인하면서 페이징을 안정적으로 하기는 어렵습니다. 실무에서는 보통 아래 중 하나를 선택합니다.
- 목록 화면:
ManyToOne만 fetch join으로 해결하고 컬렉션은 필요 시 별도 조회 - 상세 화면: 단건 조회에서 컬렉션 fetch join 사용(페이징 필요 없음)
- 컬렉션은 배치 로딩으로 N+1을 “N/batch + 1” 수준으로 줄임
배치 로딩은 아래에서 다룹니다.
해결 2: EntityGraph로 선언적으로 로딩 그래프 제어
EntityGraph는 “어떤 연관을 즉시 로딩할지”를 리포지토리 메서드 단에서 선언적으로 지정하는 방식입니다. JPQL을 직접 쓰지 않고도 fetch join과 유사한 효과를 얻을 수 있습니다.
어노테이션 기반 EntityGraph
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"member"})
List<Order> findAll();
}
위처럼 동일한 findAll()에 그래프를 붙이면, member는 즉시 로딩됩니다.
NamedEntityGraph로 재사용하기
여러 곳에서 동일한 로딩 정책을 쓰면 엔티티에 그래프를 정의해두는 편이 관리가 쉽습니다.
@NamedEntityGraph(
name = "Order.withMember",
attributeNodes = @NamedAttributeNode("member")
)
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
}
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(value = "Order.withMember")
List<Order> findByIdIn(List<Long> ids);
}
fetch join vs EntityGraph: 무엇을 언제 쓰나
fetch join
- 장점: JPQL로 조인 형태를 명확히 제어 가능, 복잡한 조건/정렬/필터링에 유리
- 단점: 쿼리 문자열 관리 부담, 컬렉션 페치 조인과 페이징 충돌
EntityGraph
- 장점: 리포지토리 메서드에 로딩 정책을 선언적으로 부여, 쿼리 오염이 적음
- 단점: 조인 방식/세부 최적화는 Hibernate 구현체에 의존하는 면이 있음, 복잡한 동적 그래프는 코드가 장황해질 수 있음
정리하면 “단순히 연관 몇 개를 더 로딩”하는 목적이면 EntityGraph가 깔끔하고, “조인 조건/정렬/필터를 세밀하게 통제”해야 하면 fetch join이 더 직관적입니다.
컬렉션 N+1의 현실적 해법: 배치 로딩(@BatchSize, default_batch_fetch_size)
컬렉션을 무리하게 fetch join으로 끌고 오면 페이징/중복 문제가 생깁니다. 이때 많이 쓰는 절충안이 배치 로딩입니다.
배치 로딩은 LAZY 로딩이 발생하더라도, Hibernate가 여러 프록시/컬렉션을 모아서 in 쿼리로 한 번에 가져오는 최적화입니다.
설정 1) 전역 설정
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
설정 2) 엔티티/컬렉션 단위 설정
@BatchSize(size = 100)
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> orderItems = new ArrayList<>();
이렇게 하면 Order 100개에 대한 orderItems를 각각 쿼리 100번 날리는 대신, 대략 다음처럼 줄어듭니다.
- 주문 1번
- 주문아이템
in조회 몇 번(배치 크기에 따라)
즉 “N+1을 1+N에서 1+N/100 수준”으로 낮추는 방식입니다.
실무 체크리스트: N+1을 예방하는 습관
1) 엔티티를 API 응답으로 그대로 내보내지 않기
엔티티 직렬화 과정에서 LAZY 초기화가 연쇄적으로 터지기 쉽습니다. DTO로 변환하고, 필요한 연관만 의도적으로 로딩하세요.
2) 화면/유스케이스 단위로 조회 모델을 분리하기
- 목록용 조회:
Order+Member정도만 - 상세용 조회: 컬렉션 포함
조회 모델을 분리하면 fetch join/EntityGraph 전략도 단순해집니다.
3) 트랜잭션 경계를 명확히 하기
OSIV를 켠 상태에서는 컨트롤러까지 영속성 컨텍스트가 살아 있어 “나도 모르게 LAZY 로딩”이 발생합니다. 반대로 OSIV를 끄면 서비스 밖에서 LAZY 접근 시 LazyInitializationException이 터집니다.
둘 중 무엇이 정답이라기보다, 팀의 정책에 맞게 “어디서 로딩을 끝낼지”를 합의해야 합니다. 트랜잭션이 기대대로 적용되지 않는 케이스는 Spring Boot 3.2에서 @Transactional 무시되는 7가지에서 자주 겪는 함정을 잘 정리해 두었습니다.
4) 로그로 쿼리 개수를 항상 확인하기
개발/스테이징에서만이라도 SQL 로그와 실행 횟수를 확인하면 N+1은 조기에 잡힙니다.
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
결론: 추천 조합
ManyToOne/OneToOne의 N+1- 1순위: fetch join 또는 EntityGraph로 즉시 로딩
OneToMany/ManyToMany컬렉션의 N+1- 상세 단건: 컬렉션 fetch join 고려
- 목록 + 페이징: 무리한 컬렉션 fetch join은 피하고, 배치 로딩(
default_batch_fetch_size)으로 완화
공통
- DTO 기반 응답, 유스케이스 단위 조회 분리, 트랜잭션 경계 명확화
이 조합으로 접근하면 “쿼리 수 폭발”을 막으면서도 페이징/중복/유지보수성까지 균형 있게 가져갈 수 있습니다.