JPA 활용2 - 성능 최적화

1. API 개발 기본


  • 템플릿 엔진을 사용해서 랜더링하는 컨트롤러랑 API 스타일 컨트롤러를 분리한다. 공통으로 예외처리 할 때, 패키지나 구성단위로 처리하기때문이다. API랑 화면이랑은 공통처리 해야 하는 요소가 다르다.
  • 엔티티를 파라미터나 return 값으로 노출시키면 안된다. 반드시 DTO를 만들어서 사용해야 한다.
    • 실무에서는 회원 엔티티를 위한 API가 다양하게 만들어지는데, 한 엔티티에 각각 API를 위한 모든 요구사항을 담기 어렵다.
    • 엔티티를 파라미터로 받으면 엔티티가 변경되면 API 스펙이 변한다.
  • DTO 사용의 이점
    • 엔티티와 API 스펙을 명확히 분리 가능
    • 엔티티가 변해도 API 스펙이 변하지 않는다.

등록 API

@ResponseBody
@PostMapping("/api/v2/members")
 public CreateMemberResponse saveMemberV2(@RequestBody @Valid CreateMemberRequest request) {
 Member member = new Member();
 member.setName(request.getName());
 Long id = memberService.join(member);
 return new CreateMemberResponse(id);
 }

Member 엔티티를 파라미터로 받고 return 하는게 아니라 CreateMemberRequest라는 DTO 하나를 만들어서 받았고, DTO를 반환했다. 절대 엔티티 파라미터, 반환하지 말아야 한다.


조회 API

return으로 응답 반환할 때 반드시 객체로 한번 감싸서 넘겨야 한다. 리스트로 넘기면 안된다.

[ "count":4 // count 추가시 JSON 스펙이 깨져버림
  {
    "id":1,
    "name":"test1"
  },
  {
  "id":1,
  "name":"test1"
  }
]

return으로 리스트를 넘긴 경우 응답이 위와 같이 온다. 전체를 []으로 감싸져 있다. array로 넘어온 것이다. array로 넘어왔으면 object가 계속 같은 것이 쭉쭉 와야한다. 만약에 여기다가 “count”를 넣어달라고 하면 맨 위에 따로 count가 추가된 것처럼 JSON 스펙이 깨져버린다. 확장이 불가능한 것이다. 따라서 기본적으로 스펙 밖에는 object가 있고 그 안쪽에 array가 오게 만들어야 한다. 아래의 경우가 이상적인 형태이다.

{
  "count":4
  "data":[{
    "id":1,
    "name":"test1"
    },
    {
    "id":1,
    "name":"test1"
    }
  ]
}


반환을 리스트로 하는게 아니라 객체로 감싸서 반환함으로 인해 유연성이 높아진다. 아래는 리스트가 아닌 DTO로 반환한 경우이다.

@GetMapping("/api/v2/members")
    public Result membersV2(){
        List<Member> findMembers = memberService.findMembers();
        List<MemberDto> collect = findMembers.stream()
                // map은 스트림 내 요소들을 하나씩 특정 값으로 변환
                .map(m -> new MemberDto(m.getName()))
                // map으로 각 요소를 변환한 후 결과를 List로 만들기
                .collect(Collectors.toList());

        return new Result(collect);
        // 리스트로 바로 반환하면 
        // JSON 배열 타입으로 [] 나가기때문에 유연성이 떨어진다.
        // 따라서 객체로 한 번 감싸서 반환
    }

    // 제너릭으로 인해 생성자로 들어오는 것의 타입 그대로 세팅된다.
    // collect 타입이 List<MemberDto>이므로
    // T가 List<MemberDto>이 된다.
    @Data
    @AllArgsConstructor
    static class Result<T>{
        private T data;
    }


아래 코드는 리스트가 아닌 DTO를 반환함으로 유연성을 높였고 count를 추가하면서 확장한 예시이다.

@GetMapping("/api/v2/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.size(),collect);
    }
    @Data
    @AllArgsConstructor
    static class Result<T>{
        private int count;
        private T data;
        
    }

{
  "count":4
  "data":[{
    "id":1,
    "name":"test1"
    },
    {
    "id":1,
    "name":"test1"
    }
  ]
}


2. API 개발 - 지연 로딩과 조회 성능 최적화 - XXToOne 의 경우


가장 무난한 방법 - fetch join

// repository의 메서드
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();
    }

// 컨트롤러의 메서드
    @GetMapping("/api/v3/simple-orders")
    public List<SimpleOrderDto> ordersV3(){
        List<Order> orders = orderRepository.findAllWithMemberDelivery();
        List<SimpleOrderDto> result = orders.stream()
                .map(o -> new SimpleOrderDto(o))
                .collect(Collectors.toList());

        return result;
    }

