Spring에 대한 공부를 하며, Spring에서 제공하는 DI 덕분에 객체지향 설계 원칙인 SOLID 중 지켜지지 않던 O(OCP), D(DIP)를 지키며 개발할 수 있다는 것에 공감할 수 있었다. 문득, DI에 대해 공부해보며 궁금증이 생긴 부분이 있었고 이를 Spring에서 어떻게 해결해주는지에 대하여 알아본 결과를 정리해보았다.

 

궁금증....만약 인터페이스 1개에 구현체가 2개있는 경우는 의존성 주입이 어떻게 될까??


실제 코드를 통해 알아 보았다. 아래와 같은 Car라는 Interface가 있다.

public interface Car {
    int go();
}

해당 인터페이스를 구현하는 구현체 k5, k7이 아래와 같이 작성되있다고 가정해보자. 참고로 @Compenent의 경우 해당 클래스를 Spring Bean으로 등록한다는 것을 말한다.

@Component
public class k5 implements Car{

    @Override
    public int go() {
        return 5;
    }
}
@Component
public class k7 implements Car{

    @Override
    public int go() {
        return 7;
    }
}

그리고 테스트를 진행해 보았다.

	@Autowired
	private Car car;

참고로, @Autowired의 경우 Car Interface에 맞는 Bean으로 등록 된 객체를 가져와 Spring에서 자동으로 DI를 해달라고 명시해주는 어노테이션이다. 하지만, car 부분에 빨간줄이 생기며 제대로 DI가 안된다는 것을 알 수 있었다.

원인의 에러는 다음과 같았다.

 

Could not autowire. There is more than one bean of 'Car' type.
Beans: k5   (k5.java) 
       k7   (k7.java) 

직역해보면 autowire할 수 없다. 'Car' type의 bean이 한개보다 많다. 라고 한다.

그렇다. 구현체가 두개 이상 있는 경우에는 역시나 DI를 제대로 하지 못한다.

 

 

그렇다면, 해결 방법으로는 어떤 것이 있을까??


  1. 변수 네이밍을 Bean 이름으로 한다.
  2. @qualifier("Bean 객체 이름")을 사용한다.
  3. @primary를 사용한다.
  4. 사용하는 구현체 1개만 @component를 유지하고 나머지 구현체의 @component를 지워 Bean에 구현체 한개만 등록되도록 한다.

4 가지의 방법을 직접 코드로 구현해보며, 각각의 장단점에 대하여 느껴보자.

 

 

1번째 방법을 통해 해결해본 코드이다.

	@Autowired
	private Car k5;

결과적으로 제대로 DI를 통해 객체를 주입받지만, SOLID의 원칙인 OCP와 DIP가 위배된 코드를 확인할 수 있다.

 

 

2번째 방법을 통해 해결해본 코드이다.

	@Qualifier("k5")
	@Autowired
	private Car car;

결과적으로 제대로 DI를 통해 객체를 주입받고, OCP도 지키며 DIP도 지킨다고 볼 수 있다. Spring에서도 @qualifier를 추가하여 문제를 해결하라고 안내하긴 한다. 하지만,, @qualifier("k5")를 사용하다가 후에 k7으로 주입하고 싶으면 @qualifier("k5")

 

 @qualifier("k7") 변경 작업이 불가피하기때문에 결과적으로 보면 OCP와 DIP를 위반한다고 볼 수 있다.

 

3번째 방법을 통해 해결해본 코드이다.

@Component
@Primary
public class k5 implements Car{

    @Override
    public int go() {
        return 5;
    }
}

해당 @primary의 경우 구현체 중 우선순위를 정해준 것이기 때문에, 같은 interface의 구현체 중 1개만 정해야 하는 단점이 있지만, 테스트 클래스에서 수정하는 것이 아닌 구현체 클래스에서 수정하는 것을 볼 수 있다. 또한, OCP와 DIP도 지켜주는 모습이다.

 

4번째 방법을 통해 해결하는 방법은 사용하는 구현체 딱 한 개 말고는 @component를 삭제하여 Bean에 등록을 안시켜주면 된다. Spring에서 Interface에 의존성 주입을 할 때 1개의 구현체만 있는경우는 문제가 생기지 않기 떄문이다.

 

결과적으로, 3번째와 4번째 방법이 좋은 해결 방법이라고 볼 수 있다.

가장 좋은 방법은 4번째 방법으로, 사용할 하나의 구현체만 Bean으로 등록하는 것이 좋은 방법인 것 같다.

하지만, 여러개의 구현체를 두고 상황에 맞춰서 각각 다른 구현체를 사용해야 한다면 2번째 방법을 통해 해결하는 것이 최선인 것 같다.

혹시나, 나중에 구현체를 두개이상 두는 경우 해당 방법으로 해결하면 좋을 것 같다.

'spring > 정리' 카테고리의 다른 글

로그  (0) 2022.06.04

사용하는 이유

로그를 사용하고 싶을 때, System.out.println()을 사용해도 원하는 기능을 얻을 수 있을 것이다. 그럼에도 Slf4j를 사용하여 얻을 수 있는 이점이 있다.

 

1. 더 많은 내용을 얻을 수 있다.

System.out.println()
Slf4j

System.out.println()과 Slf4j를 사용했을 때의 로그 차이이다. 차이를 보면 다음과 같다.

1. 찍히지 않는 실행한 스레드

2. 클래스 이름

3. message가 출력이 된다.

 

 

2. 로그의 레벨을 정할 수 있다.

log는 trace, debug, info, warn, error를 제공합니다.

 

예를들어, 아래와 같은 기준을 두며 로그의 레벨을 정할 수 있습니다.

debug는 개발서버에서 보는 것이다.
info는 비즈니스로직이기 때문에 운영시스템에서 봐야한다.

 

또한, application.properties 설정에서 패키지별로 로그의 레벨을 따로 정할 수 있습니다.

# hello.springmvc 패키지와 그 하위 로그 설정
logging.level.hello.springmvc=trace

위와같이 코드를 작성하면, hello.springmvc 하위 패키지에 있는 log는 trace로그부터 찍히도록 할 수 있습니다.

 

참고로 기본은 info이고, trace -> debug -> info -> warn -> error 순으로 찍을 수 있습니다.

 

예를들어, trace로 설정하면 trace, debug, info, warn, error의 모든 로그가 찍히고,

debug로 설정하면 debug, info, warn, error의 모든 로그가 찍힙니다.

 

그렇기때문에 spring boot 로그는 기본을 info로 두고있습니다. 실제로 trace로 변경해보면 기존에 보이지않던 수 많은 로그가 찍히는 것을 볼 수 있습니다.

 

 

3. 콘솔에만 출력하는 것이 아닌, 파일이나 네트워크 등 별도의 위치에 남길 수 있다.

 

4. 성능이 우세하다.

 

 

 

 

사용하는 방법

어노테이션 X

package hello.springmvc.basic;

import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class LogTestController {

    @GetMapping("/log-test")
    public String logTest() {
        String name = "spring";

        log.trace("trace log={}", name);
        log.debug("debug log={}", name);
        System.out.println("info log =" + name);
        log.info("info log= {}", name);
        log.warn("warn log={}", name);
        log.error("error log= {}", name);
        return "ok";
    }
}

 

어노테이션 O

package hello.springmvc.basic;

import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
public class LogTestController {

