Posts [JPA] QueryDSL
Post
Cancel

[JPA] QueryDSL

※ 실습 프로젝트는 Github에서 확인 할 수 있습니다.

QueryDSL이란?

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

QueryDSL를 사용해야 하는 이유

Querydsl은 타입에 안전한 방식(type-safe)으로 HQL 쿼리를 실행하기 위한 목적으로 만들어졌다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
try {
    
    tx = session.beginTransaction(); // create transaction
    Query<Product> theQuery = session.createQuery("from Product where id=:i", Product.class);
    theQuery.setParameter("i", 1);  

    List<Product> products = theQuery.getResultList(); 
    System.out.println("Total product size is " + products.size());
} catch (Exception ex) {
    ex.printStackTrace();
} finally {
    tx.commit(); // commit transaction
    session.close();
    sessionFactory.close();
}

만약 where조건에 있는 product_id컬럼을 product_id1으로 변경한다고 가정해보자. 개발자 입장에서는 프로그램을 실행 후 해당 쿼리가 실행되기 전까지는 오류를 발견할 수 가 없다. 소프트웨어의 요구사항은 계속해서 바뀌며 개발도중에 DB스키마도 수시로 변경될 가능성이 크다. 그때마다 관련 쿼리가 문제 없는지 확인하는 과정은 생산성을 떨어뜨리며 실수하기도 쉽다.

QueryDSL을 사용한다면 Entity 도메인 객체에 수정이 일어나면 관련된 쿼리 코드에 컴파일 에러가 발생하게 된다. 컴파일 에러가 발생하는 코드를 수정하고 컴파일 에러가 없어진다면 적어도 소프트웨어 동작중에 쿼리가 잘못되어 에러가 발생하는 경우는 없을 것이다. 이런걸 Type safe(타입 안정성) 하다고 한다.

이러한 타입 안정성은 소프트웨어 개발에서 아래와 같은 큰 이득을 준다. 1) 과감하게 도메인 타입의 리팩토링을 할 수 있게 되어 소프트웨어의 품질을 높일 수 있게 된다. 2) 쿼리 작성 과정에서 코드 자동완성 기능을 사용함으로써 쿼리를 더 빠르고 안전하게 만들 수 있게 된다.

더 자세한 내용은 여기를 참고하자.

QueryDSL 적용

1. gradle 설정(Gradle 5.0 이상 & IntelliJ 2020.x 이상 사용시)

최근 Gradle 버전이 계속 증가하면서 Querydsl의 Gradle Plugin이 해당 버전을 못쫓아가는 경우가 계속 발생한다. 그러다보니 계속해서 QClass 생성 방법이 변경되다보니 Gradle & IntelliJ가 업데이트 될 때마다 새로운 설정 방법을 필요로 하게된다.

이로 인해 최근엔 Gradle의 Annotation processor 을 사용하는 방법을 많이 사용하고 있다.

QueryDSL gradle설정에 대한 상세한 설명은 허니몬님의 블로그 포스팅을 꼭 정독해보자.

Note: 아래는 Gradle Plugin이 필요 없는 설정 (build.gradle) 코드이다. 즉, com.ewerk.gradle.plugins.querydsl 플러그인 사용하지 않는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
dependencies {
    // QueryDSL
    implementation "com.querydsl:querydsl-jpa:${querydslVersion}"
    annotationProcessor(
            "javax.persistence:javax.persistence-api",
            "javax.annotation:javax.annotation-api",
            "com.querydsl:querydsl-apt:${querydslVersion}:jpa")
}

def generated='src/main/generated'
sourceSets {
    main.java.srcDirs += [ generated ]
}

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

clean.doLast {
    file(generated).deleteOnExit();
}

이것 이외에는 별도의 설정이 필요 없다. 이후부터는 Gradle Project (View -> Tool Windows -> Gradle Project)을 열어 Tasks -> other -> compileJava를 실행시키시면 src/main/generated에 Q클래스들이 생성됩니다.

1

2. Java Config & 기본 사용법

먼저 Java 설정을 진행합니다.

2-1. Java Config

설정값을 모아둔 패키지()에 QuerydslConfiguration을 생성합니다.

2

