본문 바로가기
JPA

Querydsl 중급 문법

by Heesu.lee 2021. 4. 28.

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

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

@DisplayName("프로젝션 대상이 하나인 경우")
@Test
void simpleProjection() {
    // given

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

    // then
    assertThat(result).isNotNull();

    result.forEach(name -> System.out.println("name = " + name));
}

@DisplayName("Tuple 사용 - 프로젝션 대상이 두 개 이상인 경우")
@Test
void tupleProjection() {
    // given

    // when
    List<Tuple> result = queryFactory
            .select(member.username, member.age)
            .from(member)
            .fetch();

    // then
    assertThat(result).isNotNull();

    result.forEach(tuple -> {
        System.out.println("username = " + tuple.get(member.username));
        System.out.println("age = " + tuple.get(member.age));
    });
}

프로젝션 대상이 하나인 경우 타입 지정이 명확하기 때문에 대상 타입으로 반환한다.

반면, 프로젝션 대상이 둘 이상인 경우 타입이 여러개일 수 있기 때문에 Tuple 로 반환 해준다.

 

Tuple 의 경우 presentation 이나 service 까지 노출하여 사용하는 것은 추천되지 않는다.

이러한 이유로는 com.querydsl.core.Tuple 를 사용하기 때문에 repository 에 대한 디펜던시가 생길 수 있다.

이는 추후 변경에 취약할 수 있는 코드를 만들 수 있다. (서비스에 필요한 DTO 생성하여 사용 권장)

 

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

Querydsl 에선 결과를 DTO 로 조회하기 위하여 다음 3가지 방법을 지원한다.

  • 프로퍼티 접근 (Setter)
  • 필드 직접 접근
  • 생성자 사용
@Data
@NoArgsConstructor
public class MemberDto {

    private String username;
    private int age;

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

@Data
public class UserDto {

