이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.
Query DSL을 Spring Data JPA에서 사용하려면 사용자 정의 리포지토리를 만들어야 한다.
MemberTeamDto
@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;
}
}
MemberSearchCondition
@Data
public class MemberSearchCondition {
//회원명, 팀명, 나이(ageGoe, ageLoe)
private String username;
private String teamName;
private Integer ageGoe;
private Integer ageLoe;
}
사용자 정의 리포지토리 생성
사용자 정의 리포지토리 사용법
- 사용자 정의 인터페이스 작성
- 사용자 정의 인터페이스 구현 클래스 작성
- 스프링 데이터 리포지토리에 사용자 정의 인터페이스 상속
사용자 정의 인터페이스 구현 클래스
규칙: 사용자 정의 인터페이스 이름(리포지토리 인터페이스 이름도 가능) + Impl
스프링 데이터 JPA가 인식해서 스프링 빈으로 등록
여기서는 사용자 정의 인터페이스 구현 클래스의 이름을 위의 그림과 다르게 MemberRepositoryCustomImpl로 작성하였다
사용자 정의 인터페이스 인터페이스 작성
public interface MemberRepositoryCustom {
List<MemberTeamDto> search(MemberSearchCondition condition);
}
사용자 정의 인터페이스 구현 클래스 작성
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
private final JPAQueryFactory queryFactory;
public MemberRepositoryCustomImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}
@Override
//회원명, 팀명, 나이(ageGoe, ageLoe)
public List<MemberTeamDto> search(MemberSearchCondition condition) {
return queryFactory
.select(new QMemberTeamDto(
QMember.member.id,
QMember.member.username,
QMember.member.age,
QTeam.team.id,
QTeam.team.name))
.from(QMember.member)
.leftJoin(QMember.member.team, QTeam.team)
.where(usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe()))
.fetch();
}
private BooleanExpression usernameEq(String username) {
return StringUtils.isEmpty(username) ? null : member.username.eq(username);
}
private BooleanExpression teamNameEq(String teamName) {
return StringUtils.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);
}
}
- search 메서드
-queryFactory를 통해 Member와 Team 엔티티를 leftJoin 하여 조회한다.
-조회하는 필드는 Member의 id, username, age와 Team의 id, name이다.
-조회된 데이터를 MemberTeamDto에 매핑하여 반환한다.
-쿼리에 사용되는 조건들은 where 절에서 각 메서드를 호출하여 동적으로 생성된다. 각 조건 메서드는 null일 경우 해당 조건을 무시-하도록 설계되었다. - 조건 메서드
-각 조건 메서드는 BooleanExpression을 반환하며, null을 반환할 경우 해당 조건은 무시된다.
-usernameEq: username이 비어 있지 않으면 해당 값을 기준으로 검색한다.
-teamNameEq: teamName이 비어 있지 않으면 해당 팀 이름을 기준으로 검색한다.
-ageGoe: ageGoe가 null이 아니면 해당 나이 이상의 조건을 추가한다.
-ageLoe: ageLoe가 null이 아니면 해당 나이 이하의 조건을 추가한다. - 동적 쿼리: usernameEq, teamNameEq, ageGoe, ageLoe 메서드들은 입력 값이 없는 경우(null 또는 빈 값) 조건을 무시하도록 구현되어 있어 동적 쿼리를 구성할 수 있다.
- BooleanExpression: Querydsl에서 사용하는 조건식 타입으로, 각 조건을 결합하여 where 절에 사용할 수 있다. null을 반환할 경우 해당 조건은 무시되므로, 동적으로 쿼리의 조건을 설정할 수 있다.
- leftJoin: Member와 Team을 조인할 때 leftJoin을 사용하여 Member가 속한 Team이 없는 경우에도 결과에 포함될 수 있도록 한다.
스프링 데이터 리포지토리 상속
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
List<Member> findByUsername(String username);
}
Query DSL 페이징 연동
전체 카운트를 한 번에 조회하는 단순한 방법
public interface MemberRepositoryCustom {
//...
//전체 카운트를 한 번에 조회하는 단순한 방법
Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable);
//데이터 내용과 전체 카운트를 별도로 조회하는 방법
Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable);
//CountQuery 최적화
Page<MemberTeamDto> searchPageOptimizationCount(MemberSearchCondition condition, Pageable pageable);
}
MemberRepositoryImpl
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
private final JPAQueryFactory queryFactory;
public MemberRepositoryCustomImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}
//...
/**
* 단순한 페이징, fetchResults() 사용
*/
@Override
public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable) {
QueryResults<MemberTeamDto> results = queryFactory
.select(new QMemberTeamDto(
QMember.member.id,
QMember.member.username,
QMember.member.age,
QTeam.team.id,
QTeam.team.name))
.from(QMember.member)
.leftJoin(QMember.member.team, QTeam.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);
}
}
fetchResults(), fetchCount() Deprecated는 QueryDSL에서 향후 미지원한다고 발표하였다.
따라서 이 글의 Querydsl fetchResults(), fetchCount() Deprecated(향후 미지원) 섹션의 SearchPageSimple() 리펙토링 방식을 사용하는 것을 권장한다.
이 코드는 Querydsl을 사용하여 페이징 처리된 조회 쿼리를 작성한 예시이다.
사용자가 입력한 조건에 맞춰 조회 결과를 페이징하여 반환하는 기능을 제공한다.
주요 메서드는 searchPageSimple으로, 주어진 조건과 Pageable 객체를 기반으로 조회 결과를 처리한 후 Page 객체로 반환한다.
- QueryResults<MemberTeamDto> results
QueryFactory를 통해 QMember와 QTeam을 기준으로 조회를 수행한다.
select 절에서 Member와 Team의 필드를 가져와 MemberTeamDto에 매핑한다.
where 절에서는 동적으로 쿼리 조건을 적용하여 데이터를 필터링한다.
offset과 limit를 pageable 객체에서 가져와 페이징 처리를 한다.
fetchResults() 메서드는 결과 목록과 총 개수를 동시에 가져온다. - 페이징 처리
results.getResults()를 통해 조회된 MemberTeamDto 리스트를 가져온다.
results.getTotal()은 조건에 맞는 전체 데이터의 개수를 반환한다.
new PageImpl<>(content, pageable, total)을 통해 페이징된 결과를 반환한다. PageImpl은 Spring Data에서 제공하는 페이징 결과 클래스이다. - Pageable 객체
Pageable은 페이징과 정렬 정보를 포함한 객체로, 클라이언트로부터 전달받은 페이지 번호, 페이지 크기, 정렬 등의 정보를 담고 있다.
offset은 조회를 시작할 데이터의 위치(즉, 몇 번째 데이터부터 조회할지를 의미)이며, limit는 한 페이지당 조회할 데이터의 개수를 설정한다.
- offset(pageable.getOffset())은 조회 시작 위치를 설정
- limit(pageable.getPageSize())은 한 페이지에 포함될 데이터 개수를 설정
- fetchResults()로 쿼리 실행 후, 조회된 리스트와 총 개수를 가져온다.
- PageImpl을 사용하여 최종 결과를 페이징 형식으로 반환한다.
데이터 내용과 전체 카운트를 별도로 조회하는 방법
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
private final JPAQueryFactory queryFactory;
public MemberRepositoryCustomImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}
//...
/**
* 복잡한 페이징
* 데이터 조회 쿼리와, 전체 카운트 쿼리를 분리
*/
@Override
public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
List<MemberTeamDto> content = queryFactory
.select(new QMemberTeamDto(
QMember.member.id,
QMember.member.username,
QMember.member.age,
QTeam.team.id,
QTeam.team.name))
.from(QMember.member)
.leftJoin(QMember.member.team, QTeam.team)
.where(usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe()))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
long total = queryFactory
.select(QMember.member)
.from(QMember.member)
.leftJoin(QMember.member.team, QTeam.team)
.where(usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe()))
.fetchCount();
return new PageImpl<>(content, pageable, total);
}
}
fetchResults(), fetchCount() Deprecated는 QueryDSL에서 향후 미지원한다고 발표하였다.
따라서 이 글의 CountQuery 최적화 섹션의 searchPageOptimizationCount 메서드를 방식을 이용하는 것을 권장한다.
이 코드는 Querydsl을 사용하여 페이징 처리된 조회 쿼리를 구현한 예시이다.
searchPageComplex 메서드는 동적 조건을 기반으로 데이터를 조회하고, 페이징 처리된 결과를 반환한다.
주요 특징은 fetch()와 fetchCount()를 사용해 데이터 조회와 총 개수 계산을 별도로 처리하여 성능을 최적화하려는 접근이다.
- 데이터 조회 부분
List<MemberTeamDto> content: 실제 데이터를 조회하는 쿼리이다. QueryFactory를 통해 Member와 Team을 조인하여 필요한 데이터를 MemberTeamDto로 매핑한 후 페이징 처리(offset, limit)를 적용해 조회한다.
fetch()는 조건에 맞는 데이터를 조회해 리스트로 반환한다. - 총 데이터 개수 조회 부분
long total: 총 데이터 개수를 조회하는 쿼리이다. 데이터의 페이징 처리와 별개로, 전체 결과의 총 개수를 계산하기 위해 fetchCount()를 사용한다.
이 쿼리는 content 쿼리와 동일한 조건을 사용하지만, 실제 데이터를 가져오는 대신 개수만 계산한다. - 페이징 결과 반환
new PageImpl<>(content, pageable, total)을 통해 조회된 데이터를 페이징 형식으로 반환한다. PageImpl 클래스는 스프링 데이터에서 제공하는 Page 인터페이스의 구현체로, 페이징된 데이터 목록과 관련 정보를 담고 있다.
- offset(pageable.getOffset())과 limit(pageable.getPageSize())를 사용하여 페이징 처리한다. offset은 조회를 시작할 위치를, limit은 한 페이지당 조회할 데이터 개수를 설정한다.
- fetchCount()는 전체 데이터의 개수를 반환하며, 페이징 처리에 사용된다. 이 부분은 데이터의 개수만 계산하기 때문에 select(QMember.member)로 데이터를 조회하지 않고 개수를 세는 최적화된 쿼리다.
- PageImpl<>(content, pageable, total)을 사용하여 페이징된 데이터를 담은 Page 객체를 반환한다.
CountQuery 최적화
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
private final JPAQueryFactory queryFactory;
public MemberRepositoryCustomImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}
//...
@Override
public Page<MemberTeamDto> searchPageOptimizationCount(MemberSearchCondition condition, Pageable pageable) {
List<MemberTeamDto> content = queryFactory
.select(new QMemberTeamDto(
QMember.member.id,
QMember.member.username,
QMember.member.age,
QTeam.team.id,
QTeam.team.name))
.from(QMember.member)
.leftJoin(QMember.member.team, QTeam.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
.select(QMember.member.count())
.from(QMember.member)
.leftJoin(QMember.member.team, QTeam.team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
);
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}
}
이 코드는 Querydsl을 사용하여 페이징을 최적화한 쿼리를 작성하는 예시이다.
이 메서드는 MemberSearchCondition을 기반으로 검색 조건을 적용하고, Pageable 객체를 사용하여 페이지네이션을 수행한다. 또한, 페이징 처리에 있어서 성능을 최적화하기 위해 count 쿼리를 분리하여 처리한다.
쿼리 실행 과정
- content 쿼리는 실제로 조회할 데이터 목록을 가져오는 쿼리이다. 이때, Pageable 객체의 offset과 limit을 사용하여 페이징을 처리한다.
- countQuery는 전체 데이터의 개수를 계산하는 쿼리이다. 이 쿼리는 페이징에 필요한 총 개수를 구하기 위해 사용된다.
성능 최적화
- 페이징 성능을 최적화하기 위해 PageableExecutionUtils.getPage()를 사용한다. 이는 content 쿼리가 실행된 결과와, 필요할 때만 countQuery를 실행하여 전체 페이지 수를 계산하도록 한다. 따라서 불필요한 count 쿼리 실행을 줄여 성능을 높인다.
쿼리 조건 설정
- usernameEq, teamNameEq, ageGoe, ageLoe 같은 메서드들은 조건이 있을 때만 쿼리에 추가하는 방식으로, 동적으로 쿼리 조건을 설정할 수 있다.
코드 설명
- queryFactory: QueryDSL에서 제공하는 쿼리 생성기.
- QMember.member: QueryDSL로 생성된 QType 클래스.
- QTeam.team: 마찬가지로 Team 엔티티의 QType.
- PageableExecutionUtils.getPage(): 페이징 처리를 위해 사용하는 유틸리티 메서드로, 페이징 결과 리스트와 전체 개수를 넘겨준다.
Querydsl fetchResults(), fetchCount() Deprecated(향후 미지원)
Querydsl의 fetchCount() , fetchResult() 는 개발자가 작성한 select 쿼리를 기반으로 count용 쿼리를 내부에서 만들어서 실행한다.
그런데 이 기능은 select 구문을 단순히 count 처리하는 용도로 바꾸는 정도이다.
따라서 단순한 쿼리에서는 잘 동작하지만, 복잡한 쿼리에서는 제대로 동작하지 않는다.
Querydsl은 향후 fetchCount() , fetchResult() 를 지원하지 않기로 결정했다.
따라서 count 쿼리가 필요하면 다음과 같이 별도로 작성해야한다.
@Test
public void count() {
Long totalCount = queryFactory
//.select(Wildcard.count) //select count(*)
.select(member.count()) //select count(member.id)
.from(member)
.fetchOne();
System.out.println("totalCount = " + totalCount);
}
count(*) 을 사용하고 싶으면 예제의 주석처럼 Wildcard.count 를 사용하면 된다.
member.count() 를 사용하면 count(member.id) 로 처리된다.
응답 결과는 숫자 하나이므로 fetchOne() 을 사용한다.
fetchResults()는 QueryDSL에서 한번에 결과 리스트와 전체 카운트를 가져오는 방식이지만, 성능상의 이슈로 인해 deprecated되었다.
이를 대신하여, 결과 목록을 가져오는 쿼리와 전체 카운트를 가져오는 쿼리를 분리하여 리팩토링할 수 있다.
아래는 fetchResults()를 사용하지 않고 동일한 기능을 수행하도록 리팩토링한 코드이다.
SearchPageSimple() 리펙토링
@Override
public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable) {
// content 쿼리: 결과 리스트를 가져오는 쿼리
List<MemberTeamDto> content = queryFactory
.select(new QMemberTeamDto(
QMember.member.id,
QMember.member.username,
QMember.member.age,
QTeam.team.id,
QTeam.team.name))
.from(QMember.member)
.leftJoin(QMember.member.team, QTeam.team)
.where(usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe()))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// count 쿼리: 전체 카운트를 가져오는 쿼리
long total = queryFactory
.select(QMember.member.count())
.from(QMember.member)
.leftJoin(QMember.member.team, QTeam.team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
)
.fetchOne();
return new PageImpl<>(content, pageable, total);
}
- content는 fetch()를 사용하여 결과 리스트만 가져온다. fetch()는 리스트만 반환하며, fetchResults()처럼 총 개수나 기타 정보는 반환하지 않는다.
- total은 fetchOne()을 사용하여 전체 개수를 가져온다. count() 쿼리만 실행하여 효율적으로 전체 데이터를 구한다.
- 최종적으로 PageImpl을 사용하여 결과 리스트(content), 페이지 정보(pageable), 전체 개수(total)를 반환한다.
이렇게 쿼리를 분리하면, 각각의 목적에 맞는 쿼리를 효율적으로 실행할 수 있어 성능 최적화에 도움이 된다.
컨트롤러 개발
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberRepository memberRepository;
@GetMapping("/v2/members")
public Page<MemberTeamDto> searchMemberV2(MemberSearchCondition condition, Pageable pageable) {
return memberRepository.searchPageSimple(condition, pageable);
}
@GetMapping("/v3/members")
public Page<MemberTeamDto> searchMemberV3(MemberSearchCondition condition, Pageable pageable) {
return memberRepository.searchPageComplex(condition, pageable);
}
@GetMapping("/v4/members")
public Page<MemberTeamDto> searchMemberV4(MemberSearchCondition condition, Pageable pageable) {
return memberRepository.searchPageOptimizationCount(condition, pageable);
}
}
- /v1/members (검색 조건만으로 검색)
검색 조건인 MemberSearchCondition을 기반으로 데이터를 필터링하여 List<MemberTeamDto>를 반환한다.
페이징 처리 없이 모든 결과를 반환하는 방식이다. - /v2/members (간단한 페이징 처리)
MemberSearchCondition과 Pageable을 받아 간단한 페이징 처리를 한다.
memberRepository.searchPageSimple()을 호출하며, 페이징된 데이터를 Page<MemberTeamDto>로 반환한다.
searchPageSimple()은 데이터 조회와 개수 조회를 동시에 처리하는 간단한 페이징 로직이다. - /v3/members (복잡한 페이징 처리)
MemberSearchCondition과 Pageable을 받아 복잡한 페이징 처리를 한다.
memberRepository.searchPageComplex()는 데이터 조회와 카운트 쿼리를 분리하여 각각 처리하며, 페이징 성능을 개선한 방식이다. - /v4/members (최적화된 카운트 쿼리 적용)
MemberSearchCondition과 Pageable을 기반으로 데이터를 조회하지만, 카운트 쿼리를 최적화하여 필요할 때만 실행하는 방식이다.
memberRepository.searchPageOptimizationCount()는 PageableExecutionUtils.getPage()를 사용해 불필요한 카운트 쿼리를 방지하는 방식으로 최적화되어 있다.
예시 요청
http://localhost:8080/v4/members?ageGoe=30&ageLoe=44&page=0&size=5
이 요청은 v4 엔드포인트에 대한 호출로, 다음 조건을 포함한다
- 나이 조건: ageGoe=30, ageLoe=44 — 나이가 30 이상 44 이하인 멤버를 조회한다.
- 페이징 조건: page=0, size=5 — 0번째 페이지부터 5개씩 조회한다.
이 호출은 searchPageOptimizationCount() 메서드를 사용해 페이징을 처리하며, 최적화된 카운트 쿼리를 적용하여 성능을 높인다.
반환 결과
Page<MemberTeamDto> 형태로 JSON 응답이 반환된다. 여기에는 다음 정보가 포함된다:
- content: 해당 페이지에 해당하는 MemberTeamDto 리스트.
- totalElements: 전체 결과의 총 개수.
- totalPages: 전체 페이지 수.
- pageable: 현재 페이징 상태.
'Back-End > QueryDSL' 카테고리의 다른 글
[Query DSL] 중급 문법 (0) | 2024.08.19 |
---|---|
[Query DSL] 기본 문법 (0) | 2024.08.18 |
[Query DSL] Query DSL 초기 세팅 (0) | 2024.08.17 |