실전 Querydsl

Hyun·2023년 11월 6일

JPA

목록 보기
4/4

  • 자바 코드로 쿼리를 작성할 수 있다.

  • 필드명을 잘못 작성하거나 쿼리문에 오타가 있으면 컴파일 에러를 발생시킨다.

  • 쉽게 동적쿼리를 작성할 수 있다.

Querydsl 적용

  • Querydsl 은 Q-Type 파일을 생성하는 것이 중요하다.
dependencies {
    // ...

    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

build.gradle 에 다음과 같이 추가한다.


  • Gradle 탭에서 Tasks > build > clean 을 실행하여 build 디렉토리를 깔끔하게 지운다.


  • Gradle 탭에서 Tasks > other > compileJava 를 실행하여 Q-Type 파일을 생성한다.


다음과 같이 build 디렉토리 하위에 Q-Type 파일이 생성된다.


  • Q-Type 에 alias를 넣어 새로 생성하거나 Q-Type의 기본 인스턴스를 사용하여 쿼리를 날릴 수 있다.
    JPAQueryFactory query = new JPAQueryFactory(em);
    // QHello hello = new QHello("hello");
    QHello hello = QHello.hello;

    Hello h1 = query
            .selectFrom(hello)
            .fetchOne();

Querydsl 기본문법

JPAQueryFactory 와 Q-Type

  • EntityManager 를 넣어 JPAQueryFactory 를 생성해야 쿼리문을 작성할 수 있다.
@Autowired
EntityManager em;

JPAQueryFactory queryFactory = new JPAQueryFactory(em);

  • Q-Type 클래스는 직접 생성하기 보다는 내부의 기본 인스턴스를 사용하는 것이 깔끔하다.

    • static import 를 통해 더욱 가독성을 높일 수 있다.
before

    QMember mem = new QMember("member");

    Member findMember = queryFactory
            .select(mem)
            .from(mem)
            .where(mem.username.eq("member1"))
            .fetchOne();

    assertThat(findMember.getUsername()).isEqualTo("member1");


============================================

after

    // QMember.member 를 static import

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

    assertThat(findMember.getUsername()).isEqualTo("member1");

정적 검색 조건 쿼리

    Member findMember = queryFactory
            .selectFrom(member)
            .where(
                    member.username.eq("member1"),
                    member.age.between(10, 30)
            )
            .fetchOne();
    
    // JPQL
    // select m from Member m
    // where m.username = 'member1' and m.age between 10 and 30;
  • selectFromselectfrom 을 합친 메서드이다.
  • where 안에 조건 쿼리들을 , 로 구분하면 and 를 붙여 쿼리를 만든다.

  • 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%’ 검색

// ...

조건 쿼리 예시


결과 조회

  • 메서드에 따라 조회 쿼리 결과물을 가져오는 방식이 다르다.

  • count 쿼리를 자동으로 날려주는 결과 조회 메서드인 .fetchResults().fetchCount() 는 deprecated 되었다.

    • count 쿼리는 직접 작성하는 것이 좋다.
    List<Member> fetch = queryFactory
            .selectFrom(member)
            .fetch();                       // 리스트에 담아 반환


    Member fetchOne = queryFactory
            .selectFrom(member)
            .fetchOne();                    // 단 건 조회인 경우, 객체로 바로 반환


    Member fetchFirst = queryFactory
            .selectFrom(member)
            .fetchFirst();                  // 첫 번째만 조회 (limit 1) 후, 객체로 바로 반환
  • .fetch()
    • 결과물을 리스트에 담는다.

  • .fetchOne()
    • 결과물이 1개인 경우 바로 객체로 반환한다.
    • 결과물이 없는 경우 null 을 반환한다.
    • 결과물이 여러개인 경우 예외를 발생시킨다.

  • .fetchFirst()
    • 1개만 조회하는 limit 1 쿼리를 추가한다.
    • 결과물이 1개인 경우 바로 객체로 반환한다.
    • 결과물이 없는 경우 null 을 반환한다.

정렬

    List<Member> result = queryFactory
            .selectFrom(member)
            .where(member.age.eq(100))
            .orderBy(member.age.desc(), member.username.asc().nullsLast())
            .fetch();
  • orderBy 내에 다음과 같이 정렬 조건을 추가하여 정렬할 수 있다.
  • 정렬 시 null 을 뒤로 가게하는 nullsLast() 도 사용가능하다.

페이징

    List<Member> contents = queryFactory
            .selectFrom(member)
            .orderBy(member.username.desc())
            .offset(1)
            .limit(2)
            .fetch();                               // 콘텐츠를 가져오는 페이징 쿼리 


    Long totalCount = queryFactory
            .select(member.count())
            .from(member)
            .fetchOne();                            // total count 쿼리
  • 다음과 같이 페이징의 contents 를 가져오는 쿼리와 totalCount 쿼리를 각각 날린다.
  • offset, limit 메서드를 사용해 쉽게 페이징할 수 있다.

집계함수와 그룹화

  • 집계함수를 사용한 경우, 특정 객체에 데이터를 담아 반환하는 대신 튜플로 반환할 수도 있다.

    • 튜플 객체.get(select 한 컬럼) 을 사용해 튜플에 담긴 데이터를 가져온다.
    Tuple tuple = queryFactory
            .select(
                    member.count(),         // 총 개수
                    member.age.sum(),       // 총합
                    member.age.avg(),       // 평균
                    member.age.max(),       // 최대값
                    member.age.min()        // 최소값
            )
            .from(member)
            .fetchOne();


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

  • groupBy 메서드를 사용해 그룹화도 가능하다.
    List<Tuple> result = queryFactory
            .select(team.name, member.age.avg())
            .from(member)
            .join(member.team, team)
            .groupBy(team.name)                     // 팀 명으로 그룹화
            .having(team.name.contains("Korea"))    // 팀 명이 Korea 를 포함하는 경우만 결과로 출력
            .fetch();

groupByhaving 모두 사용할 수 있다.


조인

  • FK-PK 조인
    List<Member> result = queryFactory
            .selectFrom(member)
            .join(member.team, team)
            .where(team.name.eq("teamA"))	// team 과 조인하였기에, team 이름을 사용한 조건 쿼리 가능
            .fetch();
  • .join(루트 엔티티의 연관 엔티티 필드, 연관 엔티티 Q-Type) 을 통해 내부조인 가능
  • joinleftJoin 이나 rightJoin 으로 바꿔 외부조인도 가능하다.

  • FK-PK 연관관계가 없는 세타 조인
    List<Tuple> result = queryFactory
            .select(member, team)
            .from(member)
            .join(team).on(member.username.eq(team.name))   // 회원의 이름과 팀의 이름이 같은 경우 조인 (PK-FK 조인 x)
            .fetch();
  • .select(member, team)
    • FK-PK 연관관계가 없으므로 memberteam 데이터를 모두 가져온다.

  • .on(조인 조건 쿼리)
    • 조인 조건을 만족하는 경우 조인한다.
    • 이 경우 join 내부엔 엔티티가 1개만 들어간다.
      -> .join(조인하려는 엔티티 Q-Type)

  • joinleftJoin 이나 rightJoin 으로 바꿔 외부조인도 가능하다.

  • 페치 조인

    • join(...) 뒤에 fetchJoin() 을 추가하여 페치 조인을 할 수 있다.
    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() 을 추가하여 페치 조인 가능

  • emf.getPersistenceUnitUtil().isLoaded(검사하려는 엔티티)
    • 엔티티 매니저 팩토리에서 제공하는 기능을 통해 특정 엔티티가 프록시 엔티티인지 실제 엔티티인지 판별한다.
    • 반환 값이 true 인 경우 실제 엔티티이다.

서브 쿼리

  • JPAExpressions 내부에 서브 쿼리를 작성할 수 있다.
    • static import 를 통해 가독성을 높일 수 있다.
    QMember memberSub = new QMember("memberSub");   // 같은 테이블로 서브 쿼리 작성 시, 서브 쿼리의 테이블 alias가  
                                                    // 달라야 하므로 다른 별칭의 Q-Type 생성

    List<Member> result = queryFactory
            .selectFrom(member)
            .where(member.age.goe(
                    JPAExpressions
                            .select(memberSub.age.avg())
                            .from(memberSub)            	// select avg(m.age) from Member m (서브 쿼리)
            ))
            .fetch();

중급 문법


프로젝션과 결과 반환

  • 프로젝션 대상이 하나인 경우 타입을 명확하게 지정하여 가져올 수 있다.
    List<String> result = queryFactory
            .select(member.username)    // 단일 프로젝션 (문자열)
            .from(member)
            .fetch();

  • 프로젝션 대상이 여러개인 경우 튜플을 사용하여 가져올 수 있다.

    • 튜플은 Querydsl 기술이므로 리포지토리 계층 이외의 계층에서 사용하는 건 좋지 않다.
    List<Tuple> result = queryFactory
            .select(member.username, member.age)    // 문자열과 정수타입 프로젝션
            .from(member)
            .fetch();

    for (Tuple tuple : result) {
        String username = tuple.get(member.username);   // get(프로젝션 이름) 을 통해 데이터를 가져온다.
        Integer age = tuple.get(member.age);
    }

  • 프로젝션 대상이 여러개인 경우 DTO 를 사용하여 가져올 수 있다.
@Data
public class MemberDto {

    private String username;
    private int age;
}



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

select 안에 Projections.bean(DTO 클래스 타입, 프로젝션 대상들...) 를 통해 데이터를 바로 DTO 에 담아 반환할 수 있다.


@Data
public class UserDto {

    private String name;
    private int age;
}



    QMember memberSub = new QMember("memberSub");

    List<UserDto> result = queryFactory
            .select(Projections.bean(UserDto.class,
                    member.username.as("name"),		// name 

                    ExpressionUtils.as(JPAExpressions
                                        .select(memberSub.age.max())
                                        .from(memberSub), "age")))	// age
            .from(member)
            .fetch();
  • DTO 클래스의 필드명과 조회해온 프로젝션 대상의 이름이 다르면 DTO 에 데이터가 들어가지 않는다.

  • .as(alias) 를 통해 DTO 클래스의 필드명과 맞춰준다.

  • 서브쿼리의 경우 .as(alias) 가 불가능하고, ExpressionUtils.as(서브쿼리, alias) 를 사용한다.

동적 쿼리

  • where 안에 조건 쿼리들을 담는다.

    • 이때 조건 쿼리가 만약 null 이면 그냥 무시된다.

    • 조건 쿼리가 여러개이면 and 쿼리와 함께 엮는다.

    private List<Member> searchMember(String usernameCond, Integer ageCond) {
        return queryFactory
                .selectFrom(member)
                .where(usernameEq(usernameCond), ageEq(ageCond))		// null 은 무시된다.
                .fetch();
    }

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

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

조건 쿼리를 BooleanExpression 으로 반환하면 .and.or 로 조립할 수 있다.

    private BooleanExpression allEq(String usernameCond, Integer ageCond) {
        return usernameEq(usernameCond).and(ageEq(ageCond));			// 조립을 통해 재사용성을 높인다.
    }

이렇게 조립할때는 null 을 주의해야 한다.

  • usernameEq(usernameCond)null 이어선 안된다.
  • ageEq(ageCond)null 이어도 된다.

수정, 삭제 벌크 연산

  • .execute() 를 통해 수정, 삭제 쿼리를 실행한다.

  • 수정, 삭제된 쿼리수를 반환한다.

  • 벌크성 수정 쿼리는 영속성 컨텍스트에 반영되지 않고 DB에 직접 날라가므로 영속성 컨텍스트와 DB간의 데이터가 불일치할 수 있다.

    • 벌크성 쿼리를 날린 후 영속성 컨텍스트를 초기화해주는 것이 좋다.

    • em.flush() & em.clear() 실행

  • 수정 벌크 연산

    long count = queryFactory               // 수정된 쿼리 수를 반환한다.
            .update(member)
            .set(member.username, "비회원")
            .where(member.age.lt(28))
            .execute();



    long count = queryFactory
            .update(member)
            .set(member.age, member.age.add(1).multiply(2))	// member.age 에 1을 더한 뒤 2를 곱한다.
            .execute();

  • 삭제 벌크 연산
    long count = queryFactory
            .delete(member)
            .where(member.age.gt(18))
            .execute();

프로젝트에 Querydsl 적용하기


Repository 에 Querydsl 적용하기

  • 만약 스프링 데이터 JPA 리포지토리 인터페이스 기능들을 사용하며 Querydsl 을 사용하고 싶다면 링크사용자 정의 리포지토리 구현 챕터를 참고하여 리포지토리를 만든다.

  • 주입받은 엔티티 매니저를 바탕으로 생성자에서 JPAQueryFactory 를 만든다.

public class MemberRepositoryImpl implements MemberRepositoryCustom {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;

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

    // ...
}

  • 설정 클래스에서 JPAQueryFactory 를 바로 스프링 빈으로 등록하여 외부에서 주입받아 사용해도 된다.
@SpringBootApplication
public class QuerydslApplication {

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

	@Bean				
	JPAQueryFactory jpaQueryFactory(EntityManager em) { 	// 스프링 빈으로 등록
		return new JPAQueryFactory(em);
	}
}


@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;

    // ...
}

