Member를 조회할 때 Team도 함께 조회해야 할까?
단순히 member 정도만 사용하는 비즈니스 로직이 있다고 가정해보자.
System.out.println(member.getName());
이런 경우 Member만 필요한데 Team까지 JOIN해서 가져오게 되면 불필요한 데이터를 조회하는 손해가 발생한다. JPA는 이런 문제를 해결하기 위해 지연 로딩(LAZY)과 즉시 로딩(EAGER) 전략을 제공한다.
지연 로딩은 연관된 엔티티를 실제로 사용하는 시점에 데이터베이스에 조회하는 전략이다. 처음에는 프록시 객체로 가져오고, 실제 데이터가 필요한 순간에 데이터베이스를 조회한다.
FetchType.LAZY를 사용하면 프록시로 조회한다.
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
@Column(name = "USERNAME")
private String name;
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩 설정
@JoinColumn(name = "TEAM_ID")
private Team team;
}
이렇게 설정하면 Member 클래스만 데이터베이스에서 조회하고 Team은 프록시로 반환한다.
// [Member 조회 시점]
Member member = em.find(Member.class, 1L);
Member (실제 엔티티)
↓ (LAZY)
Team (프록시 엔티티)
// [Team 사용 시점]
Team team = member.getTeam();
team.getName(); // 이 시점에 DB 조회
Member (실제 엔티티)
↓
Team (실제 엔티티로 초기화)
실제 Team을 사용하는 시점인 team.getName()을 호출할 때 초기화되면서 데이터베이스를 조회한다.
즉시 로딩은 엔티티를 조회할 때 연관된 엔티티도 함께 조회하는 전략이다. Member를 조회하면 Team도 바로 함께 조회된다.
Member와 Team을 자주 함께 사용한다면?
FetchType.EAGER를 사용해서 함께 조회한다.
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
@Column(name = "USERNAME")
private String name;
@ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩 설정
@JoinColumn(name = "TEAM_ID")
private Team team;
}
즉시 로딩이기 때문에 프록시가 필요 없다.
이미 초기화가 다 끝난 상태라서 초기화도 필요 없다.
//[Member 조회 시점]
Member member = em.find(Member.class, 1L);
Member (실제 엔티티)
↓ (EAGER)
Team (실제 엔티티) // 프록시가 아닌 실제 엔티티
즉시 로딩(EAGER)는 Member 조회 시 항상 Team도 함께 조회한다. JPA 구현체는 가능하면 조인을 사용해서 SQL 한 번에 함께 조회한다.
데이터베이스 입장에서 JOIN을 한두 개 한다고 성능이 크게 느려지지는 않는다. 하지만 실무에서는 수많은 테이블이 있고 연관관계가 5개 이상만 되어도 성능이 급격히 느려진다. 그래서 가급적 전부 지연 로딩으로 설정해야 한다.
의도하지 않은 테이블까지 JOIN되어 조회되면서 예상치 못한 성능 저하가 발생할 수 있다.
List<Member> members = em.createQuery("select m from Member m", Member.class)
.getResultList();
이렇게 JPQL로 Member를 조회했을 때, 즉시 로딩으로 설정된 Team 때문에 쿼리가 추가로 발생한다.
N+1문제
처음 실행한 SQL(1번)의 결과로 N개의 추가 쿼리가 발생하는 문제다.
Member를 조회하는 쿼리 1번으로 10개의 Member를 가져왔는데, 각 Member마다 Team이 즉시 로딩으로 설정되어 있으면 Team을 조회하는 쿼리가 10번 더 발생한다.
왜 이런 문제가 발생할까?
em.find()는 PK로 조회하기 때문에 JPA가 내부적으로 최적화 할 수 있다.
하지만 JPQL은 SQL 그대로 번역된다.
-- 1. JPQL이 SQL로 번역되어 실행
SELECT * FROM Member
-- 2. Member 10개를 가져왔는데, Team이 즉시 로딩으로 설정되어 있음
-- 3. 각 Member마다 Team을 조회하는 쿼리가 추가로 발생
SELECT * FROM Team WHERE team_id = 1
SELECT * FROM Team WHERE team_id = 2
SELECT * FROM Team WHERE team_id = 3
...
SELECT * FROM Team WHERE team_id = 10
결과적으로 처음 1번의 쿼리로 Member를 조회했지만, 10개의 Member를 가져온 결과 Team을 조회하는 쿼리가 10번 더 발생하는 것이다. 이것이 N+1 문제이다.
전부 지연 로딩으로 설정하고 fetch join을 사용한다. 뒤에서 자세하게 배우므로 지금은 그런 방법이 있다는 것만 알고 넘어가자.
연관관계의 기본 Fetch 전략
@ManyToOne, @OneToOne은 기본이 즉시 로딩 -> 반드시 LAZY로 설정@OneToMany, @ManyToMany는 기본이 지연 로딩아래의 내용은 이론적인 내용이고 실무에서는 전부 지연 로딩으로 설정해야 한다.


이런 식으로 사용 빈도에 따라 전략을 선택하는 것이 이론이지만, 실무에서는 이렇게 하면 안 된다.
실무에서는 복잡한 연관관계와 비즈니스 로직 때문에 즉시 로딩을 사용하면 예측할 수 없는 성능 문제가 발생한다. 따라서 모든 연관관계는 지연 로딩으로 설정하고, 필요한 경우에만 fetch 조인으로 최적화하는 것이 올바른 접근 방법이다.