상세 컨텐츠

본문 제목

[스프링2] 8장. Spring Data JPA 활용

23-24/Spring 2

by hyom1n 2023. 11. 24. 10:00

본문

728x90

 

이번 장에서 Spring Data JPA 제공 기능을 활용하여 리포지토리 예제를 작성하고, 테스트 코드를 통하여 리포지토리의 활용법을 살펴봅시다.


8.1 프로젝트 생성

이번 장에서 사용될 프로젝트를 아래와 같이 생성합니다.

https://start.spring.io/

 

그리고 6장에서 만들었던 프로젝트의 자바 코드 일부를 다음과 같이 가져옵니다.

advanced_jpa\src

 

 

 

8.2 JPQL

JPQL(JPA Query Language) JPA에서 사용할 수 있는 쿼리입니다. JPQL의 문법은 SQL과 비슷하지만, 엔티티 객체를 대상으로 수행하기 때문에 테이블이나 칼럼의 이름 대신 매핑된 엔티티의 이름과 필드의 이름을 사용합니다.

JPQL 쿼리 기본 구조

 

 

 

8.3 쿼리 메서드 살펴보기

리포지토리는 JpaRepository를 상속받는 것으로 기본 메서드를 사용할 수 있지만, 이러한 기본 메서드는 식별자를 기반으로 생성되기 때문에 별도의 메서드를 정의해서 사용해야 하는 경우가 많습니다. 이때 쿼리 메서드를 사용하여 간단한 쿼리문을 작성할 수 있습니다.

 

8.3.1 쿼리 메서드의 생성

// 리포지토리 쿼리 메서드 생성 예
// 리턴타입 주제+서술어
List<Person> findByLastnamesAndEmail(String lastName, String email);

 

쿼리 메서드는 크게 주제와 서술어로 구분합니다. 'find...By', 'exists...By'와 같은 키워드로 쿼리의 주제를 정하며, 'By'는 서술어의 시작을 나타내는 구분자입니다.

서술어 부분에서 검색 및 정렬 조건을 지정합니다. 엔티티의 속성으로 정의할 수 있고, 논리 연산자를 사용하여 조건을 확장할 수도 있습니다. 서술어에 들어가는 엔티티의 속성 식은 엔티티에서 관리하고 있는 속성(필드)만 참조할 수 있습니다.

 

 

 

8.3.2 쿼리 메서드의 주제 키워드

쿼리 메서드의 주제 부분에 사용할 수 있는 주요 키워드는 다음과 같습니다.

  • find~By.
  • read~By.
  • get~By.
  • query~By.
  • search~By.
  • stream~By.

조회하는 기능을 수행하는 키워드로,  '~' 영역에는 도메인(엔티티)을 표현합니다. 그러나 리포지토리에서 이미 도메인을 설정한 후에 메서드를 사용하기에 생략하기도 합니다. 리턴 타입으로 Collection이나, Stream에 속한 하위 타입을 설정할 수 있습니다. ProductRepositoty에 쿼리 메서드를 작성합시다.

public interface ProductRepository extends JpaRepository<Product, Long> {
    // 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);

 

 

 

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

JPQL 서술어 부분에서 사용할 수 있는 조건자 키워드에 대해 알아봅시다.

 

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

// Is, Equals
// findByNumber 메소드와 동일하게 동작
Product findByNumberIs(Long number);
Product findByNumberEquals(Long number);

 

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

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

 

(ls)Null, (Is)NotNull값이 null인지 검사하는 조건자 키워드입니다.

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

 

(ls)True, (Is)Falseboolean 타입으로 지정된 칼럼값을 확인하는 키워드입니다. 예제 코드 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);

 

(ls)GraterThan, (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);

 

(ls)StartingWith, (Is)EndingWith, (Is)Containing, (Is)Like는 칼럼값에서 일부 일치 여부를 확인하는 조건자 키워드입니다. SQL 쿼리문에서 값의 일부를 포함하는 값을 추출할 때 사용하는 '%' 키워드와 동일한 역할을 하는 키워드입니다. 자동으로 생성되는 SQL문을 보면 Containing 키워드는 문자열의 양 끝, StartingWith 키워드는 문자열의 앞, EndingWith 키워드는 문자열의 끝에 '%'가 배치됩니다.