동적 쿼리 조회하기

  • 사용자가 입력한 검색 조건에 따라 데이터 조회 쿼리가 동적으로 바뀐다.
@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/v1/members")
    public List<MemberTeamDto> searchMember(MemberSearchCondition condition) {
        return memberRepository.search(condition);
    }
}
  • 사용자가 입력한 검색조건을 파라미터로 받는 컨트롤러
  • 검색조건을 리포지토리에 전달한다.

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

    private String username;
    private String teamName;
    private Integer ageGoe;
    private Integer ageLoe;
}
  • 사용자가 입력한 검색조건 클래스
  • 만약 사용자가 입력하지 않은 검색조건은 null 로 처리된다.

public class MemberRepositoryImpl implements MemberRepositoryCustom {

    // ...

    @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()))	// null 인 조건 쿼리들은 생성되지 않는다.
                .fetch();
    }

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

    private BooleanExpression teamNameEq(String teamName) {
        return 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;
    }
}

리포지토리 클래스

  • new QMemberTeamDto(프로젝션 대상들 ..)QueryProjection 을 적용하여 DTO 클래스를 Q-Type 파일로 생성한 것이다.

  • usernameEq(String username), teamNameEq(String teamName), ageGoe(Integer ageGoe), ageLoe(Integer ageLoe)
    • 조건 쿼리를 생성하는 메서드
    • 사용자가 입력한 검색조건을 담는 condition 의 필드들을 조건 쿼리 메서드에 전달한다.
    • 만약 전달된 필드가 null 이면 null 을 그대로 반환한다.

  • where 메서드 안의 조건 쿼리 메서드 중 반환값이 null 인 값은 쿼리문을 생성하지 않고 제거된다.

