개발놀이터

QueryDSL 기본문법 본문

JPA/QueryDSL

QueryDSL 기본문법

마늘냄새폴폴 2022. 2. 7. 16:13

이 포스팅은 인프런 김영한 님의 실전! Querydsl 편을 보고 각색한 포스팅입니다. 자세한 내용은 강의를 확인해주세요

QueryDSL 시작하기

먼저 QueryDSL을 시작하기 위해선 gradle에서 설정을 해줘야한다. 

//querydsl 추가
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'
	id 'java'
	id 'war'
	//querydsl 추가
	id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}

group = 'review'
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-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'mysql:mysql-connector-java'
	annotationProcessor 'org.projectlombok:lombok'
	providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'

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

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

//querydsl 추가
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 추가 끝

이렇게 설정해주면 엔티티를 만들었을 때 build폴더 안에 generated에 Q타입이 생성된다. 이 Q타입은 QueryDSL을 사용하기 위해서 필요하다. 

 

두번째로 QueryDSL은 QueryFactory라는 곳에서 시작된다. JPAQueryFactory는 EntityManager를 주입받아 사용할 수 있고 JPQL 빌더 역할을 한다. 때문에 자바 컴파일 단계에서 도움을 받을 수 있다. 

 

주입받는 방법은 다음과 같다.

    private final JPAQueryFactory queryFactory;

    public MemberRepositoryImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

여기서 잠깐!

JPAQueryFactory가 필드에 있어도 괜찮을까? 동시성 문제가 발생하지 않을까?

 

스프링 프레임워크는 여러 쓰레드에서 동시에 같은 EntityManager에 접근해도, 트랜잭션 마다 별도의 영속성 컨텍스트를 제공하기 때문에, 동시성 문제는 걱정하지 않아도 된다. 

 

QueryDSL vs JPQL

둘의 차이점을 알아보기 전에 코드를 먼저 확인해보자

    @Test
    public void startJPQL() {
        //member1을 찾아라
        String qlString = "select m from Member m where m.username = :username";
        Member findMember = em.createQuery(qlString, Member.class)
                .setParameter("username", "member1")
                .getSingleResult();

        assertThat(findMember.getUsername()).isEqualTo("member1");
    }

    @Test
    public void startQuerydsl() {
        Member findMember = queryFactory
                .select(member)
                .from(member)
                .where(member.username.eq("member1"))
                .fetchOne();
        
        assertThat(findMember.getUsername()).isEqualTo("member1");
    }

QueryDSL은 JPQL의 단점을 보완하기 위해 나온 프레임워크이다. 먼저 JPQL의 단점을 확인해보고 QueryDSL이 어떻게  문제를 해결했는지 알아보자

 

1. 문자로 작성되는 SQL문

JPQL에서 SQL문은 문자로 작성된다. 때문에 오타가 조금만 생겨도 문제가 발생할 수 있다. 오타가 생기더라도 애플리케이션이 실행이 되고 실행 후에 오류가 발생한다 (런타임 오류). QueryDSL은 이러한 문제를 해결하여 자바 컴파일 단계에서 도움을 받아 컴파일 시점에 오류를 잡을 수 있게 되었다. 때문에 안정적으로 SQL문을 자바로 작성하여 프로그램의 안정성을 높여준다. 

 

2. SQL injection 공격

이 단점은 1번과 연결되는 단점이다. JPQL은 SQL문을 문자로 작성하기 때문에 해커에 의한 공격이 상대적으로 쉽다. 해커들이 사용하는 대표적인 공격중 한가지인 SQL injection 공격에 취약하다. 

 

3. 동적쿼리

JPQL에서 동적쿼리는 구현하기 힘든 문제중 하나였다. JPQL에서도 동적쿼리를 위해 제공하는 몇가지 기능들이 있지만 사용하기 너무 난이도가 높고 복잡하고 가시성도 떨어져 거의 사용하지 않고 있다. QueryDSL에선 BooleanExpression를 이용해 동적쿼리를 풀어내었는데 이것은 가히 혁명이라 불려도 될 것이다. 이 부분에 대해서는 다음 포스팅인 QueryDSL 중급 문법에서 자세히 다룰 예정이다. 

 