    @GetMapping("/log-test")
    public String logTest() {
        String name = "spring";
        
        log.trace("trace log={}", name);
        log.debug("debug log={}", name);
        System.out.println("info log =" + name);
        log.info("info log= {}", name);
        log.warn("warn log={}", name);
        log.error("error log= {}", name);
        return "ok";
    }
}

 

어노테이션을 사용한 코드와 사용하지 않은 코드의 차이점을 보면

private final Logger log = LoggerFactory.getLogger(getClass());

가 있습니다. 실제로 @Slf4j의 기능은 위의 한줄의 코드를 줄여준다고 보면 됩니다.

 

 

"trace log ="+name으로 로그를 찍지 않는 이유       

 

만약 로그 레벨을 info로 설정한 경우 trace와 debug의 로그는 출력이 되지 않을 것 입니다.

 

하지만 로그를 "info log ={}", name의 방식이 아닌 "trace log ="+name으로 사용하는 경우, java는 "trace log ="+name를 읽고 cpu와 memory를 할당하지만, 실제로는 로그가 찍히지 않기 때문에 불필요한 cpu와 메모리를 낭비하게 됩니다.

 

반면, "info log = {}", name 방식을 사용하는 경우는 cpu와 메모리를 미리 할당하지 않는다고 합니다. 그렇기때문에 "info log ={}", name과 같은 방식을 사용합시다!

 

 

 

 

나중 공부를 위한 링크

1. slf4j

 

SLF4J

Simple Logging Facade for Java (SLF4J) The Simple Logging Facade for Java (SLF4J) serves as a simple facade or abstraction for various logging frameworks (e.g. java.util.logging, logback, log4j) allowing the end user to plug in the desired logging framewor

www.slf4j.org

2. logback

 

Logback Home

Logback Project Logback is intended as a successor to the popular log4j project, picking up where log4j 1.x leaves off. Logback's architecture is quite generic so as to apply under different circumstances. At present time, logback is divided into three mod

logback.qos.ch

3. 스프링부트가 제공하는 로그 기능

 

Core Features

Spring Boot lets you externalize your configuration so that you can work with the same application code in different environments. You can use a variety of external configuration sources, include Java properties files, YAML files, environment variables, an

docs.spring.io

 

해당 포스트에서 소개하는 기능은 제약이 커서 복잡한 실무 환경에서 사용하기에는 많이 부족하다고 한다. 그래도 Spring Data가 제공하는 기능이므로 간단히 알아보고, 왜 부족한지 이해해보자! (Spring에서 나중에 더 좋게 버전 업그레이드 할수도 있지않을까??)

 

 

인터페이스 지원 - QuerydslPredicateExecutor


공식 문서

 

Spring Data JPA - Reference Documentation

