728x90
반응형
SMALL

스프링 데이터 JPA에서 Querydsl을 지원하는 QuerydslRepositorySupport 라는 추상 클래스가 있다. 어떤걸 도와주냐면 우리가 이전에 사용했던 Querydsl 용 리포지토리를 아래와 같이 변경해보자.

public class MemberRepositoryImpl extends QuerydslRepositorySupport implements MemberRepositoryQueryDsl {

    // private final JPAQueryFactory queryFactory;

    public MemberRepositoryImpl(Class<?> domainClass) {
        super(Member.class);
    }
    
    ...
    
}
  • 기존에는, extends QuerydslRepositorySupport 가 없었는데 이 부분을 추가했다.
  • 그리고, JPAQueryFactory를 주입 받아야 하는 부분도 제거했다. 대신 저 QuerydslRepositorySupport를 상속받으면 반드시 생성자를 만들어 줘야 하는데 그 부분이 바로 이 부분이다.
public MemberRepositoryImpl(Class<?> domainClass) {
    super(Member.class);
}

 

이렇게 해주면 아래와 같은 장점이 있다.

  • JPAQueryFactory를 주입받지 않아도 된다.
  • from()으로 시작하는 쿼리를 작성할 수 있다. (이게 장점인지는 모르겠다)
  • getQuerydsl().applyPagination() 이라는 스프링 데이터가 제공하는 페이징을 Querydsl로 편리하게 반환이 가능한데 사실 이것도 편리한지는 잘 모르겠다. 심지어 Sort도 제대로 동작안한다.
@Override
public List<MemberTeamDto> search(MemberSearchCondition condition) {
    return from(member)
            .leftJoin(member.team, team)
            .where(usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            ).select(new QMemberTeamDto(
                    member.id.as("memberId"),
                    member.username,
                    member.age,
                    team.id.as("teamId"),
                    team.name.as("teamName")))
            .fetch();

    /*return queryFactory
            .select(new QMemberTeamDto(
                    member.id.as("memberId"),
                    member.username,
                    member.age,
                    team.id.as("teamId"),
                    team.name.as("teamName")))
            .from(member)
            .leftJoin(member.team, team)
            .where(usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            ).fetch();*/
}
  • 주석 처리한 부분이 기존에 작성했던 코드이다.
  • 보면, from()으로 시작하는데, select()가 뒤에 있고 뭔가 좀 어색하고 쿼리를 한번에 읽기엔 명시적이지 않다. 난 이걸 장점으로 생각하지는 않는다. 
@Override
    public Page<MemberTeamDto> searchPaging(MemberSearchCondition condition, Pageable pageable) {

        JPQLQuery<MemberTeamDto> jpaQuery = from(member)
                .leftJoin(member.team, team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                ).select(new QMemberTeamDto(
                        member.id.as("memberId"),
                        member.username,
                        member.age,
                        team.id.as("teamId"),
                        team.name.as("teamName")));

        JPQLQuery<MemberTeamDto> query = getQuerydsl().applyPagination(pageable, jpaQuery);
        query.fetch();

        /*List<MemberTeamDto> result = queryFactory
                .select(new QMemberTeamDto(
                        member.id.as("memberId"),
                        member.username,
                        member.age,
                        team.id.as("teamId"),
                        team.name.as("teamName")))
                .from(member)
                .leftJoin(member.team, team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        JPAQuery<Member> countQuery = queryFactory
                .select(member)
                .from(member)
                .leftJoin(member.team, team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())); */

        return PageableExecutionUtils.getPage(result, pageable, () -> countQuery.stream().count());
    }
  • 이게 이제 페이징을 좀 편리하게 해준다는 getQuerydsl().applyPagination()을 사용한 방법인데, 기존에 작성했던 코드랑 차이점은 쿼리에 offset(), limit()을 작성하지 않는다는 점이다. 
  • 근데, 이거 하나 줄이겠다고 저렇게 from()부터 시작하는 게 영 맘에 들지는 않는다.
728x90
반응형
LIST

'Querydsl' 카테고리의 다른 글

JPA 레포지토리와 Querydsl  (2) 2024.12.26
Querydsl 중급 문법  (0) 2024.12.22
Querydsl 기본 문법  (0) 2024.12.21
[Renewal] QueryDsl  (0) 2024.12.07
Spring Boot 3.1.5에서 QueryDSL 설치하기  (0) 2023.11.26
728x90
반응형
SMALL

이전 포스팅까지는 Querydsl의 기본 사용 방법에 대해 쭉 정리를 해봤는데, 이제 그 지식을 바탕으로 실무에 가까운 코드를 작성해보자.

먼저, 순수한 JPA 레포지토리와 Querydsl을 사용해보고, 이후에 스프링 데이터 JPAQuerydsl을 사용해보는 것도 하나씩 해보자.

 

순수 JPA 레포지토리와 Querydsl

MemberJpaRepository

package cwchoiit.querydsl.repository;

import com.querydsl.jpa.impl.JPAQueryFactory;
import cwchoiit.querydsl.entity.Member;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

import static cwchoiit.querydsl.entity.QMember.member;

@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;

    public void save(Member member) {
        em.persist(member);
    }

    public Optional<Member> findById(Long id) {
        return Optional.ofNullable(em.find(Member.class, id));
    }

    public List<Member> findAll() {
        return em.createQuery("select m from Member m", Member.class)
                .getResultList();
    }

    public List<Member> findAllQuerydsl() {
        return queryFactory
                .selectFrom(member)
                .fetch();
    }

    public List<Member> findByUsername(String username) {
        return em.createQuery("select m from Member m where m.username = :username", Member.class)
                .setParameter("username", username)
                .getResultList();
    }

    public List<Member> findByUsernameQuerydsl(String username) {
        return queryFactory
                .selectFrom(member)
                .where(member.username.eq(username))
                .fetch();
    }
}
  • 순수 JPA 레포지토리는 워낙 많이 만들어봤기 때문에 다른 설명이 필요없다. 그런데 이 레포지토리에서 Querydsl을 사용하기 위해 JPAQueryFactory를 주입받는 방법이 취향차이인데, 지금 방식은 빈으로 등록해서 주입받는 방법이다. 빈으로는 어디에 등록했나? 빈을 등록할 수 있는 어디든 상관은 없다. 나의 경우 엔트리 클래스에 했다. 아래 코드처럼.
package cwchoiit.querydsl;

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class QuerydslApplication {

    public static void main(String[] args) {
        SpringApplication.run(QuerydslApplication.class, args);
    }

    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager em) {
        return new JPAQueryFactory(em);
    }
}
  • 빈으로 JPAQueryFactory를 등록해서 여기저기 주입받아 사용한다. 그럼 여기서 의문이 든다. 동시성 문제가 있지 않을까? 그 걱정은 하지 않아도 된다. 왜냐하면 여기서 스프링이 주입해주는 엔티티 매니저는 실제 동작 시점에 진짜 엔티티 매니저를 찾아주는 프록시 엔티티 매니저를 주입해준다. 이 프록시가 실제 사용 시점에 트랜잭션 단위로 실제 엔티티 매니저를 할당해준다.
  • 이렇게 빈으로 등록하고 주입받는 방법을 사용해도 되고, 아래와 같이 사용해도 상관없다.
@Repository
public class MemberJpaRepository {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;

    public MemberJpaRepository(EntityManager em) {
        this.em = em;
        this.queryFactory = new JPAQueryFactory(em);
    }
    
    ...
}
  • 각자 장단점이 있는데, 이렇게 생성자에서 new로 새로 만들어내는 방법은 테스트 코드를 작성할 때 더 편리하다. 빈으로 등록한 것을 주입 시킬 필요가 없으니까. 근데 이제 실제 코드에서 생성자를 만드는 코드를 작성하는 게 좀 귀찮아진다. 

 

그리고, findAll(), findByUsername() 이 두 메서드를 보면 Querydsl로 만든 버전이 있고 순수 JPA로 만든 버전이 있는데 차이가 명확하다.

public List<Member> findAll() {
    return em.createQuery("select m from Member m", Member.class)
            .getResultList();
}

public List<Member> findAllQuerydsl() {
    return queryFactory
            .selectFrom(member)
            .fetch();
}

public List<Member> findByUsername(String username) {
    return em.createQuery("select m from Member m where m.username = :username", Member.class)
            .setParameter("username", username)
            .getResultList();
}

public List<Member> findByUsernameQuerydsl(String username) {
    return queryFactory
            .selectFrom(member)
            .where(member.username.eq(username))
            .fetch();
}
  • 순수 JPA로 작성하려면 JPQL을 작성해줘야 하는데 이 부분에서 실수를 해도 컴파일 시점에 그 부분을 잡아내지는 못한다. 왜냐? 단순 문자열이니까. 그렇지만 Querydsl을 사용하는 경우, 컴파일 시점에 문제를 다 잡아낼 수 있다. 왜냐? 자바 코드니까.
  • 또 한가지는, 파라미터 바인딩 코드가 Querydsl은 필요가 없다. 
  • Querydsl을 사용하면 여러모로 장점이 참 많다.

 

동적 쿼리와 성능 최적화 조회 - Builder 사용

동적 쿼리가 존재하는 순수 JPA 레포지토리도 만들어보고, 극한의 최적화를 위해 딱 필요한 것만 조회해오는 DTO로 성능 최적화를 해보자.

MemberTeamDto

package cwchoiit.querydsl.dto;

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

@Data
public class MemberTeamDto {

    private Long memberId;
    private String username;
    private int age;
    private Long teamId;
    private String teamName;

    @QueryProjection
    public MemberTeamDto(Long memberId, String username, int age, Long teamId, String teamName) {
        this.memberId = memberId;
        this.username = username;
        this.age = age;
        this.teamId = teamId;
        this.teamName = teamName;
    }
}
  • 쿼리를 날릴 때 SELECT절에 딱 필요한 데이터만 메모리에 퍼올리기 위해 원하는 필드만 있는 DTO를 만들었다.
  • 그리고, @QueryProjection을 사용해서, Querydsl을 사용할 때 DTO를 편리하게 사용할 수 있게 했다.

MemberSearchCondition

package cwchoiit.querydsl.dto;

import lombok.Data;

@Data
public class MemberSearchCondition {

    private String username;
    private String teamName;
    private Integer ageGoe;
    private Integer ageLoe;
}
  • 동적 쿼리에 사용될 조건 객체를 만들었다.

MemberJpaRepository 일부분

