연관된 엔티티를 조회할 때, 진짜 필요한 순간까지 쿼리를 미룰 수 있다면 어떨까요?

JPA에서 연관 관계를 다룰 때 가장 먼저 마주치는 개념이 바로 지연 로딩(Lazy Loading) 과 즉시 로딩(Eager Loading) 입니다. 이 둘의 차이를 정확히 이해하지 못하면 성능 문제와 예외가 동시에 찾아옵니다.

개념 정의

  • 즉시 로딩(EAGER): 엔티티를 조회할 때 연관된 엔티티도 ** 즉시 함께** 조회합니다.
  • ** 지연 로딩(LAZY)**: 연관된 엔티티를 ** 실제로 사용하는 시점 **에 쿼리를 실행하여 조회합니다.
JAVA
@Entity
public class Order {

    @ManyToOne(fetch = FetchType.LAZY) // 지연 로딩
    @JoinColumn(name = "member_id")
    private Member member;

    @OneToMany(mappedBy = "order", fetch = FetchType.EAGER) // 즉시 로딩
    private List<OrderItem> orderItems;
}

왜 LAZY가 기본이어야 하는가

JPA 스펙에서 @ManyToOne@OneToOne의 기본값은 EAGER이고, @OneToMany@ManyToMany의 기본값은 LAZY입니다. 하지만 실무에서는 ** 모든 연관 관계를 LAZY로 설정하는 것이 권장 **됩니다.

그 이유를 정리하면 다음과 같습니다.

  • ** 불필요한 쿼리 방지 **: EAGER로 설정하면 해당 엔티티를 조회할 때마다 연관 엔티티가 함께 로딩됩니다. 사용하지 않는 데이터까지 매번 가져오는 셈입니다.
  • **N+1 문제 악화 **: JPQL로 목록을 조회할 때 EAGER 관계가 있으면 각 엔티티마다 추가 쿼리가 발생합니다.
  • ** 예측 불가능한 쿼리 **: 개발자가 의도하지 않은 시점에 조인이 발생하여 쿼리 분석이 어려워집니다.

프록시 객체의 동작 원리

LAZY 로딩의 핵심은 ** 프록시 객체 **입니다. Hibernate는 ByteBuddy라는 바이트코드 조작 라이브러리를 사용하여 엔티티 클래스를 상속한 프록시 클래스를 런타임에 생성합니다.

프록시 생성 과정

  1. em.find(Order.class, 1L)을 호출하면 Order 엔티티를 DB에서 조회합니다.
  2. Order의 member 필드는 LAZY이므로, 실제 Member 대신 Member를 상속한 프록시 객체 가 들어갑니다.
  3. 프록시 객체는 내부에 target이라는 실제 엔티티 참조를 가지고 있으며, 처음에는 null입니다.
PLAINTEXT
Order (실제 엔티티)
└── member → MemberProxy (ByteBuddy 생성)
                └── target = null (아직 초기화 안 됨)

프록시 초기화 시점

프록시 객체의 식별자가 아닌 필드 에 처음 접근하는 순간, 프록시 초기화가 진행됩니다.

JAVA
Order order = em.find(Order.class, 1L);

// 이 시점에서는 member에 대한 쿼리가 실행되지 않음
Member memberProxy = order.getMember();

// getId()는 프록시가 이미 알고 있으므로 쿼리 미실행
Long memberId = memberProxy.getId();

// getName()을 호출하는 순간 SELECT 쿼리 실행
String name = memberProxy.getName(); // DB 쿼리 발생!

초기화 과정은 다음과 같습니다.

  1. 프록시 객체의 메서드가 호출됩니다.
  2. 프록시는 영속성 컨텍스트에 실제 엔티티 조회를 요청합니다.
  3. 영속성 컨텍스트는 DB에서 엔티티를 조회하여 1차 캐시에 저장합니다.
  4. 프록시의 target에 실제 엔티티를 연결합니다.
  5. 이후 접근에서는 target을 통해 직접 데이터를 반환합니다.

프록시의 특성

JAVA
Member member = em.find(Member.class, 1L);
Member proxy = em.getReference(Member.class, 1L);

// 같은 영속성 컨텍스트에서는 동일성 보장
System.out.println(member == proxy); // true

// 프록시 타입 확인
System.out.println(proxy instanceof Member); // true
System.out.println(proxy.getClass() == Member.class); // false (프록시 클래스)

