본문 바로가기

QueryDSL

[Query DSL] 중급 문법

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

프로젝션 : select 대상 지정

 

프로젝션 대상이 하나

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

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

- 프로젝션 대상이 하나면 타입을 명화하게 지정할 수 있음

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

 

튜플 조회

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

    @Test
    public void tupleProjection() {
        QMember member = QMember.member;
        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 Tuple의 역할

Querydsl Tuple은 Querydsl을 사용하여 데이터베이스 쿼리를 실행할 때 여러 컬럼을 선택하여 결과를 받을 수 있는 구조입니다. Tuple은 데이터베이스 쿼리 결과를 일시적으로 보관하는 역할을 합니다. 예를 들어, 여러 컬럼을 선택하여 조회한 결과를 담는 데 유용합니다.

레포지토리 계층에서 Tuple 사용

레포지토리 계층은 데이터베이스와 직접 상호작용하는 계층입니다. 이 계층에서는 쿼리를 최적화하고, 필요한 데이터를 효율적으로 가져오기 위해 Querydsl을 사용합니다. Querydsl Tuple은 이 단계에서 다양한 형태의 데이터를 손쉽게 담을 수 있게 해줍니다.

  • 쿼리 최적화: 복잡한 쿼리를 작성할 때 필요한 컬럼만 선택하여 가져올 수 있으므로 성능 최적화에 도움이 됩니다.
  • 유연한 데이터 형식: 여러 개의 컬럼을 한 번에 선택하여 가져올 수 있으며, 특정 조건에 맞는 데이터를 쉽게 추출할 수 있습니다.

DTO로 변환하는 이유

DTO(Data Transfer Object)는 데이터를 전송하는 데 사용되는 객체입니다. 주로 서비스 계층에서 레포지토리 계층으로부터 받은 데이터를 클라이언트나 다른 서비스로 전달할 때 사용됩니다.

  • 데이터 캡슐화: DTO는 필요한 데이터만을 포함하여 외부로 노출하므로, 데이터의 캡슐화와 보안을 강화할 수 있습니다.
  • 유연한 데이터 전송: 클라이언트가 필요로 하는 형태로 데이터를 가공하여 전달할 수 있습니다. 이를 통해 API의 응답 형식을 일관되게 유지할 수 있습니다.
  • 의존성 분리: 레포지토리 계층에서 사용되는 내부 구현(예: Querydsl Tuple)을 서비스나 컨트롤러 계층으로부터 분리할 수 있습니다. 이는 코드의 모듈성과 유지 보수성을 높입니다.

레포지토리 계층

public List<Tuple> findUserDetails() {
    QUser user = QUser.user;
    return queryFactory.select(user.id, user.name, user.email)
                       .from(user)
                       .fetch();
}

서비스 계층

public List<UserDTO> getUserDetails() {
    List<Tuple> tuples = userRepository.findUserDetails();
    return tuples.stream()
                 .map(tuple -> new UserDTO(
                     tuple.get(QUser.user.id),
                     tuple.get(QUser.user.name),
                     tuple.get(QUser.user.email)
                 ))
                 .collect(Collectors.toList());
}

UserDTO

public class UserDTO {
    private Long id;
    private String name;
    private String email;

    // 생성자, getter, setter
}

 

 

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

순수 JPA에서 DTO 조회

package com.example.querytest.dto;

import lombok.Data;

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

    public MemberDto(){
    }

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


}

 

