북스터디 : 스프링 부트 핵심가이드(4)

윤장원·2023년 4월 1일
0

8. Spring Data Jpa 활용

JPQL

JPQL은 JPA Query Language의 줄임말로 JPA에서 사용할 수 있는 쿼리를 의미한다. JPQL의 문법은 SQL과 매우 비슷해서 데이터베이스 쿼리에 익숙한 사람들이라면 어렵지 않게 사용할 수 있다. SQL과의 차이점은 SQL에서는 테이블이나 칼럼의 이름을 사용하는 것과 달리 JPQL은 엔티티 객체를 대상으로 수행하는 쿼리이기 때문에 매핑된 엔티티의 이름과 필드의 이름을 사용한다는 것이다.

쿼리 메서드

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

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

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

쿼리 메서드의 주제 키워드

  1. 조회하는 기능
  • find...By
  • read...By
  • get...By
  • query...By
  • search...By
  • stream...By
  1. exists...By : 특정 데이터가 존재하는지 확인
  2. count...By : 조회 쿼리를 수행한 후 쿼리 결과로 나온 레코드의 개수 리턴
  3. delete...By, remove...By : 삭제 쿼리 수행
  4. ...First..., ...Top... : 쿼리를 통해 조회된 결괏값의 개수를 제한하는 키워드

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

  1. Is
    값의 일치를 조건으로 사용하는 조건자 키워드이다. 생갹되는 경우가 많으며 Equals와 동일한 기능을 수행한다.
  2. (Is)Not
    값의 불일치를 조건으로 사용하는 조건자 키워드이다. Is는 생략하고 Not 키워드만 사용할 수도 있다.
  3. (Is)Null, (Is)NotNull
    값이 null인지 검사하는 조건자 키워드이다.
  4. (Is)True, (Is)False
    boolean 타입으로 지정된 칼럼값을 확인하는 키워드이다.
  5. And, Or
    여러 조건을 묶을 때 사용
  6. (Is)GreaterThan, (Is)LessThan, (Is)Between
    숫자나 datetime 칼럼을 대상으로 한 비교 연산에 사용할 수 있는 조건자 키워드이다. 경곗값을 포함하려면 Equal 키워드를 추가하면 된다.
  7. (Is)StartingWith, StartsWith, (Is)EndingWith, EndsWith, (Is)Containing, Contains, (Is)Like

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

정렬과 페이징 처리

정렬 처리하기

일반적으로 쿼리문에서 정렬을 사용할 때는 ORDER BY 구문을 사용한다. Asc 옵션을 사용하면 오름차순, Desc 옵션을 사용하면 내림차순으로 정렬한다. 정렬 구문은 And나 Or 키워드를 사용하지 않고 우선순위를 기준으로 차례대로 여러 조건을 작성하면 된다.
ex)

List<Product> findByNameOrderByPriceAscStockDesc(String name);

쿼리 메서드의 이름에 정렬 키워드를 삽입해서 정렬을 수행하는 것도 가능하지만 메서드의 이름이 길어질수록 가독성이 떨어지는 문제가 생긴다. 이를 해결하기 위해 매개변수를 활용해 정렬할 수 있다.
ex)

List<Product> findByName(String name, Sort sort);

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

페이징 처리

페이징이란 데이터베이스의 레코드를 개수로 나눠 페이지를 구분하는 것을 의미한다. JPA에서는 페이징 처리를 위해 Page와 Pageable을 사용한다.

ex)

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

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

메서드를 호출할 때 리턴 타입으로 Page 객체를 받아야 하기 때문에 Page로 타입을 선언해서 객체를 리턴받는다. 그리고 Pageable 파라미터를 전달하기 위해 PageRequest 클래스를 사용한다. PageRequest는 Pageable의 구현체이다.

일반적으로 PageRequest는 of 메서드를 통해 PageRequest 객체를 생성한다. of 메서드는 매개변수에 따라 다양한 형태로 오버로딩돼 있다.

@Query 어노테이션 사용하기

