JPA - XToOne 데이터 조회

·2024년 4월 30일
0

Spring/JPA

목록 보기
9/15

인프런 김영한 강사님 강의를 듣고 정리한 내용입니다.

XToOne 데이터 조회

리포지토리에 가해지는 연산 CRUD 중, 조회는 성능 문제의 90%를 차지한다. 첫번째 이유는 대부분의 사용자가 조회 기능을 가장 많이 사용하기 때문이고, 두번째 이유는 조회에서 N+1 문제가 발생하기 쉽기 대문이다.

앞서 말했듯 저장과 조회에 있어서 모두 엔티티 타입을 그대로 사용하는 것보다는 DTO를 사용하는 것이 필수적으로 요구된다. 그래야지만 API 스펙에 영향을 주지 않고, 엔티티가 외부에 노출되는 것을 피할 수 있기 때문이다.
(이전글 참고 : https://velog.io/@suhsein/JPA-DTO)

다음은 XToOne 조회 시 성능을 올리는 진화 과정이다.

  1. Entity 그 자체를 반환
  2. 읽은 데이터를 DTO로 변환해서 반환
  3. 리포지토리에서 데이터를 읽을 때 성능 최적화를 위해 fetch join
  4. 리포지토리에서 데이터를 읽을 때 DTO에 맞추어 데이터를 읽기

Entity를 API로 내보내기

리포지토리에서 데이터를 읽을 때 String 타입으로 데이터를 변환해서 읽을 수 있다.

양방향 순환 참조 문제

이 과정에서 첫번째로 발생할 수 있는 에러는 바로 양방향 순환 참조 에러이다. A<->B가 양방향으로 연관관계가 걸려있을 때, A의 데이터가 필요하다고 가정하자. A의 데이터를 읽던 중 B가 내부에 포함되어있으므로 B의 데이터를 읽게 된다. 하지만 B의 내부에도 A가 포함되어 있으므로 다시 A를 읽게 된다. 이렇게 양방향으로 걸려있는 연관관계 속에서 끝나지 않는 무한 참조가 발생하게 되는 것이다. 결론적으로 스택 오버플로우가 터져서 읽을 수가 없게 된다.
이를 해결하기 위해서는 연관관계가 걸려있는 엔티티의 어느 한 쪽에 @JsonIgnore를 걸어서 해결할 수 있다. 지금처럼 XToOne 관계에서 반대편에 @JsonIgnore를 건다.

만약 Order와 Member가 N:1 관계에 있다면

 @JsonIgnore
 @OneToMany(mappedBy = "member")
 private List<Order> orders = new ArrayList<>();

이와 같이 Member 내부의 order 필드에는 @JsonIgnore를 걸어준다.

Type Definition 에러

양방향 순환 참조 문제를 해결하고 다시 조회를 시도해보면 이번에는 Type Definition 에러가 발생한다.
보통 XToOne을 제외한 모든 연관관계에는 fetch type이 기본적으로 Lazy로 설정되어있다. Lazy로 설정하는 이유는 N+1 문제가 발생하지 않도록 하기 위함이기 때문에, XToOne 관계에도 모두 FetchType.Lazy로 설정해놓는다.
하지만 이렇게 설정을 해두면 @JsonIgnore과 LazyLoading으로 인해 TypeDefinition 에러가 발생한다. JPA는 기본적으로 Lazy로 설정한 필드에 null 값 대신 bytebuddy라는 프록시 객체를 넣어두게 된다. 그리고 Lazy Loading이 실제로 일어날 때 실제 객체가 들어오게 된다. 그렇기 때문에 로딩 전까지는 타입을 알 수 없게 된다.
이를 막기 위해서 Hibernate5 모듈을 빈으로 등록하면 프록시 객체 대신 null값이 들어가게 되어서 오류가 발생하지 않는다. 그리고 다음과 같이 Lazy 강제 초기화를 시켜서 데이터를 넣을 수도 있다.

	@GetMapping("/api/v1/simple-orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        for (Order order : all) {
            order.getMember().getName(); // Lazy 강제 초기화
            order.getDelivery().getAddress(); // Lazy 강제 초기화
            // 메서드 실행 전까지는 프록시, 메서드 실행 시 실제 target 으로부터 데이터 가져와서 Lazy 강제 초기화
        }
        return all;
    }

문제점

하지만 앞서 설명했듯 API 조회에서 바로 Entity를 내보내는 것은 여러모로 위험하다.

  1. API 스펙이 Entity에 영향받음
  2. Entity 노출 -> 보안 상 위험
  3. 필요없는 정보도 같이 읽어서 성능 상 문제

그러므로 DTO를 사용하자.

DTO로 변환해서 API로 내보내기

API 스펙 상 필요한 필드들만 DTO로 정의한다. 그리고 리포지토리에서 데이터를 읽을 때 String 타입으로 데이터를 변환해서 읽는다.
읽은 데이터를 DTO로 변환해서 API로 내보낸다.

