[4] 스프링 부트와 JPA 활용 (9) - API 개발 고급 2 (컬렉션 조회 최적화)

Intro
컬렉션 조회 최적화 ?
일대다(OneToMany)관계에 있으면 필드로 컬렉션(Collection)을 가지게 되고 이러한 상황에서의 조회를 단계적으로 최적화를 다루는 것
일대일(OneToOne), 다대일(ManyToOne) 연관관계에서 데이터 조회와 최적화는 앞 글에서 다룸
- 순서
V1 : (엔티티 조회) / 엔티티 직접 반환
V2 : (엔티티 조회) / DTO를 사용한 반환
V3 : (엔티티 조회) / DTO 반환 + fetch join / 페이징 X
V4 : (DTO 조회) / DTO 반환
V5 : (DTO 조회) / DTO 반환 + N+1 문제 해결
V6 : (DTO 조회) / DTO 반환 + 1방쿼리
엔티티 조회
V1
(API 로직)

- 로직
: 모든 Order들을 조회한 후 Entity를 직접 반환
- 문제점
N+1 문제
: order를 순회하면서 프록시 객체를 강제로 초기화
json 무한루프 문제
: json 무한루프 문제를 해결하기 위해 Entity에 직접 @JsonIgnore를 써야함
V2
(API 로직)

- 해결
DTO를 반환하여 json 무한루프 문제를 해결
- 문제점
N+1 문제
: new OrderDto(o) 과정에서 order를 순회하며 내부 생성자에서 각 요소를 강제 초기화하기 때문에 여전히 N+1 문제가 발생
(DTO)

- 주의
Order에 있는 컬렉션인 orderItems도 DTO를 사용해서 반환해야 한다
--> 실무에서 많이하는 실수! (Entity를 직접 반환하지 말자)
V3
(API 로직)

- 해결
fetch join을 사용해서 N+1 문제를 해결함
- 문제점
페이징 불가능
: 1:N 관계에서 join을 하면 데이터가 뻥튀기 되어서 우리가 원하는 페이징이 불가능함
(내부적으로 hibernate가 메모리에 올려서 해주려고 노력은 하지만,
메모리에 모든 데이터를 올린다는 것은 부하가 생길 위험이 매우 크다!
--> 1:N관계에서 페이징을 하려면 V3.1 방법을 사용하자)
(fetch join 코드 추가 - repository)

컬렉션 패치조인은 distinct 필수
: 컬렉션을 가진다는 것은 1:N 관계라는 것이고 join시 데이터가 뻥튀기 됨
즉, 반드시 distinct 옵션으로 중복 객체를 제거해야 한다
컬렉션 패치조인은 1개만 사용
: 컬렉션을 패치조인하면 1:N으로 데이터가 증가되는데 컬렉션을 또 패치조인하면
1:N:M 관계로 데이터가 많아져서 부정합하게 조회될 수 있음
V3.1
(API 로직)

- 로직
컬렉션이 아닌 Entity는 fetch join을 사용해서 조회 쿼리를 감소시킨다
OrderDto(o) 과정에서 지연로딩으로 인해 쿼리가 나갈 때 Batch size 옵션에 지정한 만큼 크기를 미리 가져온다
(SQL의 IN 키워드를 통해서 효율적으로 동작함)
- 결과적으로
N번의 쿼리가 나가지 않고 1번의 쿼리만 수행된다
(Batch size에 따라 상이)
- 해결
N+1 문제 부분 해결
: Batch size옵션을 통해서 쿼리의 횟수를 많이 줄일 수 있음
(1+N+N 이 1+1+1 이 된다)
Trade Off
fetch join을 사용하는 것 보다 많은 쿼리가 발생
--> 컬렉션 관계에서 페이징을 하려면 어쩔 수 없음
--> 하지만, 정규화된 데이터만 가져오기 때문에 때에 따라서 더 효율적이기도 함
(fetch join 코드 추가 - repository)

Batch size 옵션을 켰기 때문에 컬렉션이 아닌 Entity 조회시에도 SQL IN절이 사용
하지만, 이것보다는 fetch join을 사용하는 것이 더 효율적
(컬렉션이 아닌 Entity 관계는 join을 해도 데이터가 뻥튀기 되지 않기 때문에 안전함)
(Batch size 글로벌 옵션 추가 - application.yml)

