도입 및 문제 상황 인식
이번 포스팅에서는 QueryDSL을 활용하여 동적 쿼리를 만드는 과정을 기록하고자 한다.
QueryDSL을 이제 막 처음 사용하시는 분들에게 참고 자료로써 도움이 됐으면 한다.
현재 내가 진행하고 있는 프로젝트인 워디 프로젝트에서 동적 쿼리를 써야 할 상황이 생겼다.
백문이불여일견이라고 바로 다음 사진을 보자.
해당 페이지를 보면 관심 국가, 키워드, 기간이라는 3가지 검색 필터 조건이 들어가 있다.
당연히 각 조건은 선택이 될 수도 있고, 안될 수도 있는 부분이기 때문에 무식하게 구현한다면 2*2*2로 조건에 따라 6번의 쿼리를 작성해야 한다. (생각만 해도 끔찍하다.😂)
무식하게 구현해도 기능을 구현할 수 있지만, 조건이 늘어날수록 조건에 따라 분기해야할 로직이 많아지며, 작성해야 할 쿼리가 많아진다는 점에서 유지 보수와 코드 확장성이 매우 떨어진다고 볼 수 있다.
또한, 조건을 여러 번 분기하게 되면 코드 가독성이 매우 떨어지기 때문에 클린 코드와 멀어지기 쉽다.
따라서 동적 쿼리 같은 경우는 무작정 기능만 동작하게 구현하기 보다는 동적 쿼리를 편리하게 구현할 수 있는 Mybatis나 QueryDSL과 같은 라이브러리를 활용하는 것이 좋은 선택이라 볼 수 있다.
Table, Entity 구조
동적 쿼리 구현에 들어가기 전에 구현할 동적 쿼리 부분과 관련된 Entity 설계 부분에 대해 간단하게 언급하고자 한다.
일단 해당 페이지 부분에 직접적으로 관여하는 Entity는 Mentor와 MentorKeyword이다.
Mentor와 MentorKeyword 테이블을 보면, 다음과 같이 1:N의 관계를 맺고 있다.
따라서, 연관 관계 주인은 FK키를 가지고 있는 MentorKeyword Entity에 있으며, Mentor Entity는 OneToMany 형태로 다음과 같이 MentorKeyword를 가지고 있는 양방향 매핑이 되어 있는 구조이다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Mentor extends BaseTimeEntity {
// ..생략..
@OneToMany(mappedBy = "mentor")
private List<MentorKeyword> mentorKeywordList = new ArrayList<>();
// ..생략..
}
Repository 분리하기
현재 작업하려는 프로젝트는 스프링 데이터 JPA를 활용하고 있다.
QueryDSL을 스프링 데이터 JPA Repository에 구현 메서드로 만들기 위해서는 스프링 데이터 JPA interface를 상속받는 구현체를 만들어 해당 구현체 클래스에서 구현하는 방식으로 만들어야 한다.
하지만 스프링 데이터 JPA interface를 상속 받아보면 알겠지만, 해당 interface 내부적으로 구현되어 있는 아주 많은 메서드들을 모두 오버라이딩 해야하기 때문에 이렇게 구현하는 것은 코드 관리상 비효율적이다.
이러한 이유로 QueryDSL을 활용한 동적 쿼리 메서드와 같은 경우는 스프링 데이터 JPA 인터페이스에 구현할 수 없다.
따라서, 별개의 인터페이스를 만들고, 이를 구현한 구현체에 QueryDSL과 같은 쿼리 메서드를 구현하면 된다.
이후, 별개로 만든 인터페이스를 스프링 데이터 JPA 인터페이스가 상속하도록 하면 된다.
※ 이 때, 구현체의 이름은 'Entity이름 + Repository + Impl' 이어야만 한다!! (예를 들어, MemberRepositoryImpl 형태.)
스프링 데이터 2.x부터는 사용자 정의 구현 클래스에 '리포지토리 인터페이스 이름 + Impl'을 적용하는 대신에 '사용자 정의 인터페이스 명 + Impl' 방식도 지원한다.
※ 여기서는 내가 별개로 정의한 인터페이스를 스프링 데이터 JPA 인터페이스에 상속시킬 예정이지만, 너무 특정 화면에만 적합한 로직이거나 공통된 로직이 아닐 경우에는 오히려 스프링 데이터 JPA 인터페이스에 상속시키는 것이 아닌 따로 분리시켜 관리하는 것이 더 좋은 설계가 될 수 있다.
자, 그럼 본격적으로 Repository를 분리해보자.
가장 먼저 분리할 Repository Interface를 생성해준다. 해당 인터페이스 이름은 자유롭게 지어도 된다.
public interface MentorRepositoryCustom {
List<Mentor> searchProfileList(String nation, String keyword);
}
다음으로, 방금 생성한 인터페이스를 구현할 구현체 Repository를 생성해준다.
이 때 이름은 위에서도 언급했듯이 반드시 'Entity이름 + Repository + Impl' 형태로 지어줘야 한다.
public class MentorRepositoryImpl implements MentorRepositoryCustom {
}
QueryDSL을 활용한 동적 쿼리 구현 도입
(QueryDSL 라이브러리 설치 및 Q객체 생성 설정 부분은 생략하겠습니다.)
자, 이제 QueryDSL을 활용할 준비가 모두 끝났다!
이제 본격적으로 동적 쿼리를 구현하면 된다.
QueryDSL로 동적 쿼리를 구현하는 방법에는 다음과 같이 크게 2가지가 있다.
- BooleanBuilder
- Where 다중 파라미터 사용
두 방법 중 어느 방법을 사용해도 동적 쿼리는 해결할 수 있다.
하지만 나는 그 중에서도 Where 다중 파라미터 방법을 사용해보고자 한다.
Where 다중 파라미터 방법을 선택한 이유는 다음과 같다.
BooleanBuilder는 따로 Builder 객체를 만들어줘야 하는데, 그 과정에서 코드가 길어질 수 있다.
Where 다중 파라미터를 활용할 경우엔 조건들을 메서드로 분리할 수 있다는 장점이 있다.
조건들을 메서드로 분리하다 보니, 해당 메서드를 다른 쿼리 구현에도 재활용할 수 있다.
각 메서드들을 조합하여 하나의 메서드로 만들어 활용하는 등 메서드 조합이 가능하다.
이렇게 결정한 Where 다중 파라미터 방법을 기반으로 초기 QueryDSL 쿼리 메서드를 작성해보았다.
(본래는 현재 구현하려는 페이지에서 페이징 쿼리도 신경써야 하지만, 현재 포스팅에서 그 부분은 생략하였다.)
public class MentorRepositoryImpl implements MentorRepositoryCustom {
private final JPAQueryFactory queryFactory;
public MentorRepositoryImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}
@Override
public List<Mentor> searchProfileList(String nationCond, String keywordCond, Long monthCond) {
return queryFactory
.selectFrom(mentor)
.join(mentor.user, user).fetchJoin()
.where("여기다가 조건을 기입")
.fetch();
}
QueryDSL을 활용하기 위해서는 JPAQueryFactory를 주입 받아야 한다.
JPAQueryFactory는 Bean으로 등록하여 RequiredArgsConstructor 어노테이션을 활용해 주입받아도 된다.
이후에는 QueryDSL 문법에 따라 작성해주었다.
User와 fetchjoin을 한 이유는 User Entity에 Mentor의 닉네임이 포함되어 있기 때문이다.
관심 국가 데이터 처리
가장 먼저 필터 맨 앞에 있는 국가 데이터 부터 조건을 처리해보자.
우리가 구현할 기능은 유저가 선택한 관심 국가와 일치하는 데이터만 보여주면 된다.
따라서 간단하게 다음과 같이 nationEq() 메서드를 분리, 구현하여 해당 조건을 처리할 수 있다.
@Override
public List<Mentor> searchProfileList(String nationCond, String keywordCond, Long monthCond) {
return queryFactory
.selectFrom(mentor)
.join(mentor.user, user).fetchJoin()
.where(
nationEq(nationCond)
)
.fetch();
}
private BooleanExpression nationEq(String nationCond) {
return StringUtils.hasText(nationCond) ? mentor.nation.eq(nationCond) : null;
}
관심 국가 매개변수는 null이 들어올 수 있음을 항상 인지하고 관련 처리를 해주어야 한다.
여기서는 null 대신에 StringUtils.hasText() 메서드를 활용하였다.
그 이유는 null 외에도 ""와 같은 빈 문자가 들어올 수 있기 때문이다.
또한, 메서드 분리를 하면 기본 반환 값이 Predicate로 되어있는데, 이를 BooleanExpression으로 수정해주자.
BooleanExpression은 and 와 or 같은 메서드들을 이용해서 BooleanExpression을 조합해서 새로운 BooleanExpression을 만들 수 있다는 장점이 있다. 그러므로 재사용성이 높다. 그리고 BooleanExpression 은 null을 반환하게 되면 Where 절에서 조건이 무시되기 때문에 안전하기 때문이다.
키워드 데이터 처리
이제 조금 복잡한 키워드 데이터를 처리해보자.
키워드 데이터를 방금 구현한 메서드처럼 구현하려고 하면 문제가 발생한다.
왜냐면, 우리가 화면으로 부터 받아오는 키워드 데이터는 String Type인데, 현재 쿼리에서 다루고 있는 부분은 mentor.mentorKeywordList로 mentorKeyword Entity가 여러 개 담긴 객체 형태이기 때문이다.
비교를 원하는 데이터가 mentorKeywordList 안에 있는 mentorKeyword 중에서도 멤버 변수인 String keyword를 비교하고 싶다는 점에서 서브 쿼리를 활용하면 이 문제를 해결할 수 있겠다고 생각했다.
그리고 많은 고민 끝에 이 부분은 다음과 같이 서브 쿼리를 활용해서 구현하였다.
@Override
public List<Mentor> searchProfileList(String nationCond, String keywordCond, Long monthCond) {
return queryFactory
.selectFrom(mentor)
.join(mentor.user, user).fetchJoin()
.where(
nationEq(nationCond),
keywordEq(keywordCond)
)
.fetch();
}
private BooleanExpression keywordEq(String keywordCond) {
if(!StringUtils.hasText(keywordCond)) {
return null;
}
return mentor.mentorKeywordList.contains(
JPAExpressions
.select(mentorKeyword)
.from(mentorKeyword)
.where(mentorKeyword.keyword.eq(keywordCond), mentor.id.eq(mentorKeyword.mentor.id))
);
mentor의 mentorKeywordList 중에서 현재 조회한 mentor의 id값(FK)을 비교하여 일치하는 mentorKeyword 중에서도 검색 조건과 일치하는 키워드만을 조회하는 서브 쿼리를 where 조건에 추가했다.
날짜 데이터
구현하면서 가장 애를 먹고, 힘들었던 날짜 데이터 부분이다.
날짜 데이터 조건을 QueryDSL로 구현하는데는 다음과 같은 어려움이 있었다.
내 서버가 제공해야하는 날짜 데이터는 멘토가 워킹홀리데이를 시작한 날짜와 끝난 날짜 차이를 개월 수 데이터 형태로 클라이언트에게 제공해줘야 했다.
검색 조건에서의 날짜는 두 날짜 차이가 검색 조건에서 주어진 날짜(개월 수) 보다 크거나 같은 데이터들만 출력 하는 기능으로 구현해야 했다.
워킹 홀리데이 시작 날짜와 끝 날짜는 DB에 'YYYY-MM-dd' 형태로 저장되어 있으며, 끝 날짜는 현재 시기 워킹홀리데이를 진행하고 있을 수도 있기 때문에 null이 들어갈 수도 있는 상황이다.
처음에 QueryDSL 쿼리 내부에서 날짜 데이터의 뺄셈을 구현하고자 했다.
QueryDSL 내부에서 사용되는 객체는 Entity가 아닌, Q객체를 사용하고 있었기에 Entity에 선언된 기존 날짜 차이를 구하는 메서드를 활용할 수 없었다.
다른 방법으로 QueryDSL 내부에서 SQL function을 호출하여 해결해보고자 했다.
날짜 데이터 뺄셈을 지원해주는 function을 활용한다면, Month 형태로 날짜를 구할 수 있을 것이라고 생각했다.
하지만 그 function을 찾지 못했다.
여러 시도 끝에 데이터 부분은 결국 Service 단에서 Stream으로 분기하여 처리하는 방식을 택했다.
// Service 구현부
@Transactional(readOnly = true)
public List<ProfileListRes> searchProfileList(String nationCond, String keywordCond, Long monthCond) {
if(monthCond!=null) {
return mentorRepository.searchProfileList(nationCond, keywordCond).stream()
.map(ProfileListRes::new)
.filter(p->p.getMonthPeriod()>=monthCond)
.collect(Collectors.toList());
}
return mentorRepository.searchProfileList(nationCond, keywordCond).stream()
.map(ProfileListRes::new)
.collect(Collectors.toList());
}
// ProfileListRes 생성자 구현부
public ProfileListRes(Mentor mentor) {
this.id = mentor.getId();
this.profileImageUrl = mentor.getProfileImageUrl();
this.nickname = mentor.getUser().getNickname();
this.mentorNation = mentor.getNation();
this.monthPeriod = mentor.getEndDate() != null ?
ChronoUnit.MONTHS.between(mentor.getStartDate(), mentor.getEndDate()) :
ChronoUnit.MONTHS.between(mentor.getStartDate(), LocalDate.now());
this.keywordList = mentor.getMentorKeywordList().stream()
.map(k -> k.getKeyword())
.collect(Collectors.toList());
}
참고
'👨💻 개발' 카테고리의 다른 글
SpringBoot에 CloudFront 적용 (0) | 2021.12.07 |
---|---|
AWS CloudFront 배포하기 (0) | 2021.12.07 |
SpringBoot로 AWS S3에 파일 업로드 하기 (0) | 2021.12.05 |
AWS S3 저장소 구축하기 (0) | 2021.12.05 |
SpringBoot 파일 업로드 구현 (0) | 2021.12.03 |