JPA - 프록시와 즉시, 지연로딩
프록시(Proxy)
A 엔티티와 B 엔티티가 N:1의 연관관계를 가지고 있다고 가정해보자. 우리가 A 엔티티를 조회할 때 항상 B 엔티티가 필요하지 않을 수도 있다. 예를 들어 회원 엔티티를 조회할 때 연관된 팀 엔티티는 비즈니스 로직에 따라서 사용될 때도 있지만, 그렇지 않을 때도 있다.
예를들면 우리가 이미 팀을 알고있는 회원은 회원 정보만 궁금할 것이고, 아무 정보도 모르는 회원은 회원 정보와 팀 정보 모두 궁금할 것이다. 즉, 하고싶은 말은 회원 정보만 궁금할 때는 회원 엔티티만 사용하므로 연관된 팀 엔티티를 불러올 필요가 없다는 것이다.
JPA는 이런 문제를 해결하려고 엔티티가 실제 사용될 때까지 데이터베이스 조회를 지연하는 방법을 제공하는데 이것을 지연 로딩이라고 한다. 쉽게 이야기해서 team.getName() 처럼 팀 엔티티의 어떤 속성을 사용할 때 데이터베이스에서 팀 엔티티를 조회하는 것이다.
지연 로딩을 이해하기 위해서는 우선적으로 프록시의 개념에 대한 이해가 필요하니 프록시가 무엇인지 알아보자.
프록시(Proxy)의 기초
JPA에서 식별자로 엔티티를 하나 조회할 때 EntityManager.find() 를 사용해서 조회하게 된다. 이 메서드를 이용하면 영속성 컨텍스트에 엔티티가 없으면 데이터베이스를 조회하는 특징이 있다.
Member.class
@Entity
@Getter
public class Member {
@Id
private String username;
@ManyToOne
private Team team;
}
다음과 같이 회원 엔티티를 조회하면 연관관계에 있는 팀 엔티티를 사용하든 말든 조인을 이용해서 데이터베이스에서 같이 불러오게 된다.
Member member = em.find(Member.class, "member1");
엔티티를 실제 사용하는 시점까지 데이터베이스 조회를 미루고 싶다면 EntityManager.getReference() 메서드를 사용하면 된다. 이 메서드를 사용하면 JPA는 데이터베이스를 조회하지 않고 실제 엔티티 객체도 생성하지 않는다. 대신에 데이터베이스 접근을 위임한 프록시 객체를 반환한다.
Member member = em.getReference(Member.class, "member1");
다음과 같이 엔티티를 실제 사용을 하면 데이터베이스를 조회하게 된다.
member.getUsername();
프록시(Proxy)의 구조
- 실제 클래스를 상속받아서 만들어져서 실제 클래스와 겉모양이 같다.
- 즉, 사용자 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 된다.
- 프록시 객체는 실제 객체에 대한 참조(target)를 보관한다. 이 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메서드를 호출한다.
프록시와 비슷하게 코드를 짜면 다음과 같을 것이다. (단, 진짜 프록시 객체가 똑같이 생긴것은 아니다.)
public class Proxy extends Entity {
Entity target;
Long id;
String name;
}
public class Entity {
Long id;
String name;
}
프록시(Proxy)의 초기화
프록시 객체는 member.getUsername() 처럼 실제 사용될 때 데이터베이스를 조회해서 실제 엔티티 객체를 생성하는데, 이것을 프록시 객체의 초기화라고 한다. 즉, 다음 소스코드처럼 실제로 속성들을 사용할 때 초기화되는 것이다.
Member member = em.getReference(Member.class, "member1");
member.getUsername(); //프록시 초기화
프록시가 가지고있는 실제 객체의 참조는 처음에는 null 값으로 되어있다가 실제 속성을 사용할 때 초기화가 이루어지게 되는데, 예상 초기화 소스코드는 다음과 같다.
class MemberProxy extends Member {
Member target = null;
public String getUsername(){
if(target == null) {
//1. 초기화 요청
//2. 영속성 컨텍스트에 없다면 -> DB조회
//3. 실제 엔티티 생성 및 참조 보관
this.target = ...;
}
//4. target.getUsername();
return target.getUsername();
}
}
프록시 객체가 실제 사용될 때, 실제 객체를 초기화하는 순서는 다음과 같다.
- 프록시 객체에 member.getUsername()을 호출해서 실제 데이터를 조회한다.
- 프록시 객체는 실제 엔티티가 생성되어 있지 않으면 영속성 컨텍스트에 실제 엔티티 생성을 요청한다.
- 영속성 컨텍스트에 데이터가 없다면 데이터베이스를 조회해서 실제 엔티티 객체를 생성한다.
- 프록시 객체는 생성된 실제 엔티티 객체의 참조를 Member target 멤버변수에 보관한다.
- 프록시 객체는 실제 엔티티 객체의 getUsername()을 호출해서 결과를 반환한다.
프록시(Proxy)의 특징
- 프록시 객체는 처음 사용할 때, 한 번만 초기화된다.
- 프록시 객체를 초기화한다고 프록시 객체가 실제 엔티티로 바뀌는 것이 아니라, 프록시 객체의 멤버변수인 실제 엔티티의 참조값을 통해서 실제 엔티티에 접근할 수 있다.
- 프록시 객체는 실제 엔티티를 상속받은 객체이므로 타입 체크에 유의해야 한다. (instance of 사용!)
- 영속성 컨텍스트에 이미 찾는 엔티티가 있다면 프록시 객체가 아닌 실제 엔티티를 반환한다.
- 프록시의 초기화는 영속화 되어있는 객체에 적용이 가능하므로 준영속 또는 비영속 상태의 객체를 초기화할 수 없다. 강제로 초기화하려고 하면 LazyInitializationException이 발생한다.
즉시 로딩과 지연 로딩
이때까지 로딩 전략을 설명하려고 프록시에 대해서 길게 설명했다. 즉, 프록시 객체는 주로 연관된 엔티티를 지연 로딩할 때 사용한다.
처음에 말한 예시를 들어서 설명하겠다. 예를 들어 회원 엔티티를 조회할 때 연관된 팀 엔티티도 함께 데이터베이스에서 조회하는 것이 좋을까? 아니면 회원 엔티티만 조회하고, 팀 엔티티는 실제 사용하는 시점에 데이터베이스에서 조회하는 것이 좋을까?
이와같은 고민을 해결하기 위해서 JPA는 이 두가지 방법을 따로 제공한다.
- 즉시 로딩(EAGER LOADING)
- 엔티티를 조회할 때, 연관된 엔티티도 함께 조회한다.
- 즉, 회원 엔티티를 조회할 때, 팀 엔티티도 함께 조회한다. (JPA에서는 조인을 사용해서 같이 조회한다.)
- 설정 방법: @ManyToOne(fetch = FetchType.EAGER)
- 지연 로딩(LAZY LOADING)
- 연관된 엔티티를 같이 조회하는 것이 아닌 실제 사용할 때 조회한다. 연관된 엔티티는 프록시로 조회해둔다.
- 즉, 회원 엔티티를 조회하고 사용하다가, 팀 엔티티를 사용할 일이 생겨서 사용하면 그 순간에 조회한다.
- 설정 방법: @ManyToOne(fetch = FetchType.LAZY)
즉시 로딩(EAGER LOADING)
즉시 로딩을 설정하는 방법은 다음과 같이 연관 필드에 @ManyToOne(fetch = FetchType.EAGER) 를 걸어주면 된다.
@Entity
public class Member {
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "TEAM_ID")
private Team team;
...
}
이렇게 설정하고 다음과 같이 회원 엔티티만 조회하는 코드를 작성해서 실행하면 연관된 팀 엔티티도 같이 조인해서 불러오는 것을 볼 수 있을 것이다. 두 엔티티에 해당하는 두 테이블을 조회하는 쿼리가 2번 나갈 것 같지만, 대부분의 JPA 구현체는 즉시 로딩을 최적화하기 위해서 가능하면 조인 쿼리를 사용해서 조회하게 된다.
Member member = em.find(Member.class, "member1");
select
member0_.MEMBER_ID as member_i1_3_0_,
member0_.TEAM_ID as team_id13_3_0_,
member0_.USERNAME as username9_3_0_,
team1_.TEAM_ID as team_id1_4_1_,
team1_.name as name6_4_1_
from
Member member0_
left outer join
Team team1_
on member0_.TEAM_ID=team1_.TEAM_ID
where
member0_.MEMBER_ID=?
그런데 분명 연관관계에 있는 엔티티여서 내부 조인을 사용할 수 있을 것 같은데, 외부 조인을 이용해서 가져오는 것을 볼 수 있다. 그 이유는 회원 테이블의 TEAM_ID 외래 키가 NULL 값을 허용하고 있기 때문에 외부 조인으로 불러온 것이다. 내부 조인으로 데이터를 불러오고 싶다면 다음과 같이 설정을 추가해서 NULL값을 허용하지 않으면 된다.
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "TEAM_ID", nullable = false)
private Team team;
이렇게 설정하면 다음과 같이 내부 조인 쿼리를 볼 수 있을 것이다.
select
member0_.MEMBER_ID as member_i1_3_0_,
member0_.TEAM_ID as team_id13_3_0_,
member0_.USERNAME as username9_3_0_,
team1_.TEAM_ID as team_id1_4_1_,
team1_.name as name6_4_1_
from
Member member0_
inner join
Team team1_
on member0_.TEAM_ID=team1_.TEAM_ID
where
member0_.MEMBER_ID=?
지연 로딩(LAZY LOADING)
지연 로딩을 설정하는 방법은 다음과 같이 연관 필드에 @ManyToOne(fetch = FetchType.LAZY) 를 걸어주면 된다.
@Entity
public class Member {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
...
}
이렇게 지연 로딩을 설정하면 회원 엔티티를 조회해도 팀 엔티티를 불러오지 않고, 팀 엔티티를 실제 사용할 때 데이터베이스 쿼리가 날라갈 것이다.
다음과 같이 회원 엔티티만 조회하는 코드를 실행하면 나가는 쿼리는 다음과 같다.
Member findMember = em.find(Member.class, member.getId());
select
member0_.MEMBER_ID as member_i1_3_0_,
member0_.TEAM_ID as team_id13_3_0_,
member0_.USERNAME as username9_3_0_,
from
Member member0_
where
member0_.MEMBER_ID=?
그리고 비로소 팀 엔티티를 실제 사용하는 코드를 작성해서야 팀 엔티티에 대한 데이터베이스 쿼리가 날라가는 것을 볼 수 있다.
Member findMember = em.find(Member.class, member.getId());
Team findTeam = findMember.getTeam();
findTeam.getName();
select
member0_.MEMBER_ID as member_i1_3_0_,
member0_.TEAM_ID as team_id13_3_0_,
member0_.USERNAME as username9_3_0_,
from
Member member0_
where
member0_.MEMBER_ID=?
---------------------------------------------------------------------------------------
select
team0_.TEAM_ID as team_id1_4_0_,
team0_.name as name6_4_0_
from
Team team0_
where
team0_.TEAM_ID=?
'JPA' 카테고리의 다른 글
JPA - 영속성 전이와 고아객체 (0) | 2022.06.08 |
---|---|
JPA 영속성 컨텍스트 - 1차캐시와 변경감지 (0) | 2022.05.31 |
연관관계 매핑 종류 (0) | 2022.05.21 |
값 타입 (0) | 2022.02.02 |
프록시와 연관관계 관리 (0) | 2022.02.02 |