좋은 API 를 구현하기 위한 개념들

kangsan·2021년 3월 6일
1

inflearn 김영한님의 강의 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 를 듣고 배운것을 정리한 내용입니다.

DTO 사용의 중요성

요즘은 Frontend 영역의 복잡성이 커지며 주로 백엔드와 분리하는 구조를 갖는다. 따라서 서버는 자연스럽게 API 만 제공하는 형태로 많이 사용한다.

API에서 가장 중요한 것은 스펙이고 API는 정확히 스펙을 따라야 한다. 내가 제공하는 API 가 스펙을 온전히 따르지 못하고 스펙이 차이 나거나 수정이 되었다면 그 API 를 사용하는 사람은 얼마나 짜증이 나겠는가?

API Spec 을 위한 별도의 DTO를 만드는 행위는 Entity 와 제공할 API 사이의 경계를 만들어 조금 더 안정적으로 API 스펙을 지킬 수 있게 한다.

그외에도 Entity 를 직접 제공하는 것에 비해 DTO 사용 시 API에 원하는 정보만 제공하므로 효율적이고 Entity를 직접 노출하지 않아 안전하다.

JSON 형태

API 응답값으로 나가는 JSON 은 {} 또는 JSON Array 인 [] 형태로 나갈 수 있다.
JSON Array 를 바로 보내고 있다면, API 에 추가적인 정보가 담겨야 할 경우 유연성이 떨어진다.
예를들어, 특정 엔티티의 List 를 반환하는 경우 나중에 List 와 함께 그것의 개수나 평균값 등을 함께 보내야 하는 경우 JSON Array 로 보내고 있었다면 사용하는 쪽에서 수정이 커진다.

API의 유연성을 고려하여 제공하는 데이터는 중괄호 내부에 넣어주자.

// Good

{
    "data": [...]
    // 추가적인 정보가 더 올 수 있다.
}

모든 API 가 이런 방식을 공통적으로 따르는 것이 좋다. 그렇게 만들기 위한 아름다운 방법은 Generic 을 사용하는 것이다.

static class ApiResult<T> {
	private int count;
	private T data;
}


@GetMapping("/myapi")
public ApiResult<List<myApiDto>> myApi() { ... }

조회를 최적화하는 방법

LAZY Loading 이 기본이다

EAGER 는 JPQL 최적화가 안되고, 필요없는 데이터도 싸그리 가져오고 N+1 문제가 해결되는 방법도 아니다.

LAZY Loading 사용 시 한 엔티티를 조회 한 후, 그 엔티티와 연관관계에 있는 엔티티를 조회하기 위해 개별 쿼리가 나가는데 이것은 N+1 문제가 생기는 원인이다.
예를들어 모든 Order들의 정보를 뽑고 싶을 때, Order 에 Member 와 Delivery 도 들어있는 경우,
Order 1개당 Member조회 1뻔, Delivery 조회 1번이 날아간다.
즉, SELECT * FROM order 후 각 row 마다 2개씩 쿼리가 더 나가야 하는 것. Order 가 100개라면, 201번의 쿼리가 나간다. 딱봐도 문제가 많다.

아래에서 소개하는 Fetch join으로 최적화를 할 수 있다.

Fetch Join 을 사용하자

LAZY Loading 시 쿼리가 너무 많이 나가는 문제는, 결국 한 엔티티를 온전하게 만들기 위해 관련있는 엔티티들을 개별적으로 조회해서 생기는 것이었다. 개별적으로 가져오던걸 JOIN 을 해서 한방에 가져온다면 효율적일 것이다.

JPQL 에서는 이것을 fetch join 으로 쓰면 된다.

em.createQuery(
	"select o from Order o" +
   	" join fetch o.member m" +
   	" join fetch o.delivery d", Order.class).getResultList();

이렇게 fetch join으로 엔티티 가져온 후 DTO로 변환하는것이 일반적으로 많이 쓰는 방법이다.

