개발놀이터

QueryDSL 중급문법 본문

JPA/QueryDSL

QueryDSL 중급문법

마늘냄새폴폴 2022. 2. 7. 19:43

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

 

프로젝션과 결과 반환 - 기본

프로젝션이란 뭘까? 

 

그냥 간단하게 select 대상으로 지정된 것을 말한다. 

 

크게 봤을 때 프로젝션이 한개일 때와 여러개일 때로 나눠볼 수 있다. 

 

프로젝션 대상이 하나일 때

    @Test
    public void simpleProjection() {
        List<String> result = queryFactory
                .select(member.username)
                .from(member)
                .fetch();

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

select문에 member.username하나만 있는 상황이다. 이럴 땐 제네릭이 String처럼 타입을 특정할 수 있게 된다.

 

프로젝션 대상이 둘 이상일 때

    @Test
    public void tupleProjection() {
        List<Tuple> result = queryFactory
                .select(member.username, member.age)
                .from(member)
                .fetch();

        for (Tuple tuple : result) {
            String username = tuple.get(member.username);
            Integer age = tuple.get(member.age);
            System.out.println("username = " + username);
            System.out.println("age = " + age);
        }
    }

select문에 두개가 들어가있는 상황이다. 이때는 Tuple이라는 제네릭 타입으로 바뀐다. QueryDSL에서 이렇게 프로젝션 대상이 둘 이상일때 타입을 특정지을 수 없는 상황에서 제공하는 제네릭이다. 

 

Tuple을 사용하는 방법은 get메서드를 사용해서 내가 가지고 오고 싶은 데이터를 직접 가져와야 한다. 

 

주의) Tuple은 Repository레벨에서만 사용하고 Service, Controller레벨에선 사용하지 말아야 한다. 또한, 뒤에 설명할 DTO로 조회하는 방법을 이용하는 것이 더 나은 방법이다. 

 

 

프로젝션과 결과 반환 - DTO로 조회

대부분 조회를 할 때 내가 필요한 데이터만 쏙쏙 뽑아서 가져오고 싶을 것이다. 그렇게 가져오는 것이 무겁지도 않고 깔끔하기 때문이다. 필자도 DTO로 조회하는 것을 선호한다. 

 

먼저 QueryDSL에서의 DTO로 조회를 알아보기 전에 JPQL에선 DTO로 조회할 때 어떻게 사용하는지 먼저 알아보고 넘어가자

    @Test
    public void findDtoByJPQL() {
        List<MemberDto> result = em.createQuery("select new review.hello.dto.MemberDto(m.username, m.age) from Member m ", MemberDto.class)
                .getResultList();

        for (MemberDto memberDto : result) {
            System.out.println("memberDto = " + memberDto);
        }
    }

JPQL에선 DTO로 조회하기 위해서 new 연산자를 사용해야 했다. 그리고 뒤에 패키지명을 쭉 적어준 뒤 생성자로 만드는 것 처럼 값을 넣어주면 완성이다. 

 

여기에 몇가지 단점이 있다.

 

1. 너무 지저분하다.

패키지가 조금만 많아져도 하나하나 적는게 일이 될 것이다. 

 

2. 생성자 방식만 지원한다.

생성자가 반드시 존재해야하며 뒤에 설명하겠지만 프로퍼티로 주입받거나 필드로 직접 주입받는 등의 유연한 대처가 불가능해진다.

 

3. 패키지가 바뀌면 코드를 전부 수정해야한다.

이건 꽤 큰 문제가 될 수 있다. 패키지가 바뀐다고 해서 코드를 전부 수정해야 하는 상황은 OCP를 위반할 뿐더러 유지보수에도 최악이다. 

 

 

QueryDSL에선 이 문제를 어떻게 해결했을까? 

 

QueryDSL에선 프로퍼티로 접근하는 방법과 필드로 접근하는 방법, 생성자로 접근하는 방법 이렇게 세가지를 지원한다. 하나씩 살펴보자

 

프로퍼티 접근

    @Test
    public void findDtoBySetter() {
        List<MemberDto> result = queryFactory
                .select(Projections.bean(MemberDto.class,
                                member.username,
                                member.age)
                )
                .from(member)
                .fetch();

        for (MemberDto memberDto : result) {
            System.out.println("memberDto = " + memberDto);
        }
    }

