도입
JPA와 Hibernate를 처음 배울 때 LAZY는 단순히 “나중에 불러온다” 정도로 이해하기 쉽습니다. 하지만 실무에서 LAZY는 단순 문법이 아니라, 조회 시점과 로딩 시점을 분리해서 데이터 접근 비용을 통제하는 핵심 전략에 가깝습니다.
즉 엔티티 하나를 조회했다고 해서 연결된 모든 연관 관계를 즉시 물고 들어오는 것이 아니라, 지금 당장 필요한 범위까지만 가져오고 나머지는 실제 접근 시점까지 미룹니다.
그래서 지연 로딩을 이해한다는 것은 단순히 fetch 옵션 하나를 아는 것이 아니라, ORM에서 데이터가 언제 어떻게 SQL로 바뀌는지 이해하는 일과 거의 같습니다.
필요성
예를 들어 주문 한 건을 조회할 때 회원, 배송지, 주문 라인, 상품, 쿠폰, 결제 이력까지 전부 한 번에 끌고 오면 간단한 조회 하나가 생각보다 무거운 작업이 될 수 있습니다.
반대로 기본 매핑은 가볍게 두고, 실제로 상세 화면이나 리포트 쿼리처럼 더 많은 연관 데이터가 필요한 경우에만 조회 시점에서 fetch plan을 확장하면, 같은 도메인 모델이라도 훨씬 예측 가능한 방식으로 운영할 수 있습니다.
그래서 지연 로딩의 목적은 단순히 “쿼리를 늦춘다”가 아니라, 기본 매핑을 보수적으로 유지하고 필요한 읽기 경로에서만 로딩 범위를 키우는 데 있습니다.
- 불필요한 조인과 과도한 객체 그래프 생성을 줄일 수 있음
- 기본 매핑과 조회 전략을 분리해 설계할 수 있음
- 읽기 유스케이스별로 fetch plan을 명시적으로 다루기 쉬움
- 대규모 연관 관계 모델에서도 기본 조회 비용을 낮게 유지할 수 있음
정의
JPA 공식 문서 기준으로 FetchType.LAZY는 “이 데이터를 지연 조회해도 된다”는 힌트입니다. 즉 persistence provider는 경우에 따라 더 많은 데이터를 미리 가져와도 됩니다.
반대로 FetchType.EAGER는 강제에 가깝습니다. 따라서 설계에서 EAGER를 선택하면 provider가 반드시 즉시 fetch해야 하는 제약을 걸게 됩니다.
또한 Jakarta Persistence 4.0 기준으로 @ManyToOne과 @OneToOne은 fetch를 명시하지 않으면 persistence unit의 기본 to-one fetch 설정을 따르고, 이 기본값은 하위 호환성 때문에 여전히 EAGER입니다. 반면 @OneToMany, @ManyToMany, @ElementCollection은 기본이 LAZY입니다.
"LAZY는 성능 최적화 버튼이 아니라
기본 매핑과 실제 조회 전략을 분리하게 만드는 설계 장치에 가깝습니다."
핵심 원리
예를 들어 주문 엔티티를 조회했더라도 주문의 회원, 주문 라인, 배송 정보가 모두 즉시 사용되는 것은 아닙니다. 지연 로딩은 이런 연관을 일단 미초기화 상태로 두고, 실제 getter 접근이나 컬렉션 순회가 일어나는 순간 추가 SQL을 실행합니다.
그래서 같은 find() 호출이라도 어떤 코드를 뒤에서 더 실행하느냐에 따라 추가 쿼리 수가 달라질 수 있습니다. 결국 성능은 매핑 옵션 하나보다도, 그 연관을 언제 어떤 경로에서 접근하느냐에 더 크게 좌우됩니다.
또 중요한 점은 LAZY가 만능 해결책이 아니라는 것입니다. Hibernate 공식 문서도 기본 매핑은 lazy가 자연스럽지만, 실제 읽기 경로에서는 필요한 데이터를 미리 fetch하는 설계가 더 중요하다고 설명합니다.
즉 매핑은 보수적으로, 조회는 목적에 맞게 적극적으로 설계하는 것이 지연 로딩을 다루는 기본 관점입니다.
Lazy Loading Flow
1) 엔티티 본체 조회
2) 연관 관계는 미초기화 상태 유지
3) 첫 접근 시 추가 SQL로 초기화
4) 세션 밖에서 미초기화 연관 접근 시 예외 가능
기본 규칙
| 대상 | 기본 fetch | 실무 해석 |
|---|---|---|
| @ManyToOne | DEFAULT (기본 to-one 설정, 기본값은 사실상 EAGER) | 실무에서는 fetch = LAZY를 명시하는 습관이 중요 |
| @OneToOne | DEFAULT (기본 to-one 설정, 기본값은 사실상 EAGER) | to-one 연관은 명시적으로 LAZY를 주지 않으면 쉽게 과조회가 생김 |
| @OneToMany | LAZY | 컬렉션은 기본적으로 지연 로딩됨 |
| @ManyToMany | LAZY | 컬렉션 기반 연관이므로 기본 지연 로딩 |
| @ElementCollection | LAZY | 값 타입 컬렉션도 기본적으로 지연 로딩 |
| @Basic(fetch = LAZY) | provider별 지원 차이 있음 | Hibernate에서는 bytecode enhancement가 있어야 실질적으로 동작하는 경우가 많음 |
특히 많은 개발자가 @ManyToOne이 기본 LAZY라고 오해합니다. 하지만 최신 Jakarta Persistence 기준에서도 to-one 연관은 persistence unit 기본값을 따르며, 하위 호환성 때문에 기본값은 여전히 eager 쪽에 가깝습니다.
기본 구현
@Entity
public class Order {
@Id
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List orderLines = new ArrayList<>();
}
@Entity
public class Member {
@Id
private Long id;
private String name;
}
fetch = LAZY를 적는 것은 가독성 차원의 명시일 뿐이고, 더 중요한 것은 @ManyToOne, @OneToOne처럼 기본이 쉽게 eager로 흘러갈 수 있는 to-one 연관에 LAZY를 분명히 적는 습관입니다.패턴 1. To-One 연관은 명시적으로 LAZY
대부분의 화면과 API는 to-many 컬렉션보다 to-one 연관을 훨씬 자주 사용합니다. 주문에서 회원을 보고, 게시글에서 작성자를 보고, 결제에서 주문을 보는 식의 참조가 대표적입니다.
문제는 이 지점이 기본 eager로 남아 있으면 작은 조회도 연쇄적으로 무거워질 수 있다는 것입니다. 그래서 멀티 엔티티 모델에서는 @ManyToOne(fetch = LAZY), @OneToOne(fetch = LAZY)를 명시적으로 붙이는 습관이 사실상 기본값에 가깝습니다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
패턴 2. 조회 시점에만 fetch plan 확장
Hibernate 공식 문서도 대부분의 association은 lazy로 매핑하되, 실제 사용 시점에는 명시적으로 eager fetch를 요청하라고 설명합니다. 핵심은 “기본값을 전역으로 바꾸는 것”보다 “이 조회에만 필요한 데이터를 정확히 가져오는 것”입니다.
이때 가장 흔한 방법이 join fetch와 EntityGraph입니다. 특히 join fetch는 query-time fetch 전략이지 lazy 전략이 아니므로, 정말 필요한 조회에서만 사용해야 합니다.
select o
from Order o
join fetch o.member
where o.id = :id
join fetch나 EntityGraph로 필요한 만큼만 미리 가져오는 편이 훨씬 예측 가능하고 안전합니다.패턴 3. 반복 접근은 BatchSize로 완화
지연 로딩의 대표적인 부작용은 반복 접근에서 드러납니다. 예를 들어 주문 목록을 가져온 뒤 각 주문의 회원 이름을 하나씩 접근하면, 루프마다 추가 select가 나가면서 N+1 문제가 생길 수 있습니다.
이때 Hibernate의 @BatchSize는 여러 미초기화 프록시나 컬렉션을 한 번에 묶어 가져오도록 도와줍니다. 즉 하나의 PK만 가진 select를 반복하는 대신, 여러 PK를 IN 조건으로 묶어 한 번에 조회하는 방식입니다.
@Entity
@BatchSize(size = 100)
public class Member {
@Id
private Long id;
}
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
@BatchSize(size = 20)
private Set members = new HashSet<>();
한계와 주의점
가장 대표적인 문제는 LazyInitializationException입니다. Hibernate 공식 문서 기준으로 이 예외는 열린 stateful session 밖에서 아직 가져오지 않은 프록시나 컬렉션에 접근할 때 발생합니다.
또한 지연 로딩은 “쿼리를 없애는 것”이 아니라 “나중으로 미루는 것”이기 때문에, 반복 접근이 많은 경로에서는 오히려 더 많은 round trip을 만들 수도 있습니다.
마지막으로 @Basic(fetch = LAZY)는 association lazy와는 성격이 다릅니다. Hibernate에서는 bytecode enhancement가 없으면 필드 단위 lazy가 기대처럼 동작하지 않을 수 있습니다.
- LAZY는 추가 SQL이 없다는 뜻이 아니라, 시점을 뒤로 미룬다는 뜻임
- 세션 밖에서 미초기화 연관을 접근하면 예외가 날 수 있음
- 반복 접근이 많으면 N+1 select 문제로 이어질 수 있음
@Basic(fetch = LAZY)는 provider와 enhancement 설정에 따라 체감이 달라질 수 있음
자주 하는 실수
@ManyToOne,@OneToOne의 기본값을 LAZY로 착각하고 명시를 빼먹음- 예외를 피하려고 연관 관계를 전부 EAGER로 바꿔 과조회를 만듦
- 매핑은 LAZY로 두고도 조회 시점의 fetch plan을 전혀 설계하지 않음
- 루프 안에서 지연 로딩된 연관을 반복 접근해 N+1을 유발함
@Basic(fetch = LAZY)가 enhancer 없이도 자연스럽게 동작할 것이라 기대함
실무 루틴
@ManyToOne,@OneToOne에는 가능하면 명시적으로 LAZY를 붙인다.- 컬렉션은 기본 LAZY를 유지하고 불필요한 EAGER 전환을 피한다.
- 상세 조회나 화면용 조회에서는 join fetch 또는 EntityGraph를 사용한다.
- 반복 접근이 많은 지점은
@BatchSize같은 완화 장치를 검토한다. - 트랜잭션 안에서 실제 필요한 데이터 범위를 확정하고 읽기 경로를 설계한다.
- 필드 단위 lazy가 필요하면 bytecode enhancement 전제를 먼저 확인한다.
디버깅
점검 체크리스트
- 이 연관은 to-one인가 to-many인가
- fetch를 명시했는가, 아니면 기본값에 의존하고 있는가
- 세션 안에서 접근하고 있는가
- 반복 접근으로 N+1이 생기고 있지 않은가
- join fetch / EntityGraph가 더 맞는 조회는 아닌가
- @Basic(fetch = LAZY)라면 enhancer가 적용되어 있는가
요약
- ✅
FetchType.LAZY는 provider에 대한 힌트이고,EAGER는 강제에 가깝다. - ✅
@ManyToOne,@OneToOne은 기본 fetch를 명시하지 않으면 eager 쪽으로 흐르기 쉽다. - ✅
@OneToMany,@ManyToMany,@ElementCollection은 기본적으로 LAZY다. - ✅ 지연 로딩은 과조회를 줄이는 대신, 반복 접근에서는 N+1을 만들 수 있다.
- ✅ 필요한 읽기 경로에서는
join fetch나EntityGraph로 fetch plan을 조정하는 편이 좋다. - ✅ 세션 밖에서 미초기화 연관을 접근하면 LazyInitializationException이 발생할 수 있다.
- ✅ 반복 lazy access는
@BatchSize로 완화할 수 있다. - ✅ 필드 단위 lazy는 enhancer 같은 추가 조건을 함께 봐야 한다.