728x90
반응형
SMALL

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

SELECT절에 어떤걸 가져올지를 정하는 것을 프로젝션이라고 한다. 프로젝션 대상이 하나라면 타입을 명확하게 지정할 수 있고 프로젝션 대상이 둘 이상이면 튜플이나 DTO로 조회할 수 있다.

 

프로젝션 대상이 하나인 경우

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

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

    List<Member> members = queryFactory
            .select(member)
            .from(member)
            .fetch();

    for (Member member1 : members) {
        System.out.println("member1 = " + member1);
    }
}
  • SELECT절에 퍼올리는 데이터가 딱 하나인 경우를 프로젝션 대상이 하나인 경우라고 말하고, 위의 경우들이 그 예시이다.
  • 회원의 유저명만 딱 가져오는 경우 또는 회원 엔티티 자체를 딱 가져오는 경우를 프로젝션 대상이 하나인 경우라고 표현한다.

실행 결과

result = member1
result = member2
result = member3
result = member4

member1 = Member(id=1, username=member1, age=10)
member1 = Member(id=2, username=member2, age=20)
member1 = Member(id=3, username=member3, age=30)
member1 = Member(id=4, username=member4, age=40)

 

프로젝션 대상이 둘 이상인 경우 - 튜플 조회

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

    for (Tuple tuple : results) {
        System.out.println(tuple.get(member.username));
        System.out.println(tuple.get(member.age));
    }
}
  • 위 코드와 같이 회원의 유저명과 나이 두 개를 가져오는 경우 동일한 타입이 아닌것부터 해서 하나의 타입으로 반환할 수가 없다. 이럴때 QuerydslTuple 타입으로 반환하게 된다.
  • 튜플 타입을 반환하고 데이터를 꺼낼때는 tuple.get(...)을 사용하면 된다.
  • 참고로, 그냥 이런게 있구나? 정도로 넘어가자. 왜냐하면 DTO로 반환하는 것을 잘 아는게 더 중요하고 잘 사용되기 때문이다.

실행 결과

member1
10
member2
20
member3
30
member4
40

 

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

거의 대부분의 경우, DTO로 반환하는 경우를 많이 사용한다. 그리고 이 DTO로 조회하는 것도 여러 방법이 있는데 하나씩 소개하고 어떤게 더 좋은지도 알아보자! 우선 DTO가 필요하다.

package cwchoiit.querydsl.dto;

import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class MemberDto {

    private String username;
    private int age;

    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}
  • 위와 같이 DTO를 만들었다. 이후에 Querydsl에서 DTO 조회를 할때 기본 생성자가 반드시 필요하기 때문에 기본 생성자를 대신 만들어주는 롬복의 @NoArgsConstructor도 사용했다.

 

순수 JPA에서 DTO 조회

먼저, 순수한 JPA를 사용한 DTO 조회 방법을 알아보자.

@Test
void findDtoByJPQL() {
    List<MemberDto> memberDtos = em.createQuery("SELECT new cwchoiit.querydsl.dto.MemberDto(m.username, m.age) FROM Member m", MemberDto.class)
            .getResultList();

    for (MemberDto memberDto : memberDtos) {
        System.out.println("memberDto = " + memberDto);
    }
}
  • 우선, 순수 JPA로 하려면, 위 코드처럼 new 키워드를 사용해서 패키지명부터 다 적어줘야 한다.
  • 조금 불편하고 지저분한 감은 지울수가 없다.

실행 결과

memberDto = MemberDto(username=member1, age=10)
memberDto = MemberDto(username=member2, age=20)
memberDto = MemberDto(username=member3, age=30)
memberDto = MemberDto(username=member4, age=40)

 

Querydsl에서 DTO 조회 - Bean 생성

여기가 중요하니까 글자도 큼지막하게 하고 볼드체도 빵빵하게 넣었다. 크게 3가지 방법이 있다.

  • 프로퍼티 접근 (Setter)
  • 필드 직접 접근
  • 생성자 사용

