Spring Data JPA 활용

사공광열·2023년 8월 1일
1

SpringBoot

목록 보기
7/8

프로젝트 생성


JPQL

JPQL은 JPA Query Language의 줄임말이고 JPA에서 사용할 수 있는 쿼리입니다. JPQL은 SQL과 매우 비슷합니다. 차이는 SQL은 테이블이나 칼럼의 이름을 사용하는 것과 달리 JPQL은 엔티티 객체를 대상으로 수행하는 쿼리이기 때문에 매핑된 엔티티의 이름과 필드의 이름을 사용합니다.

쿼리 메서드의 생성

쿼리 메서드는 크게 동작을 결정하는 주제(Subject)와 서술어(Predicate)로 구분합니다. 'find・・・By', 'exists・・・By'와 같은 키워드로 쿼리의 주제를 정하며 'By'는 서술어의 시작을 나타내는 구분자 역할을 합니다.
서술어 부분은 검색 및 정렬 조건을 지정하는 영역입니다. 기본적으로 엔티티의 속성으로 정의할 수 있고, AND나 OR를 사용해 조건을 확장하는 것도 가능합니다.

리포지토리의 쿼리 메서드 생성 예

// (리턴타입) + {주제 + 서술어(속성)} 구조의 메서드
List<person> findByLastnameAndEmail(String lastName, String email);

쿼리 메서드의 주제 키워드

  • find...By
  • read・・・By
  • get・・・By
  • query・・・By
  • search・・・By
  • stream・・・By
조회하는 기능을 수행하는 키워드입니다. ・・・으로 표시한 영역에는 도메인(엔티티)을 표할 수 있습니다. 그러나 리포지토리에서 이미 도메인을 설정한 후에 메서드를 사용하기 때문에 중복으로 판단해 생략하기도 합니다. 리턴 타입으로는 Collection이나 Stream에 속한 하위 타입을 설정할 수 있습니다.

ProductRepository에 쿼리 메서드를 작성합니다.

find...By

// find...By
    
    Optional<Product> findByNumber(Long number);
    List<Product> findAllByName(String name);
    Product queryByNumber(Long number);

exists...By

특정 데이터가 존재하는지 확인하는 키워드입니다. 리턴 타입으로 boolean 타입을 사용합니다.

//exists...By
    boolean existsByNumber(long number);

count...By

//count...By
    long countByName(String name);

delete...By, remove...By

삭제 쿼리를 수행합니다. 리턴 타입이 없거나 삭제한 횟수를 리턴합니다.

//delete...By, remove...By
    void deleteByNumber(long number);
    long removeByName(String name);

...First<number>..., ...Top<number>...

쿼리를 통해 조회된 결괏값의 개수를 제한하는 키워드입니다. 둘이 차이는 주제와 By 사이에 위치합니다. 한번의 동작으로 여러 건을 조회할 때 사용되며, 단 거으로 조회하기 위해서는 <number> 를 생략하면 됩니다.

//...First<number>..., ...Top<number>...
    List<Product> findFirst5ByName(String name);
    List<Product> findTop10ByName(String name);

쿼리 메서드의 조건자 키워드

JPQL의 서술어 부분에서 사용할 수 있는 몇 가지 조건자 키워드를 소개합니다.

Is

값의 일치를 조건으로 사용하는 조건자 키워드입니다. 생략되는 경우가 많으면 Equals와 동일한 기능을 수행합니다.

// findByNumber 메서드와 동일하게 동작
  Product findByNumberIs(Long number);
  Product findAllByNumberIs(Long number);

(Is)Not

값의 불일치를 조건으로 사용하는 조건자 키워드입니다. Is는 생략하고 Not 키워드만 사용할 수도 있습니다.

//(is)Not
  Product findByNumberIsNot(Long number);
  Product findByNumberNot(Long number);

(Is)Null, (Is)NotNull

값이 null 인지 검사하는 조건자 키워드 입니다.

//(Is)Null, (Is)NotNull
  List<Product> findByUpdatedAtNull();
  List<Product> findByUpdatedAtIsNull();
  List<Product> findByUpdatedAtNotNull();
  List<Product> findByUpdatedAtIsNotNull();

(Is)True, (Is)False

boolean 타입으로 지정된 칼럼값을 확인하는 키워드입니다.
Product 엔티티에 boolean 타입을 사용하는 칼럼이 없기 때문에 실제 코드에 반영하면 에러가 발생합니다. 그래서 참고만 합니다.

//(Is)True, (Is)False
  Product findByisActiveTrue();
  Product findByisActiveIsTrue();
  Product findByisActiveFalse();
  Product findByisActiveIsFalse();

And, Or

여러 조건을 묶을 때 사용합니다.

  //And, Or
  Product findByNumberAndName(Long number, String name);
  Product findByNumberOrName(Long number, String name);