이제 본격적으로 QueryDSL의 기본 문법을 알아보자

QueryDSL 기본 문법

1. 검색 조건 쿼리 

where 절에서 사용하는 다양한 문법을 제공한다.

    @Test
    public void search() {
        Member findMember = queryFactory
                .selectFrom(member)
                .where(member.username.eq("member1").and(member.age.eq(10)))
                .fetchOne();

        assertThat(findMember.getUsername()).isEqualTo("member1");
    }

 

2. 결과 조회

    @Test
    public void resultFetch() {
        List<Member> fetch = queryFactory
                .selectFrom(member)
                .fetch();

        Member fetchOne = queryFactory
                .selectFrom(QMember.member)
                .fetchOne();

        Member fetchFirst = queryFactory
                .selectFrom(QMember.member)
                .fetchFirst();

        QueryResults<Member> results = queryFactory
                .selectFrom(member)
                .fetchResults();
        results.getTotal();
        List<Member> content = results.getResults();

        long total = queryFactory
                .selectFrom(member)
                .fetchCount();
    }

-fetch() : 리스트 조회, 데이터가 없으면 빈 리스트 반환

-fetchOne() : 단 건 조회 결과가 없으면 null, 결과가 둘 이상이면 NonUniqueResultException 발생

-fetchFirst() : limit(1).fetchOne() 과 동일

-fetchResults() : 페이징 정보 포함, total count 쿼리 추가 실행

-fetchCount() : count쿼리로 변경해서 count수 조회

 

참고) QueryDSL 5.0 버전 이상 부터 fetchResults와 fetchCount가 deprecated 되었다. count 쿼리가 모든 dialect에서 또는 다중 그룹 쿼리에서 완벽하게 지원되지 않기 때문에 deprecated 되었다라고 공식 문서에 적혀있다. 

 

그럼 count 쿼리는 어떻게 날리지? 

 

 fetch()로 뽑아내고 size()를 이용해 같은 결과를 얻어 낼 수 있다.

 

fetchResults는 count 쿼리를 날릴때 조인이나 검색조건문이 같이 포함되어 날아가기 때문에 성능이 중요한 애플리케이션에선 원래 사용하기 좀 그랬다. 때문에, 이 문제를 해결하기 위해 fetch()와 count쿼리를 두번 날리는 것이 더 나을 때도 있다.

 

 

3. 정렬

    /**
     * 회원 정렬 순서
     * 1. 회원 나이 내림차순 (desc)
     * 2. 회원 이름 올림차순 (asc)
     * 단 2에서 회원 이름이 없으면 마지막에 출력 (nulls last)
     */
    @Test
    public void sort() {
        memberRepository.save(new Member(null, 100));
        memberRepository.save(new Member("member5", 100));
        memberRepository.save(new Member("member6", 100));

        List<Member> result = queryFactory
                .selectFrom(member)
                .where(member.age.eq(100))
                .orderBy(member.age.desc(), member.username.asc().nullsLast())
                .fetch();

        Member member5 = result.get(0);
        Member member6 = result.get(1);
        Member memberNull = result.get(2);
        assertThat(member5.getUsername()).isEqualTo("member5");
        assertThat(member6.getUsername()).isEqualTo("member6");
        assertThat(memberNull.getUsername()).isNull();
    }

 

 

4. 페이징

    @Test
    public void paging() {
        List<Member> result = queryFactory
                .selectFrom(member)
                .orderBy(member.username.desc())
                .offset(1)
                .limit(2)
                .fetch();

        assertThat(result.size()).isEqualTo(2);
    }

-offset : 시작 인덱스

-limit : 몇개 가져올건지

 

후에 paging 쿼리는 pageable을 이용해 사용하는데 다음 포스팅에서 다룰 예정이다. 

 

 

