JPA 중급 1 - Querydsl

JPA 중급 1 - Querydsl

QuerydslJPQL 빌더입니다.


이 포스팅의 대부분 내용은 김영한님의 실전! Querydsl을 참고하여 작성됐으며, 간단한 사용법에 대한 예제일 뿐이니 더 깊은 내용이 궁금하신분은 강의를 보세요.

📕 Querydsl


QuerydslJPQL 빌더 역할을 하는 비표준 오픈소스 프로젝트이다.

JPA 표준 빌더는 JPA Criteria인데, 이것은 너무 장황하고 가독성이 좋지 않기 때문에 비록 표준은 아니지만 Querydsl을 사용한다.

Querydsl이 너무 훌륭하기 때문에 Spring Data 프로젝트에서도 많은 지원이 되고 있는데, 근시일내에 표준으로 자리잡지 않을까 조심스레 예상해본다.


Querydsl을 왜 사용할까?


JPQL을 직접 사용하게 되면 문자열을 직접 타이핑해야 한다는 큰 단점이 존재한다.

IbatisMybatis를 사용해보신분은 알겠지만, 쿼리를 직접 타이핑한다는 것은 안정성이 굉장히 떨어지는 작업이라고 볼 수 있다.

다음 SQL을 보자.


String query = "    select \n" +
               "        m.id\n" +
               "        t.id\n" +
               "        m.create_at\n" +
               "        m.update_at\n" +
               "        m.age\n" +
               "        m.name\n" +
               "        m.team_id\n" +
               "        t.create_at\n" +
               "        t.update_at\n" +
               "        t.name\n" +
               "    from\n" +
               "        member m \n" +
               "    inner join\n" +
               "        team t \n" +
               "            on m.team_id=t.id";


눈썰미가 좋으신분은 알아차리셨을수도 있겠지만, 위 SQL에는 버그가있다.

뭘까?


String query = "    select \n" + // <-- select 뒤에 띄어쓰기가 있음
               "        m.id\n" +
               "        t.id\n" +
               "        m.create_at\n" +
               "        m.update_at\n" +
               "        m.age\n" +
               "        m.name\n" +
               "        m.team_id\n" +
               "        t.create_at\n" +
               "        t.update_at\n" +
               "        t.name\n" +
               "    from\n" +
               "        member m \n" +
               "    inner join\n" +
               "        team t \n" +
               "            on m.team_id=t.id";


위처럼 SQL에 버그가 존재하는 상태로 어플리케이션이 실행이 된다는게 가장 큰 문제점이다. (컴파일 타임에 에러가 잡히질 않는다 ! 😱)

즉, 배포가 완료된 후 사용자가 어떤 기능을 사용하여 해당 SQL이 포함된 메서드가 런타임에 호출되고 나서야 버그가 존재함을 알 수 있다는 것이다.

실무에서 이런 상황은 즉시 대처에 들어가야하는 핫픽스 작업이며(애초에 발생해서는 안된다 !), 서비스에 대한 사용자의 신뢰성이 떨어지는 결과까지도 야기할 수 있다.

심지어 원인이 오타라는, 누구나 쉽게 저지를 수 있는 실수이고 디버깅 또한 굉장히 힘들기 때문에 굉장히 질이 좋지 않은 버그라고 볼 수 있을 것 같다.


또한, 위와 비슷하지만 코드를 작성하는 개발자 관점에서 봤을 때 자바 컴파일러와 IDE의 도움을 받을 수 없다는 문제도 있다.

즉, 코드를 작성하다 잘 기억이 나지 않는다면 관련 문서를 계속해서 찾아야만 한다. 실수가 나올 가능성도 매우 높다.

그리고 가독성이 매우 떨어진다는 문제도 있다.

자바 13이후로 문자열 블록이라는 기능이 생겨서 다음과 같은 코드 작성이 가능하긴 한데, 이것도 가독성 측면에서 좋아진 것 뿐이지, 공백 유무에 의한 문제 발생 가능성은 여전히 존재한다.

아직은 우리나라에서 자바8이 가장 많이 사용되고 있고 이제야 자바 11로 넘어가고 있는 추세이기 때문에 먼 이야기다. (21.9월에 자바 17 LTS 발표인데…)