Example 108. Using @Transactional at query methods @Transactional(readOnly = true) public interface UserRepository extends JpaRepository { List findByLastname(String lastname); @Modifying @Transactional @Query("delete from User u where u.active = false") v

docs.spring.io

최근 문서

 

Spring Data JPA - Reference Documentation

Example 109. Using @Transactional at query methods @Transactional(readOnly = true) interface UserRepository extends JpaRepository { List findByLastname(String lastname); @Modifying @Transactional @Query("delete from User u where u.active = false") void del

docs.spring.io

사용 방법은 JPARepository에 상속하면 된다.

MemberRepository 코드 추가

public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom, QuerydslPredicateExecutor<Member> {
}

테스트

    @Test
    public void querydslPredicateExcutorTest() {
        Iterable<Member> result = memberRepository.findAll(QMember.member.age.between(20, 40).and(QMember.member.username.eq("member3")));

        for (Member member : result) {
            System.out.println("member = " + member);
        }
    }

findAll()로 모든 Member를 조회하는데, 조건을 추가할 수 있다. age가 20~30이며, username이 member3인 Member를 조회했다.

현재까지는 굉장히 좋은 것 같다. 뭐가 문제일까??

 

한계점

  • 조인 X (RDB인데 조인이 불가능하다고?!) - 묵시적 조인은 가능하지만, left join이 불가능하다고 한다.
  • 클라이언트가 Querydsl에 의존해야 한다. 서비스 클래스가 Querydsl이라는 구현 기술에 의존해야 한다.
    • 이렇게하면 Repository를 만드는 의미가 없다.
    • Repository를 만드는 이유는 복잡한 쿼리같은 경우 Querydsl을 repository계층 내부에 사용해서 외부로는 노출이 되지 않기 때문에 코드를 변경할 때 Repository 계층만 수정하면 된다.
    • 하지만, Querydsl이 Service 계층으로 노출되는 경우 Service 계층도 수정을 해야한다.
  • 복잡한 실무환경에서 사용하기에는 한계가 명확하다.
참고
QuerydslPredicateExecutor는 Pageable, Sort를 모두 지원하고 정상 동작한다.

 

 

 

Querydsl web 지원


공식 문서

 

Spring Data JPA - Reference Documentation

Example 108. Using @Transactional at query methods @Transactional(readOnly = true) public interface UserRepository extends JpaRepository { List findByLastname(String lastname); @Modifying @Transactional @Query("delete from User u where u.active = false") v

docs.spring.io

최근 문서

 

Spring Data JPA - Reference Documentation

Example 109. Using @Transactional at query methods @Transactional(readOnly = true) interface UserRepository extends JpaRepository { List findByLastname(String lastname); @Modifying @Transactional @Query("delete from User u where u.active = false") void del

docs.spring.io

 

한계점

  • 단순한 조건만 가능 (eq 정도만 가능)
  • 조건을 커스텀하는 기능이 복잡하고 명시적이지 않음
  • 컨트롤러가 Querydsl에 의존
  • 복잡한 실무환경에서 사용하기에는 한계가 명확 

이건 쓰지말자...

 

 

 

Repository 지원 - QuerydslRepositorySupport


장점

  • getQuerydsl().applyPagination()
    • 스프링 데이터가 제공하는 페이징을 Querydsl로 편리하게 변환 가능 (단! Sort는 오류 발생) ??
  • from() 으로 시작가능 (최근 QueryFactory를 사용해서 select()로 시작하는 것이 더 명시적이다)
  • EntityManager 제공 (주입받지 않아도 된다.)

뭐 지금까지 나쁘지 않아 보인다. 코드를 몇줄 줄일 수 있을법하다. 하지만 한계가 명확하게 존재하다고 한다.

 

한계

  • Querydsl 3.x 버전을 대상으로 만듬
  • Querydsl 4.x 에 나온 JPAQueryFactory로 시작할 수 없음
    • select로 시작할 수 없음 (from 시작)
  • QueryFactory를 제공하지 않음
  • 스프링 데이터 Sort 기능이 정상 동작하지 않음

 

 

 

Querydsl 지원 클래스 직접 만들기


스프링 데이터가 제공하는 QuerydslRepositorySupport가 지닌 한계를 극복하기 위해 직접 Querydsl 지원 클래스를 만들어보자!! 

 

장점

  • 스프링 데이터가 제공하는 페이징을 편리하기 변환
  • 페이징과 카운트 쿼리 분리 가능
  • 스프링 데이터 Sort 지원
  • select(), selectFrom() 으로 시작 가능
  • EntityManager, QueryFactory 제공

 

Querydsl4RepositorySupport

package study.querydsl.repository.support;

import com.querydsl.core.types.EntityPath;
import com.querydsl.core.types.Expression;
import com.querydsl.core.types.dsl.PathBuilder;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.support.JpaEntityInformation;
import org.springframework.data.jpa.repository.support.JpaEntityInformationSupport;
import org.springframework.data.jpa.repository.support.Querydsl;
import org.springframework.data.querydsl.SimpleEntityPathResolver;
import org.springframework.data.repository.support.PageableExecutionUtils;
import org.springframework.stereotype.Repository;
import org.springframework.util.Assert;
import javax.annotation.PostConstruct;
import javax.persistence.EntityManager;
import java.util.List;
import java.util.function.Function;
/**
 * Querydsl 4.x 버전에 맞춘 Querydsl 지원 라이브러리
 *
 * @author Younghan Kim
 * @see
org.springframework.data.jpa.repository.support.QuerydslRepositorySupport
 */
@Repository
public abstract class Querydsl4RepositorySupport {
    private final Class domainClass;
    private Querydsl querydsl;

    private EntityManager entityManager;
    private JPAQueryFactory queryFactory;
    public Querydsl4RepositorySupport(Class<?> domainClass) {
        Assert.notNull(domainClass, "Domain class must not be null!");
        this.domainClass = domainClass;
    }
    @Autowired
    public void setEntityManager(EntityManager entityManager) {
        Assert.notNull(entityManager, "EntityManager must not be null!");
        JpaEntityInformation entityInformation =
                JpaEntityInformationSupport.getEntityInformation(domainClass, entityManager);
        SimpleEntityPathResolver resolver = SimpleEntityPathResolver.INSTANCE;
        EntityPath path = resolver.createPath(entityInformation.getJavaType());
        this.entityManager = entityManager;
        this.querydsl = new Querydsl(entityManager, new
                PathBuilder<>(path.getType(), path.getMetadata()));
        this.queryFactory = new JPAQueryFactory(entityManager);
    }
    @PostConstruct
    public void validate() {
        Assert.notNull(entityManager, "EntityManager must not be null!");
        Assert.notNull(querydsl, "Querydsl must not be null!");
        Assert.notNull(queryFactory, "QueryFactory must not be null!");
    }
    protected JPAQueryFactory getQueryFactory() {
        return queryFactory;
    }
    protected Querydsl getQuerydsl() {
        return querydsl;
    }
    protected EntityManager getEntityManager() {
        return entityManager;
    }
    protected <T> JPAQuery<T> select(Expression<T> expr) {
        return getQueryFactory().select(expr);
    }
    protected <T> JPAQuery<T> selectFrom(EntityPath<T> from) {
        return getQueryFactory().selectFrom(from);
    }
    protected <T> Page<T> applyPagination(Pageable pageable,
                                          Function<JPAQueryFactory, JPAQuery> contentQuery) {
        JPAQuery jpaQuery = contentQuery.apply(getQueryFactory());
        List<T> content = getQuerydsl().applyPagination(pageable,
                jpaQuery).fetch();
        return PageableExecutionUtils.getPage(content, pageable,
                jpaQuery::fetchCount);
    }
    protected <T> Page<T> applyPagination(Pageable pageable,
                                          Function<JPAQueryFactory, JPAQuery> contentQuery, Function<JPAQueryFactory,
            JPAQuery> countQuery) {
        JPAQuery jpaContentQuery = contentQuery.apply(getQueryFactory());
        List<T> content = getQuerydsl().applyPagination(pageable,
                jpaContentQuery).fetch();
        JPAQuery countResult = countQuery.apply(getQueryFactory());
        return PageableExecutionUtils.getPage(content, pageable,
                countResult::fetchCount);
    }
}

MemberTestRepository

package study.querydsl.repository;

import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQuery;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.support.PageableExecutionUtils;
import org.springframework.stereotype.Repository;
import study.querydsl.dto.MemberSearchCondition;
import study.querydsl.entity.Member;
import study.querydsl.repository.support.Querydsl4RepositorySupport;
import java.util.List;
import static org.springframework.util.StringUtils.isEmpty;
import static study.querydsl.entity.QMember.member;
import static study.querydsl.entity.QTeam.team;
@Repository
public class MemberTestRepository extends Querydsl4RepositorySupport {
    public MemberTestRepository() {
        super(Member.class);
    }
    public List<Member> basicSelect() {
        return select(member)
                .from(member)
                .fetch();
    }
    public List<Member> basicSelectFrom() {
        return selectFrom(member)
                .fetch();
    }
    public Page<Member> searchPageByApplyPage(MemberSearchCondition condition,
                                              Pageable pageable) {
        JPAQuery<Member> query = selectFrom(member)
                .leftJoin(member.team, team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()));
        List<Member> content = getQuerydsl().applyPagination(pageable, query)
                .fetch();
        return PageableExecutionUtils.getPage(content, pageable,
                query::fetchCount);
    }

    public Page<Member> applyPagination(MemberSearchCondition condition,
                                        Pageable pageable) {
        return applyPagination(pageable, contentQuery -> contentQuery
                .selectFrom(member)
                .leftJoin(member.team, team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())));
    }

    public Page<Member> applyPagination2(MemberSearchCondition condition,
                                         Pageable pageable) {
        return applyPagination(pageable, contentQuery -> contentQuery
                        .selectFrom(member)
                        .leftJoin(member.team, team)
                        .where(usernameEq(condition.getUsername()),
                                teamNameEq(condition.getTeamName()),
                                ageGoe(condition.getAgeGoe()),
                                ageLoe(condition.getAgeLoe())),
                countQuery -> countQuery
                        .selectFrom(member)
                        .leftJoin(member.team, team)
                        .where(usernameEq(condition.getUsername()),
                                teamNameEq(condition.getTeamName()),
                                ageGoe(condition.getAgeGoe()),
                                ageLoe(condition.getAgeLoe()))
        ); }
    private BooleanExpression usernameEq(String username) {
        return isEmpty(username) ? null : member.username.eq(username);
    }
    private BooleanExpression teamNameEq(String teamName) {
        return isEmpty(teamName) ? null : team.name.eq(teamName);
    }
    private BooleanExpression ageGoe(Integer ageGoe) {
        return ageGoe == null ? null : member.age.goe(ageGoe);
    }
    private BooleanExpression ageLoe(Integer ageLoe) {
        return ageLoe == null ? null : member.age.loe(ageLoe);
    }
}

 

 

 

 

 

 

출처

 

실전! Querydsl - 인프런 | 강의

Querydsl의 기초부터 실무 활용까지, 한번에 해결해보세요!, - 강의 소개 | 인프런...

www.inflearn.com

 

Spring Data JPA 리포지토리 변경


package study.querydsl.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import study.querydsl.entity.Member;
import java.util.*;

public interface MemberRepository extends JpaRepository<Member, Long> {
    // 메소드 쿼리 복습
    // select m from Member m where m.username = username
    List<Member> findByUsername(String username);

}

interface로 만들어야하는 것 잊지말자!!

 

 

사용자 정의 리포지토리


이전에 Spring Data JPA를 공부하며 나왔던 개념이다.  Spring Data JPA에서 제공하는 기능 외 동적 쿼리등 복잡한 쿼리를 만들고 싶을 때 사용해야되는 방법이다. 사용자 정의 리포지토리를 만드는 방법은 아래와 같다.

 

사용자 정의 리포지토리 사용법

  1. 사용자 정의 인터페이스 작성
  2. 사용자 정의 인터페이스 구현
  3. 스프링 데이터 리포지토리에 사용자 정의 인터페이스 상속

그림으로 보면 위와 같다. JpaRepository를 상속받은 MemberRepository가 있다. 동적 쿼리등 복잡한 쿼리를 추가하고 싶은데, Spring Data JPA에서 제공하는 기능으로는 한계가있다.

1. MemberRepositoryCustom interface 생성

2. MemberRepositoryImpl 생성 후 기능 구현

3. MemberRepository에 MemberRepositoryCustom 상속

여기서 지켜야하는 룰이 하나있다.

추가 기능을 수행하는 클래스 명은 = "JPARepository를 상속받은 클래스 명" + "아무값(없어도 됌)" + "Impl" 로 작성해야 한다.

 

MemberRepositoryImpl에는 기존에 qeurydsl로 만들었던 search 메소드와 연관된 메소드를 그대로 넣어주었다.

 

 

 

스프링 데이터 페이징 활용


  • 스프링 데이터의 Page, Pageable을 활용해보자.
  • 전체 카운트를 한번에 조회하는 단순한 방법
  • 데이터 내용과 전체 카운트를 별도로 조회하는 방법

전체 카운트를 한번에 조회하는 단순한 방법

    @Override
    public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable) {
        QueryResults<MemberTeamDto> results = 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())
                .fetchResults();

        List<MemberTeamDto> content = results.getResults();
        long total = results.getTotal();

        return new PageImpl<>(content, pageable, total);
    }

