gony-dev 님의 블로그

[Querydsl] 중급 문법 본문

Querydsl

[Querydsl] 중급 문법

minarinamu 2024. 12. 3. 15:33

지난 시간에는 querydsl을 사용하기 위한 기본 문법들을 알아보았다.
이번 섹션에는 배운 문법들을 토대로 결과를 효율적으로 반환하는 방법들을 알아보자!

프로젝션과 결과 반환

1. Basic

프로젝션은 일전의 JPA에서도 나오는 용어로 조회를 할 때 "조회하고 싶은 대상을 지정"하는 기능을 의미한다.

매번 entity 전체를 반환하는 것이 아니라 필요한 값만 가지고 오고 싶을 때 사용한다.

프로젝션은 대상에 따라 조회하는 방식이 다르다.

  • 프로젝션 대상이 하나면, 타입을 명확히 지정할 수 있다.
  • 프로젝션 대상이 둘 이상이면, "Tuple"이나 "Dto"로 조회를 할 수 있다.

❗️주의할 점❗️

Tuple로 조회를 할 때, Repository 계층 내부에서 사용하는 것은 문제가 없지만, 그 이상의 레벨, 즉 controller나 service 단으로 전달하게 되면 구현 기술이 노출되기 때문에 추상화 원칙을 위배하게 되므로 극히 주의해야 한다!

@DisplayName("프로젝션 대상이 하나인 값을 출력한다.")
@Test
void simpleProjection(){
    List<String> result = queryFactory
            .select(member.username)
            .from(member)
            .fetch();
    for (String s : result) {
        System.out.println("s = " + s);
    }
}

@DisplayName("프로젝션 대상이 둘 이상인 값을 Tuple로 조회한다.")
@Test
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);
    }
}

 

2. DTO 조회

querydsl에서는 프로젝션 대상이 둘 이상이면 Tuple이나 Dto로 조회하는 것을 지향한다.

Tuple로 조회해보는 것은 실습해 보았으니 Dto로 조회하는 방법을 알아보겠다.

 

우선, 순수 JPA로 DTO를 조회하는 방법은 다음과 같다.

@DisplayName("순수 JPA를 사용하여 생성자를 통해 DTO를 조회한다.")
@Test
void findDtoByJPQL(){
 //given
    // MemberDto를 불러와서 프로젝션을 주입한다.
    List<MemberDto> result = em.createQuery("select new study.querydsl.dto.MemberDto(m.username, m.age)" +
                    " from Member m", MemberDto.class)
            .getResultList();

    for (MemberDto memberDto : result) {
        System.out.println("memberDto = " + memberDto);
    }
}
  • 순수 JPA에서 DTO를 조회할 때 new 명령어를 사용하여 특정 경로에 있는 클래스에 값들을 넣어주는 것을 보여주고 있다.
  • 코드에서도 볼 수 있다시피 패키지 이름을 다 적어주어야 하기 때문에 지저분하다는 단점이 있으며
    오로지 생성자 방식만 지원한다는 한계가 있다.

Querydsl에서는 3가지 방법으로 DTO를 조회할 수 있다.

  1. 프로퍼티 접근(Setter)
  2. 필드 직접 접근
  3. 생성자 사용

각 방법을 이용한 코드를 살펴보자.

@DisplayName("프로퍼티를 사용하여 DTO 조회한다.")
@Test
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);
    }
}

@DisplayName("필드에 직접 접근하여 DTO를 조회한다.")
@Test
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);
    }
}

@DisplayName("생성자를 DTO를 조회한다.")
@Test
void findDtoByConstructor(){
    List<UserDto> result = queryFactory
            .select(Projections.constructor(UserDto.class,
                    member.username, member.age))
            .from(member)
            .fetch();

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

 

  • 각 방식의 특징들을 살펴보자.
  1. 프로퍼티 접근 방식은 "Projections.bean()"을 사용하여 DTO를 조회하는데 이때 Getter/Setter를 이용하기에 사용을 지양하고 있다.
    • Setter를 이용할 경우에는 가변 객체가 되어 데이터의 무결성을 보장하기가 어렵고, 런타임 에러의 가능성이 있기 때문이다.
    • 따라서 "Projections.fields()"나 "Projections.constructor()"을 사용을 지향한다!
  2. 필드 직접 접근 방식은 프로퍼티 접근 방식과 유사하지만, 그저 필드에 접근하여 데이터를 넣는 방식이다.
    • 이 때, DTO에 있는 필드 이름과 쿼리에 작성된 필드의 이름이 같아야 하며, 
      다를 경우에는 'alias'를 이용하여 이를 동일시해주면 된다!
  3. 생성자 사용 방식은 생성자를 통해 데이터를 주입받으며 불변 객체 설계에 적합하며, 데이터 완결성이 보장된다.
    • 하지만 생성자와 순서를 일치시켜야 하며, 값이 많아지면 실수를 하게 될 수 있어 권장되지는 않는 방식이다.

2-1. DTO 조회 - 생성자 + @QueryProjection

"@QueryProjection"을 사용하는 방법은 간단하다!
그저 생성자 위에 "@QueryProjection"을 달아주면 된다.

@Data
@NoArgsConstructor
public class MemberDto {

    private String username;
    private int age;

    @QueryProjection // DTO도 Q파일로 생성!
    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}
@DisplayName("QMemberDto를 생성하여 결과를 반환한다.")
@Test
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);
    }
}
  • 이때, 불변 객체 선언을 하여 컴파일러로 타입을 체크할 수 있어 권장되지만,
    Q파일을 생성하게 되며, querydsl에 강한 의존성을 가지게 된다.
  • 즉, DTO가 순수하지 않은 형태로 존재하게 되는 것이며, 실용적인 관점에서 사용하고 싶다면 그대로 사용하고,
    그렇지 않다면, 생성자필드 접근 방식을 사용하는 것이 좋다!