String query = """
                select
                    m.id
                    t.id
                    m.create_at
                    m.update_at
                    m.age
                    m.name
                    m.team_id
                    t.create_at
                    t.update_at
                    t.name
                from
                    member m
                inner join
                    team t
                        on m.team_id=t.id";
               """;


Querydsl은 이러한 단점을 모두 보완해준다.

일단 위의 SQLQuerydsl로 작성한 예를 보자.


queryFactory
        .selectFrom(member)
        .join(member.team, team)
        .fetch();


보다시피 어떤 쿼리가 발생할지 한눈에 파악할 수 있다.

굉장히 가독성이 좋으며, 문자열 사용을 최소화고 빌더 패턴으로 작성되기 때문에 자바 컴파일러와 IDE의 도움을 모두 받을 수 있다.

즉, 쿼리에 문제가 있다면 아예 컴파일 에러가 발생하여 실행자체가 안됨과 동시에 어디에 어떤 문제가 있는지 에러 메시지를 받아볼 수 있으며, 다음과 같이 IDE 자동완성의 이점도 모두 누릴 수 있다.


image


이쯤하면 왜 Querydsl을 사용하는지에 대한 납득이 되었을 것 같다.

하지만 Querydsl은 단순히 JPQL 빌더이기 때문에, JPQL로 안되는 것은 Querydsl에서도 안된다.

Querydsl은 절대 만능이 아니며, 용빼는 재주가 있는게 아니다.

Querydsl은 단순히 JPQL 빌더라는 것을 명심해야 한다.


🚀 환경설정


빌드 스크립트에 대한 세부적인 내용이 궁금하신 분은 하기 포스팅을 참고해주세요.

📜 그레이들 Annotation processor 와 Querydsl


우선 이 세팅은 2021.07.31 현재 Querydsl 최신 버전을 기준으로 하여 작성됨을 말씀드린다.

프로젝트에 적용된 Querydsl 버전4.4.0이며, gradle 버전6.8.3이다.


버전을 먼저 언급하는 이유가 있는데, Querydsl 3.x대 버전과 4.x대 버전 사이에 상당한 차이가 있기 때문이다.


또한, Querydsl을 사용하기 위해서는 JPA EntityQClass라는 것으로 컴파일 해야 하는데, 여기서 annotationProcessorgradle의 도움이 필요하기 때문이다.


필수적인 설정은 다음과 같다.


ext {
    querydslDir = "$buildDir/generated/querydsl"
}

dependencies {
  annotationProcessor(
          'org.springframework.boot:spring-boot-configuration-processor',
          'jakarta.persistence:jakarta.persistence-api',
          'jakarta.annotation:jakarta.annotation-api',
          "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa",
  )

  implementation(
          'com.querydsl:querydsl-jpa',
  )
}


이 포스팅을 작성하기 위해 구성한 프로젝트의 전체적인 빌드 스크립트는 다음과 같다.


plugins {
    id 'java'
    id 'org.springframework.boot' version '2.5.2'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
}

ext {
    querydslDir = "$buildDir/generated/querydsl"
}

group = 'learn.jpa'
version = ''
sourceCompatibility = '11'

repositories {
    mavenCentral()
}

configurations {
    developmentOnly
    runtimeClasspath {
        extendsFrom developmentOnly
    }
}

dependencies {
    annotationProcessor(
            'org.projectlombok:lombok',
            'org.springframework.boot:spring-boot-configuration-processor',
            'jakarta.persistence:jakarta.persistence-api',
            'jakarta.annotation:jakarta.annotation-api',
            "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa",
    )

    implementation(
            'org.springframework.boot:spring-boot-starter-web',
            'org.springframework.boot:spring-boot-starter-data-jpa',
            'org.springframework.boot:spring-boot-starter-validation',
            'com.querydsl:querydsl-jpa',
    )

    testImplementation(
            'org.springframework.boot:spring-boot-starter-test',
    )

    testImplementation("org.springframework.boot:spring-boot-starter-test") {
        exclude group: "junit", module: "junit"
    }

    compileOnly(
            'org.projectlombok:lombok'
    )

    runtimeOnly(
            'com.h2database:h2'
    )
}

test {
    useJUnitPlatform()
}