1
2
3
4
5
6
7
8
9
10
@Configuration
public class QuerydslConfiguration {
    @PersistenceContext
    private EntityManager entityManager;

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

위 설정으로 이 프로젝트에서는 어느 곳에서나 JPAQueryFactory를 주입 받아 Querydsl을 사용할 수 있게 된다.

2-2. 기본적인 사용법

먼저 테스트로 사용할 Product 엔티티는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Entity
@Getter
@NoArgsConstructor
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "product_id", nullable = false, updatable = false)
    private Long id;

    private String name;

    @JsonIgnoreProperties({"products"})
    @ManyToOne
    @JoinColumn(name="category_id")
    private Category category;

    public void setCategory(Category category) {
        this.category = category;
    }

    private String description;

    private int price;

    private String manufacturer;

    private int unitInStock;

    @Builder
    public Product(String name, Category category, String description, int price, String manufacturer, int unitInStock) {
        this.name = name;
        this.category = category;
        this.description = description;
        this.price = price;
        this.manufacturer = manufacturer;
        this.unitInStock = unitInStock;
    }
}

그리고 테스트로 데이터를 넣고, 검증할 Repository는 아래와 같다.

1
2
3
public interface ProductRepository extends JpaRepository<Product, Long> {
    Product findByName(String name);
}

여기서 Querydsl Repository를 하나 생성하겠습니다. QuerydslRepositorySupport클래스를 상속받으며 클래스명은 ProductRepositorySupport 이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Repository
public class ProductRepositorySupport extends QuerydslRepositorySupport {

    private final JPAQueryFactory queryFactory;

    public ProductRepositorySupport(JPAQueryFactory queryFactory) {
        super(Product.class);
        this.queryFactory = queryFactory;
    }

    public List<Product> findByPrice(int price) {
        return queryFactory
                .selectFrom(product)
                .where(product.price.eq(price))
                .fetch();
    }
}

설정으로 Bean 등록된 queryFactory를 생성자 인잭션으로 주입 받아 사용한다.

실제 조회 메소드를 작성하다보면 아래와 같은 오류 메세지가 나온다.

7

이건 Querydsl의 QClass인 product를 사용하고 싶은데 아직 찾을수 없다는 뜻이다. 두 가지를 확인 해볼 수 있다.

1) QClass가 없는 경우

위에서 수행했던 Gradle Project (View -> Tool Windows -> Gradle Project)을 열어 Tasks -> other -> compileJava를 실행함으로써 QClass를 생성하여 해결한다.

2) 인텔리제이 설정 상의 오류

QClass는 생성되었으나 인텔리제이 static import설정이 안되있기에 그럴 수 있다. 아래 이미지와 같이 Add on-demand static import부분이 체크가 안되어있다면 체크하자.

4

컴파일 에러가 나지 않는 상태가 되었다면, 테스트 코드로 이 메소드가 정상작동하는지 테스트해보자.

2-3. 기본 사용법 테스트

아래와 같이 테스트 코드를 작성해서 위에서 구현한 findByPrice 메소드를 검증해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@ExtendWith(SpringExtension.class)
@SpringBootTest
public class ProductRepositorySupportTest {

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private ProductRepositorySupport productRepositorySupport;

    @AfterEach
    public void tearDown() throws Exception {
        productRepository.deleteAllInBatch();
    }

    @Test
    public void querydsl_기본_기능_확인() {
        final int price = 100;
        for(int i=0;i<10;i++) {
            productRepository.save(Product.builder()
                    .name("TestProduct"+i)
                    .description("Best Prodcut!")
                    .category(null)
                    .price(price)
                    .manufacturer("Apple")
                    .unitInStock(10)
                    .build());
        }

        //when
        List<Product> result = productRepositorySupport.findByPrice(price);

        //then
        assertThat(result.size(), is(price));
        assertThat(result.get(0).getName(), is("TestProduct0"));
    }
}

10개의 “ProductTest”라는 name을 가진 Product들을 삽입하고 Querydsl로 만든 findByPrice 메소드로 조회시 정상적으로 결과가 나오는지 확인합니다.

8

Queyrdsl로 작성된 코드가 정상적으로 실행되는 것을 확인할 수 있다. 하지만! 여기서 끝이 아니라, 한 단계 더 나아가보자.

3. Spring Data Jpa Custom Repository 적용

위와 같은 방식으로 Querydsl을 사용할 수 있지만, 한가지 단점이 존재한다.

항상 2개의 Repository를 의존성으로 받아야한다는 것입니다.

Querydsl의 CustomRepository와 JpaRepository를 상속한 Repository가 기능을 나눠가졌기 때문이다.

이를 해결하기 위해 Spring Data Jpa에서는 Custom Repository를 JpaRepository 상속 클래스에서 사용할 수 있도록 기능을 지원합니다.

전체적인 그림은 아래와 같습니다.

스크린샷 2021-09-12 오후 5 21 49