(Is)GreaterThan, (Is)LessThan, (Is)Between

숫자나 datetime 칼럼을 대상으로 한 비교 연산에 사용할 수 있는 조건자 키워드입니다. GreaterThan, LessThan 키워든느 비교 대상에 대한 초과/미만의 개념으로 비교 연산을 수행하고, 경계값을 포함하려면 Equal 키워드를 추가하면 됩니다.

 // (Is)GreaterThan, (Is)LessThan, (Is)Between
  List<Product> findByPriceIsGreaterThan(Long price);
  List<Product> findByPriceGreaterThan(Long price);
  List<Product> findByPriceGreaterThanEqual(Long price);
  List<Product> findByPriceIsLessThan(Long price);
  List<Product> findByPriceLessThan(Long price);
  List<Product> findByPriceLessThanEqual(Long price);
  List<Product> findByPriceIsBetween(Long lowPrice, Long highPrice);
  List<Product> findByPriceBetween(Long lowPrice, Long highPrice);

(Is)StartingWith(==StartsWith), (Is)EndingWith(==EndsWith),(Is)Containing(==Contains),(Is)Like

칼럼값에서 일부 일치 여부를 확인하는 조건자 키워드입니다. SQL 쿼리문에서 값의 일부를 포함하는 값을 추출할 때 사용하는 '%' 키워드와 동일한 역할을 하는 키워드입니다. 자동으로 생성되는 SQL문을 보면 Containing 키워드는 문자열의 양 끝, StartingWith 키워드는 문자열의 앞, EndingWith 키워드는 문자열 끝에 '%'가 배치됩니다. 여기서 별도로 고려해야하는 키워드는 Like 키워드인데, 이 키워드는 코드 수준에서 메서드 호출하면서 전달하는 값에 %를 명시적으로 입력해야 합니다.

// (Is)StartingWith(==StartsWith), (Is)EndingWith(==EndsWith),(Is)Containing(==Contains),(Is)Like
  List<Product> findByNameLike(String name);
  List<Product> findByNameIsLike(String name);

  List<Product> findByNameContains(String name);
  List<Product> findByNameContaining(String name);
  List<Product> findByNameIsContaining(String name);

  List<Product> findByNameStartsWith(String name);
  List<Product> findByNameStartingWith(String name);
  List<Product> findByNameIsStartingWith(String name);

  List<Product> findByNameEndsWith(String name);
  List<Product> findByNameEndingWith(String name);
  List<Product> findByNameIsEndingWith(String name);

정렬과 페이징 처리

일반적으로 정렬을 사용할 때 ORDER BY 구문을 사용하는데, 쿼리 메서드에서도 동일한 키워드를 사용합니다.

// Asc: 오름차순, Desc: 내림차순
  List<Product> findByNameOrderByNumberAsc(String name);
  List<Product> findByNameOrderByNumberDesc(String name);
Hibernate: 
  select
      product0_.number as number1_0_,
      product0_.created_at as created_2_0_,
      product0_.name as name3_0_,
      product0_.price as price4_0_,
      product0_.stock as stock5_0_,
      product0_.updated_at as updated_6_0_ 
  from
      product product0_ 
  where
      product0_.name=? 
  order by
      product0_.number desc

오름차순으로 정렬합니다.

다른 쿼리 메서드들은 조건 구문에서 조건을 여러 개 사용할라면 And와 Or 키워드를 사용했습니다. 하지만 정렬 구문은 And나 Or 키워드를 사용하지 않고 우선순위를 기준으로 차례대로 작성하면 됩니다.

//쿼리 메서드에서 여러 정렬 기준 사용
  List<Product> findByNameOrderByPriceAscStockDesc(String name);

이름을 price 기준으로 오름차순으로 정렬하고 그 후 후순위로 재고수량 기준으로 내림차순을 정렬합니다.

Hibernate: 
  select
      product0_.number as number1_0_,
      product0_.created_at as created_2_0_,
      product0_.name as name3_0_,
      product0_.price as price4_0_,
      product0_.stock as stock5_0_,
      product0_.updated_at as updated_6_0_ 
  from
      product product0_ 
  where
      product0_.name=? 
  order by
      product0_.price asc,
      product0_.stock desc

정렬 키워드를 사입해서 정렬을 수행하면 메서드 이름이 길어져 가독성이 떨어집니다. 매개변수를 활용해 정렬할 수 있습니다.

//매개변수를 활용한 쿼리 정렬
  List<Product> findByName(String name, Sort sort);

Sort 객체를 테스트하기 위해 test/com.springboot.advanced_jpa 패키지 내에 data/repository 패키지를 생성한 후 ProductRepositoryTest를 생성합니다.

package com.springboot.advanced_jpa.data.repository;


import com.springboot.advanced_jpa.data.entity.Product;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Order;

import java.time.LocalDateTime;

