관리 메뉴

나구리의 개발공부기록

API 개발 고급 - 컬렉션 조회 최적화, 주문 조회 V4 - JPA에서 DTO 직접 조회, V5 - V4의 컬렉션 조회 최적화, V6 - 플랫 데이터 최적화, 컬렉션 최적화 정리, API 개발 고급 - 실무 필수 최적화, OSIV와 성능 최적화 본문

인프런 - 스프링부트와 JPA실무 로드맵/실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화

API 개발 고급 - 컬렉션 조회 최적화, 주문 조회 V4 - JPA에서 DTO 직접 조회, V5 - V4의 컬렉션 조회 최적화, V6 - 플랫 데이터 최적화, 컬렉션 최적화 정리, API 개발 고급 - 실무 필수 최적화, OSIV와 성능 최적화

소소한나구리 2024. 10. 25. 17:12

출처 : 인프런 - 실전! 스프링부트와 JPA활용2 - API개발과 성능 최적화 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


1. 주문 조회 V4 - JPA에서 DTO 직접 조회

1) JPA에서 DTO 직접 조회하는 컨트롤러

(1) 패키지 구조 추가

  • 주문 조회 V4 버전에서와 동일하게 별도의 API 스펙을 가진 DTO로 조회하는 코드들은 한곳에 모아 두는 것이 좋기 때문에 ~.repository.order에 query 패키지를 추가하여 관련된 클래스들을 모아두도록 설계
  • Entity(비즈니스 핵심)와 화면이나 API의 스펙을 가진 관심사를 분리하도록 설계하는 것이 좋은 설계임을 잊지 말자

(2) ordersV4() 추가

  • OrderQueryDto라는 별도의 요구사항 API스펙이 담긴 DTO클래스를 결과로 반환하는 컨트롤러 생성
  • 별도로 관심사를 분리한 OrderQueryRepository를 설정
package jpabook.jpashop.api;

@RestController
@RequiredArgsConstructor
public class OrderApiController {
    // ... 기존 코드 생략
    private final OrderQueryRepository orderQueryRepository;

    
    @GetMapping("/api/v4/orders")
    public List<OrderQueryDto> ordersV4() {
        return orderQueryRepository.findOrderQueryDtos();
    }
}

 

(3) OrderQueryRepository 추가

  • ~.order.query 패키지에 v4컨트롤러의 반환값을 조회하기위한 Repository클래스와 메서드를 정의
  • 페치 조인을 사용하지 않고 일반 조인과 new연산자로 직접 조회할 필드를 지정하여 JPQL을 작성 즉, SQL처럼 직접 조회 대상과 조건을 지정하는 것처럼 JPQL을 작성하는 것
  • new 연산자를 사용하면 컬렉션의 데이터를 조회할 방법이 없기때문에 ToOne관계의 엔터티를 조회하는 부분과, 컬렉션을 조회하는 부분의 쿼리를 별도로 분리
  • findOrders()에서는 Order와 연관된 Member, Delivery에서 지정된 값을 OrderQueryDto타입으로 반환
  • findOrderItems()는 findOrders()의 결과를 가지고 OrderItem의 정보를 조회하여 OrderItemQueryDto타입으로 반환
  • findOrderQueryDtos()는 각 메서드를 호출하여 최종적으로 컨트롤러에서 사용하는 메서드로, findOrders()의 호출 결과를 forEach문으로 반복하여 findOrderItems()메서드로 한번더 가공하여 최종조회된 결과를 반환함
package jpabook.jpashop.repository.order.query;

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager em;

    /**
     * 컬렉션 관계를 제외한 나머지를 조회하는 쿼리의 결과를 가지고
     * 컬렉션 관계를 조회하는 쿼리를 적용하여 최종값을 반환
     * Query: 루트에서 1번, 컬렉션에서 N번 발생
     */
    public List<OrderQueryDto> findOrderQueryDtos() {
        List<OrderQueryDto> result = findOrders();
        result.forEach(o -> {
            List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
            o.setOrderItems(orderItems);
        });
        return result;
    }

    /**
     * 1:N(컬렉션) 관계를 제외한 나머지를 한번에 조회하는 쿼리
     */
    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();
    }

    /**
     * 1:N(컬렉션) 관계인 orderItems를 조회하는 쿼리
     */
    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();
    }

}

 

