업데이트:

Spring JPA2

멋쟁이사자처럼 대학 11기 백엔드 스프링부트 교육 과정 중 인프런 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화를 수강하고 정리한 포스트입니다.

1. API 개발 기본

요즘 화면을 템플릿 엔진으로 만드는 경우도 있지만 그것보다는 vue.js, react.js를 이용해서 SPA로 많이 개발하기 때문에 서버 개발자 입장에서는 프론트엔드나 앱 또는 MSA와 API로 통신해야할 일들이 많아져서 API를 잘 설계하고 개발하는게 중요하다.

https://www.postman.com/downloads/에서 REST API 테스트 도구인 postman을 설치하자.

회원 등록 API

V1 엔티티를 Request Body에 직접 매핑

// @RestController = @Controller + @ResponseBody
// @ResponseBody: json이나 xml 데이터를 바로 보냄
@RestController
@RequiredArgsConstructor
public class MemberApiController {
    private final MemberService memberService;

    // 첫 번째 버전
    @PostMapping("/api/v1/members")
    // @RequestBody: json으로 온 http 요청의 body를 매개변수에 매핑해줌
    public CreateMemberResponse saveMemberV1(@RequestBody @Valid Member member){
        Long id = memberService.join(member);
        return new CreateMemberResponse(id);
    }

    @Data
    // 정적 내부 클래스
    static class CreateMemberResponse{
        private Long id;

        public CreateMemberResponse(Long id) {
            this.id = id;
        }
    }
}

다음과 같이 POST 요청을 /api/v1/members로 json 데이터와 함께 날리면 API 응답을 받을 수 있다.

image

name을 안넣어도 저장이 된다. 왜냐하면 엔티티에 제약조건이 달려있지 않아서 @Valid 어노테이션이 그냥 지나가기 때문이다.

image

검증을 하고 싶다면 엔티티에 @NotEmpty 제약 조건을 넣어주면 된다.

@Entity
@Getter
@Setter
public class Member {
    @Id
    @GeneratedValue
    // 컬럼명 설정
    @Column(name = "member_id")
    private Long id;

    // @NotEmpty: 필수, 값이 없으면 안됨
    @NotEmpty
    private String name;

    // @Embedded: 내장 타입을 포함함
    @Embedded
    private Address address;

    // 일대다 관계
    // 양방향 연관관계, order 테이블에 있는 member 필드에 의해서 매핑됨
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}

이후 비어있는 json을 전달하게 되면 @Valid 어노테이션이 입력받은 member를 검증해서 오류를 알려준다.
스프링부트가 기본으로 에러에 대해서 설정해 놓은 스타일로 에러를 응답한다.

image

< v1의 문제 >

  • 화면과 관련된 프레젠테이션 계층의 검증 로직이 엔티티에 들어가 있다.
    하지만 API마다 검증 로직이 다를 수 있고 엔티티가 바뀔경우 API 스펙이 바뀌어버린다.

따라서 엔티티를 파라미터로 받지 말고 API의 요청 스펙에 맞춰 별도의 DTO를 만들어서 받아야한다.

V2 엔티티 대신에 DTO를 RequestBody에 매핑

// @RestController = @Controller + @ResponseBody
// @ResponseBody: json이나 xml 데이터를 바로 보냄
@RestController
@RequiredArgsConstructor
public class MemberApiController {
    private final MemberService memberService;

    // 두 번째 버전
    @PostMapping("/api/v2/members")
    // 파라미터로 CreateMemberRequest 라는 별도의 DTO 사용
    public CreateMemberResponse saveMemberV2(@RequestBody @Valid CreateMemberRequest request){
        Member member = new Member();
        member.setName(request.getName());

        Long id = memberService.join(member);
        return new CreateMemberResponse(id);
    }

    @Data
    // DTO 클래스
    static class CreateMemberRequest{
        // 검증 로직
        @NotEmpty
        private String name;
    }
}

image

< v2의 장점 >

  • 엔티티를 파라미터로 사용하면 정확히 어떤 값들이 넘어오는지 모르지만 별도의 DTO로 만들어서 파라미터로 받게되면 API 스펙이 정리되고 검증도 API 스펙에 맞게 구현할 수 있게된다.
  • 엔티티와 API 스펙을 명확히 분리할 수 있고 엔티티가 변경되어도 API 스펙이 변하지 않는다.

따라서 API를 만들 때, 엔티티를 외부에 노출하지않고 API 스펙에 맞는 별도의 DTO를 만드는게 정석이다.

회원 수정 API

// @RestController = @Controller + @ResponseBody
// @ResponseBody: json이나 xml 데이터를 바로 보냄
@RestController
@RequiredArgsConstructor
public class MemberApiController {
    private final MemberService memberService;

    @PutMapping("/api/v2/members/{id}")
    // 등록이랑 수정은 API 스펙이 달라서 별도의 DTO 사용
    public UpdateMemberResponse updateMemberV2(@PathVariable("id") Long id,
                                               @RequestBody @Valid UpdateMemberRequest request){
        // 커맨드와 쿼리의 분리
        memberService.update(id, request.getName());
        Member findMember = memberService.findOne(id);
        return new UpdateMemberResponse(findMember.getId(), findMember.getName());
    }

    @Data
    // 업데이트 요청 DTO
    static class UpdateMemberRequest{
        private String name;
    }

