트랜잭션 밖에서 연관 데이터를 읽다가 생긴 문제
엔티티 조회는 정상적으로 끝났는데 후속 알림 처리에서 오류가 발생했습니다. 조회 당시에는 읽지 않았던 연관 컬렉션에 나중에 접근했고, 그때는 Hibernate 세션이 닫혀 있었습니다.
수정한 코드는 많지 않았지만, 조회한 엔티티를 언제 어디서 사용하는지 다시 살펴봐야 했습니다.
조회 시점과 연관 데이터 접근 시점
지연 로딩된 연관 데이터는 접근하는 순간 DB에서 읽을 수 있습니다. 아직 초기화되지 않은 컬렉션을 세션이 닫힌 뒤 읽으려 하면 LazyInitializationException이 발생할 수 있습니다.
이때 트랜잭션 밖이라는 표현만으로 원인을 모두 설명하기는 어렵습니다. 실제로 세션이 열려 있는지와 연관 데이터가 이미 초기화됐는지를 함께 봐야 합니다. 문제를 일으킨 경로에서는 후속 로직이 실행될 때 필요한 컬렉션을 읽을 수 없는 상태였습니다.
필요한 연관 데이터 미리 로딩
당시 문제 구간은 연관 데이터가 비교적 작고 접근 방식도 일정했습니다. 그래서 후속 처리에서 필요한 데이터를 미리 읽도록 eager 로딩을 적용했습니다.
이 선택은 해당 관계를 사용하는 다른 조회에도 영향을 줍니다. 한 곳의 예외가 사라졌더라도 필요 없는 데이터까지 읽는 경로가 생기는지 확인해야 합니다. 조회 방식과 연관 데이터 규모가 다른 곳에 같은 결정을 그대로 적용하기는 어렵습니다.
쿼리별 조회 범위 제어
대안으로는 특정 조회에서만 fetch join이나 EntityGraph를 적용하거나, 트랜잭션 안에서 필요한 값을 DTO로 만드는 방법이 있습니다. 아래는 사용자와 역할을 한 번에 읽는 개념 예시입니다.
@Query("select u from User u left join fetch u.roles where u.id = :id")
Optional<User> findWithRoles(@Param("id") Long id);left join을 사용하면 역할이 없는 사용자도 조회 대상에 남습니다. 이 코드는 당시 엔티티 구조를 옮긴 것이 아니라, 필요한 쿼리에서 조회 범위를 지정하는 방법을 보여줍니다.
DTO를 사용하는 경우에도 필요한 연관 값을 세션이 열려 있는 동안 읽고 DTO를 완성해야 합니다. 엔티티를 DTO 안에 넣어 반환하는 것만으로 지연 로딩 문제가 사라지는 것은 아닙니다.
Eager와 쿼리 수의 구분
Eager는 연관 데이터를 미리 준비한다는 의미이지, 반드시 JOIN 한 번으로 조회한다는 뜻은 아닙니다. Hibernate 공식 문서 는 EAGER 관계도 추가 SELECT로 조회할 수 있고, 목록 조회에서 N+1 문제가 발생할 수 있다고 설명합니다.
따라서 변경 이후에는 예외 발생 여부와 실제 SQL을 따로 확인해야 합니다. 단건 조회에서 필요한 값이 준비됐는지, 목록에서는 추가 쿼리가 얼마나 발생하는지, 읽는 데이터가 과도하게 늘지는 않았는지를 봐야 합니다.
이번 사례에서는 엔티티를 조회한 위치보다 그 이후 연관 데이터를 사용하는 시점이 문제를 이해하는 데 중요했습니다. 비슷한 모델을 사용하는 다른 서비스도 같은 경로가 있는지 점검할 필요가 있었습니다.
다음 글에서는 Splunk REST API로 여러 서비스의 로그를 함께 분석한 과정을 다룹니다.