[ 메이트 프로필 ] - JPQL을 QueryDSL로 전환한 이유
📜서론
단잠 서비스에서 메이트 프로필을 조회할 때 다양한 필터링 조건을 동적으로 적용해야 하는 경우가 많습니다.
기존에는 이를 JPQL로 구현했지만, 모두 동적으로 변하다 보니 코드가 상당히 복잡했습니다.
그러다 보니 다음과 같은 오류 상황이 많이 발생했습니다.
- 실행 후, 런타임 단계에서 여러 오류 발생으로 인한 번거로움.
- 매우 길고 가독성이 떨어지는 쿼리 로직
이러한 문제를 해결하기 위해 QueryDSL을 도입하게 되었습니다. 이번 글에서는 QueryDSL이 무엇인지, JPQL과의 차이점, 그리고 실제 적용 과정에 대해 정리해보겠습니다.
❓JPQL이란
JPQL은 JPA에서 제공하는 객체 지향 쿼리 언어입니다. 기본적으로 SQL과 상당히 유사하지만, 데이터베이스의 테이블을 대상으로 쿼리를 작성하는 것이 아닌 엔티티 객체를 대상으로 쿼리를 작성합니다. 그렇기 때문에 특정 데이터베이스에 의존하지 않고 JPA 구현체를 통해 실행됩니다.
JPQL 특징
1. SQL을 추상화한 JPA의 객체지향 쿼리언어
2. Table이 아닌 Entity 객체를 대상으로 개발
🤔QueryDSL이란?
SQL, JPQL 등을 코드로 작성할 수 있도록 해주는 빌더 오픈소스 프레임워크입니다.
기존 방식(Mybatis, JPQL등)은 모두 문자열형태로 쿼리가 작성되었고 이로 인해 컴파일 단계에서 Type-Check가 불가했습니다. 이러한 risk를 줄이기 위해 query DSL이 등장했고 이를 통해 컴파일 단계에서 Type-check가 가능해졋습니다.
QueryDSL특징
1. JPQL을 코드 기반의 방식으로 작성할 수 있다.
2. 컴파일 단계에서 Type-Check가 가능하다.
3. 코드 기반(메서드 체티닝) 방식을 사용하여 동적 쿼리를 쉽게 구성할 수 있다.
🔍실제 적용 사례를 통해 QueryDSL을 살펴보겠습니다.
기존 JPQL은 문자열을 기반으로 쿼리를 작성하기 때문에 가독성이 떨어지고, 동적 쿼리를 처리하기 어렵습니다.
아래는 특정 UserProfile을 조회하는 JPQL 코드입니다.
기존 JPQL 코드 예시(일부)
@Query("SELECT DISTINCT up FROM UserProfile up " +
"JOIN up.user u " +
"JOIN u.school s " +
"WHERE s.id = :schoolId " +
"AND (:gender IS NULL OR u.gender = :gender) " +
"AND (:minEntryYear IS NULL OR u.myProfile.entryYear >= CAST(:minEntryYear AS INTEGER)) ")
List<UserProfile> findProfilesByFilters(
@Param("schoolId") Long schoolId,
@Param("gender") Integer gender,
@Param("minEntryYear") String minEntryYear
);
이 방식은 문자열을 직접 사용해야 하기 때문에 오타등 실수로 인해 오류 발생 가능성이 높고, 이를 런타임 단계에서 확인 가능하기 때문에 매우 번거로웠습니다.
🚨문제점
- 문자열 기반이므로 조건이 많아질수록 가독성이 떨어짐
- 타입 세이프를 보장하지 않음 → 오타 같은 사소한 오류도 런타임때 확인 가능
QueryDSL을 사용하면 코드 기반 방식으로 쿼리를 작성할 수 있어 가독성이 높습니다. 또한, 타입 세이프를 보장하여 컴파일 단계에서 오류를 확인하고 방지할 수 있습니다.
아래는 위 JPQL을 QueryDSL을 활용하여 변환한 코드입니다.
@Repository
@RequiredArgsConstructor
public class UserQueryDSLRepositoryImpl implements UserQueryDSLRepository {
private final JPAQueryFactory queryFactory;
@Override
public List<UserProfile> findProfilesByFilters(Long schoolId, Integer gender, String minEntryYear) {
QUserProfile up = QUserProfile.userProfile;
QUser u = QUser.user;
QSchool s = QSchool.school;
BooleanBuilder builder = new BooleanBuilder();
builder.and(s.id.eq(schoolId)); // 학교 ID 필수 조건
if (gender != null) {
builder.and(u.gender.eq(gender)); // 성별 필터
}
if (minEntryYear != null) {
builder.and(u.myProfile.entryYear.goe(Integer.parseInt(minEntryYear))); // 최소 입학년도 필터
}
return queryFactory
.selectFrom(up)
.join(up.user, u)
.join(u.school, s)
.where(builder)
.fetch();
}
}
😊QueryDSL로 변환했을 때의 장점
1. 가독성 향상
- BooleanBuilder를 사용하여 동적 조건을 추가하므로 쿼리 로직이 한눈에 보임
2. 타입 세이프 보장
- entryYear 같은 필드명을 잘못 입력해도 컴파일 단계에서 오류를 감지
3. 동적 쿼리 작성 용이
- if 문을 활용하여 필요한 조건만 추가 가능
- null 값이 자동으로 필터링되므로 불필요한 쿼리 조건이 실행되지 않음
사실 이러한 이유로도 충분히 QueryDSL을 사용할 이유가 되었지만, 예상치 못하게 매우 맘에든 부분도 있었습니다.
바로 QueryDSL에서 제공하는 다양한 연산자를 활용하여 쉽고 가독성 있게 쿼리를 작성할 수 있다는 점이었습니다.
쿼리 DSL에서는 eq, goe, loe, likeIgnoreCase 등 다양한 연산자를 기본적으로 제공하고 있습니다.
이러한 연산자를 활용하니 코드가 훨씬 간결해지고 가독성이 높아졌습니다.
QueryDSL 연산자를 활용한 코드 예시
실제 코드에서 이러한 연산자를 활용하면 다음과 같이 깔끔한 쿼리 작성이 가능합니다.
@Repository
@RequiredArgsConstructor
public class UserQueryDSLRepositoryImpl implements UserQueryDSLRepository {
private final JPAQueryFactory queryFactory;
@Override
public List<UserProfile> findProfilesByFilters(Long schoolId, Integer gender, String minEntryYear, String maxBirthYear, String mbti) {
QUserProfile up = QUserProfile.userProfile;
QUser u = QUser.user;
QSchool s = QSchool.school;
BooleanBuilder builder = new BooleanBuilder();
builder.and(s.id.eq(schoolId)); // 학교 ID 필수 조건
if (gender != null) {
builder.and(u.gender.eq(gender)); // 성별 필터
}
if (minEntryYear != null) {
builder.and(u.myProfile.entryYear.goe(Integer.parseInt(minEntryYear))); // 최소 입학년도 필터
}
if (maxBirthYear != null) {
builder.and(u.myProfile.birth.substring(0, 4).loe(maxBirthYear)); // 출생 연도 필터
}
if (mbti != null && !mbti.isEmpty()) {
builder.and(u.myProfile.mbti.likeIgnoreCase("%" + mbti + "%")); // MBTI 필터 (대소문자 무시)
}
return queryFactory
.selectFrom(up)
.join(up.user, u)
.join(u.school, s)
.where(builder)
.fetch();
}
}
참고: QueryDSL에서 자주 사용하는 연산자 정리
eq() | Equal (같다) | user.gender.eq(1) |
goe() | Greater than or equal (이상) | profile.entryYear.goe(2019) |
loe() | Less than or equal (이하) | profile.birth.substring(0,4).loe("2000") |
likeIgnoreCase() | 대소문자 구분 없이 LIKE 검색 | profile.mbti.likeIgnoreCase("%ENFP%") |
in() | IN 연산자 | user.id.in(List.of(1L, 2L, 3L)) |
isNotNull() | NULL이 아닌 경우 | profile.mbti.isNotNull() |
결론
QueryDSL을 도입한 이유가 단순히 JPQL보다 동적 쿼리 작성이 편리하기 때문이었습니다. 하지만 사용해보면서 가독성이 뛰어나고, 사용하기에 매우편리하다는것을 깨달았습니다.
특히 eq(), goe(), loe(), likeIgnoreCase() 등을 활용하면서 쿼리 작성이 더 직관적이고, 가독성이 뛰어나게 되었다는점이 가장 만족스러웠습니다.
