Spring Data JPA

goose_bumps·2025년 4월 21일

SpringBoot

목록 보기
5/9
post-thumbnail

이전 포스팅에서 간략하게 소개했던 Spring Data JPA에서 제공하는 기능들을 활용해보겠다.

그전에 복습차원에서 설명하자면 Java의 객체와 데이터베이스의 Table을 매핑하는 기능을 ORM이라 하며 자바진영에서 표준으로 채택한 ORM이 바로 JPA이다.

JPA를 구현한 것이 Hibernate이고 이를 모듈화, 자주 사용되는 기능을 쉽게 사용할 수 있도록 구현한 라이브러리를 Spring Data JPA라고 한다.

즉, 객체와 테이블 매핑을 위해 자바에서 사용되는 라이브러리라고 생각하면 된다.

1. 쿼리 메서드

JPQL이란 JPA Query Language의 줄임말로 JPA에서 사용할 수 있는 쿼리를 말한다. 데이터베이스 쿼리와 비슷하지만 테이블이 아닌 Entity 객체를 대상으로 사용하는 쿼리이기 때문에 매핑된 Entity의 이름과 필드의 이름을 사용한다.

쿼리 메서드를 사용하면 이 JPQL을 메서드 이름만으로 자동으로 생성할 수 있다.
이전 포스팅에서 JpaRepository를 상속받은 ProductRepository로부터 save,delete 등을 호출해서 사용했는데 이러한 기본적인 CRUD 메서드들도 쿼리 메서드에 해당한다.

하지만, 기본 쿼리 메서드만으로 부족할 경우 쿼리 메서드를 작성할 필요가 있다.

여기서 짚고 넘어가야 할 점은 "그럼 왜 JPQL을 사용하지 않고 쿼리 메서드를 사용하는가"이다.
사실 어떤 것을 사용하고 말고가 아니라 필요에 따라 선택하면 된다.

항목쿼리 메서드JPQL
작성방식메서드 이름 조합@Query 안에 쿼리 작성
가독성간단한 조건일때 좋음복잡한 조건도 명확하게 표현 가능
유연성제한적매우 유연
유지보수단순 쿼리는 편함복잡한 쿼리는 JPQL이 더 유지보수가 용이
타입 안전성O(컴파일 시점에 오류가 잡힘)일부는 런타임 오류 가능(문자열 기반)

쿼리 메서드의 작동 메커니즘은 다음과 같다.

쿼리 메서드

Spring Data JPA가 메서드 파싱

JPQL 생성

Hibernate 같은 JPA 구현체가 JPQL → SQL 변환

DB에서 실행

1) 네이밍 규칙

쿼리 메서드는 크게 동작을 결정하는 주제(Subject)와 서술어(Predicate)로 구분한다.
예를 들어, findById에서 findBy가 쿼리의 주제를 말하며, 서술어 시작의 구분자 역할을 By가 한다.
By 뒤로는 검색/정렬 조건을 지정하는 영역이고 이 곳이 서술어 부분이다.

그렇다면 findById는 주제가 findBy이기 때문에 어떠한 데이터를 찾는 주제이고 검색 조건이 Id라는 것을 알 수 있다.

2) 주제 키워드

주제로 사용할 수 있는 주요 키워드는 find~By, exists~By, count~By,delete(remove)~By,~First(Top)<Number>~ 등이 있다.

//find~By : 특정 데이터 조회
ex) 
Optional<Product> findByNumber(Long number);
List<Product> findAllByName(String name);

//exists~By : 특정 데이터 존재 여부 확인. 리턴 타입은 boolean
ex)
boolean existsByNumber(Long number);

//count~By : 조회 결과로 나온 레코드의 개수 반환
ex)
long countByName(String name);

//delete~By, remove~By : 삭제 쿼리 수행. 리턴 타입이 없거나 삭제한 횟수 반환
ex)
void deleteByNumber(Long number);
long removeByName(String name);

//~First<number>~, ~Top<number>~ : 조회된 결과값 개수를 제한. 단 건으로 조회 시 <number> 생략
ex)
List<Product> findFirst5ByName(Strintg name); -> name으로 조회 후 결과값 개수는 처음 5개로 제한
List<Product> findTop10ByName(String name); -> name으로 조회 후 상위 10개만 결과값으로 반환

3) 서술어 조건자 키워드

