RE-Heat 개발자 일지

[Querydsl] [4] 중급 문법 (상편) - 프로젝션 반환·@QueryProjection 본문

백엔드/Querydsl

[Querydsl] [4] 중급 문법 (상편) - 프로젝션 반환·@QueryProjection

RE-Heat 2023. 9. 29. 23:47
 

실전! Querydsl - 인프런 | 강의

Querydsl의 기초부터 실무 활용까지, 한번에 해결해보세요!, 복잡한 쿼리, 동적 쿼리는 이제 안녕! Querydsl로 자바 백엔드 기술을 단단하게. 🚩 본 강의는 로드맵 과정입니다. 본 강의는 자바 백엔

www.inflearn.com

인프런 김영한 님의 강의를 듣고 작성한 글입니다.

 

[1] 프로젝션과 결과 반환 - 기본

■ 프로젝션이란?

엔티티 전체를 가져오는 게 아니라 조회 대상을 지정해 원하는 값만 조회하는 것을 말한다.

 

① 프로젝션 대상의 하나

@Test
public void simpleProjections() throws Exception {
    List<String> result = queryFactory.select(member.username)
            .from(member)
            .fetch();

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

 

② 프로젝션 대상이 둘 이상이면 튜플이나 DTO로 조회

▶ 튜플 조회

@Test
public void tupleProjection() throws Exception {
    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);
    }
}
  • Querydsl이 값이 여러 개면 기본적으로 튜플을 사용함.
  • select절 대상이 두 개이며 username과 age의 타입이 다르므로 튜플로 받음
  • 튜플은 리포지토리 단계에서만 쓰고 바깥으로 내보낼 땐 DTO로 바꿔 반환하는 게 낫다. 다른 사람에게 어떤 방식을 쓰는지 알리는 건 보안상 좋지 않기 때문이다.

 

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

MemberDto

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

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

 

순수 JPA에서 DTO로 반환하는 코드

@Test
public void findDtoByJPQL() throws Exception {
    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 명령어를 써야 함.
  • DTO package 이름을 다 적어줘야 해서 번거로움
  • 생성자 방식만 지원함

 

■ Querydsl 빈 생성(Bean poplulation)

① 프로퍼티 접근 - Setter

@Test
public void findDtoBySetter() throws Exception {
    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);
    }
}
  • Projections.bean은 setter를 통해 데이터를 인젝션 해준다.
  • 꼭 기본 생성자를 만들어야 한다. 그렇지 않으면 아래와 같은 오류 화면이 나온다.
    • (기본 생성자를 만들거나 롬복의 @NoArgsConstructor를 쓰면 편하다)
    • @NoArgsConstructor에 protected 달아도 오류가 뜬다.

기본 생성자가 없을 때 뜨는 오류 화면
@NoArgsConstructor의 access를 protected로 지정했을 때 뜨는 오류 화면

 

② 필드 직접 접근

@Test
public void findDtoByField() throws Exception {
    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);
    }
}
  • Projections.fields는 getter·setter를 쓰는 대신 바로 필드로 직접 접근한다.

 

③ 생성자 사용

@Test
public void findDtoByConstructor() throws Exception {
    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);
    }
}

별칭 및 순서가 딱딱 들어 맞아야 쓸 수 있다.

 

④ 별칭이 다를 때 

UserDto

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

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

 

□ 필드 사용

1. 별칭이 달라 null값이 나오는 케이스

@Test
public void findUserDtoV1() throws Exception {
    List<UserDto> fetch = queryFactory
            .select(Projections.fields(UserDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();
}

UserDto의 name이 아닌 username을 썼으므로 null값이 들어간다.

 

2. 별칭을 제대로 맞추는 방법 - 해결책

@Test
public void findUserDto() throws Exception {
    List<UserDto> result = queryFactory
            .select(Projections.fields(UserDto.class,
                    member.username.as("name"),
                    ))
            .from(member)
            .fetch();
    for (UserDto userDto : result) {
        System.out.println("userDto = " + userDto);
    }
}
  • 방법 1 : .as("name")으로 별칭을 맞춰주면 된다.
  • 방법 2 : ExpressionsUtils.as(source, alias) : 필드, 서브 쿼리에 별칭 사용
    • 대부분 as를 쓴다. 단, 서브 쿼리는 ExpressionUtils.as를 사용해야 한다.

 

3. 서브쿼리까지 포함한 방법

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

    List<UserDto> result = queryFactory
            .select(Projections.fields(UserDto.class,
                    ExpressionUtils.as(member.username, "name"),

                    ExpressionUtils.as(
                            JPAExpressions
                                    .select(memberSub.age.max())
                                    .from(memberSub), "age")
            ))
            .from(member)
            .fetch();
    for (UserDto userDto : result) {
        System.out.println("userDto = " + userDto);
    }
}
  • 서브쿼리를 쓸 때 기존 alias와 다르게 쓰는 것처럼 memberSub를 따로 만들어줘야 함.
  • JPAExpressiosn를 쓰고 기존처럼 만들면 된다.
  • 서브쿼리 관련 구체적인 내용은 [Querydsl] [3] 기본 문법 (하편) [11] 서브쿼리 확인

 

□ 생성자 사용

@Test
public void findUserDtoByConstructor() throws Exception {
    List<UserDto> result = queryFactory
            .select(Projections.constructor(UserDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();
    for (UserDto userDto : result) {
        System.out.println("memberDto = " + userDto);
    }
}
  • 생성자는 필드 이름이 아닌 type으로 판단하므로 이름은 상관없다.
  • 세터나 필드는 이름을 매칭하는 게 중요해서 as로 별칭을 붙여야만 한다.

 

[3] 프로젝션과 결과 반환 - @QueryProjection

어떻게 보면 궁극의 방법이다. 그러나 dto가 Querydsl에 의존해야 하는 단점이 있다.

■ 세팅 방법

MemberDto + @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;
    }
}
  • 프로젝션 결과를 반환할 DTO의 생성자에 @QueryProjection을 붙여 준 뒤 gradle → tasks → other → compileQuerydsl을 써 Q타입 DTO를 생성한다.

 

테스트 코드

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

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

 

기존 생성자 방식과 @QueryProjection의 차이점

① 생성자 방식은 런타임 시점에 오류가 잡히는 반면 @QueryProjection 방식은 컴파일 시점에 오류가 뜬다.

런타임 시점에야 오류가 뜬 생성자 방식(좌)과 컴파일 시점에 오류를 확인할 수 있는 @QueryProjection 방식(우)

 

@QueryProjection의 단점

1. Q파일을 생성해야 한다.

2. Dto는 Querydsl을 쓰는 걸 전혀 몰랐으나 @QueryProjection을 쓰면서 Querydsl에 의존하게 됐다.

  • 따라서 만일 Querydsl을 쓰지 않기로 하면 오류가 뜬다.
  • dto를 깔끔하게 가지고 가고 싶으면 이 방식을 쓰기 애매하다.

 

결론 : Querydsl을 많이 쓰면 편의상 사용하는 것으로 합의한다.

 

■ Distinct - 중복 제거 SQL

List<String> result = queryFactory
    .select(member.username).distinct() 
    .from(member)
    .fetch();

select 옆에 .distinct()를 붙여주면 된다.