@SpringBootTest
public class ProductRepositoryTest {

  @Autowired
  ProductRepository productRepository;

  @Test
  void sortingAndPagingTest() {
      Product product1 = new Product();
      product1.setName("펜");
      product1.setPrice(1000);
      product1.setStock(100);
      product1.setCreatedAt(LocalDateTime.now());
      product1.setUpdatedAt(LocalDateTime.now());

      Product product2 = new Product();
      product2.setName("펜");
      product2.setPrice(5000);
      product2.setStock(300);
      product2.setCreatedAt(LocalDateTime.now());
      product2.setUpdatedAt(LocalDateTime.now());

      Product product3 = new Product();
      product3.setName("펜");
      product3.setPrice(500);
      product3.setStock(50);
      product3.setCreatedAt(LocalDateTime.now());
      product3.setUpdatedAt(LocalDateTime.now());

      Product savedProduct1 = productRepository.save(product1);
      Product savedProduct2 = productRepository.save(product2);
      Product savedProduct3 = productRepository.save(product3);

      productRepository.findByName("펜", Sort.by(Order.asc("price")));
      productRepository.findByName("펜", Sort.by(Order.asc("price"), Order.desc("stock")));
  }
}
Hibernate: 
  select
      product0_.number as number1_0_,
      product0_.created_at as created_2_0_,
      product0_.name as name3_0_,
      product0_.price as price4_0_,
      product0_.stock as stock5_0_,
      product0_.updated_at as updated_6_0_ 
  from
      product product0_ 
  where
      product0_.name=? 
  order by
      product0_.price asc
Hibernate: 
  select
      product0_.number as number1_0_,
      product0_.created_at as created_2_0_,
      product0_.name as name3_0_,
      product0_.price as price4_0_,
      product0_.stock as stock5_0_,
      product0_.updated_at as updated_6_0_ 
  from
      product product0_ 
  where
      product0_.name=? 
  order by
      product0_.price asc,
      product0_.stock desc

테스트 코드 결괏값을 확인하고 싶다면 출력문을 작성해줍니다.

   System.out.println(productRepository.findByName("펜", getSort()));
  }
  private Sort getSort(){
      return  Sort.by(
              Order.asc("price"),
              Order.desc("stock")
      );
  }

코드 가독성을 위해 매개변수를 활용한 쿼리 메서드를 사용하면 쿼리 메서드를 정의하는 단계에서 코드가 줄어드는 장점이 있습니다.

페이징 처리

페이징이란 데이터베이스의 레코드(튜플)를 개수로 나눠 페이지를 구분하는 것을 의미합니다. 예를 들어서 25개 레코드가 있다면 7개씩 나눠서 총 4개의 페이지로 구분하고 그중에서 특정 페이지를 가져오는 것입니다. 쉽게 말해 웹 페이지에 각 페이지를 구분해서 데이터를 제공할 때 그에 맞게 데이터를 요청하는 것이라고 생각하면 됩니다.

JPA에서는 이 같은 페이징 처리를 위해 Page와 Pageable을 사용합니다.

//페이징 처리를 위한 쿼리 메서드 
  Page<Product> findByName(String name, Pageable pageable);

리턴 타입으로 Page를 설정하고 매개변수에는 Pageable 타입의 객체를 정의합니다.

예시 사용법은 ProductRepositoryTest.java에서 호출합니다.

Page<Product> productPage = productRepository.findByName("펜", PageRequest.of(0,2));
      System.out.println(productPage.getContent());
Hibernate: 
  select
      product0_.number as number1_0_,
      product0_.created_at as created_2_0_,
      product0_.name as name3_0_,
      product0_.price as price4_0_,
      product0_.stock as stock5_0_,
      product0_.updated_at as updated_6_0_ 
  from
      product product0_ 
  where
      product0_.name=? limit ?
Hibernate: 
  select
      count(product0_.number) as col_0_0_ 
  from
      product product0_ 
  where
      product0_.name=?

쿼리 로그를 보면 select 쿼리에 limit 쿼리가 포함돼 있는 것을 볼 수 있습니다. 만약 페이지 번호를 0 이아닌 1이상의 숫자라 설정하면 offset 키워드도 포함되어 레코드 목록을 구분해서 가져오게 됩니다.

@Query 어노테이션 사용하기

데이터베이스에서 값을 가져올 때는 앞 절에서 소개한 것처럼 메서드의 이름만으로 쿼리 메서드를 생성 할 수도 있고 이번 절에서 살펴볼 @Query 어노테이션을 사용해 직접 JPQL을 작성할 수도 있습니다.

JPQL을 사용하면 JPA 구현체에서 자동으로 쿼리 문장을 해석하고 실행하게 됩니다. 만약 데이터베이스를 다른 데이터베이스로 변경할 일이 없다면 직접 해당 데이터베이스에 특화된 SQL를 작성할 수 있으며, 주로 튜닝된 쿼리를 사용하고자 할 때 직접 SQL을 작성합니다.