주의할 점입니다.

  • 프록시는 원본 엔티티를 상속하므로 instanceoftrue를 반환하지만, getClass() 비교는 false입니다.
  • 같은 영속성 컨텍스트에서 같은 엔티티를 조회하면, 먼저 프록시가 만들어졌다면 find()도 프록시를 반환합니다(동일성 보장).

LazyInitializationException

가장 흔하게 만나는 예외입니다. 영속성 컨텍스트가 닫힌 후에 초기화되지 않은 프록시에 접근하면 발생합니다.

JAVA
@Service
public class OrderService {

    @Transactional
    public Order getOrder(Long id) {
        return orderRepository.findById(id).orElseThrow();
    }
    // 트랜잭션 종료 → 영속성 컨텍스트 닫힘
}

@Controller
public class OrderController {

    public String orderDetail(Long id) {
        Order order = orderService.getOrder(id);
        // 여기서 member에 접근하면 LazyInitializationException!
        String memberName = order.getMember().getName();
    }
}

해결 방법

1. 트랜잭션 범위 내에서 초기화

필요한 데이터를 서비스 계층에서 미리 접근하여 프록시를 초기화합니다.

JAVA
@Transactional(readOnly = true)
public OrderDto getOrder(Long id) {
    Order order = orderRepository.findById(id).orElseThrow();
    // 트랜잭션 안에서 프록시 초기화
    String memberName = order.getMember().getName();
    return new OrderDto(order, memberName);
}

2. fetch join 사용

JAVA
@Query("SELECT o FROM Order o JOIN FETCH o.member WHERE o.id = :id")
Optional<Order> findByIdWithMember(@Param("id") Long id);

3. @EntityGraph 사용

JAVA
@EntityGraph(attributePaths = {"member"})
Optional<Order> findById(Long id);

4. DTO 프로젝션으로 필요한 데이터만 조회

JAVA
@Query("SELECT new com.example.dto.OrderDto(o.id, m.name) " +
       "FROM Order o JOIN o.member m WHERE o.id = :id")
Optional<OrderDto> findOrderDtoById(@Param("id") Long id);

fetch 전략 선택 기준

상황권장 전략
연관 엔티티를 거의 항상 함께 사용fetch join 또는 @EntityGraph
연관 엔티티를 가끔만 사용LAZY (기본)
목록 조회 시 연관 데이터 필요fetch join + 페이징 주의
API 응답에 필요한 데이터만DTO 프로젝션

@ManyToOne의 기본값이 EAGER이기 때문에, 명시적으로 LAZY를 설정하지 않으면 엔티티를 조회할 때마다 연관 엔티티까지 함께 로딩됩니다. 따라서 엔티티를 설계할 때 ** 모든 연관 관계에 fetch = FetchType.LAZY를 명시적으로 설정 **하는 것이 좋습니다.

주의할 점

@ManyToOne의 기본 fetch가 EAGER라는 사실을 놓치기 쉽다

JPA 스펙에서 @ManyToOne@OneToOne의 기본값은 EAGER 입니다. 엔티티를 새로 만들 때 fetch = FetchType.LAZY를 명시하지 않으면, 의도치 않은 조인 쿼리가 매번 실행됩니다.

프록시 타입 비교에 == 대신 instanceof를 써야 한다

프록시는 원본 엔티티를 상속 한 클래스이므로 getClass() == Member.classfalse를 반환합니다. 타입 비교에는 반드시 instanceof를 사용해야 합니다.

초기화되지 않은 프록시를 트랜잭션 밖으로 가져가면 예외가 발생한다

LAZY 프록시가 아직 초기화되지 않은 상태에서 영속성 컨텍스트가 닫히면, 이후 프록시의 필드에 접근하는 순간 LazyInitializationException이 발생합니다. 필요한 데이터는 트랜잭션 안에서 미리 접근 하거나, fetch join으로 한 번에 가져와야 합니다.

정리

항목설명
LAZY 로딩프록시 객체를 통해 실제 사용 시점까지 쿼리 지연
프록시 생성ByteBuddy로 엔티티를 상속한 프록시 클래스 생성
프록시 초기화식별자가 아닌 필드 접근 시 DB 쿼리 실행
LazyInitializationException영속성 컨텍스트 닫힌 후 프록시 접근 시 발생
실무 원칙모든 연관 관계를 LAZY로 설정, 필요 시 fetch join
댓글 로딩 중...