개요

기본적인 CRUD 만 구현하기에는 JPA 만한 것이 없다. 하지만 특정 날짜를 기준으로 이전 날짜 게시물만 조회하는 일이 필요할 때에는 JPA 만 사용하기에는 버겁다. 그렇다면 모든 데이터를 가져온 후 골라낼 것인가? 당연히 아니다.

JPQL

@Query("SELECT r FROM Reservation r" 
    + "WHERE r.reservatedAt < :now")
List<Reservation> findPastReservation();

원하는 조건의 데이터를 조회하기 위해서는 필연적으로 JPQL 을 필요로하게 된다. 하지만 복잡한 로직의 경우 쿼리 문자열이 매우 길어질 수 있고, 오타 등의 문법적인 오류가 발생하더라도 런타임 에러로 잡아낼 수 있다.

하드코딩되어있는 부분을 제거하고, 동적 쿼리 및 재사용성을 높일 수 있는 것이 QueryDSL 이다.

QueryDSL

설정

	implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
	annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
	annotationProcessor "jakarta.annotation:jakarta.annotation-api"
	annotationProcessor "jakarta.persistence:jakarta.persistence-api"

build.gradle 에 의존성을 추가한다.

image

이후 Gradle 에서 java 를 컴파일시킨다. 이때, 미리 엔티티를 작성해두어야한다.

image

그럼 위와 같이 generated 내부에 Q엔티티가 생성되는 것을 확인할 수 있다.

Custom Repository

image

대게 JPA 에 CUSTOM 레포지터리는 위와 같이 추가된다. 기존 JPARepository 를 상속받는 Repository 와 별개로 MemberRepositoryCustom 을 생성한다. 구현 클래스인 MemberRepositoryImpl 를 구현해놓고, 기존 MemberRepository 에서 MemberRepositoryCustom 을 함께 상속한다.

//ReservationRepositoryCustom

public interface ReservationRepositoryCustom {
    public List<Reservation> findPastReservationsByClientId(Long clientId);
}

커스텀 인터페이스와

//ReservationRepositoryCustomImpl

@Repository
@RequiredArgsConstructor
@Slf4j
public class ReservationRepositoryCustomImpl implements ReservationRepositoryCustom {
    private final JPAQueryFactory queryFactory;

    @Override
    public List<Reservation> findPastReservationsByClientId(Long clientId) {
        return queryFactory
                .selectFrom(reservation)
                .where(reservation.reservatedAt.after(LocalDateTime.now()))
                .fetch();
    }

}

구현 클래스를 작성한다.

public interface ReservationRepository extends JpaRepository<Reservation, Long>, ReservationRepositoryCustom { }

이후 기존 레포지터리에 상속을 추가한다.

끝!..?

날짜비교

하지만 치명적인 문제가 존재했다.

INSERT INTO Reservation (client_id, guide_id, name, reservated_at)
VALUES
    (1, 2, 'Reservation -1Y', DATETIME('now', '-1 years')),
    (1, 2, 'Reservation -72', DATETIME('now', '-3 days')),
    (1, 2, 'Reservation -48', DATETIME('now', '-2 days')),
    (1, 2, 'Reservation -20', DATETIME('now', '-20 hours')),
    (1, 2, 'Reservation -16', DATETIME('now', '-16 hours')),
    (1, 2, 'Reservation -12', DATETIME('now', '-12 hours')),
    (1, 2, 'Reservation -8', DATETIME('now', '-8 hours')),
    (1, 2, 'Reservation -4', DATETIME('now', '-4 hours')),
    (1, 2, 'Reservation now', CURRENT_TIMESTAMP),
    (1, 2, 'Reservation +4', DATETIME('now', '+4 hours')),
    (1, 2, 'Reservation +8', DATETIME('now', '+8 hours')),
    (1, 2, 'Reservation +12', DATETIME('now', '+12 hours')),
    (1, 2, 'Reservation +16', DATETIME('now', '+16 hours')),
    (1, 2, 'Reservation +20', DATETIME('now', '+20 hours')),
    (1, 2, 'Reservation +48', DATETIME('now', '+2 days')),
    (1, 2, 'Reservation +120', DATETIME('now', '+5 days'));

위와 같이 기본 데이터를 삽입해주고 (SQLite3)

Hibernate: 
    select
        r1_0.id,
        r1_0.client_id,
        r1_0.guide_id,
        r1_0.name,
        r1_0.reservated_at 
    from
        reservation r1_0 
    where
        r1_0.reservated_at<?
2024-05-10T16:57:52.472+09:00 TRACE 20848 --- [    Test worker] org.hibernate.orm.jdbc.bind              : binding parameter (1:TIMESTAMP) <- [2024-05-10T16:57:52.185060600]

쿼리 로그는 위와 같이 정상적으로 찍히는 것 같지만 0건이 조회된다.