Like 키워드는 코드 수준에서 메서드를 호출하면서 전달하는 값에 %를 명시적으로 입력해야 합니다.

// (Is)Like, (Is)Containing, (Is)StartingWith, (Is)EndingWith
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);

 

 

8.4 정렬과 페이징 처리

애플리케이션에서 자주 사용되는 정렬과 페이징 처리는 앞서 소개한 쿼리 메서드를 작성하는 방법을 기반으로 수행 가능합니다. 기본 쿼리 메서드인 이름을 통한 정렬과 페이징 처리가 아닌, 기본적인 정렬과 페이징 처리 방법에 대해 알아봅시다.

 

 

8.4.1 정렬 처리하기

일반적인 쿼리문에서 정렬을 사용할 때 ORDER BY 구문을 사용합니다. 쿼리 메서드도 정렬 기능에 동일한 키워드가 사용됩니다. 다음과 같이 정렬 기능을 사용할 수 있습니다.

// Asc : 오름차순, Desc : 내림차순
List<Product> findByNameOrderByNumberAsc(String name);
List<Product> findByNameOrderByNumberDesc(String name);

기본 쿼리 메서드를 작성한 후 OrderBy 키워드를 삽입하고 정렬하고자 하는 칼럼과 오름차순, 내림차순을 설정하면 정렬됩니다. findByNameOrderByNumberAsc 메서드는 상품 정보를 이름으로 검색한 후 상품 번호로 오름차순 정렬을 수행한다는 뜻입니다. 오름차순은 Asc를, 내림차순은 Desc 키워드를 사용합니다.

다른 쿼리 메서드들은 조건 구문에서 조건을 여러 개 사용하기 위해 AndOr 키워드를 사용하였는데, 정렬 구문은 이를 사용하지 않고, 우선순위를 기준으로 차례대로 작성합니다.

// 여러 정렬 기준 사용하기, And를 붙이지 않음
List<Product> findByNameOrderByPriceAscStockDesc(String name);

findByNameOrderByPriceAscStockDesc 메서드는 Price를 기준으로 오름차순 정렬 후 재고수량을 기준으로 내림차순 정렬한다는 뜻입니다.

이렇게 쿼리 메서드의 이름에 정렬 키워드를 삽입하여 정렬을 수행할 수 있지만, 이럴 경우 이름이 점점 길어져 가독성이 떨어집니다. 이를 해결하기 위하여 매개변수를 활용하여 정렬할 수 있습니다.

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

이 코드는 이전 정렬 메서드들과 거의 동일한 기능을 수행하는데, 이름에 키워드를 넣지 않고, Sort 객체를 활용하여 매개변수로 받은 정렬 기준을 통하여 쿼리문을 작성합니다. Sort 객체를 테스트해봅시다. com/springboot/advanced_jpa/data/repository 패키지를 생성하고 ProductRepositoryTest 클래스를 생성합니다.

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

sortingAndPagingTest 메서드 하단에 아래 코드를 작성하여 쿼리 메서드에 Sort 객체를 전달합시다. 

System.out.println(productRepository.findByName("펜", Sort.by(Order.asc("price"))));
System.out.println(productRepository.findByName("펜", Sort.by(Order.asc("price"), Order.desc("stock"))));

Sort 클래스 내부 클래스로 정의되어 있는 Order 객체를 활용하여 정렬 기준을 생성합니다. Order 객체에는 ascdesc 메서드가 포함되어 있어 이 메서드를 통하여 오름차순/내림차순을 지정할 수 있습니다. 여러 정렬 기준을 사용할 경우에는 콤마(,)를 사용하여 구분합니다.

