지연로딩과 즉시로딩에 대해 공부한 내용을 적고자 한다. 내가 JPA를 처음 이론적인 공부를 하지 않고 그냥 무작정 사용했을 때 이런 내용이 있는지도 사실 모르고 데이터를 받아올 때 무수히 많은 SQL문을 남발하곤 했는데, 그 남발하게된 SQL문의 원인 중 하나가 여기에 있다.
우선 지연로딩과 즉시로딩은 JPA가 데이터를 데이터베이스로부터 조회할 때 조회할 레코드에 참조 객체(테이블 관점에서는 외래키)가 있는 경우 해당 데이터까지 한꺼번에 다 가져올지 말지를 정하는 기준을 말한다.
다음 상황을 가정해보자.
팀 엔티티와 멤버 엔티티가 있고 팀과 멤버는 일대다 관계이다. 이 때 멤버를 조회할 때 팀도 한번에 조회해야 할까?
코드로 이를 직접 비교해보자.
지연로딩(LAZY Fetch)
Member Class
package org.example.entity.fetch;
import org.example.entity.Team;
import javax.persistence.*;
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public Team getTeam() {
return team;
}
public void setTeam(Team team) {
this.team = team;
}
}
멤버는 팀을 가지고 LAZY(지연 로딩)를 Fetch전략으로 가지고 있다.
Team Class
package org.example.entity;
import org.example.entity.fetch.Member;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@SequenceGenerator(
name = "TEAM_SEQ_GENERATOR",
sequenceName = "TEAM_SEQ",
initialValue = 1,
allocationSize = 100
)
public class Team {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "TEAM_SEQ_GENERATOR")
@Column(name = "TEAM_ID")
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public List<Member> getMembers() {
return members;
}
public void setMembers(List<Member> members) {
this.members = members;
}
}
팀은 뭐 별다른 내용은 없고 이런 두 엔티티가 있을 때 멤버를 조회해보자.
실행 코드
package org.example;
import org.example.entity.Team;
import org.example.entity.fetch.Member;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
public class Main {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
// ! Transaction 시작
tx.begin();
try {
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setTeam(team);
member.setUsername("memberA");
em.persist(member);
em.flush();
em.clear();
Member findMember = em.find(Member.class, member.getId());
System.out.println("findMember: " + findMember.getUsername());
System.out.println("findMember Team Class: " + findMember.getTeam().getClass());
tx.commit();
} catch (Exception e) {
e.printStackTrace();
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
지연 로딩으로 설정하면 참조 객체를 바로 엔티티로 찾아오는게 아니라 전 시간에 공부했던 프록시로 가져온다. 즉, 위 코드로 실행하면 멤버의 팀은 프록시로 가져와야 한다. 실행 결과는 다음과 같다.
찾은 멤버의 팀은 현재 Proxy로 가져온 상태임을 확인할 수 있다. 이게 지연로딩이고 실제 SQL문을 살펴봐도 팀과 조인한 SQL문이 실행되지 않았다. 이렇게 되면 만약 멤버를 조회했을 때 팀을 알 필요가 전혀 없는 상태라면 이런 SELECT문이 훨씬 이점을 가져가겠지.
이번에는 즉시로딩으로 실행해보자. 참고로 @ManyToOne, @OneToOne은 기본이 즉시로딩(EAGER)이다.
즉시로딩 (EAGER Fetch)
위에서 작성한 코드에서 수정할 내용은 LAZY를 EAGER로 변경만 하면 된다.
package org.example.entity.fetch;
import org.example.entity.Team;
import javax.persistence.*;
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "TEAM_ID")
private Team team;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public Team getTeam() {
return team;
}
public void setTeam(Team team) {
this.team = team;
}
}
EAGER로 변경 후 실행해보자. SQL문을 확인해보면 다음과 같이 출력된다.
멤버를 조회할 때 바로 조인을 해서 TEAM 정보도 가져오는 SELECT문을 확인할 수 있다. 그리고 가져온 TEAM의 클래스를 찍어보면 이번엔 Proxy가 아니라 실제 엔티티를 가져왔다. 이게 즉시로딩이다.
결론
결론을 내리자면 즉시로딩은 가급적 사용하지 않기로 하자. 아무리 멤버를 조회할 때 팀도 바로 가져오는 상황이 있다고 한 들 지연로딩으로 가져와서 팀 프록시를 초기화하는 방향으로 사용하는 걸 생각하자.
왜 그러냐면 다음과 같은 이유들이 있다.
- 즉시 로딩을 적용하면 예상하지 못한 SQL이 발생한다.
이건 위에서 봤듯 분명 아래 코드밖에 사용하지 않았다.
Member findMember = em.find(Member.class, member.getId());
물론, 여기서는 이 즉시로딩을 적용했고 이 코드를 사용하면 팀도 같이 가져온다는 걸 인지하고 있고 코드양이 적기 때문에 예상할 수 있는 SQL문이지만, 코드가 길어지고 메서드가 많아지면 많아질수록 실행되는 SQL문에서 왜 팀을 조인하는지 알아보기 힘들어질 가능성이 너무 크다.
- 즉시 로딩은 JPQL 사용을 할 때 N+1 문제가 생긴다.
이게 진짜 큰 문제인데 JPQL은 종종 사용된다. 복잡한 쿼리를 작성할 일이 많기 때문에 그래서 예시를 위해 다음 JPQL 코드를 살펴보자.
package org.example;
import org.example.entity.Team;
import org.example.entity.fetch.Member;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;
public class Main {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
// ! Transaction 시작
tx.begin();
try {
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setTeam(team);
member.setUsername("memberA");
em.persist(member);
em.flush();
em.clear();
List<Member> result = em.createQuery("select m from Member m", Member.class).getResultList();
tx.commit();
} catch (Exception e) {
e.printStackTrace();
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
위 코드부분에서 여기 부분이 JPQL을 사용하는 부분이다.
List<Member> result = em.createQuery("select m from Member m", Member.class).getResultList();
이 코드를 수행해보면 다음과 같은 SQL문이 출력된다.
Hibernate:
select
member0_.MEMBER_ID as member_i1_0_,
member0_.TEAM_ID as team_id3_0_,
member0_.username as username2_0_
from
Member member0_
Hibernate:
select
team0_.TEAM_ID as team_id1_1_0_,
team0_.name as name2_1_0_
from
Team team0_
where
team0_.TEAM_ID=?
SELECT를 두번한다. 왜 두번하냐 ? 우선 위에 작성한 JPQL은 멤버를 조회한다. 멤버를 조회했을 때 멤버가 1개면 1개의 레코드, 2개면 2개의 레코드가 출력되는데 이 때 즉시로딩은 멤버가 가지고 있는 팀도 같이 가져와야하기 때문에 각 멤버별 팀까지 가져와야 하는 SELECT문이 나간다. 그럼 이게 멤버가 10명이 있으면 팀을 가져오는 SELECT문 10번이 같이 나간다는 소리다. 그래서 N+1은 멤버를 조회하는 쿼리 1개 + 멤버가 가지고 있는 팀을 조회하는 쿼리 N개가 나간다. 그럼 멤버가 10명이면 한번에 10 + 1 쿼리가 나가는거고 이건 멤버가 팀만 가지고 있는 경우를 말한거지 만약 다른 참조객체가 2개, 3개, 여러개가 있으면 20 + 1, 30 + 1, N + 1이 된다는 소리다. 끔찍한 경우다.
그래서 즉시로딩은 사용하면 안된다.
'JPA(Java Persistence API)' 카테고리의 다른 글
[JPA] Part 13. 임베디드 타입 (0) | 2023.10.23 |
---|---|
[JPA] Part 12. CASCADE (2) | 2023.10.23 |
[JPA] Part 10. 프록시 (0) | 2023.10.22 |
[JPA] Part 9. @MappedSuperclass (0) | 2023.10.22 |
[JPA] Part 8. 상속관계 매핑 (2) | 2023.10.22 |