1. 프로퍼티 접근 - Setter

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

    for (MemberDto result : results) {
        System.out.println("result = " + result);
    }
  • DTO로 조회하는 방법 중 Setter를 사용하려면, 위와 같이 Projections.bean(...)을 사용하면 된다. 처음 파라미터는 어떤 클래스인지를, 그 다음부터는 원하는 각 필드를 채우면 된다.

실행 결과

result = MemberDto(username=member1, age=10)
result = MemberDto(username=member2, age=20)
result = MemberDto(username=member3, age=30)
result = MemberDto(username=member4, age=40)

 

 

2. 필드 직접 접근

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

    for (MemberDto result : results) {
        System.out.println("result = " + result);
    }
}
  • 위와 다른 것은 Projections.fields(...)를 사용했다는 점이다. 이 방법은 필드에 직접적으로 값을 넣어준다. DTO 클래스에 필드들은 다 private으로 선언했지만? 알다시피 리플렉션은 private도 접근 가능하게 해버릴 수 있기 때문에 상관없다.

실행 결과

result = MemberDto(username=member1, age=10)
result = MemberDto(username=member2, age=20)
result = MemberDto(username=member3, age=30)
result = MemberDto(username=member4, age=40)

 

별칭이 다른 경우

그런데, 가끔 별칭이 다른 DTO가 있을 수 있다. 무슨 말이냐면, 다음과 같은 DTO가 있다고 해보자.

package cwchoiit.querydsl.dto;

import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class UserDto {
    private String name;
    private int age;

    public UserDto(String name, int age) {
        this.name = name;
        this.age = age;
    }
}
  • 이 경우엔 유저명이 `username`이 아니라 `name`이다. 
  • 이런식으로 컬럼명과 필드명이 다른 경우엔 값을 제대로 인식을 못한다. 그래서 이렇게 별칭이 다른 DTO를 사용하려면 다음과 같이 사용해야 한다.
@Test
void findDtoByFieldsAlias() {
    List<UserDto> results = queryFactory
            .select(Projections.fields(UserDto.class, member.username.as("name"), member.age))
            .from(member)
            .fetch();

    for (UserDto result : results) {
        System.out.println("result = " + result);
    }
}
  • 별칭이 다른 필드에 .as(...)를 사용해서 해당 별칭에 맞게 변경해줘야 한다.
  • 또 다른 예시는 서브 쿼리를 사용할때도 있다. 아래 코드를 보자.
@Test
void findDtoByFieldsAlias() {
    QMember memberSub = new QMember("memberSub");

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

    for (UserDto result : results) {
        System.out.println("result = " + result);
    }
}
  • 그러니까, DTO로 조회를 하긴 하는데, 나이에 해당하는 값은 서브쿼리를 통해 어떤 고정값을 넣고 싶은 경우다. 여기서 서브쿼리는 회원의 가장 높은 나이를 넣는 것이다. 
  • 이런 경우에는, ExpressionUtils.as(서브쿼리, "별칭") 이렇게 사용할 수 있다.
  • 여기서 ExpressionUtilscom.querydsl.core.types.ExpressionUtils이다.

 

 

3. 생성자 사용

@Test
void findDtoByConstructor() {
    List<MemberDto> results = queryFactory
            .select(Projections.constructor(MemberDto.class, member.username, member.age))
            .from(member)
            .fetch();

    for (MemberDto result : results) {
        System.out.println("result = " + result);
    }
}
  • 이것도 다른 점은 Projections.constructor(...) 를 사용했다는 점이다. 내가 만들어둔 username, age를 받는 생성자를 그대로 사용한다고 보면 된다. 
  • 생성자의 파라미터 순서대로 필드들을 넣어야 한다. 
  • 생성자를 사용하는 경우에는 별칭이 달라도 상관없다. 어차피 생성자가 있냐 없냐가 중요하기 때문이다. 