    @Data
    @AllArgsConstructor
    // 업데이트 응답 DTO
    static class UpdateMemberResponse{
        private Long id;
        private String name;
    }
}

image

회원 등록을 하고 회원 수정 API를 호출하게 되면 회원이름이 변경된 것을 확인할 수 있다.

image

회원 조회 API

단순 조회 기능이어서 테이블을 변경할 이유가 없으므로 application.yml 파일을 수정하자.
ddl-auto를 none으로 설정하면 데이터를 한번 넣어두면 계속 사용이 가능하다.

spring:
  jpa:
    hibernate:
      # create: 애플리케이션 실행 시점에 테이블을 지우고 다시 생성
      # none: 데이터베이스의 정보가 유지됨
      ddl-auto: none

이후 MVC로 만들어둔 화면을 통해서 데이터들을 집어넣자.

image

회원조회 V1: 응답 값으로 엔티티를 직접 외부에 노출

@RestController
@RequiredArgsConstructor
public class MemberApiController {
    private final MemberService memberService;

    @GetMapping("api/v1/members")
    public List<Member> membersV1(){
        return memberService.findMembers();
    }
}

image

< v1의 문제 >

  • 엔티티를 파라미터나 반환값으로 전달하게되면 해당 API에서 원하지 않는 정보가 같이 전달되고 엔티티의 있는 모든 정보들이 외부에 다 노출되게 된다. @JsonIgnore 어노테이션으로 제외시킬 수 있지만 만약 다른 API에서 사용하는 필드라면 문제가 발생하고 엔티티 안에 프레젠테이션 계층을 위한 로직이 추가되어버린다.
  • 엔티티의 필드명이 바뀌면 클라이언트에게 변경된 필드명으로 값이 전달되어 API 스펙이 변경되어 버린다.
  • 리스트 값을 감싸지 않고 반환하면 API 스펙이 변경될때, 추가적인 데이터를 넣기 힘들어 유연성이 떨어진다.

회원조회 V2: 응답 값으로 엔티티가 아닌 별도의 DTO 사용

@RestController
@RequiredArgsConstructor
public class MemberApiController {
    private final MemberService memberService;

    @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());
        // List를 바로 반환하면 json의 배열타입으로 반환이 되므로 Result로 감싸줌
        return new Result(collect);
    }

    @Data
    @AllArgsConstructor
    // 데이터를 감싸는 객체
    // 추가 필드가 생겨도 변경이 쉬움
    static class Result<T>{
        private T data;
    }

    @Data
    @AllArgsConstructor
    // 외부에 노출할 것만 DTO에 저장
    // API 스펙 == DTO 코드
    static class MemberDto{
        private String name;
    }
}

다음과 같이 data 안에 dto 배열이 들어있는 것을 확인할 수 있다.

image

절대 엔티티를 외부에 노출하거나 직접 반환하지 말고 항상 DTO로 바꿔서 반환해야 한다.

2. API 개발 고급 - 준비

조회용 샘플 데이터 입력

API 개발 고급 설명을 위한 샘플용 주문 데이터를 입력하자.

  • userA
    • JPA1 BOOK
    • JPA2 BOOK
  • userB
    • SPRING1 BOOK
    • SPRING2 BOOK
/*
    총 주문 2개
    - userA
        - JPA1 BOOK
        - JPA2 BOOK
    - userB
        - SPRING1 BOOK
        - SPRING2 BOOK
*/

// @Component: 스프링의 ComponentScan의 대상이 됨
@Component
@RequiredArgsConstructor
public class InitDb {
    private final InitService initService;

    // @PostConstruct: 스프링 빈에 DI 작업이 끝나면 초기화 작업을 위해 스프링이 호출해줌
    @PostConstruct
    public void init(){
        initService.dbInit1();
        initService.dbInit2();
    }

    @Component
    @Transactional
    @RequiredArgsConstructor
    static class InitService {
        private final EntityManager em;

        public void dbInit1() {
            Member member = createMember("userA", "seoul", "asdf", "1234");
            em.persist(member);

            Book book1 = createBook("JPA1 BOOK", 10000, 100);
            em.persist(book1);

            Book book2 = createBook("JPA2 BOOK", 20000, 100);
            em.persist(book2);

            Delivery delivery = createDelivery(member);

            OrderItem orderItem1 = OrderItem.createOrderItem(book1, 10000, 1);
            OrderItem orderItem2 = OrderItem.createOrderItem(book2, 20000, 2);
            Order order = Order.createOrder(member, delivery, orderItem1, orderItem2);

            em.persist(order);
        }

        public void dbInit2() {
            Member member = createMember("userB", "yong-in", "qwer", "5678");
            em.persist(member);

            Book book1 = createBook("SPRING1 BOOK", 20000, 200);
            em.persist(book1);

            Book book2 = createBook("SPRING2 BOOK", 40000, 300);
            em.persist(book2);

            Delivery delivery = createDelivery(member);

            OrderItem orderItem1 = OrderItem.createOrderItem(book1, 20000, 3);
            OrderItem orderItem2 = OrderItem.createOrderItem(book2, 40000, 4);
            Order order = Order.createOrder(member, delivery, orderItem1, orderItem2);
            em.persist(order);
        }

        private Member createMember(String name, String city, String street,
                                    String zipcode) {
            Member member = new Member();
            member.setName(name);
            member.setAddress(new Address(city, street, zipcode));
            return member;
        }

