에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
백엔드/데이터 & DB

[JPA] 지연 로딩과 프록시: FetchType.LAZY 동작 원리, LazyInitializationException 해결

반응형
[JPA] 지연 로딩과 프록시: FetchType.LAZY 동작 원리, LazyInitializationException 해결

[JPA] 지연 로딩과 프록시: FetchType.LAZY 동작 원리, LazyInitializationException 해결

JPA에서 가장 자주 만나는 예외가 LazyInitializationException이에요. 트랜잭션 안에서는 멀쩡하던 코드가 컨트롤러에서 터지고 테스트에서는 되는데 운영에서 터져요. 이걸 제대로 잡으려면 지연 로딩이 프록시라는 가짜 객체로 동작한다는 걸 알아야 해요.

지연 로딩이 왜 필요한지, 프록시가 어떻게 동작하는지, LazyInitializationException이 터지는 정확한 구조와 해법, 즉시 로딩(EAGER)이 위험한 까닭, 프록시 관련 함정까지 차례로 풀어 볼게요. 영속성 컨텍스트 글의 "준영속 상태" 개념 위에 서 있어요.

01. 왜 미루나요 — 지연 로딩의 이유

주문(Order)을 조회할 때 회원(User)·배송(Delivery)·상품들까지 한 번에 다 가져오면, 주문 번호 하나 보여주는 화면에서도 전부 로딩돼요. 연관이 많을수록 낭비가 커져요.

그래서 JPA는 "연관 엔티티는 실제로 쓸 때 가져온다"는 지연 로딩(LAZY)을 줘요. 문제는 "쓸 때 가져온다"를 구현하려면, 일단 그 자리에 뭔가를 채워둬야 한다는 거예요. 그게 프록시예요.

02. 프록시, 진짜인 척하는 가짜 객체

JPA 지연 로딩 프록시 동작. LAZY 연관을 조회하면 진짜 엔티티를 상속한 프록시 객체가 반환되고, 실제 데이터에 접근하는 순간 SELECT가 실행되는 프록시 초기화가 일어난다
@Entity
public class Order {
    @ManyToOne(fetch = FetchType.LAZY)
    private User user;
}

Order order = orderRepository.findById(1L).orElseThrow();
order.getUser();             // 쿼리 안 나감 — User 프록시 반환
order.getUser().getName();   // 이 순간 SELECT 실행 (프록시 초기화)

프록시는 진짜 User를 상속한 가짜 객체예요. 껍데기만 있고 내용은 비어 있다가, 실제 데이터에 접근하는 순간 DB를 조회해 채워요. 이 "채우는" 과정이 프록시 초기화예요.

그리고 여기가 핵심이에요. 프록시 초기화에는 영속성 컨텍스트가 필요해요. 초기화 쿼리를 누군가 실행해줘야 하는데, 그게 영속성 컨텍스트거든요. 이 사실이 다음 예외로 이어져요.

03. LazyInitializationException이 터지는 구조

LazyInitializationException 발생 타임라인. 트랜잭션 안에서 LAZY 연관이 프록시로 채워진 채 트랜잭션이 끝나 준영속이 되고, 컨트롤러에서 프록시를 초기화하려는 순간 영속성 컨텍스트가 없어 예외가 터진다

순서대로 보면 이래요. 트랜잭션 안에서 Order를 조회하면 user는 프록시로 채워져요. 트랜잭션이 끝나면 영속성 컨텍스트가 닫히고 엔티티는 준영속이 돼요. 그 뒤 컨트롤러나 뷰에서 getUser().getName()을 부르면, 프록시가 초기화하려는데 영속성 컨텍스트가 이미 없어요. 그래서 LazyInitializationException이 터져요.

"트랜잭션 안에서는 되는데 밖에서 터진다"의 정체가 이거예요. 해법의 방향은 하나예요. 트랜잭션 안에서 필요한 데이터를 다 꺼내놓는 것이에요.

  • fetch join / @EntityGraph — 처음 조회할 때 연관까지 한 쿼리로 가져와요. 다음 편 N+1 해법과 같은 도구예요.
  • DTO 변환을 트랜잭션 안에서 — 서비스 계층에서 필요한 값을 DTO에 담아 반환하면, 밖에서는 엔티티(프록시)를 만질 일이 없어요.
OSIV(open-session-in-view)는 Spring Boot에서 기본값이 켜져 있어요. 그래서 웹 요청에서는 컨트롤러에서도 지연 로딩이 잘 동작하다가, OSIV를 끄거나 배치·@Async처럼 웹 요청 밖에서 돌 때 이 예외를 만나요. 다만 OSIV는 영속성 컨텍스트(와 DB 커넥션)를 응답 끝까지 붙잡는 대가가 있어요. 이 트레이드오프는 OSIV 편에서 따로 깊게 다뤄요.