select절에 Projections라는 키워드로 (com.querydsl.core.types를 사용해야 한다. hibernate건 사용하면 안됨) bean을 사용하면 된다. 맨 처음엔 타입을 적어주고 뒤에 알맞는 Q타입을 적어주면 된다. 

 

주의) Setter가 반드시 있어야 하며 자바 프로퍼티 규약에 따라 setXxx의 소문자 버전인 xxx와 내가 조회하고싶은 Q타입의 이름이 같아야 매칭이 된다. 또한, 기본 생성자가 있어야 한다. 기본 생성자가 없으면 set으로 접근할 수 없기 때문이다. 

 

필드 접근

    @Test
    public void findDtoByField() {
        List<MemberDto> result = queryFactory
                .select(Projections.fields(MemberDto.class,
                        member.username,
                        member.age)
                )
                .from(member)
                .fetch();

        for (MemberDto memberDto : result) {
            System.out.println("memberDto = " + memberDto);
        }
    }

똑같이 Projections를 사용하고 fields를 사용하면 된다. 프로퍼티 접근과 마찬가지로 맨 처음엔 타입을 적어주고 뒤에 알맞는 Q타입을 적어주면 된다.

 

주의) 프로퍼티 접근과 마찬가지로 내가 조회하고싶은 필드와 DTO의 필드가 일치해야 한다. 

 

