CRUD with JPAQueryFactory

5 분 소요


RECOMMEND POSTS BEFORE THIS

0. 들어가면서

QueryDSLJava 코드로 질의문(query)를 작성할 수 있게 해주는 라이브러리입니다. Java 코드로 질의문을 작성하면 다음과 같은 이점들을 얻을 수 있습니다.

  • IDE(integrated development environment)의 코드 자동 완성 기능의 도움을 받을 수 있습니다.
  • 문법적으로 잘못된 쿼리는 컴파일 시점에 찾아줍니다.
  • 도메인 객체의 필드(field)들을 사용하기 때문에 도메인 객체 변경에 따른 에러 가능성을 빌드(build) 시점에 찾을 수 있습니다.

XML, 문자열 기반 쿼리는 단순한 에러를 런타임까지 발견하지 못할 가능성이 QueryDSL보다 높습니다. QueryDSL을 사용하면 에러를 런타임 이전에 미리 발견할 수 있게 됨으로써 더 안정적인 시스템을 구축할 수 있습니다. 예전 기술 도입을 위해 간단한 예제 코드를 작성해 본 경험만 있을 뿐 비즈니스 코드에 적극적으로 사용해 본 적은 없었습니다. 이번 프로젝트에선 강력한 동적 쿼리 기능이 필요하여 QueryDSL을 도입했고, 익숙해지기까지 다소 시간이 걸렸습니다. 이번 포스트에선 QueryDSL 설정 방법과 간단한 CRUD(create, read, update, delete) 코드를 살펴보겠습니다.

1. QueryDSL Settings

다음과 같은 환경에서 구축하였습니다.

  • macOS Ventura with Intel Core
  • IntelliJ IDEA 2022.3 (Ultimate Edition)
  • 메이븐(maven) 프로젝트
  • spring-boot-starter-parent 2.7.7 version

1.1. pom.xml 파일

다음과 같은 의존성과 플러그인을 추가합니다.

    <dependencies>
        <!-- ... -->
        <dependency>
            <groupId>com.querydsl</groupId>
            <artifactId>querydsl-apt</artifactId>
            <version>${querydsl.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>com.querydsl</groupId>
            <artifactId>querydsl-jpa</artifactId>
            <version>${querydsl.version}</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!-- ... -->
            <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/annotations</outputDirectory>
                            <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

1.2. IntelliJ 환경 및 프로젝트 설정

QueryDSL은 애너테이션 프로세서(annotation processor)를 통해 엔티티(entity)들을 Q가 붙은 클래스로 컴파일하여 사용합니다. IDE에서 컴파일된 Q-Type 클래스들을 사용하려면 컴파일 위치를 잡아줘야 합니다. 이 과정이 없으면 컴파일 된 Q-Type 클래스를 찾지 못하거나 빌드 과정에서 에러가 발생합니다.

1.2.1. 환결 설정

  • 환경 설정(command + ‘,’)에서 Annotation Processors 관련 설정을 찾습니다.
  • 해당 프로젝트의 production sources directory를 pom.xml 파일의 플러그인에 정의한 outputDirectory와 일치시킵니다.

1.2.2. 프로젝트 모듈 설정

  • 프로젝트 설정(command + ‘;’)에서 모듈 관련 설정을 찾습니다.
  • 코드 에디터에서 컴파일 된 Q-Type 클래스를 임포트(import)할 수 있도록 모듈의 소스 경로로 설정합니다.

2. Types of Query Class in QueryDSL

QueryDSL에서 쿼리 작성을 지원하는 클래스는 5가지 있습니다. 각 클래스마다 다른 특징을 가지며 이번에 포스트에서 사용한 클래스는 JPAQueryFactory 입니다.

  • SQLQuery 클래스
    • 생성자로 매번 만들어 사용
    • Native Query 실행
    • DataSource 객체 사용
  • SQLQueryFactory 클래스
    • 스레드 안전하므로 싱글톤 객체로 만들어 사용 가능
    • Native Query 실행
    • DataSource 객체 사용
  • JPAQuery 클래스
    • 생성자로 매번 만들어 사용
    • JPQL(Java Persistence Query Langauge) 문법을 사용한 질의문 작성 및 실행
    • 엔티티 매니저 사용
  • JPAQueryFactory 클래스
    • 스레드 안전하므로 싱글톤 객체로 만들어 사용 가능
    • JPQL 문법을 사용한 질의문 작성 및 실행
    • 엔티티 매니저 사용
  • JPASQLQuery 클래스
    • 생성자로 매번 만들어 사용
    • Native Query 실행
    • 엔티티 매니저를 사용하지만, 1차 캐싱 기능은 사용 불가능

3. Example

간단하게 엔티티를 만들고 CRUD 기능을 실행시켜보겠습니다.

2.1. DslEntity 클래스

package action.in.blog.dsl;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class DslEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private long id;
    private String someValue;
    
    public DslEntity(String someValue) {
        this.someValue = someValue;
    }
}