        private Book createBook(String name, int price, int stockQuantity) {
            Book book = new Book();
            book.setName(name);
            book.setPrice(price);
            book.setStockQuantity(stockQuantity);
            return book;
        }

        private Delivery createDelivery(Member member) {
            Delivery delivery = new Delivery();
            delivery.setAddress(member.getAddress());
            return delivery;
        }
    }
}

3. API 개발 고급 - 지연 로딩과 조회 성능 최적화

주문 + 배송정보 + 회원을 조회하는 API 개발

지연로딩 때문에 발생하는 성능문제의 단계적 해결

간단한 주문 조회 V1: 엔티티를 직접 노출

OrderSimpleApiController

/*
    OneToOne, ManyToOne 관계 최적화
    Order
    Order -> Member
    Order -> Delivery
 */
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
    private final OrderRepository orderRepository;

    @GetMapping("/api/v1/simple-orders")
    public List<Order> ordersV1(){
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        // 양방향 연관관계에서 문제 발생! order가 member를 부르고 member가 order를 부름, 무한루프...
        // Order를 참조하는 엔티티의 필드에 @JsonIgnore 어노테이션을 작성해야함
        return all;
    }
}

image

하지만 요청이 가지 않는다. 양방향 연관관계인 필드가 서로를 참조해서 무한루프에 걸렸기 때문이다.

그래서 양방향 연관관계인 필드들에 다 @JsonIgnore 어노테이션을 작성해줘야 한다.

public class Member {
    // @JsonIgnore: json으로 반환할 때 무시함
    @JsonIgnore
    private List<Order> orders = new ArrayList<>();
}

public class OrderItem {
    @JsonIgnore
    private Order order;
}

public class Delivery {
    @JsonIgnore
    private Order order;
}

여기선 Order를 참조하는 Member, OrderItem, Delivery 엔티티에 작성해줬다.

그런데 다시 요청을 보내면 다음과 같은 에러가 발생한다.

image

지연로딩인 필드들은 DB에 쿼리를 날려서 가져오지 않고 Hibernate가 프록시 객체(ByteBuddyInterceptor)를 생성해서 넣어주고 나중에 지연로딩인 필드에 직접 접근할 때 DB에 접근해서 값을 가져와 채워준다.(프록시 초기화라고 한다)

jackson 라이브러리가 프록시 객체를 처리할 수 없어서 에러가 발생했는데
이를 처리해주는 hibernate5module 라이브러리를 추가해주자.

// 지연로딩 필드(프록시 객체)에 대해 json에서 제외해줌
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5-jakarta'

라이브러리를 추가했으면 스프링 빈에 hibernate5module을 등록하자.

@SpringBootApplication
public class JpashopApplication {

	public static void main(String[] args) {
		SpringApplication.run(JpashopApplication.class, args);
	}

	// 스프링 빈에 Hibernate5JakartaModule 등록
	@Bean
	Hibernate5JakartaModule hibernate5JakartaModule(){
		return new Hibernate5JakartaModule();
	}
}

그러면 다음과 같이 요청이 성공한 걸 볼 수 있다.

image

지연로딩은 아직 DB에서 조회한게 아니기 때문에 jackson 라이브러리가 json으로 전달할 때 hibernate5module이 다 무시하도록 되어있어서 null로 반환한다.

강제 Lazy 로딩 방법 1

다음과 같이 설정하면 지연로딩인 필드를 전부 가져올 수 있다.

// 스프링 빈에 Hibernate5JakartaModule 등록
@Bean
Hibernate5JakartaModule hibernate5JakartaModule(){
    Hibernate5JakartaModule hibernate5JakartaModule = new Hibernate5JakartaModule();
    // json 생성하는 시점에 강제 Lazy 로딩
    hibernate5JakartaModule.configure(Hibernate5JakartaModule.Feature.FORCE_LAZY_LOADING, true);
    return hibernate5JakartaModule;
}

image

이렇게 엔티티를 그대로 노출하게 되면 엔티티가 변경될 때 API 스펙이 다 바뀌게 된다.

그리고 API 스펙상 필요없는 orderItem 필드도 가져와서 필요없는 쿼리가 다 날라가게되서 성능상 문제가 발생한다.

강제 Lazy 로딩 방법 2

엔티티를 모두 노출시키지않고 원하는 필드만 노출시킬 수 있다.

hibernate5module의 FORCE_LAZY_LOADING 옵션을 주석처리하고 다음 코드를 작성하자.

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
    private final OrderRepository orderRepository;

    @GetMapping("/api/v1/simple-orders")
    public List<Order> ordersV1(){
        List<Order> all = orderRepository.findAllByString(new OrderSearch());

        // 강제 Lazy 로딩
        for (Order order : all) {
            // 프록시객체를 가져올 때는 DB에 쿼리가 날라가지 않음
            // 프록시객체.getter() -> JPA가 쿼리를 날려서 실제 값을 가져옴
            order.getMember().getName();        // Member Lazy 강제 초기화
            order.getDelivery().getAddress();   // Delivery Lazy 강제 초기화
        }

        // 양방향 연관관계에서 문제 발생! order가 member를 부르고 member가 order를 부름, 무한루프...
        // Order를 참조하는 엔티티의 필드에 @JsonIgnore 어노테이션을 작성해야함
        return all;
    }
}

