컬렉션 조회
- 이번 게시글에서는 엔티티 내부에 있는 컬렉션 필드의 조회를 최적화하는 방법에 대해서 알아본다.
- 컬렉션 필드를 쓰면 쿼리가 많이 나가서 최적화를 상당히 신경써야 한다.
v1 - 엔티티를 직접 노출
- 엔티티를 그대로 반환하기 때문에 API에서 사용하기에는 좋지 않은 방법
- 지연 로딩에 의해 프록시로 존재하는 부분을 강제로 초기화해줘야 한다.
Hibernate5Module
- JPA 사용 시 일반적으로 fetch 전략을 LAZY로 잡기 때문에 실제 엔티티 객체 대신에 프록시 객체를 갖고 있다.
- jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모르기 때문에 예외가 발생한다.
- 이를 해결하기 위해 Hibernate5Module 또는 Hibernate5JakartaModule를 스프링 빈으로 등록해준다.
스프링 부트 2.X를 사용 중인 경우
스프링 부트 3.X를 사용 중인 경우
@JsonIgnore
- 엔티티를 직접 반환할 때 해당 엔티티에 양방향 연관관계가 존재한다면 양측 엔티티가 서로 호출하면서 무한 루프가 발생한다.
@JsonIgnore
를 추가해서 순환 참조를 막는다.@JsonIgnore
는 주로 @ManyToOne 어노테이션이 있는 필드에 추가하면 된다.
v2 - 엔티티를 DTO로 변환
- 엔티티를 DTO로 변환 후 반환하는 방법
- 조회한 엔티티를 DTO의 생성자를 통해 DTO로 변환한다.
- 장점
- 해당 API를 위한 어느정도 고정된 스펙의 DTO를 반환하기 때문에 API의 스펙이 변경될 일이 적다.
- 단점
- 지연 로딩이기 때문에 N + 1의 문제가 존재한다.
- 발생하는 문제의 유형을 부르는 이름이 N + 1인거지 실제로는 더 많은 쿼리가 발생할 수도 있다.
- 컬렉션의 제네릭으로 사용되는 엔티티에 따라서 어마어마하게 많은 추가 쿼리가 발생할 수도 있다.
- 팁
map(o -> new OrderDto(o))
는 람다 레퍼런스 방식으로 map(OrderDto::new)
처럼 표현할 수 있다.
v3 - 페치 조인 최적화
- 페치 조인(fetch join)으로 데이터를 한꺼번에 가져온다.
- 엔티티로 조회했기 때문에 DB와의 작업이 수월하다. (ex : 변경 감지)
- 유연도가 높은 방식이다. (v4에 비해서 비교적 높은 편)
- 컬렉션 페치 조인은 하나만 가능하다.
- 억지로 2건 이상이 가능은 한데 하이버네이트가 제대로 쿼리나 쿼리의 결과를 인식 못 할 가능성이 크다.
- distinct 키워드를 사용하면 JPA가 중복 데이터를 제거할 수 있게 설정할 수 있다.
- 하이버네이트 6.0에서는 distinct 키워드를 명시하지 않아도 JPA가 자동으로 중복 제거를 시도한다.
- 문제점
- 컬렉션 조회 & 페치 조인을 할 때는 페이징이 안 된다.
- 정확하게는 페이징이 되는건 맞는데 모든 데이터를 가져와서 메모리 내부에서 페이징 작업을 한다.
- 데이터가 많으면 아웃 오브 메모리가 발생한다.
v3.1 - 페이징과 한계 돌파
- 사실 v3에서 페이징을 사용할 수 있는 방법이 있다.
- v3에서 설명했다시피 한꺼번에 가져와서 정렬하는 것이 문제다.
- 한꺼번에 가져오는 데이터의 개수에 제한을 설정하면 된다.
- 환경설정 방식
hibernate.default_batch_fetch_size
- 어노테이션 방식
- 컬렉션 조회때문에 1대N대M였던 것을 1대1대1로 바꿀 수 있다.
- “사이즈를 설정한다.”라는 것은 “쿼리 내부에서 사용되는 IN 절에서 포함되는 요소의 개수를 설정한다는 것을 의미한다.
- 사이즈의 숫자는 발생하는 쿼리에 따라 조절해야 한다.
- 일반적으로 100 ~ 1000이 권장된다.
- XToOne 관계만 우선 모두 페치 조인으로 최적화한다.
v4 - JPA에서 DTO로 바로 조회
- DB에서 조회한 데이터를 엔티티가 아닌 DTO로 바로 받는 방식
- DTO로 조회했기 때문에 DB와의 작업이 수월하지 못 하다. (ex : 변경 감지)
- JPQL 작성 방법이 좀 번거롭다. (패키지명 직접 명시)
- 1대1이나 N대1 관계를 먼저 조회한 다음에, 1대N 관계를 조회한다. (반복문 이용)
- 단건 조회에서 많이 사용하는 방식
- 유연도가 낮은 방식이다. (v3에 비해서 비교적 낮은 편)
- 컬렉션은 별도로 조회한다.
- 쿼리가 1 + N만큼 실행된다.
- 화면에 최적화되 있는 방식
v5 - 컬렉션 조회 최적화
- 컬렉션을 IN 절을 통해 한꺼번에 조회하는 방식
- IN 절에서 사용할 id 값 목록을 미리 만들어야 한다.
- v4의 N + 1 문제가 해결된다.
- Collectors.groupingBy를 사용하기 위해서는
@EqualsAndHashCode(of = "orderId")
처럼 객체끼리 구분할 수 있는 정확한 방법을 명시해줘야 한다.- Collectors.groupingBy는 그룹화 키를 기준으로 엔티티를 그룹화하기 위해 엔티티의 해시코드를 사용한다.
- 엔티티에 @EqualsAndHashCode 어노테이션이 없으면 기본 해시코드 구현이 사용된다.
- 기존 해시코드 구현은 엔티티의 식별자를 고려하지 않기 때문에 동일한 식별자를 가진 엔티티가 서로 다른 그룹으로 분류될 수 있다.
- 아래 예시의 경우에는 OrderItemQueryDto의 orderId를 기준으로 그룹화하기 때문에 OrderQueryDto에 어노테이션을 추가해줘야 한다.
v6 - 플랫 데이터 최적화
- Inner Join으로 쿼리 한 번으로 모든 데이터를 가져오는 방법
- groupingBy()를 위해 ` @EqualsAndHashCode(of = “xxx”)`를 지정해야 한다
- 장점
- 쿼리를 한 번만 실행한다.
- 플랫 데이터에 최적화되어 있다.
- 단점
- 쿼리를 한 번만 실행하기 위해 많은 조인이 발생한다.
- DB에서 애플리케이션에 전달하는 중복되는 데이터의 양이 증가한다.
- 상황에 따라서 성능이 하락할 수도 있다.
- 애플리케이션에서 해야 할 추가 작업이 크다.
- 분해하고 재조립하는 등 할 일이 많고 어렵고 복잡하다.
- 데이터 중복으로 인하여 페이징이 불가능하다.
결론
- 왠만하면 엔티티로 조회 후 DTO로 변환한다.
- 만약 성능이 떨어지면 페치 조인을 사용한다.
- 컬렉션을 최적화하자.
- 페이징이 필요하다면
hibernate.default_batch_fetch_size
또는 @BatchSize
를 설정한다. - 페이징이 필요없다면 페치 조인을 사용한다.
- 엔티티 조회 방식으로 해결이 안 되면 그 때부터 DTO 조회 방식을 사용한다.
- DTO 조회 방식으로도 해결되지 않는다면 Navite Query나 스프링 JdbcTemplete을 사용한다.
- 어지간해서는 페치 조인으로 성능이 많이 개선된다.
- 페치 조인으로도 안 되면 캐시를 사용하는 걸 고려해보는 것이 좋다.
- 엔티티는 영속성 컨텍스트에서 관리되기 때문에 엔티티를 캐시로 사용하면 안 된다.
- DTO만 캐시로 사용해야 한다.
출처