위의 경우가 모든 주문들을의 주문정보 조회하는데 가장 무난한 방법이다. 주문을 조회하는데 주문에 대한 멤버정보, 배송정보가 LAZY로 세팅되어 있기 때문에 map에서 dto로 바꾸는 과정에서 프록시를 초기화하는 쿼리가 나가면서 분명 N+1 문제가 발생한다. 따라서 이를 막기 위해 JPQL 쿼리문을 fetch 조인을 사용해 N+1 문제를 막아주는 방식이다. 원래 한번 감싸서 반환해야하는데 List로 반환한 것은 예시라서 그런 것이다. 그리고 DTO의 생성자 파라미터로 엔티티를 넣어줬는데 DTO에 엔티티를 넣는건 괜찮다. 엔티티를 반환하거나 컨트롤러로 받거나 할때가 문제인 것이다.

재사용성은 적지만 특정 화면에 딱 맞는 최대한의 최적화 - DTO 직접 조회

fetch join 으로 끌어내는 방식은 결과적으로 select에서 전부를 조회한다. 하지만 fetch join으로 전부 땡겨오면 정보가 너무 많아서 필요한 정보 몇가지만 추려서 받아오고 싶을 경우가 있을 것이다. 그렇게 할수만 있다면 네트워크 용량을 최적화 할 수 있다. 하지만 이게 생각보다 최적화가 미비하기도 하고 그렇게 설계하면 해당 API스펙에만 딱맞는 코드가 리포지토리에 있게 된다. 그럼 API스펙이 바뀌면 리포지토리를 수정해야한다는 치명적인 단점과 재사용성이 떨어진다는 단점이 있다. 따라서 페치 조인을 사용하는게 가장 좋으나 조회내용이 엄청나게 많아서 필요부분만 뽑아와야하는 경우에는 어쩔수 없이 이 방법을 사용해야한다.
설명하기 앞서 리포지토리는 순수하게 관리되어야 한다. 이렇게 화면에 딱맞는 API 기능은 리포지토리에 같이 넣기보다는 따로 관리하는게 관리하기 좋다. 그림1

order 패키지 아래 orderrepository를 넣어야하는데 안넣어서 그냥 화살표로 표시했다. 요점은 API에 딱 맞는 리포지토리는 저렇게 simplequery 패키지를 따로 만들어서 관리하는게 좋다는 것이다. 이렇게 따로 관리해주면 핵심 비즈니스 로직들은 OrderRepository를 참조하고 화면과 관련된 것들은 떼어낸 OrderSimpleQueryRepository를 참조하게 된다. 결과적으로 관심사를 분리할 수 있게 된다.

// 특정 조건에 딱맞는 API를 위한 repository
public class OrderSimpleQueryRepository {
    private final EntityManager em;
    public List<OrderSimpleQueryDto> findOrderDtos() {
        return em.createQuery(
                "select new jpabook.jpashop.repository.order.simplequery.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
                        " from Order o" +
                        " join o.member m" +
                        " join o.delivery d", OrderSimpleQueryDto.class)
                .getResultList();
        // jpa는 엔티티나 벨류값만 반환이 가능하다. dto같은 건 안된다.
        // 하려면 new 사용
    }
}

// 컨트롤러
// 핵심 리포지토리가 아니라 쿼리 리포지토리 사용
// 일반적인 find가 아니라 특정 API에 딱 fit한 경우
@GetMapping("/api/v4/simple-orders")
    public List<OrderSimpleQueryDto> ordersV4(){
        return orderSimpleQueryRepository.findOrderDtos();
    }

jpa는 엔티티나 값타입만 반환이 가능하므로 다른 방식으로 받고 싶으면 생성자를 사용하면 된다. new 패키지명 + 생성자 로 코딩하면 된다. join 해도 사용하지 않으면 Lazy 상태라 프록시가 오는건 똑같다고 생각할 수 있으나 지금은 조회열에 원하는 것을 적어두었다. 따라서 프록시가 아니라 진짜가 온다. 무난한 방법과 다르게 select에 원하는 것을 직접 적어줬기 때문에 select에서 조회하는 부분이 줄어들 것이다.

참고로 Lazy라서 프록시로 넘어온 상태에서 초기화 안하고 그대로 반환으로 넘기면 value가 null로 표기된다.


3. API 개발 - 지연 로딩과 조회 성능 최적화 - XXToOne이 아닌 경우 - 컬렉션