그러면 다음과 같이 내가 원하는 member와 delivery 필드만 가져온 것을 볼 수 있다.

image

주의!
지연로딩(LAZY)을 피하기 위해 즉시로딩(EARGR)으로 설정하면 안된다.
JPQL로 조회만 하여도 나머지가 EAGER로 설정되어있으면 모두 쿼리를 다 날려서 N+1 문제가 발생할 수 있고 다른 API에서 필요없는 정보를 항상 가져오기 때문에 성능 최적화를 할 수 있는 여지가 없어진다.

간단한 주문 조회 V2: 엔티티를 DTO로 변환

OrderSimpleApiController - 추가

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
    private final OrderRepository orderRepository;

    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> ordersV2(){
        // N+1의 문제 -> 1번째 쿼리의 결과로 N번의 쿼리가 추가 실행되는 문제
        // 영속성 컨텍스트에 엔티티가 있는 경우에는 쿼리를 생략할 수 있음
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<SimpleOrderDto> result = orders.stream()
                // DTO로 변환되면서 LAZY 초기화 발생
                .map(o -> new SimpleOrderDto(o))
                .collect(Collectors.toList());
        return result;
    }

    @Data
    // API 스펙을 명확히 정의
    static class SimpleOrderDto{
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;

        public SimpleOrderDto(Order order){
            this.orderId = order.getId();
            this.name = order.getMember().getName();    // LAZY 초기화: 영속성 컨텍스트가 id 값으로 찾고 없으면 db쿼리 날림
            this.orderDate = order.getOrderDate();
            this.orderStatus = order.getStatus();
            this.address = order.getDelivery().getAddress();    // LAZY 초기화
        }
    }
}

다음과 같이 엔티티가 dto로 변환되어 반환된 것을 볼 수 있다.

image

하지만 v1과 v2 모두 공통적인 문제가 있는데 LAZY 로딩으로 인한 데이터베이스 쿼리가 너무 많이 생겨버린다는 것이다.

먼저 ORDER를 조회한 다음에 MEMBER와 DELIVERY도 조회하기 때문에 ORDER 조회 결과 하나당 MEMBER, DELIVERY를 한 번 조회해서 ORDER(1) + MEMBER(2) + DELIVERY(2) = 총 5번의 쿼리가 생성된다.
만약 영속성 컨텍스트에 엔티티가 있다면 쿼리 생략이 가능하다.

이런 지연로딩의 문제는 패치조인을 통해 최적화할 수 있다.

간단한 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화

OrderSimpleApiController - 추가

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
    private final OrderRepository orderRepository;

    @GetMapping("/api/v3/simple-orders")
    public List<SimpleOrderDto> ordersV3(){
        // 패치조인을 이용한 N+1 문제 해결
        List<Order> orders =  orderRepository.findAllWithMemberDelivery();
        List<SimpleOrderDto> result = orders.stream()
                .map(SimpleOrderDto::new)
                .collect(Collectors.toList());
        return result;
    }
}

OrderRepository - 추가 코드

@Repository
// Lombok: final 필드 생성자
@RequiredArgsConstructor
public class OrderRepository {
    // 엔티티 매니저 주입
    private final EntityManager em;

    // 패치조인 활용
    public List<Order> findAllWithMemberDelivery() {
        // order를 가져올 때 한번에 member와 delivery도 가져옴 (join과 동시에 select)
        // LAZY 옵션을 다 무시하고 값을 다 채워서 가져옴
        return em.createQuery("select o from Order o " +
                "join fetch o.member m " +
                "join fetch o.delivery d ", Order.class)
                .getResultList();
    }
}

실무에서 JPA의 성능 문제의 90%는 N+1 문제이다.
기본적으로 LAZY로 깔고 필요한 것만 패치조인으로 DB에서 한방에 가져오면 대부분의 성능문제가 해결된다.

다음과 같이 v2와 v3는 결과적으로 같지만 쿼리가 다르다.

image

image

v2는 쿼리가 5번 실행되지만 v3는 쿼리가 한 번만 실행된다!

fetch join을 통해 쿼리 한 번에 다 들고와서 LAZY 로딩 자체가 일어나지 않는다.

간단한 주문 조회 V4: JPA에서 DTO로 바로 조회

OrderSimpleApiController - 추가

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
    private final OrderRepository orderRepository;
    private final OrderSimpleQueryRepository orderSimpleQueryRepository;

    @GetMapping("/api/v4/simple-orders")
    public List<OrderSimpleQueryDto> ordersV4(){
        // 엔티티를 조회해서 엔티티를 DTO로 변환하지 않고
        // JPA에서 DTO로 바로 조회
        return orderSimpleQueryRepository.findOrderDtos();
    }
}

OrderSimpleQueryRepository 조회 전용 리포지토리

@Repository
@RequiredArgsConstructor
// 성능 최적화를 위한 쿼리용 리포지토리
// 리포지토리는 순수한 엔티티를 조회하는데 쓰임
// 화면에 의존적인 로직은 리포지토리와 분리
public class OrderSimpleQueryRepository {
    private final EntityManager em;

    public List<OrderSimpleQueryDto> findOrderDtos() {
        // DTO로 반환하기 위해선 new 연산자을 써야함
        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();
    }
}

