다음과 같은 두 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
를 분리하는 것이 좋다.