생성자 접근

    @Test
    public void findUserDtoByField() {
        List<MemberDto> result = queryFactory
                .select(Projections.constructor(MemberDto.class,
                        member.username.as("name"),
                        member.age)
                )
                .from(member)
                .fetch();

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

마지막 생성자 접근이다. 생성자 접근을 사용하려면 Projections에 constructor를 사용하면 된다. 

 

 

프로젝션과 결과 반환 - @QueryProjection

@Data
public class MemberDto {

    private String username;
    private int age;

    @QueryProjection
    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

생성자에 @QueryProjection을 달아준 뒤에 gradle에 compile.java를 누르게 되면 Q타입으로 DTO가 만들어진다. 

 

    @Test
    public void findDtoByQueryProjection() {
        List<MemberDto> result = queryFactory
                .select(new QMemberDto(member.username, member.age))
                .from(member)
                .fetch();

        for (MemberDto memberDto : result) {
            System.out.println("memberDto = " + memberDto);
        }
    }

이제 사용해주면 되는데 select 절에 생성자를 만들듯이 new로 Q타입 DTO를 넣어주면 된다. 

 

이 방법은 컴파일러롤 타입을 체크할 수 있으므로 가장 안전한 방법이다. 위의 세개의 방법은 실수로 필드를 하나 추가하거나 하면 런타임 오류가 발생하게 된다. 하지만 @QueryProjection은 컴파일 단계에서 이를 막아준다. 때문에, 가장 안전한 방법이다. 

 

하지만 DTO에 QueryDSL 어노테이션을 유지해야하는 점이 단점이다. DTO는 Repository, Service, Controller까지 다양하게 움직이는데 움직일 때 마다 QueryDSL 어노테이션을 달고 움직여야 한다는 것이다. 

 

아키텍처의 부분에서 DTO는 깔끔하게 가져가고 싶다면 QueryProjection은 사용하지 말아야 할 것이다. 하지만 애플리케이션이 QueryDSL에 상당한 부분 종속적이고 의존적이라면 안전을 위해 사용하는 것도 나쁘지 않을 것이다. 

 

때문에 상황에 따라 알맞은 판단을 해야 할 것이다. 

 

 

 

동적쿼리

QueryDSL로 동적쿼리를 만드는 것에는 두가지 방법이 있다. 

 

1. BooleanBuilder사용

2. Where 다중 파라미터 사용 (BooleanExpression 사용)

 

하나씩 알아보자

 

BooleanBuilder

    @Test
    public void dynamicQuery_BooleanBuilder() {
        String usernameParam = "member1";
        Integer ageParam = 10;

        List<Member> result = searchMember1(usernameParam, ageParam);

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

    private List<Member> searchMember1(String usernameCond, Integer ageCond) {
        BooleanBuilder builder = new BooleanBuilder();

        if (usernameCond != null) {
            builder.and(member.username.eq(usernameCond));
        }

        if (ageCond != null) {
            builder.and(member.age.eq(ageCond));
        }

        return queryFactory
                .select(member)
                .from(member)
                .where(builder)
                .fetch();
    }

 

먼저 메서드를 뽑는다. 그리고 BooleanBuilder를 선언하고 빌더를 작성하면 된다. 

 

BooleanExpression

    @Test
    public void dynamicQuery_WhereParam() {
        String usernameParam = "member1";
        Integer ageParam = 10;

        List<Member> result = searchMember2(usernameParam, ageParam);

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

    private List<Member> searchMember2(String usernameCond, Integer ageCond) {
        return queryFactory
                .selectFrom(member)
                .where(usernameEq(usernameCond), ageEq(ageCond))
                .fetch();
    }

    private BooleanExpression usernameEq(String usernameCond) {
        return usernameCond != null ? member.username.eq(usernameCond) : null;
    }

    private BooleanExpression ageEq(Integer ageCond) {
        return ageCond != null ? member.age.eq(ageCond) : null;
    }

마찬가지로 메서드를 뽑아내고 BooleanExpression으로 메서드를 한번 더 뽑는다. 그리고 해당 메서드를 where절에 넣어준다. 이렇게 하는 이유는 QueryDSL의 where절에는 null이 들어오게 되면 무시해버리기 때문이다.

 

BooleanBuilder vs BooleanExpression 무엇을 사용해야할까?

 

개인적으로 BooleanExpression이 깔끔하고 좋은 것 같다. 하지만 무엇을 선택하든 상관없다. BooleanExpression은 가독성이 좋아지고 코드를 재사용할 수 있다는 장점이 있다.

 

 

벌크성 쿼리

보통 update문에 사용되고 한번에 많은 데이터의 값을 변경해야 할 때 사용한다. JPA의 더티체킹을 사용할 수도 있지만 더티체킹은 영속성컨텍스트에 있는 데이터와 디비의 값을 일일이 하나하나 비교하기 때문에 쿼리가 많이 나가는 단점이 있다. 

 

벌크성 쿼리를 사용하면 한방쿼리로 업데이트를 할 수 있다. 

    @Test
    public void bulkUpdate() {
        long count = queryFactory
                .update(member)
                .set(member.username, "비회원")
                .where(member.age.lt(28))
                .execute();
    }

사용방법은 평범한 update문 뒤에 execute를 달아주면 된다. 

 

주의) 벌크성 쿼리는 영속성 컨텍스트와 관련없는 쿼리이다. 즉, 디비에는 적용되지만 영속성 컨텍스트에는 업데이트가 안된다는 소리다. 

 

예를 들어서 위의 예제처럼 28살보다 나이가 어린 사람의 이름을 비회원으로 바꿔버리는 작업을 한다고 가정해보자

 

저 벌크성 쿼리를 날리는 순간 디비에는 비회원이라고 이름이 바뀐다. 하지만 영속성 컨텍스트에는 그대로 회원1이라고 남아있다는 얘기이다. 

 

영속성 컨텍스트는 디비에서 값을 가져올 때 참조값을 확인하여 이미 영속성 컨텍스트에 값을 가지고 있다면 디비에서 가져온 값을 무시해버리는 특징이 있다. 

 

때문에, 디비에 무작정 벌크성 쿼리로 저장한다고 해서 영속성컨텍스트에 있는 값들이 바뀐다는 것은 아니다. 벌크성 쿼리를 날리고 다시 영속성컨텍스트에서 값을 가져와 작업한다면 큰 버그로 이어질 것이다.

 

그럼어떻게 해야하나?

 

EntityManager를 주입받아 em.flush(); em.clear(); 이 두개를 실행해주면 된다. 벌크성 쿼리를 날린 후 영속성컨텍스트를 깔끔하게 비운뒤 다시 디비에서 값을 가져오게 해서 영속성컨텍스트를 갱신하는 것이다. 

 

'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