JPA에서 연관관계가 매핑된 객체들을 가져오는 전략인 Loading 전략에 대해 알아보자🔥
우선 프록시부터 알아봅시다!
JPA는 지연 로딩의 구현을 JPA의 구현체에게 위임하였는데, 이에 따라 하이버네이트 구현체에서 사용하는 지연 로딩을 지원하기 위한
프록시와 관련된 내용이다.
프록시 객체는 지연 로딩을 제공하기 위해 도움을 주는 객체로, 실제 엔티티 객체 대신 DB 조회를 지연할 수 있는 가짜 객체이다.
예시를 통해 더 자세히 알아보자.
@Entity
@Getter
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
}
@Entity
@Getter
public class Team {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
private String name;
}
Member member = em.find(Member.class, memberId);
System.out.println("=== 실제 객체 호출 ===");
Team team = member.getTeam();
Hibernate:
select
m1_0.id,
m1_0.name,
m1_0.team_id
from
Member m1_0
where
m1_0.id=?
=== 실제 객체 호출 ===
Hibernate:
select
t1_0.id,
t1_0.name
from
Team t1_0
where
t1_0.id=?
이렇듯 프록시는 실제 해당 객체가 호출되기 전까지 객체를 대신하며 DB에서 실제 조회를 늦춰주는 효과가 있다.

프록시 클래스는 실제 클래스를 상속 받아 만들어져 실제로 겉 모양은 같다.

또한, 실제 객체에 대한 참조를 보관하여, 프록시 객체의 메서드(getId(), getName())를 호출하면 프록시 객체가 실제 객체의 메소들를 호출하게 되는 것이다. 앞선 코드에선 영속성 컨텍스트에 해당 프록시 객체가 가르키는 Team가 존재하지 않았으므로 DB에서 조회한 다음 호출하게 된 것이다.
해당 객체가 프록시 객체인지 확인하는 방법은 PersistenceUtil.isLoaded(Object entity) 메소드를 사용하여 알 수 있다.
boolean isLoad = em.getEntityManagerFactory().getPersistenceUtil().isLoaded(entity);
위의 isLoad의 값이 false일 경우, 프록시 인스턴스임을 의미하고, true일 경우 이미 초기화 되었거나 프록시 인스턴스가 아님을 의미한다.
이제 프록시가 무엇인지 알았으니 즉시 로딩과 지연 로딩에 대해 본격적으로 알아보자🔥
즉시 로딩은 엔티티를 조회할 때 연관된 엔티티도 함께 조회한다.
위의 예시에선 member를 조회할 때, team도 함께 조회하는 것을 의미한다.
설정 방법 : @ManyToOne(fetch = FetchType.EAGER)

JPA에서는 조인 쿼리를 사용하여 한 번의 쿼리를 통해 두 엔티티를 모두 조회한다.
Hibernate:
select
m1_0.id,
m1_0.name,
t1_0.id,
t1_0.name
from
Member m1_0
left join
Team t1_0
on t1_0.id=m1_0.team_id
where
m1_0.id=?
JPA는 참조 값(외래 키)인 teamId가 Null 일 수도 있기 때문에 외부 조인 (LEFT OUTER JOIN)을 사용하여 Team 엔티티를 조회한다. 여기서, 외부 조인보다 성능 및 최적화에 유리한 내부 조인 (LEFT INEER JOIN)을 사용하려면 어떻게 해야할까?
💡 해답은 외래키에 NOT NULL 제약조건을 추가해주면 된다.
JPA는 NOT NULL 제약 조건이 적용된 외부키에 대해 NULL 값이 존재하지 않으므로 내부 조인을 사용하게 된다. 즉, 필수 관계의 경우에 내부 조인을 사용하는 것이다.
따라서 Member 엔티티를 아래와 같이 수정할 수 있다.
@Entity
public class Member {
...
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "TEAM_ID", nullable = false)
private Team team;
...
}
Hibernate:
select
m1_0.id,
m1_0.name,
m1_0.team_id,
t1_0.team_id,
t1_0.name
from
Member m1_0
join
Team t1_0
on t1_0.team_id=m1_0.team_id
where
m1_0.id=?
위의 방법 외에도 @ManyToOne.optional = false 설정을 해주면 내부 조인을 사용할 수 있다.
지연 로딩은 연관된 엔티티를 실제 사용할 때 조회한다.
앞서 설명한 프록시 객체로 대신하고 있다가 객체가 호출될 때 조회되는 방식이다.
설정 방법 : @ManyToOne(fetch = FetchType.LAZY)

위의 코드를 다시 불러와서 보자면
Member member = em.find(Member.class, memberId);
System.out.println("=== 실제 객체 호출 ===");
Team team = member.getTeam();
실제 DB 쿼리문을 살펴보면 아래와 같다. (영속성 컨텍스트에 member, team 모두 없다고 가정)
Hibernate:
select
m1_0.id,
m1_0.name,
m1_0.team_id
from
Member m1_0
where
m1_0.id=?
=== 실제 객체 호출 ===
Hibernate:
select
t1_0.id,
t1_0.name
from
Team t1_0
where
t1_0.id=?
이렇게 호출될 때 쿼리문을 날려 객체를 가져온다.
영속성 컨텍스트에 연관된 객체들을 모두 올려두는 것도 비효율적인 것 같고, 객체가 실제로 참조될 때 쿼리를 날려 객체를 가져오는 것도 속도적인 측면에선 비효율적인 것 같다. 그럼 어떤 상황일 때 즉시 로딩, 지연 로딩을 구분해서 사용해야 하는지는 개발자가 설계하는 애플리케이션 로직에 따라 다르다.
멤버와 팀이 같이 호출되는 부분이 많다면 즉시 로딩이 효율적이고, 반대의 경우의 지연 로딩이 효율적일 것 같다.
하지만 JPA의 구현체인 하이버네이트(Hibernate)에서는 N+1 문제로 인해 지연 로딩 (Lazy Loading) 을 권장하고 있다.

자세한 이유는 다음 포스팅을 참고하자!
참고
자바 ORM 표준 JPA 프로그래밍 - 김영한
Hibernate ORM 공식문서