본문으로 건너뛰기
목록으로

JPA 연관관계의 기본 페치 전략 — to-one은 EAGER, to-many는 LAZY인 이유

Johny Cho
Software Engineer @ Kurly

JPA 연관관계 어노테이션은 저마다 기본 페치 전략이 다릅니다. @ManyToOne과 @OneToOne은 기본이 즉시 로딩(EAGER), @OneToMany와 @ManyToMany는 기본이 지연 로딩(LAZY) 입니다. 외우긴 쉬운데, "왜 이렇게 나뉘나"를 알면 실수가 줄고 어떤 연관을 LAZY로 둬야 하는지도 보입니다. 규칙은 한 줄입니다 — 연관의 대상이 "1건"이면 EAGER, "N건"이면 LAZY 가 기본입니다.

어노테이션별 기본값​

어노테이션대상 수기본 페치
@ManyToOne1건(to-one)EAGER
@OneToOne1건(to-one)EAGER
@OneToManyN건(to-many)LAZY
@ManyToManyN건(to-many)LAZY
(참고) @Basic 필드1개 값EAGER
(참고) @ElementCollectionN개 값LAZY
@Entity
public class Order {

@ManyToOne // 기본 EAGER — 주문을 읽으면 회원도 함께 로딩
private Member member;

@OneToOne // 기본 EAGER
private Payment payment;

@OneToMany(mappedBy = "order") // 기본 LAZY — 주문 라인은 접근할 때 로딩
private List<OrderLine> lines = new ArrayList<>();

@ManyToMany // 기본 LAZY
private Set<Coupon> coupons = new HashSet<>();
}
PlantUML 코드
@startuml
skinparam shadowing false
skinparam defaultFontName sans-serif
hide empty members
hide circle

class Member #E9F2E9
class Order #EEF3FB
class OrderLine #FFF3E0

Order "N" --> "1" Member : @ManyToOne\n기본 EAGER (대상 1건)
Order "1" --> "N" OrderLine : @OneToMany\n기본 LAZY (대상 N건)
@enduml

PlantUML to-one은 EAGER, to-many는 LAZY라는 기본 페치 전략

왜 대상 수로 나뉠까​

핵심은 "한 번 더 불러오는 비용이 예측 가능한가" 입니다.

  • to-one(@ManyToOne·@OneToOne) — 대상이 최대 1건입니다. 보통 소유 측이 외래 키(FK)를 들고 있어, 부모를 읽을 때 조인 한 번이면 함께 가져올 수 있습니다. 비용이 작고 일정하니, 스펙은 "어차피 같이 쓸 테니 기본으로 함께 로딩하자"고 본 것입니다.
  • to-many(@OneToMany·@ManyToMany) — 대상이 몇 건일지 모릅니다. 주문 하나에 라인이 1개일 수도 10,000개일 수도 있습니다. 부모를 읽을 때마다 이 컬렉션을 전부 끌어오면 쓰지도 않을 대량의 행을 매번 로딩하게 됩니다. 그래서 기본을 LAZY로 두고 실제로 접근할 때 로딩합니다.

즉 "1건이라 저렴하다 → EAGER", "N건이라 비쌀 수 있다 → LAZY" 가 기본값의 논리입니다. 예를 들어 상품 500개를 목록으로 읽는데 각 상품의 @OneToMany 리뷰가 기본 EAGER라면, 상품 500개를 읽는 순간 리뷰 수만 건이 전부 딸려 오는 식이 됩니다.

EAGER는 "요구", LAZY는 "힌트"​

기본값을 이해할 때 한 가지 더 알아 둘 점은, 이 둘의 강제력이 다르다는 것입니다. JPA 스펙은 이렇게 규정합니다.

  • EAGER — 연관을 반드시 즉시 로딩하라는 요구(requirement). 구현체는 이를 지켜야 합니다.
  • LAZY — 처음 접근할 때 로딩하라는 힌트(hint). 구현체가 사정상 즉시 로딩해도 스펙 위반은 아닙니다.