Note: Spring Data 공식 문서을 참고하시면 Custom Repository 내용이 나오니 자세히 읽어보면 된다.

위와 같이 구성하면 ProductRepository에서 ProductRepositoryImpl 의 코드도 사용할 수 있다.

일종의 공식이라고 보면 되는데, Custom이 붙은 인터페이스를 상속한 Impl 클래스의 코드는 Custom 인터페이스를 상속한 JpaRepository에서도 사용할 수 있다.

Note: Custom과 Impl만 외워도 된다.

직접 코드로 구현해보자.

먼저 ProductRepository와 같은 위치에 ProductRepositoryCustom 인터페이스ProductRepositoryImpl클래스를 생성한다.

6

그리고 ProductRepositoryCustom 인터페이스에 다음과 같은 코드를 추가합니다.

1
2
3
public interface ProductRepositoryCustom {
    List<Product> findByPrice(int price);
}

ProductRepositoryImpl 클래스는 기존에 있던 Support 클래스 코드를 참고해서 구현합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@RequiredArgsConstructor
public class ProductRepositoryImpl implements ProductRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<Product> findByPrice(int price) {
        return queryFactory
                .selectFrom(product)
                .where(product.price.eq(price))
                .fetch();
    }
}

Note: 다른 블로그를 보시면 QuerydslSupport 상속 코드도 추가하는데, 페이징이 필요한게 아니라면 안해도 된다. 결국 JPAQueryFactory를 통해서 작동하기 때문이다.

그리고 이 코드를 ProductRepository에서 쓸수 있게 상속 구조로 변경하자.

1
2
3
public interface ProductRepository extends JpaRepository<Product, Long>, ProductRepositoryCustom {
    Product findByName(String name);
}

그럼 이 코드가 정상작동하는지 테스트 해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Test
public void querydsl_Custom설정_기능_확인() {
    final int price = 100;
    for(int i=0;i<10;i++) {
        productRepository.save(Product.builder()
                .name("TestProduct"+i)
                .description("Best Prodcut!")
                .category(null)
                .price(price)
                .manufacturer("Apple")
                .unitInStock(10)
                .build());
    }

    //when
    List<Product> result = productRepository.findByPrice(price);

    //then
    assertThat(result.size(), is(price));
    assertThat(result.get(0).getName(), is("TestProduct0"));
}

테스트 코드를 실행해보면 아래 이미지와 같이 성공하는 것을 확인할 수 있다.

9

4. 상속/구현 없는 Repository

마지막은 jojoldu 블로그에서 가장 선호하는 방식인 Querydsl만으로 Repository를 구성하는 방법이다. 아래처럼 JPAQueryFactory 만 있으면 Querdsl을 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
@RequiredArgsConstructor
@Repository
public class ProductQueryRepository {
    private final JPAQueryFactory queryFactory;

    public List<Product> findByPrice(int price) {
        return queryFactory
                .selectFrom(product)
                .where(product.price.eq(price))
                .fetch();
    }
}
  • 최소한의 Bean 등록을 위해 @Repository를 선언한다.
  • 별도의 상속(extends) / 구현(implements) 없이 JPAQueryFactory 만 있으면 된다.
  • 특정 Entity 만 사용해야한다는 제약도 없다.

아래 테스트 코드를 실행해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Autowired
private ProductRepository productRepository;

@Autowired
private ProductQueryRepository productQueryRepository;

@Test
public void querydsl_기본_기능_확인2() {
    final int price = 100;
    for(int i=0;i<10;i++) {
        productRepository.save(Product.builder()
                .name("TestProduct"+i)
                .description("Best Prodcut!")
                .category(null)
                .price(price)
                .manufacturer("Apple")
                .unitInStock(10)
                .build());
    }

    //when
    List<Product> result = productQueryRepository.findByPrice(price);

    //then
    assertThat(result.size(), is(price));
    assertThat(result.get(0).getName(), is("TestProduct0"));
}

테스트 코드를 실행해보면 아래 이미지와 같이 성공하는 것을 확인할 수 있다.

10

Core 기능이 아닌 어드민/API 등에서 특정 Entity를 메인으로 확정할 수 없는 경우와 자주 변경되는 기능에 대해서는 위처럼 선언해서 사용하시면 심플하게 변경에 대응할 수 있다.

5. 주의 사항

Querydsl의 QClass를 담는 src/main/generated는 빌드시 자동 생성되는 파일들의 디렉토리이니 무조건 .gitignore에 추가해야 한다.

11

출처

This post is licensed under CC BY 4.0 by the author.