본문 바로가기
Java & Spring

연관관계 주인/지연로딩 N+1 체크리스트

by yamoojin83 2025. 10. 2.

연관관계 주인/지연로딩 N+1 체크리스트

JPA/Hibernate로 도메인 모델을 설계할 때 가장 많이 실수하는 두 가지가 있습니다. 첫째, 연관관계의 주인(owner)을 잘못 잡아 업데이트가 반영되지 않는 문제, 둘째, 지연로딩(LAZY)로 인한 N+1 쿼리 폭증입니다. 이 글에서는 “문제 → 원인 → 해결” 순서로 체크리스트를 정리하고, 실전 코드와 함께 안전한 패턴을 제시합니다.

도메인 예시


@Entity
class Member {
  @Id @GeneratedValue Long id;
  String name;

  // 읽기전용: mappedBy = "member" → 주인은 Order.member
  @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
  List<Order> orders = new ArrayList<>();
}

@Entity
class Order {
  @Id @GeneratedValue Long id;

  // 주인(외래키 보유): Order.member
  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "member_id")
  Member member;

  @OneToMany(mappedBy = "order", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
  List<OrderItem> items = new ArrayList<>();

  void addItem(Product p, int qty) {
    OrderItem oi = new OrderItem(this, p, qty);
    items.add(oi);
  }
}

@Entity
class OrderItem {
  @Id @GeneratedValue Long id;

  @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name="order_id")
  Order order;

  @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name="product_id")
  Product product;

  int quantity;

  protected OrderItem() {}
  public OrderItem(Order order, Product product, int quantity) {
    this.order = order; this.product = product; this.quantity = quantity;
  }
}

@Entity
class Product {
  @Id @GeneratedValue Long id;
  String name;
  int price;
}

체크리스트: 연관관계 주인

  • 외래키를 가진 쪽이 항상 주인입니다. (예: Order.member)
  • mappedBy반대편 읽기전용 매핑입니다. mappedBy 쪽에 값을 세팅해도 DB 업데이트가 일어나지 않습니다.
  • 양방향일 때는 연관관계 편의 메서드를 한 곳에 모아 일관성 유지(order.setMember(m) 등).
  • equals/hashCode식별자 기반으로 제한(특히 컬렉션 키 사용 시).
  • 컬렉션은 new ArrayList<>()로 즉시 초기화하여 NPE와 하이버네이트 프록시 문제 회피.

문제: N+1 쿼리

memberRepository.findAll() 호출 후 각 멤버의 주문을 순회하면, 멤버 1건 + 주문 N건으로 총 N+1 쿼리가 나갑니다. 지연로딩 자체가 문제는 아니며, “순회하며 매번 접근”하는 패턴이 N+1을 유발합니다.

해결 1: XToOne은 fetch join


@Query("select o from Order o join fetch o.member where o.id in :ids")
List<Order> findByIdInWithMember(@Param("ids") List<Long> ids);

@ManyToOne, @OneToOne 같은 XToOne은 fetch join으로 묶어도 결과 row 수가 크게 늘지 않습니다. 페이징에도 비교적 안전합니다.

해결 2: 컬렉션은 BatchSize 또는 EntityGraph


@Entity @BatchSize(size = 100)
class Member { ... }

@EntityGraph(attributePaths = {"items"})
@Query("select o from Order o where o.member.id = :memberId")
List<Order> findAllByMemberId(@Param("memberId") Long memberId);

컬렉션(@OneToMany)을 fetch join으로 한 번에 가져오면 중복 row가 늘어나 페이징이 깨질 수 있습니다. 이때는 배치 사이즈(IN 쿼리로 묶음 조회)나 엔티티 그래프를 조합하세요.

해결 3: 조회 전용은 DTO 프로젝션


@Query("select new com.example.api.OrderView(o.id, m.name, o.createdAt) " +
       "from Order o join o.member m where o.createdAt > :from")
List<OrderView> findRecent(@Param("from") LocalDateTime from);

복잡한 화면은 엔티티 대신 DTO 투영을 사용해 불필요한 연관 초기화를 막고, SQL도 간결하게 유지합니다.

페이징 주의

  • 컬렉션 fetch join + Pageable은 카디널리티 폭증으로 잘리지 않는 문제가 생깁니다.
  • 대신 Page<T>에서 countQuery를 분리하거나, Slice로 대체하세요.

로그와 진단


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

개발 환경에서는 바인딩 파라미터까지 로그를 보고 N+1을 테스트로 검출하세요.

FAQ

Q. EAGER로 바꾸면 N+1이 사라지나요?
A. 대부분의 경우 더 큰 문제를 만듭니다. 필요한 화면에서만 fetch join/그래프/DTO로 최적화하세요.

Q. 컬렉션 fetch join을 페이징과 함께 써도 되나요?
A. 권장하지 않습니다. distinct로 중복을 줄여도 DB/네트워크 비용이 큽니다.

 

 

 

👉 1편: 연관관계 주인/지연로딩 N+1 체크리스트

👉 2편: 메서드명 쿼리 vs @Query vs QueryDSL 비교

👉 3편: @Transactional 전파/고립수준 이해

👉 4편: 페이징 성능: 카운트 최적화·키셋 페이징