Batch size를 지정하는 방법
- 글로벌 옵션
: jpa.properties.hibernate.default_batch_fetch_size 옵션 지정
(보통 글로벌 옵션으로 지정해서 많이 사용함)
- 개별 옵션
: @BatchSize 어노테이션으로 개별 지정
Batch size의 적당한 크기 ?
100 ~ 1000 사이가 적당
(클수록 성능은 좋으나, 순간 부하가 높아서 DB성능에 따라서 조절해야 함)
- 왜냐하면,
SQL IN 절을 사용할 때 DB에 따라 1000건 이상시 오류를 발생할 수 있음
DTO 조회
V4
(API 로직)

- 로직
JPA를 DTO로 조회하는 findOrderQueryDtos() 메서드 호출
OrderQueryDto DTO를 통해 반환
(OrderQueryRepository - 쿼리 추가)

DTO 조회를 위한 별도의 repository(OrderQueryRepository)를 생성
표현 계층에 데이터를 만들기 위한 특수한 repository
관심사를 분리해서 확실하게 유지보수 할 수 있다
repository를 분리할 경우에 DTO도 controller에서 정적으로 생성하지 말고 따로 파일로 분리해야 한다
--> 그렇지 않으면 repository 가 controller를 참조하는 역행이 발생
- 로직
컬렉션이 아닌 Entity와 데이터는 findOrders()라는 쿼리로 값을 미리 채움
결과를 순회하며 orderId에 해당하는 정보를 찾는 findOrderItems() 호출
(순회하면 쿼리가 나가기 때문에 N+1 문제 발생)
- 해결
DTO를 통해서 원하는 데이터만 DB에서 가져올 수 있음
- 문제점
순회하며 쿼리를 수행하기 때문에 N+1 문제가 발생
(JPA를 직접 조회하기 위한 DTO)

컬렉션 값인 orderItems는 따로 쿼리를 호출하기 때문에 생성자에 주입 X

V5
(API 로직)

- 로직
JPA를 DTO로 조회하는 findAllByDto_optimization() 메서드 호출
OrderQueryDto DTO를 통해 반환
(OrderQueryRepository - 쿼리 추가)

- 로직
컬렉션이 아닌 Entity와 데이터는 findOrders()라는 쿼리로 값을 미리 채움
결과에서 orderId를 모두 추출해서 쿼리의 파라미터로 전송 (SQL IN 절 사용)
쿼리의 결과를 orderId에 따르는 List<OrderItemQueryDto>로 정리하기 위해 Map<Long, List<OrderItemQueryDto>> 형태로 변환
순회하면서 setOrderItems()를 통해 정리한 값 지정
- 해결
N+1 문제 해결
: orderId를 모두 추출해서 SQL IN절을 사용하여 1번의 쿼리로 해결
V6
(API 로직)

- 로직
JPA를 DTO로 조회하는 findAllByDto_flat() 호출
(컬렉션을 따로처리하지 않고 모든 데이터를 가져오게 동작)
모든 데이터를 가진 OrderFlatDto로 반환
(V1 ~ V5와 결과 스펙이 다르다! 이것을 맞춰주려면 application에서 값을 일일히 매칭하고 형식을 바꿔주어야 함 --> 난 귀찮으니 패스)
(OrderQueryRepository - 쿼리 추가)

컬렉션을 따로처리하지 않고 모~두 한꺼번에 join해서 가져옴
--> 데이터 중복이 발생할 수 밖에 없으며, 이것은 application에서 처리해야함
(모든 데이터를 가진 DTO 추가 생성)

정리
- 엔티티 조회
V1 : 엔티티를 직접 반환
V2 : 엔티티 조회 후 DTO로 반환
V3 : fetch join으로 쿼리 수 최적화 / 페이징 X
V3.1
컬렉션을 제외한 나머지는 fetch join으로 조회
컬렉션은 지연 로딩을 유지하며, Batch size 옵션 지정
--> 페이징 가능!
- DTO 직접 조회
V4 : JPA에서 DTO로 직접 조회
V5 : 일대다 관계인 컬렉션을 SQL IN 절을 통해 메모리에 미리 조회해서 최적화
(페이징 O)
V6 : 모든 테이블 join결과를 application에서 정리해서 API스펙에 맞춰 반환
(페이징 X)
엔티티 조회 vs DTO 직접 조회
엔티티 조회는 JPA에서 제공하는 fetch join이나 batch size옵션으로 N+1 문제 쉽게 해결하지만, DTO 직접 조회 에서는 추가적으로 해야할 것이 많음
--> 이것은 변경사항 발생시 그만큼 수정해야 할 것이 많음을 의미
--> 기본적으로는 엔티티 조회를 우선적으로 사용하는 것을 권장
- 권장 순서
엔티티 조회
- 페이징 없으면 -->
V3
- 페이징 있으면 -->
V3.1
DTO 직접 조회