[QueryDSL] 2. QueryDSL 중급 문법

HJ·2024년 3월 12일
0

QueryDSL

목록 보기
2/4
post-thumbnail

김영한 님의 실전! Querydsl 강의를 보고 작성한 내용입니다.


1. 프로젝션 결과 반환 DTO

프로젝션 대상이 하나라면 타입을 명확히 지정할 수 있습니다.

대상이 둘 이상인 경우 튜플이나 DTO 를 통해 반환 받게 됩니다.

1-1. 순수 JPA 에서 DTO 반환

List<MemberDto> result = em.createQuery(
                "select new study.querydsl.dto.MemberDto(m.username, m.age) " +
                "from Member m", MemberDto.class)
            .getResultList();

순수 JPA 에서 DTO 를 조회할 때는 new 명령어를 사용해서 생성자를 통해 반환합니다. 또한 DTO 의 패키지 경로까지 모두 작성해야 합니다.


1-2. QueryDSL 빈 ( Bean population )

QueryDSL 에서 DTO 를 쉽게 조회할 수 있도록 빈 생성 방식을 제공합니다. 이때 3가지 방법으로 DTO 에 값을 담아 반환할 수 있습니다.

  1. 프로퍼티 접근( setter )

  2. 필드 직접 접근

  3. 생성자 사용


1-2-1. 프로퍼티 접근( setter )

List<MemberDto> result = queryFactory
                .select(Projections.bean(MemberDto.class,
                        member.username,
                        member.age))
                .from(member)
                .fetch();

Projections.bean() 을 사용해서 DTO 를 바로 조회할 수 있습니다. 첫 번째 파라미터로 어떤 DTO 인지를 명시하고, 그 뒤에 필요한 필드들을 넣어주면 됩니다. 이때 DTO 에는 기본 생성자가 필요합니다.


1-2-2. 필드 직접 접근

List<MemberDto> result = queryFactory
                .select(Projections.fields(MemberDto.class,
                        member.username,
                        member.age))
                .from(member)
                .fetch();

Projections.fields 를 통해 필드에 값을 바로 넣어버리기 때문에 DTO 에 getter, setter 는 필요하지 않습니다. 형태는 프로퍼티 접근과 동일합니다.


1-2-3. 생성자 접근

List<MemberDto> result = queryFactory
                .select(Projections.constructor(MemberDto.class,
                        member.username,
                        member.age))
                .from(member)
                .fetch();

Projections.constructor 를 통해 생성자 접근 방식으로 DTO 를 생성할 수 있습니다. 이때 전달하는 username 과 age 는 DTO 에 선언된 username, age 와 타입이 일치해야 합니다.


1-3. 필드명이 다른 경우

Projection 에서 사용한 username, age 이 필드명 그대로 MemberDto 에 존재하기 때문에 사용할 수 있었습니다. 즉, 엔티티와 DTO 의 필드명이 동일하게 매칭되었기 때문에 가능했습니다.

하지만 만약 username 이 아닌 name 이라는 필드를 가진 DTO 가 있으면 어떻게 될까요?

[ UserDto ]

public class UserDto {
    private String name;
    private int age;
}

[ 테스트 코드 ]

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

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

[ 출력 결과 ]

userDto = UserDto(name=null, age=10)
userDto = UserDto(name=null, age=20)
userDto = UserDto(name=null, age=30)
userDto = UserDto(name=null, age=40)

테스트 실행 결과를 보면 username 과 name 이 매칭이 되지 않기 때문에 name 에 null 값이 들어간 것을 볼 수 있습니다.

생성자의 경우 DTO 에 생성자가 존재하면 문제없이 동작하지만, 프로퍼티나 필드 접근 생성 방식에서 이름이 다를 때는 문제가 발생하며, 이를 해결할 수 있는 방법은 2가지가 존재합니다.


[ 해결 방법 1. 별칭 사용 ]

@Test
void findUserDto() {
    List<UserDto> result = queryFactory
            .select(Projections.fields(UserDto.class,
                    member.username.as("name"),
                    member.age))
            .from(member)
            .fetch();
}