매개 변수를 활용한 쿼리 메서드를 사용하면 쿼리 메서드를 정의하는 단계에서 코드가 줄어드는 장점이 있으나 호출하는 위치에서는 여전히 정렬 기준이 길어 가독성이 떨어집니다. 해당 코드는 정렬 기준을 설정하기 위한 필수적인 구문이기 때문에 코드의 양을 줄이기는 어렵습니다. 하지만 아래 코드와 같이 Sort 부분을 하나의 메서드로 분리하여 쿼리 메서드를 호출하는 코드를 작성할 수 있습니다.

private Sort getSort() {
    return Sort.by(
            Order.asc("price"),
            Order.desc("stock")
    );
}

 

 

 

8.4.2 페이징 처리하기

페이징이란 데이터베이스의 레코드를 개수로 나누어 페이지를 구분하는 것입니다. 25개의 레코드가 존재할 때, 레코드를 7개 씩 총 4개 페이지로 구분하고, 특정 페이지를 가져오는 등 웹 페이지에서 각 페이지를 구분하여 데이터를 제공할 때 그에 맞게 데이터를 요청하는 것입니다.

JPA에서는 이 같은 페이징 처리를 위하여 Page와 Pageable을 사용합니다. 아래와 같이 페이징 처리가 가능한 쿼리 메서드를 작성할 수 있습니다.

Page<Product> findByName(String name, Pageable pageable);

리턴 타입으로 Page를 설정하고, 매개변수로 Pageable 타입의 객체를 정의하였습니다. findByName메서드는 아래와 같이 호출할 수 있습니다.

Page<Product> productPage = productRepository.findByName("펜", PageRequest.of(0, 2));

findByName 메서드를 호출할 때 리턴 타입으로 Page 객체를 받아야 하기 때문에 Page<Product>로 타입 선언 후 객체를 리턴받았습니다. 그리고 Pageable 파라미터를 전달하기 위하여 PageRequest 클래스를 사용하였습니다. PageRequest 는 Pagealble의 구현체입니다. 

일반적으로 PageRequest는 of 메서드를 통하여 PageRequest 객체를 생성합니다. of 메서드는 매개변수에 따라 다양한 형태로 오버로딩되어 있는데, 다음과 같은 매개변수 조합을 지원합니다.

of 메서드 매개변수 비고
of(int page, int size) 페이지 번호, 페이지 당 데이터 개수 데이터 정렬 X
of(int page, int size, Sort) 페이지 번호, 페이지 당 데이터 개수, 정렬 sort에 의하여 정렬
of(int page, int size, Direction, String...properties) 페이지 번호, 페이지 당 데이터 개수, 정렬 방향, 속성 sort, by(direction, properties)에 의하여 정렬

 

페이지 번호를 0이 아닌 1 이상의 숫자로 설정하면 offset 키워드가 포함되어 레코드 목록을 구분하여 가져옵니다. Page 객체를 그대로 출력하면 해당 객체의 값을 보여주지 않고 몇 번째 페이지에 해당하는지만 확인할 수 있습니다. 각 페이지를 구성하는 세부적인 값을 보려면 getContent() 메서드를 사용하여 출력해야 합니다.

Page<Product> productPage = productRepository.findByName("펜", PageRequest.of(0, 2));
Ststem.out.println(productPage.getContent());

 

 

 

8.5 @Query 어노테이션 사용하기

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

JPQL을 사용하면 JPA 구현체에서 자동으로 쿼리 문장을 해석하고 실행합니다. 만약 데이터베이스를 다른 데이터베이스로 변경할 일이 없다면 직접 해당 데이터베이스에 특화된 SQL을 작성할 수 있고, 일반적으로 튜닝된 쿼리를 사용하고자 할 때 직접 SQL을 작성합니다. JPQL을 직접 다루는 방법을 알아보기 위하여 상품정보를 조회하는 메서드를 리포지토리에 추가합시다.

 

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