상품정보를 조회하는 메서드를 리포지토리에 추가합니다.

@Query("SELECT p FROM Product p WHERE p.name = ?1")
  List<Product> findByName(String name);

FROM 뒤에서 엔티티 타입을 지정하고 별칭을 생성합니다. WHERE문에서 조건을 지정합니다. 여기서 ?1은 파라미터를 전달받기 위한 인자에 해당합니다. 1은 첫 번째 파라미터를 의미합니다. 순서가 바뀌면 오류가 발생하기 때문에 @Param 사용을 추천합니다.

@Query("SELECT p FROM Product p WHERE p.name = :name")
  List<Product> findByNameParam(@Param("name") String name);
Hibernate: 
  select
      product0_.number as number1_0_,
      product0_.created_at as created_2_0_,
      product0_.name as name3_0_,
      product0_.price as price4_0_,
      product0_.stock as stock5_0_,
      product0_.updated_at as updated_6_0_ 
  from
      product product0_ 
  where
      product0_.name=?
@Query("SELECT p.name, p.price, p.stock FROM Product p WHERE p.name = : name")
  List<Object[]> findByNameParam2(@Param("name") String name);

원하는 칼럼의 값만 추출할 수 있습니다.
SELECT에 가져오고자 하는 칼럼을 지정하면 됩니다. Object 배열의 리스트 형태로 리턴 타입을 지정해야합니다. 호출하면

Hibernate: 
  select
      product0_.name as col_0_0_,
      product0_.price as col_1_0_,
      product0_.stock as col_2_0_ 
  from
      product product0_ 
  where
      product0_.name=?

QueryDSL 적용하기

앞에서는 @Query 어노테이션을 사용해 직접 JPQL의 쿼리를 작성하는 방법을 알아봤다면, 메서드의 이름을 기반으로 생성하는 JPQL의 한계는 @Query 어노테이션을 통해 대부분 해소할 수 있지만 직접 문자열을 입력하기 때문에 컴파일 시점에 에러를 잡지 못하고 런타임 에러가 발생할 수 있습니다.

쿼리의 문자열이 잘못된 경우에는 애플리케이션이 실행된 후 로직이 실행되고 나서야 오류를 발견할 수 있습니다. 개발 환경에서는 문제가 없는 것처럼 보이다가 실제 운영 환경에 애플리케이션을 배포하고 나서 오류가 발견되는 리스크를 유발합니다.

QueryDSL로 해결이 가능합니다. 이는 문자열이 아니라 코드로 쿼리를 작성할 수 있도록 도와줍니다.

QueryDSL이란?
QueryDSL은 정적 타입을 이용해 SQL과 같은 쿼리를 생성할 수 있도록 지원하는 프레임워크입니다. 문자열이나 XML 파일을 통해 쿼리를 작성하는 대신 QueryDSL이 제공하는 플루언트(Fluent) API를 활용해 쿼리를 생성할 수 있습니다.

QueryDSL의 장점

  • IDE가 제공하는 코드 자동 완성 기능을 사용할 수 있습니다.
  • 문법적으로 잘못된 쿼리를 허용하지 않습니다. 따라서 정상적으로 활용된 QueryDSL은 문법 오류를 발생시키지 않습니다.
  • 고정된 SQL 쿼리를 작성하지 않기 때문에 동적으로 쿼리를 생성할 수 있습니다.
  • 코드로 작성하므로 가독성 및 생산성이 향상됩니다.
  • 도메인 타입과 프로퍼티를 안전하게 참조할 수 있습니다.

QueryDSL을 사용하기 위한 프로젝트 설정

pom.xml파일에 의존성을 추가합니다.

<dependency>
  <groupId>com.querydsl</groupId>
  <artifactId>querydsl-apt</artifactId>
  <scope>provided</scope>
</dependency>
<dependency>
  <groupId>com.querydsl</groupId>
  <artifactId>querydsl-jpa</artifactId>
</dependency>
<plugin>
  <groupId>com.mysema.maven</groupId>
  <artifactId>apt-maven-plugin</artifactId>
  <version>1.1.3</version>
  <executions>
      <execution>
          <goals>
              <goal>process</goal>
          </goals>
          <configuration>
              <outputDirectory>target/generated-sources/java</outputDirectory>
              <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
              <options>
                  <querydsl.entityAccessors>true</querydsl.entityAccessors>
              </options>
          </configuration>
      </execution>
  </executions>
</plugin>

QueryDSL은 지금까지 작성했던 엔티티 클래스와 Q도메인(QDomain)이라는 쿼리 타입의 클래스를 자체적으로 생성해서 메타데이터로 사용하는데, 이를 통해 SQL과 같은 쿼리를 생성해서 제공한다.

