본문 바로가기
JPA

[QueryDSL] 중급 문법 (프로젝션, 동적 쿼리)

by 그리득 2024. 6. 20.
728x90

JPA를 쓰다보면 엔티티를 그대로 가져오는 대신, 데이터 전송 객체(DTO)에 담아서 가져오는 경우가 많다.

이런 방식은 필요한 데이터만 가져와, 불필요한 엔티티 참조를 줄일 수 있어 성능 최적화와 유지보수에 유리하다.

QueryDSL에서도 프로젝션을 사용하여 결과를 DTO, 튜플 형태로 반환받을 수 있는데, 이를 통해 다양한 방법으로 데이터를 처리할 수 있다.

이번 글에서는 QueryDSL을 활용한 프로젝션과 동적 쿼리에 대해 중점적으로 다뤄보겠다.

 

프로젝션

QueryDSL을 이용해 Entity 전체를 가져오는 것이 아니라 조회 대상을 지정해 원하는 값만 조회하는 것을 말한다.

 

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

 

먼저 MemberDto는 아래와 같다.

@NoArgsConstructor
@Data
public class MemberDto {
    private String username;
    private int age;

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

 

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

 

  • 프로젝션 대상이 하나면 타입을 명확하게 지정할 수 있다.
  • 프로젝션 대상이 둘 이상이면 튜플이나 DTO로 조회해야 한다.

 

@Test
void simpleProjection(){
    List<String> result = queryFactory.select(member.username).from(member).fetch();
    for (String username : result) System.out.println("username = " + username);

    List<Member> result2 = queryFactory.select(member).from(member).fetch();
    for (Member m : result2) System.out.println("m = " + m);
}

출력결과:
username = member5
username = member1
username = member2
username = member3
username = member4

m = Member(id=3, username=member5, age=55)
m = Member(id=4, username=member1, age=10)
m = Member(id=5, username=member2, age=20)
m = Member(id=6, username=member3, age=10)
m = Member(id=7, username=member4, age=20)

 

 

튜플 조회

프로젝션 대상이 둘 이상일 때 사용한다.

@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("-----------");
        System.out.println(username);
        System.out.println(age);
    }
}

출력결과:
-----------
member5
55
-----------
member1
10
-----------
member2
20
-----------
member3
10
-----------
member4
20
  • 위와 같은 Tuple은 QueryDSL이 제공한다. 따라서 리포지토리의 앞단인 컨트롤러, 서비스 계층에서는 사용을 지양하자.

 


 

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

순수 JPA에서 DTO 조회

@Test
void findDtoByJPQL() {
    List<MemberDto> resultList =
            em.createQuery("select new study.querydsl.dto.MemberDto(m.username, m.age) from Member m", MemberDto.class)
                    .getResultList();
    for (MemberDto memberDto : resultList) System.out.println("memberDto = " + memberDto);
}

출력결과:
memberDto = MemberDto(username=member5, age=55)
memberDto = MemberDto(username=member1, age=10)
memberDto = MemberDto(username=member2, age=20)
memberDto = MemberDto(username=member3, age=10)
memberDto = MemberDto(username=member4, age=20)
  • 순수 JPA에서는 DTO를 조회할 때 new 명령어를 사용하고, DTO의 패키지를 모두 적어야 해서 매우 지저분하다. 또한 생성자 방식만 제공한다.

 

QueryDSL 빈 생성(Bean population)

 

결과를 DTO로 반환할 때 사용한다. 다음 3가지 방법을 지원한다.

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

1. 프로퍼티 접근

@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);
}

출력결과:
memberDto = MemberDto(username=member5, age=55)
memberDto = MemberDto(username=member1, age=10)
memberDto = MemberDto(username=member2, age=20)
memberDto = MemberDto(username=member3, age=10)
memberDto = MemberDto(username=member4, age=20)
  • 위의 방법을 사용하기 위해 DTO 클래스에 기본 생성자가 있어야 한다.
  • select 절에 Projections.bean(반환 DTO 클래스, set하고 싶은 필드1, set하고 싶은 필드2, ...)을 넣어준다.

2. 필드 직접 접근

@Test
void findDtoByFields() {
    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);
}
//출력결과 위와 같음
  • select 절에 Projections.fields(반환 DTO 클래스, 필드1, 필드2, ...)을 넣어준다.

