Spring Data JPA의 Fetch 전략과 Fetch 조인 비교하기
JPA의 페치 전략이란
JPA는 엔티티를 조회할 때 연관 관계의 엔티티를 언제 로딩해올지 설정할 수 있는 페치 전략을 제공하고 있습니다.
Fetch 전략의 종류
1. FetchType.EAGER (즉시로딩)
2. FetchType.LAZY (지연로딩)
현업에서는 되도록이면 LAZY를 사용하라는 말을 들어왔는데, 이번 글을 써보며 왜 그런지 깊이 알아보고자 합니다.
먼저 두 가지 타입의 Fetch 전략에 대해 자세히 알아보겠습니다.
즉시 로딩(EAGER)
엔티티를 조회할 때 연관 관계에 있는 엔티티까지 즉시 한 번에 조회합니다. @...ToOne 관계에서는 기본적으로 즉시 로딩 전략을 사용하도록 설정되어 있습니다. 즉시 로딩은 조회하는 엔티티와 연관 관계의 엔티티가 반드시 같이 사용되는 경우에 사용하면 좋습니다.
@Entity
public class Parent extends Timestamped {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long pid;
@Column(length = 20)
private String parentName;
@OneToMany(cascade = CascadeType.ALL, mappedBy = "user", fetch = FetchType.EAGER)
private List<Child> children;
...
}
@Entity
public class Child extends Timestamped {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long cid;
// default FetchType: EAGER
@ManyToOne
@JoinColumn(name = "pid")
Parent parent;
@Column(length = 20)
String childName;
public void setParent(Parent parent) {
this.parent = parent;
parent.updateChildren(this);
}
...
}
위의 예시 코드에서 Child를 조회하면 Child의 조회 쿼리와 연관 관계인 Parent의 조회 쿼리, 총 2개가 즉시 발생합니다.
즉시 조인 사용 시 가능한 NOT NULL 제약 조건을 설정하라
JPA는 연관 관계를 조회할 때 Nullable의 여부에 따라 다른 조인 전략을 사용합니다.
- Nullable한 경우: 연관 관계가 null인 객체까지 조회하기 위해 LEFT OUTER JOIN 사용
- Not Nullable한 경우: INNER JOIN 사용
ex. NULL이 삽입되지 않는 컬럼인데 NOT NULL 제약 조건을 설정하지 않았다면?
1. 연관관계 조회에 쓰이는 LEFT OUTER JOIN은 필요하지 않은 데이터도 함께 조회합니다.
2. LEFT OUTER JOIN은 조회를 위해 INNER JOIN보다 더 많은 테이블을 메모리에 올립니다.
이와 같은 이유로 가능한 LEFT OUTER JOIN보다는 가능한 INNER JOIN을 사용하는 것이 성능에 좋습니다. INNER JOIN을 사용해 조회 성능을 최적화하고 싶다면 적극적으로 NOT NULL 제약 조건을 사용합시다.
@Entity
public class Child extends Timestamped {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long cid;
@ManyToOne
@JoinColumn(name = "pid", nullable = false)
Parent parent;
@Column(length = 20)
String childName;
public void setParent(Parent parent) {
this.parent = parent;
parent.updateChildren(this);
}
...
}
지연 로딩(LAZY)
지연 로딩 전략은 연관 관계의 엔티티가 실제로 필요해질 때 쿼리를 실행하여 연관관계의 엔티티를 조회하는 전략입니다. 조회 대상 엔티티의 연관관계에는 엔티티인 척하는 가짜 프록시 객체를 넣어두고, 실제로 프록시 객체에 요청이 들어오면 실제 연관 관계의 엔티티를 조회합니다. @...ToMany 관계에서는 기본적으로 지연 로딩을 사용합니다.
프록시에 대해 궁금하다면 아래 글을 참고해주세요.
@Entity
public class Parent extends Timestamped {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long pid;
@Column(length = 20)
private String parentName;
// default FetchType: LAZY
@OneToMany(cascade = CascadeType.ALL, mappedBy = "user")
private List<Child> children;
...
}
@Entity
public class Child extends Timestamped {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long cid;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "pid")
Parent parent;
@Column(length = 20)
String childName;
public void setParent(Parent parent) {
this.parent = parent;
parent.updateChildren(this);
}
...
}
위의 예시 코드에서는 Child를 조회하는 그 당시에는 Child를 조회하는 쿼리 단 1개만 발생합니다. 조회한 Child 객체에서 연관 관계인 Parent 속성에는 Parent 객체인 척하는 프록시 객체가 들어갑니다. 실제로 이 프록시 객체에 Parent 속성에 대한 요청이 들어왔을 때, 프록시 객체는 Parent 객체 조회 쿼리를 전송하여 진짜 객체를 로딩하고 요청에 대한 결과값을 반환합니다. 결과적으로 즉시 로딩과 달리 연관 관계 조회 쿼리가 바로 발생하지 않고, 정말 Parent 엔티티가 사용될 때에 조회 쿼리가 발생합니다. 지연 로딩에서는 연관 엔티티가 사용되지 않으면 쿼리가 한 번, Parent 엔티티가 사용되면 쿼리가 두 번 발생합니다.
추천하는 페치 전략 설정 방법
위에서도 언급했듯 기본적으로 컬렉션 조회에서는 지연로딩을, 단일 엔티티 조회에서는 즉시 로딩을 사용합니다. 그러나 되도록이면 모든 연관관계에 지연 로딩을 사용하고, 필요한 곳에 즉시 로딩을 사용하는 것을 추천합니다.
즉시 로딩을 지양하는 이유는 다음과 같습니다.
1. 때로는 필요하지 않은 연관 관계의 엔티티도 함께 조회합니다.
2. 즉시 로딩을 무작정 사용하면 예상치 못한 SQL문이 발생할 수 있습니다.
페치 전략에서 발생하는 N+1 문제
JPA의 페치 전략만을 사용하면 OneToMany 관계를 조회할 때 N+1 문제가 발생할 수 있습니다. N+1 문제란 하나의 엔티티를 조회할 때 연관 관계의 수인 N개 만큼 추가 쿼리가 발생하는 문제를 말합니다. 최종적으로 하나의 엔티티를 N+1 번의 조회 쿼리로 읽어와 성능이 저하될 수 있습니다.
N+1 문제를 해결하기 위해 등장한 Fetch Join
SQL의 Join과 달리 JPQL에서만 제공하는 성능 최적화를 위한 조인 전략입니다. 연관된 엔티티나 컬렉션을 한 번의 쿼리로 함께 조회하여 N+1 문제를 해결하기 위해 만들어졌습니다.
@Query("SELECT distinct p FROM Parent p join fetch p.children where p.name = :parentName")
public List<Parent> findAllByFetchJoin(String parentName);
Fetch Join을 사용해 조회 성능을 최적화하는 구체적인 방법은 아래 글에서 확인할 수 있습니다.
Join과 Fetch Join의 차이
일반적인 Join
- 조회하고자 하는 엔티티만 영속화합니다.
- 쿼리 검색 조건에는 필요하지만 실제 데이터는 필요하지 않은 상황에 사용하면 좋습니다.
- 즉, 필요한 Entity만 영속성 컨텍스트에 올리고자 할 때 사용합니다.
Fetch Join
- 조회하고자 하는 엔티티와 연관된 엔티티까지 모두 영속화합니다.
- 뒤늦게 연관 관계의 데이터를 참조하더라도 추가 쿼리가 발생하지 않습니다.
Fetch Join의 한계
1. Fetch Join 대상에는 별칭을 줄 수 없습니다.
2. 둘 이상의 컬렉션을 Fetch Join할 수 없습니다.
3. 컬렉션의 경우에는 Fetch Join을 사용하면 페이징 API를 사용할 수 없습니다.
참고자료