순수 JPA에서 DTO 조회 코드

    @Test
    public void findDtoBySetter(){
        List<MemberDto> result = em.createQuery("select new com.example.querytest.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 population)

결과를 DTO 변환할 때 사용

다음 3가지 방법 지원

 

프로퍼티 접근 - Setter

    @Test
    public void findDtoBySetter(){
        QMember member = QMember.member;

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


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

 

필드 직접 접근

    @Test
    public void findDtoByFields(){
        QMember member = QMember.member;

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


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

- getter, setter 없이 직접 접근

 

생성자 사용

    @Test
    public void findDtoByConstructor(){
        QMember member = QMember.member;

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


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

 

 

별칭이 다를 때

package com.example.querytest.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserDto {
    
    private String name;
    private int age;
}
    @Test
    public void findUserDto(){
        QMember member = QMember.member;
        QMember memberSub = new QMember("memberSub");
        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();


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

 

- 프로퍼티나, 필드 접근 생성 방식에서 이름이 다를 때 해결 방안

- ExpressionUtils.as(source.alias) : 필드나, 서브 쿼리에 별칭 적용

- username.as("memberName") : 필드에 별칭 적용

 

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

생성자 + @QueryProjection

package com.example.querytest.dto;

import com.querydsl.core.annotations.QueryProjection;
import lombok.Data;

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

    public MemberDto(){
    }

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

- ./gradlew compileQuerydsl

- QMemberDto 생성확인

 

@QueryProjection 활용

    @Test
    public void findQueryProjection() {
        QMember member = QMember.member;
        List<MemberDto> result = queryFactory
                .select(new QMemberDto(member.username, member.age))
                .from(member)
                .fetch();
        for(MemberDto memberDto : result){
            System.out.println("memberDto: " + memberDto);
        }
    }

이 방법은 컴파일러로 타입을 체크할 수 있으므로 가장 안전한 방법이다. 다만 DTOQueryDSL 어노테이션을 유지 해야 하는 점과 DTO까지 Q 파일을 생성해야 하는 단점이 있다.

 

 

 

차이점

  @Test
    public void findDtoByConstructor(){
        QMember member = QMember.member;

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


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

    @Test
    public void findQueryProjection() {
        QMember member = QMember.member;
        List<MemberDto> result = queryFactory
                .select(new QMemberDto(member.username, member.age))
                .from(member)
                .fetch();
        for(MemberDto memberDto : result){
            System.out.println("memberDto: " + memberDto);
        }
    }

@QueryProjection은 컴파일 시점에서 에러를 잡아주며 Projections는 런타임시 에러를 잡아준다.

고민해야할 점)

DTO는 다양한 계층간에서 사용을 하고 있었지만 @QueryProjection을 쓰게 되면 QueryDSL에 대한 의존성이 생겨버린다. 만약 Querydsl을 뺴게 되면 문제가 발생한다. 순수하게 사용하고 싶다면 Projection을 사용하거나 QueryDSL 사용이 많아지면 @QueryProjection을 쓰게 되면 컴파일 시점에 잡아주고 코드의 양이 줄어든다는 이점이 있다. 

 

distinct

    @Test
    public void findDistinctMember() {
        QMember member = QMember.member;

        List<String> result = queryFactory
                .select(member.username).distinct()
                .from(member)
                .fetch();
        
        
        for(String s : result){
            System.out.println("s : " + s);
        }
    }

- distinct는 JPQL의 distinct와 같다

 

 

쉬어가기) 정적 쿼리와 동적쿼리에 대해서 알고 가기

 

JPQL(Java Persistence Query Language)

JPQL은 JPA(Java Persistence API)에서 사용되는 쿼리 언어로, 주로 정적 쿼리로 사용됩니다. JPQL은 데이터베이스 테이블이 아닌 엔티티 객체를 대상으로 쿼리를 작성합니다. 쿼리 문장이 미리 정의되어 고정된 형태로 사용되기 때문에 정적 쿼리에 속합니다.

String jpql = "SELECT u FROM User u WHERE u.name = :name";
TypedQuery<User> query = entityManager.createQuery(jpql, User.class);
query.setParameter("name", "John Doe");
List<User> users = query.getResultList();

 

JPA 쿼리 메서드

JPA 쿼리 메서드는 Spring Data JPA에서 제공하는 기능으로, 메서드 이름을 통해 쿼리를 자동으로 생성합니다. 이 방식도 주로 정적 쿼리로 분류됩니다. 메서드 이름에 따라 쿼리가 고정되기 때문에 정적 쿼리에 해당합니다.

public interface UserRepository extends JpaRepository<User, Long> {
    List<User> findByName(String name);
}

 

Querydsl

Querydsl은 동적 쿼리 생성을 지원하는 프레임워크입니다. Querydsl을 사용하면 코드 기반으로 동적으로 쿼리를 생성할 수 있습니다. 이로 인해 다양한 조건에 따라 쿼리를 유연하게 생성할 수 있어 동적 쿼리에 속합니다.

QUser user = QUser.user;
JPAQuery<User> query = new JPAQuery<>(entityManager);
List<User> users = query.from(user)
                        .where(user.name.eq("John Doe"))
                        .fetch();

 

  • JPQL: 주로 정적 쿼리. 미리 정의된 쿼리 문장을 사용하여 고정된 형태로 쿼리를 실행합니다.
  • JPA 쿼리 메서드: 정적 쿼리. 메서드 이름을 통해 고정된 쿼리를 자동으로 생성하고 실행합니다.
  • Querydsl: 동적 쿼리. 코드 기반으로 동적으로 쿼리를 생성할 수 있으며, 다양한 조건에 따라 유연하게 쿼리를 작성할 수 있습니다.

 

동적 쿼리 - BooleanBuilder 사용

동적 쿼리를 해결하는 두가지 방식

- BooleanBuilder

- Where 다중 파라미터 사용

    @Test
    public void dynamicQuery_BooleanBuilder(){
        String usernameParam = "member1";
        Integer ageParam = null;

        List<Member> results = searchMember(usernameParam, ageParam);
        assertThat(results.size()).isEqualTo(1);
    }

    public List<Member> searchMember(String usernameCond, Integer ageCond){
        QMember member = QMember.member;

//        BooleanBuilder builder = new BooleanBuilder(member.username.eq("member1")); // 초기 값 설정 가능
        BooleanBuilder builder = new BooleanBuilder();
        
        if(usernameCond != null){
            builder.and(member.username.eq("member1"));
        }
        
        if(ageCond != null){
            builder.and(member.age.eq(10));
        }


        return queryFactory
                .selectFrom(member)
//                .where(builder.and(member.age.eq(20))) // 추가적으로 조건 and(), or() 가능
                .where(builder)
                .fetch();
    }

 

 

동적 쿼리 - Where 다중 파라미터 사용

 @Test
    public void dynamicQuery_WhereParam(){
        String usernameParam = "member1";
        Integer ageParam = null;

        List<Member> results = searchMember2(usernameParam, ageParam);
        assertThat(results.size()).isEqualTo(1);
    }
    public List<Member> searchMember2(String usernameCond, Integer ageCond) {
        QMember member = QMember.member;
        return queryFactory
                .selectFrom(member)
                .from(member)
//                .where(allEq(usernameCond, ageCond)) // Java 이기에 조합이 가능
                .where(usernameEq(usernameCond), ageEq(ageCond))
                .fetch();
    }

    private BooleanExpression usernameEq(String usernameCond) {
        QMember member = QMember.member;
        return usernameCond !=null ? member.username.eq(usernameCond) : null;
    }

    private BooleanExpression ageEq(Integer ageCond) {
        QMember member = QMember.member;
        return ageCond!=null? member.age.eq(ageCond) : null;
    }

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

- where 조건에 null 값은 무시된다.

- 메서드를 다른 쿼리에서도 재활용 할 수 있다.

- 쿼리 자체의 가독성이 높아진다.

 

조합 가능

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

- null 체크는 주의해서 처리해야함

 

 

수정, 삭제 벌크 연산

 

  • Bulk 연산:
    • 대량의 데이터를 한 번에 수정하는 작업.
    • 쿼리 한 번으로 많은 데이터를 변경할 때 사용.
  • JPA의 기본 동작:
    • JPA는 기본적으로 엔티티(Entity)를 가져와서 엔티티의 값을 변경.
    • 트랜잭션이 커밋될 때 플러시(Flush)가 발생하고, 이는 영어로 'Dirty Checking'이라고 함.
    • Dirty Checking(변경 감지)은 엔티티의 변경을 감지하여 업데이트 쿼리를 생성하고 데이터베이스에 전송.
  • 변경 감지의 문제:
    • 변경 감지는 개별 엔티티에 대해 일어나므로, 엔티티가 많을 경우 쿼리도 많이 발생.
    • 많은 쿼리를 한 번에 처리해야 하는 경우 성능 저하 발생.
  • 성능 문제:
    • 쿼리가 많아지면 성능이 떨어짐.
    • 이를 해결하기 위해 한 번에 많은 데이터를 처리할 수 있는 Bulk 연산을 사용.

 

쿼리 한번으로 대량 데이터 수정

    @Test
    @Commit
    public void bulkUpdate(){
        // member1 = 10 -> 비회원
        // member2 = 20 -> 비회원
        // member3 = 30 -> 유지
        // member4 = 40 -> 유지

        QMember member = QMember.member;

        long count = queryFactory
                .update(member)
                .set(member.username, "비회원")
                .where(member.age.lt(28))
                .execute();

    }

 

 

Bulk 연산을 사용하지 않고 각 엔티티를 개별적으로 업데이트하려면, 먼저 조건에 맞는 엔티티들을 조회한 후, 해당 엔티티들의 속성을 수정하고, 수정된 엔티티들을 다시 저장해야 합니다. 이는 JPA의 변경 감지(Dirty Checking) 메커니즘을 통해 이루어집니다.

import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.EntityManager;
import java.util.List;

public class MemberService {

    @Autowired
    private JPAQueryFactory queryFactory;

    @Autowired
    private EntityManager entityManager;

    @Transactional
    @Commit
    public void bulkUpdate() {
        // member1 = 10 -> 비회원
        // member2 = 20 -> 비회원
        // member3 = 30 -> 유지
        // member4 = 40 -> 유지

        QMember member = QMember.member;

        // 조건에 맞는 엔티티들을 조회
        List<Member> membersToUpdate = queryFactory
                .selectFrom(member)
                .where(member.age.lt(28))
                .fetch();

        // 각 엔티티의 속성을 수정
        for (Member m : membersToUpdate) {
            m.setUsername("비회원");
        }

        // 엔티티 매니저를 통해 변경 사항이 자동으로 커밋됨 (Dirty Checking)
    }
}

주요 변경 사항 설명

  1. 조건에 맞는 엔티티 조회:
    • QueryDSL을 사용하여 age가 28보다 작은 회원들을 조회합니다.
    • queryFactory.selectFrom(member).where(member.age.lt(28)).fetch();를 사용하여 조건에 맞는 회원 목록을 가져옵니다.
  2. 엔티티 속성 수정:
    • 조회된 각 엔티티의 username을 "비회원"으로 수정합니다.
    • for 루프를 사용하여 각 회원의 username 속성을 변경합니다.
  3. 변경 사항 저장:
    • JPA의 변경 감지(Dirty Checking) 메커니즘에 의해, 트랜잭션이 커밋될 때 변경 사항이 자동으로 데이터베이스에 반영됩니다.
    • @Transactional 어노테이션을 사용하여 메서드가 트랜잭션 내에서 실행되도록 합니다. 트랜잭션이 끝날 때 엔티티 매니저가 변경 사항을 자동으로 커밋합니다.

주의 사항

  • 성능 문제: 개별 엔티티를 업데이트하는 방식은 데이터베이스와의 여러 번의 통신이 발생하므로, 대량의 데이터를 처리할 때 성능 저하가 발생할 수 있습니다. 이런 경우에는 여전히 Bulk 연산을 사용하는 것이 좋습니다.
  • 트랜잭션 관리: 메서드가 트랜잭션 내에서 실행되어야 변경 사항이 제대로 반영됩니다. @Transactional 어노테이션을 사용하여 트랜잭션을 관리합니다.

 

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

 

Bulk 연산은 JPA의 영속성 컨텍스트를 무시하고 직접 데이터베이스에 쿼리를 실행하기 때문에, 데이터베이스의 변경 사항이 영속성 컨텍스트에 반영되지 않습니다. 따라서, Bulk 연산 후에 영속성 컨텍스트와 1차 캐시를 비워주어야 합니다. 이를 통해 데이터베이스의 최신 상태를 반영할 수 있습니다.

아래 코드는 Bulk 연산 후에 영속성 컨텍스트와 1차 캐시를 비워주고, 데이터베이스에서 다시 조회하여 최신 상태를 가져오는 방법을 보여줍니다.

 

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import javax.persistence.EntityManager;
import java.util.List;

public class MemberService {

    @Autowired
    private JPAQueryFactory queryFactory;

    @Autowired
    private EntityManager em;

    @Test
    @Transactional
    @Commit
    public void bulkUpdate() {
        QMember member = QMember.member;

        // Bulk 연산 수행
        long count = queryFactory
                .update(member)
                .set(member.username, "비회원")
                .where(member.age.lt(28))
                .execute();

        // 영속성 컨텍스트와 1차 캐시 비우기
        em.flush();
        em.clear();

        // 데이터베이스에서 다시 조회하여 최신 상태 가져오기
        List<Member> results = queryFactory
                .selectFrom(member)
                .fetch();

        // 조회한 데이터 출력
        for (Member data : results) {
            System.out.println("data : " + data);
        }
    }
}

 

 

기존 숫자에 1 더하기

    @Test
    public void bulkdAdd(){
        QMember member = QMember.member;
        long count = queryFactory
                .update(member)
                .set(member.age, member.age.add(1))
                .execute();
    }

곱하기 : multiply(x)

        long count = queryFactory
                .update(member)
                .set(member.age, member.age.multiply(2))
                .execute();

 

쿼리 한번으로 대량 데이터 삭제

    @Test
    public void bulkdDelete(){
        QMember member = QMember.member;
        long count = queryFactory
                .delete(member)
                .where(member.age.gt(18))
                .execute();
    }

 

 

SQL function 호출하기

- SQL function은 JPA와 같이 Dialect에 등록된 내용만 호출할 수 있다.

 

- member -> M으로 변경하는 replace 함수 사용

    @Test
    public void sqlFunction(){
        QMember member = QMember.member;
        List<String> result = queryFactory
                .select(Expressions.stringTemplate(
                        "function('replace', {0}, {1}, {2})",
                        member.username, "member", "Member"))
                .from(member)
                .fetch();

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

 

 

소문자로 변경해서 비교해라

    @Test
    public void sqlFunction(){
        QMember member = QMember.member;
        List<String> result = queryFactory
//                .select(Expressions.stringTemplate(
//                        "function('lower', {0})", member.username))
                .select(member.username)
                .from(member)
                .where(member.username.eq(member.username.lower()))
                .fetch();

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

lower 같은 ansi 표준 함수들은 querydsl이 상단부분 내장하고 있다. 다라서 다음과 같이 처리해도 결과는 같다.

.where(member.username.eq(member.username.lower()))