2.2. DslStore 클래스

  • 엔티티 매니저(EntityManager)를 생성자 주입(constructor injection)을 통해 전달 받습니다.
    • @PersistenceContext를 사용하는 것을 권장하지만, 특정 버전 이상부터는 동일한 프록시 객체를 주입하므로 동일합니다.
    • 참고 - @PersistenceContext 애너테이션
  • 생성자를 통해 엔티티 매니저를 전달 받으면 @DataJpaTest 애너테이션을 사용한 테스트를 쉽게 풀어낼 수 있습니다.
  • 각 메소드들은 가장 단순한 CRUD 기능을 수행합니다.
package action.in.blog.dsl;

import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.EntityManager;
import java.util.List;

import static action.in.blog.dsl.QDslEntity.dslEntity;

@Repository
public class DslStore {

    private final EntityManager entityManager;
    private final JPAQueryFactory jpaQueryFactory;

    public DslStore(EntityManager entityManager) {
        this.entityManager = entityManager;
        this.jpaQueryFactory = new JPAQueryFactory(entityManager);
    }

    @Transactional
    public void createEntity(DslEntity entity) {
        entityManager.persist(entity);
    }

    public List<DslEntity> getEntityByContains(String value) {
        return jpaQueryFactory
                .selectFrom(dslEntity)
                .where(dslEntity.someValue.containsIgnoreCase(value))
                .fetch();
    }

    @Transactional
    public void updateEntity(DslEntity entity) {
        jpaQueryFactory
                .update(dslEntity)
                .set(dslEntity.someValue, entity.getSomeValue())
                .where(dslEntity.id.eq(entity.getId()))
                .execute();
    }

    @Transactional
    public void deleteEntity(long id) {
        jpaQueryFactory
                .delete(dslEntity)
                .where(dslEntity.id.eq(id))
                .execute();
    }
}

4. Tests

4.1. @DataJpaTest 애너테이션

테스트 코드 전에 @DataJpaTest 애너테이션을 먼저 살펴보겠습니다.

  • @Transactional 애너테이션이 함께 선언되어 있습니다.
    • 테스트가 종료되면 작업한 내용들에 대해 자동으로 롤백(rollback) 처리합니다.
  • 기저에 깔린 @Transactional 애너테이션은 다음과 같은 관점에서 테스트를 어렵게 만듭니다.
    • 스토어(store) 객체를 @Autowired 애너테이션을 통해 빈으로 주입 받아 테스트 하는 경우라면 테스트에서 이미 시작된 트랜잭션이 스토어 객체까지 전파되어 외부 트랜잭션에서 확인할 수 있는 방법이 없습니다. 그렇기 때문에 검증(assert)하려면 스토어 객체를 사용해야 합니다.
    • 테스트 메소드에서 @Transactional 애너테이션의 전파 타입을 NOT_SUPPORTED 같은 것으로 오버라이딩(overriding)하면 테스트 전역에 쓰레기 데이터가 남기 때문에 다른 테스트의 정합성이 떨어집니다.
    • 엔티티 매니저의 1차 캐싱 기능 때문에 검증 또한 정확하지 않을 수 있습니다. 영속성 컨텍스트 캐시에 준비된 데이터가 나오는 경우엔 정확하게 쿼리가 수행되었는지 검증할 수 없습니다.