@Query 어노테이션을 사용하여 JPQL 형식의 쿼리문을 직접 작성하였습니다. from 절에서 엔티티 타입을 지정하고, 별칭을 생성합니다. as는 생략할 수 있습니다. where 절에서 조건을 지정합니다. 조건문에서 사용한 '?1'은 파라미터를 전달받기 위한 인자입니다. 1은 첫 번째 파라미터를 의미하는데, 이러한 방식을 사용할 경우 파라미터의 순서가 바뀌면 오류가 발생할 가능성이 있어 @Param 어노테이션을 사용합니다.

@Query("SELECT p FROM Product p WHERE p.name = :name")
List<Product> findByNameParam(@Param("name") String name);

파라미터를 바인딩하는 방식으로 메서드를 구현하면 코드의 가독성이 높아지고 유지보수가 수월합니다. 위 두 예제는 동일한 쿼리를 생성하여 실행됩니다.

@Query("SELECT p.name, p.price, p.stock FROM Product p WHERE p.name = :name")
List<Object[]> findByNameParam2(@Param("name") String name);

findByNameParam2 메서드는 엔티티 타입이 아니라, 원하는 칼럼의 값만 추출하는 메서드입니다. select에 가져오고자 하는 칼럼을 지정하면 됩니다. 이때 Object 배열의 리스트 형태로 리턴 타입을 지정합니다.

 

 

 

8.6 QueryDSL 적용하기

앞에서는 @Query 어노테이션을 사용하여 직접 JPQL의 쿼리를 작성하였습니다. 메서드의 이름을 기반으로 생성하는 JPQL의 한계는 @Query 어노테이션을 통하여 대부분 해소할 수 있으나, 직접 문자열을 입력하기 때문에 컴파일 시점에 에러를 잡지 못하고 런타임 에러가 발생할 수 있습니다. 쿼리의 문자열이 잘못된 경우에 애플리케이션이 실행된 후 로직 실행 이후에야 오류를 발견합니다. 때문에 개발 환경에서는 문제가 없는 것으로 보이다가 실제 운영 환경에서 애플리케이션 배포 이후에 오류가 발견되는 리스크를 유발합니다.

이 같은 문제를 해소하기 위하여 QueryDSL을 사용합니다. QueryDSL는 문자열이 아니라 코드로 쿼리를 작성할 수 있도록 돕습니다.

 

 

 

8.6.1 QueryDSL이란?

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

 

 

8.6.2 QueryDSL의 장점

QueryDSL을 사용하면 다음과 같은 장점이 있습니다.

  • IDE 제공 코드 자동 완성 기능 사용
  • 문법적 잘못된 쿼리 허용 X
  • 고정된 SQL 쿼리 작성 X (동적 쿼리 생성 가능)
  • 가독성 및 생산성 향상
  • 도메인 타입과 프로퍼티 안전하게 참조

 

 

 

8.6.3 QueryDSL 사용 위한 프로젝트 설정

QueryDSL을 사용하려면 설정이 필요합니다. pom.xml 파일에 의존성과 APT(Annotaion Processing Tool) 플러그인을 추가합니다. APT는 어노테이션으로 정의된 코드를 기반으로 새로운 코드를 생성하는 기능입니다.

    <!-- QueryDSL을 사용하기 위한 디펜던시 추가 (예제 8.24) -->
    <dependency>
      <groupId>com.querydsl</groupId>
      <artifactId>querydsl-apt</artifactId>
      <scope>provided</scope>
    </dependency>

    <dependency>
      <groupId>com.querydsl</groupId>
      <artifactId>querydsl-jpa</artifactId>
    </dependency>
      <!-- QueryDSL을 사용하기 위한 플러그인 추가 (예제 8.25) -->
      <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>

JPAAnnotationProcessor@Entity 어노테이션으로 정의된 엔티티 클래스를 찾아서 쿼리 타입을 생성합니다.

 

생성을 마치고 메이븐의 Lifectcle에서 compile 단계를 선택하여 빌드 작업을 수행합니다.

메이븐의 Lifectcle

빌드가 완료되면 outputDirectory 태그에 설정한 target/generated-sources/java에 Q도메인 클래스가 생성된 것을 확인할 수 있습니다.

target/generated-sources/java

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

 

 

 