(4) OrderQueryDto, OrderItemQueryDto

  • 직접 JPA에서 DTO를 조회하기위해 생성된 별도의 DTO들
  • Order와 OrderItem을 각각 조회해야하기에 각각의 DTO를 생성하여 Repository 코드에서 해당DTO타입으로 반환함
package jpabook.jpashop.repository.order.query;

@Data
public class OrderQueryDto {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;
    private List<OrderItemQueryDto> orderItems;

    public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
    }
}

package jpabook.jpashop.repository.order.query;

@Data
public class OrderItemQueryDto {

    private Long orderId;
    private String itemName;
    private int orderPrice;
    private int count;

    public OrderItemQueryDto(Long orderId, String itemName, int orderPrice, int count) {
        this.orderId = orderId;
        this.itemName = itemName;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}

 

(5) 실행 결과

  • 모든 코드를 적용하고 API를 호출해보면 정상적으로 응답이 반환되며 당연하게도 N + 1문제가 발생되어 Order를 조회하는 쿼리 1번, 컬렉션을 조회하는 쿼리 N번이 발생함

2. 주문 조회 V5 - 컬렉션 조회 최적화

1) 최적화를 적용한 컨트롤러

(1) orderV5() 추가

  • 최적화쿼리가 적용된 메서드를 호출하는 컨트롤러를 생성
public class OrderApiController {
    // ... 기존 코드 생략
    
    @GetMapping("/api/v5/orders")
    public List<OrderQueryDto> ordersV5() {
        return orderQueryRepository.findAllByDto_optimization();
    }
}

 

(2) findAllByDto_optimization() 추가

  • 기존에 작성해 둔 findOrders로 Order의 데이터를 조회하는 것은 동일하지만 컬렉션의 데이터를 가져오는 쿼리를 IN 연산자를 사용하여 orderId값을 비교하여 포함된 값을 한방에 가져오도록 변경
  • 위의 과정을 위해 findOrders의 결과에서 id값만 추출하고, 해당 값을 JPQL의 쿼리파라미터의 인자로 입력
  • 컬렉션의 결과를 Map으로 변환하여 코드 작성의 용이함과 한번더 최적화가 되도록한 뒤에 Order의 결과와 Map으로 변환된 컬렉션의 조회결과를 합쳐서 반환함
  • Map이 List에 비해 시간복잡도가 줄게되며 List에서 하나씩 꺼내는 것보다 Map으로 메모리에 한번에 올려서 반환하는 것이 조금의 성능 최적화가 있을 수 있음
package jpabook.jpashop.repository.order.query;

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
    // ... 기존 코드 생략
    
    /**
     * 최적화 적용 쿼리메서드
     * Query: 루트에서 1번, 컬렉션에서 1번 발생(최적화 적용됨)
     */
    public List<OrderQueryDto> findAllByDto_optimization() {
        /**
         * 1:N(컬렉션) 관계를 제외한 나머지를 한번에 조회하는 쿼리
         */
        List<OrderQueryDto> result = findOrders();  // 여기까지는 기존과 동일

        // 컬렉션을 조회하는 쿼리의 결과를 MAP으로 반환
        Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));

        // result의 결과에 컬렉션으로 조회된 값을 저장 후 반환
        result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
        return result;
    }

    /**
     * 1:N(컬렉션) 관계인 orderItems를 조회하는 쿼리
     */
    private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
        // 뽑아온 id를 JPQL의 쿼리 파라미터의 인자로 입력하고 OrderItem의 orderId가 인자에 포함되어있으면 한방에 조회되도록 in 연산자를 사용
        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 :orderIds", OrderItemQueryDto.class)
                        .setParameter("orderIds", orderIds)
                        .getResultList();

        // 반환된 값을 한번더 최적화하기위해 Map으로 변환, 코드작성이 용이하고 성능이 더 좋음
        // 메모리에 한번에 올려서 반환하여 조금더 성능 최적화를 기대함
        Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
                .collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
        return orderItemMap;
    }

    // 조회된 result에서 id값만 뽑아서 반환하는 메서드
    private List<Long> toOrderIds(List<OrderQueryDto> result) {
        List<Long> orderIds = result.stream().map(o -> o.getOrderId()).toList();
        return orderIds;
    }
}

 

(3) 결과

  • API를 호출해보면 쿼리결과가 기존의 Order를 조회하는 쿼리1번과 컬렉션을 조회하는 쿼리 1번 총2번의 쿼리로  N+1문제를 해결함
  • 그러나 이렇게 Dto를 직접 반환하는 쿼리를 작성해보면 select에서 데이터를 원하는 값만 조회하는 장점이 있지만 코드를 작성하는 것이 매우 번거로운 부분이 많아서 트레이드 오프가 필요함