3. 생성자 접근

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

    for (MemberDto memberDto : result) System.out.println("memberDto = " + memberDto);
}
//출력결과 위와 같음
  • select 절에 Projections.constructor(반환 DTO 클래스, 필드1, 필드2, ...)을 넣어준다.
  • 이때 DTO에 존재하는 생성자의 순서와 constructor 안에 들어가는 필드들의 순서가 같아야 한다. 또한 필드명이 일치해야 한다(프로퍼티, 필드, 생성자 접근 방법 모두 해당).

필드명이 일치하지 않는 경우 as를 사용하여 필드명을 맞춰주자.

필드명이 일치하지 않는 예를 보기 위해 UserDto를 정의하였다.

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

    public UserDto(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

 

 

그리고 아래의 select 절의 member.username.as("name")으로 해준다.

 

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

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

 

또한 서브쿼리를 사용하여 별칭을 부여할 때는 ExpressionUtils를 사용한다.

@Test
void findUserDto3() {

    QMember memberSub = new QMember("memberSub");
    List<UserDto> result = queryFactory
            .select(Projections
                    .constructor(
                            UserDto.class, member.username.as("name"),
                            ExpressionUtils.as(//서브쿼리의 결과를 사용
                                    JPAExpressions.select(memberSub.age.max())
                                                    .from(memberSub), "age"
                            )
                    )
            )
            .from(member)
            .fetch();

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

 


 

 

DTO 클래스에 @QueryProjection 어노테이션 추가

DTO 클래스에 @QueryProjection 어노테이션 추가

  • @QueryProjection 어노테이션을 DTO 클래스에 추가합니다.
  • 예시:
@QueryProjection
public class MemberDto {
    private String username;
    private int age;

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

    // getters and setters
}

 

  1. Gradle 설정
    • Gradle에서 QueryDSL을 컴파일하여 Q타입을 생성합니다.
    • gradle -> Tasks -> other -> compileQuerydsl을 더블클릭하여 DTO 클래스에 대한 Q타입을 생성합니다.
  2. Q타입 생성 확인
    • 생성된 Q타입 클래스는 다음과 같습니다 :
/**
 * study.querydsl.dto.QMemberDto is a Querydsl Projection type for MemberDto
 */
@Generated("com.querydsl.codegen.DefaultProjectionSerializer")
public class QMemberDto extends ConstructorExpression<MemberDto> {

    private static final long serialVersionUID = 1356709634L;

    public QMemberDto(com.querydsl.core.types.Expression<String> username, com.querydsl.core.types.Expression<Integer> age) {
        super(MemberDto.class, new Class<?>[]{String.class, int.class}, username, age);
    }

}

 

테스트 코드 작성

  • QueryDSL을 사용하여 DTO 프로젝션을 테스트합니다.
  • 예시:
@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);
}

출력결과:
memberDto = MemberDto(username=member5, age=55)
memberDto = MemberDto(username=member1, age=10)
memberDto = MemberDto(username=member2, age=20)
memberDto = MemberDto(username=member3, age=10)
memberDto = MemberDto(username=member4, age=20)

컴파일 에러 확인

  • Q타입의 생성자를 사용하기 때문에, 없는 필드를 추가하거나 해당 생성자가 없는 경우 컴파일 에러가 발생합니다.
  • 예시:
@Test
void findDtoByQueryProjection() {
    List<MemberDto> result = queryFactory
            .select(new QMemberDto(member.username, member.age, member,id))//컴파일 에러 발생
            .from(member)
            .fetch();
    for (MemberDto memberDto : result) System.out.println("memberDto = " + memberDto);
}

 

  1. 단점
    • @QueryProjection 어노테이션을 DTO 클래스에 추가하면 해당 DTO 클래스가 QueryDSL 라이브러리에 의존성을 가지게 됩니다.
  2. distinct 사용
    • QueryDSL을 사용하여 distinct를 적용할 수 있습니다.
    • 예시:
@Test
void distinct() {
    List<String> result = queryFactory
            .select(member.username).distinct()
            .from(member)
            .fetch();
}

 

 

@QueryProjection 어노테이션 방식을 사용하면 QueryDSL에 대한 의존성이 높아지긴 하지만, 어노테이션 하나로 적용하기 때문에, 기술을 변경하더라도 해당 어노테이션만 수정하면 되는 장점이 있고,

컴파일 시점에 에러를 잡아줘서 이점이 더 많다고 판단하여 @QueryProjection 어노테이션 방식을 채택.

 

•참고 문헌 : 자바 ORM 표준 JPA 프로그래밍 / 김영한