어제 수업이였는데 올리지 못하고 잠이들어 어린이날인 오늘 올린다
조회 전략(Fetch Strategy)은 연관된 엔티티를 언제 조회할지 결정하는 방식이다.
예를 들어 Student와 Course가 연관관계로 연결되어 있다고 가정해보자.
이때 학생을 조회하면서 수업 정보까지 바로 가져올지, 아니면 나중에 실제로 필요할 때 가져올지를 정하는 것이 조회 전략이다.
즉시 로딩은 엔티티를 조회하는 시점에 연관된 엔티티도 함께 조회하는 방식이다.
FetchType.EAGER@ManyToOne, @OneToOne즉시 로딩은 처음 보면 편리해 보인다.
엔티티를 조회하자마자 연관된 데이터도 이미 준비되어 있기 때문이다.
하지만 항상 필요한 것은 아닌 연관 데이터까지 함께 조회될 수 있다는 문제가 있다.
이로 인해 예상하지 못한 조인이나 추가 쿼리가 발생할 수 있고, 성능상 비효율이 생길 수 있다.
실무에서는 이런 이유로 즉시 로딩을 무조건 신뢰하기보다 필요한 시점에 명확하게 조회하는 방식을 더 선호하는 편이다.
지연 로딩은 연관 엔티티를 바로 조회하지 않고, 실제로 사용할 때 조회하는 방식이다.
FetchType.LAZY@OneToMany, @ManyToMany지연 로딩의 장점은 불필요한 데이터 조회를 줄일 수 있다는 점이다.
즉, 성능 최적화에 유리하다.
다만 여기서 한 가지 의문이 생긴다.
연관 엔티티를 아직 조회하지 않았는데도 어떻게 객체처럼 다룰 수 있는가?
이 역할을 담당하는 것이 바로 프록시 객체이다.
지연 로딩을 사용할 때 JPA는 실제 엔티티 대신 프록시 객체를 넣어둔다.
프록시는 쉽게 말하면 실제 객체를 대신하는 가짜 객체이다.
겉으로는 진짜 엔티티처럼 보이지만, 내부 데이터는 아직 전부 조회되지 않은 상태이다.
예를 들어 student.getCourse()를 호출했을 때, 바로 실제 Course 엔티티가 들어 있는 것이 아니라 프록시가 들어 있을 수 있다.
이후 getName()처럼 실제 값이 필요한 시점이 되면 그때 DB 조회가 발생하고, 프록시가 초기화된다.
흐름을 정리하면 다음과 같다.
즉, 프록시는 지연 로딩을 가능하게 해주는 핵심 장치라고 볼 수 있다.
N+1 문제는 쿼리 1번으로 데이터를 조회한 뒤, 조회된 결과 수만큼 추가 쿼리가 발생하는 현상이다.
예를 들어 학생 목록 10명을 조회했다고 해보자.
List<Student> students = studentRepository.findAll();
여기까지는 쿼리 1번이다.
그런데 이후 반복문에서 각 학생의 수업 정보를 조회하면 상황이 달라진다.
for (Student student : students) {
System.out.println(student.getCourse().getName());
}
이 경우 학생이 10명이라면 course를 조회하기 위한 쿼리가 10번 추가로 발생할 수 있다.
결국 전체 쿼리는 1 + N이 된다.
이 현상이 위험한 이유는 데이터 수가 많아질수록 성능 저하가 커지기 때문이다.
테스트 데이터가 적을 때는 문제가 잘 드러나지 않지만, 실제 서비스 환경에서는 큰 차이로 이어질 수 있다.
또한 N+1 문제는 단순히 LAZY만의 문제라고 볼 수는 없다.
EAGER 역시 JPQL에서 연관 엔티티를 함께 조회하도록 명시하지 않으면 추가 조회가 발생할 수 있다.
결국 중요한 것은 로딩 전략 자체보다도 실제로 어떤 쿼리가 실행되느냐이다.
1) Fetch Join
가장 대표적인 해결 방법은 FETCH JOIN이다.
SELECT s FROM Student s JOIN FETCH s.course
이 방식은 학생과 수업을 한 번의 쿼리로 함께 가져온다.
따라서 반복문에서 프록시를 하나씩 초기화하면서 추가 쿼리가 발생하는 문제를 줄일 수 있다.
장점은 다음과 같다.
성능 최적화에 효과적이다
의도가 명확하다
실무에서 자주 사용된다
다만 컬렉션 fetch join은 페이징과 함께 사용할 때 주의가 필요하다.
2) EntityGraph
EntityGraph는 어노테이션으로 함께 조회할 연관 필드를 지정하는 방식이다.
@EntityGraph(attributePaths = {"course"})
@Query("select s from Student s")
List findAll();
이 방식은 쿼리를 크게 바꾸지 않고도 어떤 연관 데이터를 함께 조회할지 지정할 수 있다.
장점은 다음과 같다.
코드가 비교적 깔끔하다
재사용하기 좋다
리포지토리 메서드 수준에서 관리하기 편하다
반면 내부적으로 어떤 조회가 일어나는지 직접적으로 드러나지 않아, 복잡한 상황에서는 fetch join보다 덜 직관적으로 느껴질 수 있다.