ABOUT

성능과 운영 안정성을 함께 끌어올리는 개발자입니다.

92% Positional Error Reduction
79% p95 Latency Improvement
90%+ Long Tasks Reduction

2022.02 · 한국장학재단

우수 멘티

한국장학재단 사회 리더 대학생 멘토링 IT

2022.10 · 동작구청

우수 인재상

동작구청 우수 SW 인재

2025.05 · (주) 그랩

프로그래밍 우수상

(주) 그랩 우수 프로그램 개발

2025.05 · AWSKRUG

AWS한국사용자모임 발표

AI agent 스크립트 튜닝 관련 발표

ComputerScience

Development

Engineering

Trouble Shooting

GUESTBOOK

첫 마음부터
함께 나누는 온기

방명록 작성하러 가기

SUBSCRIBE

최신소식을
편하게 만나보세요.

지연 로딩 (LAZY)

도입

데이터를 아예 조회하지 않는 방식이 아니라, 연관 엔티티나 컬렉션의 초기화 시점을 실제 접근 순간까지 미루는 방식이다.

JPA와 Hibernate를 처음 배울 때 LAZY는 단순히 “나중에 불러온다” 정도로 이해하기 쉽습니다. 하지만 실무에서 LAZY는 단순 문법이 아니라, 조회 시점과 로딩 시점을 분리해서 데이터 접근 비용을 통제하는 핵심 전략에 가깝습니다.

즉 엔티티 하나를 조회했다고 해서 연결된 모든 연관 관계를 즉시 물고 들어오는 것이 아니라, 지금 당장 필요한 범위까지만 가져오고 나머지는 실제 접근 시점까지 미룹니다.

그래서 지연 로딩을 이해한다는 것은 단순히 fetch 옵션 하나를 아는 것이 아니라, ORM에서 데이터가 언제 어떻게 SQL로 바뀌는지 이해하는 일과 거의 같습니다.

필요성

도메인 모델이 커질수록 모든 연관 관계를 즉시 로딩하는 방식은 과도한 조인과 불필요한 객체 그래프 생성을 만들기 쉬워서, 로딩 시점을 늦추는 설계가 중요해진다

예를 들어 주문 한 건을 조회할 때 회원, 배송지, 주문 라인, 상품, 쿠폰, 결제 이력까지 전부 한 번에 끌고 오면 간단한 조회 하나가 생각보다 무거운 작업이 될 수 있습니다.

반대로 기본 매핑은 가볍게 두고, 실제로 상세 화면이나 리포트 쿼리처럼 더 많은 연관 데이터가 필요한 경우에만 조회 시점에서 fetch plan을 확장하면, 같은 도메인 모델이라도 훨씬 예측 가능한 방식으로 운영할 수 있습니다.

그래서 지연 로딩의 목적은 단순히 “쿼리를 늦춘다”가 아니라, 기본 매핑을 보수적으로 유지하고 필요한 읽기 경로에서만 로딩 범위를 키우는 데 있습니다.

지연 로딩이 특히 중요한 이유
  • 불필요한 조인과 과도한 객체 그래프 생성을 줄일 수 있음
  • 기본 매핑과 조회 전략을 분리해 설계할 수 있음
  • 읽기 유스케이스별로 fetch plan을 명시적으로 다루기 쉬움
  • 대규모 연관 관계 모델에서도 기본 조회 비용을 낮게 유지할 수 있음

정의

JPA에서 FetchType.LAZY는 provider에 대한 힌트이며, EAGER처럼 반드시 강제되는 규칙이 아니라 필요 시 더 일찍 가져올 수 있는 정책이다

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) 세션 밖에서 미초기화 연관 접근 시 예외 가능

기본 규칙

LAZY를 제대로 쓰려면 관계 종류마다 기본 fetch 정책이 다르다는 점과, 필드 단위 lazy는 별도 조건이 필요할 수 있다는 점을 함께 알아야 한다
대상 기본 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 쪽에 가깝습니다.

기본 구현

실무에서 가장 먼저 해야 할 일은 to-one 연관에 LAZY를 명시하고, 컬렉션은 기본 LAZY를 유지한 채 조회별 fetch plan을 별도로 설계하는 것이다
@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

실무에서 지연 로딩의 출발점은 컬렉션보다도, 기본 fetch가 쉽게 eager로 흘러갈 수 있는 ManyToOne과 OneToOne에 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 확장

매핑은 lazy로 유지하고, 실제로 더 많은 데이터가 필요한 읽기 경로에서만 join fetch나 EntityGraph로 로딩 범위를 키우는 것이 가장 안정적인 패턴이다

Hibernate 공식 문서도 대부분의 association은 lazy로 매핑하되, 실제 사용 시점에는 명시적으로 eager fetch를 요청하라고 설명합니다. 핵심은 “기본값을 전역으로 바꾸는 것”보다 “이 조회에만 필요한 데이터를 정확히 가져오는 것”입니다.

이때 가장 흔한 방법이 join fetchEntityGraph입니다. 특히 join fetch는 query-time fetch 전략이지 lazy 전략이 아니므로, 정말 필요한 조회에서만 사용해야 합니다.