public List<MemberTeamDto> searchByBuilder(MemberSearchCondition condition) {

    BooleanBuilder builder = new BooleanBuilder();

    if (StringUtils.hasText(condition.getUsername())) {
        builder.and(member.username.eq(condition.getUsername()));
    }

    if (StringUtils.hasText(condition.getTeamName())) {
        builder.and(team.name.eq(condition.getTeamName()));
    }

    if (condition.getAgeGoe() != null) {
        builder.and(member.age.goe(condition.getAgeGoe()));
    }

    if (condition.getAgeLoe() != null) {
        builder.and(member.age.loe(condition.getAgeLoe()));
    }

    return queryFactory
            .select(new QMemberTeamDto(
                    member.id.as("memberId"),
                    member.username,
                    member.age,
                    team.id.as("teamId"),
                    team.name.as("teamName")))
            .from(member)
            .leftJoin(member.team, team)
            .where(builder)
            .fetch();
}
  • BooleanBuilder를 사용해서 동적 쿼리를 먼저 만들어봤다. 이전에도 이 녀석을 사용해보고 이후에 WHERE 다중 파라미터 방식을 했던것처럼 이번에도 그렇게 해보자.
  • BooleanBuilder를 사용해도 코드가 나쁘지 않다. 느낌은 JDBC를 사용해서 동적 쿼리를 만들어내는 거랑 비슷한데, 확연히 다른 점은 단순 문자열로 동적 쿼리를 만들어내는 게 아니란 점이다. 이게 정말 가장 강력한 장점이다.
  • 한가지 위 코드에는 오점이 있다. 오점이라기 보단 불 필요한 부분. 바로 SELECT절에 퍼올리는 데이터에 as(...)로 별칭을 주는 부분이다. 저렇게 작성한 이유는 데이터베이스에는 MemberID 필드가 `member_id`인데 DTO`memberId`이기 때문에 기본적으로 저렇게 별칭을 주는게 맞다. 근데 저건 Q클래스의 DTO이고 생성자이다. 즉, 필드가 들어가는 순서가 명확하고 어떤 타입인지도 이미 컴파일러는 알고 있으며, 생성자를 사용해 DTO 타입의 객체를 만들어내기 때문에 저렇게 할 필요가 없다. 그러니까 아래처럼 작성해도 된다.
return queryFactory
    .select(new QMemberTeamDto(
            member.id,
            member.username,
            member.age,
            team.id,
            team.name))
    .from(member)
    .leftJoin(member.team, team)
    .where(builder)
    .fetch();

 

그러나, 이 BooleanBuilder를 사용하는 것보다 무조건 WHERE절 다중 파라미터를 사용하는 게 더 좋다. 일단 코드를 보면 무슨말인지 확 이해가 된다.

 

동적 쿼리와 성능 최적화 조회 - WHERE절 다중 파라미터 사용

public List<MemberTeamDto> search(MemberSearchCondition condition) {
    return queryFactory
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.id,
                    team.name))
            .from(member)
            .leftJoin(member.team, team)
            .where(usernameEq(condition.getUsername()),
                   teamNameEq(condition.getTeamName()),
                   ageGoe(condition.getAgeGoe()),
                   ageLoe(condition.getAgeLoe()))
            .fetch();
}

private BooleanExpression usernameEq(String username) {
    return StringUtils.hasText(username) ? member.username.eq(username) : null;
}

private BooleanExpression teamNameEq(String teamName) {
    return StringUtils.hasText(teamName) ? team.name.eq(teamName) : null;
}

private BooleanExpression ageGoe(Integer ageGoe) {
    return ageGoe != null ? member.age.goe(ageGoe) : null;
}

private BooleanExpression ageLoe(Integer ageLoe) {
    return ageLoe != null ? member.age.loe(ageLoe) : null;
}
  • 같은 코드가 이렇게 바뀌었다. 쿼리만 따로 보면 이렇다.
public List<MemberTeamDto> search(MemberSearchCondition condition) {
    return queryFactory
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.id,
                    team.name))
            .from(member)
            .leftJoin(member.team, team)
            .where(usernameEq(condition.getUsername()),
                   teamNameEq(condition.getTeamName()),
                   ageGoe(condition.getAgeGoe()),
                   ageLoe(condition.getAgeLoe()))
            .fetch();
}
  • 이게 가장 큰 장점이 뭐냐? 쿼리가 읽힌다는 점이다. 동적 쿼리를 처리하는 방법은 다양하다. JDBC를 사용해서 동적쿼리를 처리하나 위에서 다뤘던 BooleanBuilder를 사용하나 쿼리만 딱 봤을 때 어떤 쿼리가 나갈지 예상하기 쉽지 않다. 위에 "어떤 코드가 있나~?" 하고 보고 내려와야 하니까. 
  • 그런데 이 방식은 그럴 필요가 없다. 쿼리만 봐도 어떤 쿼리가 나갈지 그냥 눈에 보인다. 이게 정말 정말 큰 장점이다. 그리고 또 하나의 장점은 조건에 사용되는 usernameEq, teamNameEq, ageGoe, ageLoe 와 같은 메서드는 메서드이기 때문에 재사용이 가능하다는 점이다. 다른 동적 쿼리를 작성할 때 이렇게 범용적으로 사용될 것 같은 조건들은 또 사용될 가능성이 높다. 그럴때 그냥 만들어 둔 이 녀석들을 가져다가 사용하면 된다. 
  • 또다른 장점은, 각 메서드를 조합하는 게 가능하다는 점이다. 다 이전 포스팅에서 다뤘던 내용이지만 한번 더 강조하고자 말하는 중이니 어떻게 조합한다는 거지?에 대한 의문은 이전 포스팅이 해결해 줄 것이다.

 

조회 API 컨트롤러 개발

이제 이 동적쿼리를 실제로 어디선가 호출하는 그런 케이스를 만들어보자. REST API로 어떤 데이터를 조회할 때 저 동적 쿼리가 사용된다고 가정해보는 것이다. 이걸 테스트해보기 위해서는 더미데이터가 데이터베이스에 좀 있어야 한다. 그래서 이 부분을 먼저 좀 처리해보자.

 

우선, 프로파일을 나눠서 테스트 코드에는 영향이 끼치지 않도록 아래와 같이 해보자.

 

`src/main/resources/application.yaml`

spring:
  profiles:
    active: local
...

 

`src/test/resources/application.yaml`

spring:
  profiles:
    active: test
...

 

InitMember

package cwchoiit.querydsl.controller;

import cwchoiit.querydsl.entity.Member;
import cwchoiit.querydsl.entity.Team;
import jakarta.annotation.PostConstruct;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Profile("local")
@Component
@RequiredArgsConstructor
public class InitMember {

    private final InitMemberService memberService;

    @PostConstruct
    public void init() {
        memberService.init();
    }

    @Component
    @RequiredArgsConstructor
    static class InitMemberService {
        private final EntityManager em;

        @Transactional
        public void init() {
            Team teamA = new Team("teamA");
            Team teamB = new Team("teamB");
            em.persist(teamA);
            em.persist(teamB);

            for (int i = 0; i < 100; i++) {
                Team selectedTeam = i % 2 == 0 ? teamA : teamB;
                em.persist(new Member("member"+i, i, selectedTeam));
            }
        }
    }
}
  • 이 클래스는 오직 프로파일이 `local`인 경우에만 살아있을 것이다. @Profile("local") 애노테이션을 달았기 때문이다.
  • 그리고, @PostConstruct 애노테이션을 사용해서, 스프링 띄우고 바로 호출되도록 했다. 뭘 바로 호출해야 하냐? 가 데이터를 만드는 작업이다. 
  • 그런데, 가 데이터를 만들 땐 트랜잭션이 필요하다. 그래서 @Transactional@PostConstruct를 분리해야 한다. 이 둘은 동시에 적용될 수 없다. 
  • 이렇게 가 데이터를 만들게 하고 나서, 컨트롤러 하나를 추가해보자.

MemberController

package cwchoiit.querydsl.controller;

import cwchoiit.querydsl.dto.MemberSearchCondition;
import cwchoiit.querydsl.dto.MemberTeamDto;
import cwchoiit.querydsl.repository.MemberJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberJpaRepository memberJpaRepository;

    @GetMapping("/v1/members")
    public List<MemberTeamDto> searchMembersV1(MemberSearchCondition condition) {
        return memberJpaRepository.search(condition);
    }
}
  • 컨트롤러 하나를 간단히 만들고, 파라미터로 위에서 동적 쿼리 조건 처리할 때 사용하는 MemberSearchCondition을 사용했다.

 

이제 이 API를 호출해보자.

### GET Members
GET http://localhost:8080/v1/members?teamName=teamB&ageGoe=31&ageLoe=50&username=member49

 

스프링 데이터 JPAQuerydsl

이제 순수 JPA 레포지토리를 스프링 데이터 JPA로 변경해보자.

 

MemberRepository

package cwchoiit.querydsl.repository;

import cwchoiit.querydsl.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface MemberRepository extends JpaRepository<Member, Long> {
    List<Member> findByUsername(String username);
}
  • 스프링 데이터 JPA로 변경하려면 이렇게 만들면 끝난다. 기존에 있던, save(), findAll(), findById()와 같은 공통 메서드들은 이미 스프링 데이터 JPA는 다 만들어서 제공해주기 때문에 그런 공통 메서드가 아닌 findByUsername()만 만들면 되는데 이마저도 스프링 데이터 JPA가 제공하는 메서드 쿼리 기능을 사용해서 시그니처만으로 끝낼 수 있다.
  • 문제는 Querydsl을 사용했던 부분들이다. 걔네들은 어떻게 구현하면 될까?

 

사용자 정의 레포지토리

Querydsl을 사용하려면 구현 코드를 작성해야 하는데, 스프링 데이터 JPA는 인터페이스이기 때문에 조금 복잡한 과정이 필요하다.

 

1. 사용자 정의 인터페이스 작성

package cwchoiit.querydsl.repository;

import cwchoiit.querydsl.dto.MemberSearchCondition;
import cwchoiit.querydsl.dto.MemberTeamDto;

import java.util.List;

public interface MemberQuerydslRepository {
    List<MemberTeamDto> search(MemberSearchCondition condition);
}
  • 인터페이스 이름은 원하는대로 만들면 된다. 그리고, 이전에 순수 JPA 레포지토리에서 만들었던 search()를 그대로 만들어준다.

2. 사용자 정의 인터페이스 구현

package cwchoiit.querydsl.repository;

import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import cwchoiit.querydsl.dto.MemberSearchCondition;
import cwchoiit.querydsl.dto.MemberTeamDto;
import cwchoiit.querydsl.dto.QMemberTeamDto;
import lombok.RequiredArgsConstructor;
import org.springframework.util.StringUtils;

import java.util.List;

import static cwchoiit.querydsl.entity.QMember.member;
import static cwchoiit.querydsl.entity.QTeam.team;

@RequiredArgsConstructor
public class MemberQuerydslRepositoryImpl implements MemberQuerydslRepository {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<MemberTeamDto> search(MemberSearchCondition condition) {
        return queryFactory
                .select(new QMemberTeamDto(
                        member.id,
                        member.username,
                        member.age,
                        team.id,
                        team.name))
                .from(member)
                .leftJoin(member.team, team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()))
                .fetch();
    }

    private BooleanExpression usernameEq(String username) {
        return StringUtils.hasText(username) ? member.username.eq(username) : null;
    }

    private BooleanExpression teamNameEq(String teamName) {
        return StringUtils.hasText(teamName) ? team.name.eq(teamName) : null;
    }

    private BooleanExpression ageGoe(Integer ageGoe) {
        return ageGoe != null ? member.age.goe(ageGoe) : null;
    }

    private BooleanExpression ageLoe(Integer ageLoe) {
        return ageLoe != null ? member.age.loe(ageLoe) : null;
    }
}
  • 구현체를 만들고, 순수 JPA 레포지토리에서 만들었던 그 메서드를 그대로 가져와보자. 

