[JAVA] LazyInitializationException 실무에서 해결한 방법

Java와 Spring Boot로 JPA를 사용하다 보면 LazyInitializationException을 한 번쯤 만나게 됩니다. 단순히 FetchType.LAZY가 문제라고 보기보다는, 엔티티를 조회한 시점과 실제로 연관 데이터를 사용하는 시점이 어긋났다고 이해하는 편이 정확합니다.

Java LazyInitializationException은 언제 발생할까?

Java LazyInitializationException은 Hibernate에서 지연 로딩으로 설정된 연관 객체를 초기화하려고 했지만, 이미 영속성 컨텍스트가 닫혀 있을 때 발생하는 예외입니다. Spring Boot와 JPA를 함께 사용할 때는 보통 서비스 메서드의 트랜잭션이 끝난 뒤 컨트롤러나 JSON 직렬화 과정에서 연관 객체에 접근하면서 자주 발생합니다.

실무에서는 이 예외를 처음 보면 “왜 조회는 됐는데 값을 꺼내는 순간 터지지?”라는 식으로 헷갈리는 경우가 많습니다. JPA는 Lazy로 설정된 연관 객체를 처음부터 모두 조회하지 않고, 실제 접근 시점에 추가 쿼리를 날려 데이터를 가져오려고 합니다. 그런데 그 시점에 DB 세션이 이미 닫혀 있다면 더 이상 데이터를 가져올 수 없기 때문에 예외가 발생합니다.


org.hibernate.LazyInitializationException:
failed to lazily initialize a collection of role:
com.example.order.domain.Order.orderItems,
could not initialize proxy - no Session

위 메시지에서 중요한 부분은 could not initialize proxy - no Session입니다. 연관 객체를 가져오려는 시점에는 프록시 객체만 남아 있고, 실제 데이터를 조회할 수 있는 Hibernate Session은 이미 종료되었다는 뜻입니다.

 

LazyInitializationException 원인을 코드로 이해하기

LazyInitializationException 원인은 대부분 트랜잭션 범위 밖에서 Lazy 연관 객체에 접근하는 데 있습니다. 예를 들어 주문과 주문 상품이 일대다 관계라고 가정해보겠습니다.


@Entity
public class Order {

    @Id
    @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> orderItems = new ArrayList<>();

    public List<OrderItem> getOrderItems() {
        return orderItems;
    }
}

아래 서비스 코드는 주문만 조회해서 반환합니다. 이때 orderItems는 Lazy 설정이므로 아직 조회되지 않은 상태입니다.


@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    @Transactional(readOnly = true)
    public Order findOrder(Long orderId) {
        return orderRepository.findById(orderId)
                .orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다."));
    }
}

문제는 컨트롤러에서 반환된 엔티티를 그대로 응답으로 내려보낼 때 자주 발생합니다.


@GetMapping("/orders/{orderId}")
public Order getOrder(@PathVariable Long orderId) {
    return orderService.findOrder(orderId);
}

서비스 메서드가 끝나면 트랜잭션도 종료됩니다. 이후 Jackson이 JSON으로 변환하면서 orderItems에 접근하면, Hibernate는 그제야 연관 데이터를 조회하려고 합니다. 하지만 이미 Session이 닫혀 있으므로 LazyInitializationException이 발생합니다.

 

LazyInitializationException을 해결하는 첫 번째 기준: 엔티티를 그대로 반환하지 않기

LazyInitializationException을 줄이는 가장 기본적인 기준은 API 응답에서 엔티티를 그대로 반환하지 않는 것입니다. 엔티티는 DB 모델과 도메인 상태를 표현하는 객체이고, API 응답은 클라이언트가 필요로 하는 데이터 구조입니다. 두 역할을 분리하지 않으면 Lazy 로딩뿐 아니라 순환 참조, 불필요한 필드 노출, 응답 구조 변경 문제까지 함께 따라오는 경우가 많습니다.

실무에서는 DTO를 만들어 필요한 값만 명시적으로 담는 방식이 유지보수에 더 유리합니다. 어떤 연관 데이터가 응답에 필요한지 코드에서 드러나기 때문에, 나중에 조회 쿼리나 응답 구조를 변경할 때 영향 범위를 파악하기 쉽습니다.


public record OrderResponse(
        Long orderId,
        List<OrderItemResponse> items
) {
    public static OrderResponse from(Order order) {
        return new OrderResponse(
                order.getId(),
                order.getOrderItems().stream()
                        .map(OrderItemResponse::from)
                        .toList()
        );
    }
}

