들어가며
JPA를 이용해서 개발을 하다보니 처음에는 순환 참조 에러, 그 이후로는 지연 로딩 관련 에러를 계속 마주치게 된다.
특히나 지연로딩에 관련해서 애를 많이 먹었기 때문에 이번에는 지연로딩과 즉시로딩, 그리고 그 둘을 알기 위해 프록시까지 정리를 해보고자 한다.
객체는 객체 그래프로 연관된 객체들을 탐색한다. 그런데 객체가 DB에 저장되어 있으므로 연관된 객체를 마음껏 탐색하기는 어렵다.
JPA 구현체들은 이 문제를 해결하려고 프록시라는 기술을 사용한다. 프록시를 사용하면 연관된 객체를 처음부터 DB에서 조회하는 것이 아니라, 실제 사용하는 시점에서 DB에서 조회할 수 있다. 하지만 자주 함께 사용하는 객체들은 조인을 사용해서 함께 조회하는 것이 효과적이다.
JPA는 즉시 로딩과 지연 로딩이라는 방법으로 둘을 모두 지원한다.
출처 - 자바 ORM 표준 JPA 프로그래밍 8장 프록시와 연관관계 관리
💡 객체 그래프 탐색에 관한 내용은 <자바 ORM 표준 JPA 프로그래밍> 책의 1장에 소개되어있다. 이 내용에 관해서는 다음번 포스팅에서 다뤄보기로 한다.
프록시(Proxy)
프록시(Porxy)의 사전적 의미는 '대리' 혹은 '대리인'이다. JPA에서의 프록시는 실제 엔티티 객체 대신에 DB 조회를 지연할 수 있게 해주는 가짜(대리) 객체를 의미한다.
프록시 객체는 정확히 어떤 순간에 만들어지고 사용될까?
지난번 영속성 컨텍스트를 공부하면서 JPA에서 엔티티를 조회할 때 영속성 컨텍스트의 1차캐시에 저장된 엔티티가 없으면 DB를 조회한다고 했었다. 이렇게 엔티티를 직접 DB에서 조회하면, 엔티티를 실제 사용하든 사용하지 않든 DB를 조회하게 된다. 만약 엔티티를 실제 사용하는 시점까지 DB 조회를 미루고 싶다면 프록시 객체를 반환하도록 하는 메소드를 호출하면 된다.
글만 봐서는 이해하기 쉽지 않다. Spring Data JPA를 사용할 때에 프록시를 직접 다루지 않았고, 지연 로딩 에러가 터졌을 때 에러로그에서나 마주쳤던 녀석이기 때문이다.
프록시 객체가 DB 조회를 미루는 방식은 하이버네이트를 이용해서 확인해볼 수 있다.
Member 객체가 이미 존재한다고 가정하고 우리가 필요한 코드만 보자.
// ...엔티티 매니저와 트랜잭션, 혹은 그 외 코드들 생략
Member findMember = em.find(Member.class, member.getId());
System.out.println("findMember.id = " + findMember.getId());
System.out.println("findMember.name = " + findMember.getName());
// ...엔티티 매니저와 트랜잭션, 혹은 그 외 코드들 생략
이렇게 find() 메소드를 사용하게 되면 영속성 컨텍스트에 엔티티가 없을 경우에 DB에 쿼리를 날리게 된다.
이번에는 프록시 객체를 조회하는 메소드를 사용하면 get 메소드를 호출하기 전까지는 DB에 쿼리를 날리지 않는다.
// ...엔티티 매니저와 트랜잭션, 혹은 그 외 코드들 생략
Member ReferenceMember = em.getReference(Member.class, member.getId());
// ...엔티티 매니저와 트랜잭션, 혹은 그 외 코드들 생략
이제 우리가 엔티티를 실제로 사용하고 싶다. 이때 get 메소드를 호출하면 다음과 같이 SELECT 쿼리가 DB로 날아가면서 데이터가 조회된다.
Member ReferenceMember = em.getReference(Member.class, member.getId());
System.out.println("findMember.id = " + ReferenceMember.getId());
System.out.println("findMember.name = " + ReferenceMember.getName());
이렇게 프록시 객체는 get 메소드를 통해 실제 사용될 때 DB를 조회해서 실제 엔티티 객체를 생성하게 되는데, 이걸 프록시 객체의 초기화 라고 한다.
프록시의 특징
- 프록시는 객체를 처음 사용할 때 한번만 초기화된다.
- 프록시 객체를 초기화하는 것은 프록시가 실제 엔티티로 바뀌는 게 아니라 프록시 객체를 통해서 실제 엔티티에 접근하는 것이다.
- 프록시 객체는 원본 엔티티를 상속받은 객체이므로 타입 체크 시에 주의해서 사용해야 한다.
- 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 프록시가 아닌 실제 엔티티를 반환한다.
- 초기화는 영속성 컨텍스트의 도움을 받아야 가능하다. 따라서 준영속 상태의 프록시를 초기화하면 하이버네이트는
LazyInitalizationException
예외를 발생시킨다.
이렇게 프록시에 대해 알아보았다.
하지만 위에서 다룬 예시는 연관관계가 존재하지 않는 단일 테이블만을 다룬 예시이기 때문에 프록시가 왜 존재하는지 알기 어렵다.
프록시가 빛을 발하는 경우는 엔티티간의 연관관계가 있을 때이다.
우리가 일대다 <-> 다대일로 연관관계 매핑을 해두었을 때, 엔티티를 조회하면 당장 사용하지 않는 참조된 엔티티까지 전부 불러온다면 성능상 문제가 발생하게 될 것이다.
예를 들어, Post와 Comment 엔티티가 서로 연관관계 매핑이 되어있다고 했을 때 Post만 조회하고 싶은데 Comment까지 함께 불러와진다면 SELECT 쿼리가 Post와 Comment 모두에게 날아가기 때문에 성능이 느려질 수 밖에 없다.
이렇게 한번에 연관된 엔티티까지 한번에 불러오는 방식이 즉시로딩, 사용하고자 하는 엔티티를 호출하는 시점에 불러오는 방식을 지연로딩이라고 하는데, 프록시 개체는 주로 연관된 엔티티를 지연 로딩 할 때 사용된다.
즉시 로딩과 지연 로딩
즉시 로딩(EAGER LOADING)
즉시 로딩은 엔티티를 조회할 대 연관된 엔티티도 함께 조회하는 방식이다.
즉시 로딩을 사용하기 위해서는 엔티티의 연관관계 설정 어노테이션에 다음과 같은 속성을 부여해주면 된다.
@ManyToOne(fetch = FetchType.EAGER)
즉시 로딩으로 엔티티를 불러오게되면 JPA 구현체(하이버네이트)는 즉시 로딩을 최적화하기 위해 가능하면 조인 쿼리를 사용한다.
아래 사진처럼 쿼리 한 번으로 두 엔티티를 모두 조회하는 것이다.
지연로딩(LAZY LOADING)
반대로 지연 로딩은 연관된 엔티티를 실제 사용할 때 조회하는 방식이다.
지연 로딩을 사용하기 위해서는 엔티티의 연관관계 설정 어노테이션에 다음과 같은 속성을 부여해주면 된다.
@ManyToOne(fetch = FetchType.LAZY)
지연 로딩으로 엔티티를 조회하고 쿼리문을 확인해보자.
// ...엔티티 매니저와 트랜잭션, 혹은 그 외 코드들 생략
Member member = em.find(Member.class, 2L);
System.out.println("member.getName = " + member.getName());
// ...엔티티 매니저와 트랜잭션, 혹은 그 외 코드들 생략
지연 로딩으로 member만 조회하고 team을 조회하지 않았기 때문에 member를 조회하는 쿼리만 날아가는 걸 볼 수 있다. 그럼 이번에는 team 객체도 조회해보자.
// ...엔티티 매니저와 트랜잭션, 혹은 그 외 코드들 생략
Member member = em.find(Member.class, 2L);
System.out.println("member.getName = " + member.getName());
System.out.println("================");
Team team = member.getTeam(); // 프록시 객체
System.out.println("member.getTeam.getName = " + team.getName()); // 실제 객체 조회 후 반환
// ...엔티티 매니저와 트랜잭션, 혹은 그 외 코드들 생략
이번에는 member를 조회하는 쿼리문을 날리고 나서, team안에 있는 무언가를 조회하기 위해 getName() 메소드를 호출하고 나서야 team을 조회하는 쿼리문을 날리는 걸 확인할 수 있다.
이때, 헷갈리면 안되는 부분있다. team을 조회하는 쿼리를 날리는 시점은 member에서 getTeam() 메소드를 호출하는 시점이 아니라는 점이다. getTeam() 메소드를 호출하는 시점에는 팀을 조회하는 대신에 조회한 회원의 team 필드(멤버변수)에 프록시 객체를 넣어두게 되고, getName()으로 team 객체를 건드렸을 때 비로소 team을 조회하는 쿼리를 날려서
한번 확인해보자.
// ...엔티티 매니저와 트랜잭션, 혹은 그 외 코드들 생략
Member member = em.find(Member.class, 2L);
System.out.println("member.getName = " + member.getName());
System.out.println("================");
Team team = member.getTeam(); // 프록시 객체
System.out.println("프록시 객체 : " + team.getClass());
System.out.println("================");
String name = team.getName(); // 실제 객체 조회
System.out.println("member.getTeam.getName = " + name);
// ...엔티티 매니저와 트랜잭션, 혹은 그 외 코드들 생략
참고로 조회 대상이 영속성 컨텍스트에 이미 있으면 프록시 객체를 사용하지 않고 실제 객체를 사용한다고 한다. 영속성 컨텍스트에 저장되어 있는데 굳이 프록시 객체를 사용할 필요 없이 영속성 컨텍스트에서 가져와서 쓰면 되기 때문이다.
프록시와 즉시 로딩 주의
이렇게 프록시, 즉시 로딩 그리고 지연로딩을 정리해 보았다. 그런데 여기서 드는 의문점이 한가지가 있다.
그냥 전부다 즉시 로딩으로 마음 편하게 데이터를 가져오면 되는거 아닐까?
이건 좋지 않은 생각이라고 김영한 강사님께서 말하셨다. 처음부터 연관된 엔티티를 모두 영속성 컨텍스트에 올려두는 것은 현실적이지 않을 뿐더러 연관된 엔티티가 하나거나 매우 적은 수라면 즉시 로딩을 사용해도 크게 문제가 없지만 만약 수 많은 데이터를 담고 있다면 해당 데이터들을 함께 로딩하기 위해 그 데이터의 수 만큼 쿼리문을 날리기 때문이다.
또, 즉시 로딩을 사용하게 되면 JPQL을 사용할 때, N + 1 문제에 직면하게 된다.
우리가 Spring Data JPA를 사용하기 위해 인터페이스에 JpaRepository
를 상속받고 인터페이스가 제공하는 메소드를 호출해서 사용하게 되는데, 이때 JPA는 메소드 이름을 분석해서 JPQL을 생성한 후 쿼리를 날리게 된다. 따라서 우리가 직접 JPQL을 구현하지 않더라도 내부적으로 JPQL을 사용하고 있는 것이다.
이렇게 되면 N + 1의 문제에서 벗어나기 힘들다.
따라서 실무에서는 대부분 (강사님은 거의 무조건이라고 강조하셨다) 지연 로딩을 사용하도록 권장하고 있다고 한다.
주의해야할 점은 JPA가 제공하는 연관관계 매핑 어노테이션의 기본 fetch 전략이 다음과 같기 때문에 지연 로딩을 사용하고자 할 때 속성을 잘 설정해주어야한다는 것이다.
@ManyToOne
,@OneToOne
: 즉시 로딩 (FetchType.EAGER
)@OneToMany
,@ManyToMany
: 지연 로딩 (FetchType.LAZY
)
마치며
드디어 벼르고 벼르던 즉시 로딩과 지연 로딩에 대한 정리가 끝이 났다.
JPA를 이용한 개발 공부를 하면서 지연 로딩 관련 에러를 정말 많이 봤는데, 이번 기회로 왜 이런 에러를 뱉어내는지 어느 정도는 이해를 하면서 에러를 마주할 수 있게 되었다.
출처 및 참고
'Study > Spring boot' 카테고리의 다른 글
[Spring boot] WebClient 사용해보기 - 모듈화 1-2 (0) | 2023.08.26 |
---|---|
[Spring boot] WebClient 사용해보기 - 모듈화 1-1 (0) | 2023.08.25 |
[JPA] 영속성 관리 (0) | 2022.06.21 |
[JPA] JPA, Hibernate, Spring Data JPA의 차이점 (0) | 2022.06.20 |
[Spring boot] RestControllerAdvice를 이용한 예외 처리 (0) | 2022.06.13 |