8.6.4 기본적인 QueryDSL 사용하기

위 프로젝트 설정을 마쳤다면 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 객체를 사용합니다. JPAQuery는 엔티티 매니저를 활용하여 생성합니다. 이렇게 생성한 JPAQuery는 빌더 형식으로 쿼리를 작성합니다. 빌더 메서드에서 확인할 수 있듯이 SQL 쿼리에서 사용되는 키워드로 메서드가 구성되어 있습니다. 그렇게 때문에 메서드를 활용하여 쉽게 코드를 작성할 수 있습니다.

List 타입으로 값을 리턴받기 위하여 fetch() 메서드를 사용해야 합니다. 반환 메서드로 사용할 수 있는 메서드는 다음과 같습니다.

  • List<T> fetch(): 조회 결과를 리스트로 반환
  • T fetchOne: 단 건의 조회 결과 반환
  • T fetchFirst(): 여러 건의 조회 결과 중 1건 반환
  • Long fetchCount(): 조회 결과의 개수 반환
  • QueyResult<T> fetchResults(): 조회 결과 리스트와 개수를 포함한 QueyResults를 반환

 

JPAQuery 객체가 아닌 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를 사용했을 때와 달리 select 절부터 작성 가능합니다.

만약 전체 칼럼을 조회하지 않고 일부만 조회하고 싶다면 selectFrom()이 아닌 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 Name : " + product.get(qProduct.price));
        System.out.println("----------------");
    }
}

select 대상이 하나인 경우 리턴 타입 List<String>을 사용하고, 여러 개일 경우 List<Tuple> 타입을 사용하며 콤마(,)로 구분하여 작성합니다.

 

이제 QueryDSL를 실제 로직에서 활용할 수 있도록 설정해봅시다. 먼저 config 클래스를 생성합니다.

@Configuration
public class QueryDSLConfiguration {

    @PersistenceContext
    EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory(){
        return new JPAQueryFactory(entityManager);
    }

}

JPAQueryFactory 객체를 @Bean 객체로 등록하면 매번 JPAQueryFactory를 초기화 않고 스프링 컨테이너에서 가져다 쓸 수 있습니다. 이렇게 생성한 config 클래스는 아래와 같이 사용할 수 있습니다.

@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("----------------");
    }
}

 

 

 

8.6.4 QuerydslPredicateExecutor, QuerydslRepositorySupport 활용하기

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

 

QuerydslPredicateExecutor는 JpaRepository와 함께 리포지토리에서 QueryDSL을 사용할 수 있게 인터페이스를 제공합니다. QuerydslPredicateExecutor를 실습하기 위하여 QProductRepository 클래스를 생성합시다.

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

QProductRepository 클래스는 QuerydslPredicateExecutor를 상속받도록 설정한 Product 엔티티에 대한 리포지토리입니다. QuerydslPredicateExecutor 인터페이스 메서드는 대부분 Predicate 타입을 매개변수로 받습니다. Predicate는 표현식을 작성할 수 있도록 QueryDSL에서 제공하는 인터페이스입니다. QProductRepository에 대한 실습 코드를 작성하기 위하여 test 디렉터리에 QProductRepositoryTest 클래스를 생성합시다.

@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 클래스 역시 QueryDSL 라이브러리를 사용하는 데에 유용한 기능을 제공합니다. 가장 보편적으로 사용하는 방식은 CustomRepository를 활용하여 리포지토리를 구현하는 방식입니다.

 

지금까지 예로 든 Product 엔티티를 활용하기 위한 구조는 다음과 같습니다.

  • 앞에서 사용했던 방식처럼 JpaRepository를 상속받는 ProductRepository를 생성합니다.
  • 직접 구현한 쿼리를 사용하기 위해서는 JpaRepository를 상속받지 않는 리포지토리 인터페이스인 ProductRepositoryCustom을 생성합니다. 이 인터페이스에 정의하고자 하는 기능들을 메서드로 정의합니다.
  • ProductRepositoryCustom에서 정의한 메서드를 사용하기 위해 ProductRepository에서 ProductRepositoryCustom을 상속받습니다.
  • ProductRepositoryCustom에서 정의한 메서드를 기반으로 실제 쿼리 작성을 하기 위해 구현체인 ProductRepositoryCustomImpl 클래스를 생성합니다.
  • ProductRepositoryCustomImpl 클래스에서는 다영한 방법으로 쿼리를 구현할 수 있으며 QueryDSL을 사용하기 위하여 QuerydslRepositorySupport를 상속받습니다.