이번에는 검색/정렬 조건에 조건자로 들어갈 수 있는 주요 키워드에 대해 알아보자.

//Is(Equals) : 값의 일치 조건. 생략되는 경우가 많음
ex)
Product findByNumberIs(Long number); -> 매개변수와 일치하는 데이터 검색

//(Is)Not : 값의 불일치 조건. Is 생략가능
ex)
Product findByNumberIsNot(Long number); -> 매개변수와 일치하지 않는 데이터 검색

//(Is)Null, (Is)NotNull : 값이 null인지 검사. Is 생략 가능
ex)
List<Product> findByPriceNull(); -> price 필드가 null인 데이터 조회

//And, Or : 여러 조건을 묶을 때 사용
ex)
Product findByNumberAndName(Long number, String name); 
-> 매개변수로 들어간 number와 name을 검색 조건으로 사용

//(Is)GreaterThan, (Is)LessThan, (Is)Between : 비교 연산. 경계값 포함 시 Equal 추가
ex)
List<Product> findByPriceGreaterThan(Long price) -> 데이터 > price 조회
List<Product> findByPriceLessThan(Long price) -> 데이터 < price 조회
List<Product> findByPriceBetween(Long lowerPrice, Long higherPrice)
-> lowerPrice < 데이터 < higherPrice
List<Product> findByPriceGreaterThanEqual(Long price) -> 데이터 >= price 조회

여기서 주의할 점은 쿼리 메서드 작성 시 리턴 타입을 아무렇게나 정의하면 안된다는 것이다.
예를 들어, 특정 가격대의 데이터를 조회할 경우 여러 개의 데이터가 반환될텐데 리턴 타입을 Product로 정의하면 안되는 것이다.

그래서 일반적으로는 타입을 정할 때 다음과 같이 정의한다.

// 여러 개
List<Product> findByPriceBetween(Long lower, Long higher);

// 하나 (단일 결과일 거라고 확신할 때)
Optional<Product> findById(Long id);

findById는 반환타입이 Optional이고 만약 Entity객체 타입 그대로 반환하고 싶다면 getReferenceById를 사용하면 된다.

2. 정렬 처리

정렬 처리는 보통 오름차순 아니면 내림차순으로 하는데 쿼리 메서드만으로 정의하거나 매개변수를 활용할 수 있다.

1) 쿼리 메서드로 정렬 처리

쿼리 메서드에서 정렬 처리할 경우 서술어 조건자로 OrderBy를 추가해주면 된다.
name으로 데이터를 조회하고 이를 number를 기준으로 정렬 처리하고 싶을 경우

List<Product> findByNameOrderByNumberAsc(String name);
List<Product> findByNameOrderByNumberDesc(String name);

예시와 같이 정의하면 되는데 여기서 끝에 Asc는 오름차순으로, Desc는 내림차순으로 정렬 처리하는 기능을 한다.

정렬 기준을 여러 개로 정의할 경우 AndOr같은 키워드를 사용하지 않고 뒤에 쿼리 메서드를 이어 붙이면 된다.

name으로 데이터를 조회 후 먼저 number를 기준으로 오름차순 정렬한 후 후순위로 stock을 기준으로 내림차순 정렬을 하려면

//우선순위 number > stock
List<Product> findByNameOrderByNumberAscStockDesc(String name);

우선순위대로 작성하면 된다.

하지만 이러한 방법은 정렬 기준이 많아질수록 가독성이 너무 떨어지는 단점이 있다.
이를 해결할 수 있는 방법이 바로 매개변수를 활용하는 방법이다.

2) 매개변수를 활용하여 정렬 처리

위의 예시를 그대로 사용하여 매개변수를 활용한다면

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

이처럼 Sort 객체를 매개변수로 활용하여 정렬 기준을 정할 수 있다.
위의 쿼리 메서드는 Repository에 정의되어 있다. 그렇다면 호출하는 쪽에서는 어떻게 작성해야 하는가?

productRepository.findByName("pen", Sort.by(Sort.Order.asc("number")));

매개변수로 Sort 클래스의 static 메서드인 by()를 호출하고 그 매개변수로 Sort의 내부 클래스인 Sort.Order를 입력하면 된다.

그리고 Sort.Order의 static 메서드를 호출하여 오름차순, 내림차순을 정할 수 있다.

