Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
Tags
- jpa - 객체지향 쿼리 언어
- 자바의 정석 기초편 ch7
- 자바의 정석 기초편 ch6
- 스프링 입문(무료)
- 2024 정보처리기사 시나공 필기
- 자바의 정석 기초편 ch8
- @Aspect
- 자바의 정석 기초편 ch5
- 스프링 mvc2 - 타임리프
- 코드로 시작하는 자바 첫걸음
- 2024 정보처리기사 수제비 실기
- 자바의 정석 기초편 ch1
- 스프링 db2 - 데이터 접근 기술
- 자바의 정석 기초편 ch11
- jpa 활용2 - api 개발 고급
- 자바의 정석 기초편 ch4
- 스프링 고급 - 스프링 aop
- 자바의 정석 기초편 ch14
- 스프링 mvc1 - 서블릿
- 자바의 정석 기초편 ch2
- 게시글 목록 api
- 스프링 db1 - 스프링과 문제 해결
- 스프링 mvc2 - 로그인 처리
- 자바의 정석 기초편 ch3
- 스프링 mvc2 - 검증
- 스프링 mvc1 - 스프링 mvc
- 타임리프 - 기본기능
- 자바의 정석 기초편 ch12
- 자바의 정석 기초편 ch9
- 자바의 정석 기초편 ch13
Archives
- Today
- Total
나구리의 개발공부기록
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) 권장 순서
- 엔터티 조회 방식으로 우선 접근을 하고 페치조인으로 쿼리 수를 최적화
- 컬렉션이 있다면 페이징이 있을때는 BatchSize로 최적화하고 페이징이 필요없다면 페치 조인으로 사용
- 엔터티 조회 방식으로 해결이 안되면 DTO 조회 방식을 사용
- 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();
}
}