데이터 내용과 전체 카운트를 별도로 조회하는 방법

    @Override
    public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
        List<MemberTeamDto> content = 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();

        long total = queryFactory
                .selectFrom(member)
                .leftJoin(member.team, team)        // 어떤 상황에는 join이 필요없는 경우가 있다.
                .where(
                        usernameEq(condition.getUsername()),
                        teamnameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()))
                .fetchCount();
        return new PageImpl<>(content, pageable, total);
    }

별도로 조회하는 방법은 성능을 조금 높이기 위함일 수 있습니다. 예를들어, count 쿼리를 조회한 뒤 count가 0인 경우 content 쿼리를 하지 않을 수 있습니다. 아니면 카운트 쿼리를 content 쿼리보다 단순하게 조회할 수 있는 경우 성능상 이점이 생길 수 있습니다.

참고
Querydsl이 향후 fetchCount()를 제공하지 않습니다. 
fetchCount() => fetchOne()
fetchresults() => fetch() 권장

 

 

CountQuery 최적화


count 쿼리를 생략할 수 있는 경우는 아래와 같다.

  1. 페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때 
  2. 마지막 페이지 일 때 (offset + 컨텐츠 사이즈를 더해서 전체 사이즈 구함)

위의 최적화를 할 수 있는 코드는 count쿼리를 따로 사용할 때 사용할 수 있다.

    @Override
    public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
        List<MemberTeamDto> content = 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();

        // countQuery에서는 fetchCount()를 해야 카운트 쿼리가 날라간다.
        JPAQuery<Member> countQuery = queryFactory
                .selectFrom(member)
                .leftJoin(member.team, team)        // 어떤 상황에는 join이 필요없는 경우가 있다.
                .where(
                        usernameEq(condition.getUsername()),
                        teamnameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                );

        // PageableExecutionUtils.getPage에서는 content pagealbe의 토탈 사이즈를 보고 count 쿼리를 생략할 수 있는 경우에
        // fetchCount() 쿼리 함수를 실행하지 않는다.
        return PageableExecutionUtils.getPage(content, pageable, () -> countQuery.fetchCount());
        //return new PageImpl<>(content, pageable, total);
    }
참고
Querydsl이 향후 fetchCount()를 제공하지 않습니다. 
fetchCount() => fetchOne()
PagealbeExecutionUtils.getPage에서는 content, pageable의 토탈 사이즈를 보고 count 쿼리를 생략할 수 있는 경우에 fetchCount()를 실행하지 않기 때문에 count쿼리가 필요하지 않는 경우 쿼리가 발생하지않는다.

getPage코드는 아래와 같다.

	public static <T> Page<T> getPage(List<T> content, Pageable pageable, LongSupplier totalSupplier) {

		Assert.notNull(content, "Content must not be null!");
		Assert.notNull(pageable, "Pageable must not be null!");
		Assert.notNull(totalSupplier, "TotalSupplier must not be null!");

		if (pageable.isUnpaged() || pageable.getOffset() == 0) {

			if (pageable.isUnpaged() || pageable.getPageSize() > content.size()) {
				return new PageImpl<>(content, pageable, content.size());
			}

			return new PageImpl<>(content, pageable, totalSupplier.getAsLong());
		}

		if (content.size() != 0 && pageable.getPageSize() > content.size()) {
			return new PageImpl<>(content, pageable, pageable.getOffset() + content.size());
		}

		return new PageImpl<>(content, pageable, totalSupplier.getAsLong());
	}

 

 

 

Controller 개발


이제 controller를 개발해보자.

MemberController 코드 추가

public class MemberController {
    ...

    @GetMapping("/v2/members")
    public Page<MemberTeamDto> seachMemberV2(MemberSearchCondition condition, Pageable pageable) {
        return memberRepository.searchPageSimple(condition, pageable);
    }

    @GetMapping("/v3/members")
    public Page<MemberTeamDto> seachMemberV3(MemberSearchCondition condition, Pageable pageable) {
        return memberRepository.searchPageComplex(condition, pageable);
    }
}

이제 postman에서 아래와 같이 page, size를 바꿔가며 데이터를 조회해보면 페이지별로 조회가 가능하다.

localhost:8080/v2/members?page=0&size=5

테스트를 해보니 V3의 경우 데이터 < 조회한 데이터 크기 경우 count 쿼리가 나가지 않는 것을 확인할 수 있습니다.

 

 

 

스프링 데이터 정렬


Spring Data JPA는 자신의 정렬(Sort)를 Querydsl의 정렬(OrderSpecifier)로 편리하게 변경하는 기능을 제공한다. 해당 부분은 뒤에 Spring Data JPA가 제공하는 Querydsl 기능에서 살펴본다. 

Spring Data Sort를 Querydsl의 OrderSpecifier로 변환

    JPAQuery<Member> query = queryFactory
            .selectFrom(member);
            
    for (Sort.Order o : pageable.getSort()) {
        PathBuilder pathBuilder = new PathBuilder(member.getType(),
    member.getMetadata());
    
        query.orderBy(new OrderSpecifier(o.isAscending() ? Order.ASC : Order.DESC,
                pathBuilder.get(o.getProperty())));
    }
    
    List<Member> result = query.fetch();
참고
정렬은 조건이 조금만 복잡해져도 Pagealbe의 sort기능을 사용하기 어렵다고 한다. 루트 엔티티 범위를 넘어가는 동적 정렬 기능이 필요하면 스프링 데이터 페이징이 제공하는 sort를 사용하기 보다는 파라미터를 받아서 직접 처리하는 것을 권장.

 

 

 

출처

 

실전! Querydsl - 인프런 | 강의

Querydsl의 기초부터 실무 활용까지, 한번에 해결해보세요!, - 강의 소개 | 인프런...

www.inflearn.com

 

순수 JPA와 Querydsl Repository

package study.querydsl.repository;

import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import study.querydsl.entity.Member;
import study.querydsl.entity.QMember;

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

import static study.querydsl.entity.QMember.*;

@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_Querydsl() {
        return queryFactory
                .selectFrom(member)
                .fetch();
    }

    public List<Member> findByUsername_Querydsl(String username){
        return queryFactory
                .selectFrom(member)
                .where(member.username.eq(username))
                .fetch();
    }
}