package org.springframework.boot.test.autoconfigure.orm.jpa;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.test.autoconfigure.OverrideAutoConfiguration;
import org.springframework.boot.test.autoconfigure.core.AutoConfigureCache;
import org.springframework.boot.test.autoconfigure.filter.TypeExcludeFilters;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.properties.PropertyMapping;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.core.annotation.AliasFor;
import org.springframework.data.repository.config.BootstrapMode;
import org.springframework.test.context.BootstrapWith;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.transaction.annotation.Transactional;

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(DataJpaTestContextBootstrapper.class)
@ExtendWith({SpringExtension.class})
@OverrideAutoConfiguration(
    enabled = false
)
@TypeExcludeFilters({DataJpaTypeExcludeFilter.class})
@Transactional
@AutoConfigureCache
@AutoConfigureDataJpa
@AutoConfigureTestDatabase
@AutoConfigureTestEntityManager
@ImportAutoConfiguration
public @interface DataJpaTest {
    String[] properties() default {};

    @PropertyMapping("spring.jpa.show-sql")
    boolean showSql() default true;

    @PropertyMapping("spring.data.jpa.repositories.bootstrap-mode")
    BootstrapMode bootstrapMode() default BootstrapMode.DEFAULT;

    boolean useDefaultFilters() default true;

    ComponentScan.Filter[] includeFilters() default {};

    ComponentScan.Filter[] excludeFilters() default {};

    @AliasFor(
        annotation = ImportAutoConfiguration.class,
        attribute = "exclude"
    )
    Class<?>[] excludeAutoConfiguration() default {};
}

4.2. 테스트 코드

위에서 설명한 문제점 때문에 엔티티 매니저의 동작을 테스트에서 제어할 필요가 있습니다.

  • 테스트 코드에서 트랜잭션의 시작, 롤백 등을 제어합니다.
  • 적절한 위치에서 플러시(flush)와 클리어(clear)를 호출하여 쿼리를 실행하고 영속성 컨텍스트 내부 캐시를 비웁니다.
package action.in.blog.dsl;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.PersistenceUnit;
import java.util.List;
import java.util.function.Consumer;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;

@DataJpaTest
public class DslStoreIT {

    @PersistenceUnit
    EntityManagerFactory factory;

    void transaction(Consumer<EntityManager> consumer) {
        EntityManager em = factory.createEntityManager();
        EntityTransaction transaction = em.getTransaction();
        transaction.begin();
        try {
            consumer.accept(em);
        } catch (Exception ex) {
            throw ex;
        } finally {
            transaction.rollback();
            em.close();
        }
    }

    void flushAndClear(EntityManager em) {
        em.flush();
        em.clear();
    }

    @Test
    void createEntity() {
        transaction((em) -> {
            DslEntity dslEntity = new DslEntity("Hello World");


            DslStore sut = new DslStore(em);
            sut.createEntity(dslEntity);


            flushAndClear(em);
            DslEntity result = em.find(DslEntity.class, dslEntity.getId());
            assertThat(result.getSomeValue(), equalTo("Hello World"));
        });
    }

    @Test
    void getEntityByContains() {
        transaction((em) -> {
            DslEntity dslEntity = new DslEntity("Hello World");
            em.persist(dslEntity);
            flushAndClear(em);


            DslStore sut = new DslStore(em);
            List<DslEntity> result = sut.getEntityByContains("llo Wor");


            DslEntity firstItem = result.get(0);
            assertThat(result.size(), equalTo(1));
            assertThat(firstItem.getSomeValue(), equalTo("Hello World"));
        });
    }

    @Test
    void updateEntity() {
        transaction((em) -> {
            DslEntity dslEntity = new DslEntity("Hello World");
            em.persist(dslEntity);
            flushAndClear(em);


            DslStore sut = new DslStore(em);
            sut.updateEntity(new DslEntity(dslEntity.getId(), "Hello QueryDSL World"));


            flushAndClear(em);
            DslEntity result = em.find(DslEntity.class, dslEntity.getId());
            assertThat(result.getSomeValue(), equalTo("Hello QueryDSL World"));
        });
    }

    @Test
    void deleteEntity() {
        transaction((em) -> {
            DslEntity dslEntity = new DslEntity("Hello World");
            em.persist(dslEntity);
            flushAndClear(em);


            DslStore sut = new DslStore(em);
            sut.deleteEntity(dslEntity.getId());


            flushAndClear(em);
            DslEntity result = em.find(DslEntity.class, dslEntity.getId());
            assertThat(result == null, equalTo(true));
        });
    }
}

