-
JPA 지연 로딩, 결국 ProxySpring Framework 2021. 11. 21. 23:42반응형
1) 서론
JPA가 적용된 서버의 로그를 보면, LazyInitializationException이 빠지지 않고 항상 올라오곤 합니다. 일주일에 한 개씩은 발생하는 에러 같은데요.
사실 이는 단순한 문법적 오류이며, 하이버네이트가 어떤 식으로 지연 로딩하는지에 대한 깊은 이해가 부족하기 때문이라고 생각합니다. "그냥 get()하면 가지고 오는 거 아닌가?"라고 생각하기 쉽습니다. 저 또한 그랬습니다.
결론부터 말씀드리면, 스프링 프레임워크에서 지겹게 사용되는 프록시가 원인입니다.
해당 에러들을 해결하며 공부한 것을 정리해보겠습니다.
2) 에러 코드
(모든 코드는 임의로 만들어낸 상황입니다)
테스트 코드는 아직 부족합니다. 참고만 해주세요.
public class Player { ... 생략 ... @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "teamId") private Team team; }
@Test void lazyInitializationException() { // given // 이미 team 객체는 생성되어 있는 상태 Team team = teamRepository.findById(1); Player player = new Player(); player.setPlayerName("박지성"); player.setTeam(team); playerRepository.save(player); player = playerRepository.findById(1); // when // LazyInitializationException 발생 의도 String teamName = player.getTeam().getTeamName(); // 박지성의 팀 이름 조회 // then assertThat(teamName, is(equalTo("대한민국"))); }
- Team(대한민국) - Player(박지성)이 1:N으로 매핑되어있습니다.
- Player를 바탕으로 Team을 조회합니다.
could not initialize proxy [com.example.jpapractise.Team#1] - no Session org.hibernate.LazyInitializationException: could not initialize proxy [com.example.jpapractise.Team#1] - no Session
- LazyInitializationException 발생
3) LazyInitializationException 이유
3. 1) 프록시가 핵심
하이버네이트에서 지연 로딩(lazy lodaing)은 프록시를 기반으로 합니다. 프록시 개념이 조금 생소합니다.
프록시(proxy)는 영어 단어 그대로 "대리"라는 의미입니다. 네트워크에서 사용되는 프록시 서버가 익숙할 것 같은데요. 이는 서버와 클라이언트 사이 프록시 서버를 사용해서 캐싱, 보안 목적의 접근 제한 등에 사용됩니다.
하지만 스프링 프레임워크와 하이버네이트에서는 프로그래밍적으로 "프록시 패턴"에 조금 더 집중해야 할 것 같습니다.
프록시 패턴은 디자인 패턴 중 하나인데요. 요청자가 타깃(target) 객체를 바로 조회하는 것이 아닌, 프록시 객체를 조회합니다. 그리고 해당 프록시 객체가 타깃 객체를 호출하는 방식입니다.
요청 --> 프록시 객체 --> 타깃 객체
프록시 패턴 자체의 깊은 내용은 아래 글을 참고 부탁드립니다.
3. 2) 그래서 왜 프록시 이야기를 하죠?
위에서 언급했듯이 하이버네이트의 지연 로딩은 프록시를 기반으로 합니다.
지연 로딩의 대상이 되는 객체는 곧바로 DB에 접근하여 값을 조회하지 않습니다. 실제로 조회하고자 하는 대상을 필요할 때까지 미루는것이 핵심인데요. 이를 미루기 위해 실제 엔티티 생성하지 않고, 프록시 객체를 생성합니다.
조회 대상의 프록시 객체를 생성해서 영속성 컨텍스트에 저장하고, 실제로 해당 객체가 사용될 때 DB 조회 후 엔티티 생성하는데요. 이때 실제 사용될 엔티티 생성하는 행위를 "영속성 초기화"라고 합니다.
Player 객체를 호출할 때 포함된 필드인 Team 객체는 프록시 객체입니다. 영속성 컨텍스트에 Team 프록시 객체는 가지고 왔지만, Team 프록시 객체의 필드들은 가지고 오지 않았습니다.
영속성 컨텍스트의 생명주기는 트랜잭션 단위인데요. 트랜잭션은 스프링 부트에서 CGLIB Proxy 사용으로 클래스 단위입니다.
즉, Team 프록시 객체를 가지고 오는 것까지가 하나의 트랜잭션입니다. Team.teamName 조회할 때는 이미 트랜잭션이 끝이난 상태이며, 영속성 컨텍스트는 종료됐습니다.
영속성 컨텍스트가 종료되었다는 의미는 프록시 객체도 준영속(detached) 상태가 되었다는 의미와 같습니다. 즉, 이미 영속성이 끝났으며, 프록시 객체를 초기화할 수 없습니다. 그렇기 때문에 실제 엔티티를 조회할 수도 없습니다.
// Player의 Team 프록시 클래스까지 하나의 트랜잭션 String teamName = player.getTeam().getTeamName();
그래서 LazyInitializationException 에러가 발생합니다.
4) 해결 방법
@BeforeAll // @Transactional 메서드 시작 전 save() 위함 @Test void saveBefore() { // given Player player = new Player(); Team team = teamRepository.findById(1); player.setPlayerName("박지성"); player.setTeam(team); playerRepository.save(player); // when player = playerRepository.findById(1); // then assertThat(player.getPlayerName(), is(equalTo("박지성"))); }
- 테스트 코드에서 @Transactional 사용 시 rollback 되므로, 다른 메서드에서 save() 합니다.
@Test @Transactional void lazyInitializationException() { // given Player player = playerRepository.findById(1).orElseThrow(); // when // LazyInitializationException 의도 String teamName = player.getTeam().getTeamName(); // then assertThat(teamName, is(equalTo("대한민국"))); System.out.println("\n teamName >>>>> " + teamName); }
- @Transactional 사용으로 메서드 전체를 하나의 트랜잭션으로 묶습니다
테스트 통과했고, teamName이 출력되는 것을 볼 수 있습니다.
5) 대안
사실 꼭 @Transactional 애노테이션을 붙이는것이 해결 방법은 아닙니다. 너무 긴 트랜잭션 때문에 DB락을 너무 오래 잡게 되는 경우에는 사용할 수 없습니다.
이럴 때는 JPQL 이용한 join fetch 혹은 EntityGraph를 사용해서 한 번에 가지고 올 수도 있습니다. 다른 곳에서는 lazy로 사용하고, 필요한 부분에서만 한 번에 가지고 올 수 있습니다.
위의 경우에 @Transactional을 사용해서 해결한 이유는 지연 로딩의 트랜잭션 범위와 프록시를 설명하기 위함일 뿐입니다. 상황에 맞게 적절한 방법을 선택하면 될 것 같습니다.
반응형'Spring Framework' 카테고리의 다른 글
JPA cascade 멈춰! (2) 2022.02.02 @Transactional rollback과 테스트 의문점 (0) 2022.01.24 JPA N+1 문제를 해결해보자 (0) 2021.08.20 [JPA, Redis]페이지별 결과 캐싱 (0) 2021.08.11 SpringBoot 테스트 중 Bean을 찾을 수 없을 때 (0) 2021.07.25