SELECT
    r1_0.id,
    r1_0.client_id,
    r1_0.guide_id,
    r1_0.name,
    r1_0.reservated_at
FROM
    reservation r1_0
WHERE
    r1_0.reservated_at > '2024-05-10T16:57:52.185060600';

해당 쿼리를 직접 SQL 에 동작시키면 7건의 데이터가 조회되는 것을 확인할 수 있었다.

Type 문제?

DB 내에서는 시간이 TimeStamp 이고, Java 에서는 LocalDateTime 으로 비교되기에 문제가 있는 가 했다.

    @Override
    public List<Reservation> findPastReservationsByClientId(Long clientId) {
        return queryFactory
                .selectFrom(reservation)
                .where(reservation.reservatedAt.after(Timestamp.valueOf(LocalDateTime.now())))
                .fetch();
    }

TimeStamp 로 타입을 변경해도 똑같았다.

DB 에서는 2024-05-10 16:57:52 이렇게 표현되지만

실제로는 2024-05-10T16:57:52.185060600 이기 때문일까??

public final DateTimePath<java.time.LocalDateTime> reservatedAt = createDateTime("reservatedAt", java.time.LocalDateTime.class);

해당 타입은 DateTimePath 이고 분명히 before, after 등이 존재하지만 어디에도 사용 예제가 안보였다.

JPA??

  • List<Reservation> findByClientIdAndReservatedAtBefore(Long client_id, LocalDateTime reservatedAt);
  • List<Reservation> findByClientIdAndReservatedAtLessThanEqual(Long client_id, LocalDateTime reservatedAt);

JPA 문법을 사용해서도 시간을 비교할 수 있다. 하지만 조건에 따라 메서드의 이름이 과도하게 길어지고 오타 등을 알아보기 힘들다는 점에서 사용하지 않는다.

어쨋든 JPA 문법을 사용했을 때에도 위처럼 동일한 쿼리 로그가 발생하지만 조회는 정상적으로 이루어지지 않는다.

문제..

도대체 무엇이 문제일까?

  • SQL 직접 입력시
    • (Time) between (String) and (String) 이거나
    • (SQL 에서 자동으로 String 변환) between (Stirng) and (String)
  • QueryDSL
    • (Time or Stirng) between (Time) and (Time)

여기에서 QueryDSL, JPA 를 사용하면 뒷 부분이 Time 타입이라 제대로 비교되지 않는가? 하는 추측을 해본다.

하지만 제대로 된 원인은 파악하지 못했다…

해결책