CLOSING

JPAQueryFactory 클래스는 insert 쿼리를 지원하지 않습니다. insert 코드를 작성할 순 있지만, 테스트 코드를 실행하면 다음과 같은 에러를 만나게 됩니다. insert 기능을 사용하기 위해 다음과 같은 옵션이 있습니다.

  • 간단한 방법으로 엔티티 매니저의 persist 메소드를 사용
  • 동일한 문법 체계를 사용하고자 한다면 SQLQueryFactory 클래스를 사용
2022-12-27 21:56:55.341 ERROR 81152 --- [           main] o.h.hql.internal.ast.ErrorTracker        : line 2:1: unexpected token: dslEntity
2022-12-27 21:56:55.345 ERROR 81152 --- [           main] o.h.hql.internal.ast.ErrorTracker        : line 2:1: unexpected token: dslEntity

antlr.NoViableAltException: unexpected token: dslEntity
    at org.hibernate.hql.internal.antlr.HqlBaseParser.selectFrom(HqlBaseParser.java:1163) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final]
    at org.hibernate.hql.internal.antlr.HqlBaseParser.queryRule(HqlBaseParser.java:825) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final]
    at org.hibernate.hql.internal.antlr.HqlBaseParser.selectStatement(HqlBaseParser.java:336) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final]
    at org.hibernate.hql.internal.antlr.HqlBaseParser.insertStatement(HqlBaseParser.java:373) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final]
    at org.hibernate.hql.internal.antlr.HqlBaseParser.statement(HqlBaseParser.java:206) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final]
    at org.hibernate.hql.internal.ast.QueryTranslatorImpl.parse(QueryTranslatorImpl.java:294) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final]
    at org.hibernate.hql.internal.ast.QueryTranslatorImpl.doCompile(QueryTranslatorImpl.java:189) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final]
    at org.hibernate.hql.internal.ast.QueryTranslatorImpl.compile(QueryTranslatorImpl.java:144) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final]
    at org.hibernate.engine.query.spi.HQLQueryPlan.<init>(HQLQueryPlan.java:113) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final]
    at org.hibernate.engine.query.spi.HQLQueryPlan.<init>(HQLQueryPlan.java:73) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final]
    at org.hibernate.engine.query.spi.QueryPlanCache.getHQLQueryPlan(QueryPlanCache.java:162) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final]
    at org.hibernate.internal.AbstractSharedSessionContract.getQueryPlan(AbstractSharedSessionContract.java:636) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final]
    at org.hibernate.internal.AbstractSharedSessionContract.createQuery(AbstractSharedSessionContract.java:748) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final]
    at org.hibernate.internal.AbstractSharedSessionContract.createQuery(AbstractSharedSessionContract.java:114) ~[hibernate-core-5.6.14.Final.jar:5.6.14.Final]
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:na]
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
    at java.base/java.lang.reflect.Method.invoke(Method.java:566) ~[na:na]
    at org.springframework.orm.jpa.ExtendedEntityManagerCreator$ExtendedEntityManagerInvocationHandler.invoke(ExtendedEntityManagerCreator.java:362) ~[spring-orm-5.3.24.jar:5.3.24]
    at com.sun.proxy.$Proxy104.createQuery(Unknown Source) ~[na:na]
    at com.querydsl.jpa.impl.JPAInsertClause.execute(JPAInsertClause.java:79) ~[querydsl-jpa-5.0.0.jar:na]
    at action.in.blog.dsl.DslStore.createEntity(DslStore.java:29) ~[classes/:na]
    at action.in.blog.dsl.DslStoreIT.lambda$createEntity$0(DslStoreIT.java:48) ~[test-classes/:na]
    at action.in.blog.dsl.DslStoreIT.transaction(DslStoreIT.java:27) ~[test-classes/:na]
    at action.in.blog.dsl.DslStoreIT.createEntity(DslStoreIT.java:43) ~[test-classes/:na]
    ...

TEST CODE REPOSITORY

REFERENCE

댓글남기기