데이터베이스에서 값을 가져올 때 @Query 어노테이션을 사용해 직접 JPQL을 작성할 수도 있다. 주로 튜닝된 쿼리를 사용하고자 할 때 직접 SQL을 작성한다.

ex)

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

쿼리문에서 SQL 예약어에 해당하는 단어는 소문자로 작성해도 된다. As는 별칭을 생성하는 것이므로 생략 가능하다. 조건문에서 사용한 '?1'은 파라미터를 전달받기 위한 인자에 해당한다. 1은 첫 번째 파라미터를 의미한다. 하지만 이 같은 방식을 사용할 경우 파라미터의 순서가 바뀌면 오류가 발생할 가능성이 있어 @Param 어노테이션을 사용하는 것이 좋다.

ex)

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

QeuryDSL 적용하기

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

이 같은 문제를 해결하기 위해 사용되는 것이 QueryDSL이다. QueryDSL은 문자열이 아니라 코드로 쿼리를 작성할 수 있도록 도와준다.

QeuryDSL이란?

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

QueryDSL의 장점

  • IDE가 제공하는 코드 자동 완성 기능을 사용할 수 있다.
  • 문법적으로 잘못된 쿼리를 허용하지 않는다.
  • 고정된 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>

또한 태그에 QeuryDSL을 사용하기 위한 APT 플러그인을 추가해야 한다.

<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>
        </configuration>
      </execution>
    </executions>
  </plugin>

APT는 어노테이션으로 정의된 코드를 기반으로 새로운 코드를 생성하는 기능이다.

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

기본적인 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을 사용하기 위해서는 JPAQuery 객체를 사용한다. JPAQery는 엔티티 매니저(EntityManager)를 활용해 생성한다. 이렇게 생성된 JPAQuery는 빌더 형식으로 쿼리를 작성한다.

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

JPAQuery를 사용했을 때와 달리 JPAQueryFactory에서는 select 절부터 작성 가능하다.

QueryDSL을 실제 비즈니스 로직에서 활용할 수 있도록 설정은 다음과 같이 컨피그 클래스를 생성한다.

import com.querydsl.jpa.impl.JPAQueryFactory;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class QueryDSLConfiguration {

  @PersistenceContext
  EntityManager entityManager;

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

이렇게 생성한 컨피그 클래스는 다음과 같이 사용할 수 있다.

@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을 사용할 수 있게 인터페이스를 제공한다.

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

}

QuerydslPredicateExecutor 인터페이스의 메서드는 대부분 Predicate 타입을 매개변수로 받는다. Predicate를 이용해 findOne() 메서드를 호출하는 방법은 다음과 같다.

@SpringBootTest
public class QProductRepositoryTest {

    @Autowired
    QProductRepository qProductRepository;

   
    @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는 간단하게 표현식으로 정의하는 쿼리로 생각하면 된다.
QuerydslPredicateExecutor를 사용하면 더욱 편하게 QueryDSL을 사용할 수 있지만 joindlsk fetch 기능은 사용할 수 없다는 단점이 있다.

JPA Auditing 적용

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

  • 생성 주체
  • 생성 일자
  • 변경 주체
  • 병경 일자

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

JPA Auditing 기능 활성화

가장 먼저 스프링 부트 애플리케이션에 Auditing 기능을 화렁화해야 한다. main() 메서드가 있는 클래스에 @EnableJpaAuditing 어노테이션을 추가하면 된다. 이렇게 하면 정상적으로 기능이 활성화되지만 @WebMvcTest 어노테이션을 지정해서 테스트를 수행하는 코들르 작성하면 애플리케이션 클래스를 호출하는 과정에서 예외가 발생할 수 있다. 이 같은 문제를 해결하기 위해 별도의 Configuration 클래스를 생성해서 애플리케이션 클래스의 기능과 분리해서 활성화할 수 있다.

Configuration 클래스 생성

@Configuration
@EnableJpaAuditing
public class JpaAuditingConfiguration {
 
}

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;

}

0개의 댓글