그렇다면 정렬 기준이 여러 개인 경우에는 어떻게 해야할까?
Sort.by()의 매개변수를 여러 개 입력하면 된다.

number를 선순위로 오름차순, stock을 후순위로 내림차순으로 정렬기준을 정할 경우에는 다음과 같이 작성하면 된다.

productRepository.findByName("pen", Sort.by(Sort.Order.asc("number"), Sort.Order.desc("stock")));

[ Sort 클래스 공식 문서 ]

https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/domain/Sort.html

하지만, 이것마저 가독성이 떨어지는 것 같으면 어떻게 해야할까? 메서드를 별도로 정의해서 호출하면 된다.

3) 메서드를 정의하여 정렬처리

위와 크게 다를 것은 없다.
예시를 통해 이해하는 것이 빠르니 예시를 보자.

    public void findByName(String name) {
        productRepository.findByName(name, getSort());
    }

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

매우 간단하다. 별도로 메서드를 정의해서 내용을 메서드에 미리 적어두고 매개변수에는 해당 메서드를 호출만 하면 된다.

3. 페이징 처리

페이징이란 데이터베이스의 레코드를 개수로 나눠 페이지를 구분하는 것을 말한다.

레코드가 30개 있을 경우 레코드를 6개씩 5개의 페이지로 구분하고, 그 중 특정 번호의 페이지를 가져오는 것이 페이징 처리다.

JPA에서 페이징 처리를 하려면 Page, Pageable 인터페이스를 사용해야 한다.

우선 Repository에 쿼리 메서드를 정의하는 예를 보자.
name으로 데이터들을 찾고 이를 페이징 처리하는 것이다.

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

리턴 타입으로 Page를 매개변수로 Pageable 객체가 정의되었는데 왜 이런지는 호출하는 쪽을 보면 알 것이다.

이번에는 호출을 해보겠다.

productRepository.findByName(name, PageRequest.of(0,2));

Pageable 객체 자리에 PageRequest를 입력했는데 이는 PageRequestPageable의 구현체이기 때문이다.

PageRequest의 static 메서드인 of를 통해 객체를 생성할 수 있는데 매개변수에 따라 오버로딩 되어 있다.

반환타입메서드기능
static PageRequestof(int pageNumber, int pageSize)정렬되지 않은 PageRequest객체 생성
static PageRequestof(int pageNumber, int pageSize, Sort sort)정렬 기준이 적용된 객체 생성

여기서 pageNumber는 인덱스 개념으로 0일 경우 1번째 페이지를 말한다.

매개변수가 pageNumber가 0, pageSize가 2이면 1번째 페이지에 데이터가 2개로 페이징 처리한다는 것이다.

이렇게 얻은 Page 객체를 출력해도 객체 값은 안보이고 몇 번쨰 페이지에 해당하는지만 확인할 수 있다.
각 페이지를 구성하는 세부적인 값을 확인하려면 getContent()를 호출하면 된다.

System.out.println(productRepository.findByName(name, PageRequest.of(0,2)).getContent());

추가로 공식문서에서 Page 인터페이스에는 getContent()라는 메서드를 찾을 수 없는데 이는 사실 상속받은 메서드이기 때문이다.

Page 인터페이스는 Slice 인터페이스를 상속받는데, Slice 인터페이스의 구현 클래스 SliceImplChunk 클래스를 상속받는다.

getContent()Chunk 클래스에 정의되어 있어 공식문서에서는 나오지 않는 것이다.

[Page 인터페이스 공식문서]
https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/domain/Page.html

4. @Query

지금까지는 쿼리 메서드를 작성하는 법을 알아보았고 이번에는 직접 JPQL을 작성하는 법을 알아보자.
JPQL을 직접 사용하려면 @Query 에너테이션을 사용하면 되는데 그 전에 먼저 메서드를 작성해야 한다.

이름으로 데이터를 찾는 findByNameJPQL로 구현해보겠다.

    @Query
    List<Product> findByName(String name);

구현할 메서드에 Query를 추가해주고 그 매개변수로 JPQL을 작성해주면 된다.

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

쿼리를 설명하자면 from 뒤에는 엔티티 타입을 지정하고 as로 별칭을 정할 수 있다. 그래서 내가 지정한 엔티티 타입의 name 필드값이 파라미터와 일치하는 Product 타입의 객체 p를 조회하는 것이다.