Hibernate는 컬렉션과 to-one 연관에 대해 프록시/바이트코드로 LAZY를 실제로 구현합니다. 다만 "LAZY로 뒀으니 무조건 미뤄지겠지"가 항상 성립하진 않는다는 점(아래 to-one 주의)이 여기서 나옵니다.

그런데 to-one의 EAGER 기본이 실무에서는 문제​

기본값이 EAGER라고 해서 to-one을 그대로 두면, 두 가지가 따라옵니다.

① 사용하지 않는데도 매번 조인/추가 조회. 주문만 필요한 화면에서도 member가 EAGER면 회원을 항상 함께 읽습니다.

② 목록 조회에서 N+1. 더 아픈 쪽입니다. JPQL이나 findAll로 목록을 읽으면, EAGER to-one은 보통 엔티티마다 추가 SELECT로 당겨져 쿼리가 1 + N번 나갑니다.

// Order.member가 기본 EAGER일 때
List<Order> orders = orderRepository.findAll();
// 1) SELECT * FROM orders ← 목록 1번
// 2) SELECT * FROM member WHERE id = ? ← 주문 수(N)만큼 반복 → N+1

(엔티티 하나를 em.find로 읽을 땐 조인으로 한 번에 가져오지만, 목록 쿼리에서는 이렇게 N+1이 됩니다.)

그래서 실무에서는 to-one 연관도 명시적으로 fetch = LAZY로 두는 것을 권장합니다. 그리고 정말 함께 필요한 지점에서만 그때그때 함께 로딩합니다 — join fetch·@EntityGraph·DTO 프로젝션 등으로요. 필요할 때 함께 읽는 방법은 JPA 연관관계 즉시 로딩에 정리돼 있습니다.

@ManyToOne(fetch = FetchType.LAZY)   // 기본 EAGER를 LAZY로 명시 — 필요할 때만 로딩
private Member member;

to-one을 LAZY로 둘 때의 주의​

to-one의 LAZY가 항상 깔끔하게 동작하는 건 아닙니다. 특히 @OneToOne의 비소유(mappedBy) 측이나 nullable한 to-one은 LAZY로 둬도 즉시 로딩처럼 동작하기 쉽습니다.

  • 프록시로 지연시키려면, 적어도 연관이 있는지 없는지(null 여부) 는 알아야 프록시를 꽂을지 정할 수 있습니다. FK를 자기 테이블에 가진 소유 측은 그 FK 값만 보고 판단되지만, FK가 상대 테이블에 있는 비소유 @OneToOne 은 쿼리를 날려 봐야 null인지 알 수 있어, 결국 즉시 로딩처럼 됩니다.
  • 이 경우는 Hibernate의 바이트코드 인핸스먼트(@LazyToOne 등)로 보완하거나, 애초에 연관을 FK 소유 방향으로 설계해 피합니다.

정리하면, 컬렉션(to-many)의 LAZY는 자연스럽게 동작하지만, to-one의 LAZY는 소유 방향·nullable 여부에 따라 제약이 있습니다.

정리​

  • 기본 페치는 대상 수로 갈립니다 — @ManyToOne·@OneToOne(1건)은 EAGER, @OneToMany·@ManyToMany(N건)는 LAZY.
  • 이유는 "1건이라 조인 한 번으로 저렴 → EAGER, N건이라 비쌀 수 있음 → LAZY" 입니다.
  • 스펙상 EAGER는 요구, LAZY는 힌트라, LAZY가 항상 미뤄진다고 단정할 수 없습니다.
  • to-one의 EAGER 기본은 불필요한 조회와 목록 조회 N+1을 부르니, 실무에서는 fetch = LAZY로 명시하고 필요할 때만 함께 로딩하는 편이 안전합니다.
  • 단 to-one LAZY는 비소유 @OneToOne·nullable에서 제약이 있어, 소유 방향 설계나 바이트코드 인핸스먼트로 보완합니다.

참고​