만약 Q도메인 클래스(QProduct)가 제대로 생성되지 않았다면 프로젝트 폴더를 마우스 오른쪽 버튼으로 클릭한 후 [Maven] → [Generate Sources and Update Folders]를 선택한다.

또한 코드가 정상적으로 동작하지 않는다면 IDE의 설정을 조정해야 한다. 인텔리제이 IDEA 에서 [Ctrl + Alt + Shift + S]를 클릭하거나 메뉴에서 [File] → [Project Structure]를 차례로 선택해 설정 창을 연 다음 [Modules] 탭을 클릭한다. 그러고 나서 아래와 같이 generated-sources를 눌러 [Mark as] 항목에 있는 [Sources]를 눌러 IDE에서 소스파일로 인식할 수 있게 설정한다.

기본적인 QueryDSL 사용하기

테스트 코드록 기본적인 QueryDSL 사용법을 알아봅니다. 우선 테스트 코드 기본적인 QueryDSL 사용법을 알아보겠습니다.

@PersistenceContext
  EntityManager entityManager;
  @Test
  void queryDslTest() {
      JPAQuery<Product> query = new JPAQuery(entityManager);
      QProduct qProduct = QProduct.product;

      List<Product> productList = query
              .from(qProduct)
              .where(qProduct.name.eq("펜"))
              .orderBy(qProduct.price.asc())
              .fetch();

      for (Product product : productList) {
          System.out.println("----------------");
          System.out.println();
          System.out.println("Product Number : " + product.getNumber());
          System.out.println("Product Name : " + product.getName());
          System.out.println("Product Price : " + product.getPrice());
          System.out.println("Product Stock : " + product.getStock());
          System.out.println();
          System.out.println("----------------");
      }
  }

QueryDSL에 의해 생성된 Q도메인 클래스를 활용하는 코드입니다. 다만 Q도메인 클래스와 대응되는 테스트 클래스가 없으므로 엔티티 클래스에 대응되는 리포지토리의 테스트 클래스 에 포함해도 무관합니다.

QueryDSL을 사용하려면 JPAQuery 객체를 사용합니다. JPAQuerysms 엔티티 매니저를 활용해서 생성합니다. 빌더 형식으로 쿼리를 작성합니다.
QueryDSL 사용하려면 list() 메서드를 사용해야합니다.

  • List< T > fetch(): 조회 결과를 리스트로 반환합니다.
  • T fetchOne: 단 건의 조회 결과를 반환합니다.
  • T fetchFirst(): 여러 건의 조회 결과 중 1건을 반복합니다. 내부 로직을 살펴보면 .limit(1).fetchOne()으로 구현돼 있습니다.
  • Long fetchCount(): 조회 결과의 개수를 반환합니다.
  • QueryResult< T > fetchResults(): 조회 결과 리스트와 개수를 포함한 QueryResults를 반환합니다.
객체를 사용해서 코드를 작성하는 방법 외에 다른 방법은론 JPAQueryFactory를 활용해서 작성합니다.
@Test
  void QueryDSLTest2(){

      JPAQueryFactory jpaQueryFactory = new JPAQueryFactory(entityManager);
      QProduct qProduct= QProduct.product;

      List<Product> productList = jpaQueryFactory.selectFrom(qProduct)
              .where(qProduct.name.eq("펜"))
              .orderBy(qProduct.price.asc())
              .fetch();
      for (Product product : productList){
          System.out.println("----------------");
          System.out.println();
          System.out.println("Product Number : " + product.getNumber());
          System.out.println("Product Name : " + product.getName());
          System.out.println("Product Price : " + product.getPrice());
          System.out.println("Product Stock : " + product.getStock());
          System.out.println();
          System.out.println("----------------");
      }
  }

여기서는 JPAQueryFactory를 활용해 쿼리를 작성했습니다. JPAQuery를 사용했을 때와 달리 JPAQueryFactory는 select 절부터 작성 가능합니다.

만약 전체 칼럼을 조회하지 않고 일부만 조회하고 싶다면 selecFrom()이 아닌 select()와 from() 메서드를 구분해서 사용하면 됩니다.

@Test
  void QueryDslTest3(){
      JPAQueryFactory jpaQueryFactory = new JPAQueryFactory(entityManager);
      QProduct qProduct = QProduct.product;

      List<String> productList= jpaQueryFactory
              .select(qProduct.name)
              .from(qProduct)
              .where(qProduct.name.eq("펜"))
              .orderBy(qProduct.price.asc())
              .fetch();
      for(String product : productList){

          System.out.println("----------------");
          System.out.println("Product Name : "+ product);
          System.out.println("----------------");
      }

      List<Tuple> tupleList= jpaQueryFactory
              .select(qProduct.name, qProduct.price)
              .from(qProduct)
              .where(qProduct.name.eq("펜"))
              .orderBy(qProduct.price.asc())
              .fetch();

      for(Tuple product : tupleList){

          System.out.println("----------------");
          System.out.println("Product Name : "+ product.get(qProduct.name));
          System.out.println("Product Price : "+ product.get(qProduct.price));
          System.out.println("----------------");
      }
  }