엔티티 필드에 .as("dto 필드명") 를 붙이면 정상적으로 username 이 name 필드에 들어가게 됩니다.


[ 해결 방법 2. ExpressionUtils 사용 ]

@Test
void findUserDto() {
    QMember memberSub = new QMember("memberSub");

    List<UserDto> result = queryFactory
            .select(Projections.fields(UserDto.class,
                    member.username.as("name"),
                    ExpressionUtils.as(
                        JPAExpressions
                            .select(memberSub.age.max())
                            .from(memberSub), "age")
                        )
                    )
            .from(member)
            .fetch();
}

ExpressionUtils.as(source, alias) 형태로 필드나 서브 쿼리에 별칭을 지정하는 형태로 이름이 다른 문제를 해결할 수 있습니다.

필드의 경우는 별칭을 사용하는 방식으로 해결하고, 서브 쿼리의 경우 ExpressionUtils 를 사용해서 해결합니다.




1-4. @QueryProection

[ DTO ]

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

응답으로 사용할 DTO 의 생성자에 @QueryProection 을 붙이면 Q 클래스가 생성되고, 그 내부에 생성자를 가지게 됩니다.


[ 테스트 코드 ]

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

그 후 select 절에서 new 를 통해 Q 클래스를 생성하면 기존 DTO 가 생성돼서 반환됩니다.

@QueryProjection 을 사용하면 컴파일 시점에 오류를 잡을 수 있는 반면, Projections.constructor 를 사용하면 런타임에 오류가 발생하게 됩니다.

하지만 DTO 에 QueryDSL 어노테이션을 유지해야 하는 점과 DTO 까지 Q 클래스를 생성해야 하는 단점이 있습니다.




2. 동적 쿼리

2-1. BooleanBuilder

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
            .selectFrom(member)
            .where(builder)
            .fetch();
}

BooleanBuilder 를 생성해서 동적 쿼리를 작성할 수 있습니다. builder 에 조건을 추가할 수 있는데 null 인지 판단하는 로직을 추가하면 동적 쿼리를 작성할 수 있습니다. 그 후 where 절 안에 builder 를 넣으면 자동으로 조건이 생성됩니다.

만약 usernameCond 가 필수라면 new BooleanBuilder(member.username.eq(usernameCond)) 로 작성해서 초기 조건을 지정할 수 있습니다.

또 where 절에 builder.and() 와 같이 계속해서 조건을 작성할 수 있습니다.


2-2. where 절 다중 파라미터

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 ? null : member.username.eq(usernameCond);
}

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

where 에 조건을 여러 개 나열하면 and 조건으로 들어가고, null 이 들어가면 무시됩니다.
이 방식을 사용하면 메서드를 다른 쿼리에서도 재활용 할 수 있다는 장점이 있습니다.

또 아래처럼 두 조건을 조합해서 사용할 수 있지만, null 체크에 주의해야 합니다.

private BooleanExpression allEq(String usernameCond, Integer ageCond) {
    return usernameEq(usernameCond).and(ageEq(ageCond));
}




3. 수정, 삭제 벌크 연산

[ 수정 ]

long count = queryFactory
        .update(member)
        .set(member.username, "성인")
        .where(member.age.lt(19))
        .execute();

execute() 를 사용하며, 반환값은 영향을 받은 로우의 수가 됩니다.


[ 삭제 ]

long count = queryFactory
        .delete(member)
        .where(member.age.gt(18))
        .execute();

영속성 컨텍스트에 있는 엔티티를 무시하고 실행되기 때문에 벌크연산을 실행하고 나면 영속성 컨텍스트를 초기화 하는 것이 좋습니다.




4. SQL Function 호출

String result = queryFactory
        .select(Expressions.stringTemplate(
                    "function('replace', {0}, {1}, {2})", 
                        member.username, "member", "M"))
        .from(member)
        .fetchFirst();

member 를 M 으로 변경하는 replace 함수를 호출하는 코드입니다. SQL function 은 JPA 와 같이 Dialect 에 등록된 내용만 호출할 수 있습니다.

profile
공부한 내용을 정리해서 기록하고 다시 보기 위한 공간

0개의 댓글