Spring Data JPA 없이 JPA와 querydsl을 사용한 예시이다.

참고로, @RequiredArgsConstructor로 JPAQueryFactory에 넣어줄 수 있는 이유는 Spring Bean으로 등록해놨기 때문이다. 아래로 예시를 들어주겠다.

@SpringBootApplication
public class QuerydslApplication {

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

	@Bean
	JPAQueryFactory jpaQueryFactory(EntityManager em){
		return new JPAQueryFactory((em));
	}
}

 

 

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


MemberTeamDto 생성

package study.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;
    }
}
Member와 Team을 함께 반환해줄 dto 생성

MemberSearchCondition 생성

package study.querydsl.dto;

import lombok.Data;

@Data
public class MemberSearchCondition {
    // 회원명, 팀명, 나이 (ageGoe, ageLoe)

    private String username;
    private String teamName;
    private Integer ageGoe;
    private Integer ageLoe;
}
파라미터 조건에따라 MemberTeamDto를 반환해줄 파라미터 안에 넣을  클래스

MemberJpaRepository 코드 추가

public class MemberJpaRepository {
    ...

    public List<MemberTeamDto> searchByBuilder(MemberSearchCondition memberSearchCondition){
        BooleanBuilder builder = new BooleanBuilder();
        if(StringUtils.hasText(memberSearchCondition.getUsername())) {
            builder.and(member.username.eq(memberSearchCondition.getUsername()));
        }
        if(StringUtils.hasText(memberSearchCondition.getTeamName())) {
            builder.and(team.name.eq(memberSearchCondition.getTeamName()));
        }
        if(memberSearchCondition.getAgeGoe() != null){
            builder.and(member.age.goe(memberSearchCondition.getAgeGoe()));
        }
        if(memberSearchCondition.getAgeLoe() != null){
            builder.and(member.age.loe(memberSearchCondition.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문 안에 넣는 예시이다.

참고로, StringUtils.hasText()는 null이나 ""빈 값 모두 false로 걸러주는 기능을 해준다.

 

음.. 확실히 이렇게 보니 가독성이 떨어지는 것 같긴하다..

그렇다면, where절은 조금 더 깔끔하겠지?!

 

 

 

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


MemberJpaRepository 코드 추가

public class MemberJpaRepository {
    
    ...
    public List<MemberTeamDto> search(MemberSearchCondition condition) {
        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();
    }

    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)? null : member.age.goe(ageGoe);
    }

    private BooleanExpression ageLoe(Integer ageLoe) {
        return (ageLoe == null)? null : member.age.loe(ageLoe);
    }
}

이렇게 각각 기능들을 메소드로 분리함으로써 가독성도 높아지고, 재사용성도 생긴다. 개인적으로 BooleanBuilder보다 where절이 객체지향 프로그래밍에 맞는 코드인 것 같다.

 

 

 

조회 API 컨트롤러 개발


사전 준비

main에 있는 application.yml을 test에도 추가해줍니다. (resources 파일도 추가해야 함)

그리고 설정을 아래와 같이 local부분만 다르게 test로 작성해줍니다. (main도 profiles: active: local 추가)

spring:
  profiles:
    active: local			// main (test 경우에는 local이 아닌 test)
  datasource:
    url: jdbc:h2:tcp://localhost/~/querydsl
...

그 후 InitMember 클래스 추가 (애플리케이션 로딩 시점에 데이터를 추가하기 위한 클래스 입니다. 테스트 용도)

package study.querydsl.controller;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import study.querydsl.entity.Member;
import study.querydsl.entity.Team;

import javax.annotation.PostConstruct;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

@Profile("local")
@Component
@RequiredArgsConstructor
public class InitMember {
    private final InitMemberService initMemberService;

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

    @Component
    static class InitMemberService {
        @PersistenceContext private 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 selectTeam = i % 2 == 0? teamA : teamB;
                em.persist(new Member("member"+i, i, selectTeam));
            }
        }
    }
}

InitMemberService를 따로 만들어준 이유는 @PostConstruct와 @Transactional을 함께 사용하지 못하기 때문에 분리하기 위해 만들어 주었다. @Profiles("local")은 이전에 application.yml에 설정해주었던 profiles 값이 Local인 경우에만 구동시켜준다.

 

사전 준비는 끝났다. 게임을 시작해보자.

 

MemberController 생성

package study.querydsl.controller;

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

import java.util.List;

@RestController
@RequiredArgsConstructor
public class MemberController {
    private final MemberJpaRepository memberJpaRepository;

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

}

만들고 주소/v1/members로 검색하면 이전에 넣어주었던 데이터들이 나온다. (웹에서 접근하면 보기 힘들기 때문에 postman과 같은 api툴을 사용하기를 바란다!)

위와같이 파라미터 조건을 추가하면 조건에 맞게 조회된 데이터를 확인할 수 있다.

참고로 아무런 파라미터를 설정하지 않는다면 모든데이터가 조회되기 때문에, 사용할 때 각별히 주의하자!!!!

 

 

 

 

 

출처

 

실전! Querydsl - 인프런 | 강의

Querydsl의 기초부터 실무 활용까지, 한번에 해결해보세요!, - 강의 소개 | 인프런...

www.inflearn.com

 

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