3. 주문 조회 V6 - 플랫 데이터 최적화(더 최적화 하기)

1) 1+1을 1번의쿼리로 최적화 해보기

(1) orderV6()

  • 1번의 JPQL쿼리로 데이터를 가져오는 findAllbyDto_flat()메서드를 호출한 뒤에 원하는 API의 스펙에 맞춰서 결과를 가공해서 반환
  • 만약 가공하지않고 그대로 반환하면 중복 데이터가 포함됨
package jpabook.jpashop.api;

@RestController
@RequiredArgsConstructor
public class OrderApiController {
    // ... 기존 코드 생략
    
    @GetMapping("/api/v6/orders")
    public List<OrderQueryDto> ordersV6() {
        //  여기에서 직적 반환하면, API스펙과 달리 모든 쿼리의 모든 데이터가 응답이됨(중복 데이터가 포함됨)
        List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();

        // 아래처럼 API의 스펙에 맞춰서 반환되도록 일일히 작업을 해주어야 함
        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());
    }
}

 

(2) OrderFlatDto추가, OrderQueryDto 수정

  • 한번의 JPQL로 쿼리로 DB데이터를 가져오기위한 별도의 DTO클래스인 OrderflatDto를 생성
  • 컨트롤러에서 반환된 결과값을 원하는 형태로 가공하기 위해 API스펙이 정의된 OrderQueryDto클래스에 전체의 필드가 파라미터로 담긴 생성자를 추가로 생성하고 @EqualsAndHashCode(of = "orderId") 애노테이션을 입력하여 orderId가 같으면 같은 값으로 인식할 수 있도록 설정
package jpabook.jpashop.repository.order.query;

// DB에서 가져와야할 Order와 OrderItem 필드를 한번의 쿼리로 가져올 수 있도록 DTO를 정의
@Data
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;

    public OrderFlatDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus,
                        Address address, String itemName, int orderPrice, int count) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
        this.itemName = itemName;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}

@Data
@EqualsAndHashCode(of = "orderId")
public class OrderQueryDto {
    // ... 기존 코드 생략
    
    // orderItems를 파라미터로 받는 생성자를 추가생성
    public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address, List<OrderItemQueryDto> orderItems) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
        this.orderItems = orderItems;
    }
 
}

 

(3) findAllByDto_flat()

  • JPQL쿼리 한번으로 전체 데이터를 가져오는 메서드
  • 쿼리 한번으로 데이터를 가져오지만 중복데이터까지 모두 가져오기때문에 데이터가 매우크면 V5버전보다 느릴 수 있으며 Order를 기준으로 페이징을 적용할 수 없음
public class OrderQueryRepository {
    // ... 기존 코드 생략
    
    // 1+1을 쿼리 1번으로 변경, 그러나 상황에 따라 V5보다 느릴 수 있고 페이징이 불가능함
    public List<OrderFlatDto> findAllByDto_flat() {
        return em.createQuery(
        "select new" +
            " jpabook.jpashop.repository.order.query.OrderFlatDto(o.id, m.name, o.orderDate, o.status, d.address, i.name, oi.orderPrice, oi.count)" +
            " from Order o" +
            " join o.member m" +
            " join o.delivery d" +
            " join o.orderItems oi" +
            " join oi.item i", OrderFlatDto.class).getResultList();
    }
}

 

(4) 결과

  • 쿼리는 1번이지만 원하는 API 스펙으로 반환하기 위해서는 애플리케이션에서 데이터를 가공해야하는 작업이 크게 발생함
  • 한번의 쿼리로 데이터를 가져올 때 중복이 발생되기 때문에 쿼리에 페이징 기능을 적용할 수 없으며, 상황에 따라서 한번에 가져오는 데이터의 양이 너무 많아질 경우 나눠서 가져오는 것보다 성능이 안좋을 수 있음

4. API 개발 고급 정리

1) 조회별 정리

(1) 엔터티 조회

  • V1 : 엔터티를 조회해서 그대로 반환 - 사용하지 말것
  • V2 : 엔터티를 조회 후 DTO로 변환 - N+1문제 발생
  • V3 : 페치 조인으로 쿼리 수를 최적화 - 성능이 안나올 경우 최적화 적용
  • V3.1 : 컬렉션과 페이징 한계 돌파 - 컬렉션은 페치 조인시 페이징이 불가능한데, 1+1으로 ToOne의 관계는 페치 조인으로, 컬렉션은 지연로딩의 BatchSize로 최적화