clean {
    delete file('src/main/generated')
}

task cleanGeneatedDir(type: Delete) {
    delete file('src/main/generated')
}


위 코드가 뭔지 몰라도 일단 필수 설정만 똑같이 작성하면 Querydsl을 사용하는데 큰 문제는 없을 것 같다.

만약 위 코드가 더 궁금하다면 groovygradle에 대한 공부가 필요할 것이다.


그리고 Querydsl 4.x에서 JPAQueryFactory가 핵심적인 역할을 하는데, 이 클래스를 스프링 빈으로 만들기 위해 다음과 같은 설정파일을 작성한다.

필자는 Querydsl이 JPA 도메인에 포함된다고 판단하여 JPA 설정파일에 이 설정을 집어넣는다.


@Configuration
@EnableJpaAuditing
public class JpaConfig {
    @PersistenceContext
    private EntityManager entityManager;

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


참고로 이렇게 설정파일을 작성하는 이유가 두가지 존재한다.


  • Querydsl은 비표준이기 때문에 @DataJpaTest의 도움을 받지 못한다
    • @DataJpaTest는 슬라이싱 테스트를 진행할 때 JPA관련 스프링 빈을 주입해주는데, JPAQueryFactory빈을 주입해주지 않기 때문에 Querydsl 테스트를 하지 못한다
  • JPAQueryFactory싱글톤으로 활용하기 위함이다


이후 모든 Querydsl 테스트를 진행할 때 다음과 같이 진행하면 된다.

여기서 JUnit5버전이며, 생성자 주입을 받기 위해 src/test/resources/spring.properties 를 생성한 후 다음과 같은 설정을 추가해야 한다.


# file: 'src/test/resources/spring.properties'
spring.test.constructor.autowire.mode=ALL


@DataJpaTest
@Import(JpaConfig.class)
class QueryRepositoryTest {
  private final JPAQueryFactory queryFactory;
  private final TestEntityManager entityManager;

  QueryRepositoryTest(JPAQueryFactory queryFactory, TestEntityManager entityManager) {
    this.queryFactory = queryFactory;
    this.entityManager = entityManager;
  }
  
  // test cases...
}


이렇게 설정하면 @SpringBootTest를 사용하지 않는 슬라이싱 테스트를 진행하더라도 Querydsl 테스트를 진행할 수 있다.

명심해야 할 것은, Querydsl을 사용하기 위해서는 QClass가 반드시 필요하므로 항상 gradle에서 build 태스크를 먼저 실행해줘야 QClass가 컴파일되어 Querydsl이 정상적으로 동작하게 된다는 것이다.


📜 사용방법


우선 결과를 조회하는 fetch() 관련 메서드에 대해 알면 좋을 것 같다.

자주 사용하는 fetch는 다음과 같다.

