Blog

[Spring][258] JPA → JPQL → QueryDSL 변화과정

Category
Author
Tags
PinOnMain
1 more property

공통 - Controller, 검색 키워드와 페이징 처리를 위한 페이저블 객체

우선 공통적으로 적용된 컨트롤러 코드는 다음과 같습니다.
@Controller @RequiredArgsConstructor public class BooksViewController { private final AdminCategoriesService adminCategoriesService; private final AdminBooksService adminBooksService; @GetMapping("/admin/booksManage") public String adminBooksManageView( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size, // 현재 페이지, 페이지 크기 @RequestParam(defaultValue = "bookId") String sort, @RequestParam(defaultValue = "ASC") String direction, // 정렬 기준, 정렬 방향 @RequestParam(value = "keyword", required = false) String keyword, // 검색 키워드 @AuthenticationPrincipal UserDetailsImpl userDetails, Model model) { long startTime = System.currentTimeMillis();//실행시간 측정 Sort.Direction sortDirection = Sort.Direction.fromString(direction); PageRequest pageRequest = PageRequest.of(page, size, sortDirection, sort); BooksPageResponseDto booksPageResponseDto = adminBooksService.findBooksWithPaginationAndSearching(userDetails.getUser(), keyword, pageRequest); model.addAttribute("books", booksPageResponseDto.getAdminBooksResponseDtos()); model.addAttribute("currentPage", page); model.addAttribute("totalPages", booksPageResponseDto.getTotalPages()); long endTime = System.currentTimeMillis(); long durationTimeSec = endTime - startTime; System.out.println(durationTimeSec + "m/s"); // 실행시간 측정 return "admin/booksManage"; }
Java
복사
프론트엔드로부터 keyword 를 QueryString으로 …?keyword=abc와 같은 형태로 HTML폼으로부터 특정 값들을 추출 할 수 있으며, HTTP요청에 담아 컨트롤러로 문자열을 전달 할 수 있습니다.
@RequestParam(value = "keyword", required = false) String keyword, // 검색 키워드
Java
복사
페이지 처리를 위해서는 순수 JPA를 사용하게 되면 Total Count를 뽑아와서 현재 내가 몇번째 페이지인지를 계산해서 표시를 해주어야 합니다. 그런데 이런 계산을 모두 직접 계산하는 메소드, 변수를 직접 생성하고 매개변수로 Service에 전달하게 되면 코드가 복잡해지게 됩니다! 페이저블 객체를 사용하지 않는 경우 추가되어야 하는 코드는 다음과 같습니다.
// 직접 offset과 limit 계산 int offset = page * size; int limit = size; // 전체 페이지 수 계산 메서드 private int calculateTotalPages(long totalCount, int pageSize) { return (int) Math.ceil((double) totalCount / pageSize); }
Java
복사
BooksPageResponseDto booksPageResponseDto = adminBooksService.findBooksWithPaginationAndSearching(userDetails.getUser(), keyword, offset, limit, sortDirection, sort);
Java
복사
이 복잡한 코드를 해결해주기 위해 스프링 데이터 JPA는 페이징과 정렬을 표준화 해두었습니다.
현재 페이지, 페이지 크기, 정렬 기준, 정렬 방향을 HTTP요청으로 받아오고 pageable 객체를 통해 페이징 처리된 객체를 아래와 같이 생성할 수 있습니다.
PageRequest pageRequest = PageRequest.of(page, size, sortDirection, sort);
Java
복사
keyword, pageable 객체를 Service 계층에 필요한 데이터를 조회(READ)하는 비지니스 로직에 활용 될 수 있도록 매개변수로 전달하게 됩니다.
BooksPageResponseDto booksPageResponseDto = adminBooksService.findBooksWithPaginationAndSearching( userDetails.getUser(), keyword, pageRequest);
Java
복사

초기 MVP JPA - Service 계층에서의 구현 상태 코드

JPA를 활용한 도서 목록 검색 및 페이징 Service 계층의 코드는 다음과 같습니다. 복잡하지만, 단순하게 살펴보면 검색을 위한 keyword 와 페이징 처리를 위한 페이징 객체pageable 를 매개변수로 받은 뒤 Repository 계층에 구현된 findByBookNameContainingIgnoreCase라는 메소드에 전달하면서 쿼리문을 자동 생성하는 구조입니다. JPA에 의해 자동으로 생성되기 때문에 개발자는 쿼리문에 대한 제어권이 없는 상태입니다.
@Service @RequiredArgsConstructor @Transactional(readOnly = true) public class AdminBooksService { private final AdminBooksRepository adminBooksRepository; private final BookCategoryRepository bookCategoryRepository; // 초기 JPA 단계에서의 검색페이징 조회 기능 코드 public BooksPageResponseDto findBooksWithPaginationAndSearching(User loginUser, String keyword, Pageable pageable) { // 로그인한 사용자 관리자 확인 validateUserAuthority(loginUser); // 검색어가 있을 경우 검색 조건을 추가 if (StringUtils.hasText(keyword)) { Page<Book> booksPage = adminBooksRepository.findByBookNameContainingIgnoreCase(keyword, pageable); List<AdminBooksResponseDto> adminBooksResponseDtos = booksPage.getContent() .stream() .map(AdminBooksResponseDto::new) .collect(Collectors.toList()); return new BooksPageResponseDto(adminBooksResponseDtos, booksPage.getTotalPages()); } else { // 검색어가 없을 경우 전체 목록을 가져옵니다. Page<Book> booksPage = adminBooksRepository.findAll(pageable); List<AdminBooksResponseDto> adminBooksResponseDtos = booksPage.getContent() .stream() .map(AdminBooksResponseDto::new) .collect(Collectors.toList()); return new BooksPageResponseDto(adminBooksResponseDtos, booksPage.getTotalPages()); } } ... }
Java
복사

쿼리문 튜닝을 위한 JPQL - Service 계층에서의 구현 상태 코드

JPQLService 계층에서 구현하기 위해서는 JPA 로 구현되었던 위 Service 계층의 코드를 전체적으로 수정해야 합니다.
이전 JPA로 구현된 코드와 비교하여 쿼리문 튜닝을 위해 생각보다 많은 코드가 증가하는 것을 확인 할 수 있었습니다. 핵심적인 내용은 다음과 같습니다.
1.
엔티티 매니저 EntityManager 를 직접 주입해야 하며
2.
쿼리문 자체가 메소드에 포함되어 구분하기 어렵고 개발자가 실수할 가능성이 있습니다. String jpql = "SELECT b FROM book b WHERE 1 = 1"; → 컴파일러는 해당 구문을 단순히 문자열로만 보고 쿼리문의 실수를 정확하게 인지 하지 못하기 때문에 오류를 찾아내기 어렵습니다.
3.
검색과 페이징 관련 set 구문 또한 메소드 내에 함되어 비지니스 로직이 너무 무거워지는 경향이 있습니다.
@Service @RequiredArgsConstructor @Transactional(readOnly = true) public class AdminBooksService { private final AdminBooksRepository adminBooksRepository; private final BookCategoryRepository bookCategoryRepository; //JPQL 용 엔티티 매니저 @PersistenceContext private EntityManager entityManager; //검색페이징 조회 부분의 JPQL 구현 public BooksPageResponseDto findBooksWithPaginationAndSearching(User loginUser, String keyword, Pageable pageable) { // 로그인한 사용자 관리자 확인 validateUserAuthority(loginUser); // JPQL 쿼리 문자열 String jpql = "SELECT b FROM book b WHERE 1 = 1"; // 검색어가 주어진 경우, 도서명에 대한 검색 조건 추가 if (StringUtils.hasText(keyword)) jpql += " AND LOWER(b.bookName) LIKE LOWER(:keyword)"; // JPQL 쿼리 실행 TypedQuery<Book> query = entityManager.createQuery(jpql, Book.class); // 파라미터 바인딩 if (StringUtils.hasText(keyword)) query.setParameter("keyword", "%" + keyword + "%"); // 페이징 처리 query.setFirstResult((int) pageable.getOffset()); query.setMaxResults(pageable.getPageSize()); // 조회된 엔티티를 Dto로 변환 List<AdminBooksResponseDto> booksResponseDtos = query.getResultList().stream() .map(AdminBooksResponseDto::new) .toList(); // 전체 페이지 수 계산 int totalPages = (int) Math.ceil((double) getTotalBooksCount(keyword) / pageable.getPageSize()); // 결과를 포함한 응답 Dto 반환 return new BooksPageResponseDto(booksResponseDtos, totalPages); } //검색어에 따른 전체 도서 개수 조회 private Long getTotalBooksCount(String keyword) { String countJpql = "SELECT COUNT(b) FROM book b WHERE 1 = 1"; if (StringUtils.hasText(keyword)) countJpql += " AND LOWER(b.bookName) LIKE LOWER(:keyword)"; TypedQuery<Long> countQuery = entityManager.createQuery(countJpql, Long.class); if (StringUtils.hasText(keyword)) countQuery.setParameter("keyword", "%" + keyword + "%"); return countQuery.getSingleResult(); }
Java
복사

쿼리문 튜닝을 위한 JPQL - Repository 계층에서 @Query 어노테이션을 활용한 구현 상태 코드

위 Service 계층에서 메소드 자체를 변경하는 것 외에도 JPQL@Query라는 어노테이션으로 쿼리 튜닝을 구현하는 방법이 있습니다. 이는 위 Service 계층 구현보다 매우 간단하게 처리 할 수 있고 쿼리문 튜닝을 위한 코드량을 획기적으로 축소 시킬 수 있습니다.
우선 기본적으로 JPA 단계에서의 Service 계층의 메소드는 그대로 유지됩니다.
이후 Repository 계층에서 아래와 같은 어노테이션 코드가 추가됩니다.
@Repository public interface AdminBooksRepository extends JpaRepository<Book, Long>, QuerydslPredicateExecutor<Book> { // 초기 JPA 단계에서의 검색페이징 조회 기능 코드 //Page<Book> findByBookNameContainingIgnoreCase(String keyword, Pageable pageable); // JPA 단계 + JPQL 어노테이션 방식의 검색페이징 조회 기능 코드 @Query("SELECT b FROM book b WHERE LOWER(b.bookName) LIKE LOWER(CONCAT('%', :keyword, '%'))") Page<Book> findByBookNameContainingIgnoreCase(@Param("keyword") String keyword, Pageable pageable);
Java
복사
쿼리문을 @Query라는 어노테이션에 작성하게 되면서 DB로부터 해당 객체를 가져오는 로직에 위 쿼리문이 함께 실행 되는 것으로 볼 수 있습니다.
Service 계층에서 구현하던 방식보다 매우 간편하게 직접 쿼리문 튜닝을 할 수 있게 되었습니다.
하지만 여전히 컴파일러는 해당 쿼리문은 문자열로 보기 때문에 정확한 오류를 찾아내기 어렵습니다. 이는 개발자의 쿼리문 작성 실수는 컴파일러가 체크하지 못한다는 단점은 그대로 이어지고 있는 것입니다.

쿼리문 튜닝을 위한 QueryDSL - Service 계층에서 구현 상태 코드

하지만, 현재까지의 문제점
JPAJPQL쿼리문을 튜닝하고자 했습니다.
하지만, 아직도 raw 쿼리 자체를 사용하는것이 문제
→ 컴파일단계에서 이 쿼리문의 문제를 파악하지 못할 가능성 높습니다.
QueryDSL을 사용한 이유
QueryDSL은 자바 기반의 쿼리 빌더로, 쿼리 작성 시 가독성, 유지보수성, 타입 안정성 등을 강조하며 다양한 형태의 쿼리를 지원해주는 목표를 가지고 있는 기술입니다.
최초 JPA를 통해 쿼리문을 간단히 작성할 수 있었으며 JPQL을 통해 쿼리문을 튜닝 할 수 있었습니다. 하지만 쿼리문 튜닝을 위해 “select … “처럼 쿼리문을 직접 작성하는 상태가 되면서 점점 코드 가독성이 떨어지게 되었습니다. 이는 마치 다시 JDBC템플릿을 쓰는것처럼 과거로 돌아가고 있었습니다.
여기서 QueryDSL이 그 문제를 해결 할 수 있는 방안이었습니다.
위와 같은 JPQL 의 장점인 간편한 쿼리문 튜닝이라는 장점을 가지고 있으면서, 컴파일 단계에서의 개발자 실수를 발견 할 수 있는 보완점이 있는 상위호환 기술인 QueryDSL을 사용하게 되었습니다.
우선 QueryDSL을 적용한 Service 계층의 코드입니다.
@Service @RequiredArgsConstructor @Transactional(readOnly = true) public class AdminBooksService { private final AdminBooksRepository adminBooksRepository; private final BookCategoryRepository bookCategoryRepository; // 최종 QueryDSL(Q객체)을 활용한 검색페이징 조회 기능 코드 public BooksPageResponseDto findBooksWithPaginationAndSearching(User loginUser, String keyword, Pageable pageable) { // 로그인한 사용자 관리자 확인 validateUserAuthority(loginUser); QBook qBook = QBook.book; BooleanBuilder builder = new BooleanBuilder(); // 검색어가 있을 경우 검색 조건을 추가 if (StringUtils.hasText(keyword)) builder.and(qBook.bookName.containsIgnoreCase(keyword)); // 페이징된 엔티티를 Dto로 변환하여 반환 Page<Book> adminBooks = adminBooksRepository.findAll(builder, pageable); int totalPages = adminBooks.getTotalPages(); List<AdminBooksResponseDto> booksResponseDtos = adminBooks.stream().map(AdminBooksResponseDto::new).toList(); return new BooksPageResponseDto(booksResponseDtos, totalPages); } }
Java
복사
이전 코드들과 다르게 특징적인 부분은 QBook 이라는 부분과 Builder입니다.
QBook? Q-Class란?
QueryDSl로 개발을 하려면 Q-Class를 이용해 개발하게 됩니다. 왜 이런 클래스, 객체가 생성되는 것일까요?
엔티티 클래스의 메타 정보를 담고 있는 클래스로, Querydsl은 이를 이용하여 타입 안정성(Type safe)을 보장하면서 쿼리를 작성할 수 있게 됩니다.
QClass는 엔티티 클래스와 대응되며  엔티티의 속성을 나타내고 있습니다. 이러한 QClass를 사용하여 쿼리를 작성하면 엔티티 속성을 직접 참조하고 조합하여 쿼리를 구성할 수 있습니다. QClass를 사용하면 컴파일 시점에 오류를 확인할 수 있고, IDE의 자동완성 기능을 활용하여 쿼리 작성을 보다 편리하게 할 수 있습니다.
굳이 엔티티 클래스 대신 Q클래스를 만들어서 사용하는 이유는 무엇일까요?
Q클래스는 QueryDSL이 컴파일 시에 엔티티의 메타 모델을 생성하는 데 사용되는 클래스입니다. 엔티티 클래스를 기반으로 생성되기는 하지만 엔티티의 복사본이라기보다는 엔티티의 메타 정보를 담고 있는 도우미 클래스라고 볼 수 있습니다. 엔티티 클래스의 복사본이라기보다는 엔티티 클래스를 토대로 쿼리를 작성하기 위한 도구로 이해하는 것이 적절합니다.
QueryDSL의 핵심 기능인 쿼리 빌더
쿼리 빌더의 장점과 특징이 해당 기술을 선택한 주요 이유라고 볼 수 있습니다.
가독성과 유지보수성: 쿼리가 복잡해지거나 동적으로 생성되어야 할 때, 빌더를 사용하면 코드의 가독성이 향상되고 유지보수에 유리해짐
각 부분이 메서드 호출로 표현되기 때문에, 쿼리의 의도를 쉽게 이해 가능
동적 쿼리 생성: 쿼리 빌더를 사용하면 동적으로 쿼리를 생성하기가 편리,
예를 들어, 사용자가 선택한 조건에 따라서 쿼리에 조건을 추가하거나 변경하는 것이 쉽고, 이는 런타임에 유연하게 쿼리를 조작할 수 있도록 도움
타입 안정성: 쿼리 빌더는 자바나 다른 언어의 정적 타입 시스템을 활용하여 타입 안정성을 보장
컴파일 타임에 오류를 잡아내어 런타임 오류를 방지하는 데 도움을 줍니다.
코드 완성 및 인텔리센스 활용: 대부분의 쿼리 빌더는 IDE의 코드 완성 및 인텔리센스를 지원합니다. 이는 개발자에게 쿼리 작성 시 도움을 주어 개발 생산성을 높여줍니다.
데이터베이스 추상화: 쿼리 빌더는 종종 ORM(Object-Relational Mapping) 프레임워크(우리의 JPA)와 연동하여 사용됩니다. ORM은 데이터베이스와 애플리케이션 객체 간의 매핑을 담당하고, 쿼리 빌더는 이러한 ORM과 함께 사용되어 객체 지향 코드와 데이터베이스 간의 상호 작용을 추상화하는 데 도움을 줍니다.
QuerydslPredicateExecutor 인터페이스
Repository 계층에서는 특별한 코드가 필요하지 않습니다.
단순히 JPA의 동적 쿼리생성에 + QuerydslPredicateExecutor 인터페이스를 상속받아 각종 추가적인 조건 기능을 부여하게 됩니다.
@Repository public interface AdminBooksRepository extends JpaRepository<Book, Long>, QuerydslPredicateExecutor<Book> { }
Java
복사
Repository가 상속받는 인터페이스 QuerydslPredicateExecutor 에는 내부적으로 Predicate라는 검색 조건을 나타내는 구현체가 있습니다.
Predicate는 일종의 필터 역할을 하며, 쿼리에 적용할 다양한 조건을 표현해줍니다.
여기에 JPA 심플메소드만으로도 Predicate가 적용된 튜닝 쿼리가 생성되어 Repository를 코드를 간략하게 만들어줍니다.