Published on

Spring Boot JPA N+1 해결 - fetch join·EntityGraph

Authors

서버 성능 튜닝을 하다 보면 “쿼리는 한 번만 날렸는데 왜 DB 로그엔 수십 번이 찍히지?” 같은 상황을 자주 마주칩니다. Spring Boot + JPA(Hibernate)에서 대표적인 원인이 바로 N+1 문제입니다. 이 글에서는 N+1이 발생하는 정확한 메커니즘을 짚고, 실무에서 가장 많이 쓰는 해결책인 fetch joinEntityGraph 를 중심으로, 페이징/중복/컬렉션 로딩 한계까지 안전하게 정리합니다.

또한 트랜잭션 경계가 꼬이면 LAZY 로딩이 예상치 못하게 동작하거나, 반대로 서비스 레이어에서 트랜잭션이 적용되지 않아 디버깅이 어려워질 수 있습니다. 관련해서는 Spring Boot 3.2에서 @Transactional 무시되는 7가지도 함께 참고하면 좋습니다.

N+1 문제란 무엇인가

N+1은 “부모 엔티티 목록을 조회하는 쿼리 1번” + “각 부모에 대해 연관 엔티티를 로딩하는 쿼리 N번”이 추가로 발생하는 현상입니다.

예를 들어 OrderMemberManyToOne(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의 함정: 중복과 페이징

OrderorderItems를 함께 가져오고 싶어서 다음을 작성하면 어떨까요.

@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 레벨 중복 제거 힌트 역할을 합니다. 다만 페이징 문제는 여전히 남습니다.

페이징이 필요할 때의 권장 패턴

컬렉션까지 한 번에 페치 조인하면서 페이징을 안정적으로 하기는 어렵습니다. 실무에서는 보통 아래 중 하나를 선택합니다.

  1. 목록 화면: ManyToOne만 fetch join으로 해결하고 컬렉션은 필요 시 별도 조회
  2. 상세 화면: 단건 조회에서 컬렉션 fetch join 사용(페이징 필요 없음)
  3. 컬렉션은 배치 로딩으로 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 기반 응답, 유스케이스 단위 조회 분리, 트랜잭션 경계 명확화

이 조합으로 접근하면 “쿼리 수 폭발”을 막으면서도 페이징/중복/유지보수성까지 균형 있게 가져갈 수 있습니다.