QueryDSL 기본 문법

PEPPERMINT100·2021년 3월 28일
1

서론

JPA를 공부하며 JPA와 거의 함께 움직이는 QueryDSL의 간단한 세팅과 사용 방법에 대해서 알아보았다. 그 때는 QueryDSL의 필요성을 알아보는 것을 중심으로 글을 작성하며 문법에 대해서는 다루지 않았는데, 이번엔 QueryDSL의 간단한 문법과 BooleanBuilder를 통한 조건절 만들기 등 실전 예제에 대해서 간단하게 다뤄보도록 하겠다.

세팅

이번 글 역시 그레이들 기준으로 세팅을 진행하는데, 메이븐의 경우는 공식문서에서 설정 방법을 찾을 수 있다.

먼저 build.gradle파일을 아래와 같이 작성해준다.

plugins {
    id 'org.springframework.boot' version '2.3.5.RELEASE'
    id 'io.spring.dependency-management' version '1.0.10.RELEASE'
    id 'java'
    id 'com.ewerk.gradle.plugins.querydsl' version '1.0.10'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-jdbc'
    runtimeOnly 'mysql:mysql-connector-java'

    implementation 'com.querydsl:querydsl-jpa'
    implementation 'com.querydsl:querydsl-apt'

    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
}

test {
    useJUnitPlatform()
}

test {
    useJUnitPlatform()
}

def querydslSrcDir = "$buildDir/generated/querydsl"

querydsl {
    jpa = true
    querydslSourcesDir = querydslSrcDir
}

sourceSets {
    main.java.srcDirs querydslSrcDir
}

configurations {
    querydsl.extendsFrom compileClasspath
}

compileQuerydsl{
    options.annotationProcessorPath = configurations.querydsl
}

설정 관련 자세한 내용은 저번 글을 참조하면 된다.

이번 글 작성에서는 저번 세팅을 그대로 가져오지 않고 공부를 위해 QueryDSL 세팅을 처음부터 다시 해보았는데, 역시나 생각보다 오래 걸렸다. 구글링하며 찾은 글에서도 원래 QueryDSL의 세팅은 복잡하고 오래 걸릴 수 있다고 한다.

JPAQueryFactory를 빈으로 등록해주고 @Configuration으로 클래스를 등록해준다. 참고로 모든 코드는 여기에서 볼 수 있다.

@Configuration
public class DatabaseConfig {

    @PersistenceContext
    private EntityManager entityManager;

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

이번에 사용할 엔티티는 아래와 같다.

@Entity(name = "USER_TABLE")
@Getter @Setter
@ToString(of = {"id", "username", "level"})
@NoArgsConstructor
public class User {

    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String username;

    private Integer level;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "battle_class_id")
    private BattleClass battleClass;

    public User(String username, Integer level, BattleClass battleClass) {
        this.username = username;
        this.level = level;
        changeBattleClass(battleClass);
    }

    private void changeBattleClass(BattleClass battleClass){
        this.battleClass = battleClass;
        battleClass.getUserList().add(this);
    }
}
@Entity(name = "BATTLE_CLASS_TABLE")
@Getter @Setter
@ToString(of = {"id", "className"})
@NoArgsConstructor
public class BattleClass {

    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "battle_class_id")
    private Long id;

    private String className;

    @OneToMany(mappedBy = "battleClass", fetch = FetchType.LAZY)
    private List<User> userList = new ArrayList<>();

    public BattleClass(String className) {
        this.className = className;
    }
}

간단히 설명하자면 User가 있고 이름, 레벨, 직업을 갖는다. 그리고 생성자에서는 직접 영속성 컨텍스트에 직업을 설정하고 직업 군 유저들을 업데이트 할 수 있도록 해주었다.

그리고 모든 fetchTypeLAZY 형태로 등록하여 연관 매핑이 된 엔티티들은 가져오지 않도록 했다.

각각 리포지토리 역시 JPARepository를 상속받아 만들어주고 테스트 파일로 넘어온다.

엔티티를 만들어준 다음에는 그레이들 플러그인에서 complieQuerydsl를 실행하여 Q클래스를 만들어준다.