public record OrderItemResponse(
        Long itemId,
        String itemName,
        int quantity
) {
    public static OrderItemResponse from(OrderItem orderItem) {
        return new OrderItemResponse(
                orderItem.getId(),
                orderItem.getItemName(),
                orderItem.getQuantity()
        );
    }
}

다만 DTO로 변환한다고 해서 자동으로 문제가 해결되는 것은 아닙니다. DTO 변환이 반드시 트랜잭션 안에서 일어나야 합니다. 트랜잭션이 끝난 뒤 컨트롤러에서 DTO 변환을 하면, 결국 같은 문제가 다시 발생할 수 있습니다.

서비스 계층에서 DTO로 변환하기

제가 선호하는 방식은 서비스 계층에서 조회와 DTO 변환을 함께 끝내는 방식입니다. 이렇게 하면 트랜잭션 안에서 필요한 Lazy 연관 객체를 모두 접근하게 되고, 컨트롤러는 이미 완성된 응답 객체만 반환합니다.


@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    @Transactional(readOnly = true)
    public OrderResponse findOrder(Long orderId) {
        Order order = orderRepository.findById(orderId)
                .orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다."));

        return OrderResponse.from(order);
    }
}

@GetMapping("/orders/{orderId}")
public OrderResponse getOrder(@PathVariable Long orderId) {
    return orderService.findOrder(orderId);
}

이 구조에서는 컨트롤러가 엔티티의 Lazy 필드에 접근하지 않습니다. 서비스 메서드 안에서 필요한 데이터를 모두 읽고 DTO를 완성했기 때문에, 응답 직렬화 시점에 Hibernate Session이 필요하지 않습니다.

 

Fetch Join으로 LazyInitializationException 해결하기

LazyInitializationException을 해결할 때 가장 많이 사용하는 방법 중 하나가 Fetch Join입니다. Fetch Join은 연관 엔티티를 함께 조회해서 Lazy 필드를 미리 초기화하는 방식입니다.


public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("""
        select distinct o
        from Order o
        join fetch o.orderItems
        where o.id = :orderId
    """)
    Optional<Order> findByIdWithItems(@Param("orderId") Long orderId);
}

이제 서비스에서는 주문과 주문 상품을 함께 조회한 뒤 DTO로 변환할 수 있습니다.


@Transactional(readOnly = true)
public OrderResponse findOrder(Long orderId) {
    Order order = orderRepository.findByIdWithItems(orderId)
            .orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다."));

    return OrderResponse.from(order);
}

Fetch Join은 조회 의도가 명확할 때 좋은 선택입니다. “이 API에서는 주문과 주문 상품을 반드시 함께 보여준다”는 요구사항이 있다면, Repository 메서드 이름과 쿼리에서 그 의도가 드러납니다.

주의할 점도 있습니다. 일대다 컬렉션을 Fetch Join하면 결과 row가 늘어날 수 있고, 페이징 쿼리와 함께 사용할 때 예상과 다르게 동작할 수 있습니다. 단건 상세 조회에서는 비교적 단순하게 사용할 수 있지만, 목록 조회에서는 별도 전략을 잡는 편이 안전합니다.

 

EntityGraph로 필요한 연관 관계를 지정하기

LazyInitializationException 해결 방법으로 EntityGraph도 자주 사용합니다. EntityGraph는 Repository 메서드에 어떤 연관 관계를 함께 조회할지 선언하는 방식입니다. JPQL을 직접 작성하지 않아도 되는 장점이 있어, 단순한 조회에서는 코드가 깔끔해집니다.


public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = {"orderItems"})
    Optional<Order> findWithOrderItemsById(Long id);
}

EntityGraph는 쿼리 자체가 복잡하지 않고, 특정 연관 객체만 함께 가져오면 되는 상황에 잘 맞습니다. 반대로 조건이 복잡하거나 조인 대상이 많아지는 경우에는 JPQL Fetch Join이 의도를 더 분명하게 드러낼 때가 있습니다.

팀에서 협업할 때는 “이 메서드는 어떤 연관 데이터를 함께 가져오는가”가 중요합니다. 메서드 이름과 EntityGraph 선언을 함께 보면 조회 범위를 어느 정도 파악할 수 있지만, 조회 조건까지 섬세하게 제어해야 한다면 명시적인 쿼리가 더 읽기 쉬울 수 있습니다.

 

DTO Projection으로 처음부터 필요한 데이터만 조회하기

LazyInitializationException을 피하는 또 다른 방법은 엔티티를 조회한 뒤 DTO로 변환하는 것이 아니라, 처음부터 DTO에 필요한 데이터만 조회하는 것입니다. 화면이나 API 응답에 필요한 필드가 명확하다면 DTO Projection이 더 단순할 수 있습니다.


