- JPQL
- 쿼리 메서드
- 정렬과 페이징 처리
- @Query
- QueryDSL
- JPA Auditing
Spring Data JPA에서 제공하는 기능들에 대해 더 알아보고 다양한 활용법에 대해 살펴본다.
그 과정에서 리포지토리 예제를 작성하고 ,
리포지토리의 활용법을 테스트 코드를 통해 학습
실습 프로젝트는 새로 생성하지 않고
지난번에 했던 프로젝트에서 진행!
spring.io - Spring Data JPA Document
JPA Query Language
SQL과 문법이 매우 비슷하다.
SQL
테이블이나 컬럼의 이름을 사용.
JPQL
매핑된 엔티티의 이름과 필드의 이름을 사용.
엔티티 객체를 대상으로 수행하는 쿼리이기 때문
_____________________🐢🎈____________________
JPA에서 제공하는 기본 메서드들은 식별자 기반으로 생성되기 때문에
별도의 메서드를 정의해서 사용하는 경우가 많다.
이때, 간단한 쿼리문을 작성하기 위해 사용되는 것이 쿼리 메서드!
쿼리 메서드는 큰 범주로 분류해 보면
동작을 결정하는 주제 Subject
와 서술어 Predicate
로 나눠볼 수 있다.
// 리턴타입 주제와서술어(속성)
List<Person> findByLastnameAndEmail(String lastName, String email);
주제 부분에 사용할 수 있는 주요 키워드는 다음과 같다.
가장 일반적
으로 사용null
반환단일 엔티티
만을 반환.예외
던짐find...By
와 동일하게 동작find...By
와 동일하게 동작find...By
와 동일하게 동작find...By
와 동일하게 동작반환 타입
을 Stream
으로 지정하여boolean
반환boolean existsByNumber(Long number);
long countByName(String name);
void deleteByNumber(Long number);
long removeByName(String name);
한번의 동작으로 여러건을 조회할 때 사용.
단건 조회시 <number> 생략
List<Product> findFirst5ByName(String name);
List<Product> findTop10ByName(String name);
값의 일치
를 조건으로 사용하는 키워드
생략되는 경우가 많으며,
Equals 와 동일한 기능을 수행한다.
// findByNumber 메서드와 동일한 동작
Product findByNumberIs(Long number);
Product findByNumberEquals(Long number);
값의 불일치
를 조건으로 사용하는 키워드
Is는 생략 가능
Product findByNumberIsNot(Long number);
Product findByNumberNot(Long number);
값이 null
인지 검사하는 키워드
List<Product> findByUpdatedAtNull();
List<Product> findByUpdatedAtIsNull();
List<Product> findByUpdatedAtNotNull();
List<Product> findByUpdatedAtIsNotNull();
데이터 타입이 boolean인 컬럼의 값을 확인하는 키워드
Product findByidActiveTrue();
Product findByidActiveIsTrue();
Product findByidActiveFalse();
Product findByidActiveIsFalse();
여러 조건을 묶을 때 사용
Product findByNumberAndName(Long number, String name);
Product findByNumberOrName(Long number, String name);
숫자
나 datetime
컬럼을 대상으로 한 비교 연산에 사용할 수 있는 키워드
GreaterThan과 LessThan은
기본적으로 경곗값은 포함하지 않으며
경곗값
을 포함
하려면 Equal
키워드를 추가하면 된다.
(Is)GreaterThan
: 비교 대상에 대한 초과의 개념으로 비교연산 수행
(Is)LessThan
: 비교 대상에 대한 미만의 개념으로 비교연산 수행
(Is)Between
: 비교 대상에 대해 범위를 적용하여 연산 수행
// price보다 큰 값을 갖는 Price컬럼 조회
List<Product> findByPriceIsGreaterThan(Long price);
List<Product> findByPriceGreaterThan(Long price);
// price 값 이상인 Price 컬럼 조회
List<Product> findByPriceGreaterThanEqual(Long price);
// price보다 작은 값을 갖는 Price 컬럼 조회
List<Product> findByPriceIsLessThan(Long price);
List<Product> findByPriceIsLessThan(Long price);
// price 값 이하인 Price 컬럼 조회
List<Product> findByPriceIsLessThanEqual(Long price);
// lowPrice 이상 highPrice 이하인 Price 컬럼 조회
List<Product> findByPriceIsBetween(Long lowPrice, Long highPrice);
List<Product> findByPriceBetween(Long lowPrice, Long highPrice);
컬럼 값에서 일부 일치
여부를 확인하는 키워드
SQL 쿼리에서 %
키워드와 동일한 역할을 한다.
(Is)StartingWith
== StartsWith
문자열의 앞
검색
(Is)EndingWith
== EndsWith
문자열의 끝
검색
(Is)Containing
== Contains
문자열의 양 끝
검색
(Is)Like
SQL의 Like 절 조건문에서와 마찬가지로
메서드에서 전달하는 인자
에 %
를 명시
적으로 입력해주여야 한다.
// name컬럼 값이 '김'으로 시작하는 데이터 조회
List<Product> findByNameLike(String name); // String name = "김%"
List<Product> findByNameIsLike(String name); // String name = "김%"
// name 값이 name 컬럼에 포함돼있는 데이터 조회
List<Product> findByNameContains(String name);
List<Product> findByNameContaining(String name);
List<Product> findByNameIsContaining(String name);
// name 값으로 시작하는 name 컬럼 데이터 조회
List<Product> findByNameStartsWith(String name);
List<Product> findByNameStartingWith(String name);
List<Product> findByNameIsStartingWith(String name);
// name 값으로 끝나는 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);
정렬을 위한 메서드를 작성할 때,
정렬 조건을 여러개로 나타내야 할 경우
And 나 Or 키워드를 사용하지 않고
우선순위를 기준으로 차례대로 작성해주면 된다.
List<Product> findByNameOrderByPriceAscStockDesc(String name);
다만, 이렇게 여러개의 조건을 메서드 이름으로 나열할 경우
가독성이 떨어지는 문제
가 생긴다.
Sort
클래스를 매개변수
로 전달하여
정렬을 처리할 수도 있다.
List<Product> findByName(String name, Sort sort);
productRepository
.findByName(
"펜",
Sort.by(
Sort.Order.asc("price"),
Sort.Order.desc("stock")
)
);
@Test
void sortingAndPagingTest() {
...
productRepository.findByName("펜", getSort())
}
private Sort getSort() {
return Sort.by(
Order.asc("price"),
Order.asc("stock")
);
}
페이징은
데이터베이스의 레코드를
개수로 나눠
페이지를 구분하는것을 의미한다.
JPA에서는 페이징 처리를 위해 Page
와 Pageable
을 사용한다.
Page<Product> findByName(String name, Pageable pageable);
리턴 타입으로 Page
를 설정하고,
매개변수에 Pageable
타입의 객체를 정의한다.
위의 예제를 사용하기 위해서는 아래와 같이 호출한다.
Page<Product> productPage
= productRepository
.findByName(
"펜",
PageRequest.of(0, 2)
);
Pageable을 매개변수로 받으면,
해당 메서드의 반환 타입은 Page 객체가 된다.
PageRequest는 Pageable의 구현체이다.
_____________________🎈____________________
데이터베이스에서 값을 가져올 때는
앞 절에서 소개한 것처럼
메서드의 이름만으로 쿼리 메서드를 생성할 수도 있고
이번 절에서 살펴볼 @Query
어노테이션을 사용해
직접 JPQL을 작성할 수도 있다.
데이터베이스를 다른 데이터베이스로 변경할 일이 없다면
직접 특화된 SQL을 작성할 수 있으며,
주로 튜닝된 쿼리를 사용하고자 할때
직접 SQL을 작성한다.
Repository에 JPQL 이용 상품정보 조회 메서드 추가
@Query("SELECT p FROM Product AS p WHERE p.name = ?1")
List<Product> findByName(String name);
WHERE절에서 사용된 ?1
은
첫 번째 파라미터에 매개변수를 전달받기 위해 사용된 것이다.
이 방법은 순서가 꼬이기 쉽고,
그만큼 오류가 나기 쉽기 때문에 추천하지 않고
@Param
어노테이션을 사용한 다음의 방법을 추천한다.
@Query("SELECT p FROM Product AS p WHERE p.name = :name")
List<Product> findByName(@Param("name") String name);
@Query("SELECT p.name, p.price, p.stock FROM Product p WHERE p.name = :name")
List<Object[]> findByNameParam(@Param("name") String name);
_____________________🐢🎈____________________
메서드의 이름을 기반으로 생성하는 JPQL의 한계를
@Query
어노테이션을 통해 대부분 해소할 수 있지만
직접 문자열을 입력하기 때문에,
컴파일 시점에 에러를 잡지 못하고 런타임 에러가 발생
할 수 있다.
이러한 이유로
개발환경에서는 문제가 없는 것처럼 보이다가
실제 운영
환경에 어플리케이션을 배포하고 나서
오류가 발견
되는 리스크를 유발한다.
이와 같은 문제를 해결
하기 위해 사용되는 것이 QueryDSL
이다.
QueryDSL은 문자열이 아니라 코드로 쿼리를 작성할 수 있도록 도와준다.
정적 타입
을 이용해
SQL과 같은 쿼리
를 생성
할 수 있도록 지원하는
프레임워크
이다.
문자열이나 XML 파일을 통해 쿼리를 작성하는 대신
QueryDSL이 제공하는 플루언트 API
를 활용해
쿼리를 생성할 수 있다.
IDE가 제공하는 코드 자동 완성 기능
을 사용할 수 있다.
문법적으로 잘못된 쿼리를 허용하지 않는다.
고정된 SQL 쿼리를 작성하지 않기 때문에
동적으로 쿼리
를 생성할 수 있다.
코드로 작성하므로 가독성과 생산성이 향상
된다.
도메인 타입과 프로퍼티를 안전하게 참조
할 수 있다.
spring boot 2.7.12 기준
Spring Boot 2.6 이상 버전에서는 Querydsl 5.0을 사용한다.
querydsl-jpa
querydsl-apt
plugins {
...
id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}
// -------- queryDSL 설정 추가 -------------------
// querydsl에서 사용할 경로 설정
def querydslDir = "$buildDir/generated/querydsl"
// JPA 사용 여부와 사용할 경로를 설정
querydsl {
jpa = true
querydslSourcesDir = querydslDir
}
// build 시 사용할 sourceSet 추가
sourceSets {
main.java.srcDir querydslDir
}
// querydsl 컴파일시 사용할 옵션 설정
compileQuerydsl{
options.annotationProcessorPath = configurations.querydsl
}
// querydsl 이 compileClassPath 를 상속하도록 설정
configurations {
compileOnly {
extendsFrom annotationProcessor
}
querydsl.extendsFrom compileClasspath
}
gradle build 후 어플리케이션을 실행하면 Q도메인 파일이 생성된다.
나는 이 프로젝트에서 사용한 도메인 이름이 Product라서
파일 이름이 QProduct
라고 생겼다.
Q도메인 파일은 아래 위치에 있다.
jpaStudy
- build
- generated
- querydsl
- proj.package.path.data.entity
- QProduct.java
..
@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("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());
}
}
@PersistenceContext
EntityManager entityManager;
@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("==========================================");
}
}
@PersistenceContext
EntityManager entityManager;
@Test
void queryDslTest3() {
JPAQueryFactory jpaQueryFactory = new JPAQueryFactory(entityManager);
QProduct qProduct = QProduct.product;
// Select 대상이 1개인 경우
List<String> productList = jpaQueryFactory
.select(qProduct.name)
.from(qProduct)
.where(qProduct.name.eq("펜"))
.orderBy(qProduct.price.asc())
.fetch();
productList.forEach(productName -> {
System.out.println("=============================");
System.out.println("productName : " + productName);
System.out.println("=============================");
});
// Select 대상이 여러개인 경우
List<Tuple> tupleList = jpaQueryFactory
.select(qProduct.name, qProduct.price)
.from(qProduct)
.where(qProduct.name.eq("펜"))
.orderBy(qProduct.price.asc())
.fetch();
tupleList.forEach(product -> {
System.out.println("=============================");
System.out.println("productName : " + product.get(qProduct.name));
System.out.println("productPrice : " + product.get(qProduct.price));
System.out.println("=============================");
});
}
JPAQueryFactory 객체를 Bean으로 등록하여
필요시 객체를 Spring 에서 주입하도록 설정
@Configuration
public class QueryDSLConfiguration {
@PersistenceContext
EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
JPAQueryFactory를 Spring에서 주입해줌으로써
EntityManager 와 JPAQueryFactory 초기화 과정이 필요없어졌다.
@Autowired
JPAQueryFactory jpaQueryFactory;
@Test
void queryDslTest3() {
QProduct qProduct = QProduct.product;
List<String> productList = jpaQueryFactory
.select(qProduct.name)
.from(qProduct)
.where(qProduct.name.eq("펜"))
.orderBy(qProduct.price.asc())
.fetch();
productList.forEach(productName -> {
System.out.println("=============================");
System.out.println("productName : " + productName);
System.out.println("=============================");
});
}
Spring Data JPA 에서는
QueryDSL을 더욱 편하게 사용할 수 있게
QuerydslPredicateExcutor 인터페이스와
QuerydslRepositorySupport 클래스를 제공한다.
QuerydslPredicateExcutor는
JpaRepository와 함께
리포지토리에서 QueryDSL을 사용할 수 있게 인터페이스를 제공한다.
QuerydslPredicateExcutor 인터페이스를 활용하면
더욱 편하게 QueryDSL을 사용할 수 있지만
join
, fetch
사용 불가
실습!
public interface QProductRepository extends JpaRepository<Product, Long> ,
QuerydslPredicateExecutor<Product> {
}
@SpringBootTest
class QProductRepositoryTest {
@Autowired
QProductRepository qProductRepository;
@Test
void queryDSLTest2() {
QProduct qProduct = QProduct.product;
Iterable<Product> productList = qProductRepository.findAll(
qProduct.name.contains("펜")
.and(qProduct.price.between(550, 1500))
);
productList.forEach(product -> {
System.out.println(product.getNumber());
System.out.println(product.getStock());
});
}
@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());
}
}
}
QuerydslRepositorySupport 클래스 역시
QueryDSL 라이브러리를 사용하는데 유용한 기능을 제공한다.
가장 보편적인 사용방식은
CustomRepository를 활용해 리포지토리를 구현하는 방식이다.
간단하게 구조를 설명하자면 다음과 같다.
먼저 앞에서 사용했던 방식처럼
JpaRepository를 상속받는
ProductRepository
를 생성
한다.
이때 직접 구현한 쿼리를 사용하기 위해서는
JpaRepository를 상속받지 않는
리포지토리 인터페이스
인
ProductRepositoryCustom
을 생성
한다.
이 인터페이스에 정의하고자 하는 기능들을 정의!
ProductRepositoryCustom에서 정의한 메서드를 사용하기 위해
ProductRepository에서 ProductRepositoryCustom을 상속
받는다.
ProductRepositoryCustom에서 정의된 메서드를 기반으로
실제 쿼리 작성을 하기 위해
구현체인 ProductRepositoryCustomImpl 클래스를 생성
한다.
ProductRepositoryCustomImple 클래스에서
는
다양한 방법으로 쿼리를 구현할 수 있지만
QueryDSL을 사용하기 위해
QueryDslRepositorySupport를 상속받는다.
위와 같이 구성하면
DAO나 서비스에서
리포지토리에 접근하기 위해 ProductRepository를 사용한다.
ProductRepository를 활용함으로써 QueryDSL의 기능도 사용할 수 있게 된다.
실습!
이전에 만들어둔 인터페이스 이름과 겹치지 않게
support 패키지 생성 후 이 안에 구현.
2-1-1. ProductRepositoryCustom
public interface ProductRepositoryCustom {
List<Product> findByName(String name);
}
2-1-2. ProductRepositoryCustomImpl
@Component
public class ProductRepositoryCustomImpl extends QuerydslRepositorySupport implements ProductRepositoryCustom {
// QuerydslRepositorySupport 상속시 필수 구현 부분
// 생성자를 통해 도메인 클래스를 부모 클래스에 전달해야 한다.
public ProductRepositoryCustomImpl() {
super(Product.class);
}
@Override
public List<Product> findByName(String name) {
QProduct product = QProduct.product;
return from(product) // QuerydslRepositorySupport 제공 메서드. JPAQuery를 반환한다.
.where(product.name.eq(name)) // 이하 QueryDSL(JPAQuery) 메서드
.select(product)
.fetch();
}
}
2-1-3. ProductRepository
@Repository("productRepositorySupport") // 다른 파일과 같은 이름으로, bean 충돌 방지를 위해 bean 이름 지정
public interface ProductRepository extends JpaRepository<Product, Long> ,
ProductRepositoryCustom {
}
리포지토리를 생성하면서
모든 로직을 구현했기 때문에
findByName() 메서드를 사용할 때는
간단히 구현해서 사용할 수 있다.
@SpringBootTest
class ProductRepositoryTest {
@Autowired
ProductRepository productRepository;
@Test
void findByNameTest() {
List<Product> productList = productRepository.findByName("펜");
productList.forEach(product -> {
System.out.println(product.getNumber());
System.out.println(product.getName());
System.out.println(product.getPrice());
System.out.println(product.getStock());
});
}
}
_____________________🐢🎈____________________
JPA에서 Auditing
이란 감시하다
라는 뜻으로
각 데이터마다 누가
, 언제
데이터를 생성했고 변경했는지
감시한다는 의미로 사용됨
엔티티 클래스에는 공통적으로 들어가는 필드가 있다.
예를 들면,
생성 일자
와 변경 일자
가 있다.
이런 필드들은
매번 생성하가나 변경할 때 마다 값을 주입해야 하는 번거로움이 있다.
이런 번거로움을 해결하기 위해 JPA Auditing을 사용한다.
스프링 부트 애플리케이션에 Auditing 기능을 활성화해야 한다.
Config 파일을 새로 만든 후
@EnableJpaAuditing
어노테이션을 추가해주면 된다.
@Configuration
@EnableJpaAuditing
public class JpaAuditingConfiguration {
}
각 엔티티에 공통으로 들어가게 되는 컬럼을 하나의 클래스로 분리하고
@MappedSuperclass
: JPA 엔티티 클래스가 상속받을 경우 자식 클래스에게 매핑 정보를 전달한다.
@EntityListeners(AuditingEntityListener.class)
: 엔티티를 데이터베이스에 적용하기 전후로 콜백을 요청할 수 있게 한다.
위 2개의 어노테이션은 꼭 달아주어야 한다.
@Getter
@Setter
@ToString
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@MappedSuperclass // JPA 엔티티 클래스가 상속받을 경우 자식 클래스에게 매핑 정보를 전달한다.
@EntityListeners(AuditingEntityListener.class) // 엔티티를 데이터베이스에 적용하기 전후로 콜백을 요청할 수 있게 한다.
public class BaseEntity {
@CreatedDate // 데이터 생성 날짜 자동 주입
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate // 데이터 수정 날짜 자동 주입
private LocalDateTime updatedAt;
}
@EqualsAndHashCode
@ToString
위의 두 어노테이션은 callSuper 옵션을 true로 설정해주고,
@Builder 어노테이션은 @SuperBuilder 어노테이션으로 변경해준다.
그리고 BaseEntity를 상속받는다.
@EqualsAndHashCode(callSuper = true) // callSuper = true : 부모 클래스 필드 포함
@ToString(callSuper = true) // callSuper = true : 부모 클래스 필드 포함
@Getter
@Setter
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@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;
}
@Autowired
ProductRepository productRepository;
@Test
void auditingTest() {
Product product = Product.builder()
.name("펜")
.price(1000)
.stock(100)
.build();
Product save = productRepository.save(product);
System.out.println("product name : " + save.getName());
System.out.println("createdAt : " + save.getCreatedAt());
}
테스트 통과 확인