여기서 select 대상이 하나인 경우 첫번째 방식으로 하면됍니다. 만약 select 대상이 여러개 일경우 쉼표로 구분해서 작성합니다. 추가적으로 리턴 타입을 Tuple타입으로 지정합니다.

QueryDSL을 실제 비즈니스 로직에서 활용할 수 있게 설정해 보겠습니다. 컨피그 클래스를 생성합니다.

@Configuration
public class QueryDSLConfiguration {
  
  @PersistenceContext
  EntityManager entityManager;
  
  @Bean
  public JPAQueryFactory jpaQueryFactory(){
      return new JPAQueryFactory(entityManager);
  }
}

JPAQueryFactory 객체를 @Bean 객체로 등록해두면 앞에서 작성한 예저처럼 매번 JPAQueryFactory를 초기화하지 않고 스프링 컨테이너에서 가져다 쓸 수 있습니다. 이렇게 사용한 컨피그 클래스를 사용해보겠습니다.

@Autowired
  JPAQueryFactory jpaQueryFactory;

  @Test
  void QueryDslTest4(){
       QProduct qProduct = QProduct.product;

       List<String> productList=jpaQueryFactory
               .select(qProduct.name)
               .from(qProduct)
               .where(qProduct.name.eq("펜"))
               .orderBy(qProduct.price.asc())
               .fetch();

       for (String product : productList){

           System.out.println("----------------");
           System.out.println("Product Name : "+ product);
           System.out.println("----------------");
       }
  }

QuerydslPredicateExecutor, QuerydslRepositorySupport 활용

스프링 데이터 JPA에서는 QueryDSL을 더욱 편하게 사용할 수 있게 QuerydslPredicateExecutor 인터페이스와 QuerydslRepositorySupport 클래스를 제공합니다. 이 두개를 활용법을 알아봅니다.

QuerydslPredicateExecutor 인터페이스

QuerydslPredicateExecutor는 JpaRepository와 함께 리포지토리에서 QueryDSL을 사용할 수 있게 인터페이스를 제공합니다. 예제를 구분하기위해 data/repository/QProductRepository를 만듭니다.

public interface QProductRepository extends JpaRepository<Product, Long>,
  QuerydslPredicateExecutor<Product>{

}

QuerydslPredicateExecutor를 상속받도록 설정한 Product 엔티티에 대한 리포지토리입니다.
인터페이스 내부를 보면 다양한 메서드를 제공합니다.

Optional<T> findOne(Predicate predicate);

Iterable<T> findAll(Predicate predicate);

Iterable<T> findAll(Predicate predicate, Sort sort);

Iterable<T> findAll(Predicate predicate, OrderSpecifier<?>... orders);

Iterable<T> findAll(OrderSpecifier<?>... orders);

Page<T> findAll(Predicate predicate, Pageable pageable);

long count(Predicate predicate);

boolean exists(Predicate predicate);

QuerydslPredicateExecutor 인터페이스의 메서드는 대부분 Predicate 타입을 매개변수로 받습니다. Predicate 표현식을 작성할 수 있게 QueryDSL에서 제공하는 인터페이스입니다. QProductRepository에 대한 실습 코드를 작성하기 위해 test 디렉터리에 다음과 같이 QProductRepositoryTest 클래스를 생성합니다.
test/com.springboot.advanced_jpa/data/repository/QProductRepositoryTest.java

@SpringBootTest
public class QProductRepositoryTest {

  @Autowired
  QProductRepository qProductRepository;
}

클래스 생성

Predicate를 이용해 findOne() 메서드를 호출하는 방법

@Test
  public void QueryDslTest1(){

      Predicate predicate = QProduct.product.name.containsIgnoreCase("펜")
              .and(QProduct.product.price.between(1000, 2500));

      Optional<Product> foundProduct = qProductRepository.findOne(predicate);

      if(foundProduct.isPresent()){
          Product product = foundProduct.get();
          System.out.println(product.getNumber());
          System.out.println(product.getName());
          System.out.println(product.getPrice());
          System.out.println(product.getStock());
      }
  }

Predicate는 간단하게 표현식으로 정의하는 쿼리를 생각하면 됩니다. 여기서는 Predicate를 명시적으로 정의하고 사용했다면 이젠 서술부만 가져다 사용할 수도 있습니다.

@Test
  public void QueryDslTest2(){
      QProduct qProduct = QProduct.product;

      Iterable<Product> productList= qProductRepository.findAll(
              qProduct.name.contains("펜")
                      .and(qProduct.price.between(550,1500))
      );

      for (Product product : productList){
          System.out.println(product.getNumber());
          System.out.println(product.getName());
          System.out.println(product.getPrice());
          System.out.println(product.getStock());
      }
  }