04. 그럼 EAGER로 하면 되지 않나요

"쓸 때마다 터지니 그냥 즉시 로딩(EAGER)으로 다 가져오자"는 생각이 들 수 있어요. 그런데 EAGER는 더 큰 문제를 만들어요.

JPA 즉시 로딩 EAGER의 함정. 쓰든 안 쓰든 매 조회마다 연관을 로딩하고 JPQL에서는 조인이 아니라 추가 쿼리로 채워 N+1이 터진다. 기본을 LAZY로 두고 필요한 곳에서만 fetch join하는 게 정석이다

EAGER는 쓰든 안 쓰든 연관 엔티티를 항상 로딩해요. 연관이 늘수록 모든 조회가 무거워지고 전역 설정이라 특정 화면에서만 끌 수도 없어요. 결정적으로, JPQL로 조회하면 EAGER 연관을 조인이 아니라 추가 쿼리로 채워요. 결과가 100건이면 연관 조회 쿼리가 100번 더 나가요. 다음 편에서 다룰 N+1이 EAGER 때문에 의도치 않게 터지는 거예요.

그리고 기본값 함정 — @OneToMany·@ManyToMany는 기본이 LAZY지만, @ManyToOne·@OneToOne기본이 EAGER예요. 아무 설정 안 하면 다대일 연관이 전부 즉시 로딩으로 돌아가요. 그래서 실무에서는 모든 연관에 fetch = FetchType.LAZY를 명시하고, 필요한 조회에서만 fetch join으로 가져오는 걸 기본으로 둬요.

05. 프록시 관련 함정들

getId()는 초기화하지 않아요

프록시에서 getId()만 부르면 쿼리가 안 나가요. ID는 프록시가 이미 알고 있거든요(조회한 외래키 값). 그래서 "연관의 ID만 필요한" 코드는 LAZY여도 추가 쿼리 없이 돌아가요. 반대로 말하면, ID 외의 어떤 필드든 건드리는 순간 초기화돼요.

타입 비교가 어긋나요

User proxy = order.getUser();          // 프록시
proxy.getClass() == User.class;        // false! 프록시는 User의 자식 클래스
proxy instanceof User;                 // true — 비교는 instanceof로

프록시는 원본을 상속한 별도 클래스라 getClass() 비교가 깨져요. 타입 비교는 instanceof로 하고, equals를 구현할 때도 getClass() 비교 대신 instanceof를 쓰는 게 안전해요.

이미 영속 상태면 프록시가 안 나올 수도 있어요

같은 트랜잭션에서 그 User를 이미 조회해 1차 캐시에 있으면, JPA는 프록시 대신 진짜 객체를 줘요(동일성 보장 때문에). 즉 "LAZY니까 항상 프록시"가 아니에요. 프록시 여부에 의존하는 코드를 짜면 안 되는 이유예요.

06. 자주 만나는 문제

LazyInitializationException이 터져요

트랜잭션 밖(준영속)에서 프록시를 초기화한 거예요. fetch join·@EntityGraph로 트랜잭션 안에서 로딩하거나, DTO 변환을 트랜잭션 안에서 끝내요.

안 쓰는 연관까지 매번 조회돼요

EAGER 연관이 있는 거예요. 특히 @ManyToOne 기본값을 의심해요. 전부 LAZY로 명시해요.

JSON 직렬화에서 무한 루프·예외가 나요

컨트롤러에서 엔티티를 그대로 반환하면, 직렬화가 모든 getter를 호출하며 프록시를 건드려요. 양방향 연관이면 무한 루프까지 돌아요. 엔티티 직접 반환 대신 DTO로 변환해 반환하는 게 정석이에요.

한 장으로 요약

지연 로딩은 프록시(가짜 객체)를 먼저 주고, 실제 접근 시점에 영속성 컨텍스트를 통해 초기화하는 방식이에요. 이 한 문장에서 나머지가 다 따라 나와요.

  • 예외의 정체 — 영속성 컨텍스트가 닫힌 뒤(준영속) 프록시를 건드리면 LazyInitializationException이에요.
  • 해법의 방향 — 트랜잭션 안에서 fetch join·DTO로 필요한 데이터를 다 꺼내놓는 거예요.
  • 기본 설정 — 연관은 전부 LAZY 명시. EAGER는 N+1의 씨앗이에요.

영속성 컨텍스트의 준영속 개념 위에 선 글이었고, 바로 다음은 그 유명한 N+1 문제를 정면으로 다뤄요.

출처: Hibernate User Guide — Fetching · Spring Data JPA — Entity Graph

반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."