문제 상황
JPA와 Spring Data Jpa를 처음 접한 날은 신세계의 연속이었다.
그러나, named쿼리가 아닌 JPQL을 작성하면서 JdbcTemplate에서 느꼈던 몇가지 공통된 불편함이 있었다.
문제1) String 쿼리 관리의 불편함
예를 들어 다음 쿼리를 보자
@Query(" select r" +
"from Reservation r " +
"where r.reservationSlot.date = :date " +
"and r.reservationSlot.theme.id =:themeId " +
"and r.reservationSlot.time.id = :timeId")
Optional<String> findByDateAndThemeIdAndTimeId(LocalDate date, long themeId, long timeId);
이 쿼리는 두 가지 문제가 있다.
1) "select r" 다음 공백이 누락되어 있다.
2) 반환 타입이 일치하지 않는다 > Reservation이 아닌 String을 반환하고 있음
그러나, 이 오류는 런타임에 발견된다. 여기서 쿼리를 String이 아닌 코드로 관리하고 싶다는 생각이 들었다.
-> 수요1. 코드로 쿼리를 관리하면서 휴먼에러를 최소화하고 싶다.
문제2) 인터페이스 답지 않은 인터페이스
인터페이스는 책임과 역할을 명시한 것이다. 그러나, 지금의 ReservationRepository를 보자
public interface ReservationRepository extends JpaRepository<Reservation, Long>, QueryDslReservationRepository{
@Query(" select r " +
"from Reservation r " +
"where r.reservationSlot.date = :date " +
"and r.reservationSlot.theme.id =:themeId " +
"and r.reservationSlot.time.id = :timeId")
Optional<Reservation> findByDateAndThemeIdAndTimeId(LocalDate date, long themeId, long timeId);
@Query("""
select r
from Reservation r
join fetch r.reservationSlot
where r.member = :member
and r.reservationSlot.date >=:date
order by r.reservationSlot.date asc, r.reservationSlot.time.startAt asc""")
List<Reservation> findByMemberAndDateGreaterThanEqual(Member member, LocalDate date);
......
}
1) public interface들을 가시적으로 파악하기 힘들다.
2) 인터페이스에 대한 구현(JPQL)이 노출되어 있다.
인터페이스가 책임과 역할을 명시하는 것을 넘어서 어떻게 실행될 것인지에 대한 메소드 정보가 튀어나온 느낌이 들었다. 캡슐화가 잘 지켜지지 않은 것을 넘어, SQL 의존적인 코드에서 온전히 벗어나지 못한 느낌이었다.
- 수요2. inerface 내에 구현 관련 코드가 없으면 좋겠다.
문제3) 동적 쿼리
방탈출 도메인에서 어드민은 선택적인 필터링 정보를 바탕으로 예약 정보를 조회할 수 있다.
필터링 정보
- 예약한 멤버 이름
- 예약한 테마 이름
- 시작 날짜
- 종료 날짜
각 필터링 정보는 주어질 수도, 주어지지 않을수도 있다.
예를 들어 멤버 이름과 테마이름만 주어지면 전체기간에 대해 해당 멤버가 해당 테마를 예약한 이력을 가져와야 한다.
그러나, 테마 이름과 시작날짜 2024-05-01만 입력하면, 해당 테마의 예약이력 중 5월 1일 이후의 이력만을 가져와야 했다.
이렇듯 입력 정보에 따라 쿼리를 동적으록 구성해야 한다.(이를 동적쿼리라고 칭한다).
나와 페어였던 백호는 JPQL을 활용해 다음과 같이 동적쿼리를 짰다.
@Query("""
select r
from Reservation r
join fetch r.reservationSlot
join fetch r.member
where (:startDate is null or r.reservationSlot.date>= :startDate)
and (:endDate is null or r.reservationSlot.date <= :endDate)
and (:themeId is null or r.reservationSlot.theme.id = :themeId)
and (:memberId is null or r.member.id = :memberId)""")
List<Reservation> findByConditions(
Optional<LocalDate> startDate,
Optional<LocalDate> endDate,
Long themeId,
Long memberId
);
지저분했다.
- 수요3: 더 깔끔한 동적 쿼리를 구성하고 싶다.
정리하자면 내가 겪은 문제 상황은 다음과 같다.
문제1) 휴먼에러에 취약한 String query
문제2) 구현이 튀어나온 인터페이스
문제3) 지저분한 동적 쿼리
이로부터 해결하고 싶은 수요는 다음과 같았다.
수요1) 쿼리를 코드로 관리하고 싶다
수요2) 구현을 캡슐화하고 싶다
수요3) 깔끔한 동적 쿼리
QueryDsl?
영한님 JPA 강의를 듣다가 지나가다 queryDsl이란 키워드를 던져주셨는데,
조금 찾아보니 지금 내가 겪는 문제를 상당부분 해결할 수 있는 기능이 많았다.
호기심이 커졌고 브랜치를 파 우테코 Lv2 미션에 적용해보면서
내가 겪는 문제를 QueryDsl이 근본적으로 해결할 수 있는지,
어떤 부분이 장점이고, 단점인지를 시음해보기로 했다.
사용법은 이미 잘 정리한 reference가 있어 공식문서를 기반으로 가볍게만 적용해보기로 했다.
느낀 점
1) 코드로 관리하는 쿼리 > 타입안전성 / 재사용성
queryDsl로 리팩터링한 코드를 보며 느낀 점을 복기하면 다음과 같다.
1-1) 타입안전성이 보장된다.
@Override
public List<Reservation> findByDateAndTheme(LocalDate date, Theme theme) {
return jpaQueryFactory.selectFrom(reservation)
.where(isSameDate(date), isSameTheme(theme))
.fetch();
}
private BooleanExpression isSameTheme(Theme theme) {
return reservation.reservationSlot.theme.eq(theme);
}
private BooleanExpression isSameDate(LocalDate date) {
return reservation.reservationSlot.date.eq(date);
}
- String으로 쿼리를 관리하지 않다보니 잘못된 필드/ 반환타입 불일치/ 공백 등을 신경쓰지 않아주어도 되었다.
- QClass에서 Entity의 메타정보를 관리하고 잘못된 쿼리의 경우 상당부분 컴파일 에러를 발생시켜주었다.
- 전반적으로 더 안정적인 쿼리를 작성할 수 있게 되었다.
1-2) 코드 재사용성이 증가했다.
@Override
public Optional<Reservation> findByDateAndThemeIdAndTimeId(LocalDate date, long themeId, long timeId) {
return Optional.ofNullable(jpaQueryFactory.selectFrom(reservation)
.where(isSameDate(date), isSameThemeId(themeId), isSameTimeId(timeId))
.fetchOne());
}
- 기존 JPQL 쿼리에서는 date를 비교하는 쿼리가 중복되었다.
- QueryDsl에서는 isSameDate라는 private 메서드를 통해 공통된 쿼리를 코드로 재사용할 수 있었다.
1-3) 코드 가독성이 증가했다.
// 기존
@Query("""
select r
from Reservation r
join fetch r.reservationSlot
where r.member = :member
and r.reservationSlot.date >=:date
order by r.reservationSlot.date asc, r.reservationSlot.time.startAt asc""")
List<Reservation> findByMemberAndDateGreaterThanEqual(Member member, LocalDate date);
// QueryDsl
@Override
public List<Reservation> findByMemberAndDateGreaterThanEqual(Member member, LocalDate date) {
return jpaQueryFactory.selectFrom(reservation)
.where(reservation.member.eq(member),
reservation.reservationSlot.date.goe(date))
.orderBy(dateAsc(), timeAsc())
.fetch();
}
private OrderSpecifier<LocalTime> timeAsc() {
return reservation.reservationSlot.time.startAt.asc();
}
private OrderSpecifier<LocalDate> dateAsc() {
return reservation.reservationSlot.date.asc();
}
- 복합값 타입을 사용하고 있는 만큼 정렬문에서 가독성이 향상되었다.
- 특히 JPQL에선 프로젝션 or Dto 타입 반환을 위해선 패키지 경로를 모두 명시해야해서 가독성이 크게 떨어지는 경우가 많았는데, 이런 부분을 private method로 분리한다면 더 읽기 쉬운 쿼리를 작성할 수 있을 것 같다.
2) 역할과 구현의 분리 > 캡슐화
상당히 마음에 들었던 것은 QueryDsl을 사용하면서 인터페이스다운 인터페이스를 구성할 수 있었다는 점이다.
실제 리팩터링한 QueryDslRepository 코드는 다음과 같다.
public interface QueryDslReservationRepository {
List<Reservation> findByDateAndTheme(LocalDate date, Theme theme);
List<Reservation> findAllByDateBetween(LocalDate startDate, LocalDate endDate);
List<Reservation> findByConditions(
Optional<LocalDate> startDate,
Optional<LocalDate> endDate,
Long themeId,
Long memberId
);
Optional<Reservation> findByDateAndThemeIdAndTimeId(LocalDate date, long themeId, long timeId);
List<Reservation> findByMemberAndDateGreaterThanEqual(Member member, LocalDate date);
}
어떤 역할을 수행하는지만 명시되어 있다.
어떤 쿼리를 사용해서, 혹은 어떤 조인문을 사용해서 엔티티를 CRUD하는지는 구현체 내부로 캡슐화되어 있다.
즉, 역할과 구현이 분리되어 있다.
레포지토리의 역할은 구현체에서 구현한다.
public class QueryDslReservationRepositoryImpl implements QueryDslReservationRepository {
private JPAQueryFactory jpaQueryFactory;
public QueryDslReservationRepositoryImpl(JPAQueryFactory jpaQueryFactory) {
this.jpaQueryFactory = jpaQueryFactory;
}
...
}
인터페이스 다운 인터페이스를 유지할 수 있다는 게 마음에 들었다.
3) 깔끔한 동적 쿼리
마지막으로 QueryDsl에 관심을 가지게 한 동적쿼리문이다.
QueryDsl은 크게 두 가지 방법으로 동적 쿼리문을 작성할 수 있다.
1. BooleanBuilder
2. BooleanExpression
3-1) BooleanBuilder
BooleanBuilder는 builder에 하나씩 조건을 추가하는 방식으로 만들어진다.
다음 예시에서는 name과 author를 기반으로 동적쿼리를 작성하고 있다.
name | author | 쿼리 |
O | O | name일치 + author 일치 |
O | X | name일치 |
X | O | author 일치 |
X | X | 전체 반환 |
@RequiredArgsConstructor
@Repository
public class BookRepositoryImpl implements BookRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public List<Book> findBooks(String name, String author) {
BooleanBuilder builder = new BooleanBuilder();
if (!StringUtils.isEmpty(name)) {
builder.and(book.name.eq(name));
}
if (!StringUtils.isEmpty(idx)) {
builder.and(book.author.eq(author));
}
return queryFactory
.selectFrom(book)
.where(builder)
.fetch();
}
}
출처 : https://kkambi.tistory.com/193
3-2) BooleanExpression
두번째는 BooleanExpression인데, 각각 별도의 메서드를 활용하여 조건문을 적용시켜주는 것이다.
여기서 중요한 점은 만약 BooleanExpression에서 null을 반환하면 그 조건이 없었던 것처럼 무시된다.
예를 들어 다음과 같은 쿼리가 있고 3개의 BooleanExpression이 있다고 가정해보자
where(조건1, 조건2, 조건3)
만약 조건1에서 null을 반환하면 where절은 조건1을 무시하고 조건2 and 조건3으로 쿼리를 작성한다.
만약 조건2도 null이라면 조건3만 반영한다.
조건 3도 null이라면 조건이 없는 것으로 생각한다.
이를 기반으로 내가 불편함을 겪었던 동적 쿼리를 리팩터링하면 다음과 같다.
@Override
public List<Reservation> findByConditions(
Optional<LocalDate> startDate,
Optional<LocalDate> endDate,
Long themeId,
Long memberId
) {
return jpaQueryFactory.selectFrom(reservation)
.join(reservation.member, member)
.fetchJoin()
.where(greaterThanEqualDate(startDate),
lessThanEqualDate(endDate),
isSameThemeId(themeId),
isSameMemberId(memberId))
.fetch();
}
private BooleanExpression isSameMemberId(Long memberId) {
if (memberId == null) return null;
return reservation.member.id.eq(memberId);
}
private BooleanExpression isSameThemeId(Long themeId) {
if (themeId == null) {
return null;
}
return reservation.reservationSlot.theme.id.eq(themeId);
}
private BooleanExpression lessThanEqualDate(Optional<LocalDate> endDate) {
return endDate.map(QReservation.reservation.reservationSlot.date::loe).orElse(null);
}
private BooleanExpression greaterThanEqualDate(Optional<LocalDate> startDate) {
return startDate.map(QReservation.reservation.reservationSlot.date::goe).orElse(null);
}
놀라웠다. 기본적으로 가독성있는 코드가 완성되었고, 동적 쿼리를 이렇게 깔끔하게 작성할 수 있다는 게 좋았다.
무엇보다 where 절 각각에 있는 BooleanExpression은 다른 쿼리에서도 재사용가능한 코드였다.
장점만 있을까?
찍먹을 해본 날에는 항상 장점만 있을 것 같다. 새로운 맛이 놀랍기도 하고 또 신선하니까
그러나, 단점을 이해하고 있어야 장점을 더 잘 활용할 수 있고 이 기술을 선택하는 기준을 세울 수 있다
개인적으로 찾아보며 느낀 단점은 다음과 같다.
1) exists의 경우 count 쿼리를 사용해 성능이 느리다.
- 자세한 내용은 다음 블로그를 참조하자
- @Query나 QueryDsl은 count 쿼리를 통해 exists를 구현해놓아 속도가 느리다
- 그에 비해 JPARepository의 exists는 내부적으로 limit1을 통해 조회하기에 속도가 빠르다
- 즉, exists 관련된 쿼리문은 되도록 JPARepository를 활용하자
2) 협업 환경에서의 문제
- QueryDsl은 버전별 문법 변화가 컸다.
- 또한 버전별로 환경 설정 및 Qclass directory 설정이 달라 서로 다른 환경의 로컬에서 협업해야 한다면 오류 발생가능성이 있을 것 같았다.
3) 영속성 컨텍스트의 1차 캐시를 활용하지 못한다.
- QueryDsl은 간단히 말하면 JPQL builder이다.
-이는 JPQL을 직접 쏜다는 것을 의미하고 영속성 컨텍스트 내부 1차 캐시에 이미 저장되어 있는 객체의 경우에도 직접 DB에 쿼리를 쏘아 가져온다는 것을 의미한다. 즉, 영속성 컨텍스트의 1차 캐시적 이점을 활용하지 못한다.
돌아보기
내가 원했던 개선점들을 다시 돌아보자
수요1) 쿼리를 코드로 관리하고 싶다
수요2) 구현을 캡슐화하고 싶다
수요3) 깔끔한 동적 쿼리
QueryDsl 은 내가 문제를 느낀 3가지 지점을 상당부분 개선해주었다. Lv3에서 한번 적용해보자고 이야기하고플 정도로 코드 친화적으로 쿼리를 작성할 수 있다는 점이 마음에 들었다.
그러나, 그 이전에 JPQL에 대한 깊이 있는 이해가 선행되야 한다는 것을 뼈저리게 느꼈다.
QueryDsl이 JPQL base를 두고 여러 기능을 제공하고 있는 만큼 JPQL 동작 원리를 모르면 QueryDsl에서 발생하는 여러 오류들을 디버깅할 자신이 없을 것 같았다.
여러모로 유의미한 시도가 아니었나 싶다.
'우테코' 카테고리의 다른 글
프로젝트 API문서 작성을 위한 Swagger 도입기 (1) | 2024.09.02 |
---|---|
MockRestServiceServer : 외부 API 호출 테스트 하기 (0) | 2024.06.29 |
[우테코- Lv3] 아이디어 기획본1 (0) | 2024.06.19 |
[Rest Docs vs Swagger] 2편 : Swagger Spring docs적용기 (0) | 2024.06.16 |
[Rest Docs vs Swagger] 1편 : Rest Docs로 API 문서 자동화해보기 (0) | 2024.06.13 |