public record OrderItemDto(
        Long orderId,
        Long itemId,
        String itemName,
        int quantity
) {
}

@Query("""
    select new com.example.order.dto.OrderItemDto(
        o.id,
        oi.id,
        oi.itemName,
        oi.quantity
    )
    from Order o
    join o.orderItems oi
    where o.id = :orderId
""")
List<OrderItemDto> findOrderItemDtos(@Param("orderId") Long orderId);

이 방식은 Lazy 로딩 자체에 의존하지 않습니다. 필요한 필드만 select해서 DTO로 받기 때문에, 트랜잭션 밖에서 연관 객체를 초기화할 일이 없습니다.

다만 DTO Projection은 도메인 로직을 수행해야 하는 경우보다는 조회 전용 API에 더 잘 맞습니다. 엔티티의 상태 변경이나 비즈니스 규칙 검증이 필요한 흐름이라면 엔티티를 조회해서 처리하는 편이 자연스럽습니다. 조회 모델과 도메인 모델을 구분해서 판단하는 것이 좋습니다.

 

@Transactional만 붙이면 LazyInitializationException이 해결될까?

LazyInitializationException이 발생했을 때 가장 쉽게 시도하는 방법이 @Transactional을 추가하는 것입니다. 트랜잭션 안에서 Lazy 필드에 접근하면 예외가 사라지는 경우가 많기 때문에, 겉으로는 해결된 것처럼 보입니다.

하지만 어디에 @Transactional을 붙이는지가 중요합니다. 서비스 계층에서 필요한 데이터를 조회하고 DTO로 변환하기 위해 트랜잭션을 사용하는 것은 자연스럽습니다. 반대로 컨트롤러까지 트랜잭션 범위를 넓히는 방식은 응답 생성과 데이터 접근 책임이 섞일 수 있습니다.


// 권장하기 어려운 예시
@GetMapping("/orders/{orderId}")
@Transactional(readOnly = true)
public OrderResponse getOrder(@PathVariable Long orderId) {
    Order order = orderService.findOrderEntity(orderId);
    return OrderResponse.from(order);
}

컨트롤러에 트랜잭션을 붙이는 방식은 당장 예외를 없애는 데는 도움이 될 수 있습니다. 하지만 계층 간 책임이 흐려지고, 나중에 응답 로직이 커졌을 때 데이터 접근 흐름을 추적하기 어려워집니다. 실무에서는 서비스 계층 안에서 조회 범위와 변환 시점을 명확히 끝내는 쪽이 더 관리하기 좋습니다.

 

FetchType.EAGER로 바꾸는 것은 좋은 해결책일까?

LazyInitializationException을 만났을 때 FetchType.LAZYFetchType.EAGER로 바꾸는 경우가 있습니다. 이 방식은 연관 객체를 항상 즉시 조회하게 만들어 예외를 피할 수는 있습니다.


@OneToMany(mappedBy = "order", fetch = FetchType.EAGER)
private List<OrderItem> orderItems = new ArrayList<>();

하지만 저는 이 방법을 기본 해결책으로 보지 않습니다. EAGER는 해당 연관 관계가 필요한지와 관계없이 항상 조회 대상이 됩니다. 작은 예제에서는 편해 보이지만, 엔티티 관계가 늘어나면 어떤 쿼리에서 어떤 데이터가 함께 조회되는지 예측하기 어려워집니다.

LazyInitializationException은 “항상 미리 가져오라”는 신호가 아니라, “필요한 시점과 조회 범위를 명확히 정하라”는 신호에 가깝습니다. 그래서 FetchType을 바꾸기보다는 API 요구사항에 맞게 Fetch Join, EntityGraph, DTO Projection 중 하나를 선택하는 편이 좋습니다.

 

Open Session In View 설정은 어떻게 봐야 할까?

Spring Boot에서는 Open Session In View, 줄여서 OSIV 설정이 LazyInitializationException과 자주 함께 언급됩니다. OSIV가 켜져 있으면 웹 요청이 끝날 때까지 영속성 컨텍스트를 열어둘 수 있어서, 컨트롤러나 뷰 렌더링 단계에서도 Lazy 로딩이 가능해질 수 있습니다.


spring:
  jpa:
    open-in-view: false