(2) DTO 직접 조회

  • V4 : JPA에서 DTO 직접 조회 - N+1문제 발생
  • V5 : 컬렉션 조회 최적화 - 일대다 관계인 컬렉션은 IN절을 활용하여 메모리에 미리 로딩하여 조회하여 마찬가지로 1+1쿼리로 최적화
  • V6 : 플랫 데이터 최적화 - JOIN 결과를 그대로 조회 후 애플리케이션에서 원하는 모양으로 직접 가공

2) 권장 순서

  1. 엔터티 조회 방식으로 우선 접근을 하고 페치조인으로 쿼리 수를 최적화
  2. 컬렉션이 있다면 페이징이 있을때는 BatchSize로 최적화하고 페이징이 필요없다면 페치 조인으로 사용
  3. 엔터티 조회 방식으로 해결이 안되면 DTO 조회 방식을 사용
  4. DTO 조회 방식으로 해결이 안되면 NativeSQL이나 스프링 JdbcTemplate를 사용

3) 엔터티 조회 방식 VS DTO 직접 조회 방식

  • 엔터티 조회 방식은 페치 조인이나 BatchSize와 같은 기능을 활용하여 코드를 거의 수정하지 않고 옵션만 약간 변경해서 다양한 최적화를 시도할 수 있음
  • 반면에 DTO를 직접 조회하는 방식은 성능을 최적화하거나 성능 최적화 방식을 변경할 때 많은 코드를 변경해야 함
  • 보통 성능 최적화는 단순한 코드를 복합한 코드로 변경되기에 개발자는 성능 최적화와 코드 복잡도 사이에서 선택을 해야함
  • 엔터티 조회 방식은 JPA가 많은 부분을 최적화 해주기 때문에 단순한 코드를 유지하면서 성능을 최적화 할 수 있는 반면 DTO 조회 방식은 SQL을 직접 다루는 것과 유사하기 때문에 비즈니스 상황에 따라서 선택을 해야함

** 참고

  • 만약 성능 최적화를 위해 캐시를 사용하기로 했다면 엔터티는 직접 캐싱을 하면 안됨
  • 엔터티는 영속성 컨텍스트에서 관리되는데 캐시때문에 데이터가 안지워지는 등의 상황으로 관리가 꼬여버릴 수 있기 때문에 DTO로 변환을 해서 DTO로 캐시해야 함

4) DTO 조회 방식의 선택지

  • V4, V5, V6에서 단순히 V6가 쿼리가 1번 실행된다고 V6이 항상 좋은 방법은 아님

(1) V4

  • 코드가 단순하기 때문에 특정 주문 한건만 조회하면 이 방식을 사용해도 성능이 잘나옴
  • 즉, Order데이터가 1건이면 그 필드의 컬렉션인 OrderItem을 찾기위한 쿼리도 1번만 실행하면 됨

(2) V5

  • V4에 비해서 코드가 복잡하지만 여러 주문을 한꺼번에 조회하는 경우에는 최적화를 적용한 V5방식을 사용해야만 N+1문제를 1+1로 해결할 수 있음
  • Order데이터가 1000건이면 V4방식으로 사용하면 1 + 1000번의 조회가 실행되는 반면 V5는 1 + 1로 총 2번의쿼리로 최적화를 할 수 있으며 상황에 따라서는 수십, 수백배의 성능 차이가 날 수 있음

(3) V6

  • 완전히 다른 접근방식인데, 쿼리 한번으로 데이터를 가져오고 애플리케이션에서 원하는 스펙에 맞춰서 반환되도록 수정하는 방식
  • 쿼리 한번으로 값을 다 가져오기 때문에 매우 좋아보이지만 페이징이 불가능하기 때문에 실무에서 수백, 수천건 단위로 페이징기능이 보통 필요하기 때문에 실무에서 선택하기에는 어려움
  • 또한 데이터가 많으면 쿼리에서 중복된 데이터도 더 많아질 것이기 때문에 조회되는 데이터의 양이 증가되기 때문에 V5와 비교해서 성능차이가 미비할 수 있음

** 즉 V3, V3.1, V5버전을 사용해서 해결이 안되면 MyBatis나 JdbcTemplate을 사용해서 해결하자


5. API 개발 고급 - 실무 필수 최적화, OSIV와 성능 최적화