StringTemplate

    @Override
    public List<Reservation> findPastReservationsByClientId(Long clientId) {
            String dateFormat = LocalDateTime.ofInstant(Instant.now(), ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        StringTemplate formattedDate = Expressions.stringTemplate("datetime({0})", reservation.reservatedAt);
        log.info("formattedDate: {}", formattedDate);

        return queryFactory
                    .selectFrom(reservation)
                    .where(formattedDate.goe(dateFormat))
                    .fetch();
    }

결국 String 으로 타입 변환 및 Format 지정후 현재 시간과 비교했다. 나는 SQLite 를 사용하여 변환을 datetime 을 사용했지만 MySQL 같은 경우는 DATE_FORMAT 을 사용해야한다.

    DateTemplate<String> formattedDate = Expressions.dateTemplate(String.class, "datetime({0})", reservation.reservatedAt, ConstantImpl.create("%Y-%m-%d %H:%i:%s"));

두 번째로 DateTemplate 을 사용할 수 있다.

Template 으로 변경하는 이유는 날짜 연산을 더 용이하게 하기 위해서인 것 같다.

//MySQL

    @Override
    public List<Reservation> findPastReservationsByClientId(Long clientId) {

        String dateFormat = LocalDateTime.ofInstant(Instant.now(), ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
//        DateTemplate<String> formattedDate = Expressions.dateTemplate(String.class, "DATE_FORMAT({0}, {1})", reservation.reservatedAt, ConstantImpl.create("%Y-%m-%d %H:%i:%s"));
        StringTemplate formattedDate = Expressions.stringTemplate("DATE_FORMAT({0}, {1})", reservation.reservatedAt, ConstantImpl.create("%Y-%m-%d %H:%i:%s"));
        log.info("formattedDate: {}", formattedDate);

        return queryFactory
                    .selectFrom(reservation)
                    .where(formattedDate.goe(dateFormat))
                    .fetch();
    }

MySQL 같은 경우는 위와 같다.

하지만 이처럼 DB에 따라서 메서드가 변경되는 점이 매우 불편해보인다.

Expression

DateTimePath 의 after 메서드에는 2 가지가 존재한다.

  • after(LocalDateTime right)
  • after(Expression right)

첫 번쨰는 이미 동작안하는 것을 알기에 두 번째를 활용해보자.

https://github.com/querydsl/querydsl/issues/2389

위 이슈를 겨우 찾아 참고할 수 있었다.

    @Override
    public List<Reservation> findPastReservationsByClientId(Long clientId) {

        return queryFactory
                .selectFrom(reservation)
                .where(reservation.reservatedAt.before(
                        Expressions.dateTimeOperation(
                                LocalDateTime.class, Ops.DateTimeOps.CURRENT_TIMESTAMP
                        )
                ))
                .fetch();
    }

after 내 파라미터를 Expression 으로 작성한다. CURRENT_TIMESTAMP 를 LocalDateTime 클래스로 받아 Expression 을 생성할 수 있었다.

그 결과

image

진짜 겨우 이전 날짜 데이터들을 조회할 수 있었다. NOW 가 포함된 이유는 DB 에 현재 시간으로 데이터 생성 후 다시 현재 시간을 측정해 조회하기에 조회 시점 기준 과거 데이터이기 때문이다!!

리팩토링

Repository

꼭 무언가를 상속/구현 받지 않고, 특정 Entity 를 지정하지 않더라도 QueryDSL 을 사용할 수 있는 방법은..?

JPAQUeryFactory 만 있으면 QueryDSL 을 사용할 수 있다.!!!

@Repository
@RequiredArgsConstructor
public class ReservationQueryRepository {
    private final JPAQueryFactory queryFactory;

    public List<Reservation> findPastReservations() {
        return queryFactory.selectFrom(reservation)
                .where(reservation.reservatedAt.before(
                        Expressions.dateTimeOperation(LocalDateTime.class,
                                Ops.DateTimeOps.CURRENT_TIMESTAMP)
                ))
                .fetch();
    }
}

굳이 상속과 구현하지 않더라도 바로 레포지터리를 생성해서 이용할 수 있다는 뜻이다.

FetchType.Lazy

2024-05-10T23:03:07.830+09:00  INFO 20972 --- [    Test worker] c.m.demo.service.ReservationService      : pastReservationsByClientId
Hibernate: 
    select
        r1_0.id,
        r1_0.client_id,
        r1_0.guide_id,
        r1_0.name,
        r1_0.reservated_at 
    from
        reservation r1_0 
    where
        r1_0.reservated_at>datetime('now')
Hibernate: 
    select
        u1_0.id,
        u1_0.email,
        u1_0.name 
    from
        user u1_0 
    where
        u1_0.id=?
2024-05-10T23:03:08.175+09:00 TRACE 20972 --- [    Test worker] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [1]
Hibernate: 
    select
        u1_0.id,
        u1_0.email,
        u1_0.name 
    from
        user u1_0 
    where
        u1_0.id=?
2024-05-10T23:03:08.182+09:00 TRACE 20972 --- [    Test worker] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [2]

두 번째로 Reservation 을 조회하고 난 이후, User 가 매핑되어 있어 총 3 번의 조회가 발생하게 된다. 이는 User 가 늘어날 수록 더 많은 조회가 이루어질 것이다.

@Entity
@Table
@Data
public class Reservation {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "client_id")
    private User client;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "guide_id")
    private User guide;

    private String name;

    private LocalDateTime reservatedAt;
}

이를 해결하기 위해 @ManyToOne(fetch = FetchType.LAZY) 를 사용한다. 이를 통해 필요하지 않은 정보들은 매핑되어있어도 조회하지 않는다.

2024-05-10T23:06:41.471+09:00  INFO 1916 --- [    Test worker] c.m.demo.service.ReservationService      : pastReservationsByClientId
Hibernate: 
    select
        r1_0.id,
        r1_0.client_id,
        r1_0.guide_id,
        r1_0.name,
        r1_0.reservated_at 
    from
        reservation r1_0 
    where
        r1_0.reservated_at>datetime('now')
2024-05-10T23:06:41.828+09:00  INFO 1916 --- [    Test worker] c.m.demo.service.ReservationService      : pastReservationsByClientId.size(): 7

추가

https://github.com/metatron-app/metatron-discovery/blob/master/discovery-server/src/main/java/app/metatron/discovery/domain/datasource/DataSourcePredicate.java#L28

TimeStamp 를 그대로 비교하는 건 위 예제 말고 없는 것 같은데 joda 라는 DateTime 을 사용하고 있다. Joda 의존성을 받아 Joda.DateTime 으로 타입을 변경해도 여전히 문제가 발생했다.

  • 결론
    • after(Expression right)
    • 문자열 Template 으로 변경 후 비교

코드

Github Code

참고

[우아콘2020] 수십억건에서 QUERYDSL 사용하기

실무 활용 - 스프링 데이터 JPA와 Querydsl (+동적쿼리, +페이징)

업데이트:

댓글남기기