이번 게시글에서는 QueryDSL을 활용하여 동적 쿼리를 처리하는 방식에 대해서 다루도록 하겠습니다.
QueryDSL이란 오픈소스 프로젝트로 JPQL을 Java 코드로 작성할 수 있게 해주는 라이브러리입니다.
QueryDSL의 장점은 @Query로 바로 JPQL을 사용하는 경우 런타임에 Exception이 발생하지만 QueryDSL을 사용한다면 잘못된 쿼리를 작성했을 때 컴파일 시점에 에러가 발생하기 때문에 빠르게 문제를 파악할 수 있다는 것입니다.
또한 QueryDSL은 복잡한 동적 쿼리를 작성하는 것을 간단하게 해주는 장점이 있습니다.
먼저 제가 프로젝트에 QueryDSL을 도입하게 된 상황에 대해서 소개해드린 후 QueryDSL을 사용하기 위한 설정과 어떻게 적용했는지를 설명하도록 하겠습니다.
QueryDSL 도입 배경
제가 프로젝트를 시작한 후 맡은 도메인은 폴더와 관련된 기능을 구현해 달라였습니다.
폴더는 내부에 폴더가 중첩해서 들어갈 수 있어야 하고 폴더 아래에는 파일이 존재해야 한다는 요구사항이 있었습니다.
이를 위해서 저는 아래와 같이 유저에게 폴더, 복습 자료(파일)를 연결하였고 폴더는 폴더를 ManyToOne으로 가질 수 있게 설계하였습니다.
아래는 프로젝트 ERD의 일부분을 가져온 그림입니다.
위와 같은 구조를 만든 후 폴더에 있는 하위 폴더들과 파일들을 조회하는 API 개발을 시작하였습니다.
이때 저는 단순히 parent_directory_id가 null 즉 루트에 있는 폴더들을 조회할 때와 루트에 존재하지 않는 폴더들을 조회할 때의 경우만 나누어서 코드를 작성하면 되겠구나라고 생각하였고 이대로 구현을 하였습니다.
위와 같은 생각을 하고 작성한 코드는 아래와 같습니다. @Transactional(readOnly = true)는 class level에 존재합니다.
DirectoryService
/**
* 유저의 과목과 폴더 목록을 조회
*/
public DirectoryTotalShowResponse getDirectorySubList(Long userId, Long directoryId) {
validateDirectory(directoryId);
List<DirectoryResponse> directoryResponseList = getDirectoryResponseList(userId, directoryId);
List<ReviewSimpleResponse> reviewSimpleResponseList = reviewService.getReviewSimpleResponseList(directoryId);
log.info("reviewSimpleResponseList: {}", reviewSimpleResponseList.size());
return DirectoryTotalShowResponse.of(directoryResponseList, reviewSimpleResponseList);
}
private List<DirectoryResponse> getDirectoryResponseList(Long userId, Long parentDirectoryId) {
List<Directory> directoryList =
parentDirectoryId == 0 ? directoryRepository.findAllByUserUserIdAndParentDirectoryIsNull(userId)
: directoryRepository.findAllByParentDirectoryId(parentDirectoryId);
//해당 메서드 없는 폴더 보려고 하면 exception 처리하기
log.info("directoryList: {}", directoryList.size());
return directoryList.stream()
.map(DirectoryResponse::from)
.toList();
}
ReviewService
public List<ReviewSimpleResponse> getReviewSimpleResponseList(Long directoryId) {
return reviewRepository.findByDirectoryId(directoryId).stream()
.map(ReviewSimpleResponse::from)
.toList();
}
DirectoryRepository
@Repository
public interface DirectoryRepository extends JpaRepository<Directory, Long> {
List<Directory> findAllByUserUserIdAndParentDirectoryIsNull(Long userId);
List<Directory> findAllByParentDirectoryId(Long parentDirectoryId);
}
ReviewRepository
@Repository
public interface ReviewRepository extends JpaRepository<Review, Long> {
List<Review> findByDirectoryId(Long directoryId);
}
Spring Data JPA를 활용하여 코드를 작성하였습니다. 기존의 요구사항에서는 해당 코드는 크게 문제가 없어 보입니다.
하지만 기능 구현에 있어서 추가로 요구 사항이 생겼습니다. 폴더를 그냥 조회하는 것보다는 정렬 기능을 붙이는 게 좋지 않을까요??라는 의견이 있어 이 기능을 추가해 달라는 요청을 받게 되었습니다.
위와 같은 요구 사항을 받은 후 코드를 어떻게 수정할까를 고민하였습니다. 정렬을 Sort와 같은 객체를 만들어 현재 존재하는 메서드에 단순히 파라미터를 추가하는 방식으로 구현할 수도 있지만 이와 같이 요구 사항이 추가될 때마다 계속 기능을 추가한다면 나중에 코드를 이해하기도 어렵고 기능 확장에 좋지 않은 코드가 탄생할 것이라는 생각이 들었습니다.
따라서 문제 해결을 위한 방법을 더 생각해 보던 중 쿼리가 directory, review 이 2개의 테이블에만 각각 적용이 되고 요구 사항만 조금씩 바뀌는 동적 쿼리라는 것을 눈치채게 되었습니다.
이전에 인프런에서 김영한 강사님의 강의인 스프링 DB 2편 - 데이터 접근 활용 기술 강의를 들었을 때 동적 쿼리를 처리하는 데에는 QueryDSL을 활용하면 편리하다는 것을 들었고 강의를 따라 QueryDSL 기술을 조금 사용해 본 기억이 떠올랐고 이를 활용하여 현재 프로젝트에 발생한 문제를 해결하자는 생각을 하게 되었습니다.
QueryDSL 설정
QueryDSL을 프로젝트에 적용하기 위해서는 해주어야 하는 설정들이 존재합니다.
우선 build.gradle에 아래와 같은 내용을 dependencies에 추가해주어야 합니다.
//QueryDsl
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
위와 같은 코드를 통해서 필요한 라이브러리들을 가져올 수 있습니다.
또한 build.gradle 최하단에 아래와 같은 내용들을 추가해야 합니다.
//Querydsl 설정
def generated = 'src/main/generated'
//querydsl QClass 파일 생성 위치를 지정
tasks.withType(JavaCompile).configureEach {
options.getGeneratedSourceOutputDirectory().set(file(generated))
}
//java source set 에 querydsl QClass 위치 추가
sourceSets {
main.java.srcDirs += [ generated ]
}
//자동 생성된 Q클래스 gradle clean으로 제거
clean {
delete file(generated)
}
위의 내용은 QueryDSL 사용을 위한 Q클래스들이 생성되는 위치를 설정해 주고 이후 clean을 했을 때 이 파일들을 제거할 수 있게 해주는 설정입니다.
위와 같이 설정하는 이유는 Q클래스들은 메타 정보로 build 할 때마다 생성되기 때문에 계속 유지하지 않게 하기 위해서입니다.
위와 같은 이유로 github에도 이 파일들을 올리지 않는 것이 좋기 때문에 .gitignore에 아래와 같은 내용을 추가해 줍니다.
src/main/generated
마지막으로 QueryDSL을 사용하기 위한 설정 파일을 아래와 같이 설정해 주면 QueryDSL을 사용할 준비가 끝나게 됩니다.
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
이제 QueryDSL을 사용하기 위해 필요한 QClass들을 생성해 보도록 하겠습니다.
현재 디렉토리 구조는 아래와 같습니다.
이때 아래와 같이 intellij 우측에 있는 compileJava를 실행하면 됩니다.
위와 같은 작업을 하면 아래와 같이 새로운 디렉토리가 추가되게 됩니다.
이 파일들을 지우기 위해서는 아래 사진과 같이 clean을 실행하시면 됩니다.
QueryDSL 적용
이제 추가된 요구사항인 정렬 기능을 구현해 보도록 하겠습니다.
우선 정렬 기준을 설정하기 위해서 Enum class를 만들었습니다.
public enum SortType {
NAME_ASC,
NAME_DESC,
CREATED_AT,
UPDATED_AT
;
public static final Map<SortType, Function<QDirectory, OrderSpecifier<?>>> DIRECTORY_SORT_MAP = Map.of(
SortType.CREATED_AT, dir -> dir.createdDate.desc(),
SortType.UPDATED_AT, dir -> dir.modifiedDate.desc(),
SortType.NAME_ASC, dir -> dir.title.asc(),
SortType.NAME_DESC, dir -> dir.title.desc()
);
public static final Map<SortType, Function<QReview, OrderSpecifier<?>>> REVIEW_SORT_MAP = Map.of(
SortType.CREATED_AT, review -> review.createdDate.desc(),
SortType.UPDATED_AT, review -> review.modifiedDate.desc(),
SortType.NAME_ASC, review -> review.title.asc(),
SortType.NAME_DESC, review -> review.title.desc()
);
}
이름순, 생성순, 수정순으로 정렬 기준을 설정하였고, 이 정렬 기준에 맞는 OrderSpecifier를 생성하여 사용하기 편하게 하기 위하여 Directory, Review에 사용할 Map을 각각 만들어주었습니다.
이때 Function이라는 FunctionalInterface를 사용하여 QClass에서 정렬 기준을 가져오기 편하게 설계하였습니다.
이후 Controller에서 정렬 기준을 프론트에서 가져와 Service로 넘겨주었고 코드를 아래와 같이 변경하였습니다.
DirectoryService
/**
* 유저의 과목과 폴더 목록을 조회
*/
public DirectoryTotalShowResponse getDirectorySubList(Long userId, Long directoryId, List<SortType> sort) {
validateDirectory(directoryId);
List<DirectoryResponse> directoryResponseList = getDirectoryResponseList(userId, directoryId, sort);
List<ReviewSimpleResponse> reviewSimpleResponseList = reviewService.getReviewSimpleResponseList(directoryId, sort);
return DirectoryTotalShowResponse.of(directoryResponseList, reviewSimpleResponseList);
}
private List<DirectoryResponse> getDirectoryResponseList(Long userId, Long parentDirectoryId, List<SortType> sort) {
List<Directory> directoryList =
parentDirectoryId == 0 ? directoryRepository.findAllByUserIdAndParentDirectoryIsNull(userId, sort)
: directoryRepository.findAllByParentDirectoryId(parentDirectoryId, sort);
return directoryList.stream()
.map(DirectoryResponse::from)
.toList();
}
위의 코드만 보시면 기존의 코드에서 parameter로 sort만 넘겨준 것을 제외하면 모두 동일한 코드입니다.
QueryDSL을 적용하면서 많은 변화가 있었던 부분은 Repository 부분입니다.
우선 기존 DirectoryRepository는 아래와 같이 변경되었습니다.
@Repository
public interface DirectoryRepository extends JpaRepository<Directory, Long>, DirectoryRepositoryCustom {
}
기존에 있던 메서드가 모두 사라지고 extends 한 interface가 하나 추가되었습니다.
해당 interface의 내용은 아래와 같습니다.
public interface DirectoryRepositoryCustom {
List<Directory> findAllByParentDirectoryId(Long parentDirectoryId, List<SortType> sort);
List<Directory> findAllByUserIdAndParentDirectoryIsNull(Long userId, List<SortType> sort);
}
기존에 DirectoryRepository에 있던 메서드들이 여기로 이동되었습니다.
위와 같이 수정한 이유는 이 두 메서드를 직접 구현하기 위해서입니다.
이 두 메서드를 구현하기 위해서 아래와 같은 코드를 작성합니다.
@Repository
@RequiredArgsConstructor
public class DirectoryRepositoryImpl implements DirectoryRepositoryCustom{
private final JPAQueryFactory query;
@Override
public List<Directory> findAllByParentDirectoryId(Long parentDirectoryId, List<SortType> sort) {
return query
.selectFrom(directory)
.where(directory.parentDirectory.id.eq(parentDirectoryId))
.orderBy(sortExpression(sort))
.fetch();
}
@Override
public List<Directory> findAllByUserIdAndParentDirectoryIsNull(Long userId, List<SortType> sort) {
return query
.selectFrom(directory)
.where(directory.parentDirectory.id.isNull(), directory.user.userId.eq(userId))
.orderBy(sortExpression(sort))
.fetch();
}
// 기본 정렬 - 이름 오름차순
private OrderSpecifier<?>[] sortExpression(List<SortType> sort) {
if (sort == null || sort.isEmpty()) {
return new OrderSpecifier[]{directory.title.asc()};
}
return sort.stream()
.map(sortType -> DIRECTORY_SORT_MAP.getOrDefault(sortType, dir -> dir.title.asc()).apply(directory))
.toArray(OrderSpecifier[]::new);
}
}
위와 같이 DirectoryRepository의 구현체를 직접 만들어주어 필요한 메서드들의 구현을 완료하였습니다.
이와 동일하게 ReviewRepositoryImpl도 구현해 주었습니다.
@Repository
@RequiredArgsConstructor
public class ReviewRepositoryImpl implements ReviewRepositoryCustom {
private final JPAQueryFactory query;
@Override
public List<Review> findByDirectoryId(Long directoryId, List<SortType> sort) {
return query
.selectFrom(review)
.where(review.directory.id.eq(directoryId))
.orderBy(sortExpression(sort))
.fetch();
}
// 기본 정렬 - 이름 오름차순
private OrderSpecifier<?>[] sortExpression(List<SortType> sort) {
if (sort == null || sort.isEmpty()) {
return new OrderSpecifier[]{
review.title.asc()
};
}
return sort.stream()
.map(sortType -> REVIEW_SORT_MAP.getOrDefault(sortType, review -> review.title.asc()).apply(review))
.toArray(OrderSpecifier[]::new);
}
}
위와 같이 작업한 덕분에 Service에서는 큰 수정 없이 기능을 확장할 수 있었습니다.
또한 정렬 기준이 변경되는 경우에도 원하는 정렬 기준에 맞게 폴더, 리뷰(파일)를 정렬하는 동적 쿼리도 원활히 동작하였습니다.
요구사항의 추가
프로젝트가 진행되며 폴더와 파일에 대한 검색 기능을 추가해 달라는 요구 사항이 추가되었습니다.
처음 요구 사항이 추가되었을 때 QueryDSL을 적용하지 않았다면 또 분기를 나누는 등의 많은 수정이 필요하였겠지만 이미 동적 쿼리에 대한 대응을 어느 정도 해주었기 때문에 큰 무리 없이 해당 기능을 구현할 수 있었습니다.
사실 RepositoryImpl에서만 작업을 하면 끝나지만 이전 작업했던 코드들을 보았을 때 루트 폴더냐 아니냐에 대한 내용도 where에서의 조건만 조금 수정하면 하나의 메서드로 정리가 가능해 보여 이 내용에 대한 작업도 함께 진행하였습니다.
작업을 완료한 DirectoryService는 아래와 같습니다.
/**
* 유저의 과목과 폴더 목록을 조회
*/
public DirectoryTotalShowResponse getDirectorySubList(
Long userId, Long directoryId, List<SortType> sort, String search) {
validateDirectory(directoryId);
List<DirectoryResponse> directoryResponseList =
directoryRepository.findAllDirectoryResponseBySortAndSearch(userId, directoryId, sort, search);
List<ReviewSimpleResponse> reviewSimpleResponseList =
reviewService.getReviewSimpleResponseList(userId, directoryId, sort, search);
return DirectoryTotalShowResponse.of(directoryId, directoryResponseList, reviewSimpleResponseList);
}
기존에 private 메서드를 사용해서 처리를 따로 해주어야 했지만 이제 메서드 하나로 폴더를 모두 찾아올 수 있게 되었습니다.
또한 검색 키워드를 파라미터로 추가해 주었습니다.
마지막 수정 사항으로는 원래 Directory, Review를 DB에서 찾아온 후 원하는 column들만 response로 주기 위해서 따로 작업을 해주었지만 현재 코드에서는 그러한 부분이 존재하지 않는 모습을 볼 수 있습니다.
위와 같은 일이 가능한 이유는 QueryDSL로 데이터를 찾아올 때 DTO Projection을 이용하여 원하는 column들만 가져오게 하였기 때문입니다. 이를 통해서 DB의 부하도 줄이고 Service에서 추가로 작업해야 하는 과정도 제거하였습니다.
이제 Repository의 수정 사항을 확인해 보겠습니다.
DirectoryRepositoryCustom
public interface DirectoryRepositoryCustom {
List<DirectoryResponse> findAllDirectoryResponseBySortAndSearch(
Long userId, Long parentDirectoryId, List<SortType> sort, String search);
}
DirectoryRepositoryImpl
@Repository
@RequiredArgsConstructor
public class DirectoryRepositoryImpl implements DirectoryRepositoryCustom {
private final JPAQueryFactory query;
@Override
public List<DirectoryResponse> findAllDirectoryResponseBySortAndSearch(
Long userId, Long parentDirectoryId, List<SortType> sort, String search) {
return query
.select(Projections.constructor(DirectoryResponse.class,
directory.id,
directory.title,
directory.directoryColor,
directory.directoryIcon))
.from(directory)
.where(directory.user.userId.eq(userId), isSearchExpression(parentDirectoryId, search))
.orderBy(sortExpression(sort))
.fetch();
}
//search가 null이면 parentDirectoryId로 검색, 아니면 search로 검색 - search 존재 -> 전체 검색
private BooleanExpression isSearchExpression(Long parentDirectoryId, String search) {
if (ObjectUtils.isEmpty(search)) {
return parentDirectoryFindExpression(parentDirectoryId);
} else {
return directory.title.contains(search);
}
}
private BooleanExpression parentDirectoryFindExpression(Long parentDirectoryId) {
if (parentDirectoryId == 0) {
return directory.parentDirectory.isNull();
} else {
return directory.parentDirectory.id.eq(parentDirectoryId);
}
}
// 기본 정렬 - 이름 오름차순
private OrderSpecifier<?>[] sortExpression(List<SortType> sort) {
if (sort == null || sort.isEmpty()) {
return new OrderSpecifier[]{directory.title.asc()};
}
return sort.stream()
.map(sortType -> DIRECTORY_SORT_MAP.getOrDefault(sortType, dir -> dir.title.asc()).apply(directory))
.toArray(OrderSpecifier[]::new);
}
}
ReviewRepositoryCustom
public interface ReviewRepositoryCustom {
List<ReviewSimpleResponse> findAllReviewSimpleResponseBySortAndSearch(
Long userId, Long parentDirectoryId, List<SortType> sort, String search);
}
ReviewRepositoryImpl
@Repository
@RequiredArgsConstructor
public class ReviewRepositoryImpl implements ReviewRepositoryCustom {
private final JPAQueryFactory query;
@Override
public List<ReviewSimpleResponse> findAllReviewSimpleResponseBySortAndSearch(
Long userId, Long parentDirectoryId, List<SortType> sort, String search) {
return query
.select(Projections.constructor(ReviewSimpleResponse.class,
review.id,
review.title,
review.imageInfo.imageUrl,
review.reviewCnt))
.from(review)
.where(review.user.userId.eq(userId), searchExpression(parentDirectoryId, search))
.orderBy(sortExpression(sort))
.fetch();
}
//search가 null이면 parentDirectoryId로 검색, 아니면 search로 검색 - search 존재 -> 전체 검색
private BooleanExpression searchExpression(Long parentDirectoryId, String search) {
if (ObjectUtils.isEmpty(search)) {
return review.directory.id.eq(parentDirectoryId);
} else {
return review.title.contains(search);
}
}
// 기본 정렬 - 이름 오름차순
private OrderSpecifier<?>[] sortExpression(List<SortType> sort) {
if (sort == null || sort.isEmpty()) {
return new OrderSpecifier[]{
review.title.asc()
};
}
return sort.stream()
.map(sortType -> REVIEW_SORT_MAP.getOrDefault(sortType, review -> review.title.asc()).apply(review))
.toArray(OrderSpecifier[]::new);
}
}
우선 검색 조건이 존재하면 전체 테이블에서 검색을 진행해야 하니 이 부분은 검사하는 searchExpression 메서드를 생성하였습니다.
또한 검색 조건이 없는 경우 루트 폴더인지 아닌지를 확인하는 메서드를 parentDirectoryFindExpression으로 만들었습니다.
위의 두 메서드의 return type은 BooleanExpression으로 이 조건이 참인지 거짓인지에 따라서 where 절에서 필터링을 진행합니다.
그리고 마지막으로 select를 할 때 DTO projection을 이용하기 위해서 DirectoryResponse, ReviewSimpleResponse의 생성자를 이용하였습니다.
QueryDSL을 활용하여 동적 쿼리를 처리하였고, 변경되는 요구 사항에 대해서 유연하게 대처할 수 있었습니다.
위와 같은 경험을 통해서 기존 코드의 수정은 최대한 줄이면서 기능 확장에 대해서는 열려있는 코드를 작성하기 위한 고민을 할 수 있었고 조금 더 객체지향 설계 원칙을 지키는 코드를 작성하는 경험을 할 수 있었습니다.
'CS > 스프링' 카테고리의 다른 글
AOP를 이용한 분산락(Named Lock) 처리 (0) | 2024.07.28 |
---|---|
synchnonized, 비관적 락, 낙관적 락, 분산락(named lock)을 사용한 데드락 처리 (0) | 2024.07.26 |
join 제거, dto projection, covering index를 활용한 성능 최적화 (0) | 2024.07.04 |
spring 프로젝트에서 더미 데이터 사용 & 테스트용 fixture 사용하기 (0) | 2024.06.16 |
spring security 적용시 @WebMvcTest 코드에서 csrf() 없애기 (0) | 2024.06.06 |
댓글