OrderSimpleQueryDto 리포지토리에서 DTO 직접 조회

@Data
// 컨트롤러에서 DTO를 정의하면 리포지토리에서 컨트롤러를 의존하게 됨
// 리포지토리에서 컨트롤러로 의존가능성을 없애기 위해 외부 클래스로 정의
public class OrderSimpleQueryDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    public OrderSimpleQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address){
        this.orderId = orderId;
        this.name = name;    // LAZY 초기화: 영속성 컨텍스트가 id 값으로 찾고 없으면 db쿼리 날림
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;    // LAZY 초기화
    }
}

v4는 진짜 필요한 것들만 join으로 select해서 가져온다.

하지만 v3와 v4는 각자 장단점이 있어 어느게 더 좋다고 우열을 가리기 힘들다.

v3는 fetch join으로 원하는 것만 select 한 것이다. 엔티티를 가져와 비즈니스 로직에서 사용해서 데이터를 변경할 수 있고 다양한 API에서 원하는 DTO로 변환하여 사용이 가능하다.

v4는 원하는 것만 쿼리문을 통해 DTO로 가져온 것이다. 엔티티가 아니라 DTO로 가져와서 데이터를 변경할 수 없고 해당 API에서만 사용이 가능해 재활용이 불가능하다. 그리고 리포지토리에 API 스펙에 맞춘 코드가 들어가게되어 논리적인 계층이 깨지고 화면에 의존적이게 된다. 그래서 API 스펙이 바뀌면 리포지토리도 바꿔야한다. 하지만 v3보다 조금 더 성능 최적화 면에서 낫다.

* select 문은 성능에 큰 영향을 미치지 않고 from 절의 join이나 where 절의 조건들 때문에 성능 문제가 생긴다. API의 트래픽이 크고 조회할 필드들이 너무 많을 때는 최적화를 고려를 해봐야한다.

쿼리 방식 선택 권장 순서

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

4. API 개발 고급 - 컬렉션 조회 최적화

ManyToOne 이나 OneToOne 관계에서는 fetch join을 통해 쉽게 문제 해결이 가능했지만
일대다 조회(컬렉션 조회)의 경우 DB 입장에서는 데이터가 뻥튀기되어 성능을 최적화하기 어렵다.

주문 조회 V1: 엔티티 직접 노출

Order를 조회할 때, OrderItem과 Item을 추가로 조회하자.

@RestController
@RequiredArgsConstructor
public class OrderApiController {
    private final OrderRepository orderRepository;

    @GetMapping("api/v1/orders")
    public List<Order> ordersV1(){
        List<Order> all = orderRepository.findAllByString(new OrderSearch());

        // iter 이용
        // 강제 Lazy 로딩
        for(Order order : all){
            order.getMember().getName();
            order.getDelivery().getAddress();
            // OrderItem Lazy 강제 초기화
            List<OrderItem> orderItems = order.getOrderItems();
            // lambda 이용
            // Item Lazy 강제 초기화
            orderItems.stream().forEach(o -> o.getItem().getName());
        }

        return all;
    }
}

엔티티를 직접 노출하기 때문에 가급적이면 사용하지 않는다.

주문 조회 V2: 엔티티를 DTO로 변환

@RestController
@RequiredArgsConstructor
public class OrderApiController {
    private final OrderRepository orderRepository;

    @GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2(){
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());
        return result;
    }

    @Getter
    static class OrderDto{
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        // 단순히 DTO로 엔티티를 래핑해서 보내는 것이 아닌 엔티티에 대한 의존을 완전히 끊어야 함
        // 나중에 OrderItem이 수정되면 API 스펙도 다 바뀌게 되기 때문에 OrderItem도 DTO로 바꿔서 반환해야 함
        private List<OrderItem> orderItems;

        public OrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
            // 프록시 초기화
            orderItems.stream().forEach(o -> o.getItem().getName());
            orderItems = order.getOrderItems();
        }
    }
}

OrderItem 엔티티를 그대로 반환하게 되면 엔티티가 외부에 노출이 되고
만약 엔티티가 변경되면 API 스펙이 바뀌기 때문에 OrderItem도 Dto로 변환해서 반환해야한다.

@Getter
static class OrderDto{
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;
    // 단순히 DTO로 엔티티를 래핑해서 보내는 것이 아닌 엔티티에 대한 의존을 완전히 끊어야 함
    // 나중에 OrderItem이 수정되면 API 스펙도 다 바뀌게 되기 때문에 OrderItem도 DTO로 바꿔서 반환해야 함
    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.stream().forEach(o -> o.getItem().getName());
//            orderItems = order.getOrderItems();
        // DTO로 변환
        orderItems = order.getOrderItems().stream()
                .map(orderItem -> new OrderItemDto(orderItem))
                .collect(Collectors.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();
    }
}

외부로는 OrderDTO안에 OrderItemDto로 래핑해서 나가기 때문에 문제가 깔끔하게 해결된다.

하지만 컬렉션을 조회하면 쿼리가 많이 나가기 때문에 페치 조인을 통해 최적화해보자.

주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화

@RestController
@RequiredArgsConstructor
public class OrderApiController {
    private final OrderRepository orderRepository;

    @GetMapping("/api/v3/orders")
    public List<OrderDto> ordersV3(){
        List<Order> orders = orderRepository.findAllWithItem();
        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());
        return result;
    }
}
@Repository
@RequiredArgsConstructor
public class OrderRepository {
    // 엔티티 매니저 주입
    private final EntityManager em;

