본문 바로가기
JPA

Querydsl - 1편

by 성건희 2022. 2. 8.
반응형

현업에서 Spring Data JPA + Querydsl 은 거의 무조건 사용되므로 잘 이해하고 사용하자.

Querydsl 장점

  • 쿼리를 자바 코드로 작성하여 문법 오류를 컴파일 시점에 잡음
  • 동적 쿼리 문제 해결
  • 쉬운 SQL 스타일 문법

build.gradle 설정

Spring Boot 2.6 이상 버전에서는 Querydsl 5.0 을 사용하므로
아래와 같이 querydsl-jpa, querydsl-apt 을 추가하고, 버전을 명시해주어야 한다.

  • querydsl-jpa : 실제 애플리케이션에서 Querydsl 을 사용할때 필요한 라이브러리
  • querydsl-apt : Q 클래스를 만드는 용도로 쓰임
buildscript {
    ext {
        queryDslVersion = "5.0.0"
    }
}

plugins {
    id 'org.springframework.boot' version '2.6.3'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    //querydsl 추가
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
    id 'java'
}

group = 'study'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'

    //querydsl 추가
    implementation "com.querydsl:querydsl-jpa:${queryDslVersion}"
    annotationProcessor "com.querydsl:querydsl-apt:${queryDslVersion}"

    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

def querydslDir = "$buildDir/generated/querydsl"
querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}
sourceSets {
    main.java.srcDir querydslDir
}
configurations {
    querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}

참고 ) Querydsl 로 생성되는 Q 클래스는 git 에 올리면 안된다.
보통 build 폴더는 gitIgnore 하므로 $buildDir/generated/querydsl 위치에 넣으면 됨

위와 같이 설정하고 compileQuerydsl 을 클릭하면 Q 클래스가 생성된다.

Test 코드 작성 시 @Commit 과 @Rollback(false) 의 차이

기본적으로 테스트 코드는 테스트 완료 시, 트랜잭션 rollback 을 하기때문에 DB 에서 결과를 볼 수 없다.
테스트 코드에서 엔티티가 실제 테스트 DB 에 잘 반영이 되었는지 눈으로 보고싶다면
@Commit 이나 @Rollback(false) 를 사용하면 된다.
해당 어노테이션을 넣어주게 되면 테스트 완료 시 롤백을 하지 않는다.
참고로 @Commit 내부를 까보면 @Rollback(false) 를 사용하고 있기 때문에 둘의 기능은 동일하다.

Q 클래스는 static import 해서 사용하자

Q 클래스는 new QMember() 로 생성 할 필요 없이 static 하게 QMember.member 로 생성할 수 있다.

QMember m = QMember.member;

Member findMember = queryFactory.select(m)
                                .from(m)
                                .where(m.username.eq("member1"))
                                .fetchOne();

하지만 변수로 뽑지않고 static import 를 이용해서 깔끔하게 처리 할 수 있다.

import static study.querydsl.domain.QMember.member;
...

Member findMember = queryFactory.select(member)
                                .from(member)
                                .where(member.username.eq("member1"))
                                .fetchOne();

Querydsl 로 실행되는 JPQL 을 보고싶다면?

application.yml 에 use_sql_comments 옵션을 넣어주면 된다.

Querydsl 로 작성한 코드는 JPQL 의 빌더 역할이기 때문에 결국 JPQL 로 변환된다.
실행하면 아래와 같이 JPQL 코드를 볼 수 있다.

alias (별칭) 가 member1 로 찍힌 이유

Querydsl 이 자동으로 만들어주는 Q 클래스의 alias 가 member1 로 지정되어 있기 때문

같은 테이블을 조인해야 하는 상황에서는 alias 가 달라야 하므로 아래와 같이 직접 선언하면 된다.
보통 이렇게 사용할 일은 많이 없긴하다.

QMember m1 = new QMember("m1");

select, selectFrom

Member findMember = queryFactory.select(member)
                                .from(member)
                                .where(member.username.eq("member1"))
                                .fetchOne();

위와 같이 select 와 from 절이 같은 경우는 아래와 같이 selectFrom 으로 줄여 쓸 수 있다.

Member findMember = queryFactory.selectFrom(member)
                                .where(member.username.eq("member1"))
                                .fetchOne();

where 절 and

and 방식

Member findMember = queryFactory.selectFrom(member)
                                .where(member.username.eq("member1")
                                                      .and(member.age.eq(10)))
                                .fetchOne();

쉼표 (,) 방식

Member findMember = queryFactory.selectFrom(member)
                                .where(
                                        member.username.eq("member1"),
                                        member.age.eq(10)
                                )
                                .fetchOne();

