JPA fetch 전략
서론
JPA는 ORM 기술에 대한 자바의 표준 API 기술 명세이다. 즉, ORM 기술을 위한 인터페이스들의 집합이다.
자바의 객체와 RDB의 테이블 간 패러다임 차이로 인하여 JPA에서는 테이블 간 연관 관계 매핑을 지원한다. RDB 상에서는 다른 테이블의 외래키를 통하여 테이블 간 관계를 맺고 있지만, Java에서는 객체 간 참조를 통해 관계를 맺는다. 이러한 패러다임의 차이로 인하여 엔티티 조회 시 몇 가지 고려사항이 존재한다.
본 포스팅에서는 즉시 로딩과 지연 로딩의 차이를 중점적으로 설명하고자 한다.
Proxy
fetch 전략을 알아보기에 앞서 Proxy 객체에 대해 알아보고자 한다.
Proxy는 ‘대리’, ‘대신’을 뜻한다. 즉, Proxy 객체는 진짜 객체(엔티티)를 대신하는 가짜 객체이다.
Proxy 객체는 엔티티의 Id(PK) 값만 가지고 있으며, 진짜 객체에 대한 참조(target)를 가지고 있다.
초기 Proxy 객체의 target은 null이기 때문에, 해당 엔티티의 일반 필드나 메서드를 호출할 경우 DB에 쿼리를 보내 진짜 객체를 가져오게 된다. 이미 해당 엔티티가 조회되어 target 값에 진짜 객체가 매핑되어 있는 경우에는 초기화 과정을 진행하지 않는다.
진짜 객체가 아닌 Proxy 객체를 사용하는 이유가 무엇일까?
위 클래스 다이어그램에서 A는 B와 D를 참조 중이며, B는 C를 참조 중이다. 만약, A 엔티티를 조회하게 되었을 때 연관된 객체를 모두 가지고 오게 된다면 메모리에 큰 부담일 것이다.
실제에서는 엔티티 간 다양한 연관 관계를 맺고 있기 때문에 엔티티 그래프가 굉장히 복잡하게 구성되어있다. 하나의 엔티티를 조회하였을 때 연관된 모든 엔티티를 불러오는 것은 부담이다. 또한, 사용자가 단순히 엔티티 A 내의 필드나 메서드만 사용하는 경우에는 굳이 연관된 모든 객체를 메모리로 가져오는 것은 리소스 낭비이다.
따라서, Proxy 객체를 도입하여
JPA에서는 EntityMangaer.getReference() 메서드를 통하여 엔티티에 대한 Proxy 객체를 가져올 수 있다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Setter
@Getter
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
public Member(String username) {
this.username = username;
}
}
Member member = em.getReference(Member.class, 1L);
System.out.println(member.getClass().getName());
System.out.println(em.getEntityManagerFactory().getPersistenceUnitUtil().isLoaded(member));
위 실행을 통하여 getReference()
메서드를 통하여 호출된 객체는 Proxy 객체라는 것을 알 수 있다.
진짜 객체는 EntityManager.find() 메서드를 통하여 얻을 수 있다.
Member member = em.find(Member.class, 1L);
System.out.println(member.getClass().getName());
System.out.println(em.getEntityManagerFactory().getPersistenceUnitUtil().isLoaded(member));
getReference()
와 find()
실행 시 출력 로그를 보면 find()
메서드 실행 시에는 쿼리가 실행된 것을 알 수 있다. getReference()
실행 시에는 쿼리가 발생하지 않았는데 왜 그런 것일까?
이는 앞서 보았던 Proxy 객체의 특성 때문이다. Proxy 객체는 엔티티의 id(PK) 값만 가지고 있다. 따라서, getReference(Member.class, 1L)
의 경우에는 인자로 전달해주었던 1L을 Proxy 객체가 들고 있게되어 쿼리가 발생하지 않는 것이다.
연관 관계 매핑
Java에서는 객체 간 참조를 통해 연관 관계를 맺는다. 그러나, RDB의 경우에는 외래키를 통하여 다른 테이블과 관계를 맺게 된다. 데이터베이스에서 테이블 간 조인을 통하여 특정 컬럼들을 조회하는 것과 달리, Java 상에서는 특정 객체에 모든 필드 값을 할당하여야하는 패러다임 불일치 문제가 발생한다. JPA에서는 이 문제를 해결하기 위해 Proxy 객체를 도입하였다.
연관된 객체(필드)에 대한 Proxy 객체의 사용 여부는 즉시 로딩과 지연 로딩에 따라 결정된다.
- 즉시 로딩(Eager): 연관된 객체의 실제 객체를 바로 메모리(영속성 컨텍스트)로 로딩
- 지연 로딩(Lazy): 연관된 객체가 사용되는 시점에 메모리(영속성 컨텍스트)로 실제 객체를 로딩
지연 로딩은 엔티티가 초기화될 당시 해당 필드에 매핑할 객체의 Proxy 객체를 들고와 매핑한다. 이후 해당 객체가 실제 사용될 경우에 데이터베이스로 추가 쿼리를 날려 실제 객체로 매핑한다.
엔티티 간 연관 관계는 아래 4가지 종류로 구성된다.
- @OneToOne (1:1)
- @OneToMany (1:N)
- @ManyToOne (N:1)
- @ManyToMany (N:N)
위 관계들 중 ~ToOne 관계들은 엔티티 내에 하나의 필드로 다른 엔티티와 관계를 맺고 있는 경우를 의미하며, ~ToMany 관계들은 엔티티 내에 List
형태의 필드로 다른 엔티티와 관계를 맺고 있는 경우를 의미한다.
- ~toOne: 즉시 로딩
- ~toMany: 지연 로딩
JPA에서는 기본적으로 ~toOne 관계를 맺고 있는 필드에 대해서는 즉시 로딩을 적용하며, ~toMany 관계를 맺고 있는 필드에 대해서는 지연 로딩을 적용한다. 이는 ~toOne 관계의 경우에는 연관 관계를 맺고 있는 엔티티가 1개 뿐이기에 메모리 로드의 비용이 그리 크지 않아 즉시 로딩을 적용하는 것으로 추측된다.
예측하지 않은 쿼리나 불필요한 데이터 로드 방지 등의 이유로 모든 연관 관계에 대해서는 지연 로딩을 권장한다. 그러나, 상황에 따라 즉시 로딩을 적용하였을 때 성능 상의 이점을 볼 수 있는 경우가 존재하기 때문에 지연 로딩을 전제로 하되, 필요한 경우 즉시 로딩을 고려하는 방향으로 설계를 권장하고 있다.
Practice
특정 엔티티가 영속성 컨텍스트에 로드된 지 여부를 확인하기 위하여 아래와 같은 2가지 방법으로 확인을 진행한다.
- 연관 관계 매핑된 필드의 class name을 출력
- PersistenceUtil.isLoaded(Object entity) 메서드를 영속성 컨텍스트 내 로드 여부를 확인
서비스 클래스(테스트 클래스)에서는 @PersistenceContext
어노테이션을 통하여 EntityManager
를 주입받는다. EntityManger
를 통하여 EntityMangerFactory
객체를 불러오고, 해당 객체에서 PersistenceUtil
객체를 불러와 PersistenceUtil.isLoaded(Object entity) 메서드를 호출한다. 자세한 내용은 코드에서 서술한다.
추가적으로 ManyToMany 관계는 중간 테이블을 생성해 1:N - N:1 관계로 풀어쓰는 경우가 많기 때문에 실습에서 ManyToMany에 대한 부분은 생략한다.
1. OneToOne
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Setter
public class Mentor {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToOne
@JoinColumn(name = "mentee_id")
private Mentee mentee;
public Mentor(String name) {
this.name = name;
}
}
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter @Setter
public class Mentee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String studentNumber;
@OneToOne(mappedBy = "mentee")
private Mentor mentor;
public Mentee(String studentNumber) {
this.studentNumber = studentNumber;
}
}
OneToOne 관계를 알아보기 위해 Mentor:Mentee = 1:1 관계를 구성한다.
양방향 관계는 대표적인
현재 DB 내의 데이터는 아래와 같으며, 별다른 연관 관계를 설정해주지 않았다.
Mentor 엔티티가 해당 관계의 주인이며, Mentee의 외래키를 가지고 있다.
이 상태에서 Mentor를 조회하여 mentee 필드의 상태를 확인해보자.
// 테스트를 진행하는 클래스 내 작성
@PersistenceContext
private EntityManager em;
public void getMentorTest() {
Mentor mentor = mentorRepository.findById(1L)
orElseThrow(() -> new RunTimeException("Entity Not Found"));
System.out.println(mentor.getMentee().getClass().getName());
System.out.println(em.getEntityManagerFactory().getPersistenceUnitUtil().isLoaded(mentor.getMentee()));
}
위와 같이 fetch 전략을 설정해주지 않았을 경우, 자동으로 Join 쿼리가 발생하게 되며 연관 관계를 맺고 있는 mentee 필드에 진짜 객체 Mentee
가 삽입된 것을 알 수 있다. 이를 통해 OneToOne 관계에서는 기본적으로 즉시 로딩 방식을 사용하는 것을 알 수 있다.
그렇다면, 직접 fetch 전략을 지연 로딩으로 수정해준다면?
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Setter
public class Mentor {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToOne(fetch = FetchType.LAZY) // 지연 로딩으로 수정
@JoinColumn(name = "mentee_id")
private Mentee mentee;
public Mentor(String name) {
this.name = name;
}
}
fetch 전략을 지연 로딩(Lazy Loading) 방식으로 수정해주니 Mentee
의 Proxy 객체가 매핑된 것을 알 수 있디.
그렇다면, 반대 방향(Mentee → Mentor) 관계에서 지연로딩을 적용하면 동일할까? 결론부터 이야기하자면 아니다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter @Setter
public class Mentee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String studentNumber;
@OneToOne(mappedBy = "mentee", fetch = FetchType.LAZY)
private Mentor mentor;
public Mentee(String studentNumber) {
this.studentNumber = studentNumber;
}
}
public void getMenteeTest() {
Mentee mentee = menteeRepository.findById(1L)
orElseThrow(() -> new RunTimeException("Entity Not Found"));
System.out.println(mentee.getMentor().getClass().getName());
System.out.println(em.getEntityManagerFactory().getPersistenceUnitUtil().isLoaded(mentee.getMentor()));
}
결과에서 알 수 있듯이 반대 방향(연관 관계의 주인이 아닌 Mentee에서 조회)에서는 지연 로딩을 명시했음에도 적용이 되지 않았다.
Trouble Shooting
해당 부분은 문제에 대한 해결책이라기보다는 문제 현상에 대한 이유를 설명한다.
PROBLEM. OneToOne 관계의 주인이 아닌 엔티티의 연관 관계 필드 조회 시 지연 로딩 미적용
OneToOne 관계에서 주인이 아닌 엔티티의 경우, 연관 관계를 맺고 있는 필드에 대해 지연 로딩을 적용하더라도 즉시 로딩이 된다.
REASON. 주인이 아닌 엔티티는 외래키(FK)를 가지고 있지 않는다.
해당 문제의 이유를 알기 위해서는 Proxy 객체의 구성을 되돌아 보아야 한다.
Proxy 객체는 엔티티의 PK만 가지고 있으며 나머지 필드는 가지고 있지 않다. 따라서, Proxy 객체를 만들기 위해서는 엔티티의 PK가 필요하다.
연관 관계이 주인인 엔티티는 상대 엔티티의 PK를 외래키(FK)로 가지고 있다. Mentor의 경우에는 Mentee의 외래키를 mentee_id 컬럼에 들고 있기 때문에 Mentee 엔티티에 대한 Proxy 객체 생성이 가능하다. 그러나, Mentee 테이블에는 Mentor에 대한 외래키를 가지고 있지 않기 때문에 Mentee 테이블 내에서 Mentor 프록시 객체를 만들어낼 방법이 없다. 따라서, 연관 관계를 맺고있는 필드인 Mentor 테이블에 대한 추가적인 쿼리(매핑할 Mentor를 찾기 위해)를 보내게 되면서 실제 객체가 매핑되는 것이다.
위와 같은 이유로 인하여 OneToOne 관계에서는 지연로딩이 적용되지 않는 경우가 있다는 것을 인지하여야 한다.
2. OneToMany, ManyToOne
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Setter
@Getter
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
public Team(String name) {
this.name = name;
}
}
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Setter
@Getter
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
public Member(String username) {
this.username = username;
}
}
OneToMany, ManyToOne 관계를 알아보기위해 Team:Member = 1:N 관계를 구성하였다. 관계의 주인은 N에 위치한 Member이다.
현재 DB 내 데이터는 아래와 같으며, 별다른 연관 관계를 설정해주지 않았다.
Member 엔티티에서 외래키로 team_id를 가지고 있는 것을 확인할 수 있다.
이제 각 엔티티를 조회하여 연관 관계 매핑된 필드의 상태에 대해 알아보자.
public void getMemberTest() {
Member member = memberRepository.findById(1L)
.orElseThrow(() -> new RuntimeException("Entity Not Found"));
System.out.println(member.getTeam().getClass().getName());
System.out.println(em.getEntityManagerFactory().getPersistenceUnitUtil().isLoaded(member.getTeam()));
}
위 결과에서 알 수 있듯이 1:N 관계에서 N에 해당하는 엔티티인 Member를 조회하였을 때, 관계를 맺고 있는 필드 Team은 즉시로딩 되는 것을 알 수 있다. 또한, 즉시 로딩이 되기 위하여 JPA에서 자체적으로 Join 쿼리를 발생시킨다.
따라서, ~toOne
관계인 필드에 대해서는 기본적으로 즉시 로딩이 적용되는 것을 알 수 있다.
이제 team 필드에 대해 지연 로딩을 적용한 뒤 다시 조회를 진행한다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Setter
@Getter
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
public Member(String username) {
this.username = username;
}
}
지연 로딩을 적용한 team 필드에 대해서 프록시 객체가 조회되었다.
이제 1:N 관계에서 1에 위치한 Team 엔티티를 조회하고 members 필드를 확인해보자.
public void getTeamTest() {
Team team = teamRepository.findById(1L)
.orElseThrow(() -> new RuntimeException("Entity Not Found"));
System.out.println(team.getMembers().getClass().getName());
System.out.println(em.getEntityManagerFactory().getPersistenceUnitUtil().isLoaded(team.getMembers()));
}
Team.members
필드는 List로 선언하였지만 PersistenceBag
객체가 호출된 것을 알 수 있다. PersistenceUtil.isLoaded(team.getMembers())
을 통해 members 객체를 조회하였을 때, false가 출력되는 되는 것을 보았을 때 지연 로딩은 정상적으로 적용되고 있는 것을 확인할 수 있다.
PersistenceBag
PersistenceBag은 하이버네이트에서 엔티티를 영속 상태로 만들 때 엔티티에 컬렉션이 있으면, 이를 추적하고 관리할 목적으로 원본 컬렉션을 하이버네이트 내장 컬렉션으로 변경하는 컬렉션 래퍼이다.
컬렉션 래퍼는 컬렉션에 대한 지연로딩 처리를 지원한다. 따라서, 리스트 형태로 선언되어 관계를 맺고있는 members 필드(컬렉션)에 대해 지연 로딩이 적용된 것이다.
public void getTeamTest() {
Team team = teamRepository.findById(1L)
.orElseThrow(() -> new RuntimeException("Entity Not Found"));
System.out.println(team.getMembers().getClass().getName());
System.out.println(em.getEntityManagerFactory().getPersistenceUnitUtil().isLoaded(team.getMembers()));
for (Member member : team.getMembers()) {
System.out.println(member.getClass().getName());
System.out.println(em.getEntityManagerFactory().getPersistenceUnitUtil().isLoaded(member));
}
}
앞서 본 지연 로딩 엔티티는 단순히 해당 엔티티의 getClass()
를 호출하거나 id(PK)를 확인하더라도 추가적인 쿼리가 발생되지 않고 여전히 프록시 객체 상태를 유지하였지만, PersistenceBag
객체에 대해서는 컬렉션 내에 위치한 엔티티를 순회할 경우 바로 쿼리가 발생하여 실제 객체가 매핑되는 것을 알 수 있다.
이 떄, Team 엔티티에 연관 관계 필드에 대해 지연 로딩을 적용시킨 뒤 조회하였을 때 한 가지의 의문이 발생한다.
“1:N 관계에서 1에 위치한 Team은 Member의 id(PK)를 외래키로 가지고 있지 않는데 왜 지연로딩이 적용되는 것이지?”
이를 가능하게 하는 것도 PersistenceBag
이다.
그렇다면, 연관된 엔티티가 하나도 없어도 똑같이 PersistenceBag
이 적용이 될까?
현재 team2는 연관 관계를 맺고 있는 member가 하나도 없다. 해당 경우에 team2 엔티티를 조회해서 members 필드를 확인해보자.
public void getTeamTest2() {
Team team = teamRepository.findById(2L)
.orElseThrow(() -> new RuntimeException("Entity Not Found"));
System.out.println(team.getMembers().getClass().getName());
System.out.println(em.getEntityManagerFactory().getPersistenceUnitUtil().isLoaded(team.getMembers()));
for (Member member : team.getMembers()) {
System.out.println(member.getClass().getName());
System.out.println(em.getEntityManagerFactory().getPersistenceUnitUtil().isLoaded(member));
}
}
연관된 엔티티가 없더라도 PersistenceBag 객체가 매핑되는 것을 알 수 있다. 그리고 members 순회 시 조회 쿼리가 똑같이 발생하는 것을 확인할 수 있다. PersistenceBag
은 엔티티에 대한 프록시 객체를 직접적으로 들고있는 것이 아니라 그 자체가 프록시 객체 역할을 하는 컬렉션 래퍼이다. 따라서, OneToOne 관계의 프록시 객체와는 위와 같은 차이가 발생한다.
Conclusion
JPA에서는 엔티티를 조회했을 때, 해당 엔티티와 연관 관계를 맺고 있는 엔티티들을 모두 로딩하는 것이 아니다. ~toOne
관계에 대해서는 즉시 로딩, ~toMany
관계에 대해서는 지연 로딩을 적용시키고 개발자가 fetch 타입을 설정하여 로딩 방식을 지정할 수 있었다. 일반적으로는 예측 불가능한 쿼리나 리소스 효율성 관점에서 모든 관계에 대해 지연 로딩을 적용하며, 필요한 경우 즉시 로딩을 고려할 수 있다.
조회된 엔티티와 연관 관계를 맺고 있으며, 지연 로딩 적용한 엔티티의 경우에는 id(PK) 값만 가지고 있는 프록시 객체가 로드된다. 해당 엔티티의 멤버 변수나 메서드 사용 시에 실제 객체를 매핑하기 위하여 추가적인 쿼리가 발생하였다.
OneToOne 관계에서는 연관 관계의 주인이 아닌 쪽의 엔티티를 조회할 경우, 해당 엔티티 내에서 지연 로딩을 설정하더라도 즉시 로딩만 적용된다. 이는 Proxy 객체 생성 시 필요한 엔티티의 id(PK) 값을 모르기 때문에 이를 매핑하기 위한 쿼리를 보냄과 동시에 실제 엔티티를 가지고와 매핑 시키는 것이다.
OneToMany, ManyToOne 관계에서 1에 해당하는 엔티티에서는 N 관계를 맺고 있고 있는 엔티티들을 리스트(컬렉션) 형태로 관리하게 된다. JPA에서는 N 관계의 엔티티에 대해 PersistenceBag
이라는 컬렉션 래퍼를 적용하여 매핑을 진행한다. PersistenceBag
은 그 자체로 프록시 객체이며, 해당 객체를 순회하는 등의 경우에 실제 쿼리가 바로 발생하게 된다.