Post

Spring & JPA 로 개발하는 REST API

이 포스팅에서는 RDBMS를 기반으로 설명하고 있기 때문에, NoSQL를 사용하는 API 개발에 대한 참고는 되지 못할 것이라는 점을 밝혀둔다.

1. REST API - CRUD

오늘날에 API는 컨트롤러 계층에서 데이터를 제공하는 방식으로 개발되어지고 있다. 그런데, 컨트롤러에서 데이터를 제공할 때 유의해야 하는 게 있다. 그건 바로 데이터를 제공할 때 어떻게 데이터를 가져와서 뿌리냐는 점이다.

만약 컨트롤러에서 바로 엔티티에 접근하게 되면, 프레젠테이션 계층에 대한 작업이 도메인 계층에도 영향을 끼치게 된다.
이런 관계성을 띄게 되면 엔티티를 수정하고 싶을 때 이 엔티티를 사용하고 있는 프레젠테이션 계층의 작업들 하나하나를 신경써야되므로 유지보수하기가 굉장히 어려워진다.

엔티티는 데이터베이스 형상 유지 역할에만 집중할 수 있게, 화면과 API에 보여지기 위한 DTO를 사용해 개발하도록 하자.

1-1. REST API를 위한 @ResponseBody

Spring에서 뷰 렌더링이 아닌 REST API를 제공하려고 한다면 @ResponseBody를 이용하면 된다.
간편하게 @RestController 를 사용하자. @Controller와 @ResponseBody를 포함하는 애노테이션이다.

1-2. R( Read )

데이터를 조회하는 API를 설계할 때 중요한 점은 JSON 스펙을 유연하게 만들어야 한다는 것이다.
왜냐하면, 엔티티나 DTO를 그대로 담아서 JSON을 뿌리게 되면 스펙이 굳어져 버리면서 추후에 내보내고 싶은 데이터가 늘어난다고 해도 추가하기 어렵기 때문이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@GetMapping("/members")
public Result membersV2() {
    List<Member> findMembers = memberService.findMembers();
	List<MemberDTO> collect = findMembers.stream()
        .map(m -> new MemberDTO(m.getName()))
        .collect(Collectors.toList());
    
    return new Result(collect);
}

@AllArgsConstructor
@Data
static class Result<T> {
    private T data;
}

@AllArgsConstructor
@Data
static class MemberDTO {
    private String name;
}

위의 예제처럼 Object 타입으로 감싸서 반환해서 유연하게 대처하도록 하자.

엔티티를 노출해서 유지보수하기 어렵게 만들지 말고 DTO를 사용해 노출해야 하는 데이터만 노출하는 것을 권장한다는 걸 다시 한번 강조한다.

2. API 고급 - ToOne

API 성능 튜닝이 필요한 것은 대부분 조회하는 친구들이다. 왜냐하면, 조회 API가 고객들이 가장 많이 사용하게 되는 기능이라서 그만큼 다양한 API를 개발할 필요가 있기 때문이다. 다양한 조회 API를 만들기 위해 관계성을 갖는 테이블을 어떻게 조인해서 성능을 튜닝할지 고민할 부분이 많다.

먼저, 지연 로딩은 무조건 사용한다는 전제를 깔아두자. 즉시 로딩을 사용하게 되면 성능 튜닝이 매우 어려워진다. 내가 의도하지 않았던 쿼리들이 줄기줄기 타고 내려가서 잭과 콩나무가 되버릴 것이다.

API 성능 튜닝을 하는 순서를 점차적으로 설명해보겠다. 튜닝이 필요하다면 아래 순서대로 시도해보자.

2-1. 쿼리 방식 선택 권장 순서

  1. 우선 엔티티를 DTO로 변환하는 방법을 선택한다.
  2. 필요하면 fetch join으로 성능을 최적화한다. 대부분의 성능 이슈가 해결된다.
  3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용하자.
  4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL를 직접 사용한다.

먼저 1번의 방법부터 살펴보자.

2-2. “우선 엔티티를 DTO로 변환하는 방법”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@GetMapping("/orders")
public List<SimpleOrderDto> ordersV2() {
    List<Order> orders = orderRepository.findAll();
    List<OrderDTO> result = orders.stream()
        .map(o -> new OrderDTO(o))
        .collect(toList());
    return result;
}

@Getter
static class OrderDTO {
    
    private Long orderId;
    private String name;
    private LocalDateTime orderDate; //주문시간
    private OrderStatus orderStatus;
    private Address address;
    
    public OrderDTO(Order order) {
        orderId = order.getId();
        name = order.getMember().getName(); // LAZY 초기화
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress(); // LAZY 초기화
    }
}

위의 예제는 주문 엔티티를 DTO로 변환해서 조회하고 있다. 주문 엔티티는 외래 키로 회원 엔티티와 배송 엔티티를 참조하는 엔티티라고 가정하자.

그런 주문 엔티티에서 필요한 데이터를 선정해 DTO를 만든 것이다. 회원 엔티티의 유저네임과 배송 엔티티의 주소 데이터를 지연 로딩하기 때문에 이를 초기화하게 하고 있다.

  • 쿼리가 1 + N + N 번 실행된다.
    • order 조회 1번
    • order -> member 지연 로딩 N 번
    • order -> delivery 지연 로딩 N 번