OSIV를 켜두면 LazyInitializationException이 눈에 덜 보일 수 있습니다. 하지만 그만큼 컨트롤러나 JSON 직렬화 과정에서 예상하지 못한 쿼리가 발생할 가능성도 생깁니다. 개발 초기에는 편하게 느껴질 수 있지만, API가 많아지고 팀원이 늘어나면 데이터 조회 위치가 흐려지는 문제가 생길 수 있습니다.

OSIV를 끄는 것이 항상 정답이라고 단정할 수는 없습니다. 다만 API 서버에서는 서비스 계층에서 필요한 데이터를 모두 준비하고, 컨트롤러는 응답만 담당하게 만드는 구조가 더 명확합니다. 그래서 새 프로젝트라면 OSIV를 끄고 LazyInitializationException을 설계 신호로 받아들이는 쪽을 선호합니다.

 

실무에서 사용한 LazyInitializationException 해결 흐름

LazyInitializationException을 해결할 때 저는 먼저 “어떤 화면 또는 API에서 어떤 연관 데이터가 필요한가”를 확인합니다. 예외가 난 필드만 보고 즉시 EAGER로 바꾸면 문제는 숨겨지지만, 조회 설계가 나아지는 것은 아닙니다.

단건 상세 조회라면 Fetch Join 또는 EntityGraph

주문 상세, 게시글 상세, 회원 상세처럼 단건을 조회하면서 연관 데이터가 함께 필요한 경우에는 Fetch Join이나 EntityGraph를 먼저 검토합니다. 조회 목적이 분명하고 연관 범위가 크지 않다면 코드도 단순하게 유지됩니다.


@Transactional(readOnly = true)
public OrderDetailResponse getOrderDetail(Long orderId) {
    Order order = orderRepository.findByIdWithItems(orderId)
            .orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다."));

    return OrderDetailResponse.from(order);
}

이 방식의 장점은 조회 의도가 명확하다는 점입니다. 상세 API에서 필요한 연관 데이터를 Repository 쿼리에서 드러내고, 서비스에서 응답 DTO로 변환하면 이후 계층에서 Lazy 로딩에 기대지 않아도 됩니다.

목록 조회라면 DTO Projection을 먼저 고려

목록 조회에서는 엔티티 그래프를 무리하게 넓히기보다 DTO Projection을 고려하는 편입니다. 목록 API는 보통 화면에 필요한 필드가 제한적입니다. 이런 경우 엔티티 전체와 여러 연관 객체를 가져오는 것보다 필요한 컬럼만 조회하는 방식이 읽기 쉽고 응답 구조도 명확합니다.


public record OrderSummaryResponse(
        Long orderId,
        String orderStatus,
        LocalDateTime orderedAt
) {
}

@Query("""
    select new com.example.order.dto.OrderSummaryResponse(
        o.id,
        o.status,
        o.orderedAt
    )
    from Order o
    where o.member.id = :memberId
    order by o.orderedAt desc
""")
List<OrderSummaryResponse> findOrderSummaries(@Param("memberId") Long memberId);

목록 조회에서 필요한 데이터가 늘어날 때마다 연관 객체를 계속 붙이면 조회 구조가 복잡해질 수 있습니다. 화면에서 필요한 값이 명확하다면 처음부터 조회 전용 DTO를 만드는 것이 더 나은 선택이 되는 경우가 많습니다.

도메인 로직이 필요하면 엔티티 조회 후 트랜잭션 안에서 처리

반대로 단순 조회가 아니라 주문 상태 변경, 결제 취소, 재고 차감처럼 도메인 로직이 필요한 경우에는 엔티티를 조회해서 처리하는 편이 자연스럽습니다. 이때는 필요한 연관 데이터를 트랜잭션 안에서 명확히 로딩하고, 로직 수행과 응답 변환까지 한 흐름 안에서 마무리합니다.


@Transactional
public CancelOrderResponse cancelOrder(Long orderId) {
    Order order = orderRepository.findByIdWithItems(orderId)
            .orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다."));

    order.cancel();

    return CancelOrderResponse.from(order);
}

이런 흐름에서는 LazyInitializationException을 피하는 것보다 더 중요한 기준이 있습니다. 도메인 규칙을 어느 계층에서 실행할지, 필요한 연관 데이터가 무엇인지, 상태 변경 후 어떤 응답을 내려줄지를 분명히 하는 것입니다.

 

LazyInitializationException 해결 방법별 선택 기준

LazyInitializationException 해결 방법은 하나로 고정하기보다 조회 목적에 따라 나누는 것이 좋습니다. 같은 예외라도 상세 조회, 목록 조회, 상태 변경 API에서 선택할 방법이 달라질 수 있습니다.