QuerydslPredicateExecutor를 사용하면 편하게 QueryDSl을 사용할 수 있지만 join이나 fetch 기능은 사용할 수 없다는 단점이 있습니다.

QuerydslRepositorySupport 추상 클래스 사용하기

QuerydslRepositorySupport 클래스 역시 QueryDSL 라이브러리를 사용하는 유용한 기능을 제공합니다. 일반적인 방법으로는 CustomRepository를 활용해 리포지토리를 구현하는 방식입니다.

지금 까지 예로 든 Product 엔티티를 활용하기 위한 객체들의 상속 구조를 살펴보면

JpaRepository와 QuerydslRepositorySupport는 SpringDataJPA에서 제공하는 인터페이스와 클래스입니다. ProductRepository와 ProductRepositoryCustom, ProductRepositoryCustomImpl은 직접 구현해야 합니다.

간단한 구조는

  • 먼저 앞에서 사용했던 방식처럼 JpaRepository를 상속받는 ProductRepository를 생성합니다.
  • 이때 직접 구현한 쿼리를 사용하기 위해서는 JpaRepository를 상속받지 않는 리포지토리 인터페이스인 ProductRepositoryCustom을 생성합니다. 이 인터페이스에 정의하고자 하는 기능들을 메서드로 정의합니다.
  • ProductRepositoryCustom에서 정의한 메서드를 사용하기 위해 ProductRepository에서 ProductRepositoryCustom을 상속받습니다.
  • ProductRepositoryCustom에서 정의된 메서드를 기반으로 실제 쿼리 작성을 하기 위해 구현체인 ProductRepositoryCustomImpl 클래스를 생성합니다.
  • ProductRepositoryCustomImpl 클래스에서는 다양한 방법으로 쿼리를 구현할 수 있지만 QueryDSL을 사용하기위해 QueryDslRepositorySupport를 상속받습니다.
위와같이 구성하면 DAO나 서비스에서 리포지토리에 접근하기 의해 ProductRepository를 사용합니다. 따라서 추가적으로 QueryDSl의 기능도 사용할 수 있습니다. 이제 리포지토리 패키지안에 support 패키지를 만들어서 구현합니다.

public interface ProductRepositoryCustom {
    
    List<Product> findByName(String name);
}

인터페이스를 생성하고 쿼리로 구현하고자 하는 메서드를 정의하는 작업을 수행합니다. 여기서는 간단하게 findByName()을 정의하고 사용해보겠습니다.

그럼 이제 ProductRepositoryCustomImpl클래스를 작성합니다.

@Component
public class ProductRepositoryCustomImpl extends QuerydslRepositorySupport implements ProductRepositoryCustom {
  
  
  public  ProductRepositoryCustomImpl(){
      super(Product.class);
  }
  
  @Override
  public List<Product>findByName(String name) {

      QProduct product = QProduct.product;

      List<Product> productList= from(product)
              .where(product.name.eq(name))
              .select(product)
              .fetch();
      return  productList;
  }
}

ProductRepositoryCustomImpl 클래스에서 QueryDSLㅇ,ㄹ 사용하기 위해 QuerydslRespositorySupport를 상속받고 ProductRepositoryCustom 인터페이스를 구현합니다. 여기서 QuerydslRespositorySupport 상속을바받으면 도메인 클래스를 부모 클래스에 전달해야 합니다.

QuerydslRespositorySupport QProduct를 사용해 query문을 짭니다. 여기서 from 메서드가 어떤 도메인에 접근할 것인지 지정하는 역할을 수행하고 JPAQuery를 리턴합니다.

여기서 기존에 Product 엔티티 클래스와 매핑해서 사용하던 ProductRepository가 있다면 ProductRepositoryCustom을 상속받아 사용할 수 있습니다. 하지만 구별을 위해서 여기서는 별도로 생성했습니다.

ProductRepository는

@Repository("productRepositorySupport")
public interface ProductRepository extends JpaRepository<Product, Long>, ProductRepositoryCustom {
}

기존과 동일하게 JpaRepository를 상속받아 구성하면 됩니다. 이전에 ProductRepository라는 이름이 이미 사용되고 있기 때문에 빈 생성 시 충돌이 발생해서 Repository를 이용해 변도로 빈 이름을 설정해줍니다.

이 코드를 사용할 때는 ProductRepository만 이용하면 됩니다. 기본적으로 JpaRepository에서 제공하는 메서드도 사용할 수 있고, 별도로 ProductRepositoryCustom 인터페이스에서 정의한 메서드도 구현체를 통해 사용할 수 있습니다.