    public List<Order> findAllWithItem() {
        // hibernate 버전 6부터는 distinct 명령어가 자동 수행되서 작성하지 않아도 결과가 2개만 나온다
        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();
    }
}

Order와 OrderItem을 조인하는데 가져오는 데이터 개수가 order : orderItem = 2 : 4 이므로 order 데이터가 4개로 뻥튀기가 되어버린다. (order가 2개 있고 각각 orderItem을 2개씩 갖고있기 때문에 조인을 하게되면 order가 orderItem 갯수에 맞춰 쿼리 결과가 4줄이 된다)

하지만 hibernate는 뻥튀기된지 모르기 때문에 distinct 연산자를 이용해서 중복을 제거해줘야 한다.
(hibernate 버전 6부터 자동 수행된다)

  • DB 쿼리에서 distinct는 data row가 완전히 같아야 중복을 제거해주는데 지금 상황에서는 그렇지 않기 때문에 적용이 되지 않는다.
  • JPA에서는 distinct가 있으면 애플리케이션에 데이터를 가져와서 같은 id 값을 가진 엔티티가 있으면 중복을 제거하고 컬렉션에 하나만 담아서 반환해준다.

컬렉션 페치 조인은 한가지 단점이 있는데 바로 페이징이 불가능하다는 것이다.

  • 페이징을 하게되면 하이버네이트가 WARN 로그로 firstResult/maxResult specified with collection fetch; applying in memory!를 출력한다.
    위 로그는 컬렉션 데이터를 다 가져와 메모리 상에서 페이징 처리를 진행한다는 의미이다.
    그러면 out of memory 와 같은 에러가 발생할 확률이 높다.
  • 일대다 조인을 해버리는 순간, 페이징 기준이 일대’다’ 기준으로 뻥튀기가 되어버려서 페이징이 불가능해진다.

* 컬렉션 페치 조인은 1개만 사용할 수 있다. 컬렉션 둘 이상에 페치 조인을 하게되면 데이터가 너무 많이 빵튀기되어(1:N*M) JPA가 기준을 찾지 못해 데이터를 맞추지 못할 수 있다.

주문 조회 V3.1: 엔티티를 DTO로 변환 - 페이징과 한계 돌파

  1. XToOne 관계는 모두 페치조인한다.
  2. 컬렉션은 지연 로딩으로 조회한다.
  3. 지연로딩 성능 최적화를 위해 hibernate.default_batch_size, @BatchSize를 사용한다.
@RestController
@RequiredArgsConstructor
public class OrderApiController {
    private final OrderRepository orderRepository;

    @GetMapping("/api/v3.1/orders")
    public List<OrderDto> ordersV3_page(@RequestParam(value = "offset", defaultValue = "0") int offset,
                                        @RequestParam(value = "limit", defaultValue = "100") int limit){
        List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());
        return result;
    }
}
@Repository
@RequiredArgsConstructor
public class OrderRepository {
    // 엔티티 매니저 주입
    private final EntityManager em;

    // 페이징 쿼리
    public List<Order> findAllWithMemberDelivery(int offset, int limit) {
        // order를 가져올 때 한번에 member와 delivery도 가져옴 (join과 동시에 select)
        // LAZY 옵션을 다 무시하고 값을 다 채워서 가져옴
        return em.createQuery("select o from Order o " +
                        "join fetch o.member m " +
                        "join fetch o.delivery d ", Order.class)
                .setFirstResult(offset)
                .setMaxResults(limit)
                .getResultList();
    }
}

페이징을 위해 application.yml 파일에 default_batch_fetch_size 속성을 추가하자.
이렇게 하면 프로젝트 전체에 전반적으로 global하게 적용되고 만약 디테일하게 적용하고 싶으면 컬렉션이나 엔티티에 @BatchSize 어노테이션을 적용하면 된다.

spring:
  jpa:
    properties:
      hibernate:
        # default_batch_fetch_size: in query의 개수
        default_batch_fetch_size: 100

이렇게 설명하면 컬렉션과 관련된 것은 in query로 데이터를 한번에 가져와버린다.

  • v3 같은 경우 페치 조인을 통해 쿼리로 한방에 가져오지만 중복된 데이터가 많고 db에서 애플리케이션으로 데이터를 다 전송해서 데이터 전송량 자체가 많아진다. 일대다 조인을 통해 데이터가 뻥튀기되어 메모리 용량을 차지하게 된다.

  • v3.1은 조인 없이 테이블 단위로 in 쿼리를 통해 가져오기 때문에 db에서 애플리케이션으로 중복이 없는 데이터가 전송되고 데이터 전송량 자체도 줄게된다. 또한 페이징도 가능하다.

주문 조회 V4: JPA에서 DTO 직접 조회

OrderApiController에 추가

@RestController
@RequiredArgsConstructor
public class OrderApiController {
    private final OrderRepository orderRepository;
    private final OrderQueryRepository orderQueryRepository;

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

OrderQueryRepository

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
    private final EntityManager em;