and 와 쉼표 방식 모두 and 로 동작한다.
둘 중 어느걸 쓰는게 더 좋냐 하는 것은 취향 차이지만,
영한님은 쉼표 방식이 좀 더 깔끔하고, null 이 들어가도 무시하므로 동적 쿼리를 깔끔하게 짤 수 있어서 선호하신다고 함

결과 조회

  • fetch() : 리스트 조회, 데이터가 없으면 빈 리스트를 반환
  • fetchOne() : 단 건 조회
    • 결과가 없으면 : null
    • 결과가 둘 이상이면 : com.querydsl.core.NonUniqueResultException 발생
  • fetchFirst() : limit(1).fetchOne()
  • fetchResults() : 페이징 정보 포함, total count 쿼리 추가 실행 (select 쿼리 2번 나감 - 컨텐츠 쿼리 1번, total count 쿼리 1번)
    • 페이징 쿼리가 복잡해지면 컨텐츠 쿼리와 total count 쿼리가 다를 경우가 있다. (성능 최적화로 total count 쿼리를 심플하게 만듬)
      이런 복잡하고 성능이 중요한 페이징 쿼리에서는 fetchResults 를 쓰면 안된다. (쿼리 2번을 날려야한다.)
    • Querydsl 5.0 부터 Deprecated
  • fetchCount() : count 쿼리로 변경해서 count 수 조회
    • Querydsl 5.0 부터 Deprecated

fetchResults(), fetchCount() Deprecated

Querydsl 의 fetchResults(), fetchCount() 는 개발자가 작성한 select 쿼리를 기반으로 count 용 쿼리를 내부에서 만들어서 실행한다.
하지만 이 기능은 select 구문을 단순히 count 처리하는 용도로 바꾸는 정도다.
따라서 단순한 쿼리에서는 잘 동작하지만, 복잡한 쿼리에서는 제대로 동작하지 않는다.
그래서 Querydsl 은 fetchResults(), fetchCount() 를 Deprecated 하기로 결정했다.

count 쿼리가 필요하다면 다음처럼 별도로 작성해야 한다.

Long count = queryFactory.select(member.count())
                         .from(member)
                         .fetchOne();
  • member.count() 를 사용하면 count(member.id) 로 처리된다.
  • count(*) 로 처리하고 싶으면 Wildcard.count 를 사용하면 된다.
  • 응답 결과는 숫자 하나이므로 fetchOne() 을 사용한다.

집합

집계 함수는 Tuple 을 반환한다.
실무에서는 Tuple 을 많이 쓰지는 않고, DTO 로 받아오는 방법으로 많이쓴다.

List<Tuple> result = queryFactory.select(
                                         member.count(),
                                         member.age.sum(),
                                         member.age.avg(),
                                         member.age.max(),
                                         member.age.min()
                                 )
                                 .from(member)
                                 .fetch();

Tuple tuple = result.get(0);
assertThat(tuple.get(member.count())).isEqualTo(4);
assertThat(tuple.get(member.age.sum())).isEqualTo(100);
assertThat(tuple.get(member.age.avg())).isEqualTo(25);
assertThat(tuple.get(member.age.max())).isEqualTo(40);
assertThat(tuple.get(member.age.min())).isEqualTo(10);

Group By

List<Tuple> result = queryFactory.select(team.name, member.age.avg())
                                 .from(member)
                                 .join(member.team, team)
                                 .groupBy(team.name)
                                 .fetch();
  • having() 을 통해 조건도 넣을 수 있다.

세타 조인

연관관계가 없는 필드로 조인

em.persist(new Member("teamA"));
em.persist(new Member("teamB"));

List<Member> result = queryFactory.select(member)
                                  .from(member, team)
                                  .where(member.username.eq(team.name))
                                  .fetch();

assertThat(result).extracting("username")
                  .containsExactly("teamA", "teamB");
  • from 절에 여러 엔티티를 선택해서 세타 조인
  • 카테시안 곱으로 조인이 된다. (다만 DB 가 성능 최적화를 하긴함)
  • 외부 조인이 불가능하다. -> 최근에는 조인 on 을 사용하면 외부 조인이 가능하다. (JPA 2.1 부터 지원)

조인 - on 절

ON 절을 활용한 조인 (JPA 2.1 부터 지원 (하이버네이트 5.1))

1. 조인 대상 필터링

참고 : on 절을 활용해 조인 대상을 필터링 할 때, 외부 조인이 아니라 내부 조인 (inner join) 을 사용하면,
where 절에서 필터링 하는 것과 기능이 동일하다. 따라서 on 절을 활용한 조인 대상 필터링을 사용할 때,
내부 조인이면 익숙한 where 절로 해결하고, 정말 외부 조인이 필요한 경우에만 이 기능을 사용하자.


2. 연관관계 없는 엔티티 외부 조인 (보통 이 용도로 많이 사용)