앞서 설명했듯이 지연 로딩의 경우 N+1 문제를 해결하려면 fetch join을 사용한다고 했다. XXToOne이 아닌 경우도 마찬가지로 이에 대한 해결책은 fetch join이다. XXToOne이 아닌 경우에서는 join을 할 경우 데이터가 뻥튀기 된다. 이 뻥튀기 된 데이터를 처리하는 방법으로는 distinct + fetch join 사용, BatchSize 사용이 있다. distinct를 사용할 경우 DB자체에서는 데이터 뻥튀기 된 상태 그대로 애플리케이션에 들어와서 jpa가 pk값 기준으로 제거해주는 것이기 때문에 페이징 API를 사용할 수 없다. 하지만 BatchSize의 경우는 일단 Lazy면 프록시로 가져오고 첫 조회할 때 해당 Size만큼 in쿼리로 땡겨오므로 페이징 API를 사용할 수 있다. 또한 데이터가 뻥튀기 되지 않으니 DB 데이터 전송량도 감소한다. 하지만 fetch join + distinct에 비해 나가는 쿼리 호출 수가 약간 증가하는 단점이 있다. 그래도 웬만하면 BatchSize 사용하는게 낫다.

시작하기 전에

엔티티를 컨트롤러의 파라미터로 받거나 리턴값으로 주면 안된다고 했다. 현재 Order는 OrderItems와 일대다관계로 OrderItem을 컬렉션으로 가지고 있다. 만약 모든 주문에 관한 내용을 조회한다고 가정해보자. 요청이 들어오면 리포지토리에서 Order 엔티티를 찾아와서 Dto로 변환해서 반환해줄 것이다. 이때 Order 엔티티는 OrderItem을 컬렉션으로 가지고 있는데 이것도 엔티티다. 따라서 응답으로 DTO를 반환할 때 컬렉션을 제외한 부분은 그냥 하던 식으로 Dto로 옮기면 되고 이 컬렉션 부분은 엔티티이므로 다른 Dto를 만들어서 처리해주고 원래 Dto에 꼽아주면 된다.

@Data
static class OrderDto {  
 private Long orderId;
 private String name;
 private LocalDateTime orderDate; 
 private OrderStatus orderStatus;
 private Address address;
// 컬렉션이 엔티티이므로 따로 Dto 만들어서 처리
 private List<OrderItemDto> orderItems;

 public OrderDto(Order order) {
   // 기존 dto, 그냥 옮기면 된다
 orderId = order.getId();
 name = order.getMember().getName();
 orderDate = order.getOrderDate();
 orderStatus = order.getStatus();
 address = order.getDelivery().getAddress();

 // 컬렉션 dto 추가
 // stream.map으로 orderItem을 각각 dto로 바꿔주고 리스트로 뽑아서
 // 그걸로 세팅
 orderItems = order.getOrderItems().stream()
 .map(orderItem -> new OrderItemDto(orderItem))
 .collect(toList());
 }
}
@Data
// orderItem의 전체가 아니라 필요한 내용만 뽑아서 dto로 만듦
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();
 }
}


distinct + 페치 조인으로 해결

// 컨트롤러
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
 List<Order> orders = orderRepository.findAllWithItem();
 List<OrderDto> result = orders.stream()
 .map(o -> new OrderDto(o))
 .collect(toList());
 return result;
}

// 리포지토리 메서드
public List<Order> findAllWithItem() {
 return em.createQuery(
 "select distinct o from Order o" + // distinct로 중복 제거
 " join fetch o.member m" + // XXToOne
 " join fetch o.delivery d" + // XXToOne
 " join fetch o.orderItems oi" + // OneToMany -> 컬렉션 
 " join fetch oi.item i", Order.class)
 .getResultList();
}
  • 페이징 불가
  • 데이터가 뻥튀기 된 상태로 애플리케이션에 들어와 distinct 작업


BatchSize 으로 해결

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

XXToOne의 경우만 페치 조인으로 땡겨오고 일대다의 경우는 BatchSize를 사용할 것이므로 컬렉션은 그대로 Lazy로 프록시가 오게끔만 해두고 BatchSize만 설정해주면 끝이다. 위처럼 코딩했으면 현재 OrderItems는 프록시로 들어왔을 것이고 한 번 호출하게 될 때 초기화작업을 하면서 BatchSize만큼 한 번에 in 쿼리로 땡겨온다. BatchSize 설정은 다음과 같다.

글로벌 설정

# application.yml
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000


개별 설정

@BatchSize(size=100)
@OneToMany(mappedBy="team")

