개발지식 먹는 하마 님의 블로그
로딩 전략과 Fetch Join 그리고 Proxy 본문
즉시 로딩 Eager
- 모든 연관 데이터를 한 번에 로드한다.
- 여러 개의 엔티티가 자주 함께 사용될 때 유용하다.
- 쿼리 수행 횟수를 줄여 성능을 향상할 수 있다.
- @ManyToOne, @OneToOne
지연 로딩 Lazy
- 데이터가 실제로 필요한 시점에만 로드한다.
- 연관 데이터가 필요하지 않은 경우에 유용하다.
- 네트워크 트래픽을 줄이고 메모리 사용을 최적화한다.
- @OneToMany, @ManyToMany
즉시 로딩과 지연 로딩 비교 테스트
N:1 관계의 게시글과 유저가 있다고 가정하고, 아래의 테스트 코드로 테스트를 해보자.
//Board 목록만 조회
List<Board> boards = em.createQuery("SELECT b FROM Board b", Board.class).getResultList();
//조회 결과 출력
for (Board board : boards) {
System.out.println("Board Title: " + board.getTitle());
// 즉시로딩이면 이미 조회됨, 지연로딩이면 쿼리 발생
System.out.println("작성자 이름: " + board.getUser().getName());
}
테스트 코드는 게시글의 목록을 조회한 후, 가져온 게시글들의 제목과 작성자 이름을 출력한다.
🔍 즉시 로딩 실행 결과
즉시 로딩 시, 게시글과 함께 연관된 유저 정보가 한 번에 조회된다.
이미 초기에 모두 조회되었기 때문에 getName으로 유저의 이름을 출력할 때, 별도의 조회가 발생하지 않는다.
예시에서는 작성된 게시글의 유저가 3명이었기 때문에,
위 이미지 중 가운데의 유저 조회 쿼리가 유저 아이디에 따라 총 3번 반복된다.
🔍 지연 로딩 실행 결과
지연 로딩 시, 우선 요청한 게시글 목록만 조회한다.
좌측의 이미지를 보면 즉시 로딩때와는 달리 게시글만 조회한 후, 조회가 종료되는 것을 볼 수 있다.
이후 제목을 먼저 출력한 후, board.getUser().getName()로 인해 유저 정보가 필요해지자 그제야 실제로 조회한다.
+ Entity Manager 캐시
EntityManager는 내부에 1차 캐시(영속성 콘텍스트)를 가진다.
Entity 조회 시, 1차 캐시에서 해당 ID의 존재 여부를 먼저 확인한다.
만약 해당 ID가 존재하지 않다면 DB 조회 결과를 1차 캐시에 저장한다.
위의 예시의 경우 User 유재석이 처음 조회된 후, 해당 값이 EntityManager에 저장된다.
이후 캐시된 엔티티를 재사용하기 때문에 유재석이 작성한 "제목입니다 2"와 "제목입니다 5"에서는
추가적인 조회가 발생하지 않는다.
비유하자면, 즉시 로딩은 아주 열심히 하는 비서이다.
내가 어떤 정보를 요청하면 해당 정보와 함께 연관된 정보들을 한 번에 다 가져온다.
지연 로딩은 게으른 비서이다.
내가 어떤 정보를 요청하면 딱 그 정보만 가져오고 다른 건 미룬다.
내가 실제로 디테일한 연관된 정보를 추가로 요구하면 그제야 그 정보들을 가져온다.
둘 중 누구의 스타일이 옳고 그르다를 판단할 수 없다.
연관 관계가 좁을 때, 관련된 모든 정보를 다 가져와주면 너무 고마울 때도 있지만,
관계의 범위가 넓을 때, 그 모든 정보를 다 가져온다면 내용이 너무 방대하여 조회의 의미가 무색해질 것이다.
필요할 때마다 정보를 가져오는 것은 딱 필요한 정보만 가져오니까 좋을 수 있지만,
때로는 이 정도는 그냥 한 번에 가져오지 그래? 라는 생각이 드는 경우가 있을 것이다.
상황에 따라 적절한 스타일을 사용해야한다.
❓ 언제 어떤 로딩을 사용하는 것이 좋을까?
✅ 즉시 로딩이 적합한 경우
- 항상 함께 쓰이는 관계
- 자주 변경되지 않고 데이터의 양이 작을 때
✅ 지연 로딩이 적합한 경우
- 많은 엔티티와 연관되어 있을 때
- 조회 시점에 꼭 필요하지 않은 관계
주로 지연 로딩이 사용된다.
즉시 로딩은 정말 필요한 곳에서만 제한적으로 사용하는 것이 좋다.
필요시, Fetch Join 또는 EntityGraph를 사용한다.
Proxy 프록시
EntityManager로 객체를 조회하는 메서드는 2가지가 있다.
- find() - 즉시 로딩 방식의 조회 메서드
- getReference() - 지연 로딩 방식의 조회 메서드
메서드 이름에서도 유추할 수 있듯이 참고 자료인 가짜 엔티티를 가져온다는 것인데
이때 가짜 엔티티가 바로 프록시다.
프록시는 실제 엔티티 객체 대신 가짜 객체로, DB 조회를 지연할 수 있다.
- target에 진짜 객체의 참조를 보관한다.
- 실제 클래스를 상속받아서 만들어지기 때문에 사용자는 진짜 객체인지 가짜 객체인지 구분할 필요가 없다.
- 타입 체크 시, == 비교는 실패하기 때문에 instance of를 사용해야 한다.
- 처음 사용(실제 엔티티에 접근)할 때 한 번만 초기화된다.
Proxy 사용 시 진행되는 과정
Tutor proxyTutor = em.getReference(Tutor.class, tutor.getId());
위의 코드의 경우, proxyTutor는 Tutor 클래스를 상속받은 프록시 클래스의 인스턴스로
tutor의 id값만 알고 있을 뿐 실제 객체는 아니다.
따라서 조회 SQL은 실행되지 않는다.
System.out.println("proxyTutor.getName() = " + proxyTutor.getName());
getName 메서드로 Tutor 클래스의 값을 사용하고자 할 때, 실제 조회 SQL이 실행된다. (지연 로딩)
- getName() 호출은 프록시 객체의 Interceptor가 동작한다. (이 부분은 추후 별도의 포스팅에서 정리해 보도록 하겠다.)
- 인터셉터는 해당 엔티티의 초기화 여부를 검사하고 초기화되지 않은 경우 영속성 컨텍스트에 초기화 요청을 보낸다.
ex) tutor의 id가 3일 때, id가 3인 Tutor 엔티티로 초기화해 줘 - 1차 캐시에 해당 ID의 엔티티가 이미 있으면 getReference로 호출해도 실제 엔티티가 반환된다.
없으면 DB에서 조회하여 엔티티를 로딩한다. - 로딩이 완료되면 프록시 객체가 실제 필드로 초기화된다.
주의!) 영속성 컨텍스트과 관리 중인지 확인할 것
영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시를 초기화하면 LazyInitializationException 예외가 발생한다.
영속성 컨텍스트의 도움을 받아야만 실제 Entity에 접근이 가능하기 때문이다.
이미 초기화된 상태라면 프록시 객체는 더 이상 가짜가 아닌 실체 엔티티이기 때문에 준영속 상태가 되어도
추가적인 DB 접근 없이 사용이 가능하다.
N+1 문제
N+1 문제는 1개의 쿼리로 N개의 엔티티를 조회한 후, 각 엔티티와 연관된 엔티티를 N번 추가 조회하는 것이다.
즉시 로딩, 지연 로딩 모두에서 발생한다.
즉시 로딩과 지연 로딩 실행 결과를 다시 보면
1개의 쿼리로 4개의 게시글을 조회한 후,
각 게시글과 연관된 유저 엔티티를 로딩하여 3번(유재석, 김종국, 하하) 추가 조회한다.
Fetch Join으로 미리 연관 엔티티를 한 번에 같이 가져오면 N+1 문제를 방지할 수 있다.
Fetch Join
Fetch Join은 JPQL에서 성능 최적화를 위해 제공하는 기능이다.
연관 관계의 객체를 한 번에 조회하여 즉시 로딩과 유사한 효과를 낼 수 있다.
- Fetch Join이 지연로딩보다 우선권을 가진다.
- Proxy 객체가 아닌 진짜 Entity 객체가 모두 영속성 컨택스트로 관리된다.
🔍 Fetch Join 실행 결과
String query = "select t from Tutor t join fetch t.company";
Fetch Join 실행 시, 튜터와 연관된 회사까지 한 번에 쿼리문으로 조회된 후 그 값을 사용하는 것을 확인할 수 있다.
☑️ Fetch Join의 단점
- 별칭을 사용에 주의해야 한다.
별칭을 잘못 사용하면 연관된 데이터 수가 달라져서 데이터 무결성이 깨질 수 있다. - 둘 이상의 컬렉션을 Fetch 할 수 없다.
튜터는 회사, 학생 엔티티와 연관이 되어있는데 둘을 동시에 fetch join 할 수 없다. - 컬렉션을 Fetch Join 하면 페이징 API를 사용할 수 없다.
1:N 관계에서는 메모리에서 페이징 처리를 한다. -> Out Of Memory 오류를 발생시킬 수 있다. - JPA에서 fetch Join이 들어간 경우 Count 쿼리를 정상적으로 만들어내지 못한다.
@BatchSize
지연 로딩 시, @BatchSize를 활용하면 N개의 프록시 초기화를 하나의 IN 쿼리로 묶어 효율적으로 처리할 수 있다
1:N 관계의 페이징 조회에서 Fetch Join 대신, 지연 로딩을 유지하여 메모리 페이징 문제를 해결한다.
예를 들어 Tutor와 Company가 있을 때, Company를 먼저 페이징으로 가져온다.
Page<Company> companies = companyRepository.findAll(pageable);
이후, 튜터의 필드에 접근을 하게 되면 페이징으로 조회된 company 수만큼의 튜터가 조회된다.
for (Company company : companies) {
List<Tutor> tutors = company.getTutors();
}
SELECT * FROM tutor WHERE company_id = ? //페이징한 company 수만큼 반복
여기에 BatchSize를 사용하면,
@Entity
public class Company {
@OneToMany(mappedBy = "company")
@BatchSize(size = 100)
private List<Tutor> tutors;
}
SELECT * FROM tutor WHERE company_id IN (1, 2, 3, ..., 10)
이런 식으로 Tutor를 지연 로딩하지만, 요청을 한 번에 IN 절로 묶어서 조회한다!
- 전역으로 설정한 size와 필드에 대해 선언한 size 중 필드 설정을 더 우선한다.
- size를 100으로 설정했다고 무조건 100으로 나뉘어 동작하는 것은 아니다.
@BatchSize(size = 100) 설정은 최대 100개까지 한 번의 IN 쿼리로 조회하도록 힌트를 주는 것이고,
실제 IN 절에 몇 개가 포함될지는 상황(초기화 대상의 수, 영속성 컨텍스트 상태 등)에 따라 달라진다.
Collection Fetch Join
@OneToMany, @ManyToMany 관계의 리스트를 한 번의 쿼리로 함께 조회한다.
- 기본 로딩 전략이 지연 로딩인 경우, 직접 조회하지 않으면 불러와지지 않는다.
- 부모 엔티티가 자식 수만큼 중복되므로 DISTINCT가 필요하다.
단점에 대한 더 자세한 내용은 아래의 링크를 참고하세요!
https://medium.com/sjk5766/fetch-join-%ED%8A%B9%EC%A7%95-%EB%B0%8F-%EB%8B%A8%EC%A0%90-75095d1ede21
fetch join 특징 및 단점
이전 포스팅인 JPA N+1 문제에서 fetch join의 단점을 언급했지만, 샛길로 빠지는 것 같아 언급만 하고 상세한 내용을 작성하지 않았다. 이번 포스팅에서는 join과 fetch join을 비교해서 특징을 알아보
medium.com
@EntityGraph
Fetch Join을 직접 작성하지 않고 원하는 연관 엔티티를 함께 조회할 수 있도록 하는 어노테이션이다.
@Query("select t from Tutor t")
@EntityGraph(attributePaths = {"company"})
List<Member> findTutorEntityGraph();
단, Left outer join만 지원하기 때문에 다른 방식이 필요하면 직접 fetch Join을 사용해야 한다.
'Web' 카테고리의 다른 글
[Spring] QueryDSL 기초 + 페이징 적용 (0) | 2025.05.07 |
---|---|
공통 로직 처리를 위한 Filter, Interceptor 그리고 AOP (0) | 2025.04.21 |
쿠키 Cookie와 세션 Session 그리고 토큰 Token (1) | 2025.04.10 |
DTO (Data Transfer Object) (0) | 2025.03.31 |
웹 서비스 구조 및 흐름을 비유와 함께 이해하기 (0) | 2025.03.20 |