    // orderApiController 안의 orderDto를 참조하게 되면 리포지토리가 컨트롤러를 참조하게 되어 의존관계가 순환됨
    public List<OrderQueryDto> findOrderQueryDtos() {
        // 루트 쿼리: 1번
        List<OrderQueryDto> result = findOrders();
        result.forEach(o -> {
            // 컬렉션 쿼리: N번
            List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
            o.setOrderItems(orderItems);
        });
        return result;
    }

    // XToMany 관계
    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();
    }

    // XToOne 관계
    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();
    }
}

OrderQueryDto

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

    // orderItems는 생성자에서 제외
    // jpql로 new 연산자를 이용해 컬렉션을 바로 넣을 수 없기 때문
    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;
//        this.orderItems = orderItems;
    }
}

OrderItemQueryDto

@Data
public class OrderItemQueryDto {
    @JsonIgnore
    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;
    }
}

쿼리는 findOrders() 한 번, findOrderItems() 각각 한 번씩 총 3번 실행된다.
XToOne 관계는 조인으로 가져오고 XToMany 관계는 별도의 메서드로 조회한다.

주문 조회 V5: JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화

OrderApiController에 추가

@RestController
@RequiredArgsConstructor
public class OrderApiController {
    private final OrderRepository orderRepository;
    private final OrderQueryRepository orderQueryRepository;
    @GetMapping("/api/v5/orders")
    public List<OrderQueryDto> ordersV5(){
        return orderQueryRepository.findAllByDto_optimization();
    }
}

OrderQueryRepository에 추가

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
    private final EntityManager em;

    // in 쿼리로 최적화
    public List<OrderQueryDto> findAllByDto_optimization() {
        // 루트 쿼리: 1번
        List<OrderQueryDto> result = findOrders();
        // 컬렉션 쿼리: 1번
        Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));
        result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
        return result;
    }

    private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
        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();

        // Collectors.groupingBy()메서드로 정렬하여 Map으로 변환
        Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
                .collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
        return orderItemMap;
    }

    private static List<Long> toOrderIds(List<OrderQueryDto> result) {
        List<Long> orderIds = result.stream()
                .map(o -> o.getOrderId())
                .collect(Collectors.toList());
        return orderIds;
    }
}

쿼리는 findOrders() 한 번, findOrderItemMap() 한 번 총 2번 실행된다.
XToOne 관계는 조인으로 가져오고 XToMany 관계는 in 절로 쿼리 한 번에 가져온다.

주문 조회 V6: JPA에서 DTO로 직접 조회, 플랫 데이터 최적화

OrderApiController에 추가

@RestController
@RequiredArgsConstructor
public class OrderApiController {
    private final OrderRepository orderRepository;
    private final OrderQueryRepository orderQueryRepository;
    @GetMapping("/api/v6/orders")
    public List<OrderQueryDto> ordersV6(){
        List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();

        // 직접 중복을 제거
        // OrderFlatDto에서 OrderQueryDto와 OrderItemQueryDto로 분리하여 OrderQueryDto로 변환
        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());
    }
}

OrderQueryDto에 생성자 추가

@Data
// groupingBy 메서드를 호출할 때 orderId를 기준으로 하나로 묶어줌
@EqualsAndHashCode(of = "orderId")
public class OrderQueryDto {
    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;
    }
}

OrderQueryRepository에 추가

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
    private final EntityManager em;
    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();
    }
}

OrderFlatDto

@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;
    }
}

쿼리 한 번에 다 가지고 온다는 장점이 있지만 DB에서 조인으로 가지고 와서 중복 데이터가 많고 애플리케이션에서 추가 작업을 해줘야된다. 또한 페이징이 불가능하다는 단점이 있다.

5. API 개발 고급 - 실무 필수 최적화

OSIV와 성능 최적화

OSIV: Open Session In View

OSIV ON

image

spring.jpa.open-in-view : true 기본값

WARN 12016 --- [ restartedMain] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning

애플리케이션 시작 시점에 위와 같은 WARN 에러가 발생하는데 OSIV가 기본적으로 켜져있기 때문이다.

서비스 계층에서 트랜잭션이 시작될 때, JPA가 영속성 컨텍스트에서 DB 커넥션을 가져온다.
OSIV가 켜져있으면 트랜잭션이 끝나도 API나 VIEW가 유저에게 반환이 될 때까지 영속성 컨택스트가 유지된다. LAZY 로딩을 위해서 프록시 객체를 초기화하기 위해 영속성 컨텍스트가 DB 커넥션을 가지고 있어야한다.

하지만 이러면 DB 커넥션을 너무 오래동안 가지고 있어서 실시간 트래픽이 많은 애플리케이션의 경우 DB 커넥션이 모자랄 수 있다.

OSIV OFF

image

OSIV를 끄면 트랜잭션을 종료할 때 영속성 컨텍스트를 닫고, 데이터베이스 커넥션를 반환한다.
실시간 트래픽이 많을 경우 DB 커넥션을 유연하게 사용할 수 있지만 모든 지연로딩을 트랜잭션 안에서 처리해야 하고 View에서는 지연로딩이 동작하지 않는다. 결론적으로 트랜잭션이 끝나기 전에 지연 로딩을 강제로 초기화해야 한다.

커맨드와 쿼리의 분리

보통 비즈니스 로직은 엔티티를 등록하거나 수정, 삭제하는 것이기 때문에 성능상 문제가 안 된지만 조회의 경우 성능이슈가 많이 발생한다. 복잡한 화면을 출력하기 위한 쿼리는 화면에 맞추어 성능을 최적화하지만 그 복잡성에 비해 핵심 비즈니스에 큰 영향을 주지 않는다.

