스프링 JPA 연관 필드, Lazy Initialization Exception 발생 관련 개념 정리

, ,

스프링 JPA 연관 필드(Association Field)는 편리하지만 레이지 로딩 설정시 Lazy Initialization Exception이 발생할 수 있습니다. 보통은 알아서 처리해 주므로 이 예외를 만날 일이 없지만 시스템이 복잡해지면 만날 수 있고, 해결하려면 원리를 알아야 합니다.

flatlay photography of camera module parts
간단한 건 대충 하면 맞지만 복잡해지면 원리를 알아야 합니다 ⓒ사진 Vadim Sherbakov

한 줄 답변

  1. 우선 Eager 로딩으로 변경해 보세요.
  2. 메서드에 @Transactional 어노테이션을 붙이는 방법이 있습니다.

아래는 JPA 레포지토리에서 커스텀 쿼리를 사용한 경우입니다.

  1. Eager 로딩을 설정했는데도 예외가 뜨거나, Eager 로딩 설정을 할 수 없는 상황이라면 Repository에서 Native 쿼리를 사용한 게 아닌지 체크해 보세요. JPQL을 사용하면 해결될 수도 있습니다.
  2. JPQL의 Fetch Join 문법을 사용해서 디비에서 프로퍼티 관련 데이터까지 한꺼번에 불러와 보세요.

개념 알아 보기

LazyInitializationException은 객체의 연관 필드에 접근하는 시점에 프로그램이 디비와 연결돼 있지 않으면(디비 세션이 없으면) 발생하는 예외입니다.

아니 프로그램이 왜 디비와 연결돼 있지 않은가? 하실 수 있지만, 스프링 JPA에서 Repository를 사용할 때, 기본적으로 메서드 단위로 디비 세션이 열렸다가 닫혔다가 합니다. 이는 디비 세션이 비싼 자원이기 때문입니다. 세션이 많이 열려 있으면 시스템 성능, 네트워크 트래픽 성능(디비가 원격에 있는 경우겠죠), 디비 성능이 모두 저하되기 때문에 디비 세션은 필요 이상으로 열리지 않게 관리해야 합니다.

기본적으로 스프링은 서블릿 요청 안에서 디비 세션을 계속 열어 둡니다(spring.jpa.open-in-view=true 설정이 기본값이므로).1

그러나 서블릿 요청이 아닌 경우는 어떨까요? 예컨대 웹소켓이라면 말입니다. 그 때는 트랜잭션 단위로 디비 세션이 열리고 닫힙니다. 즉, 레포지토리의 경우에도 기본적으로 메서드 단위로 디비 세션이 열리고 닫히는 것입니다.

이건 spring.jpa.open-in-view=false 설정을 하면 서블릿 안에서도 마찬가지입니다.

이 지점에서 이슈가 되는 게 바로 연관 필드의 Lazy 로딩입니다. 아직 로딩되지 않은 프로퍼티가 있는데, 디비 세션이 닫혔다고 가정해 봅시다. 이제 해당 프로퍼티를 사용하려고 하면 객체는 디비에 접근하려고 하는데, 디비 세션은 이미 닫힌 상태기 때문에 LazyInitializationException이 발생하는 것입니다.

그래서 해결책은 Eager 로딩(인스턴스 생성시 연관 필드도 모두 디비에서 불러오는 방법)을 사용하거나, 세션이 닫히지 않도록 메서드에 @Transactional 어노테이션을 붙이는 것이죠.

그런데 저는 커스텀 쿼리를 사용한 상황이었습니다. Lazy 로딩을 사용한 게 아니었습니다.

커스텀 쿼리에서 native 속성을 true로 주고 JPQL이 아닌 native 쿼리를 사용했는데요. 이렇게 하니 디비에서 연관 필드에 관련된 데이터를 불러오지 못해서 연관 필드에 값이 들어오지 못했고, 이후 연관 필드를 사용하려고 하니까 LazyInitializationException이 발생했던 것이죠.

저는 레포지토리에서 네이티브 쿼리를 JPQL로 바꾸니까 문제가 해결됐습니다. JPQL은 연관 필드를 확인해서 관련된 데이터까지 알아서 불러오더군요.

이제 문제를 좀더 자세히 살펴 보고 싶은 분들은 아래 코드까지 살펴 보세요.

문제 상황 정리

DeviceStatus 클래스에 연관 필드인 Parent parent가 있는 상황이었습니다. 코드는 아래와 같습니다.

class DeviceStatus {
    // 생략

    @ManyToOne
    @JoinColumn(name = "parent_id", insertable = false, updatable = false)
    private Parent parent;
}

이제 다른 곳에서 DeviceStatusList를 불러 오려고 했습니다.

List<DeviceStatus> deviceStatusList = deviceStatusRepository.findByUserIdAndDeviceGroupId(userId, deviceGroupId);

그런데 위 코드에 있는 findByUserIdAndDeviceGroupId는 네이티브 쿼리를 사용하는 메서드였습니다. 아래처럼 정의돼 있었습니다.

디바이스 상태 데이터를 불러 오는데, 디바이스 그룹 정보는 디바이스 상태 테이블에 없고 디바이스 테이블에 있으니 디바이스 상태 테이블과 디바이스 테이블을 조인해서 특정 그룹에 있는 디바이스들의 상태만 불러오는 쿼리입니다.

@Query(
        value = "select s.* from device_status sn" +
                "join device d on s.device_id = d.idn" +
                "where d.user_id = :userId andn" +
                "      d.device_group_id = :deviceGroupId",
        nativeQuery = true
)
List<StatApp> findByUserIdAndDeviceGroupId(
    @Param("userId") Integer userId, 
    @Param("deviceGroupId") Integer deviceGroupId
);