3. 스프링 데이터 레포지토리에 사용자 정의 인터페이스 상속

package cwchoiit.querydsl.repository;

import cwchoiit.querydsl.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface MemberRepository extends JpaRepository<Member, Long>, MemberQuerydslRepository {
    List<Member> findByUsername(String username);
}
  • 스프링 데이터 JPA 레포지토리에 위에서 만든 우리의 커스텀 레포지토리를 상속시키면 끝이다.

 

스프링 데이터 페이징 활용1 - Querydsl 페이징 연동

  • Querydsl을 사용하면서 스프링 데이터의 Page, Pageable을 활용해보자.
package cwchoiit.querydsl.repository;

import cwchoiit.querydsl.dto.MemberSearchCondition;
import cwchoiit.querydsl.dto.MemberTeamDto;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

import java.util.List;

public interface MemberQuerydslRepository {
    List<MemberTeamDto> search(MemberSearchCondition condition);
    Page<MemberTeamDto> search(MemberSearchCondition condition, Pageable pageable);
}
  • 우선, 반환타입이 Page<MemberTeamDto>이고, 파라미터로 Pageable을 받는 메서드를 하나 추가한다.
  • 이제 이 녀석을 새로 구현하면 된다.
@Override
public Page<MemberTeamDto> search(MemberSearchCondition condition, Pageable pageable) {
    List<MemberTeamDto> contents = queryFactory
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.id,
                    team.name))
            .from(member)
            .leftJoin(member.team, team)
            .where(usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe()))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    List<String> totalContents = queryFactory
            .select(member.username)
            .from(member)
            .where(usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe()))
            .fetch();

    return new PageImpl<>(contents, pageable, totalContents.size());
}
  • 이전에는 fetchResults()라는 메서드를 사용하면, 데이터와 전체 개수를 같이 받아오는 메서드가 있었는데 이제는 Deprecated됐다. 그래서 데이터를 가져오는 쿼리와 카운트 쿼리를 따로 날려야 한다. 그리고 이후에 설명하겠지만 이게 맞다.
  • 위 코드를 보면, 파라미터로 받은 Pageable에서 offset, limit을 뽑아올 수가 있다. Querydsl을 사용해서 그냥 offset, limit을 넣어주면 페이징은 끝이다.
  • 그럼 카운트 쿼리를 자세히 보자. 이게 중요하다.
List<String> totalContents = queryFactory
                .select(member.username)
                .from(member)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()))
                .fetch();
  • 카운트 쿼리를 보니, 데이터를 가져오는 쿼리랑 다르다. 조인도 안하고 있고, SELECT절에서 가져오는 것들도 다르다. 이게 최대한의 성능 최적화를 할 수 있는 방법이다. 전체 개수를 가져오는 쿼리는 생각보다 굉장히 무겁다. 데이터가 100백만건이면 그 데이터를 메모리에 퍼 올리는 것만으로도 문제가 될 수 있다. 거기에 더해서, 조인을 하고 SELECT절에 필요하지 않은 데이터까지 다 퍼올리면 이 부분도 성능에 마이너스 요소이다. 
  • LEFT JOIN을 하면 전체 개수는 LEFT JOIN을 아예 하지 않아도 동일하다. 어차피 조인한 결과가 없으면 빈 값으로 그대로 레코드를 가져오기 때문이다. 그래서 굳이 조인을 하지 않아도 전체 개수에 아무런 영향을 끼치지 않는다.
  • 또한, SELECT절에는 다른 데이터가 전혀 필요없이 딱 하나만 있어도 전체 개수에 아무런 영향을 끼치지 않는다. 그래서 굳이 데이터를 가져오는 쿼리에서 봤던 여러 다른 데이터를 가져올 필요가 없다. 
  • 이렇게 전체 개수를 가져오는 쿼리를 최적화할 수 있으면 해야 한다. 매번 이렇게 할 수 있는건 아니지만 가능하면 반드시 해야 한다.
return new PageImpl<>(contents, pageable, totalContents.size());
  • 이제 반환을 하면 된다. 첫번째 파라미터는 contents, 두번째 파라미터는 pageable, 세번째 파라미터는 전체 개수를 받는다. 
  • 이것 또한, 최적화가 가능하다. 다음과 같이 말이다.
JPAQuery<String> countQuery = queryFactory
                .select(member.username)
                .from(member)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()));

return PageableExecutionUtils.getPage(contents, pageable, () -> countQuery.fetch().size());
  • PageableExecutionUtils.getPage()를 사용하면 어떤 최적화가 가능하냐면, contents를 가져왔을 때 전체 개수 쿼리를 날릴 필요가 없으면 날리지 않는다. 엥? 어떤 경우에 이럴까? 예를 들어 한 페이지의 개수가 100개인 페이징 쿼리를 날렸는데 한 페이지에 데이터가 3개만 들어왔다. 이 말은 그 쿼리 자체의 전체 개수가 3개라는 말이다. 그럼 카운트 쿼리를 날릴 필요없이 그냥 전체 개수는 3인 것이다. 

 

스프링 데이터 페이징 활용2 - 컨트롤러 개발

package cwchoiit.querydsl.controller;

import cwchoiit.querydsl.dto.MemberSearchCondition;
import cwchoiit.querydsl.dto.MemberTeamDto;
import cwchoiit.querydsl.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/v1/members")
    public List<MemberTeamDto> searchMembersV1(MemberSearchCondition condition) {
        return memberRepository.search(condition);
    }

    @GetMapping("/v2/members")
    public Page<MemberTeamDto> searchMembersV2(MemberSearchCondition condition, Pageable pageable) {
        return memberRepository.search(condition, pageable);
    }
}
  • searchMembersV2()를 보자. 페이징 처리가 가능한 REST API를 만들었다.
  • 우리가 위에서 만든 Querydsl을 사용해서 페이징 쿼리를 날려보자.
### GET Members
GET http://localhost:8080/v2/members?page=0&size=200
  • 의도적으로 한 페이지에 200개를 가져오게 해봤다. 위에서 말한대로, 카운트 쿼리를 날릴 필요가 없으면 카운트 쿼리를 날리지 않게 최적화했으니 실제로 그런지 확인해보자! 전체 데이터는 100개뿐인데 한 페이지에 200개를 뽑아오게 하면 들어오는 데이터는 100개라 전체 카운트 쿼리를 날리지 않을 것이다.

  • 카운트 쿼리는 예상대로 날라가지 않았다. 그럼 카운트 쿼리가 날라가도록 바꿔서 테스트도 해보자.
### GET Members
GET http://localhost:8080/v2/members?page=0&size=50

  • 카운트 쿼리가 날라가야 하는 경우 제대로 날리는 모습을 확인할 수 있다.

 

 

728x90
반응형
LIST

'Querydsl' 카테고리의 다른 글

참고: 리포지토리 지원 - QuerydslRepositorySupport  (0) 2024.12.30
Querydsl 중급 문법  (0) 2024.12.22
Querydsl 기본 문법  (0) 2024.12.21
[Renewal] QueryDsl  (0) 2024.12.07
Spring Boot 3.1.5에서 QueryDSL 설치하기  (0) 2023.11.26
728x90
반응형
SMALL

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

SELECT절에 어떤걸 가져올지를 정하는 것을 프로젝션이라고 한다. 프로젝션 대상이 하나라면 타입을 명확하게 지정할 수 있고 프로젝션 대상이 둘 이상이면 튜플이나 DTO로 조회할 수 있다.

 

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

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

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

    List<Member> members = queryFactory
            .select(member)
            .from(member)
            .fetch();

    for (Member member1 : members) {
        System.out.println("member1 = " + member1);
    }
}
  • SELECT절에 퍼올리는 데이터가 딱 하나인 경우를 프로젝션 대상이 하나인 경우라고 말하고, 위의 경우들이 그 예시이다.
  • 회원의 유저명만 딱 가져오는 경우 또는 회원 엔티티 자체를 딱 가져오는 경우를 프로젝션 대상이 하나인 경우라고 표현한다.

실행 결과

result = member1
result = member2
result = member3
result = member4

member1 = Member(id=1, username=member1, age=10)
member1 = Member(id=2, username=member2, age=20)
member1 = Member(id=3, username=member3, age=30)
member1 = Member(id=4, username=member4, age=40)

 

프로젝션 대상이 둘 이상인 경우 - 튜플 조회

@Test
void tupleProjection() {
    List<Tuple> results = queryFactory
            .select(member.username, member.age)
            .from(member)
            .fetch();

    for (Tuple tuple : results) {
        System.out.println(tuple.get(member.username));
        System.out.println(tuple.get(member.age));
    }
}
  • 위 코드와 같이 회원의 유저명과 나이 두 개를 가져오는 경우 동일한 타입이 아닌것부터 해서 하나의 타입으로 반환할 수가 없다. 이럴때 QuerydslTuple 타입으로 반환하게 된다.
  • 튜플 타입을 반환하고 데이터를 꺼낼때는 tuple.get(...)을 사용하면 된다.
  • 참고로, 그냥 이런게 있구나? 정도로 넘어가자. 왜냐하면 DTO로 반환하는 것을 잘 아는게 더 중요하고 잘 사용되기 때문이다.

실행 결과

member1
10
member2
20
member3
30
member4
40

 

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

거의 대부분의 경우, DTO로 반환하는 경우를 많이 사용한다. 그리고 이 DTO로 조회하는 것도 여러 방법이 있는데 하나씩 소개하고 어떤게 더 좋은지도 알아보자! 우선 DTO가 필요하다.

package cwchoiit.querydsl.dto;

import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class MemberDto {

    private String username;
    private int age;

    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}
  • 위와 같이 DTO를 만들었다. 이후에 Querydsl에서 DTO 조회를 할때 기본 생성자가 반드시 필요하기 때문에 기본 생성자를 대신 만들어주는 롬복의 @NoArgsConstructor도 사용했다.

 

순수 JPA에서 DTO 조회

먼저, 순수한 JPA를 사용한 DTO 조회 방법을 알아보자.

@Test
void findDtoByJPQL() {
    List<MemberDto> memberDtos = em.createQuery("SELECT new cwchoiit.querydsl.dto.MemberDto(m.username, m.age) FROM Member m", MemberDto.class)
            .getResultList();

    for (MemberDto memberDto : memberDtos) {
        System.out.println("memberDto = " + memberDto);
    }
}
  • 우선, 순수 JPA로 하려면, 위 코드처럼 new 키워드를 사용해서 패키지명부터 다 적어줘야 한다.
  • 조금 불편하고 지저분한 감은 지울수가 없다.

실행 결과

memberDto = MemberDto(username=member1, age=10)
memberDto = MemberDto(username=member2, age=20)
memberDto = MemberDto(username=member3, age=30)
memberDto = MemberDto(username=member4, age=40)

 

Querydsl에서 DTO 조회 - Bean 생성

여기가 중요하니까 글자도 큼지막하게 하고 볼드체도 빵빵하게 넣었다. 크게 3가지 방법이 있다.

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