실행 결과

result = MemberDto(username=member1, age=10)
result = MemberDto(username=member2, age=20)
result = MemberDto(username=member3, age=30)
result = MemberDto(username=member4, age=40)

 

 

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

이 방법은 이제 궁극의 방법인데, 장점이 매우 많지만 단점도 없지는 않다. 일단 한번 보자.

 

조회하려는 DTO의 생성자에 아래와 같이 해보자.

package cwchoiit.querydsl.dto;

import com.querydsl.core.annotations.QueryProjection;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class MemberDto {

    private String username;
    private int age;

    @QueryProjection
    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}
  • @QueryProjection 애노테이션을 사용해서, 원하는 생성자에 저 애노테이션을 붙여주면 Querydsl이 Q클래스를 만들때 이 DTO도 Q클래스를 만들어준다.

 

이러면 어떻게 사용할 수가 있냐? 다음 코드를 보자.

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

    for (MemberDto result : results) {
        System.out.println("result = " + result);
    }
}
  • 위 코드처럼 SELECT절에 QMemberDto를 그대로 넣어버릴 수 있다. 저 위에 있는 순수 JPA를 사용해야 할 땐 패키지명까지 다 작성해야 했는데 이건 그럴 필요가 없다. 
  • 또 다른 장점으로는 컴파일 단계에서 오류를 알려준다. 즉, 저 생성자에 들어갈 필드를 이미 컴파일러가 알고 있다는 말이다. 아래 스크린샷을 보자.

  • 이미 지금 저 QMemberDto는 인자는 2개가 필요하고 어떤 타입이 와야 하는지까지 다 알고 있다. 이게 정말 궁극의 장점이라고 볼 수 있다. 이전까지 알아봤던 DTO로 조회는 그렇지가 않다. 순수 JPA를 사용해서 DTO로 조회하는 것을 제외하고는 위에서 봤던 세터를 사용한 방법, 필드에 직접 접근, 생성자를 사용하는 방법 모두 컴파일러는 뭐가 들어올지 모른다. 그래서 이것이 정말 큰 장점이라고 할 수 있다.

 

그러나, 단점도 없지는 않다. 어떤 단점이 있냐? 

  • DTO까지 Q클래스를 만들어야 한다는 것 자체가 단점이다.
  • DTO는 순수한 자바 코드가 아니라 Querydsl을 의존한다.

두번째 말은 이런 것이다. 

package cwchoiit.querydsl.dto;

import com.querydsl.core.annotations.QueryProjection;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class MemberDto {

    private String username;
    private int age;

    @QueryProjection
    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}
  • 지금 이 MemberDto는 이제 Querydsl을 의존하고 있다. import 부분을 잘 보면 무슨 말인지 알 수 있다.
  • 이것 자체가 큰 단점이다. 구조적인 부분에서 말이다.
  • 만약, Querydsl을 사용하지 않게 기술을 바꾼다면, 이 부분이 영향을 받게 될 것이다.

 

선택하자

그래서 여기서부터는 선택이다. 실용적 관점에서 그냥 @QueryProjection을 사용하던가, 순수한 자바 코드를 최대한 유지하고 싶으면 @QueryProjection을 사용하지 않고, 위에서 봤던 Projections.bean(...) 이나 Projections.fields(...), Projections.constructor(...)를 사용하면 된다. 정답은 없다. 프로젝트마다, 구성원마다 차이가 있을 것이고 그에 맞게 유동적으로 맞춰가면 된다.

 

 

동적 쿼리 - BooleanBuilder 사용

동적 쿼리를 해결하는 두가지 방식이 있는데 그 중 하나인 BooleanBuilder를 사용해보자.

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

    List<Member> results = searchMember1(usernameParam, ageParam);
    assertThat(results).hasSize(1);
}