5. 집합 함수

    @Test
    public void aggregation() {
        List<Tuple> result = queryFactory
                .select(
                        member.count(),
                        member.age.sum(),
                        member.age.avg(),
                        member.age.max(),
                        member.age.min()
                )
                .from(member)
                .fetch();
   }

JPQL에서 제공하는 집합 함수를 모두 제공한다.

 

groupBy

    @Test
    public void group() {
        List<Tuple> result = queryFactory
                .select(team.name, member.age.avg())
                .from(member)
                .join(member.team, team)
                .groupBy(team.name)
                .fetch();

        Tuple teamA = result.get(0);
        Tuple teamB = result.get(1);

        assertThat(teamA.get(team.name)).isEqualTo("teamA");
        assertThat(teamA.get(member.age.avg())).isEqualTo(15);

        assertThat(teamB.get(team.name)).isEqualTo("teamB");
        assertThat(teamB.get(member.age.avg())).isEqualTo(35);
    }

 

 

6. 조인

join(조인 대상, 별칭으로 사용할 Q타입)

inner join where절

    /**
     * 팀 A에 소속된 모든 회원을 찾아라
     */
    @Test
    public void join() {
        List<Member> result = queryFactory
                .selectFrom(member)
                .join(member.team, team)
                .where(team.name.eq("teamA"))
                .fetch();

        assertThat(result).extracting("username").containsExactly("member1", "member2");
    }

outer join on

    @Test
    public void join_on_filtering() {
        List<Tuple> result = queryFactory
                .select(member, team)
                .from(member)
                .leftJoin(member.team, team).on(team.name.eq("teamA"))
                .fetch();

        for (Tuple tuple : result) {
            System.out.println("tuple = " + tuple);
        }
    }

fetch join

    @Test
    public void fetchJoinNo() {
        em.flush();
        em.clear();

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

 

 

7. 서브쿼리

    /**
     * 나이가 가장 많은 회원 조회
     */
    @Test
    public void subQuery() {
        QMember memberSub = new QMember("memberSub");

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

        assertThat(result).extracting("age").containsExactly(40);
    }

JPAExpressions는 static import 가능

 

JPQL은 from절 서브쿼리가 불가능하다. 때문에 QueryDSL에서도 from절 서브쿼리는 불가능하다. 

 

from 절의 서브쿼리 해결 방안

1. 버스쿼리를 join으로 변경한다. (가능한 상황도 있고, 불가능한 상황도 있다.)

2. 애플리케이션에서 쿼리를 2번 분리해서 실행한다.

3. native 쿼리를 만든다.

 

 

8. case 문

    @Test
    public void basicCase() {
        queryFactory
                .select(
                        member.age
                                .when(10).then("열살")
                                .when(20).then("스무살")
                                .otherwise("기타")
                )
                .from(member)
                .fetch();
    }

sql문에서 case문은 되도록이면 사용하지 않아야 할 것이다. 위의 예제의 경우 10살 20살인 데이터를 가져와서 애플리케이션 레벨에서 바꾸는 것이 효과적이다. 

 

 

9. concat 문

    @Test
    public void concat() {
        List<String> result = queryFactory
                .select(member.username.concat("_").concat(member.age.stringValue()))
                .from(member)
                .where(member.username.eq("member1"))
                .fetch();

        for (String s : result) {
            System.out.println("s = " + s);
        }
    }

참고) member.age.stringValue() 이부분이 중요한데, 문자가 아닌 다른 타입들은 stringValue()로 문자로 변환할 수 있다. 나중에 이 방법으로 ENUM타입을 처리할 때 자주 사용한다. 

 

 

'JPA > QueryDSL' 카테고리의 다른 글

스프링 부트 5.0 Querydsl 설정 변경  (0) 2023.05.30
QueryDSL 중급문법  (0) 2022.02.07
Querydsl 실무활용  (0) 2021.10.01
Querydsl 중급문법  (0) 2021.10.01
Querydsl 기본문법  (0) 2021.10.01