  • fetch()
    • 검색 결과에 대한 모든 값을 Collection에 담아 반환한다. 이 때
  • fetchCount()
    • 검색결과를 카운트로 반환한다. 반환 타입은 long
  • fetchOne()
    • 한개의 결과를 반환한다. 검색 결과가 반드시 한개인 경우에만 사용해야 한다
    • JPQL의 getSingleResult와 같다
    • 결과가 둘 이상인 경우 NonUniqueResultException을 던진다
  • fetchFirst()
    • limit 1과 같다.
    • 검색 중 처음으로 매치되는 것 하나만 반환하며, 즉시 쿼리가 종료된다
  • fetchResults()
    • 페이징 쿼리를 작성할 때 사용된다
    • 콘텐츠, 페이지 개수, 페이지 사이즈, 토탈 카운트를 반환한다
    • 콘텐츠를 찾는 쿼리와 카운트를 찾는 쿼리가 모두 발생한다 (총 2번)


검색



List<Member> members = queryFactory
                        .select(QMember.member)
                        .from(QMember.member)
                        .fetch();

List<Member> members = queryFactory
                        .selectFrom(QMember.member) // select절에 member를 전체 가져올 경우 selectFrom()으로 축약 가능
                        .fetch();

List<Member> members = queryFactory
                        .selectFrom(member) // QMember를 static import하면 이렇게 줄일 수 있다
                        .fetch();


조건 검색



// where절에 and()나 or()를 추가할 수 있으며, 아래 샘플 코드처럼 쉼표(,)로 구분하면 and()와 같이 동작한다.
List<Member> members = queryFactory
                        .selectFrom(member)
                        .where(
                                member.name.eq("siro"),
                                member.age.eq(10)
                              )
                        .fetch();


정렬



List<Member> members = queryFactory
                      .selectFrom(member)
                      .orderBy(member.name.desc())
                      .fetch();


페이징



QueryResults<Member> results = queryFactory
                                .selectFrom(member)
                                .orderBy(member.name.desc())
                                .offset(1)
                                .limit(2)
                                .fetchResults();

List<Member> content = results.getResults();
long totalCount = results.getTotal();


집합



// Tuple은 com.querydsl.core에 존재하는 클래스로, 결과가 하나의 엔티티가 아닌 여러가지 타입일 경우를 뜻한다
List<Tuple> tuples = queryFactory
                      .select(
                              member.count(),
                              member.age.sum(),
                              member.age.avg(),
                              member.age.max(),
                              member.age.min()
                             )
                      .from(member)
                      .fetch();


내부조인



// 내부조인 시 on()절은 생략해도 무방하다. where()절이 있다면 대체된다.
List<Member> members = queryFactory
                        .selectFrom(member)
                        .join(member.team, team) // innerJoin()과 join()은 같다
                        .fetch();


외부조인



// 외부조인 시에는 where()절보다 on()절을 사용해야만 한다.
List<Member> members = queryFactory
                        .selectFrom(member)
                        .leftJoin(member.team, team) // rightJoin()도 존재한다
                        .fetch();


세타조인(=크로스조인)



// 세타조인시 join 조건절 역할을 하는 where이 없거나 잘못된 경우 카테시안 곱이 발생하므로 주의한다
List<Member> members = queryFactory
                        .select(member)
                        .from(member, team)
                        .where(member.name.eq(team.name)) 
                        .fetch();


페치조인


페치조인은 fetchJoin()만 추가하면 바로 적용된다.


List<Member> fetch = queryFactory
                      .selectFrom(member)
                      .join(member.team, team)
                      .fetchJoin()
                      .fetch();


스위치



// 간단한 스위치
List<String> members = queryFactory
                        .select(member.age
                            .when(10).then("열살")
                            .when(20).then("스무살")
                            .otherwise("기타")
                        )
                        .from(member)
                        .fetch();

// 복잡한 스위치
List<String> members = queryFactory
                        .select(new CaseBuilder() // 케이스빌더 사용
                            .when(member.name.startsWith("김")).then("김씨")
                            .when(member.name.startsWith("이")).then("이씨")
                            .otherwise("기타"))
                        .from(member)
                        .fetch();


문자열, 상수 이어붙이기



List<String> members = queryFactory
                        .select(member.name.concat("_").concat(member.age.stringValue())) // concat사용
                        .from(member)
                        .where(member.name.eq("siro"))
                        .fetch();


프로젝션


프로젝션을 사용할 경우 필드명이 다른 경우 as를 사용해 필드명을 맞춰주면 된다.

예를 들자면, Member 엔티티의 값을 꼭 MemberDto에 넣어야만 되는 것이 아니며, 다양한 DTO에 넣어줄 수 있다.

기본적으로 아래의 3가지 방식을 지원한다.


// 수정자(setter) 프로젝션
List<MemberDto> memberDtos = queryFactory
                              .select(Projections.bean(MemberDto.class, member.name, member.age))
                              .from(member)
                              .fetch();

// 필드 프로젝션
List<MemberDto> memberDtos = queryFactory
                              .select(Projections.fields(MemberDto.class, member.name, member.age))
                              .from(member)
                              .fetch();

// 생성자 프로젝션
List<MemberDto> memberDtos = queryFactory
                              .select(Projections.constructor(MemberDto.class, member.name, member.age))
                              .from(member)
                              .fetch();


추가로 @QueryProjection을 생성자에 추가하면 DTOQClass로 컴파일되어 더 쉽게 사용할 수 있다.


@Getter
public class MemberDto {
    private Long id;
    private String name;
    private Integer age;

    public MemberDto() {}

