2024.10.30 업데이트
지연로딩과 즉시로딩에 대해 공부한 내용을 적고자 한다. 내가 JPA를 처음 이론적인 공부를 하지 않고 그냥 무작정 사용했을 때 이런 내용이 있는지도 사실 모르고 데이터를 받아올 때 무수히 많은 SQL문을 남발하곤 했는데, 그 남발하게된 SQL문의 원인 중 하나가 여기에 있다.
우선 지연로딩과 즉시로딩은 JPA가 데이터를 데이터베이스로부터 조회할 때 조회할 레코드에 참조 객체(테이블 관점에서는 외래키)가 있는 경우 해당 데이터까지 한꺼번에 다 가져올지 말지를 정하는 기준을 말한다.
다음 상황을 가정해보자.
팀 엔티티와 멤버 엔티티가 있고 팀과 멤버는 일대다 관계이다. 이 때 멤버를 조회할 때 팀도 한번에 조회해야 할까?
코드로 이를 직접 비교해보자.
지연로딩 (Lazy Fetch)
Member
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
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;
}
}
- 팀은 별다른 내용은 없고 멤버와 양방향으로 참조한다. 이런 두 엔티티가 있을 때 멤버를 조회해보자.
Main
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가 아니라 실제 엔티티를 가져왔다. 이게 즉시로딩이다.
결론
결론을 내리자면 즉시로딩은 가급적 사용하지 않기로 하자. 아무리 멤버를 조회할 때 팀도 바로 가져오는 상황이 있다고 한들 지연로딩으로 가져와서 팀 프록시를 초기화하는 방향으로 사용하는 걸 생각하자.
왜 그러냐면 다음과 같은 이유들이 있다.
- 즉시 로딩의 문제1 - 즉시 로딩을 적용하면 예상하지 못한 SQL이 발생한다.
이건 위에서 봤듯 분명 아래 코드밖에 사용하지 않았다.
Member findMember = em.find(Member.class, member.getId());
물론, 여기서는 이 즉시로딩을 적용했고 이 코드를 사용하면 팀도 같이 가져온다는 걸 인지하고 있고 코드양이 적기 때문에 예상할 수 있는 SQL문이지만, 코드가 길어지고 메서드가 많아지면 많아질수록 실행되는 SQL문에서 왜 팀을 조인하는지 알아보기 힘들어질 가능성이 너무 크다. 거기다가, 지금이야 Team 하나만 즉시로딩으로 설정했으니 망정이지, 즉시로딩이 만약 3개, 4개 넘어가면 조인도 그만큼 많이 하게 된다.
- 즉시 로딩의 문제2 - 즉시 로딩은 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번이 같이 나간다는 소리다. (물론, 멤버 10명이 다 같은 팀에 속해 있다면, 당연히 팀을 가져오는 쿼리는 한번만 나간다. 두번째부터는 이미 가져온 팀이니까)
그래서 N+1은 멤버를 조회하는 쿼리 1개 + 멤버가 가지고 있는 팀을 조회하는 쿼리 N개가 나간다. 그럼 최악의 경우, 멤버가 10명이고 각 멤버가 속한 팀이 서로 다 다르다면 한번에 10 + 1 쿼리가 나가게된다.
심지어! 이건 멤버가 팀만 가지고 있는 경우를 말한거지 만약 다른 참조객체가 2개, 3개, 여러개가 있으면 20 + 1, 30 + 1, N + 1이 된다는 소리다. 끔찍한 경우다. 그래서 즉시로딩은 사용하면 안된다.
그럼 지연로딩으로 설정만 하면 이런 N + 1 문제가 아예 없어지나요!?
→ 안타깝게도 아니다. 그러나, 최소한 지연로딩으로 설정하면 손 쓸 수도 없게 문제를 바라만봐야 하는 상황은 막을 수 있다.
왜 근데 아닐까? 지연로딩으로 설정하고 아래와 같이 팀 2개, 유저 2개를 만들어서 DB에 넣고 영속성 컨텍스트를 깔끔하게 정리한 후 다시 조회해보자.
package cwchoiit;
import cwchoiit.fetch.Team;
import cwchoiit.fetch.Users;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;
public class JpaMain {
public static void main(String[] args) {
EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
EntityManager entityManager = entityManagerFactory.createEntityManager();
entityManager.getTransaction().begin();
try {
Team team = new Team();
team.setName("TEAM1");
Team team2 = new Team();
team2.setName("TEAM2");
Users users = new Users();
users.setUsername("choi");
users.setTeam(team);
Users users2 = new Users();
users2.setUsername("kim");
users2.setTeam(team2);
entityManager.persist(team);
entityManager.persist(team2);
entityManager.persist(users);
entityManager.persist(users2);
entityManager.flush();
entityManager.clear();
List<Users> findUsers = entityManager.createQuery("SELECT u FROM Users u", Users.class).getResultList();
entityManager.getTransaction().commit();
} catch (Exception e) {
entityManager.getTransaction().rollback();
} finally {
entityManager.close();
}
entityManagerFactory.close();
}
}
그리고 아래 코드처럼 JPQL로 모든 유저들을 가져오는 쿼리를 날리고 그 결과를 보자.
List<Users> findUsers = entityManager.createQuery("SELECT u FROM Users u", Users.class).getResultList();
실행 결과
Hibernate:
/* SELECT
u
FROM
Users u */ select
users0_.USER_ID as user_id1_12_,
users0_.TEAM_ID as team_id3_12_,
users0_.username as username2_12_
from
Users users0_
...
보다시피, 당연히 지연로딩이기 때문에 팀에 대한 정보는 아직 조회를 하지 않는다. 그러나, 만약 이 리스트를 순회해서 팀을 가져온다면?
그땐, 당연히 N + 1 문제가 결국엔 나타나게 된다.
...
List<Users> findUsers = entityManager.createQuery("SELECT u FROM Users u", Users.class).getResultList();
for (Users findUser : findUsers) {
System.out.println(findUser.getTeam().getName());
}
...
실행 결과
...
Hibernate:
/* SELECT
u
FROM
Users u */ select
users0_.USER_ID as user_id1_12_,
users0_.TEAM_ID as team_id3_12_,
users0_.username as username2_12_
from
Users users0_
Hibernate:
select
team0_.TEAM_ID as team_id1_11_0_,
team0_.name as name2_11_0_
from
Team team0_
where
team0_.TEAM_ID=?
TEAM1
Hibernate:
select
team0_.TEAM_ID as team_id1_11_0_,
team0_.name as name2_11_0_
from
Team team0_
where
team0_.TEAM_ID=?
TEAM2
...
실행 결과를 보면 역시 마찬가지로 각 멤버가 속한 팀에 대한 쿼리를 하나씩 날리고 있다. 즉, 지연로딩으로 해도 N + 1 문제는 여전히 발생할 수 있다는 소리다. 그러나 차이점은 명확하다. 즉시로딩은 JPQL 실행 시, 개발자가 손 쓸 수도 없게 바로 N + 1 문제가 발생하지만, 지연로딩은 JPQL 실행 시 바로 N + 1 문제는 발생하지 않는다. 그래서 만약, 순회를 하더라도 팀을 조회하는 코드가 없다면 N + 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 |