select o
from Order o
join fetch o.member
where o.id = :id
핵심 포인트
지연 로딩을 제대로 쓰는 핵심은 모든 연관을 eager로 바꾸지 않는 것입니다. 기본 매핑은 가볍게 유지하고, 특정 조회에서만 join fetchEntityGraph로 필요한 만큼만 미리 가져오는 편이 훨씬 예측 가능하고 안전합니다.

패턴 3. 반복 접근은 BatchSize로 완화

지연 로딩은 과조회를 줄이지만, 반복 접근이 많은 경로에서는 N+1 select를 만들 수 있으므로 batch fetching 같은 완화 장치가 필요하다

지연 로딩의 대표적인 부작용은 반복 접근에서 드러납니다. 예를 들어 주문 목록을 가져온 뒤 각 주문의 회원 이름을 하나씩 접근하면, 루프마다 추가 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<>();

한계와 주의점

LAZY는 기본 매핑을 가볍게 만드는 데 유리하지만, 세션 경계와 실제 조회 전략을 함께 설계하지 않으면 오히려 예외와 N+1을 양산할 수 있다

가장 대표적인 문제는 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 설정에 따라 체감이 달라질 수 있음

자주 하는 실수

지연 로딩을 어렵게 만드는 원인은 문법보다도, 기본 fetch 규칙과 세션 경계와 조회별 fetch plan 책임을 서로 섞어 생각하는 데 있다
  • @ManyToOne, @OneToOne의 기본값을 LAZY로 착각하고 명시를 빼먹음
  • 예외를 피하려고 연관 관계를 전부 EAGER로 바꿔 과조회를 만듦
  • 매핑은 LAZY로 두고도 조회 시점의 fetch plan을 전혀 설계하지 않음
  • 루프 안에서 지연 로딩된 연관을 반복 접근해 N+1을 유발함
  • @Basic(fetch = LAZY)가 enhancer 없이도 자연스럽게 동작할 것이라 기대함

실무 루틴

지연 로딩을 실무에서 안정적으로 쓰려면, 기본 매핑은 가볍게 유지하고 읽기 유스케이스마다 필요한 fetch plan을 따로 설계하는 루틴이 필요하다
  1. @ManyToOne, @OneToOne에는 가능하면 명시적으로 LAZY를 붙인다.
  2. 컬렉션은 기본 LAZY를 유지하고 불필요한 EAGER 전환을 피한다.
  3. 상세 조회나 화면용 조회에서는 join fetch 또는 EntityGraph를 사용한다.
  4. 반복 접근이 많은 지점은 @BatchSize 같은 완화 장치를 검토한다.
  5. 트랜잭션 안에서 실제 필요한 데이터 범위를 확정하고 읽기 경로를 설계한다.
  6. 필드 단위 lazy가 필요하면 bytecode enhancement 전제를 먼저 확인한다.

디버깅

지연 로딩 문제를 디버깅할 때는 에러 한 줄보다, 현재 연관이 미초기화 상태인지와 세션 경계 밖에서 접근하고 있는지를 먼저 확인해야 한다
1
먼저 문제가 난 연관이 to-one인지 to-many인지, 그리고 기본 fetch 규칙이 무엇인지 확인한다.
2
세션이 열려 있는 동안 접근한 것인지, 이미 종료된 뒤에 접근한 것인지 먼저 구분한다.
3
예외가 없다면 그다음에는 반복 접근 때문에 N+1이 생기고 있는지 본다.
4
특정 조회에서만 많은 연관이 필요하다면 매핑을 EAGER로 바꾸기보다 query-time fetch plan을 조정한다.
5
필드 단위 lazy가 기대대로 동작하지 않으면 enhancer 적용 여부를 함께 점검한다.
점검 체크리스트
- 이 연관은 to-one인가 to-many인가
- fetch를 명시했는가, 아니면 기본값에 의존하고 있는가
- 세션 안에서 접근하고 있는가
- 반복 접근으로 N+1이 생기고 있지 않은가
- join fetch / EntityGraph가 더 맞는 조회는 아닌가
- @Basic(fetch = LAZY)라면 enhancer가 적용되어 있는가

요약

지연 로딩의 핵심은 모든 연관을 늦게 읽게 만드는 것이 아니라, 기본 매핑은 가볍게 두고 필요한 조회에서만 fetch plan을 키워 ORM의 로딩 시점을 통제하는 데 있다
  • FetchType.LAZY는 provider에 대한 힌트이고, EAGER는 강제에 가깝다.
  • @ManyToOne, @OneToOne은 기본 fetch를 명시하지 않으면 eager 쪽으로 흐르기 쉽다.
  • @OneToMany, @ManyToMany, @ElementCollection은 기본적으로 LAZY다.
  • ✅ 지연 로딩은 과조회를 줄이는 대신, 반복 접근에서는 N+1을 만들 수 있다.
  • ✅ 필요한 읽기 경로에서는 join fetchEntityGraph로 fetch plan을 조정하는 편이 좋다.
  • ✅ 세션 밖에서 미초기화 연관을 접근하면 LazyInitializationException이 발생할 수 있다.
  • ✅ 반복 lazy access는 @BatchSize로 완화할 수 있다.
  • ✅ 필드 단위 lazy는 enhancer 같은 추가 조건을 함께 봐야 한다.

 

 

728x90