DTO 로 바로 조회할 수도 있다.

JPQL에서 바로 DTO 조회를 할 수 있다. 이 방법의 장점은 엔티티를 가져와서 DTO 로 변환하는 과정을 거치지 않아도 된다는 것과, 실제 날린 Query가 DTO 를 구성하는데 필요한 컬럼만 가져오므로 최적화 된 사이즈의 크기로 받을 수 있다는 것이다.

em.createQuery(
	"select new myshop.repository.order.query.OrderDto(o.id, m.name, d.address)" +
   	" from Order o" +
   	" join o.member m" +
   	" join o.delivery d", OrderDto.class).getResultList();

대신 생성자를 JPQL 안에서 채워야 하니 코드가 지저분해지고, 재사용성이 없어진다.
또한 성능개선이 미미하다. 대부분의 성능 이슈는 데이터 조금 더 보낸것보다 조회에서 where 절 인덱스 못타는 것 같은 이유일때가 더 많다.

Collection 조회 최적화

Collection 조회시 fetch join 의 문제점

한 엔티티에서 연관되어 참조할 엔티티가 하나인 ToOne 관계는 fetch join을 통해 성능 최적화가 충분히 된다.
하지만 ToMany 관계에 N+1 문제를 해결하기 위해 fetch join 을 사용하는 경우 중복된 데이터가 나오게 된다.

예를들어 가져올 Order DTO 가 다음과 같이 생긴경우

static class OrderDto {
	private Long orderId,
   	private String name;
   	private Address address;
   	private List<OrderItemDto> orderItems;
}

// JPQL
em.createQuery(
    "select o from Order o" +
    " join fetch o.member m" +
    " join fetch o.delivery d"
    " join fetch o.orderItems oi" +
    " join fetch oi.item i", Order.class).getResultList();

JPQL 의 수행 결과가 Order 가 가진 OrderItem 수 만큼 중복되어 나온다.
Order1 이 OrderItem 3개를 가진 경우 DB Query 수행 결과는
Order1 - OrderItem1
Order1 - OrderItem2
Order1 - OrderItem3
이런식으로 나올것이다.
SQL에서 join을 하는 경우 Many 쪽에 맞게 1 쪽이 뻥튀기 되기 때문이다.
이 쿼리 결과를 Entity로 바꾸고 있으니

Order {
	id: 1
	orderItem: [OrderItem1, OrderItem2, OrderItem3]
}
Order {
	id: 1
	orderItem: [OrderItem1, OrderItem2, OrderItem3]
}
Order {
	id: 1
	orderItem: [OrderItem1, OrderItem2, OrderItem3]
}

이런식으로 중복된 결과가 나오게 되는 것이다.

Select Distinct

이걸 해결하기 위한 방법으로 JPQL 에 distinct 를 넣을 수 있다.

em.createQuery(
    "select distinct o from Order o" +
    " join fetch o.member m" +
    " join fetch o.delivery d"
    " join fetch o.orderItems oi" +
    " join fetch oi.item i", Order.class).getResultList();

이 방식은 SQL 의 DISTINCT 이외에 추가적인 기능을 해준다. SQL DISTINCT 는 완전히 row가 같아야 가능한데 Query 결과는 Order1 에 붙은 OrderItem1,2,3 이 각각 다른 형태이므로 DISTINCT 안될 것이다. 하지만 JPA 는 같은 id 값의 엔티티에 대해서 중복 제거를 해줘서 우리가 원하는 중복 없는 fetch join 결과를 얻을 수 있다.

하지만, DB상에서 페이징이 안된다는 치명적인 단점이 있다. 페이징이 되긴 하는데 다음과 같은 경고창이 나온다.

WARN 85176 --- [nio-8080-exec-1] o.h.h.internal.ast.QueryTranslatorImpl :
HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