1. 프로퍼티 접근 - Setter

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

    for (MemberDto result : results) {
        System.out.println("result = " + result);
    }
  • DTO로 조회하는 방법 중 Setter를 사용하려면, 위와 같이 Projections.bean(...)을 사용하면 된다. 처음 파라미터는 어떤 클래스인지를, 그 다음부터는 원하는 각 필드를 채우면 된다.

실행 결과

result = MemberDto(username=member1, age=10)
result = MemberDto(username=member2, age=20)
result = MemberDto(username=member3, age=30)
result = MemberDto(username=member4, age=40)

 

 

2. 필드 직접 접근

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

    for (MemberDto result : results) {
        System.out.println("result = " + result);
    }
}
  • 위와 다른 것은 Projections.fields(...)를 사용했다는 점이다. 이 방법은 필드에 직접적으로 값을 넣어준다. DTO 클래스에 필드들은 다 private으로 선언했지만? 알다시피 리플렉션은 private도 접근 가능하게 해버릴 수 있기 때문에 상관없다.

실행 결과

result = MemberDto(username=member1, age=10)
result = MemberDto(username=member2, age=20)
result = MemberDto(username=member3, age=30)
result = MemberDto(username=member4, age=40)

 

별칭이 다른 경우

그런데, 가끔 별칭이 다른 DTO가 있을 수 있다. 무슨 말이냐면, 다음과 같은 DTO가 있다고 해보자.

package cwchoiit.querydsl.dto;

import lombok.Data;
import lombok.NoArgsConstructor;

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

    public UserDto(String name, int age) {
        this.name = name;
        this.age = age;
    }
}
  • 이 경우엔 유저명이 `username`이 아니라 `name`이다. 
  • 이런식으로 컬럼명과 필드명이 다른 경우엔 값을 제대로 인식을 못한다. 그래서 이렇게 별칭이 다른 DTO를 사용하려면 다음과 같이 사용해야 한다.
@Test
void findDtoByFieldsAlias() {
    List<UserDto> results = queryFactory
            .select(Projections.fields(UserDto.class, member.username.as("name"), member.age))
            .from(member)
            .fetch();

    for (UserDto result : results) {
        System.out.println("result = " + result);
    }
}
  • 별칭이 다른 필드에 .as(...)를 사용해서 해당 별칭에 맞게 변경해줘야 한다.
  • 또 다른 예시는 서브 쿼리를 사용할때도 있다. 아래 코드를 보자.
@Test
void findDtoByFieldsAlias() {
    QMember memberSub = new QMember("memberSub");

    List<UserDto> results = 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 result : results) {
        System.out.println("result = " + result);
    }
}
  • 그러니까, DTO로 조회를 하긴 하는데, 나이에 해당하는 값은 서브쿼리를 통해 어떤 고정값을 넣고 싶은 경우다. 여기서 서브쿼리는 회원의 가장 높은 나이를 넣는 것이다. 
  • 이런 경우에는, ExpressionUtils.as(서브쿼리, "별칭") 이렇게 사용할 수 있다.
  • 여기서 ExpressionUtilscom.querydsl.core.types.ExpressionUtils이다.

 

 

3. 생성자 사용

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

    for (MemberDto result : results) {
        System.out.println("result = " + result);
    }
}
  • 이것도 다른 점은 Projections.constructor(...) 를 사용했다는 점이다. 내가 만들어둔 username, age를 받는 생성자를 그대로 사용한다고 보면 된다. 
  • 생성자의 파라미터 순서대로 필드들을 넣어야 한다. 
  • 생성자를 사용하는 경우에는 별칭이 달라도 상관없다. 어차피 생성자가 있냐 없냐가 중요하기 때문이다. 

실행 결과

result = MemberDto(username=member1, age=10)
result = MemberDto(username=member2, age=20)
result = MemberDto(username=member3, age=30)
result = MemberDto(username=member4, age=40)

 

 

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

이 방법은 이제 궁극의 방법인데, 장점이 매우 많지만 단점도 없지는 않다. 일단 한번 보자.

 

조회하려는 DTO의 생성자에 아래와 같이 해보자.

package cwchoiit.querydsl.dto;

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

@Data
@NoArgsConstructor
public class MemberDto {

    private String username;
    private int age;

    @QueryProjection
    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}
  • @QueryProjection 애노테이션을 사용해서, 원하는 생성자에 저 애노테이션을 붙여주면 Querydsl이 Q클래스를 만들때 이 DTO도 Q클래스를 만들어준다.

 

이러면 어떻게 사용할 수가 있냐? 다음 코드를 보자.

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

    for (MemberDto result : results) {
        System.out.println("result = " + result);
    }
}
  • 위 코드처럼 SELECT절에 QMemberDto를 그대로 넣어버릴 수 있다. 저 위에 있는 순수 JPA를 사용해야 할 땐 패키지명까지 다 작성해야 했는데 이건 그럴 필요가 없다. 
  • 또 다른 장점으로는 컴파일 단계에서 오류를 알려준다. 즉, 저 생성자에 들어갈 필드를 이미 컴파일러가 알고 있다는 말이다. 아래 스크린샷을 보자.

  • 이미 지금 저 QMemberDto는 인자는 2개가 필요하고 어떤 타입이 와야 하는지까지 다 알고 있다. 이게 정말 궁극의 장점이라고 볼 수 있다. 이전까지 알아봤던 DTO로 조회는 그렇지가 않다. 순수 JPA를 사용해서 DTO로 조회하는 것을 제외하고는 위에서 봤던 세터를 사용한 방법, 필드에 직접 접근, 생성자를 사용하는 방법 모두 컴파일러는 뭐가 들어올지 모른다. 그래서 이것이 정말 큰 장점이라고 할 수 있다.

 

그러나, 단점도 없지는 않다. 어떤 단점이 있냐? 

  • DTO까지 Q클래스를 만들어야 한다는 것 자체가 단점이다.
  • DTO는 순수한 자바 코드가 아니라 Querydsl을 의존한다.

두번째 말은 이런 것이다. 

package cwchoiit.querydsl.dto;

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

@Data
@NoArgsConstructor
public class MemberDto {

    private String username;
    private int age;

    @QueryProjection
    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}
  • 지금 이 MemberDto는 이제 Querydsl을 의존하고 있다. import 부분을 잘 보면 무슨 말인지 알 수 있다.
  • 이것 자체가 큰 단점이다. 구조적인 부분에서 말이다.
  • 만약, Querydsl을 사용하지 않게 기술을 바꾼다면, 이 부분이 영향을 받게 될 것이다.

 

선택하자

그래서 여기서부터는 선택이다. 실용적 관점에서 그냥 @QueryProjection을 사용하던가, 순수한 자바 코드를 최대한 유지하고 싶으면 @QueryProjection을 사용하지 않고, 위에서 봤던 Projections.bean(...) 이나 Projections.fields(...), Projections.constructor(...)를 사용하면 된다. 정답은 없다. 프로젝트마다, 구성원마다 차이가 있을 것이고 그에 맞게 유동적으로 맞춰가면 된다.

 

 

동적 쿼리 - BooleanBuilder 사용

동적 쿼리를 해결하는 두가지 방식이 있는데 그 중 하나인 BooleanBuilder를 사용해보자.

@Test
void dynamicQuery_booleanBuilder() {
    String usernameParam = "member1";
    Integer ageParam = 10;

    List<Member> results = searchMember1(usernameParam, ageParam);
    assertThat(results).hasSize(1);
}

private List<Member> searchMember1(String usernameParam, Integer ageParam) {

    BooleanBuilder builder = new BooleanBuilder();

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

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

    return queryFactory
            .selectFrom(member)
            .where(builder)
            .fetch();
}
  • 위 코드가 BooleanBuilder를 사용하는 방법이다. 딱히 별 게 없다. 약간 JDBC 기술을 사용해서 동적 쿼리를 작성하는 것과 느낌이 비슷하긴 한데 훨씬 깔끔하다. 문자열이 없기 때문에.

 

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

@Test
void dynamicQuery_whereParam() {
    String usernameParam = "member1";
    Integer ageParam = 10;

    List<Member> results = searchMember2(usernameParam, ageParam);
    assertThat(results).hasSize(1);
}

private List<Member> searchMember2(String usernameParam, Integer ageParam) {
    return queryFactory
            .selectFrom(member)
            .where(usernameEq(usernameParam), ageEq(ageParam))
            .fetch();
}

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

private BooleanExpression usernameEq(String usernameParam) {
    return usernameParam == null ? null : member.username.eq(usernameParam);
}
  • 이게 이제 WHERE 다중 파라미터 사용 방법이다. 훨씬 더 깔끔하다.
  • 왜냐하면, 쿼리 자체의 가독성이 높아지기 때문이다. ageEq, usernameEq를 보지말고, 딱 쿼리자체만 보면 굳이 저 메서드를 추적하지 않아도 WHERE절에 usernameEq(...), ageEq(...)만 보더라도 어떤 조건인지 예측이 가능해진다.
  • 저렇게 WHERE절에 다중 파라미터로 만들면 파라미터에 null이 들어오는 경우 그냥 무시를 해버리기 때문에 동적 쿼리를 매우 깔끔하고 간단하게 만들 수 있다. 예를 들어, ageEq(...)null을 반환하면 WHERE절안에 null은 무시가 된다.
  • 그리고 또 장점은 재사용이 가능하다는 점이다. 분명히 회원의 유저명이 같은 조건같은 건 여기저기 사용될 가능성이 높다. 이게 메서드로 만든 이상 여기저기 가져다가 사용이 가능해진다.
  • 또 한가지 장점은  조합이 가능하다는 점이다. 이것도 엄청 강력한 장점인데 아래 코드를 보자.
private BooleanExpression ageAndUsernameEq(Integer ageParam, String usernameParam) {
    return ageEq(ageParam).and(usernameEq(usernameParam));
}
  • 저 두 메서드(조건) ageEq(...), usernameEq(...)는 메서드이고 타입이 BooleanExpression이기 때문에 두 개를 AND 또는 OR로 조합해서도 사용이 가능하다. 기가맥히다.
  • 물론, 이렇게 조합하는 경우, 앞 부분의 null 체크는 조심해야 한다. 왜냐하면 ageEq(...)null인 경우엔 이렇게 될 것 아닌가?null.and(...) 이러면 NullPointerException이기 때문에 이 부분만 조심하면 된다. 뭐 아래와 같이 작성하면 되겠지.
private BooleanExpression ageAndUsernameEq(Integer ageParam, String usernameParam) {
    if (ageParam == null) {
        return usernameEq(usernameParam);
    }
    return ageEq(ageParam).and(usernameEq(usernameParam));
}

 

나는 이 WHERE절에 다중 파라미터 방식을 너무나 선호한다.

 

수정, 삭제 벌크 연산

