N+1 이슈는 어떤 것일까?
- 개발자는 요청이 1개로 처리되길 원한 상태로 비즈니스 로직을 구현하였지만, N개의 추가 Query가 발생하게 되는 현상
- 연관 관계를 Mapping하는 과정에서 fetch Type을 Lazy 와 Eager 전략에서 각각 발생한다.
Eager Loding에서 N+1이 발생하는 이유
- 즉시 로딩에서 이러한 이슈가 발생되는 이유는 JPQL 을 사용하는 경우 전체 조회를 했을 때 영속성 콘테스트가 아니라 데이터 베이스에서 데이터를 직접적으로 조회한 후 로딩전략이 동작하기 때문이다.
- 예를 들어 A라는 Entity가 존재하고, 그 A에 종속적인 엔티티의 데이터가 10개 존재한다고 하였을 때 A를 조회할경우 A에 종속적안 데이터들이 조회 row 만큼 쿼리가 호출된다.
- 정리하자면 , JPQL은 즉시 로딩 쿼리를 만들때, 연관관계가 있는 엔티티는 신경 쓰지 않고, 조회 대상이 되는 엔티티를 기준으로 쿼리를 만든다. 따라서, 처음에 조회 대상 엔티티 기준의 쿼리를 날리고, 연관된 엔티티가 있음을 확인한 이후에 글로벌 패치 전략을 확인한다. (이때, N번의 쿼리 발생)
- JPQL이란 Java Persistence Query Language의 약어로, 엔티티 객체를 대상으로 쿼리를 작성할 수 있도록 해준다.
그렇다면 어떻게 이슈를 해결할 수 있을까?
- 대부분의 경우 FetchType 을 Lazy 로 바꾸어 해결한다. 그럴 경우 N개의 쿼리는 없어지고 원래의 쿼리 1개만을 산출한다.
- 그렇다면 과연 FetchType의 문제일까? 전혀 그렇지 않다. FetchType을 Lazy로 설정하였다는 것은 연관관계 데이터를 프록시 객체로 바인딩한다는 것이다. 하지만 우리가 직접 DB를 구성하는 클래스들 즉 Entity 도메인 클래스들은 항상 프록시로 이용되지는 않는다.
- 즉 지연 로딩에서 해당 문제가 발생하는 이유는 지연로딩 전략을 사용한 하위 엔티티를 로드할 때, JPA에서 프록시 엔티티를 unproxy 할 때 해당 엔티티를 조회하기 위한 추가적인 쿼리가 실행되어 발생한다. (하위 엔티티가 1차 캐시 저장소에 없는 경우)
- 실제로는 연관관계 Entity의 Member 변수를 사용하거나 가공하는 일은 코드로 구현하는 경우가 훨씬 흔하다.
- 또한 Lazy 타입이어도 반복문에서 A클래스의 종속된 클래스의 필드를 조회하는 작업을 진행할 경우(꼭 조회에 국한되지 않는다.) 똑같은 N+1의 문제를 맞이하게 된다. 이말은 연관관계로 Mapping된 Entity의 데이터를 사용하는 시점에 문제가 발생한다는 것이다.
결론 : 결국 FetchType을 변경하는 것은 단지 N+1의 발생 시점을 연관관계 데이터를 사용하는 시점으로 미룰지 , 아니면 초기 데이터 로드 시점에 가져오느냐에 차이만 있는 것이다.
그렇다면 근본적인 원인이 무엇일까?
JPARepository를 상속하여 우리가 Repository 계층(데이터 액세스 계층)을 구현함에 있어 해당 인터페이스에 정의된 메소드를 실행하게된다.
그때 JPA는 메소드 이름을 분석하여 JPQL을 생성 후 실행한다. JPQL은 SQL을 추상화한 객체지향 쿼리 언어이기에 SQL에 종속적이지 않고 Entity 객체와 해당 객체의 Filed 이름을 가지고 쿼리를 날린다.
그렇기 때문에 JPQL은 findAll()이란 메소드를 수행하였을 때 해당 엔티티를 조회하는 select * from EntityName 쿼리만 실행하게 되는것이다.
JPQL 입장에서는 연관관계 데이터를 무시하며 해당 엔티티 기준으로 쿼리를 조회하는 특성이 존재한다. 고로, 연관된 엔티티 데이터가 필요한 경우, FetchType으로 지정한 시점에 조회를 별도로 호출하게 되는 것이다.
해결 방안
Fetch Join: 연관된 엔티티 또는 컬렉션을 한번에 같이 조회하는 기능. 연관된 엔티티 모두 영속성 콘테스트에 올려버리게 된다.
우리가 원하는 N+1의 문제없이 쿼리를 작성하기 위해선 Fetch Join을 이용할 경우 해당 문제를 해결할 수 있다. 다만 DataJPA의 영역이 아닌 Native Query를 작성하거나 queryDsl을 통해 해당 문제를 해결할 수 있다.
하지만 불필요한 쿼리의 작성을 기피하고 싶은 경우 또는 필드마다 Fetch Type의 설정이 까다로운 경우에는 @EntityGraph 어노테이션을 이용하면 된다.
Fetch Join 주의 사항
Fetch Join도 단점은 존재한다. 우선은 우리가 연관관계 설정시 사용한 FetchType을 사용할 수 없다는 것이다. Fetch Join을 사용하게 되면 데이터 호출 시점에 모든 연관 관계의 데이터를 가져오기 때문에 FetchType을 Lazy로 해놓는것이 무의미하다.
또한 페이징 처리시 OneToMany관계에서 데이터 누락이 발생할 수 있다.
JPA는 데이터 누락을 해결하기 위해서 데이터를 전체 full scan 해서 가져오고, 메모리에서 페이지 처리를 한다. 즉, 메모리 부하가 생길 수 있다. 이러한 문제의 해결책으로 ManyToOne일 때, fetch join을 사용하거나, @BatchSize를 사용할 수 있다.
@EntityGraph
@EntityGraph 의 attributePaths에 쿼리 수행시 바로 가져올 필드명을 지정하면 Eager 조회로 가져오게 된다. Fetch join과 동일하게 JPQL을 사용하여 Native Query 문을 작성하고 필요한 연관관계를 EntityGraph에 설정하면 된다. 그러나 Fetch join과는 다르게 join 문이 outer join으로 실행된다.
@EntityGraph(attributePaths = "example")
@Query("select t from Test t")
List<Test> findAllEntityGraph();
Fetch Join과 @EntityGraph 공통 주의점
Fetch Join과 EntityGraph는 JPQL을 사용하여 JOIN문 사용하게 되는데 카테시안 곱(Cartesian Product)이 발생하여 Owner의 수만큼 Dependency가 중복 데이터로 존재할 수 있다. 그러므로 중복된 데이터가 컬렉션에 존재하지 않도록 주의해야 한다.
'Spring-boot' 카테고리의 다른 글
SpringBoot 외부 Rest API 호출 방식(JAVA) - RestTemplate (0) | 2023.02.25 |
---|---|
io.jsonwebtoken.SignatureException 정리 (0) | 2023.02.24 |
@Getter와 @Setter는 왜 지양되어야 하는가? (0) | 2022.11.29 |
[Spring] 패키지 구조 (0) | 2022.10.21 |
[JPA] 비즈니스 로직 디자인 패턴에 관한 정리 (0) | 2022.08.27 |