    private String name;
    private int age;
}

 

Setter 를 이용한 DTO 반환

@DisplayName("Setter 를 사용한 프로퍼티 접근 DTO 생성")
@Test
void findDtoBySetter() {
    // given

    // when
    List<MemberDto> result = queryFactory
            .select(Projections.bean(MemberDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();

    // then
    assertThat(result).isNotNull();
    result.forEach(member -> System.out.println("member = " + member));
}

해당 기능을 사용하기 위해선 조회하고자 하는 DTO 에 기본 생성자가 있어야 한다.

참고로 기본 생성자가 private 이나 protected 인 경우 정상적으로 작동하지 않는다.

 

필드 직접 접근하여 DTO 반환

@DisplayName("필드 직접 접근하여 DTO 생성")
@Test
void findDtoByField() {
    // given

    // when
    List<MemberDto> result = queryFactory
            .select(Projections.fields(MemberDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();

    // then
    assertThat(result).isNotNull();
    result.forEach(member -> System.out.println("member = " + member));
}

필드 접근 방식은 Getter, Setter 를 전혀 사용하지 않고,

자바 리플렉션 등을 이용하여 DTO 필드에 직접 접근하여 주입하여 생성하는 방식이다.

이를 위하여 빈 생성자를 필요로 하며 접근 제어자는 public 으로 해야 한다.

(참고로 필드 이름이 동일하여야 한다.)

 

생성자를 사용한 DTO 반환

@DisplayName("생성자를 이용한 DTO 조회")
@Test
void findDtoByConstructor() {
    // given

    // when
    List<MemberDto> result = queryFactory
            .select(Projections.constructor(MemberDto.class,
                    member.username,
                    member.age)) // 원하는 생성자 파라미터 위치가 맞아야 한다. (타입)
            .from(member)
            .fetch();

    // then
    assertThat(result).isNotNull();
    result.forEach(member -> System.out.println("member = " + member));
}

생성자를 이용한 DTO 의 경우, 자신이 정의한 생성자를 통하여 결과를 DTO 로 반환 받는 방식이다.

이를 사용하기 위해, 관련 생성자가 정의가 되어있어야 하며 생성자에 넣는 파라미터 타입이 동일하여야 한다.

 

필드 이름이 다른 경우

생성자 방식의 경우 필드의 타입이 동일하면 정상 동작하지만,

Setter 방식이나, 필드 주입 방식의 경우 동일한 이름이 아닌 경우 쿼리 요청은 되지만 해당 값은 null 로 반환된다.

이를 해결하기 위하여 별칭 따로 두어 사용할 수 있다.

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

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

추가적으로 서브 쿼리에도 별칭을 적용하여 조회할 수 있다. (ExpressionUtils.as 사용)

 

프로젝션 결과 반환 - @QueryProjection

MemberDto

@Data
@NoArgsConstructor
public class MemberDto {

    private String name;
    private int age;

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

DTO 프로젝션에 사용될 생성자에 @QueryProejction 어노테이션을 붙인다.

이를 통해 Querydsl 에 의존된 (사용할 수 있는) 생성자를 만들어 프로젝션에 사용할 수 있게 된다.

즉 해당 어노테이션을 붙인 뒤 compileQuerydsl 실행 시 QMemberDto 클래스가 생성된다.

 

@DisplayName("@QueryProjection 을 활용한 DTO 조회")
@Test
void findDtoByQueryProjection() {
    // given

    // when
    List<MemberDto> result = queryFactory
            .select(new QMemberDto(member.username, member.age))
            .from(member)
            .fetch();

    // then
    assertThat(result).isNotNull();
    result.forEach(member -> System.out.println("member = " + member));
}

@DisplayName("생성자를 이용한 DTO 조회")
@Test
void findDtoByConstructor() {
    // given

    // when
    List<MemberDto> result = queryFactory
            .select(Projections.constructor(MemberDto.class,
                    member.username,
                    member.age)) // 원하는 생성자 파라미터 위치가 맞아야 한다. (타입)
            .from(member)
            .fetch();

    // then
    assertThat(result).isNotNull();
    result.forEach(member -> System.out.println("member = " + member));
}

 

생성자 이용 방식과 다른 점은 Querydsl 에 종속된 Q 클래스 생성자를 활요하여 생성한다.

이는 기존 생성자 방식과 다르게 정해진 타입만을 받기 때문에 컴파일 단계에서 오류를 찾을 수 있다.

생성자 방식은 주어진 생성자에 필요없는 파라미터를 던져도 컴파일 단계에서 확인이 불가하다. (런타임 에러)

 

컴파일 시점에 타입 체크도 하고 너무 편리한 기능을 제공하지만 단점 역시 존재한다.

우선 불필요한 Q클래스를 생성하여야 한다. 오로지 생성만을 위한 클래스 생성이 다소 아쉬운 부분이다.

그리고 앞서 설명드린 점처럼 DTO 가 Querydsl 에 종속되어버린다. (의존관계에 대한 문제점 발생)

 

DTO 는 레포지토리 계층 뿐만 아니라, 서비스, 컨트롤러 등 여러 계층 속에서 사용된다.

하지만 다른 계층에서 Querydsl 에 대한 의존성이 없다면 이는 사용할 수 없게 된다.

 

이러한 트레이드오프를 항상 고려하고 사용해야 한다.

 

동적 쿼리

BooleanBuilder 사용

@DisplayName("BooleanBuilder 를 활용한 동적 쿼리")
@Test
void useBooleanBuilder() {
    // given
    String username = "member1";
    Integer age = null;

    // when
    List<Member> result = searchMember1(username, age);

    // then
    assertThat(result).isNotNull();
    assertThat(result.size()).isEqualTo(1);
}

private List<Member> searchMember1(String username, Integer age) {
    BooleanBuilder builder = new BooleanBuilder();

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

    return queryFactory
            .selectFrom(member)
            .where(builder)
            .fetch();
}

BooleanBuilder 를 통한 동적 쿼리 생성은 빌더를 생성 후 필요한 조건을 null 확인 여부에 따라 and, or 등으로 더해주면 된다.

null 경우엔 해당 조건이 포함이 되지 않기 때문에 실제 조건절에 반영되지 않는다. (age 가 null 이기에 조건에 미포함 - 아래 참고)

 

실행된 쿼리

BooleanBuilder 생성 시 초기 조건 넣어 줄 수 있다. - new BooleanBuilder(member.username.eq(username));

단 앞단에서 해당 조건에 필요한 파라미터가 null 이 아님이 보장이 되어야 한다.

 

Where 다중 파라미터 사용

@DisplayName("Where 다중 파라미터 활용한 동적 쿼리")
@Test
void useWhereMultipleParams() {
    // given
    String username = "member1";
    Integer age = 10;

    // when
    List<Member> result = searchMember2(username, age);

    // then
    assertThat(result).isNotNull();
    assertThat(result.size()).isEqualTo(1);
}

private List<Member> searchMember2(String username, Integer age) {
    return queryFactory
            .selectFrom(member)
            .where(allEq(username, age))
            .fetch();
}

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

private BooleanExpression usernameEq(String username) {
    if (username == null) {
        return null;
    }
    return member.username.eq(username);
}

private BooleanExpression allEq(String username, Integer age) {
    return usernameEq(username).and(ageEq(age));
}

Where 에 다중 파라미터를 활용한 동적 쿼리 작성 시

필요한 조건 (BooleanExpression 혹은 Predicate) 메서드를 따로 뽑아서 where() 안에 파라미터로 사용하면 된다.

조건 메서드가 null 을 반환하는 경우 해당 조건은 where 절에 미포함되어 쿼리가 요청된다.

 

해당 방식의 가장 큰 장점은 코드의 재사용성이다. 

자바 코드로 쿼리를 작성함으로써 해당 코드의 재사용성을 높일 수 있다.

이 뿐만 아니라, 의미있는 메서드들의 조합과 네이밍을 통해서 코드의 가독성을 훨씬 높일 수 있다.

조건 메서드들을 조합하기 위해선 BooleanExpression 을 반환 타입으로 가져야 한다.

 

수정, 삭제 벌크 연산

// 대량 데이터 수정
long updated = queryFactory
        .update(member)
        .set(member.username, "비회원")
        .where(member.age.lt(28))
        .execute();

// 기존 나이에 1 더하기
long added = queryFactory
        .update(member)
        .set(member.age, member.age.add(1))
        .execute();

// 기존 나이에 1.5 곱하기
long multiplied = queryFactory
        .update(member)
        .set(member.age, member.age.multiply(1.5))
        .execute();

// 대량 데이터 삭제
long deleted = queryFactory
        .delete(member)
        .where(member.age.gt(18))
        .execute();

벌크 연산은 위 예시와 같이 사용할 수 있지만, 사용 시 주의해야할 사항이 있다.

JPQL 배치와 마찬가지로, 영속성 컨텍스트에 있는 엔티티를 무시하고 실행되기 때문에 배치 쿼리를 실행하고 난 후에 영속성 컨텍스트를 초기화 하는 것이 안전하다. 

 

다시말해, 벌크 연산 이후 영속성 컨텍스트의 1차 캐시와 실제 DB 의 값이 다른 현상이 발생한다.

동일한 트랜잭션 단위 안에 있을 시 해당 데이터 접근 시 1차 캐시 내용을 가져오기 때문에 잘못된 데이터를 사용하게 된다.

 

벌크 이후 동일 트랜잭션 내에서 데이터를 사용하고자 한다면 EntitiyManager flush, clear 하여 사용하는 것이 안전하다.

 

영속성 컨텍스트 - heesutory.tistory.com/19?category=918707

 

영속성 컨텍스트

JPA 가장 중요한 2가지 객체와 관계형 데이터베이스 매핑하기 (Object Relational Mapping) 영속성 컨텍스트 - 실제 JPA 가 어떻게 동작하는지 Entity Manager 와 EntityManagerFactory 고객의 요청마다 EntityMa..

heesutory.tistory.com

참고

  • 해당 게시글은 김영한님의 "실전! Querydsl" 강의를 참고하여 작성하였습니다.
  • 자세한 내용은 강의를 통해 확인하시길 적극 권장드립니다.

 

'JPA' 카테고리의 다른 글

Querydsl 실무 활용편 (2)  (0) 2021.05.03
Querydsl 실무 활용편 (1)  (0) 2021.05.02
Querydsl 기본 문법  (0) 2021.04.26
Querydsl 사용 개요  (0) 2021.04.25
쿼리 메소드 기능  (0) 2021.01.26

댓글