p.name = ?1에서 1은 첫 번째 파라미터를 의미한다. 만약, nameprice를 파라미터로 사용하고 이를 바탕으로 조회할 경우 WHERE p.name = ?1 AND p.price = ?2로 작성해야 한다.

하지만 이런 방식은 파라미터 순서를 잘못 정의하면 오류가 발생할 수 있기 때문에

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

이렇게 @Param을 사용하는 방식이 더 효율적이다.

예시를 하나 더 들자면 인자로 넘긴 nameprice와 일치하는 Product 타입의 데이터로부터 name,price,stock을 조회할 경우 다음과 같이 작성하면 된다.

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

여기서 리턴타입이 왜 List<Object[]>일까?
우선 where 이후의 조건을 만족하는 데이터가 여러 개 나올 수 있기 때문에 List<>로 정의해야 한다.
제네릭 타입은 Object 배열로 정의하는 이유는 해당 데이터들로부터 얻는 필드값의 타입이 전부 다를 수 있기 때문에 모든 클래스가 상속받는 Obejct 타입의 배열로 정의한 것이다.

JPQL은 복잡한 조건을 표현하기 유리하기 때문에 알아둘 필요가 있다.

https://docs.oracle.com/html/E13946_05/ejb3_langref.html

여기가 공식문서 링크인데 필자도 시간을 내서 깊게 공부할 계획이다.

5. QueryDSL

하지만, @Query를 사용하는 방식은 한 가지 치명적인 단점을 가지고 있다.
컴파일 시점에서 에러를 잡지 못하고 런타임 에러가 발생할 수 있다. 문법적인 오류를 컴파일러가 잡아내지 못하는 것이다.

이러한 문제를 해결하기 위한 대안으로 QueryDSL이 있다.

QueryDSL은 정적 타입을 이용해 SQL 같은 쿼리를 생성하도록 지원하는 프레임워크로 문법적으로 잘못된 쿼리를 허용하지 않는다. 그리고 코드로 작성하기 때문에 가독성과 생산성이 높다.

QueryDSL을 사용하려면 설정을 추가해야 하는데 유감스럽게도 공식문서에서는 Maven 위주로만 설명이 되어 있어 Gradle을 사용하는 입장에서 굉장히 난감했다.

그래서 찾은 방법은 다음과 같다.

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
	annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
	implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'

	//QueryDSL 설정 의존성
	implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
	annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta'
	compileOnly 'com.querydsl:querydsl-apt:5.1.0:jakarta'
	annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
	annotationProcessor 'jakarta.annotation:jakarta.annotation-api'

}

build.gradle에 의존성을 다음과 같이 추가해주고

def querydslDir = "$buildDir/generated/querydsl"

sourceSets {
	main {
		java {
			srcDirs += querydslDir
		}
	}
}

tasks.withType(JavaCompile).configureEach {
	options.annotationProcessorGeneratedSourcesDirectory = file(querydslDir)
}

Q 도메인 클래스 생성 경로를 build->generated로 지정하였다.

그리고 터미널에 ./gradlew clean compileJava을 실행해주면 build->generated 폴더에 QProduct라는 클래스가 생성되는 것을 확인할 수 있다.

이렇게 하면 QueryDSL을 사용할 준비가 완료된 것이다.

1) QueryDSL 사용법

사용법을 예제를 통해 보여줄 것인데 간단한 메서드로 보여주겠다.

public class ProductRepositoryTest {
    @PersistenceContext
    EntityManager entityManager;

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

우선 EntityManagerJPA에서 SQL 없이 엔티티를 조작하고 정의하기 위해 필요한 도구이다. 사실 Spring Data JPA에서는 CrudRepository, JpaRepository 등의 인터페이스가 EntityManager의 기능을 자동으로 래핑해서 제공하므로, 대부분은 직접 쓸 일이 없다.

하지만, QueryDSL이나 JPQL 등 복잡한 조건 조회를 할 경우에는 직접 사용해야 한다.

이제 생성한 JPAQuery에 생성자 매개변수로 EntityManager를 입력해주면 된다.
QProduct 즉, Q 도메인 클래스는 new 생성자가 아닌 static 메서드로 Q도메인클래스명.클래스명(소문자)로 객체를 생성한다.

이제 이름이 "pen"인 데이터를 price의 오름차순으로 데이터를 정렬하여 List 형태로 반환하도록 코드를 작성하겠다.

public class ProductRepositoryTest {
    @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("pen"))
                                    .orderBy(qProduct.price.asc())
                                    .fetch();
    }
}
  • from : 쿼리 소스를 정의
  • where : 쿼리 필터를 정의
  • orderBy : 결과의 순서를 정의
  • fetch : 조회 결과를 List< T >로 반환