따라서 크고 복잡한 애플리케이션을 만든다면 핵심 비즈니스 로직과 쿼리를 명확하게 분리하여 개발하는 것이 유지보수 관점에서 좋다. 그래서 보통 서비스 계층에서 트랜잭션을 유지하고 비즈니스 로직 서비스 계층과 쿼리 서비스 계층으로 분리하여 개발한다.

  • OrderService
    • OrderService: 핵심 비즈니스 로직
    • OrderQueryService: 화면이나 API에 맞춘 서비스(주로 읽기 전용 트랜잭션 사용)

보통 실시간 트래픽이 많은 서비스의 경우 OSIV를 끄고, ADMIN 시스템의 경우 OSIV를 키고 개발한다.

다음으로

Spring 데이터 JPA 소개

스프링 데이터 JPA는 JPA를 사용할 때 반복하는 코드를 자동화 해준다. 이미 라이브러리는 포함되어 있다.
기존의 MemberRepository 를 스프링 데이터 JPA로 변경해보자.

스프링 데이터 JPA 적용 MemberRepository

public interface MemberRepository extends JpaRepository<Member, Long> {
    // select m from Member m where m.name = :name
    List<Member> findByName(String name);
}

리포지토리를 interface로 만들고 JpaRepository<T, ID> 를 extends하면 된다.
interface라 구현체를 스프링 빈에 injection 해줘야 할 것 같지만 스프링 Data JPA가 알아서 다 만들어서 넣어준다.

기존 리포지토리를 변경해도 save, findAll 메서드는 기본적으로 제공한다.
findOne 메서드의 경우 반환타입이 Optional인 findById 메서드로 제공을 해준다.
findByName 메서드의 경우 interface에 선언만 해줘도 Spring Data JPA가 알아서 JPQL을 만들어서 구현해준다.

QueryDSL 소개

QueryDSL은 JPQL를 자바 코드로 작성할 수 있게 해준다.
JPA 스펙 표준 중에 Criteria가 있지만 어떤 JPQL이 만들어지는지 한 눈에 안 들어와서 잘 사용하지 않는다.

실무에서는 복잡한 동적 쿼리를 작성하게 되는데 이때, Querydsl을 사용하면 높은 개발 생산성을 얻으면서도 쿼리 오류를 컴파일 시점에 빠르게 잡을 수 있다. 또한 자바 코드로 구현해서 코드 재사용이 가능하고 자동완성도 지원하며 Dto를 조회하는 jpql 쿼리도 줄여준다.

build.gradle에 querydsl 추가

plugins {
	id 'java'
	// 스프링부트 플러그인은 라이브러리들에 대한 디펜던시 버전 관리를 해줌
	id 'org.springframework.boot' version '3.0.6'
	id 'io.spring.dependency-management' version '1.1.0'
}

group = 'jpabook'
version = '0.0.1-SNAPSHOT'
// 자바 버전
sourceCompatibility = '17'

configurations {
	compileOnly {
		// 롬복
		extendsFrom annotationProcessor
	}
}

// 라이브러리를 받는 공간
repositories {
	mavenCentral()
}

// 그래들은 기본적으로 의존관계가 필요한 라이브러리들을 전부 연결하여 가져옴
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	// devtools: 개발할 때 유용한 기능들이 많은 라이브러리(캐쉬, 리로딩 등)
	implementation 'org.springframework.boot:spring-boot-devtools'
	// 지연로딩 필드(프록시 객체)에 대해 json에서 제외해줌
	implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5-jakarta'
	// 쿼리 파라미터 로그 외부 라이브러리
	implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.8.0'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	// JUnit4 추가
	testImplementation("org.junit.vintage:junit-vintage-engine") {
		exclude group: "org.hamcrest", module: "hamcrest-core"
	}
	// Querydsl 추가
	implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
	annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
	annotationProcessor "jakarta.annotation:jakarta.annotation-api"
	annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

tasks.named('test') {
	useJUnitPlatform()
}

// Querydsl 추가
clean {
	delete file('src/main/generated')
}

build.gradle을 위와 같이 작성하고 Gradle > Tasks > other > compleJava를 실행하면 build/generated 하위 폴더에 Q클래스들이 생성된다.

Querydsl로 처리

@Repository
public class OrderRepository {
    // 엔티티 매니저 주입
    private final EntityManager em;
    private final JPAQueryFactory query;

    public OrderRepository(EntityManager em) {
        this.em = em;
        this.query = new JPAQueryFactory(em);
    }

    // QueryDSL 활용
    public List<Order> findAll(OrderSearch orderSearch){
        // Q클래스 객체 생성: 스태틱 변수
        // 스태틱 임포트
//        QOrder order = QOrder.order;
//        QMember member = QMember.member;

        // 아래의 query가 JPQL로 변환되어 실행됨
        // 컴파일 시점에서 문법 오류를 확인할 수 있음
        return query
                .select(order)
                .from(order)
                .join(order.member, member)
                .where(statusEq(orderSearch.getOrderStatus()), nameLike(orderSearch.getMemberName()))
                .limit(1000)
                .fetch();
    }
}

참조

태그:

카테고리:

업데이트:

댓글남기기