쿼리 한번으로 대량 데이터에 쓰기 작업을 하는 것을 말한다.

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

    assertThat(count).isEqualTo(2);
}
  • 쿼리 자체는 새로운 게 전혀 없다.
  • 그렇지만, 벌크 연산은 가장 중요한 부분이 벌크 연산을 하고 나면 반드시 영속성 컨텍스트를 flush(), clear() 해주는 게 그냥 무조건 좋다. 왜 그러냐면, 벌크 연산은 영속성 컨텍스트를 무시하고 바로 데이터베이스에 쿼리를 날리기 때문에 영향받는 레코드가 이미 영속성 컨텍스트에서 관리되고 있다면 이 관리되는 데이터는 벌크 연산을 날린 데이터랑 불일치가 일어난다. 그래서 원치 않는 결과를 마주할 수도 있으니 항상 벌크 연산은 flush(), clear()를 해준다고 그냥 머리에 박아두자.
@Test
void bulkUpdate() {
    long count = queryFactory
            .update(member)
            .set(member.username, "비회원")
            .where(member.age.lt(28))
            .execute();

    assertThat(count).isEqualTo(2);

    em.flush();
    em.clear();
}
  • 이런식으로 말이다.

 

그리고, 벌크 연산으로 자주 사용되는 특정 컬럼에 모두 같은 값을 더하거나 곱하는 경우도 보자.

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

    assertThat(count).isEqualTo(4);

    em.flush();
    em.clear();

    List<Member> members = queryFactory
            .selectFrom(member)
            .fetch();

    for (Member findMember : members) {
        System.out.println("findMember = " + findMember);
    }
}

@Test
void bulkMultiply() {
    long count = queryFactory
            .update(member)
            .set(member.age, member.age.multiply(2))
            .execute();

    assertThat(count).isEqualTo(4);

    em.flush();
    em.clear();

    List<Member> members = queryFactory
            .selectFrom(member)
            .fetch();

    for (Member findMember : members) {
        System.out.println("findMember = " + findMember);
    }
}
  • 간단하게 나이에 1을 모두 더하거나, 나이에 2를 모두 곱하거나에 대한 쿼리이다.

 

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

@Test
void bulkDelete() {
    long count = queryFactory
            .delete(member)
            .where(member.age.lt(28))
            .execute();

    assertThat(count).isEqualTo(2);

    em.flush();
    em.clear();

    List<Member> members = queryFactory.selectFrom(member).fetch();

    for (Member findMember : members) {
        System.out.println("findMember = " + findMember);
    }
}
  • 이번엔 삭제를 벌크로 하는 방법에 대한 쿼리이다.

 

SQL Function 호출

@Test
void sqlFunction() {
    // member.username 에서 'member' 라는 단어를 'M' 으로 변경
    List<String> results = queryFactory
            .select(Expressions.stringTemplate(
                    "function('replace', {0}, {1}, {2})", member.username, "member", "M"))
            .from(member)
            .fetch();

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

실행 결과

2024-12-24T22:29:10.394+09:00 DEBUG 39987 --- [    Test worker] org.hibernate.SQL                        : 
select
    replace(m1_0.username, ?, ?) 
from
    member m1_0
    
result = M1
result = M2
result = M3
result = M4

 

 

이번에는 소문자로 변경하는 함수를 사용하는 쿼리를 보자.

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

    List<String> results = queryFactory
            .select(member.username)
            .from(member)
            .where(member.username.eq(member.username.lower()))
            .fetch();

    for (String result : results) {
        System.out.println("result = " + result);
    }
}
  • 주석 처리한 부분을 먼저보자. 이번에는 WHERE절에 함수를 사용했다. 간단하게 소문자로 변경하는 함수인데, 왜 주석처리했냐? 저런 ANSI 표준 함수들은 Querydsl이 상당 부분 다 내장하고 있어서 저렇게 어렵게 작성할 필요없이 그 바로 아래에 주석 처리하지 않은 부분처럼 할 수 있다.

 

728x90
반응형
LIST

'Querydsl' 카테고리의 다른 글

참고: 리포지토리 지원 - QuerydslRepositorySupport  (0) 2024.12.30
JPA 레포지토리와 Querydsl  (2) 2024.12.26
Querydsl 기본 문법  (0) 2024.12.21
[Renewal] QueryDsl  (0) 2024.12.07
Spring Boot 3.1.5에서 QueryDSL 설치하기  (0) 2023.11.26
728x90
반응형
SMALL

이전 포스팅에서 Querydsl 세팅하는 방법과 왜 Querydsl을 사용해야 하는지를 이야기했다. 이제 천천히 하나씩 Querydsl을 사용해보면서 이게 얼마나 막강한 녀석인지 직접 체감해보자.

 

우선, 엔티티를 정의해야 한다. 참고로, 나는 JPAQuerydsl을 같이 사용한다. 그래서 JPA로 엔티티를 만들어내고 Querydsl을 곁들인다.

 

엔티티 정의

Member

package cwchoiit.querydsl.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String username;
    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    public Member(String username) {
        this(username, 0, null);
    }

    public Member(String username, int age) {
        this(username, age, null);
    }

    public Member(String username, int age, Team team) {
        this.username = username;
        this.age = age;
        if (team != null) {
            changeTeam(team);
        }
    }

    private void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}

 

Team

package cwchoiit.querydsl.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class Team {

    @Id
    @GeneratedValue
    @Column(name = "team_id")
    private Long id;

    private String name;

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "team")
    private final List<Member> members = new ArrayList<>();

    public Team(String name) {
        this.name = name;
    }
}
  • 간단하게 Team - Member 엔티티를 만들었다.
  • 당연히, 팀과 멤버는 1:N 관계이고 여기서는 다대일 양방향 연관관계로 만들었다.

 

Querydsl 맛보기

이제 테스트 코드로 간단한 Querydsl 코드를 작성하자.

QuerydslBasicTest

package cwchoiit.querydsl;

import com.querydsl.jpa.impl.JPAQueryFactory;
import cwchoiit.querydsl.entity.Member;
import cwchoiit.querydsl.entity.Team;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import static cwchoiit.querydsl.entity.QMember.member;
import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@Transactional
public class QuerydslBasicTest {

    @Autowired
    EntityManager em;

    JPAQueryFactory queryFactory;

    @BeforeEach
    public void setUp() {
        queryFactory = new JPAQueryFactory(em);
        Team teamA = new Team("teamA");
        Team teamB = new Team("teamB");
        em.persist(teamA);
        em.persist(teamB);

        Member member1 = new Member("member1", 10, teamA);
        Member member2 = new Member("member2", 20, teamA);
        Member member3 = new Member("member3", 30, teamB);
        Member member4 = new Member("member4", 40, teamB);
        em.persist(member1);
        em.persist(member2);
        em.persist(member3);
        em.persist(member4);
    }

    @Test
    void querydsl() {
        Member findMember = queryFactory
                .select(member)
                .from(member)
                .where(member.username.eq("member1"))
                .fetchOne();

        assertThat(findMember).isNotNull();
        assertThat(findMember.getUsername()).isEqualTo("member1");
        assertThat(findMember.getAge()).isEqualTo(10);
    }
}
  • 우선, Querydsl을 사용하려면 JPAQueryFactory가 필요하다. 이 친구는 필드 레벨에 선언하는 게 가장 좋다. 어차피 멀티스레드 환경에서도 안전하게 동작하게 설계됐기 때문에 필드 레벨에 선언해도 아무런 문제도 없다.
  • 그리고 여기서는 각 테스트 별로 데이터가 준비될 수 있게 @BeforeEach 애노테이션으로 테스트 데이터를 만들어 낸다.
  • JPAQueryFactoryEntityManager가 필요하다. 그래서, 생성자로 전달하는 모습을 확인할 수 있다.
  • 실제로 Querydsl을 사용하는 코드를 중점적으로 봐보자.
@Test
void querydsl() {
    Member findMember = queryFactory
            .select(member)
            .from(member)
            .where(member.username.eq("member1"))
            .fetchOne();

    assertThat(findMember).isNotNull();
    assertThat(findMember.getUsername()).isEqualTo("member1");
    assertThat(findMember.getAge()).isEqualTo(10);
}
  • QMemberstatic-import를 하면 그 클래스 안에 필드로 선언된 `member`를 위 코드처럼 간단하고 명료하게 사용할 수 있다.
  • 그리고 코드를 보면, 자바 코드인데도 불구하고 SQL처럼 보여지는 이 가시성이 정말 막강한 장점이다. 동적 쿼리를 작성할때도 정말 강력한 것이 where(...)안에 필요한 조건문을 아주 간결하게 작성할 수 있다. 이후에 저 부분은 더 멋지게 변경될 것이다.

 

검색 조건 쿼리

아래 코드를 보자.

@Test
void search() {
    /*Member findMember = queryFactory
            .selectFrom(member)
            .where(member.username.eq("member1").and(member.age.eq(10)))
            .fetchOne();*/

    Member findMember = queryFactory
            .selectFrom(member)
            .where(member.username.eq("member1"), member.age.eq(10))
            .fetchOne();

    assertThat(findMember).isNotNull();
    assertThat(findMember.getUsername()).isEqualTo("member1");
}
  • 주석 처리한 것과 주석 처리하지 않은 두 쿼리가 완전히 동일한 쿼리이다. and(...) 으로 메서드 체인형식으로 사용할 수도 있고, and의 경우에는 저렇게 (,)로 연결해도 동일한 결과이다.
  • 참고로, and가 있으면 당연히 or도 있다.
  • 그러 뭐가 더 좋냐? 뭐 사람마다 다르겠지만, 개인적으로는 (,) 방식을 더 선호한다. 근데 이건 뭐가 됐든 상관없다.

 

결과 조회

@Test
void resultFetch() {
    List<Member> members = queryFactory.selectFrom(member).fetch();

    Member findMember = queryFactory.selectFrom(member).fetchOne();

    // 아래와 동일한 코드 queryFactory.selectFrom(member).limit(1).fetchOne();
    Member findMemberFirst = queryFactory.selectFrom(member).fetchFirst();
}
  • fetch() → 리스트 조회, 데이터 없으면 빈 리스트 반환
  • fetchOne() → 단건 조회, 결과가 없으면 null, 결과가 둘 이상이면 NonUniqueResultException 발생
  • fetchFirst()limit(1).fetchOne()과 동일한 편의 메서드

 

정렬

/**
 * 회원 정렬
 * 회원 나이 내림차순
 * 회원 이름 올림차순
 * 단 2에서 회원 이름이 null 이면 마지막에 출력
 */
@Test
void sort() {
    Member memberNull = new Member(null, 100);
    Member member5 = new Member("member5", 100);
    Member member6 = new Member("member6", 100);
    em.persist(memberNull);
    em.persist(member5);
    em.persist(member6);

    List<Member> members = queryFactory
            .selectFrom(member)
            .where(member.age.eq(100))
            .orderBy(member.age.desc(), member.username.asc().nullsLast())
            .fetch();

    assertThat(members).isNotNull();
    assertThat(members.getFirst().getUsername()).isEqualTo("member5");
    assertThat(members.get(1).getUsername()).isEqualTo("member6");
    assertThat(members.get(2).getUsername()).isNull();
}
  • 정렬도 Querydsl을 사용하면 여려 정렬을 한번에 할 수 있으며, 해당값이 null인 경우, 마지막에 위치시킬지 맨 처음에 위치시킬지를 정할 수 있다.
  • 마지막에 위치시키는 건 nullsLast(), 맨 처음에 위치시키는 건 nullsFirst()를 사용하면 된다.
  • 여기서는 nullsLast()를 사용했다.

 