    @Test
    public void findDtoByJPQL() {
        List<MemberDto> result = em.createQuery("select new study.querydsl.dto.MemberDto(m.username, m.age) from Member m", MemberDto.class)
                .getResultList();

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

이전에 사용한 바 있는 JPQL을 사용해 DTO로 변환해 조회하는 코드이다. 해당 코드는 new Operation을 사용해야하기 때문에 DTO의 package 명을 다 적어줘야 한다. 이때문에 코드가 지저분해지고, 코드 작성에도 불편함이 있다. 또한, 생성자 방식만 지원하는 불편함도 있다.

 

하지만, querydsl은 위의 문제를 깔끔하게 해결하는 방법을 제시한다.

 

Querydsl 빈 생성(Bean population)

결과를 DTO 반환할 때 사용한다. 아래와 같은 3가지 방법을 지원한다.

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

프로퍼티 접근 - Setter

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

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

 

필드 직접 접근 

    @Test
    public void findDtoByField() {
        List<MemberDto> result = queryFactory
                .select(Projections.fields(MemberDto.class,     // getter, setter 상관없이 값을 넣는다.
                        member.username,
                        member.age))
                .from(member)
                .fetch();

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

 

생성자 접근

    @Test
    public void findDtoByConstructor() {
        List<MemberDto> result = queryFactory
                .select(Projections.constructor(MemberDto.class,     // 파라미터 생성자에 값이 딱딱 들어간다.
                        member.username,
                        member.age))
                .from(member)
                .fetch();

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

 

필드 값이 다른 dto 접근 - 프로퍼티 접근법만 예시 (필드, 생성자 접근 동일)

    @Test
    public void findUserDtoBySetter() {
        List<UserDto> result = queryFactory
                .select(Projections.bean(UserDto.class,       //setter를 통해 들어간다.
                        member.username.as("name"),
                        member.age))
                .from(member)
                .fetch();

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

.as("바꾸고싶은 필드명")으로 필드명을 바꿔주면 된다.

 

서브쿼리 추가 dto 접근 - 필드 접근법만 예시 (프로퍼티, 생성자 접근 동일)

    @Test
    public void findUserDtoByFieldSubQuery() {
        QMember memberSub = new QMember("memberSub");
        List<UserDto> result = queryFactory
                .select(Projections.fields(UserDto.class,     // getter, setter 상관없이 값을 넣는다.
                        member.username.as("name"),
                        Expressions.as(JPAExpressions
                                .select(memberSub.age.max())
                                .from(memberSub), "age")
                ))
                .from(member)
                .fetch();

        for (UserDto userDto : result) {
            System.out.println("userDto = " + userDto);
        }
    }
  • ExpressionUtils.as(source, alias) : 필드나 서브쿼리에 별칭 적용

 

 

 

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


@QueryProjection을 사용하기 위해서는 준비 작업이 필요합니다.

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

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

사용하고자하는 dto 클래스의 생성자에 @QueryProjection을 추가해줍니다. 실행을 한번 해보거나 이전에 돌렸던 compilequerydsl을 눌러줍니다. 그렇게되면 @MemberDto가 생성됩니다. 이제 사용해봅시다.

 

생성자 + @QueryProjection

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

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

생성자 dto 보다의 장점은 컴파일 시점에 오류를 잡아준다는 것 입니다. 생성자 dto는 런타임 시점에 오류를 잡아주지만, 생성자+@QueryProjection은 컴파일 시점에 오류를 잡아줍니다.

 

위 @QueryProjection은 다 좋지만 2가지 단점이 존재합니다.

  1. QMemberDto와 같이 Q멤버를 만들어야 해야 합니다.
  2. Q멤버로 만드는 경우, dto는 querydsl에 의존성을 가지게 됩니다.
    • 해당 문제는 아키텍쳐 관점에서 문제가 생긴다고 한. 해당 dto를 repository, service, application 계층 모두 사용하는데 querydsl에 의존적인 dto를 모든 계층에서 사용하는데 어떠한 문제가 있을지는 아직 모르겠다,,

 

distinct

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

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

이렇게 select뒤에 distinct 적어주면된다.

 

 

 

동적 쿼리 - BooleanBuilder 사용


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

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

    private List<Member> searchMember1(String usernameCond, Integer ageCond) {
        BooleanBuilder builder = new BooleanBuilder();
        if(usernameCond != null){
            builder.and(member.username.eq(usernameCond));
        }

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

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

위와 같이 null이 아닌 경우에만 builder.and(where안에 넣을 조건)을 넣어주면 된다.

 

 

 

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


김영한님이 즐겨쓰는 방식이라고 한다. 

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

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

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

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

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

이 기능의 장점은 메소드라 빠지기 때문에, 아래와 같이 조립이 가능하다.

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

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

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

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

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

    private BooleanExpression allEq(String usernameCond, Integer ageCond){
        return usernameEq(usernameCond).and(ageEq(ageCond));
    }
  • where 조건에 null 값은 무시된다.
  • 메서드를 다른 쿼리에서도 재활용 할 수 있다. (이 점이 정말 좋은 것 같다.)
  • 쿼리 자체의 가독성이 높아진다. (이전 BooleanBuilder보다 가독성이 뛰어나다.)

 

 

수정, 삭제 배치 쿼리


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

    @Test
    public void bulkUpdate() {
        // member1 = 10 = > 비회원
        // member2 = 20 = > 비회원
        // member1 = 30 = > 그대로
        // member2 = 40 = > 그대로

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

두 컬럼의 데이터를 update했지만, 쿼리는 한번만 나간 것을 볼 수 있다.

참고
벌크 연산은 이전에도 말했지만, 조심해야할 것이 있다.
영속성 컨텍스트를 무시하고 DB에 직접 접근해서 update 쿼리문을 날리기 때문이다. 이것이 문제가 되는 것은 영속성 컨텍스트에 update쿼리문에 날라간 데이터가 있고, 벌크 연산 후 데이터를 가져오면 DB가 아닌 영속성 컨텍스트에서 가져오기 때문에 벌크연산된 데이터가 아닌 이전의 데이터를 가져온다. 그렇기 때문에 벌크 연산후에는 em.clear()를 해주자.

 

쿼리 한번으로 대량 데이터 추가, 곱, 삭제

    @Test
    public void bulkAdd() {
        long count = queryFactory
                .update(member)
                .set(member.age, member.age.add(1)) //곱하기는 add 대신 multiply
                .execute();
    }

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

해당 벌크 연산도 벌크 연산 후 em.clear()를 필수로 해주어야 한다.

 

 

 

SQL function 호출하기


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

 

아래 예시는 member string을 m string으로 replace하는 함수를 사용한 예제이다.

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

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

replace말고도 소문자로 변환하는 경우는 아래예시이다.

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

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

비교할 데이터를 모두 소문자로 비교한 뒤, 비교할 때는 위와같이 소문자로 변경한 뒤 비교할 수 있다.

 

참고로, lower 같은 ansi 표준 함수들은 querydsl이 상당부분 내장하고 있다. 따라서 아래와 같이 처리해도 결과는 같다.

.where(member.username.eq(member.username.upper()))

 

 

 

 

 

출처

 

실전! Querydsl - 인프런 | 강의

Querydsl의 기초부터 실무 활용까지, 한번에 해결해보세요!, - 강의 소개 | 인프런...

www.inflearn.com

 

예제 도메인 모델은 Spring Data JPA에서 사용한 모델링이랑 같다.

 

 

JPQL vs Querydsl


    @Test
    public void startJPQL() throws Exception {
        Member findByJPQL = em.createQuery("select m from Member m where m.username = :username", Member.class)
                .setParameter("username", "member1")
                .getSingleResult();

        Assertions.assertThat(findByJPQL.getUsername()).isEqualTo("member1");

    }

    @Test
    public void startQuerydsl() {
        JPAQueryFactory queryFactory = new JPAQueryFactory(em);
        QMember m = QMember.member;

        Member findByQuerydsl = queryFactory.select(m)
                .from(m)
                .where(m.username.eq("member1"))
                .fetchOne();

        Assertions.assertThat(findByQuerydsl.getUsername()).isEqualTo("member1");
    }

JPQL과 Querydsl 테스트하는 코드의 차이이다.

 

 

Querydsl 장점

  • 컴파일시점에 오류를 잡아낸다. (JPQL은 메소드가 실행될 때 오류(Runtime Error)를 발견)
  • 자바 코드로 쿼리를 작성할 수 있다. 
  • 파라미터 바인딩을 자동으로 해결해 준다.
  • Code Assistant도 잘되어있다.

 

 

기본 Q-Type 활용


Q클래스 인스턴스를 사용하는 2가지 방법

        QMember m = new QMember("m");
        QMember m = QMember.member;

참고로 QMember.member은 QMember객체에 만들어놓은 객체이다.

@Generated("com.querydsl.codegen.DefaultEntitySerializer")
public class QMember extends EntityPathBase<Member> {
    ...
    public static final QMember member = new QMember("member1");
}

 

코드를 더 심플하게 하는 방법

import static study.querydsl.entity.QMember.*;

위와 같이 import를 해주면 아래와 같이 코드를 만들 수 있다.

        JPAQueryFactory queryFactory = new JPAQueryFactory(em);

        Member findByQuerydsl = queryFactory.select(member)
                .from(member)
                .where(member.username.eq("member1"))
                .fetchOne();

 

Querydsl 쿼리문 보는법

application.yml에 아래와 같이 옵션을 추가해주어야한다.

spring:
  datasource:
    url: jdbc:h2:tcp://localhost/~/querydsl
    username: sa
    password:
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        #show_sql: true
        format_sql: true
        use_sql_comments: true #querydsl 쿼리문 보는 옵션

 

 

 

검색 조건 쿼리


기본 검색 쿼리

    @Test
    public void search() {
        Member findMember = queryFactory
                .selectFrom(member)
                .where(member.username.eq("member1")
                        .and(member.age.eq(10)))
                .fetchOne();
                
        assertThat(findMember.getUsername()).isEqualTo("member1");
    }

기본 검색 조건은 .and(), or()를 메서드 체인으로 연결할 수 있다.

select, from을 selectfrom으로 합칠 수 있음.

 

JPQL이 제공하는 모든 검색 조건 제공

member.username.eq("member1") // username = 'member1'
member.username.ne("member1") //username != 'member1'
member.username.eq("member1").not() // username != 'member1'

member.username.isNotNull() //이름이 is not null

member.age.in(10, 20) // age in (10,20)
member.age.notIn(10, 20) // age not in (10, 20)
member.age.between(10,30) //between 10, 30

member.age.goe(30) // age >= 30
member.age.gt(30) // age > 30
member.age.loe(30) // age <= 30
member.age.lt(30) // age < 30

member.username.like("member%") //like 검색 
member.username.contains("member") // like ‘%member%’ 검색 
member.username.startsWith("member") //like ‘member%’ 검색

AND 조건을 파라미터로 처리

    @Test
    public void searchAndParam() {
        List<Member> result1 = queryFactory
                .selectFrom(member)
                .where(member.username.eq("member1"),
                        member.age.eq(10))
                .fetch();
        assertThat(result1.size()).isEqualTo(1);
    }

where()에 파라미터로 검색 조건을 추가하면 AND조건이 추가된다.

 

 

 

결과 조회


  • fetch()
    • list 조회, 데이터 없으면 빈 리스트 반환
  • fetchOne()
    • 단건 조회
    • 결과가 없으면 Null
    • 결과가 둘 이상이면 : NonUniqueResultException
  • fetchFirst()
    • limit(1).fetchOne()
  • fetchResults()
    • 페이징 정보 포함, total count 쿼리 추가 발생
  • fetchCount()
    • count 쿼리로 변경해서 count 수 조회
//List
List<Member> fetch = queryFactory
          .selectFrom(member)
          .fetch();

//단 건
Member findMember1 = queryFactory
          .selectFrom(member)
          .fetchOne();
          
//처음 한 건 조회
Member findMember2 = queryFactory
          .selectFrom(member)
          .fetchFirst();
          
//페이징에서 사용
QueryResults<Member> results = queryFactory
          .selectFrom(member)
          .fetchResults();
          
//count 쿼리로 변경
long count = queryFactory
          .selectFrom(member)
          .fetchCount();

 

 

 

정렬


   /**
     * 회원 정렬 순서
     * 1. 회원 나이 내림차순(desc)
     * 2. 회원 이름 올림차순(asc)
     * 단, 2에서 회원 이름이 없으면 마지막에 출력(nulls last)
     */
    @Test
    public void sort() throws Exception {
        //given
        em.persist(new Member(null, 100));
        em.persist(new Member("member5", 100));
        em.persist(new Member("member6", 100));

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

        //then
        Member member5 = result.get(0);
        Member member6 = result.get(1);
        Member memberNull = result.get(2);

        Assertions.assertThat(member5.getUsername()).isEqualTo("member5");
        Assertions.assertThat(member6.getUsername()).isEqualTo("member6");
        Assertions.assertThat(memberNull.getUsername()).isNull();
    }

위와 같이 orderBy안에 파라미터로 정렬 할 수 있다.

 

 

페이징


조회 건수 제한

    @Test
    public void paging(){
        List<Member> result = queryFactory
                .selectFrom(member)
                .orderBy(member.username.desc())
                .offset(1)      // 0부터 시작
                .limit(2)       // 최대 2건 조회
                .fetch();

        assertThat(result.size()).isEqualTo(2);
    }

전체 조회 수가 필요하다면??

fetch() 대신 fetchResults()로 바꿔주면 된다.
주의
count 쿼리가 실행되니 성능상 주의해야 한다.
성능이 나오지 않는다면, count 쿼리를 분리해야 한다.

 

 

 

집합


집합 함수

    @Test
    public void aggregation() {
        List<Tuple> result = queryFactory
                .select(
                        member.count(),     // 개수
                        member.age.sum(),   // 합
                        member.age.avg(),   // 평균
                        member.age.max(),   // 가장 큰 값
                        member.age.min())   // 가장 작은 값
                .from(member)
                .fetch();

        //Tuple은 querydsl이 제공하는 값
        Tuple tuple = result.get(0);
        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.max())).isEqualTo(40);
        assertThat(tuple.get(member.age.min())).isEqualTo(10);
    }

위와 같이 java 코드처럼 사용가능하다.

 

GroupBy

    @Test
    public void group() throws Exception {
        //given
        List<Tuple> result = queryFactory
                .select(team.name, member.age.avg())
                .from(member)
                .join(member.team, team)
                .groupBy(team.name)     // 팀 이름으로 그룹
                .fetch();

        //when
        Tuple teamA = result.get(0);
        Tuple teamB = result.get(1);

        //then
        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);
    }

groupBy, 그룹화된 결과를 제한하려면 having

 

GroupBy, having

                .groupBy(team.name)     // 팀 이름으로 그룹
                .having(team.name.eq("teamB"))

위처럼 "teamB"와 같은 team만 가져올 수 있습니다.

 

 

 

조인 - 기본 조인


    /**
     * 팀 A에 소속된 모든 회원
     */
    @Test
    public void join() throws Exception {
        List<Member> result = queryFactory
                .selectFrom(member)
                .join(member.team, team)
                .where(team.name.eq("teamA"))
                .fetch();

        assertThat(result).extracting("username").containsExactly("member1", "member2");
    }

위의 방식으로 join을 사용할 수 있다. leftjoin, rightjoin 모두 사용 가능하다.

연관관계가 없는 경우 from 절에 모든 데이터를 가져온 뒤, where 문으로 theta join할 수 있다.

 

 

조인 - on절


on절을 활용한 조인

  1. 조인 대상 필터링
  2. 연관관계 없는 엔티티 외부 조인

 

조인 대상 필터링

    /**
     * 예) 회원과 팀을 조인하면서, 팀 이름이 teamA인 팀만 조인, 회원은 모두 조회
     * jpql : select m, t from Member m left join m.team t on t.name = "teamA"
     */
    @Test
    public void join_on_filtering() throws Exception {
        List<Tuple> result = queryFactory
                .select(member, team)
                .from(member)
                .join(member.team, team)
                .on(team.name.eq("teamA"))
                .fetch();

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

 

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

    /**
     * 연관관계가 없는 엔티티 외부 조인
     * 회원의 이름이 팀 이름과 같은 대상 외부 조인
     * jpql : select m, t from Member m LEFT JOIN Team t on m.username = t.name
     */
    @Test
    public void join_on_no_relation() throws Exception {
        em.persist(new Member("teamA"));
        em.persist(new Member("teamB"));
        em.persist(new Member("teamC"));

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

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

주의해야 할 점은 leftjoin() 부분에 일반 조인과 다르게 엔티티가 하나만 들어갔다.

  • 일반 조인 : leftJoin(member.team, team)
  • on 조인 : from(member).leftJoin(team).on(~~~)

위의 방식으로 조인 문법이 다르다.

 

 

 

조인 - 페치 조인


    @Test
    public void fetchjoinUse() throws Exception {
        em.flush();
        em.clear();

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

        boolean loaded = emf.getPersistenceUnitUtil().isLoaded(findMember.getTeam());
        assertThat(loaded).as("페치조인 미적용").isTrue();
    }

join().fetchJoin() 을사용하면 fetch join이 된다.

 

 

 

서브 쿼리


com.querydsl.jpa.JPAExpressions 사용

 

서브 쿼리 eq 사용

    /**
     * 나이가 가장 많은 회원 조회
     */
    @Test
    public void subQuery() {
        QMember memberSub = new QMember("memberSub");

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

        assertThat(result.get(0).getAge()).isEqualTo(40);
    }

같은 QType을 쓰면안되기때문에, 서브쿼리에 넣을 QType은 새롭게 만들어서 넣어줘야 한다.

 

서브 쿼리 goe 사용

    /**
     * 나이가 평균 이상인 회원
     */
    @Test
    public void subQueryGoe() {
        QMember memberSub = new QMember("memberSub");

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

        assertThat(result.size()).isEqualTo(2);
    }

goe는 >= 라고 보면된다.

 

서브 쿼리 In 사용

    /**
     * 나이가 10 초과인 회원
     */
    @Test
    public void subQueryIn() {
        QMember memberSub = new QMember("memberSub");

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

        assertThat(result.size()).isEqualTo(3);
    }

 

select 절에 subquery

    /**
     * 나이가 10 초과인 회원
     */
    @Test
    public void selectSubQuery() {
        QMember memberSub = new QMember("memberSub");

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

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

 

from 절의 서브쿼리 한계

JPA JPQL 서브쿼리의 한계점으로 from 절의 서브쿼리(인라인 뷰)는 지원하지 않는다. 

 

from 절의 서브쿼리 해결방안

  • 서쿼리를 Join으로 변경한다. (가능한 상황도 있고, 불가능한 상황도 있다고 한다.)
  • 애플리케이션에서 쿼리를 2번 분리해서 실행한다.
  • nativeSQL을 사용한다.

from절의 서브 쿼리를 줄이기 위한 책을 추천해주었다. SQL AntiPattern 나중에 읽을 기회가 된다면 사서 봐보자.

 

 

 

Case 문


    @Test
    public void basicCase() {
        List<String> result = queryFactory
                .select(member.age.when(10).then("열살")
                        .when(20).then("스무살")
                        .otherwise("서른살 이상"))
                .from(member)
                .fetch();

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

    @Test
    public void complexCase() {
        List<String> result = 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 s : result){
            System.out.println("s = " + s);
        }
    }

간단한 case문 예제이다.

 

orderyBy에서 Case문 함께 사용하기

 

예를들어 아래와 같은 임의의 순서로 회원을 출력하고 싶다면?

  1. 0~30살이 아닌 회원을 가장 먼저 출력
  2. 0~20살 회원 출력
  3. 21~30살 회원 출력
    @Test
    public void addCase() {
        NumberExpression<Integer> rankPath = new CaseBuilder()
                .when(member.age.between(0, 20)).then(2)
                .when(member.age.between(21, 30)).then(1)
                .otherwise(3);

        List<Tuple> result = queryFactory
                .select(member.username, member.age, rankPath)
                .from(member)
                .orderBy(rankPath.desc())
                .fetch();

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

Querydsl은 자바 코드로 작성하기 때문에 rankPath처럼 복잡한 조건을 변수로 선언해서 select절, orderBy절에서 사용할 수 있다.

 

 

 

상수, 문자 더하기


상수 더하기

    /**
     * 상수 더하기
     */
    @Test
    public void constant() {
        List<Tuple> result = queryFactory
                .select(member.username, Expressions.constant("A"))
                .from(member)
                .fetch();

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

Expressions.constant()를 사용하면 된다.

 

문자 더하기

    /**
     * 문자 더하기
     */
    @Test
    public void concat() {
        //{username}_{age}
        List<String> result = queryFactory
                .select(member.username.concat("_").concat(member.age.stringValue()))
                .from(member)
                .where(member.username.eq("member1"))
                .fetch();

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

가져온 값을 .stringValue()를 사용하며 string 변환하면 더할 수 있다.

 

 

 

 

 

 

 

 

출처

 

실전! Querydsl - 인프런 | 강의

Querydsl의 기초부터 실무 활용까지, 한번에 해결해보세요!, - 강의 소개 | 인프런...

www.inflearn.com

 

build.gralde 코드 추가 (주석된 부분 추가)

buildscript {
	ext {
		queryDslVersion = "5.0.0"
	}
}

plugins {
	id 'org.springframework.boot' version '2.6.7'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	//querydsl 추가
	id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
	id 'java'
}

group = 'study'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	//querydsl 추가
	implementation "com.querydsl:querydsl-jpa:${queryDslVersion}"
	implementation "com.querydsl:querydsl-apt:${queryDslVersion}"


	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

test {
	useJUnitPlatform()
}

//querydsl 추가 시작
def querydslDir = "$buildDir/generated/querydsl"

querydsl {
	jpa = true
	querydslSourcesDir = querydslDir
}
sourceSets {
	main.java.srcDir querydslDir
}
compileQuerydsl{
	options.annotationProcessorPath = configurations.querydsl
}
configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
	querydsl.extendsFrom compileClasspath
}
//querydsl 추가 끝

검증용 엔티티 생성 (Hello Class)

package study.querydsl.entity;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
@Getter
@Setter
public class Hello {

    @Id @GeneratedValue
    private Long id;
}

후에 오른쪽 gradle 클릭 (없으면 왼쪽 맨아래 네모난 박스 클릭 마우스 이동 후 gradle 클릭) ->  querydsl 클릭 -> task 클릭 -> other 클릭 -> compileQuerydsl 더블 클릭

참고
위의 작업없이 build만 해도 되긴함.

 

후에 build>generated>querydsl>study.qeurydsl.entity를 보면 QHello가 생선된 걸 볼 수 있다.

QHello 코드

package study.querydsl.entity;

import static com.querydsl.core.types.PathMetadataFactory.*;

import com.querydsl.core.types.dsl.*;

import com.querydsl.core.types.PathMetadata;
import javax.annotation.processing.Generated;
import com.querydsl.core.types.Path;


/**
 * QHello is a Querydsl query type for Hello
 */
@Generated("com.querydsl.codegen.DefaultEntitySerializer")
public class QHello extends EntityPathBase<Hello> {

    private static final long serialVersionUID = 1910216155L;

    public static final QHello hello = new QHello("hello");

    public final NumberPath<Long> id = createNumber("id", Long.class);

    public QHello(String variable) {
        super(Hello.class, forVariable(variable));
    }

    public QHello(Path<? extends Hello> path) {
        super(path.getType(), path.getMetadata());
    }

    public QHello(PathMetadata metadata) {
        super(Hello.class, metadata);
    }

}
Hello 엔티티를 보고 querydsl이 QHello란 엔티티를 만들어준 것이다.
QHello는 querydsl에서 쿼리와 관련된 작업을 할 때 사용하기위해 만들어지는 엔티티이다.
참고
querydsl이 만들어준 QHello와 같은 엔티티는 git에서 관리하면 안된다. (자동으로 gitignore되는 폴더에 생성되기때문에 상관없긴하다.)

 

 

 

출처

 

실전! Querydsl - 인프런 | 강의

Querydsl의 기초부터 실무 활용까지, 한번에 해결해보세요!, - 강의 소개 | 인프런...

www.inflearn.com

 

 

+ Recent posts