일대다의 경우 해당 필드 에 바로 붙여주면 되는데 다대일의 경우에 BatchSize를 사용하고 싶다면 일쪽에 클래스 에 붙여줘야 한다.

BatchSize의 적정 크기

  • 100 ~ 1000 사이를 권장
  • DB에 따라 IN절 파라미터를 1000으로 제한하기도 하므로 반드시 1000 이하를 사용한다.
  • 1000으로 설정하는 것이 성능상 가장 좋으나 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는지로 판단해야 한다 -> 애매하면 100 ~ 500 사이 값을 사용


재사용성은 적지만 특정 화면에 딱 맞는 최대한의 최적화 - DTO 직접 조회

단건 조회를 위한 최적화

// 사용할 DTO
@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;
    }
}

// 컬렉션에 들어가는 dto
@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;
    }
}



// 컨트롤러
@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4() {
    return orderQueryRepository.findOrderQueryDto();
}

// 여기부터 따로 빼놓은 쿼리 리포지토리

public List<OrderQueryDto> findOrderQueryDto() {
        // 컬렉션부분이 빈 dto를 가져옴
        List<OrderQueryDto> result = findOrders();
        // 컬렉션 부분을 채워줘야 함
        result.forEach( o-> {
          // 해당 주문번호에 대한 orderItems 땡겨와서 해당 order의 orderitems 컬렉션에 넣기
            List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
            o.setOrderItems(orderItems);
        });
        return result;
    }


private List<OrderQueryDto> findOrders() {
        return em.createQuery(
                // new 를 쓸때 sql처럼 쓰는건데 dto를 할뿐
                // data를 플렛하게 한줄뿐이 못 넣음
                // 근데 orderitems는 컬렉션이라 처리 불가능 따로 처리해줘야 함
                // 그러므로 일단 컬렉션 아닌것만 땡겨옴
                "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();
    }

// 해당 주문번호에 대한 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();

    }

위의 코드는 단순하다. 컬렉션만 빼고 Dto로 땡겨오고 forEach로 돌리면서 주문번호를 가지고 orderItem을 찾는 쿼리를 날려서 받은 정보로 컬렉션을 세팅해주는 방법이다. 만약 조회한 order데이터가 1건이면 orderItem을 찾기 위한 쿼리도 1번만 실행된다. 하지만 order 데이터가 여러 건이면 각각마다 orderItem을 조회하는 쿼리를 날리게 되므로 N+1 문제가 발생한다. 따라서 여러 건은 다른 방식으로 설계해야하고 단건 조회의 경우는 이 방법은 선택하는게 가장 좋다.
정리하자면 BatchSize를 설정해도 in쿼리를 한 번 날려서 초기화시켜줘야하므로 결국 2번의 쿼리가 나가는데, 단건 조회의 경우 1건만 조회하므로 N+1이 생길일이 없이 BatchSize와 마찬가지로 조회 쿼리 1번, 컬렉션 땡겨오는 쿼리 1번이 나간다. 따라서 재사용성은 없을지라도 원하는 필드만 딱 땡겨오는 방법으로 가장 최적화가 잘 되었다고 볼 수 있다.

여러 건 조회 최적화

// 컨트롤러
@GetMapping("/api/v5/orders")
public List<OrderQueryDto> ordersV5() {
    return orderQueryRepository.findAllByDto_optimization();
}


// 따로 떼어논 쿼리 리포지토리
public List<OrderQueryDto> findAllByDto_optimization() {
        // 위 방법과 똑같이 일단 컬렉션 비워놓고 가져옴
        List<OrderQueryDto> result = findOrders();

        // orderId만 뽑아서 리스트로 만듦
        List<Long> orderIds = result.stream()
                .map(o -> o.getOrderId())
                .collect(Collectors.toList());

        // 뽑아논 orderId 리스트로 in 쿼리 날려서 orderitems 뽑아오기
        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 -> orderItemQueryDto.getOrderId()));

        // dto에 비워져있던 컬렉션 채우기
        result.forEach(o-> o.setOrderItems(orderItemMap.get(o.getOrderId())));
        return result;
    }
  • ToOne 관계들은 먼저 조회하고 여기서 얻은 식별자로 ToMany 관계인 OrderItem을 in쿼리로 한 번에 조회
  • Map을 사용해 매칭 성능 O(1)로 향상
  • 루트 1번, 컬렉션 1번 쿼리가 나간다. N + 1 문제 해결


4. 성능 최적화 결론


엔티티 조회