@SpringBootTest(classes = Application.class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class ApplicationTests {

    @Autowired private JPAQueryFactory query;
    @Autowired private BattleClassRepository battleClassRepository;
    @Autowired private UserRepository userRepository;

    @BeforeAll
    public void setUp(){
        BattleClass warrior = new BattleClass("전사");
        BattleClass archer = new BattleClass("궁수");
        BattleClass mage = new BattleClass("마법사");
        battleClassRepository.saveAll(List.of(warrior, archer, mage));

        User pepper = new User("pepper", 5, warrior);
        User flame = new User("flame", 10, warrior);
        User iksuo = new User("iksuo", 2, archer);
        User scott = new User("scott", 15, mage);
        User bob = new User("bob", 25, archer);
        User jane = new User("jane", 11, mage);
        User gorden = new User("gorden", 3, warrior);
        User kelly = new User("kelly", 9, archer);
        User mickey = new User("mickey", 1, mage);
        User ken = new User("ken", 13, warrior);
        userRepository.saveAll(List.of(pepper, flame, iksuo, scott, bob, jane, gorden, kelly, mickey, ken));
    }
}

이제 위와 같이 테스트할 객체들을 DB에 넣어주고 @BeforeAll로 테스트 이전에 데이터베이스에 넣어주면 기본적인 세팅은 끝이다.

기본 쿼리

    @Test
    public void is_there_10_characters(){
        List<User> allCharacters = query
                .selectFrom(user)
                .fetch();
        Assertions.assertEquals(10, allCharacters.size());
    }

    @Test
    public void users_level_under_10(){
        List<User> usersLevelUnder10 = query
                .selectFrom(user)
                .where(user.level.loe(10))
                .fetch();

        Assertions.assertEquals(6, usersLevelUnder10.size());
    }

    @Test
    public void highest_level_user(){
        List<User> userOrderedByLevel = query
                .selectFrom(user)
                .orderBy(user.level.desc())
                .fetch();

        Assertions.assertEquals(25, userOrderedByLevel.get(0).getLevel());
    }

위 세 코드는 기본적인 쿼리문이다. loeLess or Equal 즉 작거나 같다를 뜻하고 크거나 같다는 goe를 사용해주면 된다. 또 eq, between 등 다양한 조건을 거는 것이 가능하며 굉장히 직관적이다. 기본적인 쿼리들은 크게 어려운 부분이 없다.

    @Test
    public void inner_join(){
        List<User> joinedWithBattleClass = query
                .selectFrom(user)
                .join(user.battleClass, battleClass)
                .fetch();

        joinedWithBattleClass.forEach(System.out::println);
    }

    @Test
    public void left_join_on(){
        List<User> allArchers = query
                .select(user)
                .from(user)
                .leftJoin(battleClass)
                .on(battleClass.className.eq("궁수"), user.level.loe(10), user.username.eq("somerandomname"))
                .fetch();

        Assertions.assertEquals(10, allArchers.size());
    }

    @Test
    public void sub_query(){
       //query.select(user.level.max()).from(user).fetch();
       List<User> maxLevelUser = query
                .selectFrom(user)
                .where(
                    user.level.in(
                        JPAExpressions
                        .select(user.level.max())
                        .from(user)
                    )
                )
                .fetch();

       Assertions.assertEquals(25, maxLevelUser.get(0).getLevel());
    }

위와 같이 join 역시 지원한다. join 이후에 on 과 같이 조건절에서는 and() 또는 or()를 통해서 조건을 더 추가할 수도 있고

.on(battleClass.className.eq("궁수"), user.level.loe(10), user.username.eq("somerandomname"))

이렇게 ,를 통해 나열하면 자동으로 and 처럼 동작하게 된다.

서브쿼리 역시 JPAExpressions 을 통해 작성할 수 있다.

Tuple

TupleQueryDSL에서 제공하는 타입이다. 일반적으로 selectFrom 또는 select를 통해 Q클래스를 선택하는데, 그룹 함수를 사용할 경우에는 쿼리에서 가져오는 애트리뷰트를 다르게 선택해야하는 경우가 있을 수 있다.

이런 경우에 QueryDSLTuple의 형태로 데이터를 반환한다. 일반적인 Collections 객체 처럼 작용하지만 우리가 정확히 원하는 결과를 가져오기 위해서는 Dto를 사용할 필요가 있다. 특히 Entity에 변화가 있을 때 Dto를 사용하지 않으면 API 설계에 큰 영향을 미칠 수 있다.

@Data
@NoArgsConstructor
public class UserDto {
    private String username;
    private Integer level;
    private String className;

    @QueryProjection
    public UserDto(String username, Integer level, String className) {
        this.username = username;
        this.level = level;
        this.className = className;
    }
}

위 처럼 UserDto를 생성하고 생성자 위에 @QueryProjection이라는 어노테이션을 붙여준다. 이렇게 하고 다시 그레이들 플러그인의 compileQuerydsl을 실행해주면 QUserDto라는 자바 클래스가 생성된다.

    @Test
    public void custom_dto_tuple(){
        List<Tuple> tupleBob = query
                .select(
                        user.username,
                        user.level,
                        user.battleClass
                )
                .from(user)
                .where(user.username.eq("bob"))
                .fetch();

        List<UserDto> bob = query
                .select(new QUserDto(
                        user.username,
                        user.level,
                        user.battleClass.className
                ))
                .from(user)
                .where(user.username.eq("bob"))
                .fetch();
        Assertions.assertEquals(1, bob.size());
        Assertions.assertEquals("bob", bob.get(0).getUsername());
    }

위 코드를 보면 Tuple형태로 가져올 수도 있고 그 아래 select 안에 QUserDto를 생성하여 원하는 형태로 데이터를 가져올 수도 있다.

Dto클래스의 각 변수의 이름을 원래 엔티티인 User와 맞춰주어야 하는 점을 유의한다.

동적 쿼리

일반적으로 조건절을 붙일 때는 where를 통해 조건을 나열할 수 있다. 하지만 조건이 한 두개가 아닌 경우에는 코드를 작성하기도 알아보기도 어렵고 유지 보수하기 힘들 수 있으며 코드를 확장성 있게 작성하고 싶다면 BooleanBuilder를 이용할 수 있다.

    private List<User> findByClassNameAndLevelUnder(String className, Integer level){
        BooleanBuilder builder = new BooleanBuilder();

        if(!Objects.isNull(className)){
            builder.and(user.battleClass.className.eq(className));
        }

        if(level > 0){
            builder.and(user.level.lt(level));
        }

        List<User> results = query
                .selectFrom(user)
                .where(builder)
                .fetch();

        return results;
    }

위 처럼 메소드를 하나 만들어준다. 특정 클래스의 특정 레벨 이하의 유저만 가져오게 하는 코드이다. BooleanBuilder 인스턴스를 하나 만들어주고 and()를 통해 안에 where() 처럼 조건 제약을 걸어주고 실제 쿼리에는 builder만 넣어주면 된다.

    private BooleanExpression findByClassName(String className){
        return Objects.isNull(className) ? null : user.battleClass.className.eq(className);
    }

    private BooleanExpression findByLevelUnder(Integer level){
        return level < 0 ? null : user.level.lt(level);
    }

아니면 이렇게 BooleanExpression을 통해서 특정 조건을 메소드화 할 수도 있다. 이렇게 하면 이 조건을 다양한 쿼리에서 사용할 수 있으므로 확장성에 있어서 유리하다.

   private List<User> findByClassNameAndLevelUnderWithBooleanExpression(String className, Integer level){
        return query
                .selectFrom(user)
                .where(findByClassName(className), findByLevelUnder(level))
                .fetch();
    }

BooleanExpression을 통해 만든 함수를 where절에 넣어주면 역시 똑같이 작동한다.

   @Test
    public void dynamic_query(){
        List<User> results1 = findByClassNameAndLevelUnder("마법사", 10);
        List<User> results2 = findByClassNameAndLevelUnderWithBooleanExpression("마법사", 10);
        Assertions.assertEquals(results1.get(0).getUsername(), results2.get(0).getUsername());
    }

위 테스트 코드를 통해 두 코드가 동일하게 작동하는지 확인할 수 있다.

profile
기억하기 위해 혹은 잊어버리기 위해 글을 씁니다.

0개의 댓글