private List<Member> searchMember1(String usernameParam, Integer ageParam) {

    BooleanBuilder builder = new BooleanBuilder();

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

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

    return queryFactory
            .selectFrom(member)
            .where(builder)
            .fetch();
}
  • 위 코드가 BooleanBuilder를 사용하는 방법이다. 딱히 별 게 없다. 약간 JDBC 기술을 사용해서 동적 쿼리를 작성하는 것과 느낌이 비슷하긴 한데 훨씬 깔끔하다. 문자열이 없기 때문에.

 

동적 쿼리 - WHERE 다중 파라미터 사용

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

    List<Member> results = searchMember2(usernameParam, ageParam);
    assertThat(results).hasSize(1);
}

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

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

private BooleanExpression usernameEq(String usernameParam) {
    return usernameParam == null ? null : member.username.eq(usernameParam);
}
  • 이게 이제 WHERE 다중 파라미터 사용 방법이다. 훨씬 더 깔끔하다.
  • 왜냐하면, 쿼리 자체의 가독성이 높아지기 때문이다. ageEq, usernameEq를 보지말고, 딱 쿼리자체만 보면 굳이 저 메서드를 추적하지 않아도 WHERE절에 usernameEq(...), ageEq(...)만 보더라도 어떤 조건인지 예측이 가능해진다.
  • 저렇게 WHERE절에 다중 파라미터로 만들면 파라미터에 null이 들어오는 경우 그냥 무시를 해버리기 때문에 동적 쿼리를 매우 깔끔하고 간단하게 만들 수 있다. 예를 들어, ageEq(...)null을 반환하면 WHERE절안에 null은 무시가 된다.
  • 그리고 또 장점은 재사용이 가능하다는 점이다. 분명히 회원의 유저명이 같은 조건같은 건 여기저기 사용될 가능성이 높다. 이게 메서드로 만든 이상 여기저기 가져다가 사용이 가능해진다.
  • 또 한가지 장점은  조합이 가능하다는 점이다. 이것도 엄청 강력한 장점인데 아래 코드를 보자.
private BooleanExpression ageAndUsernameEq(Integer ageParam, String usernameParam) {
    return ageEq(ageParam).and(usernameEq(usernameParam));
}
  • 저 두 메서드(조건) ageEq(...), usernameEq(...)는 메서드이고 타입이 BooleanExpression이기 때문에 두 개를 AND 또는 OR로 조합해서도 사용이 가능하다. 기가맥히다.
  • 물론, 이렇게 조합하는 경우, 앞 부분의 null 체크는 조심해야 한다. 왜냐하면 ageEq(...)null인 경우엔 이렇게 될 것 아닌가?null.and(...) 이러면 NullPointerException이기 때문에 이 부분만 조심하면 된다. 뭐 아래와 같이 작성하면 되겠지.
private BooleanExpression ageAndUsernameEq(Integer ageParam, String usernameParam) {
    if (ageParam == null) {
        return usernameEq(usernameParam);
    }
    return ageEq(ageParam).and(usernameEq(usernameParam));
}

 

나는 이 WHERE절에 다중 파라미터 방식을 너무나 선호한다.

 

수정, 삭제 벌크 연산

쿼리 한번으로 대량 데이터에 쓰기 작업을 하는 것을 말한다.

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

    assertThat(count).isEqualTo(2);
}
  • 쿼리 자체는 새로운 게 전혀 없다.
  • 그렇지만, 벌크 연산은 가장 중요한 부분이 벌크 연산을 하고 나면 반드시 영속성 컨텍스트를 flush(), clear() 해주는 게 그냥 무조건 좋다. 왜 그러냐면, 벌크 연산은 영속성 컨텍스트를 무시하고 바로 데이터베이스에 쿼리를 날리기 때문에 영향받는 레코드가 이미 영속성 컨텍스트에서 관리되고 있다면 이 관리되는 데이터는 벌크 연산을 날린 데이터랑 불일치가 일어난다. 그래서 원치 않는 결과를 마주할 수도 있으니 항상 벌크 연산은 flush(), clear()를 해준다고 그냥 머리에 박아두자.
