[Java] Querydsl 이란? 검색 기능과 페이징
Querydsl 이란?
Querydsl은 DB 에서 CRUD 작업을 할 때 SQL 쿼리 대신 Java 코드로 작성할 수 있도록 도와주는 라이브러리 이다.
JPA 를 사용하면, 기본적인 CRUD 에 대해서는 아주 훌륭하지만 검색에 따라 애로사항이 발생 할 수 있다.
1
2
3
게시판 검색 기능에 대해 클라이언트의 요구사항이 있다고 생각해보자.
"저는 날짜로도 검색 가능하고 제목으로도 검색 가능했으면 좋겠어요. 그리고 작성자도 검색 가능하게 추가해주세요!"
위 요구사항을 해결하기 위해선 이러한 방법들이 존재 할 것이다.
- 날짜, 제목, 작성자로 검색 가능한 각각의 엔드포인트 생성
- 하나의 엔드포인트에서 조건값을 받아 핸들링
- …
첫 번째 방법을 이용하게 된다면
1
"FE 개발자님! 검색 조건에 따라서 엔드포인트가 달라져야 되니 프론트 에서 날짜는 여기, 제목은 여기, 작성자는 여기로 요청 주시면 돼요!"
각 테이블마다 엔드포인트는 셀 수 없이 늘어나게 될 것이고, 결국 FE, BE 둘 다 재사용성이 낮아지게 된다.
하지만 하나의 엔드포인트에서 조건값을 받아 핸들링 한다면?
기존 조회 기능을 Querydsl 을 사용하도록 리팩토링하거나, 이미 서비스 중이라면 하나의 엔드포인트 만을 추가함으로 해결 할 수 있다
.
해당 챕터를 진행하기 위해서는 JPA 를 선행학습 해보는 걸 추천한다.
의존성 추가
build.gradle
1
2
3
4
5
6
7
8
plugins {
id 'com.ewerk.gradle.plugins.querydsl' version '1.0.10' // Querydsl
}
dependencies {
implementation "com.querydsl:querydsl-jpa" // Querydsl
implementation "com.querydsl:querydsl-apt" // Querydsl
}
QEntity 파일 생성
JPA에서 사용 하는 엔티티를 Querydsl 에서 사용가능 하도록 QEntity 파일 생성
build.gradle
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def querydslDir = "src/main/generated/queryDsl" // Querydsl
querydsl {
jpa = true // Querydsl
querydslSourcesDir = querydslDir // Querydsl
}
sourceSets {
main.java.srcDir querydslDir // Querydsl
}
configurations {
querydsl.extendsFrom compileClasspath // Querydsl
}
compileQuerydsl {
options.annotationProcessorPath = configurations.querydsl // Querydsl
}
gradle build
빌드하고나면 기존에 @Entity 어노테이션이 작성되어 있던 클래스 들이 src/main/generated/queryDsl
경로에 생성된다.
QEntity 파일 경로 설정
QEntity 파일들은 내가 설정 한 src/main/generated/queryDsl
경로에 생성되기 때문에, 해당 경로에 대해 설정을 추가해야 한다.
이클립스에서 프로젝를 선택 후 “Alt + Enter” 를 누르면 Properties 창이 표시된다.
Java Build Path -> Source 를 선택하고 “ADD Folder” 를 선택한다.
src/main/generated/queryDsl
경로를 체크 한 뒤 OK 를 눌러 창을 종료하고 Apply 한다.
Config 파일 생성
JPAQueryFactory : Querydsl 생성 후 EntityManager 를 통해 쿼리 결과를 반환할 수 있도록 해준다.
QuerydslConfig.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.querydsl.jpa.impl.JPAQueryFactory;
@Configuration
public class QuerydslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
DTO 추가
메인 모델 속성이 외부에 노출되면 보안 문제가 발생할 수 있다. 그러니 Repose 데이터를 반환 할 때 사용 할 DTO 를 추가해주자.
- @Builder : JPA 사용 시 Entity -> DTO 변환을 위한 어노테이션
- @QueryProjection : gradle build 를 통해 QDto 파일을 생성
VocQuestionDto.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class VocQuestionDto {
@Getter
public static class Response {
private Long id;
private String category;
private String title;
private String content;
private String email;
private String username;
private String stationId;
private int needReply;
private boolean active;
@JsonDeserialize(using = CustomLocalDateTimeDeserializer.class)
@JsonSerialize(using = CustomLocalDateTimeSerializer.class)
private LocalDateTime createdAt;
@JsonDeserialize(using = CustomLocalDateTimeDeserializer.class)
@JsonSerialize(using = CustomLocalDateTimeSerializer.class)
private LocalDateTime updatedAt;
@Builder
@QueryProjection
public Response(Long id, String category, String title, String content, String email, String username,
String stationId, int needReply, LocalDateTime createdAt, LocalDateTime updatedAt, boolean active) {
this.id = id;
this.category = category;
this.title = title;
this.content = content;
this.email = email;
this.username = username;
this.stationId = stationId;
this.needReply = needReply;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
this.active = active;
}
}
}
@QueryProjection 을 추가 후 gradle 을 다시 빌드하면 QDto 파일이 생성된 걸 확인 할 수 있다.
검색 조건 추가
검색 기능을 위해 해당 테이블에서 사용 할 조건 검색에 대한 값을 추가하자.
VocQuestionSearchCondition.java
1
2
3
4
5
6
7
8
9
10
11
12
@NoArgsConstructor
@AllArgsConstructor
@Setter
@Getter
public class VocQuestionSearchCondition {
private String categoryId;
private String title;
private String username;
private String email;
private String stationId;
}
컨트롤러 추가
VocController.java
1
2
3
4
5
6
7
8
9
@GetMapping("/v2")
public ResponseEntity<Page<VocQuestionDto.Response>> getVocQuestionsVer2(
VocQuestionSearchCondition condition,
@RequestParam(name="offset") @NotNull long offset,
@RequestParam(name="limit") @NotNull int limit,
Sort sort) {
Pageable pageable = new OffsetBasedPageRequest(offset, limit, sort);
return new ResponseEntity<>(vocQuestionService.getVocQuestionsSearchable(pageable, condition), HttpStatus.OK);
}
파라미터에서 offset, limit, sort 는 페이징과 정렬 처리를 위한 값이며,
VocQuestionSearchCondition 은 검색을 위한 내용이다.
OffsetBasedPageRequest 에 대한 코드는 github 에서 확인 가능하다.
서비스 추가
VocQuestionServiceImpl.java
1
2
3
4
5
6
private final VocQuestionRepositoryCustom customRepository;
@Override
public Page<VocQuestionDto.Response> getVocQuestionsSearchable(Pageable pageable, VocQuestionSearchCondition condition) {
return customRepository.searchVocQuestion(condition, pageable);
}
Custom Repository 추가
위 까지의 내용은 준비 단계 였고, 사실 아래 내용이 실제 Querydsl 을 사용하는 부분이다.
VocQuestionRepositoryCustom.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import com.querydsl.core.QueryResults;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.toyseven.ymk.common.dto.QVocQuestionDto_Response;
import com.toyseven.ymk.common.dto.VocQuestionDto;
import com.toyseven.ymk.common.model.entity.QVocQuestionEntity;
import com.toyseven.ymk.common.search.VocQuestionSearchCondition;
import lombok.RequiredArgsConstructor;
@Repository
@RequiredArgsConstructor
public class VocQuestionRepositoryCustom {
private final JPAQueryFactory queryFactory; // 쿼리 결과를 반환 할 수 있도록 Bean 에서 가지고 온다.
private final QVocQuestionEntity question = QVocQuestionEntity.vocQuestionEntity; // QEntity 클래스에서 엔티티를 가지고온다.
public Page<VocQuestionDto.Response> searchVocQuestion(final VocQuestionSearchCondition condition, final Pageable pageable) {
QueryResults<VocQuestionDto.Response> result =
queryFactory.select(
// 조회 한 데이터를 DTO 로 변환시켜주는 부분이다.
new QVocQuestionDto_Response(
question.id,
question.category.displayName,
question.title,
question.content,
question.email,
question.username,
question.stationId.stationId,
question.needReply,
question.createdAt,
question.updatedAt,
question.active
)
)
.from(question)
.where(
// 조건절에 해당하는 부분이다.
stationIdEq(condition.getStationId()),
titleEq(condition.getTitle()),
usernameEq(condition.getUsername()),
emailEq(condition.getEmail()),
categoryIdEq(condition.getCategoryId())
)
// 데이터는 누적이고, 전체 검색은 매우 위험하다.
// 결과의 row 제한을 추가하는 부분이다.
// 결과적으로 페이징 처리와 같다고 보면 된다.
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetchResults();
List<VocQuestionDto.Response> content = result.getResults();
// long total = result.getTotal();
// return new PageImpl<>(content, pageable, total);
return new PageImpl<>(content);
}
private BooleanExpression categoryIdEq(final String categoryId) {
return StringUtils.hasText(categoryId) ? question.category.displayName.eq(categoryId) : null;
}
private BooleanExpression titleEq(final String title) {
return StringUtils.hasText(title) ? question.title.eq(title) : null;
}
private BooleanExpression usernameEq(final String username) {
return StringUtils.hasText(username) ? question.username.eq(username) : null;
}
private BooleanExpression emailEq(final String email) {
return StringUtils.hasText(email) ? question.email.eq(email) : null;
}
private BooleanExpression stationIdEq(final String stationId) {
return StringUtils.hasText(stationId) ? question.stationId.stationId.eq(stationId) : null;
}
}
검색 테스트
위까지 진행 후 서버를 올려보면 무리없이 작동할 것 이라고 생각하지만, 만약 안된다면 아래내용을 참고하여 @EnableJpaRepositories 어노테이션을 추가하시면 될거라고 생각합니다.
ToysevenApplication.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication
@EnableJpaAuditing
@EnableJpaRepositories // 추가
public class ToysevenApplication {
public static void main(String[] args) {
SpringApplication application = new SpringApplication(ToysevenApplication.class);
application.addListeners(new ApplicationPidFileWriter());
application.run(args);
}
}
정렬 기능 추가
정렬에 대한 기능추가를 위해 유틸클래스를 추가한다.
QuerydslUtil.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import com.querydsl.core.types.Order;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.Path;
import com.querydsl.core.types.dsl.Expressions;
import lombok.experimental.UtilityClass;
@UtilityClass
public class QuerydslUtil {
@SuppressWarnings({ "rawtypes", "unchecked" })
public static OrderSpecifier<?> getSortedColumn(Order order, Path<?> parent, String fieldName) {
Path<Object> fieldPath = Expressions.path(Object.class, parent, fieldName);
return new OrderSpecifier(order, fieldPath);
}
}
추가 된 유틸클래스를 이용하여 기능 구현
VocQuestionRepositoryCustom.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public Page<VocQuestionDto.Response> searchVocQuestion(final VocQuestionSearchCondition condition, final Pageable pageable) {
List<OrderSpecifier<?>> orders = getAllOrderSpecifiers(pageable);
QueryResults<VocQuestionDto.Response> result = queryFactory
.select(
new QVocQuestionDto_Response(
question.id,
question.category.displayName,
question.title,
question.content,
question.email,
question.username,
question.stationId.stationId,
question.needReply,
question.createdAt,
question.updatedAt,
question.active
)
)
.from(question)
.where(
stationIdEq(condition.getStationId()),
titleEq(condition.getTitle()),
usernameEq(condition.getUsername()),
emailEq(condition.getEmail()),
categoryIdEq(condition.getCategoryId())
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(orders.stream().toArray(OrderSpecifier[]::new))
.fetchResults();
List<VocQuestionDto.Response> content = result.getResults();
return new PageImpl<>(content);
}
private List<OrderSpecifier<?>> getAllOrderSpecifiers(Pageable pageable) {
List<OrderSpecifier<?>> orders = new ArrayList<>();
OrderSpecifier<?> updatedAt = QuerydslUtil.getSortedColumn(Order.DESC, question, "updatedAt");
orders.add(updatedAt);
return orders;
}
마치며
Querydsl 이 특정 부분에선 간편하지만 만능은 아니다.
예를 들면 검색부분에서 확실히 간편하게 구현 가능하다고 생각 된다.
일반적인 방법으로 JPA Repository Interface 을 이용한다면, findById() 나 save() 등 과 같은 기본적인 인터페이스가 아니라면 가독성이 떨어진다. 코드를 작성하는 당사자는 가이드대로 메소드명만 만들면 되니, 편할 지 몰라도 추가적인 조건이 둘 이상이 되면 메소드 명을 보고 목적이 직관적으로 파악되지 않는다.
1
List<Board> findByIdOrUsernameAndActive
그리고 통계와 같은 복잡한 쿼리를 위해서는 SQLMapper(MyBatis) 방식이 필요 할 수 있다.
무조건 하나의 방식을 고집하기 보다는 상황에 맞게 사용 할 줄 알아야 되고, 그걸 위해 모두 미리 공부하여 경험을 해보는 게 중요하다고 생각한다. 내가 언제 어디서 어떤 기술을 사용 할 지 알 수 없기 때문에
모든소스는 아래 github 을 클릭하여 확인 가능 합니다.
Leave a comment