그런데 이렇게 데이터를 불러온 뒤 아래처럼 로그를 찍으니 LazyInitializationException이 발생한 것입니다.

log.debug("deviceStatusList={}", deviceStatusList);

원인 정리

원인은 간단했습니다. 레포지토리에서 네이티브 쿼리를 사용하면서, parent 필드를 채울 데이터를 가져오지 못하게 됐던 것입니다. 네이티브 쿼리를 사용하면 딱 쿼리에서 정의한 데이터만을 가져오게 됩니다.

그래서 parent 필드를 Eager 로딩으로 설정했음에도 데이터를 채우지 못했던 것이죠.

이후 deviceStatusList의 내용물을 로그로 내보내게 했는데요. deviceStatusList의 내용물을 출력하려면 device.parent의 내용도 출력을 해야 하죠. 그런데 연관 필드로 정의돼 있는 이 parent 필드의 실제 데이터가 없으니 예외가 발생한 것입니다.

해결책

아래처럼 네이티브 쿼리를 JPQL로 변경하자 문제가 해결됐습니다.

@Query(
        value = "select s from DeviceStatus sn" +
                "join Device d on s.deviceId = d.idn" +
                "where d.userId = :userId andn" +
                "      d.deviceGroupId = :deviceGroupId"
)
List<StatApp> findByUserIdAndDeviceGroupId(
    @Param("userId") Integer userId, 
    @Param("deviceGroupId") Integer deviceGroupId
);

JPQL이 DeviceStatus에 정의된 parent 속성을 참고해 알아서 데이터를 가져와 세팅하더군요.

Fetch Join 문법

이 과정에서 Fetch Join 문법이라는 것도 알게 됐는데요. 제 경우 이번엔 필요가 없었습니다. 그러나 다른 경우에 필요할 수 있으니 메모해 둡니다.

Fetch Join은 JPQL 작성시, 같이 가져올 데이터를 명시적으로 지정하는 것을 말합니다.

제 경우에는 Parent 데이터를 가져와야 했으므로 아래처럼 적어 주면 됩니다. value의 세 번째 줄에 join fetch s.parent가 추가된 것을 볼 수 있습니다. s(DeviceStatus)의 parent 속성을 가져오라고 하는 것입니다.

@Query(
        value = "select s from DeviceStatus sn" +
                "join Device d on s.deviceId = d.idn" +
                "join fetch s.parentn" +
                "where d.userId = :userId andn" +
                "      d.deviceGroupId = :deviceGroupId"
)
List<StatApp> findByUserIdAndDeviceGroupId(
    @Param("userId") Integer userId, 
    @Param("deviceGroupId") Integer deviceGroupId
);

저는 parent 필드에 @ManyToOne 어노테이션을 붙여서 연관 필드로 사용하고 있으므로 JPQL이 Fetch Join을 명시하지 않아도 알아서 처리한 것 같습니다.

그러나 그렇지 않은 경우에는 이렇게 필요한 속성까지 가져와서 데이터를 채우도록 하는 쿼리가 유용할 것 같습니다. 물론 정확히 어떤 경우인지는 아직 잘 모르겠습니다.

(참고로 이상의 내용을 정리하는 데 ChatGPT의 도움을 많이 받았습니다. 자바처럼 널리 사용되는 개발 언어에서 발생하는 이슈인데, 어딘가에 잘 정리돼 있지는 않는 내용을 공부할 때 ChatGPT가 꽤 효율적입니다. 특히 자바 같은 경우는 공식 문서가 많이 복잡해서 더욱 그렇습니다).

(사진 Vadim Sherbakov on Unsplash)

  1. open in view에 대해 설명할 때 “뷰 렌더링 중”이라는 표현이 나오는데, 뷰 렌더링 시에 그렇다는 것이지 뷰 렌더링 중에 그렇다 뜻은 아닌 것으로 이해됩니다. 대표적인 것이 스프링 자신의 로그 메시지입니다.
    spring.jpa.open-in-view 속성을 설정하지 않고 스프링부트를 시작하면 “spring.jpa.open-in-view는 기본적으로 활성화돼 있습니다. 따라서 데이터베이스 쿼리는 뷰 렌더링 중에도 실행될 것입니다. 이 경고를 끄려면 spring.jpa.open-in-view를 명시적으로 설정하세요(spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning)” 하고 경고 메시지가 나옵니다.
    그런데 이 기능용 클래스인 OpenSessionInViewFilter는 서블릿 필터입니다. 즉, 서블릿 요청이 처리되기 전에 디비 세션을 여는 것입니다(서블릿 필터는 요청 처리 전에 적용됩니다). 이 클래스에 대한 공식 문서를 보면 “이 필터는 하이버네이트 세션을 현재 스레드에서 사용가능하게 해 줍니다(This filter makes Hibernate Sessions available via the current thread)” 하고 설명돼 있습니다. 쉽게 말하면 요청 내내 디비 세션이 열려 있게 된다는 뜻이지요.
    블로그 글인 Open session in view is evil을 보면 뷰 렌더링 없이 System.out.println()만으로도 spring.jpa.open-in-view 속성이 작동하는 것을 확인할 수 있습니다.
    제가 실험을 해 보기도 했습니다. HTTP 요청에서는 spring.jpa.open-in-view 속성 설정이 작동했고, 웹소켓에서는 그렇지 않다는 것을 확인했습니다. ↩︎

👇 카테고리 글 목록

, ,

대표글

댓글 남기기