jpa, spring api의 entity 조회 최적화 1 - xToOne 연관관계와 엔티티 조회
들어가며
- 스프링을 기반으로 api 프로젝트를 생성한다. jpa로 데이터를 통제한다.
- api의 경우 json 형태로 데이터를 응답한다.
- rest api 에서 스펙을 유지한 채, 효과적으로 엔티티 객체를 조작하는 방식을 정리한다.
- xToOne과 xToMany를 분리하여 정리한다. 영속성 컨텍스트에서 엔티티를 리턴하여 dto로 변환하는 방식과 처음부터 dto 자체를 출력하는 방식으로 분리하여 정리한다.
엔티티가 노출된 api
- api를 가장 쉽게 만드는 방법은 데이타베이스에서 출력한 데이터를 바로 api의 응답값으로 설정하는 방법이다.
- 이러한 방법은 많은 문제를 내제한다.
- 아래의 주석은 요구사항이며, 그에 따른 코드는 아래와 같다.
코드
- 컨트롤러
/*
*
* xToOne 관계에서의 성능 최적화
* Order 를 호출. 회원과 주문에 대한 데이터가 필요하다.
* Order -> Member (ManyToOne)
* Order -> Delivery (OneToOne)
*
*/
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1(){
final List<Order> all = orderRepository.findAllByString(new OrderSearch());
return all;
}
}
- 요구사항은 주문(order)를 출력하며, 필요로 한 데이터는 주문을 한 회원(member)와 발송 정보(delivery)이다.
- 참고로 findAllByString은 매우 단순한 조회 쿼리이며 다음과 같다.
select o from Order o join o.member m
- 위의 쿼리는 아래와 같은 문제를 야기한다.
1. 루프의 발생
- Order의 Member 필드
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "member_id")
private Member member;
- Member의 Order 필드
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
- 해당 메서드에 대한 api 호출 결과
[
{
"id": 4,
"member": {
"id": 1,
"name": "userA",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orders": [
{
"id": 4,
"member": {
"id": 1,
"name": "userA",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orders": [
{
"id": 4,
"member": {
"id": 1,
"name": "userA",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orders": [
{
"id": 4,
"member": {
"id": 1,
"name": "userA",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orders": [
{
"id": 4,
"member": {
"id": 1,
"name": "userA",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"orders": [
{
// 후략
- 위의 결과를 보면 Member 가 Orders를 호출하고 Orders 가 Member를 호출한다. 무한 루프에 빠지게 된다. JPA의 ToString, Json 바인딩의 문제가 여기서 발생한다.
- 서로를 호출할 때는 한 쪽은 Json으로의 변환을 하지 못하게 막아야 한다.
2. 프록시의 문제
-
무한루프를 해소하기 위하여 @JsonIgnore 어너테이션을 사용하여 Member 객체의 order 필드를 막았다.
-
Order의 Member 필드
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "member_id")
private Member member;
- Member의 Order 필드
@JsonIgnore
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
- 해당 메서드에 대한 api 호출 결과
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.http.converter.HttpMessageConversionException: Type definition error: [simple type, class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor]; nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: java.util.ArrayList[0]->jpabook.jpashop.domain.Order["member"]->jpabook.jpashop.domain.Member$HibernateProxy$EPLQjC8q["hibernateLazyInitializer"])] with root cause
- ByteBuddyInterceptor은 지연로딩으로 인해 생성되는 프록시 객체이다. 페치 전략은 Lazy로써 프록시 객체를 리턴한다. 젝슨 라이브러리 입장에서는 프록시를 json으로 바인딩할 수 없으며, 예외를 던진다.
- 이를 해소하기 위하여 Hibernate5Modul을 사용한다.
3. Hibernate5Modul 의 활용
implementation group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-hibernate5'
@Bean
Hibernate5Module hibernate5Module(){
final Hibernate5Module hibernate5Module = new Hibernate5Module();
// hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
return hibernate5Module;
}
[
{
"id": 4,
"member": null,
"orderDate": "2022-02-13T15:04:53.532551",
"status": "ORDER",
"totalPrice": 50000
},
{
"id": 11,
"member": null,
"orderDate": "2022-02-13T15:04:53.660548",
"status": "ORDER",
"totalPrice": 220000
}
]
- 정상 동작한다. 프록시 객체는 null로 반환한다.
- null을 허용하지 않고 모든 필드를 채우고 싶을 수 있다. 이 경우 다음의 코드를 사용한다. 지연로딩을 사용하는 모든 필드에 대하여 초기화한다.
hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
4. 필요한 엔티티에 대한 초기화
FORCE_LAZY_LOADING
은 객체 그래프의 모든 엔티티를 초기화한다. FORCE_LAZY_LOADING을 사용하지 않고 hibernate5Module을 빈으로 등록한 상태에서 원하는 객체만 초기화하고 싶은 경우, 반복문을 통한 초기화를 사용한다.- 그 코드는 아래와 같다.
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1(){
final List<Order> all = orderRepository.findAllByString(new OrderSearch());
for (Order order : all) {
order.getMember().getName();
order.getDelivery().getStatus();
}
return all;
}
[
{
"id": 4,
"member": {
"id": 1,
"name": "userA",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
}
},
"delivery": {
"id": 5,
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"status": null
},
"orderDate": "2022-02-13T15:09:26.246786",
"status": "ORDER",
"totalPrice": 50000
},
{
"id": 11,
"member": {
"id": 8,
"name": "userB",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
}
},
"delivery": {
"id": 12,
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
},
"status": null
},
"orderDate": "2022-02-13T15:09:26.316785",
"status": "ORDER",
"totalPrice": 220000
}
]
- 원하는 엔티티에 대하여 초기화를 하였다.
- 하지만 엔티티 내부에서 필요한 데이터와 노출해야할 데이터가 존재한다. 하지만 이 방식은 모든 엔티티의 필드를 노출하는 문제를 가지고 있다.
- 세세한 조작을 위하여 엔티티의 필드마다 @JsonIgnore를 할 수 없다. 엔티티 클래스가 매우 지저분해진다.
- 더 나아가 특정 API에서는 @JsonIgnore 했던 필드를 필요로 할 수 있다.
- 엔티티를 직접 노출하는 방식을 선택하면 안된다는 결론에 도달할 수밖에 없다.
처음부터 엔티티를 노출하지 말자.
- 엔티티를 노출하면 매우 많은 문제가 발생한다.
- 엔티티를 노출할 경우 프레젠테이션에 대한 메타데이터가 엔티티 클래스에 쌓이게 된다. 엔티티 클래스가 어렵고 복잡해 진다.
- API의 스펙이 엔티티가 완전하게 의존하게 된다.
- 엔티티를 수정할 때, api 스펙을 유지한 채 수정해야 한다.
- 엔티티 이외의 데이터를 삽입하는 것이 불가능하다. 요청시간, 응답 내용 등등의 내용을 추가할 수 없다. 반대로 노출을 제한하고 싶은 데이터에 대한 로직이 복잡해진다. 유연한 API 스펙을 구현할 수 없다.
- 성능 최적화의 방식에서 한계가 있다.
- 그러므로 엔티티를 API로 절대로 노출시키지 말자. 노출로 인한 단점이 너무 많다.
DTO 리턴 + 지연로딩으로 인한 n+1의 문제
- 컨트롤러를 아래와 같이 변경한다.
@GetMapping("/api/v2/simple-orders")
public Result<SimpleOrderDto> ordersV2(){
final List<Order> orders = orderRepository.findAllByString(new OrderSearch());
final List<SimpleOrderDto> collect = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return new Result(collect, "good!");
}
@Data
@AllArgsConstructor
static class Result<T> {
T data;
private String message;
}
@Data // DTO는 롬복을 자유롭게 써도 큰 문제가 없다.
static class SimpleOrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
// DTO는 중요하지 않기 때문에, 의존관계가 어떻든 크게 문제는 없다. 그러니까 Order나 기타 어떤 인자를 가지던 큰 문제가 없다.
public SimpleOrderDto(Order order) {
orderId = order.getId();
final Member member = order.getMember();
name = member.getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
}
}
- 엔티티 대신 DTO로 리턴한다.
- 다만, Lazy로딩으로 인한 n+1 문제가 발생한다.
- order를 한 번 호출하고, stream.map()에서 member와 delivery를 초기화 한다. 2(member, delivery)*2(레코드 각 각 두 개)가 발생.
2022-02-13 15:35:57.532 DEBUG 7180 --- [nio-8080-exec-2] org.hibernate.SQL :
select
order0_.order_id as order_id1_6_,
order0_.delivery_id as delivery4_6_,
order0_.member_id as member_i5_6_,
order0_.order_date as order_da2_6_,
order0_.status as status3_6_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id limit ?
2022-02-13 15:35:57.553 DEBUG 7180 --- [nio-8080-exec-2] org.hibernate.SQL :
select
member0_.member_id as member_i1_4_0_,
member0_.city as city2_4_0_,
member0_.street as street3_4_0_,
member0_.zipcode as zipcode4_4_0_,
member0_.name as name5_4_0_
from
member member0_
where
member0_.member_id=?
2022-02-13 15:35:57.558 DEBUG 7180 --- [nio-8080-exec-2] org.hibernate.SQL :
select
delivery0_.delivery_id as delivery1_2_0_,
delivery0_.city as city2_2_0_,
delivery0_.street as street3_2_0_,
delivery0_.zipcode as zipcode4_2_0_,
delivery0_.status as status5_2_0_
from
delivery delivery0_
where
delivery0_.delivery_id=?
2022-02-13 15:35:57.559 DEBUG 7180 --- [nio-8080-exec-2] org.hibernate.SQL :
select
member0_.member_id as member_i1_4_0_,
member0_.city as city2_4_0_,
member0_.street as street3_4_0_,
member0_.zipcode as zipcode4_4_0_,
member0_.name as name5_4_0_
from
member member0_
where
member0_.member_id=?
2022-02-13 15:35:57.560 DEBUG 7180 --- [nio-8080-exec-2] org.hibernate.SQL :
select
delivery0_.delivery_id as delivery1_2_0_,
delivery0_.city as city2_2_0_,
delivery0_.street as street3_2_0_,
delivery0_.zipcode as zipcode4_2_0_,
delivery0_.status as status5_2_0_
from
delivery delivery0_
where
delivery0_.delivery_id=?
fetch join 의 활용
- 위의 문제를 Eager로 해결하려는 순간 더 큰 문제가 발생한다.
-
fetch join으로 해소한다.
- 컨트롤러
@GetMapping("/api/v3/simple-orders")
public Result<SimpleOrderDto> ordersV3(){
final List<Order> orders = orderRepository.findAllWithMemberDelivery();
final List<SimpleOrderDto> collect = orders.stream()
.map(SimpleOrderDto::new)
.collect(Collectors.toList());
return new Result(collect, "good!");
}
- 리포지토리
public List<Order> findAllWithMemberDelivery() {
final String query = "" +
"select o " +
"from Order o " +
"join fetch o.member m " +
"join fetch o.delivery d ";
return em.createQuery(query, Order.class).getResultList();
}
- fetch 조인으로 인하여 쿼리 한 번으로 끝난다. 지연로딩 자체가 발생하지 않는다.
2022-02-13 15:53:02.246 DEBUG 18616 --- [nio-8080-exec-3] org.hibernate.SQL :
select
order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.delivery_id as delivery1_2_2_,
order0_.delivery_id as delivery4_6_0_,
order0_.member_id as member_i5_6_0_,
order0_.order_date as order_da2_6_0_,
order0_.status as status3_6_0_,
member1_.city as city2_4_1_,
member1_.street as street3_4_1_,
member1_.zipcode as zipcode4_4_1_,
member1_.name as name5_4_1_,
delivery2_.city as city2_2_2_,
delivery2_.street as street3_2_2_,
delivery2_.zipcode as zipcode4_2_2_,
delivery2_.status as status5_2_2_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id
- jpa 에서 엔티티를 출력할 때, 지연로딩 + fetch 로 거의 대부분의 문제가 해결된다.
- 다음은 엔티티가 아닌 dto로 바로 출력하는 방식이다.
entity가 아닌 DTO로 데이터 출력
- 기존의 방식은 엔티티 객체를 출력했다. 그리고 엔티티를 DTO로 변환하고, DTO를 json으로 변환하는 방식을 채택했다.
- DTO로 바로 반환하는 방식은 아래의 코드와 같다.
// DTO를 호출할 때 fetch를 사용하지 않음. fetch는 엔티티만 호출할 때 사용한다.
public List<OrderSimpleQueryDto> findOrderDtos() {
final String query = "" +
" select new jpabook.jpashop.repository.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address) " +
" from Order o " +
" join o.member m " +
" join o.delivery d ";
return em.createQuery(query, OrderSimpleQueryDto.class)
.getResultList();
}
2022-02-13 16:14:31.064 DEBUG 17880 --- [nio-8080-exec-2] org.hibernate.SQL :
select
order0_.order_id as col_0_0_,
member1_.name as col_1_0_,
order0_.order_date as col_2_0_,
order0_.status as col_3_0_,
delivery2_.city as col_4_0_,
delivery2_.street as col_4_1_,
delivery2_.zipcode as col_4_2_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id
DTO vs ENTITY
- dto와 entity로 출력하는 두 개의 방식은 장단이 있다.
- dto로 출력할 경우 DB를 통해 필요로한 데이터만 가져온다. 쿼리로 요청하는 select 칼럼 자체의 갯수가 줄어든다. DB와의 통신에서 최적화를 만든다.
- 다만, dto로 반환할 경우 사실상 재사용성이 없다. 엔티티가 아니므로 영속성 컨텍스트의 기능을 활용할 수 없다.
- jpql 쿼리가 다소 지저분하다.
- 리포지토리에 프레젠테이션 계층의 코드가 들어가는 단점이 있다. 더 정확하게는 DTO 자체가 프레젠테이션 계층에 의존하는 경향이 있다. 이 경우 커맨드와 쿼리를 분리하여 QueryRepository에 구현하는 방식을 채택할 수 있다. 이 경우 QueryRepository는 API 스펙을 위한 쿼리로 분리, 사용된다. 이와 대비하여 엔티티를 위한 리포지토리는 최대한 순수한 상태를 유지한다. 해당 코드는 아래와 같다.
QueryRepository : 커맨드와 쿼리의 분리
@Repository
@RequiredArgsConstructor
public class QueryRepository {
private final EntityManager em;
public List<OrderSimpleQueryDto> findOrderDtos() {
final String query = "" +
" select new jpabook.jpashop.repository.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address) " +
" from Order o " +
" join o.member m " +
" join o.delivery d ";
return em.createQuery(query, OrderSimpleQueryDto.class)
.getResultList();
}
}
정리
- API에 엔티티를 노출하지 않는다.
- fetch join을 활용하여 지연로딩으로 인한 n+1문제를 해소한다
- fetch join(v3) 혹은 dto(v4), 둘 중 하나를 선택한다.
- fetch join을 우선한다.
- 대체로 select 절에서 많은 데이터를 호출한다고 하여 성능을 많이 소모하지 않는다. 엔티티를 최대한 활용한다.
- 성능상 한계에 도달하면, 최적화의 방법 중 하나로 dto를 고려한다.
- 도저히 해소가 불가능하면, 네이티브 SQL를 사용하거나, jdbc를 직접 조작한다.