앞서, JPA를 이용해 데이터를 조회해와 클라이언트로 반환하는 API를 작성할 때에는 반환 타입으로 엔티티를 그대로 사용하면 안된다. 엔티티를 바로 반환할 때의 문제점은 아래 더보기를 참고하자.
엔티티를 직접 반환할 때의 단점
1. 엔티티를 직접 노출하게 될 경우 엔티티가 수정되면 API 스펙이 변경되어 버린다. 예를 들어 name 필드를 username으로 변경하게 되면, API를 호출해 Response를 받는 클라이언트측 입장에서는 갑자기 name 데이터를 제대로 가져오지 못하는 경우가 발생한다.
2. 엔티티는 다양한 스펙의 API에 대응할 수 있어야 한다. 하나의 API에 의존하는 엔티티는 다른 API 스펙에 유연하게 대처할 수 없다.
3. 프레젠테이션 계층을 위한 로직이 엔티티에 추가된다. 엔티티는 최대한 순수한 상태로 존재해야 한다.(1, 2번의 문제 때문)
4. 만약 특정 엔티티 타입을 담는 리스트(List)나 배열(Array)을 반환하는 경우 json의 최상위 계층이 Array([])가 되어버린다. 이렇게 되면, 다른 필드를 추가하거나 확장하기 힘들어진다.
지연 로딩(Lazy Loading) vs 즉시 로딩(Eager Loading)
JPA에 연관 데이터를 조회해오는 방식에는 지연(LAZY) 로딩과 즉시(EAGER) 로딩이 있다. 지연 로딩은 처음부터 연관된 엔티티 정보를 조회해오는 것이 아니라 이후에 필요로 할 때(호출될 때) 조회해오는 방식이고, 즉시 로딩은 처음부터 연관된 엔티티 정보를 모두 조회해오는 방식이다.
테이블간의 연관 관계를 표현하기 위해 엔티티에서는 상대 테이블을 필드로 가지게끔 하는 경우가 많은데, 연관 관계에 있는 테이블 엔티티 정보를 담는 필드는 반드시 지연 로딩으로 불러와야 한다. 즉시 로딩으로 불러올 경우, 불필요한 쿼리 실행이 늘어나 성능에 부하를 줄 수 있다.
예시로, 아래와 같이 세 개의 엔티티가 존재한다고 생각해보자. (참고로, 세 엔티티 간에는 ~ToOne 관계만 존재한다.)
@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne
@JoinColumn("member_id") // 필드명_참조 키 이름
private Member member;
@OneToOne(fetch = LAZY)
private Delivery delivery;
// 이외의 컬럼 정보는 생략
}
@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@JsonIgnore
@OneToMany(mappedBy = "member") // Order에 있는 Member 타입 필드명
private List<Order> orders;
// 이외의 컬럼 정보는 생략
}
@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Delivery {
@Id @GeneratedValue
private Long id;
@Embedded
private Address address;
@JsonIgnore
@OneToOne(fetch = LAZY)
private Order order;
// 이외의 컬럼 정보는 생략
}
N + 1 문제
위와 같은 도메인 모델링 상태에서 Order 목록을 조회하면 Order 내의 필드인 member, delivery는 지연 로딩에 의해 실제 객체가 아닌 프록시 객체로 조회된다. 이처럼 지연 로딩을 하면 처음에 루트 엔티티를 조회해올 때 연관 관계에 있는 엔티티들은 조회해오지 않는다.
반대로 EAGER 로딩이 되도록 설정하여 조회를 하도록 했다면 어떨까? Order의 조회 데이터 수가 N이었다는 전제하에 Order 조회 쿼리 1회, Order 개수만큼의 Member 조회 쿼리 N회, Order 개수만큼의 Delivery 조회 쿼리 N회, 총 1 + N + N 회의 쿼리가 호출된다. 이렇게 연관 관계가 설정된 엔티티를 조회할 때 조회된 데이터 갯수(N)만큼 연관관계의 조회 쿼리가 추가로 호출되는 문제를 두고 'N + 1 문제'라고 한다.
Cf. 만약 Order가 2개 조회되었고, 2개의 Order가 모두 같은 Member 를 참조한다면 영속성 컨텍스트에 없던 첫 번째에만 쿼리를 날리고 두번째에는 영속성 컨텍스트에서 가져오기 때문에 Member 조회에서는 2번이 아닌 1번의 쿼리가 발생한다.
EAGER 로딩 방식을 언급하는 이유는, LAZY 로딩을 하더라도 위와 같은 문제가 발생할 수 있는 경우가 존재하기 때문이다. (그래도 모든 연관 관계를 가진 엔티티 필드는 LAZY 로딩을 하자. EAGER 로딩은 성능 최적화를 시도 조차 할 수 없게 만든다.)
기본적으로, 엔티티를 조회해와서 클라이언트에게 데이터를 반환할 때에는 DTO를 따로 두어 DTO에 값을 담아 반환해야 한다. 만약 Order만 조회해올 것이라면 OrderDto라는 클래스(예시)를 만들어 반환할 필드만 반환하면 되는데, json 형태로 데이터를 보낼 때 보통 Order에 대한 Member, Delivery 정보를 함께 반환해야할 수 있다. 이 경우에는 Order 목록을 조회해온 뒤 Order 목록을 반복하여 내부의 Member와 Delivery의 속성을 조회함으로써 LAZY 초기화(JPA는 해당 정보를 영속성 컨텍스트에서 찾아보고, 없으면 DB에 조회 쿼리를 날린다.)를 시켜 Member, Delivery 정보까지 조회해오도록 강제할 수 있다.
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
public List<Order> findOrders() {
return em.createQuery(
"select o from Order o" +
" join o.member m" +
" join o.delivery d", Order.class
).getResultList();
}
}
@RestController
@RequiredArgsConstructor
public class OrderApi {
// 단순한 예제를 설명하기 위해 컨트롤러에서 레포지토리를 참조하게끔 구현했다.
private final OrderRepository orderRepository;
public List<OrderDto> orders() {
List<Order> orders = orderRepository.findOrders();
List<OrderDto> result = orders.stream()
.map(OrderDto::new)
.collect(Collectors.toList());
return result;
}
static class OrderDto {
private Long orderId;
private Long memberName;
private Long address;
public OrderDto(Order order) {
this.orderId = order.getId();
this.memberName = order.getMember().getName();
this.address = order.getDelivery().getAddress();
}
}
}
위의 코드를 보면, 컨트롤러에 작성한 스태틱 이너 클래스 OrderDto를 초기화할 때 Order를 인자로 전달해 Order 내부의 member와 delivery의 필드를 조회함으로써 두 타입의 객체를 조회해오도록 하고 있다. 이런 경우 Order 조회 쿼리 1회 + Order당 member 조회 쿼리 1회(Order가 N개라면 N회) + Order당 delivery 조회 쿼리 1회(Order가 N개라면 N회)가 실행되면서 역시나 N + 1의 문제가 발생한다.
이렇게 `~@ToOne` 관계에서는 N + 1 문제를 해결하는 방법으로 패치 조인(Fetch Join)을 사용하는 방법 또는 애초에 쿼리를 조회해올 때 DTO로 조회해오는 방식이 있다.
패치 조인(Fetch Join)
패치 조인은 실제로 SQL 문법에 존재하는 조인 유형이 아닌, JPA에서 jpql을 작성할 때 지원되는 기능이다.
패치 조인을 이용해 리팩토링하려면 앞서 작성한 예시를 아래와 같이 이렇게 수정할 수 있다.
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
public List<Order> findOrders() {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class
).getResultList();
}
}
`join` 뒤에 `fetch`를 붙임으로써 패치 조인 기능을 사용할 수 있다. 이렇게 수정하게 되면 Order뿐만 아니라, Order 내부의 member, delivery 필드의 값을 모두 한번에 가져오는 쿼리 1회가 실행된다. 즉, 1 + N번의 쿼리 호출이 1번의 쿼리 호출로 줄어든 것이다. 이러한 식으로 성능을 최적화시킬 수 있다.
DTO로 데이터 조회하기
패치 조인을 통해 데이터를 가져오는 방식이 아닌 레포지토리에서 조회해올 때부터 DTO로 조회해오는 경우 아래와 같이 jpql을 작성할 수 있다. jpql 내부의 SELECT절에 `SELECT o FROM Order o`와 같이 작성하는 것이 아닌 조회해올 타입의 DTO 생성자에 특정 필드들을 인자로 전달해준다.
package [.. 생략 ..].repository;
@Getter
@Setter
public class OrderQueryDto {
private Long orderId;
private String userName;
private LocalDateTime orderDate;
private Address address;
public OrderQueryDto(Long orderId, String userName, LocalDateTime orderDate, Address address) {
this.orderId = orderId;
this.userName = userName;
this.orderDate = orderDate;
this.address = address;
}
}
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
public List<OrderQueryDto> findOrderDtos() {
return em.createQuery(
"select new [..생략..].repository.OrderQueryDto(o.id, m.name, o.orderDate, d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderQueryDto.class
).getResultList();
}
}
패치 조인과 DTO 조회 방식의 장단점
패치 조인을 사용하는 방법과 DTO로 조회해오는 방법은 서로 장단점이 존재한다.
패치 조인(Fetch Join) | DTO로 조회 | |
비교 | 결과를 다양한 API에서 원하는 DTO로 바꿔 사용할 수 있다. (=재사용성이 좋다.) | 결과가 특정 API 또는 화면에 국한되어 사용되기 쉽다. (=재사용성이 떨어진다.) |
레포지토리의 코드(jpql)가 간결하다. (=가독성이 좋다.) | 레포지토리 코드(jpql)이 지저분해진다. (=가독성이 떨어진다.) | |
연관 관계에 있는 테이블의 모든 컬럼을 조회해오기 때문에 네트워크 자원을 많이 소모한다. | 애플리케이션 네트워크 용량 최적화에 보다 도움이 된다. (필요한 컬럼만 조회해오기 때문) 하지만, 최근에는 네트워크 성능이 워낙 좋아 별로 차이가 나지 않는다. | |
API 스펙이 레포지토리에 들어오는 것. 즉, 레포지토리가 화면에 의존하는 것이 된다. 레포지토리의 순수성이 깨진다. |
두 방식의 성능은 크게 차이나지 않는다. 때문에 되도록이면 fetch join을 쓰되, 실시간으로 잦은 조회가 일어나야하는 API를 위해서는 성능 최적화를 위해 두번 째 방식을 고려해볼 수도 있다.
또한, 레포지토리는 되도록이면 순수한 엔티티를 조회하는데 사용하는 것이 좋다. 따라서 특정 API에 의존적인 메서드는 별도의 레포지토리로 분리하여 관리하는 것이 좋다.
'Backend > JPA' 카테고리의 다른 글
JPA) Example, ExampleMatcher 사용해 조건에 맞는 값 조회하기 (0) | 2024.07.05 |
---|---|
JPA) 영속성 컨텍스트(Persistence Context) (0) | 2024.05.24 |
Springboot + JPA) 페이지네이션(Pagenation)하기 - Pageable, PageRequest, Page (0) | 2024.04.17 |
JPA(Java Persistence API) - 도커에 올린 DB(H2)와 연동하기 (0) | 2023.11.17 |