ProductRepositoryCustom 인터페이스에서 정의한 findByName() 메서드만 호출하겠습니다. 테스트코드 작성을 위해 test/com.springboot.advanced_jpa/data/repository에 support 패키지를 생성하고 ProductRepositoryTest 클래스를 생성합니다.

@SpringBootTest
public class ProductRepositoryTest {

  @Autowired
  ProductRepository productRepository;

  @Test
  void findByNameTest(){
      List<Product> productList = productRepository.findByName("펜");

      for(Product product : productList){
          System.out.println(product.getNumber());
          System.out.println(product.getName());
          System.out.println(product.getPrice());
          System.out.println(product.getStock());
      }
  }
}

[한걸음 더] JPA Auditing 적용

JPA 에서 Audit이란 감시하다라는 뜻으로, 각 데이터마다 누가 언제 데이터를 생성하고 변경했는지 감시한다는 의미로 사용합니다. 작성한 코드들을 보면 인티티 클래스에는 공통적으로 들어가는 필드가 있습니다. 예를 들면, 생성 일자와 변경 일자 같은 것입니다. 대표적으로 많이 사용되는 필드는 다음과 같습니다.

  • 생성 주체
  • 생성 일자
  • 변경 주체
  • 변경 일자
매번 엔티티 생성하거나 변경할 때마다 값을 주입해야 하는 번거로움이 있습니다. 이 같은 번거로움을 해소하기위해 Spring Data JPA에서는 값들을 자동으로 넣어주는 기능을 제공합니다. ### JPA Auditing 기능 활성화 Auditing 기능을 활성화 하기 main()메서드가 있는 클래스에 @EnalbleJpaAuditing 어노테이션을 추가합니다. AdvancedJpaApplication.java
@SpringBootApplication
@EnableJpaAuditing
public class AdvancedJpaApplication {

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

}

테스트하는 일부 상황에서 오류가 발생할 수 있어 예를들어, @WebMvcTest 어노테이션을 지정해서 테스트를 수행하는 코드를 작성하면 애플리케이션 클래스를 호출하는 과정에서 예외가 발생할 수 있습니다. 별도의 Configuration 클래스를 생성해서 애플리케이션 클래스의 기능과 분리해서 활성화할 수 있습니다.

config/JpaAuditingConfiguration.java

@Configuration
@EnableJpaAuditing
public class JpaAuditingConfiguration {
  
}

이방법으로 하게되면 위에 메인에 Auditing 어노테이션을 지워야 정상적으로 작동합니다.

BaseEntity 만들기

코드의 중복을 없애기 위해 각 엔티티에 공통으로 들어가게 되는 칼럼(필드)을 하나의 클래스로 빼는 작업을 수행해야 합니다. 생성 일자와 변경 일자만 추가해서 생성합시다.

@Getter
@Setter
@ToString
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity {

  @CreatedDate
  @Column(updatable = false)
  private LocalDateTime createdAt;

  @LastModifiedDate
  private LocalDateTime updatedAt;
}
  • @MappedSuperClass: JPA의 엔티티 클래스가 상속받을 경우 자식 클래스에게 매핑 정보를 전달합니다.
  • @EntityListeners: 엔티티를 데이터베이스에 적용하기 전후로 콜백을 요청할 수 있게 하는 어노테이션입니다.
  • AuditingEntityListener: 엔티티의 Auditing 정보를 주입하는 JPA 엔티티 리스너 클래스입니다.
  • @createdDate: 데이터 생성 날짜를 자동으로 주입하는 어노테이션입니다.
  • @LastModifiedDate: 데이터 수정 날짜를 자동으로 주입하는 어노테이션입니다.
여기서 Product 엔티티 클래스에서 공통되는 부분을 제거해줍니다.
@Entity
@Getter
@Setter
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Table(name = "product")
public class Product extends BaseEntity {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long number;

  @Column(nullable = false)
  private String name;

  @Column(nullable = false)
  private Integer price;

  @Column(nullable = false)
  private Integer stock;

}

여기서 @ToString, @EqualsAndHashCode 어노테이션에 적용한 callSuper 속성은 롬복 설명에서 다뤘다시피 부모 클래스의 필드를 포함하는 역할을 수행합니다.

시간들을 주입하지 않고 자동으로 생성되는것을 볼수 있습니다.

@Test
  public void auditingTest(){

      Product product = new Product();
      product.setName("펜");
      product.setPrice(1000);
      product.setStock(100);

      Product savedProduct = productRepository.save(product);

      System.out.println("Product Name: "+ savedProduct.getName());
      System.out.println("createdAt: "+ savedProduct.getCreatedAt());
  }
Hibernate: 
  insert 
  into
      product
      (created_at, updated_at, name, price, stock) 
  values
      (?, ?, ?, ?, ?)
Product Name: 펜
createdAt: 2023-08-05T00:13:27.472947
profile
Interactive Developer

0개의 댓글