프로젝션(Projection), DTO로 조회하는 방법

작성일

프로젝션

프로젝션이란 select 대상을 지정하는 것을 의미한다.

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

단일 프로젝션 조회

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

프로젝션 대상이 여러개 - 튜플 조회

@Test
void tupleProjection() {
    List<Tuple> result = queryFactory
            .select(member.username, member.age)
            .from(member)
            .fetch();
    for (Tuple tuple : result) {
        String name = tuple.get(member.username);
    }
}

프로젝션 타입이 2개 이상인 경우에 Tuple이 반환된다.

주의
튜플은 Querydsl의 Tuple 객체이다. 따라서, JPA에 종속적이기 때문에 Repository 부분에서만 사용하고 그 이후는 DTO로 받는게 좋다.

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

Querydsl에서 DTO로 프로젝션을 조회하는 방법은 4가지가 있다.

  • setter를 통해 값을 주입
  • 필드에 직접 주입
  • 생성자를 통한 주입
  • @QueryProjection를 통한 주입

순수 JPA에서 DTO 조회

순수 JPA에서 DTO를 조회할 때는 new 명령어를 사용해야한다. DTO의 package 이름을 다 적어줘야해서 지저분하며 생성자 방식만 지원한다.

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

프로퍼티 접근 - setter

MemberDTO의 기본 생성자로 객체를 생성 후, 동일한 필드명을 가진 파라미터에 한해 setter로 값을 셋팅한다.

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

필드 직접 접근

setter나 생성자를 사용하지 않고 바로 필드에 값을 주입한다. 역시 필드명이 동일한 파라미터에 한해 동작한다.

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

생성자 사용

MemberDTO의 생성자를 이용해 객체를 셋팅한다. 만약, 파라미터에 맞는 생성자가 없는 경우, 런타임 에러가 발생한다.

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

필드명과 파라미터명이 다른 경우

@Data
public class UserDto {

    private String name; // username
    private int age; // age
}

fields 사용 - as()

@Test
void findUserDto() {
    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): 필드나, 서브 쿼리에 별칭 적용
  • username.as(“memberName”): 필드에 별칭 적용

construct 사용

생성자를 사용할때는 UserDto에 @NoArgsConstructor, @AllArgsConstructor를 추가해야 한다.

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

@QueryProjection

위의 3가지 방법보다 @QueryProjection를 사용하는게 깔끔한 방법이다. 생성자에 @QueryProjection 어노테이션을 추가한다.

@Data
@NoArgsConstructor
public class MemberDto {

    private String username;
    private int age;

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

어노테이션 추가 후 ./gradlew compileQuerydsl 실행한다. Q클래스파일(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);
    }
}

앞서 사용했던 생성자 방법은 컴파일에서 체크하지 못하는 단점이 있다. 반면에, 이 방법은 컴파일러로 타입을 체크할 수 있으므로 가장 안전한 방법이다. 다만, DTO에 QueryDSL 어노테이션을 유지해야 하는 점과 DTO까지 Q 파일을 생성해야 하는 단점이 있다. 즉, DTO가 Querydsl에 독립적이지 못하다는 단점이 있다.