상황 추천 방식 이유
단건 상세 조회 Fetch Join, EntityGraph 필요한 연관 데이터를 명확히 함께 조회할 수 있습니다.
목록 조회 DTO Projection 응답에 필요한 필드만 조회하기 좋습니다.
도메인 로직 수행 엔티티 조회 후 트랜잭션 안에서 처리 상태 변경과 비즈니스 규칙을 엔티티 중심으로 다룰 수 있습니다.
단순 예외 회피 FetchType.EAGER 변경 지양 조회 범위가 암묵적으로 커지고 예측이 어려워질 수 있습니다.
API 서버 구조 서비스에서 DTO 변환 완료 컨트롤러와 직렬화 단계에서 Lazy 로딩에 의존하지 않습니다.

핵심은 예외 메시지를 없애는 것이 아니라, 데이터 조회 경계를 명확히 만드는 것입니다. LazyInitializationException은 JPA를 잘못 썼다는 뜻이라기보다, 연관 데이터를 언제 어디서 가져올지 아직 코드에 충분히 드러나지 않았다는 신호로 보는 편이 좋습니다.

 

LazyInitializationException을 예방하는 코드 작성 습관

LazyInitializationException은 한 번 해결하고 끝나는 예외가 아닙니다. JPA를 사용하는 프로젝트에서는 엔티티 관계가 추가되고 API 응답이 바뀔 때마다 다시 만날 수 있습니다. 그래서 몇 가지 습관을 팀 기준으로 정해두는 것이 좋습니다.

API 응답은 엔티티가 아니라 DTO로 반환한다

엔티티를 그대로 응답으로 반환하면 Lazy 로딩 문제뿐 아니라 응답 필드가 도메인 모델 변경에 흔들릴 수 있습니다. API 응답은 외부 계약에 가깝기 때문에 DTO로 분리하는 편이 안전합니다.

조회 목적에 맞는 Repository 메서드를 만든다

findById 하나로 모든 상세 조회를 처리하려고 하면, 어떤 연관 데이터가 필요한지 서비스 코드에서 흐려지기 쉽습니다. 상세 조회라면 findByIdWithItems처럼 조회 목적을 드러내는 메서드가 유지보수에 도움이 됩니다.

트랜잭션 안에서 필요한 데이터 준비를 끝낸다

서비스 메서드가 끝난 뒤 컨트롤러나 직렬화 과정에서 Lazy 필드를 건드리지 않도록 해야 합니다. 필요한 데이터 조회와 DTO 변환은 트랜잭션 안에서 마무리하는 것이 좋습니다.

EAGER 변경은 마지막에 검토한다

EAGER는 당장 예외를 없애는 쉬운 선택처럼 보일 수 있습니다. 하지만 연관 관계가 늘어나면 조회 흐름을 예측하기 어려워지고, 다른 API에도 영향을 줄 수 있습니다. 대부분의 경우에는 Fetch Join, EntityGraph, DTO Projection으로 해결하는 쪽이 더 명확합니다.

 

정리: LazyInitializationException은 조회 경계를 다시 보라는 신호입니다

Java LazyInitializationException은 단순히 Lazy 로딩이 나빠서 발생하는 문제가 아닙니다. Lazy 로딩 자체는 필요한 시점까지 연관 데이터 조회를 미루는 유용한 방식입니다. 문제는 그 필요한 시점이 트랜잭션 밖으로 밀려났을 때 발생합니다.

실무에서 해결할 때는 먼저 엔티티를 API 응답으로 그대로 반환하고 있지 않은지 확인합니다. 그다음 단건 상세 조회라면 Fetch Join이나 EntityGraph를 사용하고, 목록 조회라면 DTO Projection을 검토합니다. 도메인 로직이 필요한 경우에는 트랜잭션 안에서 엔티티를 조회하고 필요한 연관 데이터까지 준비한 뒤 응답 DTO로 변환하는 흐름이 좋습니다.

LazyInitializationException을 만났을 때 가장 피해야 할 대응은 무작정 FetchType.EAGER로 바꾸는 것입니다. 예외는 사라질 수 있지만, 조회 의도가 코드에서 흐려질 수 있습니다. 좋은 해결은 예외를 숨기는 것이 아니라, 어떤 데이터가 언제 필요한지 코드에 명확히 남기는 것입니다.

결론적으로 LazyInitializationException은 JPA 조회 설계를 점검할 좋은 계기입니다. 서비스 계층에서 필요한 데이터를 명확히 조회하고 DTO로 응답을 분리하면, 예외도 줄고 코드의 의도도 훨씬 분명해집니다.

:contentReference[oaicite:0]{index=0}