// 회원의 이름이 팀 이름과 같은 대상 외부 조인
List<Tuple> result = queryFactory.select(member, team)
                                 .from(member)
                                 .leftJoin(team)
                                 .on(member.username.eq(team.name))
                                 .fetch();
  • leftJoin 부분이 연관관계가 없는 엔티티이므로 leftJoin(member.team, team) 이 아닌 leftJoin(team) 임에 유의하자.

페치 조인 (fetch join)

페치 조인은 SQL 에서 제공하는 기능은 아니다.
SQL 조인을 활용해서 연관된 엔티티를 SQL 한번에 조회하는 기능이다.
주로 성능 최적화에 사용하는 방법이다.

@PersistenceUnit
EntityManagerFactory emf;

...

Member findMember = queryFactory.selectFrom(member)
                                .join(member.team, team)
                                .fetchJoin()
                                .where(member.username.eq("member1"))
                                .fetchOne();

boolean loaded = emf.getPersistenceUnitUtil()
                    .isLoaded(findMember.getTeam());

assertThat(loaded).as("페치 조인 적용").isTrue();
  • join(), leftJoin() 등 조인 기능 뒤에 fetchJoin() 을 추가하면 된다.
  • 참고 : emf.getPersistenceUnitUtil().isLoaded() 를 통해 프록시가 초기화가 되었는지 확인할 수 있다.

서브쿼리

com.querydsl.jpa.JPAExpressions 사용

// 나이가 가장 많은 회원 조회
QMember memberSub = new QMember("memberSub");

List<Member> result = queryFactory.selectFrom(member)
                                  .where(member.age.eq(
                                          JPAExpressions
                                                  .select(memberSub.age.max())
                                                  .from(memberSub)
                                  ))
                                  .fetch();

from 절의 서브쿼리 한계

JPA JPQL 서브쿼리의 한계점으로 from 절의 서브쿼리는 지원하지 않는다.
당연히 Querydsl 도 JPQL 의 빌더 역할이므로 지원하지 않는다.
하이버네이트 구현체를 사용하면 select 절의 서브쿼리는 지원한다.
Querydsl 도 하이버네이트 구현체를 사용하면 select 절의 서브쿼리를 지원한다.

from 절의 서브쿼리 해결방안

  1. 서브쿼리를 join 으로 변경한다. (가능한 상황도 있고, 불가능한 상황도 있다.)
  2. 애플리케이션에서 쿼리를 2번 분리해서 실행한다.
  3. nativeSQL 을 사용한다.

참고 : 화면에 맞춰서 쿼리를 짜려고 하다보면 From 절 안에 From 절 안에 From 절 ... 구조가 되기 쉽다.
DB 는 데이터를 최소화해서 가져오는 역할만 하고, 로직은 애플리케이션이나 프레젠테이션에서 처리하도록 하면
From 절의 서브 쿼리를 많이 줄일 수 있다.

한방 쿼리로 날리는게 무조건 좋을까?

실시간 트래픽이 중요한 서비스에서는 한방 쿼리로 최적화 하는게 중요할 수 있다.
하지만 어드민 페이지의 경우 한방 쿼리를 짜기위해 복잡한 쿼리로 길게 만드는 것 보다
쿼리를 나눠서 보내는게 더 나은 방식일 수 있다.

  • 정말 복잡한 수천 줄의 쿼리는 쪼개서 호출하면, 몇백 줄로 줄여서 훨씬 분량을 줄일 수 있다.- SQL AntiPatterns 책에서..

Case 문

현업에서 DB는 최소한의 필터링, 그룹핑으로 데이터를 줄이는 일만해야한다.
case 로 데이터를 변환하는 작업은 DB에서 하면 안된다.
데이터 변환 작업은 애플리케이션이나 프레젠테이션 레이어에서 해야한다.

문자 더하기 concat

// 회원명_나이
List<String> result = queryFactory.select(member.username.concat("_").concat(member.age.stringValue()))
                                  .from(member)
                                  .where(member.username.eq("member1"))
                                  .fetch();
  • member.age.stringValue() : 문자가 아닌 다른 타입은 stringValue() 로 문자로 변환할 수 있다.
    이 방법은 ENUM 을 처리할 때도 자주 사용된다.

참고

  • 실전! Querydsl
반응형

'JPA' 카테고리의 다른 글

Querydsl - 3편  (0) 2022.02.15
Querydsl - 2편  (0) 2022.02.08
JPA 를 공부하면서 알게 된 내용 정리 4  (0) 2022.02.05
JPA 를 공부하면서 알게 된 내용 정리 3  (0) 2022.02.02
JPA 를 공부하면서 알게 된 내용 정리 2  (0) 2022.01.29

댓글