페이징 적용하기

  • 사용자가 입력한 페이징 조건에 따라 페이지를 만든다.
@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

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

사용자로부터 페이징 조건인 pageable 을 파라미터로 받는 컨트롤러


public class MemberRepositoryImpl implements MemberRepositoryCustom {
    
    // ...

    @Override
    public Page<MemberTeamDto> searchPage(MemberSearchCondition condition, Pageable pageable) {
        List<MemberTeamDto> content = queryFactory      // 페이지의 contents 를 조회하는 쿼리
                .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();


        JPAQuery<Long> countQuery = queryFactory        // total 을 구하는 count 쿼리
                .select(member.count())
                .from(member)
                .leftJoin(member.team, team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                );


//        return new PageImpl<>(content, pageable, countQuery.fetchOne());  // 무조건 total 을 구하는 count 쿼리를 날린다.

        return PageableExecutionUtils.getPage(content, pageable, () -> countQuery.fetchOne());  // 특정 조건에선 total 을 구하는 count 쿼리를 생략한다.
    }
}

리포지토리 클래스

  • 사용자로부터 입력받은 pageable 을 바탕으로 페이징 수행

  • 페이지의 contents 를 구하는 쿼리와 데이터의 총 개수를 구하는 count 쿼리로 이루어져 있다.

  • PageableExecutionUtils.getPage(content, pageable, () -> countQuery.fetchOne())
    • Page 구현체를 만들어 반환한다.
    • count 쿼리를 통해 총 개수를 구하는 람다를 파라미터로 전달한다.
    • 항상 count 쿼리를 실행하지 않고 데이터의 총 개수를 계산할 수 있는 특정 경우엔 이를 생략한다.
      • 시작 페이지면서, 페이지 사이즈가 content 사이즈보다 크면
        total count = 현재 페이지의 content 사이즈
      • 마지막 페이지면서, 페이지 사이즈가 content 사이즈보다 크면
        total count = 이전 페이지까지의 모든 content 크기 + 현재 페이지의 content 사이즈

