연관관계 주인/지연로딩 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/네트워크 비용이 큽니다.
👉 2편: 메서드명 쿼리 vs @Query vs QueryDSL 비교
'Java & Spring' 카테고리의 다른 글
| @Transactional 전파/고립수준 이해 (1) | 2025.10.03 |
|---|---|
| 메서드명 쿼리 vs @Query vs QueryDSL 비교 (0) | 2025.10.02 |
| Lombok 안전 사용 규칙(@Builder/@Value/@With) (0) | 2025.10.01 |
| JUnit5 + Mockito: Given-When-Then 패턴 (0) | 2025.10.01 |
| Maven→Gradle 마이그레이션 함정 7가지 (0) | 2025.10.01 |