    @QueryProjection
    public MemberDto(Long id, String name) {
        this.id = id;
        this.name = name;
    }

    @QueryProjection
    public MemberDto(String name, Integer age) {
        this.name = name;
        this.age = age;
    }

    @QueryProjection
    public MemberDto(Long id, String name, Integer age) {
        this.id = id;
        this.name = name;
        this.age = age;
    }

    public static MemberDto createMemberDto(Long id, String name, Integer age) {
        return new MemberDto(id, name, age);
    }

    public static MemberDto createMemberDto(Long id, String name) {
        return new MemberDto(id, name);
    }

    public static MemberDto createMemberDto(String name, Integer age) {
        return new MemberDto(name, age);
    }
}


// @QueryProjection 프로젝션
List<MemberDto> memberDtos = queryFactory
                              .select(new QMemberDto(member.name, member.age))
                              .from(member)
                              .fetch();


@QueryProjection생성자 프로젝션은 서로 일장일단이 존재한다.


  • @QueryProjection
    • 장점 - 프로젝션시 발생하는 에러를 컴파일타임에 잡을 수 있다
    • 장점 - 코드가 약간 더 직관적이다
    • 단점 - DTO에 Querydsl 로직이 들어간다 (DTO가 Querydsl을 의존한다)
  • 생성자 프로젝션
    • 장점 - DTO가 Querydsl을 의존하지 않는다
    • 단점 - 런타임 에러가 발생할 수 있는 가능성이 존재한다


동적쿼리


where절null을 반환하면 해당 검색조건이 무시되기 때문에 동적쿼리를 작성하기가 편리하다.

외에 BooleanBuilder를 사용한 방법도 존재하는데 별로 선호하지 않으므로 패스.


List<Member> members = queryFactory
                        .selectFrom(member)
                        .where(allEq("siro", null))
                        .fetch();

// 계속해서 재사용할 수 있다는 큰 장점이 있다.
private BooleanExpression nameEq(String name) {
    return Objects.isNull(name) ? null : member.name.eq(name);
}

private BooleanExpression ageEq(Integer age) {
    return Objects.isNull(age) ? null : member.age.eq(age);
}

private BooleanExpression allEq(String name, Integer age) {
    return nameEq(name).and(ageEq(age));
}


벌크연산


벌크연산의 경우 영속성 컨텍스트를 무시하고 DB에 직접 쿼리하기 때문에 영속성 컨텍스트와 실제 DB의 데이터 정합성이 깨질 수 있다.

따라서 벌크연산을 가장 먼저 실행하거나, 후순위에 실행된다면 영속성 컨텍스트를 개발자가 직접 관리해야한다.

벌크연산은 이 쿼리에 영향받은 row 개수long으로 반환한다.

혹은 쿼리 메서드에 @Modifying을 추가하여 영속성 컨텍스트를 관리할 수 있다.

잦은 업데이트가 발생하는 경우 엔티티 클래스에 @DynamicUpdate를 붙여 관리할 수도 있다.

다만 @Modifying@DynamicUpdate는 잘 알아보고 사용해야 한다.


queryFactory
        .update(member)
        .set(member.age, 100)
        .where(member.age.lt(35))
        .execute();

queryFactory
        .delete(member)
        .where(member.age.eq(29))
        .execute();


DB 함수 사용


org.hibernate.dialect.Dialect 의 DB벤더 별 확장 클래스를 찾아서 (H2Dialect같은 것) 사용하고자 하는 함수가 등록돼있는지 확인하고, 사용하려는 함수가 없는 경우(커스텀 함수 등) 해당 확장 클래스를 한번 더 확장하여 함수를 등록한 후 확장한 클래스를 설정파일에 등록해야한다.


queryFactory
        .select(Expressions.stringTemplate("function('replace', {0}, {1}, {2})", member.name, "s", "S"))
        .from(member)
        .fetch();


모든 DB 벤더가 지원하는 ANSI 표준에 있는 기본적인 함수들은 이미 Hibernate가 지원하므로, stringTemplate 을 직접 사용하지 않아도 된다.


queryFactory
        .select(member.name)
        .from(member)
        .where(member.name.eq(member.name.lower()))
        .fetch();



© 2022. All rights reserved.