동적 쿼리 해결

쿼리는 크게 '동적 쿼리'와 '정적 쿼리'로 나뉜다.

정적 쿼리사용자의 입력에 상관없이 항상 같은 값만을 반환하는 쿼리를 의미하고,

동적 쿼리들어오는 조건에 따라 상황에 맞추어 값을 반환하는 쿼리를 의미한다.

우리는 동적 쿼리를 해결하는 방법에 대해 알아보려고 한다.

 

1. BooleanBuilder

  • 동적 쿼리를 해결하는 첫 번째 방안으로 BooleanBuilder를 통해 입력된 값에 대한 조건을 생성하여 추가해서 최종적인 파라미터들을 queryFactory에 반영한다.
  • 아래의 코드를 보면 'searchMember1' 메소드에서 2개의 파라미터에 대한 검사를 진행하고 돌아와 Member를 조회하는 것을 볼 수 있다.
  • 만일 값에 null이 들어간다면 null이 들어가지 않은 값에 대해서만 결과를 조회한다.
@DisplayName("BooleanBuilder를 사용하여 동적쿼리를 해결한다.")
@Test
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
            .selectFrom(member)
            .where(builder)
            .fetch();
}

 

2. Where 다중 파라미터

  • 이 방법은 BooleanExpression을 사용하여 whrer 안에 다중 파라미터를 적용할 수 있다!
@DisplayName("WhereParam을 사용하여 동적쿼리를 해결한다.")
@Test
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))
            .where(allEq(usernameCond, ageCond))
            .fetch();
}

private BooleanExpression usernameEq(String usernameCond) {
//        if(usernameCond != null){
//            return member.username.eq(usernameCond);
//        } else {
//            return null;
//        }
        return usernameCond == null ? null : member.username.eq(usernameCond); // 연산자로 단축!
    }

private BooleanExpression ageEq(Integer ageCond) {
//    if(ageCond != null){
//        return member.age.eq(ageCond);
//    } else
//        return null;
//
    return ageCond == null ? null : member.age.gt(ageCond); // 마찬가지로 연산자로 단축!
}

private Predicate allEq(String usernameCond, Integer ageCond){
    return usernameEq(usernameCond).and(ageEq(ageCond));
}
  • BooleanExpression을 사용하여 where 안에 파라미터를 넣었을 때의 이점은 다음과 같다.
    1. where 조건에 Null 값은 무시가 되며, 메서드를 다른 쿼리에서도 재사용할 수 있다.
    2. 쿼리 자체의 가독성이 높아진다.
    3. 조합이 가능하여 여러 조건들을 여러 쿼리에서 같이 사용하면 "isServicable"이라는 서비스 가능 상태를 확인할 수 있다.

수정, 삭제 배치 쿼리

쿼리 단 한 번으로 대량 데이터를 수정하는 방법이 있다!

바로 알아보자.

@DisplayName("bulk를 사용해 대용량 데이터를 수정할 수 있다.")
@Test // test는 끝나면 트랜잭션이 걸려도 자동 롤백을 실시한다. 그러므로 @Commit을 달아주어야 한다.
void bulkUpdate(){

    // member1 = 10 -> 비회원
    // member2 = 20 -> 비회원
    // member3 = 30 -> 유지
    // member4 = 40 -> 유지

    long count = queryFactory
            .update(member)
            .set(member.username, "비회원")
            .where(member.age.lt(28))
            .execute();// 조회가 아니므로 execute!!

    em.flush();
    em.clear();

    List<Member> result = queryFactory
            .selectFrom(member)
            .fetch();

    for (Member member1 : result) {
        System.out.println("member1 = " + member1);
    }
}
  • 위의 "bulkUpdate" 테스트를 실행해 보면 쿼리문을 날려준 후에 DB에 곧바로 반영이 되어 있음을 확인할 수 있지만,
    영속성 컨텍스트에는 반영이 안됨을 알 수 있다.
  • 그 이유는 영속성 컨텍스트를 거치지 않고 DB에 바로 반영해주었기 때문인데, 우리는 이것을 "repeatable Read"라고 부른다.
  • 이는 서로의 상태가 부합하여 조회 시에 큰 문제가 될 수 있기에 해결 방안이 필요하다. 그래서 우리는 "em.flush()"와 "em.clear()"를 이용하여 컨텍스트에 반강제 반영을 실시하고 초기화하여 정상적인 작동을 가능케 한다.

SQL function 호출하기

기본적으로 SQL function은 JPA와 같이 Dialect에 등록된 내용만 호출할 수 있다.

이를 querydsl에서 정상적으로 호출하는 방법을 알아보자.

@DisplayName("querydsl에서 함수를 호출하는 방법을 알아보자.")
@Test
void sqlFunction(){
    List<String> result = queryFactory
            .select(Expressions.stringTemplate("function('replace', {0}, {1}, {2})",
                    member.username, "member", "M"))
            .from(member)
            .fetch();

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

@DisplayName("where문에 function을 적용한다.")
@Test
void sqlFunction2(){
    List<String> result = queryFactory
            .select(member.username)
            .from(member)
//            .where(member.username.eq(
//                    Expressions.stringTemplate("function('lower', {0})",
//                    member.username)))
            .where(member.username.eq(member.username.lower()))
            .fetch();

    for (String s : result) {
        System.out.println("s = " + s);
    }
}
  • 위의 코드처럼 "Expressions"를 사용하여 원하는 쿼리에 대한 함수를 적용할 수 있다!
  • 또는 기본 문법을 통해서 where절에 원하는 조건식을 설정할 수 있다.