JpaRepository QuerydslRepositorySupport는 Spring Data JPA에서 제공하는 인터페이스와 클래스이며, ProductRepository와 ProductRepositoryCustom, ProductRepositoryCustomImpl은 직접 구현해야 합니다. 이와 같이 구성하면 DAO나 서비스에서 리포지토리에 접근하기 위해 ProductRepository를 사용하고, 이를 활용함으로써 QueryDSL의 기능 또한 사용할 수 있습니다.

그러면 repository 패키지 안에 support 패키지를 만들어 이를 구현해봅시다.

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

ProductRepositoryCustom 인터페이스를 생성하고 쿼리로 구현하고자 하는 메서드를 정의하였습니다. 그러면 이제 ProductRepositoryCustom의 구현체인 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을 사용하기 위하여 QuerydslRepositorySupport를 상속받았습니다. QuerydslRepositorySupport를 상속 받으면, 생성자를 통하여 도메인 클래스를 부모 클래스에 전달해야 합니다.

ProductRepositoryCustom에 정의한 findByName 메서드를 구현하였는데, 이 과정에서 Q도메인 클래스인 QProduct를 사용하여 QuerydslRepositorySupport가 제공하는 기능을 사용하였습니다. from 메서드는 어떤 도메인에 접근할 것인지 지정하는 역할을 수행하며, JPAQuery를 리턴합니다.

 

여기서 기존에 Product 엔티티 클래스와 매핑하여 사용하던 ProductRepository가 있다면 ProductRepositoryCustom을 상속받아 사용할 수 있습니다. 예제를 구분하기 위하여 ProductRepository를 새로 생성하겠습니다.

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

기존에 리포지토리를 생성하는 것과 동일하게 JpaRepository를 상속받아 구성하였습니다. 이미 ProductRepository 이름을 사용하였기 때문에 별도로 productRepositorySupport 이름을 설정하였습니다.

이 코드를 사용할 때는 ProductRepository만 이용하면 됩니다. JpaRepository에서 제공하는 메서드를 사용하거나 ProductRepositoryCustom 인터페이스에서 정의한 메서드를 구현체를 통하여 사용할 수 있습니다. 테스트를 위하여 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());
        }
    }

}

리포지토리를 구성하면서 모든 로직을 구현하였기 때문에 findByName 메서드를 사용할 때는 간단히 구현하여 사용 가능합니다.

 

 

 

8.7 JPA Auditing 적용하기

JPA에서 Audit란 감시하다라는 뜻으로, 각 데이터마다 누가, 언제 데이터를 생성했고 변경했는지 알 수 있습니다. 앞에서 작성한 코드에서 알 수 있듯 엔티티 클래스에는 생성 일자와 변경 일자 같이 공통적으로 들어가는 필드가 있습니다. 대표적으로 많이 사용되는 필드는 생성 주체, 생성 일자, 변경 주체, 변경 일자가 있습니다.

이러한 필드들은 매번 엔티티를 생성하거나 변경할 때마다 값을 주입해야 하는 번거로움이 있습니다. 이 같은 번거로움을 해소하기 위하여 Spring Data JPA에서는 값을 자동으로 넣어주는 기능을 제공합니다.

 

 

 

8.7.1 JPA Auditing 기능 활성화

먼저 스프링 부트 애플리케이션에 Auditing 기능을 활성화해야 합니다. main() 메서드가 있는 클래스에 @EnableJpaAuditing 어노테이션을 추가합니다.

