앞서 다대일 연관관계를 갖고 있는 엔티티를 조회할 때 어떻게 최적화할지 알아보았다. 이번에는 일대다 연관관계를 갖고 있는 엔티티 조회를 최적화해보자.
앞서 보인대로 엔티티를 json 화 하기 위해 다양한 처리를 해야함 (https://www.inforum24.com/memos/1204)
N+1 문제 역시 존재
DTO로 변환하면 api 스펙에 맞추어 유연한 개발이 가능하다. 그러나, 컬렉션 필드를 그대로 가져와 DTO에 반환하면 마찬가지로 엔티티를 노출한 꼴이 된다. 따라서, 내부 필드에 있는 엔티티도 DTO로 변환하여 출력해야한다.
내부 일대다 연관관계 엔티티도 DTO로 변환하면 하이버네이트의 추가적인 라이브러리를 사용하지 않아도 json으로 파싱이 잘 되는 것을 확인할 수 있다.
그러나, 지연 로딩에 의해 매 필드에 접근할 때 마다 SQL을 날려 N+1 문제가 발생한다.
페치 조인을 활용하여 접근할 필드와 연관된 테이블을 모두 불러오면 N+1 문제를 해결할 수 있다.
그러나, 사용하지 않을 필드까지 불러오는 문제가 있고, 일대다 연관관계를 조인해오면 N 개의 데이터가 필요하더라도 각 데이터가 M 개의 데이터와 연관되어 있다면 N x M 만큼의 행을 불러와 실제 JPA 에서도 해당 엔티티가 N 개가 아니라, NxM 개가 있는 것처럼 동작하게 된다.
일대다 페치 조인을 사용하게 되면, 페이징이 불가능하다. (페이징이 메모리에서 일어난다.)
일대다 페치 조인은 1개만 사용할 수 있다. (둘 이상 사용 시 데이터가 부정합하게 조회된다.)
일대다 관계에서의 중복을 제거하기 위해 distinct 키워드를 사용한다. (하이버네이트 6.0 부터는 기본값으로 distinct 키워드가 들어간다.)
SQL에도 실제로 distinct 키워드를 넣는다.
JPA에서도 같은 ID 값을 가진 엔티티가 있으면 중복을 제거하는 효과가 있다.
페치 조인은 N+1 문제를 해결할 수 있지만, 컬렉션 필드가 존재할 경우 페이징이 불가능했다. 이 문제를 해결해보자.
해결 방법
ManyToOne, OneToOne 관계는 모두 페치조인한다.
컬렉션은 모두 지연로딩으로 설정한다.
지연로딩 최적화를 위해 hibernate.default_batch_fetch_size(전역 설정), @BatchSize(필드 설정) 를 적용하여 매 번 데이터를 조회하지 않고 한번에 묶음으로 데이터를 가져올 수 있도록 한다.
위 방법을 적용하면 페이징도 가능하면서 컬렉션 조회를 최적화할 수 있다. (페치 조인으로 한 번에 가져오는 것보다 쿼리 호출 횟수는 많지만 DB 데이터 전송량은 더 적다.)
추가. batch_size 는 1000 이하로 잡는 것이 좋다.(in 쿼리가 1000개 넘어가면 오류가 나는 DBMS가 존재) 또, 1000으로 잡는 것이 한 번에 많은 데이터를 가져와 좋으나, 100으로 잡는 것에 비해 애플리케이션에 순간 부하가 증가할 수 있으므로 테스트를 해보고 결정하는 것이 좋다.
JPA 쿼리 내에서 DTO로 변환하여 반환할 때, 내부 컬렉션 필드도 DTO로 변환해야한다.
먼저, 컬렉션 필드를 제외한 DTO 리스트를 반환한다.
이후, DTO 리스트를 순회하며 컬렉션 필드를 DTO 리스트로 가져오는 쿼리를 수행한다.
맨 처음 가져온 DTO 리스트가 N 개의 원소를 갖고 있다면 쿼리는 N + 1 번 실행되게 된다.
위 방법에서 각 DTO 의 컬렉션 필드를 가져올 때, N 번의 쿼리를 실행했는데 이를 IN 절을 사용하여 한번에 가져올 수 있다.
IN 절을 사용하여 가져온 컬렉션 필드 리스트를 원래 DTO의 id 값으로 stream().collect(Collectors.groppingBy(id)) 와 같이 Map<Long, List<DTO>> 형태로 변환할 수 있다.
이후 맨 처음에 쿼리를 통해 가져온 DTO 리스트를 순회하며 Map 에서 id 값을 매핑하여 컬렉션 필드를 초기화하면 2번의 쿼리로 처리할 수 있다.
위 방법에서 쿼리를 2번 사용하는 이유는 DTO 내부에 DTO 컬렉션 필드가 존재하기 때문이다. 따라서, DTO 컬렉션 필드를 일반 필드들로 대체하면 쿼리 1번으로 DTO를 반환할 수 있다.
일대다 연관관계로 매핑된 테이블까지 한 번에 가져와 DTO로 반환하기 때문에 많은 쪽 테이블 열 개수 만큼 데이터가 나오게 되고 데이터 중복이 발생하며, 페이징이 불가능해진다.
엔티티 조회 방식으로 우선 접근
페치 조인으로 쿼리 수를 최적화
컬렉션 최적화
페이징 필요 시 default_batch_fetch_size, @BatchSize 로 최적화
페이징이 필요 없을 시 페치 조인 사용
엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식 사용
DTO 조회 방식으로도 해결이 안되면 Native SQL or Spring JdbcTemplate
추가.
DTO 조회 방식을 사용하는 것은 엔티티 조회 방식에 비해 큰 차이를 얻기 힘들고, 코드 복잡도도 증가하기 때문에 메모리 캐싱 등 다양한 최적화를 거친 후에 사용하자.
DTO 조회 최적화 방식을 선택할 때, 항상 6번 방법이 쿼리 1번 나간다고 좋은 것은 아니다.
4번 방법 - 코드 유지 보수가 5,6 번에 비해 수월하나 성능이 좋진 않음
5번 방법 - 코드 유지 보수가 힘들어지지만, 성능이 좋음 (김영한 pick)
6번 방법 - 코드 유지 보수가 힘들며, 페이징이 불가능하지만 성능이 가장 좋음 (데이터 중복이 많이 발생하면 5번과 성능 상 큰 차이가 없음)