BackEnd/JPA

[JPA] QueryDSL 중급문법 - 1

샤아이인 2022. 5. 14.

내가 공부한 것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼 겸 상세히 기록하고 얕은 부분들은 가볍게 포스팅하겠습니다.

 

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

프로젝션이란 열 단위로 조회하는 것을 의미한다. (select는 행 단위 조회)

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

 

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

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

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

프로젝션 대상이 하나면 명확하게 타입을 지정해줄 수 있다. List<String>

 

1-2) 프로젝션 대상이 둘 이상인 경우

@Test
public void tuple_projection_test() {
    List<Tuple> tuples = queryFactory
            .select(member.username, member.age) // 프로젝션 대상이 둘 이상
            .from(member)
            .fetch();

    for (Tuple tuple : tuples) {
        String username = tuple.get(member.username);
        Integer age = tuple.get(member.age);
        System.out.println("username = " + username + ", age = " + age);
    }
}

Tuple은 Repository 계층 안에서 사용하는 것은 좋지만, 그 밖의 계층에서는 DTO로 변환시켜 사용하는 것 이 좋다.

Tuple을 열어보면 package가 com.querydsl.core에 있다. 즉, Tuple도 querydsl 에 종속적인 기술이다.

 

repository 안에서만 사용하고, 외부로 노출될때는 DTO로 변환해서 나가기를 권장한다.

하부 구현에 queryDsl을 사용한다는 것을 controller, service 계층에서 알게되면 향후 repository의 구현체를 변경할때 문제가 생긴다.

 

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

이번 시간에는 Member의 모든 필드값이 아닌, name 과 age 두건만 조회해고 싶은 상황이다.

사용할 MemberDto는 다음과 같다.

@Data
@NoArgsConstructor
public class MemberDto {

    private String username;
    private int age;

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

 

2-1) JPQL로 DTO 조회

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

실행 결과는 다음과 같다.

실행되는 쿼리는 다음과 같다.

select
    member0_.username as col_0_0_,
    member0_.age as col_1_0_ 
from
    member member0_

하지만 이 방식은 사용하기 너무 번잡하다. 일단 페키지 명을 전부 다 적어줘야하는 점부터가 고난길이다...

new 명령어도 사용해야하고, 생성자 방식만 지원한다.

 

2-2) QueryDSL 에서의 DTO 반환하기

QueryDsl 에서는 총 3가지 방식의 DTO반환 방식을 지원한다.

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

 

▶ 프로퍼티 접근 - setter

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

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

이 방식은 setter가 DTO 상에 있어야 사용 가능한 방식이다.

롬복을 통해 @Setter를 지정해 주거나, @Data 를 사용해야 한다.

 

▶ 필드 직접 접근

필드 방식은 getter, setter가 DTO에 없어도 적용할 수 있는 방식이다. 

필드가 private 여도 알아서 다 처리된다.

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

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

Projections.fields(주입대상class, 필드1, 필드2, ...) 처럼 사용된다.

 

▶  프로퍼티, 필드 직접 접근 - 별칭이 다른 경우

다음과 같이 UsetDto를 따로 만들어 사용해보자.

@Data
@NoArgsConstructor
public class UserDto {

    private String name;
    private int age;

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

따라서 실행해볼 test 코드는 다음과 같다. 반환 타입이 UserDto로 바뀌게 되었다.

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

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

실행시 정상적으로 실행된다. 다만 username에 대한 데이터를 가져오지 못하게 된다.

우리가 fields()에 인자로 넘기는 이름은 member.username인데, DTO 필드 이름은 name이다.

이럴때는 member.username.as("name") 으로 사용하자.

.select(Projections.fields(UserDto .class, member.username.as("name"), member.age))

이렇게 별칭을 맞추어 주면 정상 작동한다. 이는 필드에 별칭을 주는 방식이다.

 

서브 쿼리에 별칭을 적용하는 방법을 알아보자.

@Test
public void findByUserDtoField() {
    QMember memberSub = new QMember("memberSub");

    List<UserDto> memberDtoList = 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 userDto : memberDtoList) {
        System.out.println("userDto = " + userDto);
    }
}

형식이 fields(UserDto.class, 이름관련, 나이관련) 와 같은 형식이다.

여기서 "이름관련" 부분은 'name'이라는 별칭을 지정해 주었다.

"나이관련" 부분에는 JPAExpression을 통해 subQuery를 지정해 주었는데, subQuery에는 별칭이 없다.

따라서 ExpressionUtils.as()로 한번 wrapping하여 "age" 라는 별칭을 추가해준 것 이다.

 

"이름관련" 부분에도 as("name")이 아니라, 다음과 같이 사용해도 된다.

ExpressionUtils.as(member.username, "name")

 

▶ 생성자 사용

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

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

생성자 같은 경우 이름이 아니라 type을 보고 만들어지기 때문에 직전의 field 방식의 문제가 생기지 않는다.

 

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

이번 방법은 궁극의 방법이자, 단점도 많은 방식이다.

 

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

이후 gradle에 가서 complieQuerydsl 을 실행해준다.

위와같이 QMemberDto가 생성된것을 확인할 수 있다.

 

이후 그냥 생성자를 통해 객체를 생성하듯이 넘겨주면 된다.

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

그럼 위 방식의 생성자를 사용하는 방식과, 이전시간에 배웠던 Projections.constructor는 뭐가 다를까?

 

Projections.constructor 방식은 다음과 같이 생성자에 추가적으로 값을 더 넘기게 되었을때 (id를 추가로 전달)

List<MemberDto> memberDtoList = queryFactory
        .select(Projections.constructor(MemberDto.class, member.id, member.username, member.age))
        .from(member)
        .fetch();

컴파일 오류가 아니라, 실행이 된 후 runtime 오류가 발생한다.

유저가 실제로 해당 기능을 사용하는 순간에 오류가 발생하는 것 이다.

 

똑같은 문제를 @QueryProjection 방식에서는 컴파일 오류로 알려준다.

하지만 이 방식에도 단점은 존제한다.

  1. Q 클래스 파일을 만들어야 한다는 점
  2. 아키텍처 문제(의존관계 문제), DTO 가 QueryDSL에 대한 의존성이 생겨버린다.

실용적인 관점에서 이정도는 사용해도 된다 생각되면 사용해도 된다.

'BackEnd > JPA' 카테고리의 다른 글

[JPA] 실무 활용 - 순수 JPA와 Querydsl  (0) 2022.05.15
[JPA] QueryDSL 중급문법 - 2  (0) 2022.05.14
[JPA] QueryDSL 기본문법 - 4  (0) 2022.05.14
[JPA] QueryDSL 기본문법 - 3  (0) 2022.05.14
[JPA] QueryDSL 기본문법 - 2  (0) 2022.05.13

댓글