@Test
void bulkUpdate() {
    long count = queryFactory
            .update(member)
            .set(member.username, "비회원")
            .where(member.age.lt(28))
            .execute();

    assertThat(count).isEqualTo(2);

    em.flush();
    em.clear();
}
  • 이런식으로 말이다.

 

그리고, 벌크 연산으로 자주 사용되는 특정 컬럼에 모두 같은 값을 더하거나 곱하는 경우도 보자.

@Test
void bulkAdd() {
    long count = queryFactory
            .update(member)
            .set(member.age, member.age.add(1))
            .execute();

    assertThat(count).isEqualTo(4);

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

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

    for (Member findMember : members) {
        System.out.println("findMember = " + findMember);
    }
}

@Test
void bulkMultiply() {
    long count = queryFactory
            .update(member)
            .set(member.age, member.age.multiply(2))
            .execute();

    assertThat(count).isEqualTo(4);

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

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

    for (Member findMember : members) {
        System.out.println("findMember = " + findMember);
    }
}
  • 간단하게 나이에 1을 모두 더하거나, 나이에 2를 모두 곱하거나에 대한 쿼리이다.

 

쿼리 한번으로 대량 데이터 삭제

@Test
void bulkDelete() {
    long count = queryFactory
            .delete(member)
            .where(member.age.lt(28))
            .execute();

    assertThat(count).isEqualTo(2);

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

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

    for (Member findMember : members) {
        System.out.println("findMember = " + findMember);
    }
}
  • 이번엔 삭제를 벌크로 하는 방법에 대한 쿼리이다.

 

SQL Function 호출

@Test
void sqlFunction() {
    // member.username 에서 'member' 라는 단어를 'M' 으로 변경
    List<String> results = queryFactory
            .select(Expressions.stringTemplate(
                    "function('replace', {0}, {1}, {2})", member.username, "member", "M"))
            .from(member)
            .fetch();

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

실행 결과

2024-12-24T22:29:10.394+09:00 DEBUG 39987 --- [    Test worker] org.hibernate.SQL                        : 
select
    replace(m1_0.username, ?, ?) 
from
    member m1_0
    
result = M1
result = M2
result = M3
result = M4

 

 

이번에는 소문자로 변경하는 함수를 사용하는 쿼리를 보자.

@Test
void sqlFunction2() {
    /*List<String> results = queryFactory
            .select(member.username)
            .from(member)
            .where(member.username.eq(Expressions.stringTemplate("function('lower', {0})", member.username)))
            .fetch();*/

    List<String> results = queryFactory
            .select(member.username)
            .from(member)
            .where(member.username.eq(member.username.lower()))
            .fetch();

    for (String result : results) {
        System.out.println("result = " + result);
    }
}
  • 주석 처리한 부분을 먼저보자. 이번에는 WHERE절에 함수를 사용했다. 간단하게 소문자로 변경하는 함수인데, 왜 주석처리했냐? 저런 ANSI 표준 함수들은 Querydsl이 상당 부분 다 내장하고 있어서 저렇게 어렵게 작성할 필요없이 그 바로 아래에 주석 처리하지 않은 부분처럼 할 수 있다.

 

728x90
반응형
LIST

'Querydsl' 카테고리의 다른 글

참고: 리포지토리 지원 - QuerydslRepositorySupport  (0) 2024.12.30
JPA 레포지토리와 Querydsl  (2) 2024.12.26
Querydsl 기본 문법  (0) 2024.12.21
[Renewal] QueryDsl  (0) 2024.12.07
Spring Boot 3.1.5에서 QueryDSL 설치하기  (0) 2023.11.26

+ Recent posts