슬라이싱 적용하기

  • 페이지 번호를 사용하는 경우 페이징이 필요하지만 무한 스크롤과 같은 구현은 슬라이싱으로 충분하다.

  • 컨트롤러의 구현은 페이징과 유사하다.

    @Override
    public Slice<MemberTeamDto> searchPage(MemberSearchCondition condition, Pageable pageable) {
        List<MemberTeamDto> content = queryFactory      // 페이지의 contents 를 조회하는 쿼리
                .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() + 1)	// 	page size + 1 을 가져온다.
                .fetch();

        return new SliceImpl<>(content, pageable, hasNextPage(products, pageable.getPageSize()));
    }

    private Boolean hasNextPage(List<Product> products, int pageSize) {	// 다음 페이지 존재 여부를 판별한다.
        if (products.size() > pageSize) {
            products.remove(pageSize);
            return true;
        }

        return false;
    }
  • 슬라이싱의 경우 데이터의 총 개수 대신 다음 페이지 존재 여부만 파라미터로 전달한다.
    • 페이징보다 성능이 좋다.

출처

실전! Querydsl - 김영한 강사님
https://www.inflearn.com/course/querydsl-%EC%8B%A4%EC%A0%84


PageableExecutionUtils.getPage (Hugehoo 님)
https://junior-datalist.tistory.com/342

2개의 댓글

comment-user-thumbnail
2023년 11월 11일

정리가 깔끔하네요 😎 강의 한 편 뚝딱.....

1개의 답글