페이징

@Test
void paging1() {
    List<Member> members = queryFactory
            .selectFrom(member)
            .orderBy(member.username.desc())
            .offset(1)
            .limit(2)
            .fetch();

    assertThat(members).isNotNull();
    assertThat(members.size()).isEqualTo(2);
    assertThat(members.getFirst().getUsername()).isEqualTo("member3");
    assertThat(members.get(1).getUsername()).isEqualTo("member2");
}

@Test
void paging2() {
    List<Member> members = queryFactory
            .selectFrom(member)
            .orderBy(member.username.desc())
            .offset(1)
            .limit(2)
            .fetch();

    int totalCount = queryFactory
            .selectFrom(member)
            .fetch()
            .size();

    assertThat(members).isNotNull();
    assertThat(totalCount).isEqualTo(4);
    assertThat(members.size()).isEqualTo(2);
    assertThat(members.getFirst().getUsername()).isEqualTo("member3");
    assertThat(members.get(1).getUsername()).isEqualTo("member2");
}
  • 페이징은 간단하게, offset(), limit()을 사용하면 된다. 
  • 그리고 전체 수를 가져오는 건 별도의 쿼리로 작성해줘야 한다. 예전에는 fetchResults()라는 것을 사용해서 전체 개수를 가져올 수 있었는데 그 메서드는 Deprecated 됐고, 애시당초에 그 메서드도 내부에서 전체 카운트를 가져오는 쿼리를 또 날리는 것 밖에 안된다.
  • 그래서 전체 개수를 가져오는 쿼리는 별도로 작성해서 위 paging2()처럼 가져온다. 보면 알겠지만, 전체 개수를 가져오는 쿼리는 굉장히 간단하고 실제 쿼리랑은 다르다. 지금이야 orderBy() 정도만 있고 없고의 차이지만 어떤것은 그 이상으로 전체 개수를 가져오는 쿼리가 최적화되기 때문에 이 부분은 직접 구현하도록 바뀌었다.

 

집합

집합 함수를 의미하고, SUM, COUNT, AVG, MAX, MIN, GROUP BY를 사용한다.

@Test
void aggregation() {
    List<Tuple> results = queryFactory
            .select(
                    member.count(),
                    member.age.sum(),
                    member.age.avg(),
                    member.age.min(),
                    member.age.max()
            )
            .from(member)
            .fetch();

    Tuple tuple = results.getFirst();

    assertThat(tuple.get(member.count())).isEqualTo(4);
    assertThat(tuple.get(member.age.sum())).isEqualTo(100);
    assertThat(tuple.get(member.age.avg())).isEqualTo(25);
    assertThat(tuple.get(member.age.min())).isEqualTo(10);
    assertThat(tuple.get(member.age.max())).isEqualTo(40);
}
  • COUNT, SUM, AVG, MIN, MAX도 굉장히 간단하게 그저 메서드를 호출하는 것으로 구현해낼 수 있다.
  • 그리고 타입이 가지각색이거나, 어떤 값으로 딱 추출을 해내는 경우엔 객체 타입이 아니라 Tuple 이라는 타입으로 반환받게 되는데 이 Tuple을 통해 값을 가져오는 건 그냥 get(...)으로 내가 원하는 값을 뽑아오면 된다. 
  • 참고로, 실무에서는 Tuple 타입으로 받아오는 것보다 DTO로 변환해서 가져오는 방법이 훨씬 더 많이 사용된다. 이후에 같이 알아보자.

GROUP BY도 간단하게 사용할 수 있다. 아래 코드를 보자.

/**
 * 팀의 이름과 각 팀의 평균 연령을 구해라.
 */
@Test
void group() {
    List<Tuple> results = queryFactory
            .select(team.name, member.age.avg())
            .from(member)
            .join(member.team, team)
            .groupBy(team.name)
            .fetch();

    Tuple teamA = results.getFirst();
    Tuple teamB = results.get(1);

    assertThat(teamA.get(team.name)).isEqualTo("teamA");
    assertThat(teamA.get(member.age.avg())).isEqualTo(15);

    assertThat(teamB.get(team.name)).isEqualTo("teamB");
    assertThat(teamB.get(member.age.avg())).isEqualTo(35);
}

/**
 * 팀의 이름과 각 팀의 평균 연령을 구하고 팀의 이름이 teamA 인것만 가져와라.
 */
@Test
void groupByHaving() {
    List<Tuple> results = queryFactory
            .select(team.name, member.age.avg())
            .from(member)
            .join(member.team, team)
            .groupBy(team.name)
            .having(team.name.eq("teamA"))
            .fetch();

    Tuple teamA = results.getFirst();

    assertThat(results.size()).isEqualTo(1);
    assertThat(teamA.get(team.name)).isEqualTo("teamA");
    assertThat(teamA.get(member.age.avg())).isEqualTo(15);
}
  • GROUP BY, HAVING을 사용한 코드이다. 참고로 HAVINGGROUP BY로 그룹화 된 결과를 제한하는 것이다.

 

조인

INNER

@Test
void join() {
    List<Member> members = queryFactory
            .selectFrom(member)
            .join(member.team, team)
            .where(team.name.eq("teamA"))
            .fetch();

    assertThat(members).isNotNull();
    assertThat(members.size()).isEqualTo(2);

    assertThat(members)
            .extracting(Member::getUsername)
            .containsExactly("member1", "member2");
}
  • 가장 기본이 되는 조인인 INNER 조인하는 방법이다. ON절은 어디있나요?에 대한 대답은 기본으로 ON절을 넣어준다. 이 테스트 코드를 실행했을 때 나가는 쿼리를 보면 바로 이해가 될 것이다.
  • 아 그리고 innerJoin() 메서드도 있는데 이게 join()과 동일하다.
2024-12-22T13:19:06.677+09:00 DEBUG 58042 --- [    Test worker] org.hibernate.SQL                        : 
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username 
from
    member m1_0 
join
    team t1_0 
        on t1_0.team_id=m1_0.team_id 
where
    t1_0.name=?

 

[LEFT|RIGHT] OUTER

@Test
void join() {
    List<Member> members = queryFactory
            .selectFrom(member)
            .leftJoin(member.team, team)
            .where(team.name.eq("teamA"))
            .fetch();

    /*List<Member> members = queryFactory
            .selectFrom(member)
            .rightJoin(member.team, team)
            .where(team.name.eq("teamA"))
            .fetch();*/

    assertThat(members).isNotNull();
    assertThat(members.size()).isEqualTo(2);

    assertThat(members)
            .extracting(Member::getUsername)
            .containsExactly("member1", "member2");
}
  • 외부 조인 역시 가능하다. leftJoin(), rightJoin()이 있다.

 

THETA

"막 조인"이라고 하는 세타 조인도 역시 가능하다. 

/**
 * 회원이 이름이 팀 이름과 같은 회원 조회
 */
@Test
void thetaJoin() {
    em.persist(new Member("teamA"));
    em.persist(new Member("teamB"));
    em.persist(new Member("teamC"));

    List<Member> members = queryFactory
            .select(member)
            .from(member, team)
            .where(member.username.eq(team.name))
            .fetch();

    assertThat(members).isNotNull();
    assertThat(members.size()).isEqualTo(2);
    assertThat(members).extracting(Member::getUsername).containsExactly("teamA", "teamB");
}
  • 억지스러운 예제이긴 하지만, 회원의 이름이 "teamA", "teamB", "teamC"라고 만들고 이 회원 이름과 팀 이름이 같은 회원들을 한번 조회해보고 싶은것이다. 원래 세타 조인이 이렇게 막 조인이다.
  • 세타 조인을 할땐 FROM절에 원하는 엔티티를 여러개 넣으면 된다.

 

ON

ON절을 활용한 조인은 다음 두가지 케이스에서 사용된다.

  • 조인 대상 필터링
  • 연관관계 없는 엔티티의 외부 조인

조인 대상 필터링
지금까지 join()을 사용하면서 on()을 사용하지 않으면 그냥 기본으로 조인 대상의 ID가 같은 것들을 넣어주곤 했다. 

이 경우에 더해서, 조인 대상을 필터링하고 싶을 때 추가적으로 ON절을 사용할 수가 있다.

/**
 * 회원과 팀을 조인하면서, 팀 이름이 teamA인 팀만 조인, 회원은 모두 조회
 */
@Test
void on_filtering() {
    List<Tuple> results = queryFactory
            .select(member, team)
            .from(member)
            .leftJoin(member.team, team)
            .on(team.name.eq("teamA"))
            .fetch();

    for (Tuple result : results) {
        System.out.println("result = " + result);
    }
}
  • 위 코드처럼 LEFT JOIN을 한다고 가정해보자. LEFT JOIN은 왼쪽 엔티티를 기준으로 오른쪽에 조인 대상이 없어도 결과로 출력이 된다. 그런데 그 조인 대상을 필터링을 하고 싶은 것이다. MEMBER.TEAM.ID = TEAM.ID 뿐 아니라, TEAM의 이름이 "teamA"인 애들만 조인하고 싶은 것이다.
  • 이렇게 되면 결과는 어떻게 될까? TEAM의 이름이 "teamA"인 녀석들은 조인 결과에서 팀까지 같이 출력이 되고, "teamA"가 아닌 녀석들은 조인 결과에서 팀은 빠지고 멤버만 남을 것이다. LEFT JOIN이니까.

실행 결과

result = [Member(id=1, username=member1, age=10), Team(id=1, name=teamA, members=[Member(id=1, username=member1, age=10), Member(id=2, username=member2, age=20)])]
result = [Member(id=2, username=member2, age=20), Team(id=1, name=teamA, members=[Member(id=1, username=member1, age=10), Member(id=2, username=member2, age=20)])]
result = [Member(id=3, username=member3, age=30), null]
result = [Member(id=4, username=member4, age=40), null]

 

나가는 쿼리

2024-12-22T13:36:36.154+09:00 DEBUG 58927 --- [    Test worker] org.hibernate.SQL                        : 
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username,
    t1_0.team_id,
    t1_0.name 
from
    member m1_0 
left join
    team t1_0 
        on t1_0.team_id=m1_0.team_id 
        and t1_0.name=?

 

그런데 만약, 외부 조인이 아니라 내부 조인을 사용하면 ON절을 사용하는 것 말고 WHERE를 사용해도 완전히 동일한 결과를 얻을 것이다. 왜냐하면, 내부 조인은 애시당초에 조인 결과에서 대상이 없는 녀석들은 제외시키기 때문에 조인 대상이 있는 녀석들만 결과로 출력될 것이고 거기서 팀 이름이 "teamA"인 녀석들만 간추리려면 그냥 WHERE절 사용하면 된다. 그러니까, 내부 조인인데 굳이 ON절로 안 익숙한 것을 사용하지 말고 내부조인인 경우에는 WHERE가 더 익숙하니 이걸 사용하면 좋다는 말이다.

 