이 방식은 엔티티로 내보내는 방법에서 안정성을 개선한 방법이다. 하지만 여전히 성능 문제가 발생할 여지가 있다. 왜냐하면, 데이터를 읽을 때 데이터의 갯수에 따라서 성능에 영향을 받기 때문이다.

Order, Member, Address 테이블과 관련된 데이터들을 API 스펙 상 필요로 한다고 가정하자.

  1. Order를 읽는다.
  2. Member를 읽는다.
  3. Address를 읽는다.
  4. 다음 Order에서 2-4번을 반복한다.

그래서 결론적으로 Order 1번 + Member N번 + Address N번 의 조회가 발생하게 된다. 이와 같은 문제가 바로 N+1 문제이다.

이를 해결하기 위해서 페치 조인을 활용할 수 있다.

Fetch Join 활용하기

여태까지는 String으로 데이터를 변환해서 읽는 방식을 사용했다. 이제는 N+1 문제를 해결하기 위해서 fetch join을 사용한다.

fetch join을 하는 새로운 메서드를 리포지토리에 만든다.


    public List<Order> findAllWithMemberDelivery() {
        return em.createQuery("select o from Order o"
                + " join fetch o.member m"
                + " join fetch o.delivery d", Order.class).getResultList();
    }

위와 같이 fetch join을 시도하면, 결론적으로 세 개의 테이블이 fetch join된 하나의 테이블만 조회하게 되어 1번의 조회 쿼리만 발생한다.

기존의 1+2*N번에서 1번으로 성능이 크게 개선되었다.

이제 다음과 같이 조회된 테이블로부터 DTO로 변환하여 리턴하기만 하면 된다.

 	@GetMapping("/api/v3/simple-orders")
    public List<SimpleOrderDto> ordersV3(){
        List<Order> orders = orderRepository.findAllWithMemberDelivery();
        return orders.stream()
                .map(o -> new SimpleOrderDto(o))
                .collect(Collectors.toList());
    }

위와 같이 map 문법으로 생성자로 객체를 넘겨서 DTO로 반환하게 할 수 있다.

fetch는 JPA만 존재하는 문법이다.
DB와 여러번 소통하여 데이터를 읽는 것이 네트워크 비용 상 큰 문제가 되기 때문에 N+1 문제를 해결해야한다.
그리고 fetch는 성능 최적화를 위한 JPA의 문법이라고 할 수 있다.
연관된 엔티티나 컬렉션을 1번의 쿼리로 조회할 수 있다.

리포지토리에서 DTO 직접 조회하기

여태까지는 리포지토리에서 값을 읽을 때 String으로 변화하거나, entity 타입으로 읽었다.
리포지토리에서 DTO로 직접 조회 할 수도 있다. JPQL 문법인 new 키워드로 DTO를 반환할 수가 있다.

 public List<OrderSimpleQueryDto> findOrderDtos() {
        return em.createQuery(
                "select new jpabook.jpashop.repository.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.orderStatus, d.address)"
                        + " from Order o"
                        + " join o.member m"
                        + " join o.delivery d", OrderSimpleQueryDto.class)
                .getResultList();
    }

new 키워드 사용 시 DTO의 패키지명까지 모두 적어주어야 하고, DTO 생성자에 바로 entity를 넘길 수 없다. 바로 o를 넘기게 된다면, o.id(식별자)로 인식하게 된다. 그래서 반드시 파라미터로 엔티티가 아닌 필드들을 정의해주어야 한다.

또한 fetch join을 할 때는 대상에 별칭을 줄 수 없고, Entity 타입으로만 조회가 가능하기 때문에 지금처럼 DTO 타입으로 조회할 때에는 fetch join을 사용할 수 없다.

지금처럼 DTO 직접 조회를 할 때의 장점은 필요한 필드만 골라서 조회하기 때문에 fetch join을 하는 것보다 성능이 더 좋다.

한계

하지만 필드 갯수가 그리 많지 않을 때에는 성능 상에 큰 차이가 없고, 리포지토리가 DTO에 의지하게 되는 형태가 된다. 그래서 재사용성이 떨어지는 문제가 발생한다.

결론

Fetch join을 사용하는 것과 DTO 직접 조회 간에는 Trade-off가 있다. 일반적으로 fetch join을 사용하는 것이 권장되지만, 엔티티 내부에 필드가 너무 많을 때에는 DTO 직접 조회를 고려해보는 것도 좋다.
DTO 직접 조회를 하는 메서드는 리포지토리 안에 정의하는 것보다 패키지와 클래스를 분리하는 것이 보기에도 수정하기에도 편리하다.

쿼리 방식 선택 권장 순서

  1. 우선 엔티티를 DTO로 변환하는 방법을 선택한다.
  2. 필요하면 페치 조인으로 성능을 최적화 한다. 대부분의 성능 이슈가 해결된다.
  3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.
  4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.
profile
티스토리로 블로그 이전합니다. 최신 글들은 suhsein.tistory.com 에서 확인 가능합니다.

0개의 댓글