1) 용어

  • Open Session In View : 하이버네이트
  • Open EntityManager In View : JPA
  • 관례상 OSIV라고 둘다 칭하며, 스프링부트에서의 설정에서는 Open In View라는 옵션으로 표현했음

2) OSIV ON

(1) spring.jpa.open-in-view: true(true가 기본값임)

  • 이 옵션을 기본값으로 설정하면 애플리케이션 시작 시점에 spring.jpa.open-in-view is enabled by default...... 으로 warn 로그를 남기는데는 이유가 있음
  • OSIV전략이 true이면 트랜잭션 시작하여 영속성 컨텍스트가 커넥션을 가져오고 트랜잭션이 커밋되어도 커넥션을 반환하지 않고 API 응답이 끝나거나 화면에 렌더링이 최종적으로 끝났을 때 반환됨
  • 즉 API 응답이 나가거나 화면에 데이터가 전송이 끝날때까지 데이터 커넥션이 유지 된다는 뜻이며, 그렇기 때문에 View Template이나 API 컨트롤러에서 지연로딩을 할 수 있었던 것임
  • 지연로딩은 영속성 컨텍스트가 살아있어야 가능하고 영속성 컨텍스트는 기본적으로 데이터베이스 커넥션을 유지해야하기 때문임
  • 이렇게 컨트롤러에서까지 지연로딩을 사용할 수 있는 부분은 큰 장점이나, 너무 오랜시간 동안 데이터베이스 커넥션 리소스를 사용하기 때문에 실시간 트래픽이 중요한 애플리케이션에서는 커넥션이 모자랄 수 있기 때문에 장애로 이어질 수 있는 치명적인 단점이 있음

3) OSIV OFF

  • OSIV를 끄면 트랜잭션을 시작하고 끝날 때까지만 데이터 베이스 커넥션을 유지하고 영속성 컨텍스트도 끝나기 때문에 커넥션 리소스를 낭비하지 않음
  • 그러나 OSIV를 끄면 모든 지연로딩을 트랜잭션 안에서 처리해야하기 때문에 지금까지 작성한 많은 지연로딩 코드를 트랜잭션 안으로 넣어야 하는 단점이 있음
  • 즉, 컨트롤러나 view에서 지연로딩을 사용할 수가 없기때문에 트랜잭션이 끝나기 전에 지연 로딩을 강제로 호출해두어야 함
  • 이런 문제를 해결하려면 OSIV를 다시 키거나, 관련 로직을 트랜잭션 범위안으로 집어넣어서 반환한 데이터로 컨트롤러에서 사용하거나, 페치조인을 사용해야함(컬렉션은 BatchSize 설정을 사용)

4) 해결 방안

  • 다양한 해결방법이 있지만 실무에서 주로 사용하는 해결방법을 소개

(1) 커맨드와 쿼리 분리

  • 실무에서 OSIV를 끈 상태로 복잡성을 관리하는 좋은 방법중 하나이며 자세한 소개는 링크의 내용을 참고
  • 보통 비즈니스 로직은 특정 엔터티 몇개를 등록하거나 수정하는 것이므로 성능이 크게 문제가 되지는 않음
  • 보통의 성능이슈는 조회에서 발생하는데 복잡한 화면을 출력하기 위한 쿼리는 화면에 맞추어 성능을 최적화 하는 것이 중요함
  • 그러나 복잡성에 비해 핵심 비즈니스에 큰 영향을 주는 것이 아님
  • 한 리포지토리나 한 서비스에 쿼리용과 핵심비즈니스용이 다 모아져있다고 가정했을때 핵심 비즈니스용에비해 화면에 관련된 복잡한 조회용이 훨씬많은 경우가 많은데 이렇게 구조를 설계하면 유지보수하기가 매우 어려워짐
  • 또한 비즈니스 로직과 관련된 정책과 기능은 자주 바뀌지 않는데에 반해 화면과 관련되거나 API요청에 관한 정책들은 비즈니스 로직에비해 빠르게 변경되기때문에 서로의 라이프사이클이 다른것을 고려한다면 따로 관리하는 것이 좋음
  • 그래서 크고 복잡한 애플리케이션을 개발한다면 이 둘의 관심사를 명확하게 분리하는 것이 유지보수 관점에서 좋은 선택이 될 수 있음

