다음과 같은 두 Entity가 양방향 의존 관계를 가진다.
@Entity
public class Order {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
}
@Entity
public class Member {
...
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
아래의 jqpl을 통해 조회한 Order를, Jackson을 사용해서 @ResponseBody에 담아 전달하니
em.createQuery(
"SELECT o FROM Order o",
Order.class
).getResultList();
무한 루프에 빠진다.
Order Entity는 Member를 필드로 가진다.Member Entity는 List<Order>를 필드로 가진다.즉, 양방향 관계가 무한 루프를 만드는 것이다.
따라서 관계의 한 쪽에는 @JsonIgnore를 사용해서 응답에 포함되지 않도록 한다.
@Entity
public class Member {
...
@JsonIgnore
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
@JsonIgnore를 통해 양방향 관계에서 비롯된 무한 루프를 해결했다.
이제 다시 Order 응답을 보내려고 하니 bytebuddy에서 예외가 발생한다.
bytebuddy: JPA의 Proxy를 담당하는 라이브러리
Order의 member 필드가 fetch = FetchType.lazy로 설정되어 있기 때문에, 단순히 Order만 조회하면 Member에는 프록시 객체가 설정되기 때문이다.
쉽게 말해 불완전한 객체(?)를 가지고 실제 응답을 만들려니 문제가 생기는 것이다.
여러 해결 방법이 있을 것이다.
fetch = FetchType.EAGER 사용하기jackson-datatype-hibernate5라이브러를 사용하기force lazy loading 쿼리를 날려서 실제 데이터를 가져온 다음에 응답을 만듦member Entity의 필드를 조회해서 영속화한다.Entity를 클라이언트에게 노출하는건 좋지 못하다. 그러므로 필요한 데이터만 담은 DTO를 사용하자.Lazy Loading을 사용하자.Fetch Join을 사용하자.// Controller
@RestController
@RequiredArgsConstructor
public class OrderController {
private OrderRepository orderRepository;
@GetMapping("/api/orders")
public List<OrderDto> orders() {
return orderRepository.findAll().stream()
.map(OrderDto::new)
.collect(Collectors.toList());
}
}
// Repository
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private EntityManager em;
public List<Order> findAll() {
return em.createQuery(
"SELECT o FROM Order o JOIN FETCH o.member m",
Order.class
).getResultList();
}
}
다음과 같이 Repository 레벨에서 DTO 객체를 만들 수 있다.
// Repository
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private EntityManager em;
private List<OrderQueryDto> findOrders() {
return em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderQueryDto.class)
.getResultList();
}
}
JQPL에서 new 키워드를 사용해서 객체를 만들 수 있다.
그런데 Repository는 순수하게 Entity만 다뤄야 한다. DTO를 다뤄서는 안 된다. 그러므로 DTO를 다루는 Repository를 별도로 만들자.
결과적으로 Controller(Service)는 두 종류의 OrderRepository를 사용하게 되는 것이다.
참고: Fetch Join
이제는 Collection(@XToMany)을 다뤄보자.
이전 내용을 통해 다음의 과정을 거친다.
Lazy Loading에 의해 Collection의 Entity는 전부 프록시 객체
강제 초기화를 할 경우, Entity 마다 조회 쿼리가 발생한다(N+1 Problem). 그러므로 Fetch Join을 사용해서 성능을 개선한다.
그런데 Collection + Fetch Join는 데이터 중복이 발생할 수 있다. 이는 JPQL Distinct로 해결할 수 있다.
그런데 컬렉션 + Fetch Join은 페이징이 불가능하다.
setFirstResult, setMaxResult를 사용해도 실제 SQL 쿼리에 적용되지 않는다.Hibernate는 모든 데이터 조회해서, 메모리 수준에서 페이징을 지원하나 권장되지 않는다.
DISTINCT를 사용해도 JPA 수준과 SQL 수준에서 조회 결과가 다르기 때문에 페이징을 지원하지 않는 것이다.
Collection + Fetch Join은 페이징이 불가능하므로, 아래의 방법으로 해결하자.
@xToOne관계는 컬렉션을 다루지 않으므로, 예상치 못한 데이터 중복 문제가 발생하지 않는다. 따라서 @xToOne관계만 Fetch Join을 사용해서 불러오자.
Fetch Join으로 불러온 Entity들은 @xToMany가 아니므로 페이징 쿼리를 사용할 수 있다.
Fetch Join으로 불러오지 않은 @xToMany (Collection)은 Lazy Loading으로 불러오자.
Fetch join을 사용하지 않았으므로, N+1 Problem이 발생한다. 성능 최적화를 위해 다음 방법을 사용하자
다음과 같은 조회 API가 있다.
// repository
public List<Order> findAllWithMemberDelivery(int offset, int limit) {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList();
}
// controller
@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page(
@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "limit", defaultValue = "100") int limit)
{
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
List<OrderDto> result = orders.stream()
// 생성자 내부에서 orderItem(프록시 객체) & Item에 접근하는 상황
.map(o -> new OrderDto(o))
.collect(toList());
return result;
}
이런 상황에서 다음과 같이 설정을 하면
spring:
jpa:
properties:
hiberante:
default_batch_fetch_size: 100
# 조회 결과가 500건이면, ceil(500 / 100) = 6번의 쿼리가 나간다.
OrderItem에 대해서 Fetch Join이 아니고 Lazy Loading을 사용했으므로, OrderDto 생성자 내부에서 Order Entity의 List<OrderItem>에 접근하면 조회 쿼리가 나간다.
그런데 배치 사이즈를 설정하면 Collection의 Entity들을 설정한 배치 사이즈 만큼, SQL의 IN을 사용해서 한 번의 쿼리로 조회하는 쿼리가 발생한다.
SELECT ...
WHERE orderItem_id IN (...);
예를 들어 다음과 같은 상황이 있다면
총 2개의 Order에, 각각 2개의 OrderItem이 존재한다.
OrderItem은 Order, Item과 각각 1:1 대응한다.
default_batch_fetch_size 사용 전에는 총 7번의 쿼리가 나간다.
1. Order를 조회하기 위해 1개의 조회 쿼리
2. OrderItem을 조회하기 위해 2개의 조회 쿼리
3. Item을 조회하기 위해 4개의 조회 쿼리가 발생해야 한다.
default_batch_fetch_size 사용 후에는 총 3번의 쿼리가 나간다.
1. Order를 조회하기 위해 1개의 조회 쿼리
2. OrderItem을 조회하기 위해, In을 사용한 1개의 쿼리
3. Item을 조회하기 위해, In을 사용한 1개의 쿼리
default_batch_fetch_size을 사용함으로써 다음의 이점을 얻었다.
Order에 대한 페이징 쿼리도 가능하게 되었다. N+1 Problem을 해결했다.결과적으로 Collection을 포함한 효과적인 페이징 쿼리를 한 것과 같다.
@xToOne에 배치 사이즈 적용하기em.createQuery(
"SELECT o FROM Order o",
Order.class
).getResultList();
@xToOne의 관계에도 default_batch_fetch_size가 적용된다.
1. Order 조회
2. 조회된 Order에 1:1 대응되는 각각의 Member들을 In으로 한 번에 조회
(SELECT * FROM Item WHERE m.order_id IN (...);)
이런 경우에는 In을 통해 Member를 한 번에 조회할 수 있으나, Member조회 쿼리가 발생한다는 단점이 있다. 일반적으로 @xToOne 경우에는 그냥 Fetch Join을 사용한다.
@BatchSize을 사용하면 default_batch_fetch_size보다 세세하게 설정할 수 있다.
@Entity
public class Order {
...
@BatchSize(size = 100)
@OneToMany(mappedBy = "order")
private List<OrderItem> orderItems = new ArrayList<>();
}
@xToOne의 경우에는 클레스 레벨에 사용해야 한다.
@BatchSize(size = 200)
@Entity
public class Item {...}
@Repository
public class OrderQueryRepository {
public List<OrderQueryDto> findOrderQueryDtos() {
// 1. `findOrders`를 통해 `OrderQueryDto`를 바로 만든다.
// 2. 그런데 JPA 스팩상, `Collection`은 바로 할당할 수 없다.
List<OrderQueryDto> result = findOrders();
// 3. 그러므로, Collection이 할당 안 된 상태로 `OrderQueryDto`를 만든 다음,
// Collection만 따로 조회해서 코드로 값을 할당한다.
// 결국 N+1 Problem 발생
result.forEach(o -> {
List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
o.setOrderItems(orderItems);
});
return result;
}
private List<OrderQueryDto> findOrders() {
return em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderQueryDto.class)
.getResultList();
}
private List<OrderItemQueryDto> findOrderItems(Long orderId) {
return em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
" from OrderItem oi" +
" join oi.item i" +
" where oi.order.id = : orderId", OrderItemQueryDto.class)
.setParameter("orderId", orderId)
.getResultList();
}
}
OrderQueryDto를 접근 하는 과정에서 @xToMany관계인 OrderItemQueryDto는 제외 함N(OrderItemQueryDto 조회) + 1(OrderQueryDto 조회) 문제가 발생했다.
명시적으로 IN을 사용해서 개선할 수 있다.
@Repository
public class OrderQueryRepository {
public List<OrderQueryDto> findOrderQueryDtos_opt() {
// 1. Collector를 제외해서, Fetch Join으로 불러오기
List<OrderQueryDto> result = findOrders();
// 2. 위 결과에서 Id만 뽑아오기
List<Long> ids = result.stream()
.map(o -> o.getId())
.collect(Collectors.toList());
// 3. `IN`을 사용해서 한 번에 불러오기 (N+1 Problem 해결)
List<OrderItemQueryDto> orderItems = em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count) " +
"from OrderItem oi " +
"join oi.item i " +
" where oi.order.id IN :ids",
OrderItemQueryDto.class
).setParameter("ids", ids)
.getResultList();
// 4. OrderId : OrderItem 형태로 만들기
Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems
.stream()
.collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
// 5. Order에 OrderItem 넣어주기
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
}
IN을 통해 N+1 Problem을 해결했다.
N + 1을1 + 1로 개선했는데, 이를 1로 개선할 수 있다.
1. Order 불러오기
2. OrderItem + Item 불러오기
DTO를 사용해서, 한 번에 전부 조회하는 한 방 쿼리를 만들 수 있다.
1. Order + OrderItem + Item 불러오기
// DTO
@Data
@EqualsAndHashCode(of = "orderId")
@AllArgsConstructor
public class OrderFlatDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private String itemName;
private int orderPrice;
private int count;
}
Collection 고려 없이, 그냥 전부 Join으로 묶어서 쿼리를 날리면
문제가 발생함을 알고 있다.
em.createQuery(
"SELECT NEW 경로명.OrderFlatDto(o.id, m.name, o.orderDate, o.status,. d.address, i.name, )" +
"FROM Order o " +
"JOIN o.member m " +
"JOIN o.delivery d " +
"JOIN o.orderItems oi " +
"JOIN oi.item i",
OrderFlatDto.class
).getResultList();
아래 처럼, 코드 레벨에서 중복을 제거할 수 있긴 하다.
@GetMapping("/api/v6/orders")
public List<OrderQueryDto> ordersV6() {
// 중복이 포함된 결과
List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();
// OrderFlatDto의 "orderId" 기준으로 중복을 제거한 뒤,
// OrderFlatDto -> OrderQueryDto로 만드는 코드
return flats.stream()
.collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(), o.getName(), o.getOrderDate(), o.getOrderStatus(), o.getAddress()),
mapping(o -> new OrderItemQueryDto(o.getOrderId(), o.getItemName(), o.getOrderPrice(), o.getCount()), toList())
)).entrySet().stream()
.map(e -> new OrderQueryDto(e.getKey().getOrderId(), e.getKey().getName(), e.getKey().getOrderDate(), e.getKey().getOrderStatus(), e.getKey().getAddress(), e.getValue()))
.collect(toList());
}
장점
단점
Session(Hibernate) = Entity Manager(JPA)
JPA는 언제 DB Connection을 가져오고 반납할까?
Persistence Context(이하 PC)가 정상적으로 작동하려면 DB Connection을 가지고 있어야 한다. 그래야 Lazy Loading, Dirty Checking 등이 가능할 것이다.
PC와 DB Connection은 밀접히 연관되어 있는데, 기본적으로 PC는 DB Transaction이 시작할 때 Connection을 가져온다.
그러면 반납은 언제 이뤄질까? 이는 Open Session In View에 따라 다르다.
spirng:
jpa:
open-in-view: false # default true
OSIV가 켜져있으면 (기본값 true), Transaction이 종료되어도 커넥션을 반환하지 않는다. 다른 곳(계층)에서 쿼리가 나갈 수 있기 때문이다. (Lazy loading 등)
대신 API응답을 완료하면 (View Render가 될 때 까지) 커넥션을 반환한다,
그런데 응답이 나갈때 까지 커넥션을 붙잡고 있으면, 커넥션 풀에 커넥션이 말라버릴 수 있다는 단점이 있다.
OSIV이 꺼져있으면 Transaction에 맞춰 PC가 날라가고 커넥션도 반환한다.
이를 통해 커넥션을 빠르게 반환할 수 있다는 장점이 있다. 그렇지만 트랜잭션 밖에서 지연 로딩을 사용하지 못한다는 단점이 있다.
OSIV를 끈 상태에서, 다른 계층 (트랜잭션 밖의 범위)에서 프록시 객체를 초기화 하려먼 org.hibernate.LazyInitializeationException이 발생한다. PC를 통해 프록시 초기화를 하려고 했는데, PC가 없기 때문이다. (준영속 상태)
그렇다면 OSIV를 끈 상태도 어떻게 서비스를 만들까? 여러 방법이 잇다.
프록시 초기화 작업을 별도로 묶어 놓는 방식으로 설계할 수 있다.
// 데이터 접근 서비스 계층
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true);
public class OrderQueryService {
// Query
public List<OrderDto> orders() {
List<Order> orders = orderRepository.findAllWithItem();
List<OrderDto> result = orders.stream()
.map(OrderDto::from)
.collect(toList());
return result;
}
}
// 비즈니스 로직 수행 서비스 계층
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true);
public class OrderSerivce {
private OrderQueryService orderQueryService;
// Command
public List<OrderDto> orders() {
return orderQueryService.orders();
}
}
OrderService
OrderService: 핵심 비즈니스 로직
OrderQueryService: API에 맞춘 서비스 (주로 읽기 전용)
이처럼 Command와 Query를 분리하는 것이 좋다.