빌더 형식으로 작성한 쿼리에서 호출한 메서드들의 기능이다.
쉽게 말하면 qproduct를 쿼리 소스로 정의하고 조건식으로 where, 정렬 기준을 orderBy로 정의한 것이다.
마지막으로 fetch()를 해야 리스트로 결과가 반환된다.

결과 반환은 다양한 형태로 가능한데 다음과 같다.

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

참고로 @PersistenceContext를 사용하는 방식은 JPA의 표준 스타일인데 최근에는 생성자 주입 방식이 더 권장된다.

public class ProductRepositoryTest {
    JPAQuery<Product> query;

    public ProductRepositoryTest(EntityManager entityManager) {
        this.query = new JPAQuery<>(entityManager);
    }

    public void queryDSLTest(){
        QProduct qProduct = QProduct.product;

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

이 방식이 기존의 고전적인 스타일(@PersistenceContext를 사용하는 방식)보다는 테스트가 쉽고 불변성이 보장되며, 코드가 더 명확해진다는 장점이 있어 더 많이 사용되는 추세이다.


위의 예시에서는 JPAQuery를 사용했는데 JPAQueryFactory를 사용하는 방법도 있다.

public class ProductRepositoryTest {
    @PersistenceContext
    EntityManager entityManager;

    @Test
    void queryDslTest(){
        JPAQueryFactory query = new JPAQueryFactory(entityManager);
        QProduct qProduct = QProduct.product;

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

JPAQueryFactory는 제네릭 타입을 정의하지 않으며 select 절부터 쿼리 작성이 가능하다.
위에서 from을 사용해서 쿼리의 리소스, 즉 엔티티 전체를 가져왔는데 여기서는 selctFrom으로 사용이 가능하다.
만약, 엔티티 전체가 아닌 엔티티의 특정 필드(칼럼)만 사용하고 싶을 경우에는

query.select(qProduct.name).from(qProduct)~

이렇게 사용이 가능하다.

import com.querydsl.core.Tuple;


public class ProductRepositoryTest {
    @PersistenceContext
    EntityManager entityManager;

    @Test
    void queryDslTest(){
        JPAQueryFactory query = new JPAQueryFactory(entityManager);
        QProduct qProduct = QProduct.product;
        
        //name 필드만 데이터로 가져와서 리스트로 반환
        List<String> productList = query
        						.select(qProduct.name)
                                .from(qProduct)
                                .where(qProduct.name.eq("pen"))
                                .orderBy(qProduct.price.asc())
                                .fetch();
        
        //name과 price 필드를 튜플 데이터로 가져와서 리스트로 반환
        List<Tuple> productTuple = query
        						.select(qProduct.name, qProduct.price)
                                .from(qProduct)
                                .where(qProduct.name.eq("pen"))
                                .orderBy(qProduct.price.asc())
                                .fetch(); 
    }
}

JPAQueryFactory의 이점이 바로 여기서 나타난다.
select절부터 쿼리 작성이 가능해 데이터 전체가 아닌 특정 필드만 선택해서 데이터로 가져오거나 튜플 데이터로 가져올 수 있다.

그리고 매번 JPAQueryFactory 객체를 만들지 않고 빈으로 등록하여 코드를 간결하게 만들 수 있다.

@Configuration
public class QueryDSLConfiguration {
    @PersistenceContext
    EntityManager entityManager;

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

별도의 클래스를 만들어 EntityManager를 정의하고 이를 생성자 매개변수로 받는 JPAQueryFactory@Bean을 통해 빈으로 등록해두면

import com.querydsl.core.Tuple;


public class ProductRepositoryTest {
    @Autowired
    JPAQueryFactory query;

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

     
        //name 필드만 데이터로 가져와서 리스트로 반환
        List<String> productList = query
        						.select(qProduct.name)
                                .from(qProduct)
                                .where(qProduct.name.eq("pen"))
                                .orderBy(qProduct.price.asc())
                                .fetch();
        
        //name과 price 필드를 튜플 데이터로 가져와서 리스트로 반환
        List<Tuple> productTuple = query
        						.select(qProduct.name, qProduct.price)
                                .from(qProduct)
                                .where(qProduct.name.eq("pen"))
                                .orderBy(qProduct.price.asc())
                                .fetch(); 
    }
}

이렇게 간결하게 작성이 가능하다.
빈으로 등록해두면 @AutoWired가 추가되어 있는 곳에 스프링 컨테이너가 자동으로 의존성을 추가해준다.

2) QuerydslPredicateExecutor

위에서 설명한 것처럼 EntityManager를 사용하지 않고 QueryDSL을 사용하는 방법이 있다.
Repository에서 QueryDSL을 사용하는 것인데 우선 Repository에서 QuerydslPredicateExecutor를 상속받아야 한다.

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

인터페이스가 다중 상속이 가능하다는 특징이 있기 때문에 JpaRepositoryQuerydslPredicateExecutor 둘 다 상속받는 것이 가능하다.

public class ProductRepositoryTest {
    @Autowired
    ProductRepository productRepository;
    
    public void queryDSLTest(){
        
    }
}

그리고 그 Repository를 의존성 주입 받으면 된다.

QuerydslPredicateExecutor에는 이미 다양한 메서드가 정의되어 있기 때문에 바로 사용이 가능하다.

  • long count(Predicate predicate)
  • boolean exists(Predicate predicate)
  • Iterable< T > findAll(OrderSpecifier<?>... orders)
  • Iterable findAll(Predicate predicate)
  • Iterable findAll(Predicate predicate, OrderSpecifier<?>... orders)
  • Page findAll(Predicate predicate, Pageable pageable)
  • Iterable findAll(Predicate predicate, Sort sort)
  • Optional< T > findOne(Predicate predicate)

메서드명으로 어떠한 기능인지 추론이 가능하다. 여기서 매개변수로 들어가는 객체들은 com.querydsl.core.types에서 import해야한다.

특히, Predicate는 함수형 인터페이스에서 사용하던 그 Predicate가 아니다.

findOne을 사용하는 예제를 만들어본건데 "pen"이라는 이름을 가지고 price가 1000과 2500 사이인 Product 데이터를 조회해보자.

public class ProductRepositoryTest {
    @Autowired
    ProductRepository productRepository;

    public void queryDSLTest(){
        Predicate predicate = QProduct.product
  										.name
  										.like("pen")
  										.and(QProduct.product.price.between(1000,2500));

        Optional<Product> foundProduct = productRepository.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는 표현식으로 정의되는 쿼리라고 생각하면 된다. 그래서 굳이 저렇게 정의하지 않고 매개변수에 바로 쿼리를 입력해도 된다.

Optional<Product> foundProduct = productRepository.findOne(QProduct.product
  .name
  .like("pen")
  .and(QProduct.product.price.between(1000,2500)));

QueryDSL을 편하게 사용할 수 있지만 join이나 fetch 기능을 사용할 수 없다는 단점이 있다.

https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/querydsl/QuerydslPredicateExecutor.html

QuerydslPredicateExecutor의 공식문서인데 메서드에 대한 설명이 자세히 나와있어 참고하기 좋다.

3) QueryDSL 상속구조

QueryDSL 작성을 어떻게 하는지 알아보았는데 한 가지 의문이 들 것이다. 어디에 작성을 해야하지?

다른 계층에서 DB에 데이터를 접근하려면 ProductRepository에 의존성을 주입해야 하는데 ProductRepository에 작성하려고 해도 인터페이스이기 때문에 구현부를 작성할 수 없다.
그렇다고 다른 계층에 정의하는 것은 알맞지 않다.

그래서 QueryDSL의 상속구조를 알아야 하며 뒤에서 다룰 QuerydslRepositorySupport 클래스를 사용하려면 반드시 알아야 한다.

그림에서 타원은 인터페이스를, 사각형은 클래스를 의미한다.
ProductRepositoryJpaRepository, ProductRepositoryCustom 인터페이스를 다중 상속받으며 ProductRepositoryCustom 인터페이스는 ProductRepositoryCustomImpl이 구현한다.

이 구조로 상속 관계를 설정하면 ProductRepositoryCustomImpl에서 구현한 메서드를 ProductRepository에서 호출할 수 있다.

그럼 우리는 QueryDSLProductRepositoryCustom에 정의하고 이를 ProductRepositoryCustomImpl에서 구현하면 되는 것이다.

한 가지 예시로 상품을 이름으로 조회해서 가격을 기준으로 오름차순으로 정렬된 리스트를 반환하는 쿼리를 만들어보자.

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

커스텀 레포지토리에 메서드를 정의하고 커스텀 구현 클래스로 구현한다.

public class ProductRepositoryCustomImpl implements ProductRepositoryCustom{
  @PersistenceContext
  EntityManager entityManager;

  @Override
  public List<Product> getProductListByNameOrderByAsc(String name) {
      JPAQuery<Product> query = new JPAQuery<>(entityManager);
      QProduct qProduct = QProduct.product;
      return query.from(qProduct).where(qProduct.name.eq(name)).orderBy(qProduct.price.asc()).fetch();
  }
}

그리고 마지막으로 ProductRepository가 커스텀 레포지토리를 상속받으면 된다.

@Component
public class ProductDAOImpl implements ProductDAO{

    private final ProductRepository productRepository;

    @Autowired
    public ProductDAOImpl(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Override
    public List<Product> getProductListByNameAndOrderByAsc(String name) {
        return productRepository.getProductListByNameOrderByAsc(name);
    }

그러면 위와 같이 다른 계층에서 ProductRepository로 의존성을 주입받아도 QueryDSL로 만든 쿼리를 호출할 수 있다.

4) QuerydslRepositorySupport

QuerydslRepositorySupportSpring Data JPA + QueryDSL을 함께 쓸 때, 쿼리를 좀 더 편하게 작성할 수 있도록 도와주는 추상 클래스다.
쉽게 말하면, QueryDSL 사용 시 JPAQueryFactory, EntityManager, Q클래스 등의 반복 코드를 줄여주는 도우미 클래스라고 할 수 있다.

사용방법은 굉장히 간단하다. 위의 상속구조에서 ProductRepositoryCustomImplQuerydslRepositorySupport를 상속받으면 된다.

자, 그러면 위에서 예로 들었던 상품을 이름으로 조회해서 가격을 기준으로 오름차순으로 정렬된 리스트를 반환하는 쿼리를 어떻게 작성할 수 있는지 보자.

public class ProductRepositoryCustomImpl extends QuerydslRepositorySupport implements ProductRepositoryCustom{

  public ProductRepositoryCustomImpl() {
      super(Product.class);
  }

  @Override
  public List<Product> getProductListByNameOrderByAsc(String name) {
      QProduct qProduct = QProduct.product;
      return from(qProduct).where(qProduct.name.eq(name)).orderBy(qProduct.price.asc()).fetch();
  }
}

기존에 정의해야 했던 EntityManagerJPAQuery는 필요없다.
생성자를 만들어 상속받는 클래스인 QuerydslRepositorySupport의 생성자 매개변수로 Entity클래스를 전달해주면 끝이다.

그 외에 나머지 기능은 JPAQueryJPAQueryFactory를 사용하던 방식과 비슷하다.

5) 그럼 QuerydslPredicateExecutor의 쿼리는 어디서 작성하나

QueryDSL의 상속구조를 보면 QuerydslPredicateExecutor의 쿼리를 어디에 작성해야 하는지 의문이 들 수 있다.

인터페이스인 ProductRepository가 인터페이스인 QuerydslPredicateExecutor를 상속받기 때문에 ProductRepositoryCustom처럼 구현부를 따로 만들 수가 없다.

결국 쿼리 조건인 PredicateServiceDAO 계층 등 다른 계층에서 작성할 수 밖에 없다.

이런 방식은 쿼리 책임이 분산되고 복잡한 조건 처리가 어려운 단점이 있지만 빠르게 동적 쿼리를 만들 수 있고 Repository에 메서드를 많이 만들지 않는다는 장점이 있다.

그래서 단순 조건 검색에는 QuerydslPredicateExecutor를 사용하는 것이 좋을 수 있으나 복잡한 쿼리가 필요하다면 Custom RepositoryQueryDSL을 결합한 방식을 사용하는 것이 좋다.

0개의 댓글