(2) 분리 적용

  • OrderService: 핵심 비즈니스로직
  • OrderQueryService : 화면이나 API에 맞춘 서비스를 새로 생성하고, 패키지의 위치도 별도의 위치에서 관련된 항목을 관리(주로 읽기 전용 트랜잭션을 사용함)
  • 보통 서비스 계층에서 트랜잭션을 유지하기 때문에 두 서비스 모두 트랜잭션을 유지하면서 지연 로딩을 사용할 수 있음

(3) OSIV 옵션 선택 가이드

  • 기본적으로는 OSIV를 키는 것이 커넥션이 제한되는 이슈는 있어도 장점이 극명하기 때문에 애플리케이션 규모가 작거나 사용자가 적은 ADMIN 페이지 등에서는 켜고 개발하는 것을 권장
  • 그러나 성능을 생각하면 OSIV옵션을 꺼야하기에 고객 서비스처럼 실시간 API요청이 매우 많아서 데이터베이스 커넥션이 금방 소모되는 서버에서는 해당옵션을 끄고 개발해야 장애가 나지 않음
  • 김영한님도 위처럼 가이드를 잡고 개발을 한다고 하며 이문제는 실무에서 정말많이 만나기 때문에 꼭알고 있어야 함

6) orderV3_page에 적용

(1) orderV3_page수정

  • 기존코드를 유지하기 위해 orderV3_page2로 메서드를 생성하고 컨트롤러에 OrderQueryService를 설정
  • OrderQueryService는 기존의 비즈니스 로직을 담당하는 Service코드들과는 별도로 분리해서 관리하도록 생성한뒤에 orderV3_page에서 지연로딩으로 다뤘던 코드들을 모두 트랜잭션 범위에있는 Service의 로직으로 이동
  • 단순히 컨트롤러는 화면에 입력받은 값을 전달하고, 연산결과를 반환하기만함
package jpabook.jpashop.api;

public class OrderApiController {
    // ... 기존 코드 생략
    
    private final OrderQueryService orderQueryService;

    @GetMapping("/api/v3.2/orders")
    public List<OrderDto2> ordersV3_page2(@RequestParam(value = "offset", defaultValue = "0") int offset,
                                          @RequestParam(value = "limit", defaultValue = "100") int limit) {
        return orderQueryService.ordersV3_page2(offset, limit);
    }

}

 

(2) OrderQueryService

  • 컨트롤러에서 동작하던 지연로딩 코드들을 별도의 Service클래스를 만들어서 트랜잭션을 적용하고 해당클래스의 메서드로 정의
  • 보통 화면관련된 로직의 트랜잭션은 읽기전용으로 설정함
  • 기존의 비즈니스 로직이 있는 서비스 클래스들과 분리하기위해 현재구조에서는 service에 query 패키지를 만들어서 생성하였으나, 실제 실무에서는 아키텍처 구조에 맞춰서 설계하면됨
package jpabook.jpashop.service.query;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderQueryService {

    private final OrderRepository orderRepository;

    public List<OrderDto2> ordersV3_page2(int offset, int limit) {
        List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
        return orders.stream().map(OrderDto2::new).toList();
    }
}

 

(3) OrderDto2, OrderItemDto2

  • 기존에 컨트롤러에 정의되어있던 Dto클래스를 2만 붙혀서 다른 곳에서 다시 생성(기존코드를 유지하기 위함이며 실제 리펙토링에선 당연히 지움)
  • OrderQueryService가 해당 DTO들을 사용하므로 관련된 클래스들을 한곳에 모아서 설계
package jpabook.jpashop.service.query;

@Data
public class OrderDto2 {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;
    private List<OrderItemDto2> orderItems;

    public OrderDto2(Order order) {
        orderId = order.getId();
        name = order.getMember().getName();
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress();
//            order.getOrderItems().stream().forEach(o -> o.getItem().getName()); // 강제 프록시 초기화 -> 제거
        // OrderItem -> OrderItemDto로 변환하여 저장
        orderItems = order.getOrderItems().stream().map(o -> new OrderItemDto2(o)).toList();
    }
}

@Data
public class OrderItemDto2 {
    // 클라이언트에서 OrderItemDto의 모든 정보가 필요한 것이 아닌 상품명, 주문가격, 주문수량 정보만 필요하다고 가정
    private String itemName;    // 상품명
    private int orderPrice;     // 주문가격
    private int count;          // 주문수량

    public OrderItemDto2(OrderItem orderItem) {
        itemName = orderItem.getItem().getName();
        orderPrice = orderItem.getOrderPrice();
        count = orderItem.getCount();
    }
}