먼저 Member 엔티티는 Board 엔티티를 일대다 관계(OneToMany)로 매핑,
Board엔티티는 Member엔티티를 다대일 관계(ManyToOne)로 매핑하여 양방향 매핑된 상태이다.
이때, 엔티티를 직접 조회하면 모든 결과를 다 가져오면서 양방향관계에 걸려있는 필드때문에 순환참조가 발생한다!
findAll로 엔티티를 바로 조회하여 반환했고, 그 결과 순환 참조로 인해 StackOverFlow가 발생한다
매핑된 엔티티를 계속 조회하면서 들어가는것을 로그로 확인할 수 있음
@JsonIgnore 로 양방향관계가 결려있는 필드를 끊어준다.
이를 통해 JSON 데이터에 해당 프로퍼티는 null로 들어가게 되면서 아예 포함시키지 않게 된다.
하지만, Type definition 에러가 발생한다.
지연로딩이기 때문에, 해당 객체와 관련된 값을 사용하기 전까지는 SQL 쿼리를 보내지않고, 프록시 객체를 생성해서 가져와 들고만 있는다. ByteBuddyInterceptor()라는 프록시 객체가 들어가 있는데, jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 생성하는 방법을 모르기때문에 이러한 에러가 발생한다.
이를 해결하기 위해서는 Hibernate5Module 을 스프링 빈으로 등록하여 해결할 수 있다.
build.gradle 라이브러리 등록
// Hibernate5Module 등록
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'
Entity 자체를 response로 리턴하게 되면서 순환참조가 발생한다.
따라서, Entity 자체를 return 하지 않고, DTO 객체를 만들어 필요한 데이터만 DTO에 담아 리턴하면 순환참조 문제를 방지할 수 있다.
꼭 양방향 매핑이 필요한지 다시 생각해보자.
양쪽에서 접근할 필요가 없다면, 단방향 매핑으로 다시 설계하는 것도 방법이다.
API응답으로 엔티티를 노출하면 필요없는 데이터도 모두 가져오기 때문에 성능상 좋지않고,
추후 엔티티에 수정소요가 있으면 API 스펙이 모두 바뀌기때문에 유지보수가 매우 복잡해진다.
따라서 Hibernate5Module 를 사용하기 보다는 DTO로 변환해서 반환하는 것이 더 좋은 방법이다.
* 지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EARGR)으로 설정하면 안된다!
즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다.
즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워 지고, 쿼리가 얼마나 나가는지 예측하기 어렵다
항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용하자
(Order 엔티티 안에 Member, Delivery 엔티티가 양방향 매핑되어있다.)
엔티티를 DTO로 변환하는 일반적인 방법이다.
하지만! ORDER, MEMBER, DELIVERY 총 3개의 테이블을 조회하는데, LAZY로딩으로인한 DB 쿼리가 많이 출력된다.
ORDER 조회 → SQL 1번 실행 → 결과 주문수가 2개일때,
ORDER→ MEMBER 조회
ORDER → DELIVERY 조회
ORDER → MEMBER 조회 쿼리가 N번만큼 계속 실행된다 (여기서는 N이 2이므로 쿼리가 한번더 실행)
ORDER → DELIVERY 조회 쿼리가 N번만큼 계속 실행된다 (여기서는 N이 2이므로 쿼리가 한번더 실행)
결과 : 쿼리가 총 1 + N + N번 실행된다. (단순 엔티티조회와 쿼리수 결과는 같다.)
order 조회 1번 (order 조회 결과 수가 N, 여기서는 주문조회결과가 2건이라 2가 된다)
order -> member 지연 로딩 조회 N 번
order -> delivery 지연 로딩 조회 N 번
order 조회 결과수만큼 member와 delivery를 각각 최대 N번씩 조회하는 쿼리가 나가고 성능이 저하된다.
* 지연로딩은 먼저 영속성 컨텍스트에서 조회하고 없으면, DB에 쿼리로 조회하기때문에
이미 조회된 경우 쿼리를 생략한다. → member가 둘다 같은 member라면 다음 member조회 쿼리는 생략한다
엔티티를 페치 조인(fetch join)을 사용해서 쿼리 1번에 조회
페치 조인으로 order -> member , order -> delivery 는 이미 조회 된 상태 이므로 지연로딩 X
1번의 쿼리로 order,member,delivery를 join하여 select절에 넣어서 모두 가져온다.
LAZY를 무시하고 프록시가아닌 진짜 객체의 값을 채워서 가져온다.
@Query("select o from Order o join fetch o.member m join fetch o.delivery d")
List<Order> findOrderFetchJoin();
장점 : 다른 API에서도 사용할 수 있어 재사용성이 좋고, 코드가 간결하다
단점 : 필요없는 값들을 다 가져오게된다.
JPA는 기본적으로 엔티티나 Value object만 반환할 수 있다. DTO를 반환하려면 new 명령어를 사용해서 반환해줘야한다.
@Query("select new jpabook.jpashop.repository.order.simplequery.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address) from Order o join o.member m join o.delivery d")
List<OrderSimpleQueryDto> findOrderDtos();
이때, DTO의 생성자에 파라미터로 Entity를 넣으면 JPA는 이 엔티티를 식별자로 넣어버리기때문에, DTO의 파라미터를 각각 넣어줘야한다.
select 절에서 원하는 데이터만 select하는 쿼리를 확인할 수 있다.
장점 : SELECT 절에서 원하는 데이터를 직접 선택하므로 DB 애플리케이션 네트웍 용량 최적화
단점 : 리포지토리 재사용성 떨어짐, API 스펙에 맞춰진 코드가 리포지토리에 들어가는 단점
물리적으로는 계층이 나눠져있지만, 논리적으로는 계층이 깨져있는 상태이다.
API 스펙이 바뀌면 리포지토리의 객체를 수정해줘야한다.
엔티티를 가져오는데 fetch join으로 원하는 , 외부의 모습을 건드리지않고 내부의 원하는 것만 fetch join으로 성능 튜닝한다. 코드가 간결하고, 원하는 Dto로 변환해서 재사용할 수 있다.
하지만, 불필요한 데이터를 함께 가져와서 성능 저하 원인이 될 수 도있다.
SQL 쿼리를 짜듯이 JPA로 딱 맞는 데이터만 가져와서 성능이 최적화된다.
하지만, API 스펙에 맞게 가져와서 재사용성이 안된다. 코드가 지저분해진다.
결론적으로
V3과 V4의 차이는 성능차이가 미미하다.. 보통 join을 하는 과정에서 성능차이가 나기때문에 select쪽에서 데이터 몇개를 더 가져오는데 성능차이가 많이 나는지는 의문이다. 데이터가 많거나 API 트래픽이 많으면 고민해볼 수 있다.
* DTO로 조회하는것은 API 스펙이 들어와 있는것과 같기때문에, 해당 API의 성능 쿼리용으로 별도로 만들어서 관리하는 것이 좋다. Repository는 가급적 순수한 엔티티를 조회하는데 사용해서 재사용이 용이하게 관리해주고,
복잡한 join 쿼리로 DTO를 반환하는 쿼리는 따로 관리해서 유지보수성을 높여주자!
엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두가지 방법은 각각 장단점이 있다.
둘중 상황에 따라서 더 나은 방법을 선택하면 된다.
엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다.
따라서 권장하는 방법은 다음과 같다.
쿼리 방식 선택 권장 순서