연관관계 없는 엔티티 외부 조인

/**
 * 연관 관계가 없는 엔티티 외부 조인
 * 회원의 이름이 팀 이름과 같은 대상 외부 조인
 */
@Test
void thetaJoin_on() {
    em.persist(new Member("teamA"));
    em.persist(new Member("teamB"));
    em.persist(new Member("teamC"));

    List<Tuple> results = queryFactory
            .select(member, team)
            .from(member)
            .leftJoin(team)
            .on(member.username.eq(team.name))
            .fetch();

    for (Tuple result : results) {
        System.out.println("result = " + result);
    }
}
  • 세타 조인은 아닌데, 외부 조인을 하고 싶은데 외부 조인을 할 때 연관관계가 없는 엔티티와 외부 조인을 하려고 할 때 이 ON을 사용할수가 있다. 
  • 위 코드를 보면, 지금 leftJoin()을 사용하는데 매우 주의 깊게 봐야한다! 원래는 leftJoin(member.team, team)이렇게 사용하곤 했는데 여기서는 leftJoin(team)을 사용한다. 즉, FROM절의 member와 아무런 연관이 없는 그냥 팀을 다 가져오는데 조인 조건으로 ON을 사용해서 회원이 이름이 팀의 이름과 동일한 조건을 부여했다.
  • 세타 조인을 할 땐 FROM절에 세타 조인하고 싶은 엔티티들을 여러개 넣었는데 이건 그게 아니다. 즉, 외부 조인인데 연관관계가 없는 녀석들과 외부 조인을 하려고 하는 것이다.

실행 결과

result = [Member(id=1, username=member1, age=10), null]
result = [Member(id=2, username=member2, age=20), null]
result = [Member(id=3, username=member3, age=30), null]
result = [Member(id=4, username=member4, age=40), null]
result = [Member(id=5, username=teamA, age=0), Team(id=1, name=teamA, members=[Member(id=1, username=member1, age=10), Member(id=2, username=member2, age=20)])]
result = [Member(id=6, username=teamB, age=0), Team(id=2, name=teamB, members=[Member(id=3, username=member3, age=30), Member(id=4, username=member4, age=40)])]
result = [Member(id=7, username=teamC, age=0), null]
  • 외부 조인이니까 조인 대상이 없어도 결과로 출력된다. 단지, 조인 대상이 없는 것들은 null로 표시될 뿐.

나가는 쿼리

2024-12-22T13:58:35.827+09:00 DEBUG 60017 --- [    Test worker] org.hibernate.SQL                        : 
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username,
    t1_0.team_id,
    t1_0.name 
from
    member m1_0 
left join
    team t1_0 
        on m1_0.username=t1_0.name
  • ON절에 외래키와 PK가 같은 것들을 고려하지 않고 있다. 외부 조인인데 연관관계가 없음을 의미한다.
  • 세타 조인은 FROM절에 여러개가 들어가는 것이다.
  • 물론 외부 조인 말고 내부 조인도 가능하다. 근데 내부조인은 조인 대상이 없으면 결과로 나오지 않기 때문에 그냥 세타 조인을 사용하고 WHERE로 필터링한 결과랑 동일하니까 그 방법을 사용하면 된다.

 

페치 조인

페치 조인은 SQL에서 제공하는 기능이 아니고, SQL 조인을 활용하고 연관된 엔티티를 SQL 한번에 조회하는 기능이다. JPA에서 제공하는 기능으로써 주로 성능 최적화에 사용하는 방법이다. 

@Test
void fetchJoin() {

    Member findMember = queryFactory
            .selectFrom(member)
            .join(member.team, team)
            .fetchJoin()
            .where(member.username.eq("member1"))
            .fetchOne();

    assertThat(findMember).isNotNull();
    assertThat(findMember.getUsername()).isEqualTo("member1");
    assertThat(findMember.getTeam().getName()).isEqualTo("teamA");
}
  • Querydsl을 사용해서 페치 조인을 하는 방법은 너무나 간단하다. join() 이후에 fetchJoin()만 붙여주면 끝난다.
  • leftJoin()이든 뭐든 상관없다.

 

서브 쿼리

당연하게도 서브 쿼리 역시 지원한다. 하나씩 천천히 보자. QueryDsl을 사용할때 서브 쿼리를 사용하려면 com.querydsl.jpa.JPAExpressions를 사용해야 한다. 

/**
 * SubQuery - 나이가 가장 많은 회원 조회 (EQ)
 */
@Test
void subQuery() {

    QMember memberSub = new QMember("memberSub");

    List<Member> members = queryFactory
            .selectFrom(member)
            .where(member.age.eq(
                    JPAExpressions
                            .select(memberSub.age.max())
                            .from(memberSub)
            ))
            .fetch();

    assertThat(members).extracting(Member::getAge).containsExactly(40);
}
  • WHERE절에 서브쿼리를 사용한 모습이다. 쿼리 자체는 뭐 별게 없는데 이렇게 사용하면 된다는 것을 보여주기 위해 작성했다.
  • 당연하지만, 서브 쿼리에서 엔티티에 대한 Alias는 다른 녀석을 사용해야 한다. SQL과 똑같이 생각하면 된다. 그래서 이 경우에는 Q타입 객체를 새로 생성해야만 한다.
  • 참고로, 저 JPAExpressionsstatic-import를 하면 좀 더 깔끔하다. 아래와 같이 말이다.
/**
 * SubQuery - 나이가 동일한 회원 (EQ)
 */
@Test
void subQuery() {

    QMember memberSub = new QMember("memberSub");

    List<Member> members = queryFactory
            .selectFrom(member)
            .where(member.age.eq(
                    select(memberSub.age.max())
                    .from(memberSub)
            ))
            .fetch();

    assertThat(members).extracting(Member::getAge).containsExactly(40);
}

 

EQ말고, GOE, INSELECT절에서도 사용하는 예제들도 있다.

/**
 * SubQuery - 나이가 평균 이상인 회원 (GOE)
 */
@Test
void suqQuery2() {
    QMember memberSub = new QMember("memberSub");

    List<Member> members = queryFactory
            .selectFrom(member)
            .where(member.age.goe(
                    select(memberSub.age.avg())
                            .from(memberSub)
            ))
            .fetch();

    assertThat(members).extracting(Member::getAge).containsExactly(30, 40);
}

/**
 * SubQuery - IN절 사용
 */
@Test
void subQuery3() {
    QMember memberSub = new QMember("memberSub");

    List<Member> members = queryFactory
            .selectFrom(member)
            .where(member.age.in(
                    select(memberSub.age)
                            .from(memberSub)
                            .where(memberSub.age.gt(10))
            ))
            .fetch();

    assertThat(members).extracting(Member::getAge).containsExactly(20, 30, 40);
}

/**
 * SubQuery - SELECT절
 */
@Test
void subQuery4() {
    QMember memberSub = new QMember("memberSub");

    List<Tuple> results = queryFactory
            .select(
                    member.username,
                    select(memberSub.age.avg())
                            .from(memberSub))
            .from(member)
            .fetch();

    for (Tuple result : results) {
        System.out.println("result = " + result);
    }
}

 

FROM절에서 서브쿼리는 불가능하다. 이유는 JPQLFROM절의 서브쿼리를 지원하지 않기 때문. 그리고 사실 FROM 절의 서브쿼리를 꼭 사용해야 할 필요가 없다. 거의 대부분은 JOIN으로 해결이 가능하다. 물론 모든게 다 가능하지는 않다. 그러나 간과하면 안될 것이 있다. 이게 데이터베이스에서 데이터를 가져오는 건 정말 데이터를 가져오는 것이지 데이터를 정제하고 분리하는 건 데이터베이스 레벨에서만 해야하는 게 아니다. 애플리케이션 레벨에서 충분히 가능하다. 만약 애플리케이션 레벨에서 죽어도 하기 싫다면 그냥 네이티브쿼리를 날리면 된다.

 

Case