JPA에서 중복을 제거 한 기준의 페이징이므로 DB SQL 단에서 할 수 없다. 그 말은 메모리에서 모든 페이징을 해야한다는 것인데 그러기 위해서 모든 데이터를 메모리에 퍼올려서 페이징을 수행한다. 당연히 이 동작은 조금만 데이터가 많아져도 장애가 날 가능성이 크다.

페이징

그렇다고 페이징을 포기 할 것인가? 실무에서 페이징은 필수다.
@BatchSize 또는 application.yml 의 spring.jpa.properties.hibernate.default_batch_size 옵션을 통해 해결할 수 있다. 전자가 로컬 설정이고 후자가 글로벌 설정이다.

해당 설정을 한 이후, 사용하는 주요 코드는 아래와 같다.

//JPQL
// ToOne 관계인 것들만 fetch join 한다.
em.createQuery(
	"select o from Order o" +
	" join fetch o.member m" +
	" join fetch o.delivery d", Order.class)
    	    .setFirstResult(offset)
            .setMaxResults(limit)
            .getResultList();


// Collection 필드를 채우는 과정은 LAZY Load 한다.
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
orders.stream().map(o -> new OrderDto(o)).collect(toList());


// OrderDto 생성자
public OrderDto(Order order) {
    orderId = order.getId();
    name = order.getMember().getName();
    ...
    orderItems = order.getOrderItems().stream()
       .map(OrderItemDto::new).collect(toList());
}

처음 JPQL을 통해 날리는 쿼리는 ToOne 으로 fetch join 이 가능한 애들만 페이징 걸어서 가져온다.
그리고 나머지는 순회를 하면서 채워준다. 컬렉션 필드를 LAZY LOAD 되어서 채울건데 이때 default_batch_size 옵션을 설정 해 두었으므로 최대 설정한 값 만큼의 ROW를 한방에 조회할 수 있다. 실제 날아간 쿼리는 IN 절을 이용해 조회하여 PK 기반으로 동작하므로 빠르게 수행된다.

설정 값은 1000 이하로 적당히 잡아서 주면 된다. 1000 이상은 DB 설정을 보고 정할 수 있다. 순간적인 부하가 커지므로 너무 크게 잡지 않는다.

해당 설정을 통해 중복없이, 한방에, 안전하게 페이징이 되면서, Collection을 포함한 필드도 다 가져오는 최적화를 할 수 있다.

결론

  • 웬만하면 JPQL에서 DTO 바로 찍어오지 말고 Entity 가져와서 DTO 로 바꿔라
  • 기본적으로 default_batch_size 옵션을 주자
  • ToOne 관계만 fetch join 먹이고, Collection 은 순회하며 채운다.

OSIV

Open Session In View 라는 하이버네이트 옵션이다.
같은 말을 JPA에서는 Open EntityManager In View 라고 한다.
관례상 OSIV 라고 한다.

spring.jpa.open-in-view 설정을 통해 한다. 기본값은 true 이다.

session 은 DB 커넥션을 의미한다. open session in view 라는 의미는 DB 커넥션을 @Transactional 이 끝나도 반환하지 않고, 실제 응답이 나갈 때 까지 (view 또는 api 반환) DB 커넥션을 잡고 있는 것이다.

이 기능이 켜져있으면 @Transactional 의 바깥 (일반적으로 서비스에 @Transactional 걸게 되므로 Controller, Filter 등)에서도 DB커넥션이 에서 살아 있게 되어 영속성 컨텍스트도 살아있으므로, 지연로딩을 할 수 있게된다.

당연히 DB 커넥션을 오래잡고있는 만큼 쓰레드 수가 많아지면 커넥션이 말라버릴 수 있다.
그래서 성능을 생각한다면 OSIV 를 끄는 편이다.
OSIV를 false 주면 지연로딩 없도록 @Transactional 내부로 다 밀어넣던지 쿼리용 서비스를 따로 만들어야 한다.

0개의 댓글