XXToOne 관계

  1. 단순하게 DTO로 받아서 DTO를 반환
  2. 1에서 성능 문제 발생 -> 페이 조인으로 쿼리 수 최적화

컬렉션 관계 - XXToMany

우선 XXToOne 관계는 페치 조인으로 최적화하고 컬렉션 관계는 다음과 같이 처리

  • 페이징 필요 없을 경우 : distinct + 페치 조인
  • 페이징 필요할 경우 : BatchSize

DTO 직접 조회

화면에 fit한 API 처리를 위한 최적화 -> 리포지토리 분리

  • XXToOne 관계 : DTO를 new로 직접 조회
  • XXToMany 컬렉션 관계
    • 단건 조회
      • DTO를 컬렉션을 제외한 부분만 땡겨서 채워주기
      • 따로 컬렉션을 찾아오는 쿼리를 날려 받아서 컬렉션 DTO를 받고 원래 DTO에 값 세팅
    • 여러 건 조회
      • DTO를 컬렉션을 제외한 부분만 땡겨서 채워주기
      • 땡겨온 것에서 Id값만 따로 리스트로 만들기
      • Id값으로 in쿼리 날려서 한 번에 땡겨오기
      • 땡거온 것 원래 DTO에 넣기 전에 성능 최적화 즉, 한번에 찾기 위해 Map으로 변환
      • 원래 DTO에 비워져있던 컬렉션 채우기


5. OSIV와 성능 최적화


그림1

OSIV(Open Seesion In View) 기본값은 true 이므로 수정하지 않으면 OSIV 전략으로 실행된다. OSIV 전략은 트랜잭션 시작처럼 최초 데이터베이스 커넥션 시작 시점부터 API 응답이 끝날 때 까지 영속성 컨텍스트와 데이터베이스 커넥션을 유지한다. 그래서 지금까지 API 컨트롤러에서 지연 로딩이 가능했던 것이다.(리포지토리에서 엔티티를 찾아와 지연로딩 되어 프록시로 와있던 것을 초기화하는 작업을 컨트롤러에서도 가능했다. 트랜잭션 범위가 아닌대도 가능했던 것) 지연 로딩은 영속성 컨텍스트가 살아있어야 가능하고, 영속성 컨텍스트는 기본적으로 DB 커넥션을 유지한다. 이것 자체가 큰 장점이다.
하지만 이 전략은 오랜시간동안 DB 커넥션 리소스를 사용하기 때문에 실시간 트래픽이 중요한 애플리케이션에서는 커넥션이 모자라게 되어 결국 장애로 이어진다.

실시간 트래픽이 많은 경우 반드시 OSIV를 꺼야한다. 설정은 아래와 같다.

# application.yml
spring.jpa.open-in-view: false

OSIV를 끄면 트랜잭션을 종료할 때 영속성 컨텍스트를 닫고, DB 커넥션도 반환한다. 따라서 커넥션 리소르를 낭비하지 않는다.
OSIV를 끄면 모든 지연로딩을 트랜잭션 안에서 처리해야 한다. 따라서 지금까지 service계층에 있는 트랜잭션이외의 곳에서 사용하던 모든 지연로딩을 트랜잭션 안으로 밀어넣어야 한다. 그리고 view template에서 지연로딩이 동작하지 않는다. 결론적으로 트랜잭션이 끝나기 전까지 지연로딩을 강제로 호출해 두어야 한다.
해결방안은 지금까지 서비스계층의 트랜잭션이외에서 사용하던 지연로딩 코드를 핵심 서비스가 아니라 쿼리 리포지토리와 핵심 리포지토리를 분리했던 것처럼 핵심 서비스와 쿼리 서비스를 분리하여 관리하면 된다. 예를 들어 컨트롤러의 Orders메서드의 로직이 리포지토리에서 조회 정보를 가져와 지연로딩된 부분을 초기화 시키는 작업을 한다고 했을 때 OSIV가 꺼져있으니 세션이 없어서 오류가 날 것이다. 따라서 해당 코드를 OrderQueryService(화면이나 API에 맞춘 서비스 -> 주로 읽기 전용 트랜잭션)을 만들어서 그 안에 그대로 메서드로 옮겨놓고 컨트롤러에서는 OrderQueryService의 옮겨논 메서드를 호출시켜 값을 받으면 트랜잭션 안에서 다 처리된 값을 받아서 사용하게 되니 해결할 수 있게 된다.



본 포스팅은 인프런 김영한님의 ‘실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화’ 강의를 듣고 정리한 내용을 바탕으로 복습을 위해 작성하였습니다. [강의 링크]


© 2021. By Backtony