Querydsl - 빠른 시작, 간단 정리
해당 포스팅의 코드는 Github 를 참고해주세요.
1. build.gradle 설정
plugins {
id 'org.springframework.boot' version '2.6.2'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'com.example'
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'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// querydsl
implementation 'com.querydsl:querydsl-jpa'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
}
2. 빈 등록
@Configuration
public class QuerydslConfig {
@PersistenceContext
private EntityManager em;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(em);
}
}
3. 상속을 이용한 리포지토리
- Querydsl용 인터페이스 만들고 jpa repository에 extends
- Querydsl용 인터페이스 명은 반드시 인터페이스명+Impl
- Querydsl 사용시 QXX 클래스 사용 -> static import 하면 Qmember -> member로 사용 가능
- 엔티티가 아닌 프로젝션
- 예로, DTO 같은 경우 생성자에 @QueryProjection 붙이면 new QXXDTO로 프로젝션 가능
- 필드명 다른 경우 as로 매핑
- 프로젝션 대상이 여러개인 경우 Tuple로 나오는데 차라리 DTO로 하나 만들어서 매핑하는게 더 좋은 방법
- where문 쉼표로 이으면 null일 경우 무시 가능 -> 효율적인 쿼리 작성 가능
- 부속질의 -> JPAExpressions 사용 -> static import로 SQL처럼 사용 가능
- 부속질의는 같은 엔티티 사용하려면 static import한 것 외에 따로 선언 필요
- 예시) QMember memberSub = new QMember(“memberSub”);
- 결과 조회
- fetchOne() : 단 건 조회
- fetch() : 리스트 조회, 데이터 없으면 빈 리스트 반환
- fetchResults() : 페이징 정보 포함, total count 쿼리 추가 실행
- fetchCount() : count 쿼리로 변경해서 count 수 조회
- 메인 리포지토리와 Querydsl 리포지토리를 분리하고 싶다면 그냥 JpaRepository에 상속하지 않고 QueryRepository로 따로 만들어서 사용
// jpa repository에 querydsl용 커스텀 인터페이스 리포지토리 추가
public interface MemberRepository extends JpaRepository<Member,Long>,MemberRepositoryCustom {
}
------------------------------------------------------------------------------------------
// 커스텀 인터페이스 리포지토리
public interface MemberRepositoryCustom {
List<MemberTeamDto> search(MemberSearchCondition condition);
Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable);
Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable);
}
------------------------------------------------------------------------------------------
// 커스텀 인터페이스 구현체
// 반드시 클래스명을 인터페이스Impl
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom{
private final JPAQueryFactory queryFactory;
@Override
public List<MemberTeamDto> search(MemberSearchCondition condition){
// querydsl 사용하니까 DTO도 QXX 사용해야함
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();
}
@Override
public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable) {
// querydsl 사용하니까 DTO도 QXX 사용해야함
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();
//pageImpl 은 sprint data JPA의 page의 구현체임
// content, pageable, 데이터전체개수를 받음
return new PageImpl<>(content,pageable,total);
}
@Override
public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
// querydsl 사용하니까 DTO도 QXX 사용해야함
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();
JPAQuery<Member> countQuery = queryFactory
.selectFrom(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
);
//pageImpl 은 sprint data JPA의 page의 구현체임
// content, pageable, 데이터전체개수를 받음
return PageableExecutionUtils.getPage(content, pageable,()->countQuery.fetchCount());
}
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;
}
}
4. 리포지토리 분리하기
위에서는 상속을 이용해 QueryDSL Repository를 사용했습니다. 하지만 핵심 비즈니스 로직이 있는 리포지토리와 화면에 맞춘 DTO, 복잡한 통계성 쿼리 뽑는 리포지토리를 분리하는게 좋습니다. 위와 같이 상속을 사용하면 분리하는게 아니라 JPA 리포지토리가 계속해서 커지는 것입니다.
따라서 분리해서 작성하는게 더 좋습니다.
public interface RewardQueryRepository {
Member findById(Long id);
}
------------------------------------------------------------------------------------------
@Repository
@RequiredArgsConstructor
public class RewardQueryRepositoryImpl implements RewardQueryRepository {
private final JPAQueryFactory query;
@Override
public Member findById(Long id){
// ... //
}
}
5. pageable sort 사용하기
Controller의 파라미터로 Pageable을 주면 URI에서 받는 페이징 관편 파라미터가 알아서 들어옵니다. 이 데이터를 바탕으로 공지사항을 반환하는 페이징 처리를 해보겠습니다.
NotceController.java
@RestController
@RequestMapping("/api/v1/notices")
@RequiredArgsConstructor
public class NoticeController {
private final NoticeService noticeService;
@GetMapping
public ResponseEntity<Page<NoticeDto>> getNotice(
@PageableDefault(size = 4,sort = "createdDate",direction = Sort.Direction.DESC)
Pageable pageable) {
return ResponseEntity.ok(noticeService.getNotice(pageable, NoticeType.ALL));
}
}
URI에 페이징 파라미터로 Pageable에 넣기 위해서는 page, size, sort 파라미터를 주면 된다. sort에는 정렬할 필드명,desc 또는 필드명,asc 로 쉼표(,)로 direction을 구분합니다.
NoticeServiceImpl.java
@Service
@Transactional
@RequiredArgsConstructor
public class NoticeServiceImpl implements NoticeService {
private final NoticeQueryRepository noticeQueryRepository;
@Override
public Page<NoticeDto> getNotice(Pageable pageable, NoticeType noticeType) {
return noticeQueryRepository.findPagingNotice(pageable,noticeType);
}
}
QueryDslUtil.java
public class QueryDslUtil {
public static OrderSpecifier<?> getSortedColumn(Order order, Path<?> parent, String fieldName) {
Path<Object> fieldPath = Expressions.path(Object.class, parent, fieldName);
return new OrderSpecifier(order, fieldPath);
}
}
Order, Path, fieldName을 전달하면 OrderSpecifier 객체를 리턴하는 Util 클래스를 작성해서 Sort시 마다 사용할 수 있도록 해줍니다. Pageable을 이용해서 Sort할 때 사용하는 클래스입니다.
NoticeQueryRepositoryImpl.java
@Repository
@RequiredArgsConstructor
public class NoticeQueryRepositoryImpl implements NoticeQueryRepository {
private final JPAQueryFactory query;
@Override
public Page<NoticeDto> findPagingNotice(Pageable pageable, NoticeType noticeType) {
List<OrderSpecifier> ORDERS = getAllOrderSpecifiers(pageable);
QueryResults<NoticeDto> results = query
.select(new QNoticeDto(
notice.title,
notice.text,
notice.createdDate
))
.from(notice)
.where(notice.noticeType.eq(noticeType))
.orderBy(ORDERS.stream().toArray(OrderSpecifier[]::new))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetchResults();
List<NoticeDto> content = results.getResults();
long total = results.getTotal();
return new PageImpl<>(content,pageable,total);
}
private List<OrderSpecifier> getAllOrderSpecifiers(Pageable pageable) {
List<OrderSpecifier> ORDERS = new ArrayList<>();
if (!isEmpty(pageable.getSort())) {
for (Sort.Order order : pageable.getSort()) {
Order direction = order.getDirection().isAscending() ? Order.ASC : Order.DESC;
switch (order.getProperty()) {
case "createdDate":
OrderSpecifier<?> createdDate = QueryDslUtil
.getSortedColumn(direction, Qnotice.notice, "createdDate");
ORDERS.add(createdDate);
break;
default:
break;
}
}
}
return ORDERS;
}
}
생성 된 OrderSpecifier 객체를 orderBy 함수의 argument로 넣어주면 Pageable로 들어온 Sort를 사용할 수 있습니다.
6. Slice 사용하기
Slice 기법이란 일반적인 페이징 방식이 아닌 스크롤을 밑으로 내려가면서 데이터를 불러오는 방식입니다.
Slice는 최종 페이지 수를 알 필요가 없으므로 count 쿼리가 필요 없습니다.
JPA에서는 Page 대신 Slice로 반환하면 알아서 처리해주지만 QueryDSL에서는 직접 구현해야합니다.
Slice의 동작 방식은 다음과 같습니다.
- N개의 데이터가 필요하다면 N+1 개의 데이터를 가져옵니다.
- 결과 값의 개수 > N 이라면 다음 페이지가 존재한다는 뜻입니다.
- 결과 값의 개수가 > N 라면 추가적으로 가져온 +1 데이터를 빼고 결과 리스트를 반환합니다.
RepositorySliceHelper.java
Slice 관련 로직을 여러곳에서 사용하기 위해 클래스로 하나 만들어서 정의해줍니다.
public class RepositorySliceHelper {
public static <T> Slice<T> toSlice(List<T> contents, Pageable pageable) {
boolean hasNext = isContentSizeGreaterThanPageSize(contents, pageable);
return new SliceImpl<>(hasNext ? subListLastContent(contents, pageable) : contents, pageable, hasNext);
}
// 다음 페이지 있는지 확인
private static <T> boolean isContentSizeGreaterThanPageSize(List<T> content, Pageable pageable) {
return pageable.isPaged() && content.size() > pageable.getPageSize();
}
// 데이터 1개 빼고 반환
private static <T> List<T> subListLastContent(List<T> content, Pageable pageable) {
return content.subList(0, pageable.getPageSize());
}
}
위의 설명 그대로 구현한 코드입니다.
적용한 코드
public Slice<NotificationDto> findNotificationByUsername(String username, Pageable pageable) {
List<OrderSpecifier> ORDERS = getAllOrderSpecifiers(pageable);
List<NotificationDto> results = query
.select(new QNotificationDto(
notification.title,
notification.message,
notification.checked,
notification.notificationType,
notification.uuid,
notification.TeamId
))
.from(notification)
.where(notification.member.username.eq(username))
.orderBy(ORDERS.stream().toArray(OrderSpecifier[]::new))
.offset(pageable.getOffset())
.limit(pageable.getPageSize() + 1)
.fetch();
return RepositorySliceHelper.toSlice(results, pageable);
}
프로젝트에서 사용하던 코드를 가져온 것이므로 다른 내용은 신경쓰지 마시고 offset 부분에 +1로 데이터를 하나 더 가져왔다는 것과 앞서 만들었던 RepositorySliceHelper를 사용한 것만 확인하시면 됩니다. 반환은 아래와 같은 형식으로 됩니다.
{
"content": [
{
"title": "title",
"message": "message",
"checked": false,
"notificationType": "ADMIN_CUSTOM",
"uuid": "9707c197-bbec-43db-8dee-f8b885126ec4",
"teamId": null
},
// ... 생략
{
"title": "title",
"message": "message",
"checked": false,
"notificationType": "ADMIN_CUSTOM",
"uuid": "9707c197-bbec-43db-8dee-f8b885126ec4",
"teamId": null
}
],
"pageable": {
"sort": {
"unsorted": false,
"sorted": true,
"empty": false
},
"pageSize": 20,
"pageNumber": 1,
"offset": 20,
"paged": true,
"unpaged": false
},
"number": 1,
"numberOfElements": 10,
"first": false,
"last": true,
"size": 20,
"sort": {
"unsorted": false,
"sorted": true,
"empty": false
},
"empty": false
}