지연 로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.

2-3. “fetch join으로 성능을 최적화한다.”

1
2
3
4
5
6
7
public List<Order> findAllWithMemberDelivery() {
    return em.createQuery(
        "select o from Order o" +
        " join fetch o.member m" +
        " join fetch o.delivery d", Order.class)
        .getResultList();
}

JPA에서 제공하는 fetch join을 사용해서 쿼리 1번에 모두 조회할 수 있다. 이미 조회되어 영속성 컨텍스트에 올라가므로, 지연 로딩에서 쿼리를 하지 않는다.

fetch join은 조인해오는 테이블의 모든 컬럼을 select절에 모두 집어넣어준다. (개발자의 수고스러움을 덜어준다.)

2-4. “DTO로 직접 조회하는 방법”

1
2
3
4
5
6
7
8
9
10
11
12
13
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final EntityManager em;
public List<OrderSimpleQueryDto> findOrderDtos() {
    return em.createQuery(
        "select new jpabook.jpashop.repository.order.OrderDTO(o.id, m.name, o.orderDate, o.status, d.address)" +
        " from Order o" +
        " join o.member m" +
        " join o.delivery d", OrderSimpleQueryDto.class)
        .getResultList();
    }
}

이 방법은 원하는 데이터만 조회하는 방법이다.
select절에 원하는 필드만 기재하기 위해서는, 조회하고 싶은 필드만 갖는 DTO를 만들어서 그 생성자를 JPQL Query에 사용해야 한다. 이 때, 루트 패키지부터의 path를 모두 기입해야 하는 불편함이 있다. QueryDSL를 사용하면 그냥 클래스 부르듯이 사용할 수 있으니 가능하다면 QueryDSL를 사용하는 것이 더 편하다.

  • 일반적인 SQL를 사용할 때처럼 필요한 값만 선택해서 조회
  • new 명령어로 JPQL의 결과를 DTO로 즉시 변환
  • select절에서 원하는 데이터를 직접 선택하므로 DB → 애플리케이션 네트워크 부하 최적화( 생각보다 그렇게 최적화 되지는 않는다. )
  • 리포지토리 재사용성 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어감

3. API 고급 - ToMany

이어서 엔티티가 갖는 컬렉션을 조회하는 API를 튜닝하는 방법을 알아보자.
쿼리 방식 선택을 하는 순서는 앞서 살펴본 것과 동일하다.

3-1. 엔티티를 DTO로 변환

엔티티에서 ToMany 연관관계를 갖는 필드는 컬렉션이다.

컬렉션은 조회할 때 그 컬렉션이 갖는 컬렉션도 포함해서 모두 DTO로 만들어서 생성자에서 지연로딩을 초기화한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Getter
static class OrderDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate; //주문시간
    private OrderStatus orderStatus;
    private Address address;
    private List<OrderItemDto> orderItems;
    
    public OrderDto(Order order) {
        orderId = order.getId();
        name = order.getMember().getName();
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress();
        orderItems = order.getOrderItems().stream()
            .map(orderItem -> new OrderItemDto(orderItem))
            .collect(toList());
    }
}

@Getter
static class OrderItemDto {
    private String itemName;//상품 명
    private int orderPrice; //주문 가격
    private int count; //주문 수량
    
    public OrderItemDto(OrderItem orderItem) {
        itemName = orderItem.getItem().getName();
        orderPrice = orderItem.getOrderPrice();
        count = orderItem.getCount();
    }
}

컨트롤러 코드는 “2. API 고급 - ToOne”와 다르지 않다.

3-2. 엔티티를 DTO로 변환 - fetch join 최적화

1
2
3
4
5
6
7
8
9
public List<Order> findAllWithItem() {
 return 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();
}
  • JPQL에 “distinct” ?
    • 일대다 조인에서는 레코드 row가 증가한다. 그렇기 때문에 order 엔티티의 조회 수도 증가하게 된다. JPA의 distinct는 SQL query에 distinct를 추가하고 같은 엔티티가 조회될 때 애플리케이션에서 중복을 걸러준다. 그리고 distinct를 쓰기 전과 다르게 distinct를 사용한 필드( 루트라고 부른다. )에 맞춰 컬렉션을 리스트로 묶어준다.
  • 이 방법의 단점
    • 이 방법을 사용할 때 페이징 처리를 걸면 하이버네이트에서 경고 로그를 남기며 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다.( 매우 위험 ) 페이징이 불가능하다. 하지마라.

이번 포스팅은 여기까지 하려고 한다. 이번 포스팅은 김영한님의 “실전! 스프링 부트와 JPA 활용2” 내용을 많이 참고했는데, 더 깊은 내용은 강의에서 확인했으면 하는 바람이다. 여기까지의 내용은 스스로 공부하면서 이해한 내용을 풀어 설명할 수 있는데, 그 뒤의 내용은 개인적인 해석을 첨가하기 어렵기 때문이다. 강의를 들으며 공부하면 내 마음을 이해할지도 모르겠다.

이 포스팅을 읽는 독자들에게 도움이 되기를 바라며 타이핑을 마치겠다.

참고자료

This post is licensed under CC BY 4.0 by the author.