@SpringBootApplication
@EnableJpaAuditing
public class AdvancedJpaApplication {

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

}

위와 같이 작성하면 정상적으로 기능이 활성화되지만 테스트 코드에서 오류가 발생할 수 있습니다. @WebMvcTest 어노테이션을 지정하여 테스트를 수행하는 코드를 작성하면 애플리케이션 클래스를 호출하는 과정에서 예외가 발생합니다. 이 같은 문제를 해결하기 위하여 별도의 Configuration 클래스를 생성하여 애플리케이션 클래스의 기능과 분리하여 활성화할 수 있습니다.

@Configuration
@EnableJpaAuditing
public class JpaAuditingConfiguration {}

이 방법을 사용한다면, main() 메서드가 있는 클래스에 추가한 @EnableJpaAuditing 어노테이션은 삭제해야 합니다.

 

 

 

8.7.1 BaseEntity 만들기

코드의 중복을 없애기 위해서 각 엔티티에 공통으로 들어가는 칼럼을 하나의 클래스로 빼내는 작업을 수행할 수 있습니다. 생성 주체와 변경 주체를 활용하지 않았기 때문에 제외하고, 생성 일자와 변경 일자만 추가하여 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: 데이터 수정 날짜를 자동으로 주입합니다.

이렇게 BaseEntity 클래스를 생성했다면 Product 엔티티 클래스에서 공통 칼럼을 제거할 수 있습니다.

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(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;

}

이렇게 설정하면 매번 LocalDateTime.now() 메서드를 사용하여 시간을 주입하지 않아도 자동으로 값이 생성됩니다. 테스트 코드를 작성하여 실행해봅시다.

@Test
public void auditingTest(){
    Product product = new Product();
    product.setName("펜");
    product.setPrice(1000);
    product.setStock(100);

    Product savedProduct = productRepository.save(product);

    System.out.println("productName : " + savedProduct.getName());
    System.out.println("createdAt : " + savedProduct.getCreatedAt());
}

위 예제에서 Product 엔티티에 생성일자를 입력하지 않은 상태에서 데이터베이스에 저장했지만 정상적으로 생성일자가 저장되며, 엔티티의 필드를 출력하면 해당 시간이 출력됩니다.

 

 

 

 


Quiz

1. SQL과 비슷하지만, 엔티티 객체를 대상으로 수행하는 객체인 (   JPQL   )은 JPA에서 사용할 수 있는 쿼리다.

2. 쿼리 메서드는 크게 (   주제   )와 (   서술어   )로 구분할 수 있다.

3. 쿼리 메서드의 주제는 (   키워드   )로 정하며 (   By   )는 서술어의 시작을 나타내는 구분자이다.

4. (   Sort   ) 클래스는 내부 클래스로 정의되어 있는 Order 객체를 활용하여 정렬 기준을 생성한다.

5. JPA에서는 페이징 처리를 위하여 (   Page   )  (   Pageable   )을 사용한다.

6.  (   @Query   )  어노테이션을 사용하여 직접 JPQL을 작성할 수 있으며, (   @QueryDSL   ) 는 문자열이 아니라 코드로 쿼리를 작성할 수 있도록 돕는다.

7. main() 메서드가 있는 클래스에 (@EnableJpaAuditing) 어노테이션을 추가하여 Auditing 기능을 활성화한다.

8. name으로 Student을 찾고 grade를 기준으로 내림차순 정렬 후, age를 기준으로 오름차순 정렬하는 메서드를 정의하시오.

9. Q도메인(QStudent) 쿼리 타입의 클래스를 활용하여 grade가 '3'인 Student 객체를 age를 기준으로 내림차순 정렬하여 조회하는 코드를 작성하시오.

 

 

 

// 8번 정답
List<Student> findByNameOrderByGrabeDescAgeAse(String name);
// 9번 정답
QStudent qStudent = QStudent.student;

List<Student> studentList = query
        .from(qStudent)
        .where(qStudent.grade.eq('3'))
        .orderBy(qStudent.age.desc())
        .fetch();

 

728x90

관련글 더보기