@Test
void basicCase() {
    List<String> results = queryFactory
            .select(member.age
                    .when(10).then("열살")
                    .when(20).then("스무살")
                    .otherwise("기타")
            )
            .from(member)
            .fetch();

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

@Test
void complexCase() {
    List<String> results = queryFactory
            .select(new CaseBuilder()
                    .when(member.age.between(0, 20)).then("0-20살")
                    .when(member.age.between(21, 30)).then("21-30살")
                    .otherwise("기타")
            )
            .from(member)
            .fetch();

    for (String result : results) {
        System.out.println("result = " + result);
    }
}
  • 아주 단순한 경우에는 그냥 바로 when().then()을 사용하면 된다. 그러나 조금 더 복잡한 경우에는 CaseBuilder()를 사용할 수도 있다.

나는 개인적으로 Case문을 좋아하지 않는다. 가끔 효율적일 때가 있겠지만, 100% 데이터를 퍼올려서 애플리케이션 레벨에서 이 과정을 대체할 수 있기 때문이다. 데이터베이스에서 굳이 이 계산을 할 필요가 없다고 생각한다. 

 

상수, 문자 더하기

이 경우는 꽤나 자주 사용된다.

@Test
void constant() {
    List<Tuple> results = queryFactory
            .select(member.username, Expressions.constant("A"))
            .from(member)
            .fetch();

    for (Tuple result : results) {
        System.out.println("result = " + result);
    }
}

@Test
void concat() {
    // {username}_{age}
    List<String> results = queryFactory
            .select(member.username.concat("_").concat(member.age.stringValue()))
            .from(member)
            .where(member.username.eq("member1"))
            .fetch();

    for (String result : results) {
        System.out.println("result = " + result);
    }
}
  • 상수가 필요하면, Expressions.constant("xxx") 이와 같이 사용하면 된다.
  • 그러면, SELECT절에 동일한 상수를 넣어서 다음과 같이 결과가 출력된다.
result = [member1, A]
result = [member2, A]
result = [member3, A]
result = [member4, A]
  • 이제 문자를 더하는 경우도 있는데, 같은 문자면 상관이 없는데 만약 문자와 숫자라면 자바는 타입에 매우 민감한 언어이기 때문에 단순히 더하는걸로 안되고 위 코드처럼 stringValue()로 형변환을 해줘야 한다.
result = member1_10

 

728x90
반응형
LIST

'Querydsl' 카테고리의 다른 글

참고: 리포지토리 지원 - QuerydslRepositorySupport  (0) 2024.12.30
JPA 레포지토리와 Querydsl  (2) 2024.12.26
Querydsl 중급 문법  (0) 2024.12.22
[Renewal] QueryDsl  (0) 2024.12.07
Spring Boot 3.1.5에서 QueryDSL 설치하기  (0) 2023.11.26
728x90
반응형
SMALL

QueryDsl의 탄생 이유

QueryDsl은 뭐고 왜 생겼을까? 기존 JPA를 사용할 때 다 좋은데 아쉬운 점은 동적쿼리에 취약하다는 점이다. JDBC도 마찬가지로 동적 쿼리가 매우 불편하다. 둘 다 동적으로 쿼리를 만드려면 코드가 매우 지저분해진다. 다음 코드를 보자.

@Override
public List<Item> findAll(ItemSearchCond cond) {
    String jpql = "SELECT i FROM Item i";

    Integer maxPrice = cond.getMaxPrice();
    String itemName = cond.getItemName();
    if (StringUtils.hasText(itemName) || maxPrice != null) {
        jpql += " where";
    }
    boolean andFlag = false;
    if (StringUtils.hasText(itemName)) {
        jpql += " i.itemName like concat('%',:itemName,'%')";
        andFlag = true;
    }
    if (maxPrice != null) {
        if (andFlag) {
            jpql += " and";
        }
        jpql += " i.price <= :maxPrice";
    }
    log.info("jpql={}", jpql);
    TypedQuery<Item> query = em.createQuery(jpql, Item.class);
    if (StringUtils.hasText(itemName)) {
        query.setParameter("itemName", itemName);
    }
    if (maxPrice != null) {
        query.setParameter("maxPrice", maxPrice);
    }
    return query.getResultList();
}
  • 이건 JPA로 동적 쿼리를 작성하는 부분이다. 보기만 해도 그래서 결국 어떤 쿼리를 날릴까?에 대한 굉장한 의문이 생긴다.
  • 그리고 더 큰 문제는 동적 쿼리를 만들어 낼 때 문자열에 문자열을 추가하는데 이게 자칫 잘못했다가는 SQL에 문제가 생길 수도 있다. 다음 코드를 보자.
String sql = 
"select * from member" +
"where name like ?" +
"and age between ? and ?"
  • 자칫 보면 아무런 문제가 없어보인다. 그러나, 지금 저 문자열은 띄어쓰기가 제대로 되어 있지 않아서 SQL이 문제가 발생한다. 저 문자열을 풀어보면 이렇게 된다.
String sql = "select * from memberwhere name like ?and age between ? and ?"
  • 이건 문법 오류를 발생시킬 것이다. 그런데 더 큰 문제는 문자열이라서 컴파일 시에 문제를 뱉어내지 않는다. 그래서 개발자는 자칫 실수를 할 수 있다. 이 문제가 위 JPA에서 동적 쿼리를 만들어낼때도 동일하게 적용된다. 조건에 따라 쿼리에 문자열을 추가하고 있지 않은가?

 

그리고, SQL을 작성할 때 과연 컬럼명을 다 외우면서 작성할 수 있을까? 물론 외울 수도 있다. 그런데 외울 필요없이 그냥 자바 코드 작성하듯 (.) 찍으면 사용 가능한 필드가 보여지고 그런 편의성을 제공해주면 얼마나 좋을까? 이를 위해 QueryDsl이 만들어졌다.

 

그래서 QueryDsl은 이런 장점이 있다.

  • 동적 쿼리를 작성할 때 매우매우 막강하다.
  • 타입 세이프한 쿼리를 작성하기에 좋다.

왜 이런 장점이 있는지 하나씩 알아보자.

 

QueryDsl 설정

일단, 가장 단점이라고 하면 QueryDsl은 설정이 조금 귀찮다. 그러나 이 설정의 귀찮음을 몇배는 더 능가하는 막강한 기능을 제공하기 때문에 안 쓸 이유가 없다. 한번 사용하면 못 돌아온다. 설정 관련 포스팅이 이미 있다.

 

Spring Boot 3.1.5에서 QueryDSL 설치하기

Spring Data JPA와 같이 사용하면 막강의 쿼리 작성을 할 수 있는 QueryDSL을 프로젝트에 설정하는 방법을 기록하고자 한다. 하도 버전에 따라 설치하는 방법이 달라져서 스프링 부트 3.1.5, Gradle에서 설

cwchoiit.tistory.com

이 포스팅을 보고 설정을 진행하자.

 

Q 파일

위 포스팅을 따라 진행을 하면, Q파일이 생긴것을 확인할 수 있을 것이다. 그런데 이 파일 어떻게 만드는건지 궁금하다면 이 포스팅을 참고해보면 좋다.

 

Lombok은 어떻게 동작하는걸까?

자바로 개발을 할때 이 지루한 코드들을 보거나 작성해 본 경험이 있으신가요?package cwchoiit;public class Member { private String name; private int age; public String getName() { return name; } public void setName(String name) {

cwchoiit.tistory.com

위 내용을 보면 애노테이션 프로세싱이라는 기술을 설명하는데 그 기술을 사용해서 컴파일 시점에 Q클래스들을 만들어내는 것이다. 그리고 그 클래스들을 만들어내는 타겟 애노테이션은 @Entity 애노테이션이다. 

 

 

QueryDsl 적용

이제 QueryDsl을 적용해보자. 정말 사용해보면 굉장히 깔끔하고 딱 봐도 어떤 쿼리를 내보낼지가 눈에 보일것이다.

package hello.itemservice.repository.jpa;

import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import hello.itemservice.domain.Item;
import hello.itemservice.domain.QItem;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

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

import static hello.itemservice.domain.QItem.*;

@Repository
@Transactional
public class JpaItemRepositoryV3 implements ItemRepository {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;

    public JpaItemRepositoryV3(EntityManager em) {
        this.em = em;
        this.queryFactory = new JPAQueryFactory(em);
    }

    @Override
    public Item save(Item item) {
        em.persist(item);
        return item;
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        Item findItem = em.find(Item.class, itemId);
        findItem.setItemName(updateParam.getItemName());
        findItem.setPrice(updateParam.getPrice());
        findItem.setQuantity(updateParam.getQuantity());
    }

    @Override
    public Optional<Item> findById(Long id) {
        Item item = em.find(Item.class, id);
        return Optional.ofNullable(item);
    }

    @Override
    public List<Item> findAll(ItemSearchCond cond) {
        String itemName = cond.getItemName();
        Integer maxPrice = cond.getMaxPrice();

        return queryFactory
                .select(item)
                .from(item)
                .where(likeItemName(itemName), maxPrice(maxPrice))
                .fetch();
    }

    private BooleanExpression likeItemName(String itemName) {
        if (StringUtils.hasText(itemName)) {
            return item.itemName.like('%' + itemName + '%');
        }
        return null;
    }

    private BooleanExpression maxPrice(Integer maxPrice) {
        if (maxPrice != null) {
            return item.price.loe(maxPrice);
        }
        return null;
    }
}
  • 먼저 QueryDsl을 사용하기 위해서는 아래와 같이 JPAQueryFactory를 주입받아야 한다.
private final EntityManager em;
private final JPAQueryFactory queryFactory;

public JpaItemRepositoryV3(EntityManager em) {
    this.em = em;
    this.queryFactory = new JPAQueryFactory(em);
}
  • 이 부분은 스프링 빈으로 등록해서 주입받을 수도 있고 이렇게 직접 주입 받아도 무방하다.
@Override
public List<Item> findAll(ItemSearchCond cond) {
    String itemName = cond.getItemName();
    Integer maxPrice = cond.getMaxPrice();

    return queryFactory
            .select(item)
            .from(item)
            .where(likeItemName(itemName), maxPrice(maxPrice))
            .fetch();
}

private BooleanExpression likeItemName(String itemName) {
    if (StringUtils.hasText(itemName)) {
        return item.itemName.like('%' + itemName + '%');
    }
    return null;
}

private BooleanExpression maxPrice(Integer maxPrice) {
    if (maxPrice != null) {
        return item.price.loe(maxPrice);
    }
    return null;
}
  • 이 부분이 QueryDsl을 사용한 부분이다. 일단 findAll()의 반환값을 보자. 누가봐도 어떤 쿼리가 나갈지가 너무 명확히 보인다.
  • 그리고 동적 쿼리도 기가 막히게 해결한다. itemName 조건과 maxPrice 조건을 처리하는 것을 메서드로 빼버렸다. 그리고 QueryDsl에서 제공하는 BooleanExpression 타입을 받는데 이게 null일 경우 무시해버린다.
  • 저렇게 where 조건 안에 (,)로 연결하면 AND로 이어준다.
  • 또 강력한 장점 중 하나는 메서드로 조건을 빼버렸기 때문에 재사용이 가능하다는 점이다!

 

이렇게 깔끔하게 동적쿼리를 해결할 수 있었다. 참고로, QueryDsl은 스프링 데이터 접근 예외 추상화를 지원하지는 않는다. 그래서 이를 해결하기 위해 @Repository 애노테이션을 붙여서 스프링이 프록시로 변환시켜 만들어주는 해결 방식이 있다.

 

 

 

728x90
반응형
LIST

'Querydsl' 카테고리의 다른 글

참고: 리포지토리 지원 - QuerydslRepositorySupport  (0) 2024.12.30
JPA 레포지토리와 Querydsl  (2) 2024.12.26
Querydsl 중급 문법  (0) 2024.12.22
Querydsl 기본 문법  (0) 2024.12.21
Spring Boot 3.1.5에서 QueryDSL 설치하기  (0) 2023.11.26
728x90
반응형
SMALL

업데이트 (2024.12.21)


 

Spring Data JPA와 같이 사용하면 막강의 쿼리 작성을 할 수 있는 QueryDSL을 프로젝트에 설정하는 방법을 기록하고자 한다.

하도 버전에 따라 설치하는 방법이 달라져서 스프링 부트 3.1.5, Gradle에서 설치하는 방법을 작성했다. (아마 3.x.x라면 다 이 방법으로 하면 되지 않을까 싶다)

 

버전 정보

Software or Framework Version
Spring Boot 3.4.1
QueryDSL 5.1.0
Gradle 8.4
JDK 21

 

반응형
SMALL

 

시작하기

우선, build.gradle 파일에서 아래와 같은 dependencies를 추가해준다.

 

build.gradle

// Querydsl
implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

 

그리고, 같은 파일에서 아래와 같은 설정이 필요하다.

// querydsl directory path
def querydslDir = "src/main/generated"

// querydsl directory 를 자동 임포트 할 수 있게 설정
sourceSets {
    main.java.srcDirs += [ querydslDir ]
}

// task 중 compileJava 를 실행하면 Q 클래스 파일들을 생성
tasks.withType(JavaCompile).configureEach {
    options.getGeneratedSourceOutputDirectory().set(file(querydslDir))
}

// clean 을 하면 querydsl directory 를 삭제
clean.doLast {
    file(querydslDir).deleteDir()
}

 

이렇게 설정을 하고 gradle을 다시 빌드하면 된다. 다시 빌드하고 나면 이제 QueryDsl에서 반드시 필요한 파일인 Q파일을 생성해야 하는데 생성하기 위해 우측 gradle > (artifact 명) > Tasks > other > compileJava를 실행하면 된다.

 

결과

이를 실행하면 설정한 경로에 맞게 좌측 트리에서 찾아보면 다음과 같이 Q 파일들이 정상적으로 생성되었음을 알 수 있다.

 

728x90
반응형
LIST

'Querydsl' 카테고리의 다른 글

참고: 리포지토리 지원 - QuerydslRepositorySupport  (0) 2024.12.30
JPA 레포지토리와 Querydsl  (2) 2024.12.26
Querydsl 중급 문법  (0) 2024.12.22
Querydsl 기본 문법  (0) 2024.12.21
[Renewal] QueryDsl  (0) 2024.12.07

+ Recent posts