Skip to content

JPA Specification의 문제점과 QueryDSL의 도입

이희준 edited this page Aug 28, 2024 · 2 revisions

JPA Specification의 단점과 QueryDSL의 도입

#️⃣ 연관 이슈 #115

초기에는 JPA Specification을 이용하여 문제를 해결하고자 했습니다. JPA Specification을 선택한 이유는 다음과 같습니다.

  1. 어드민 페이지에서 요구하는 검색 기능을 구현하기 위해서 파라미터에 따라 동적인 조건을 설정해줘야 하는데, 이는 기존 JPA의 정적 쿼리로 처리가 불가능합니다.
  2. JPQL 또는 Criteria API를 직접 사용하더라도 구현할 수는 있겠지만, 코드가 지나치게 장황해지는 문제가 있어 좀 더 단순한 쿼리 처리 방식이 필요했습니다.
  3. 특별한 목적 또는 명확한 이득 없이 추가적인 라이브러리를 도입하는 행위를 지양하고자 했습니다. JPA Specification은 Spring Data JPA에 기본적으로 포함되어 있어, 추가적인 라이브러리에 의존할 필요가 없으며, 동적 쿼리를 처리할 수 있다는 점이 매력적이라 생각했습니다.

당시 작성한 코드는 다음과 같습니다.

public class EventUserSpecification {
    public static Specification<EventUser> search(String search, String field) {
        return (user, query, cb) -> {
//            user.fetch("eventFrame");

            Join<EventUser, EventFrame> join = user.join("eventFrame", JoinType.LEFT);

            if("userName".equals(field)) return cb.like(user.get("userName"), "%" + search + "%");
            else if ("phoneNumber".equals(field)) return cb.like(user.get("phoneNumber"), "%" + search + "%");
            else if ("frameId".equals(field)) return cb.like(join.get("frameId"), "%" + search + "%");
            return cb.conjunction();
        };
    }
}

위 코드는 정상적으로 동작하지만 join이라는 이름과는 달리 EventFrame을 join 조건으로 이용할 뿐, 연관관계를 fetch하지 않기 때문에 EventFrame을 나중에 가져와 N + 1 문제가 발생합니다. 내부적으로 join은 where절 조건에 사용될 뿐 실제 객체를 가져오지 않습니다.

image

이를 회피하기 위해서는 user.fetch("eventFrame")으로 연관 데이터를 실제로 fetch하도록 구성하면 됩니다. 코드는 아래와 같습니다.

public class EventUserSpecification {
    public static Specification<EventUser> search(String search, String field) {
        return (user, query, cb) -> {
            user.fetch("eventFrame");

            if("userName".equals(field)) return cb.like(user.get("userName"), "%" + search + "%");
            else if ("phoneNumber".equals(field)) return cb.like(user.get("phoneNumber"), "%" + search + "%");
            else if ("frameId".equals(field)) return cb.like(user.get("eventFrame").get("frameId"), "%" + search + "%");
            return cb.conjunction();
        };
    }
}

하지만, 이 방식은 현재 프로젝트에서 간헐적으로 에러가 발생하는 문제가 있었습니다. 동일한 코드가 특정 데이터를 가져올 때는 정상적으로 동작하지만, 다른 경우에는 예외를 발생시켰습니다.

예외 발생 0 페이지 정상 동작 1 페이지

org.hibernate.query.SemanticException: Query specified join fetching, but the owner of the fetched association was not present in the select list

위 두 사진은 동일 API에 대해 0번 페이지, 1번 페이지에 대한 API를 요청한 결과입니다. 단지 페이지만 다를 뿐인데, 어떤 경우에는 정상적인 결과를 반환하고, 또 어떤 경우에는 비정상적인 결과를 반환했으며, 이외에도 특정 검색어에 대해서는 조회가 되지만, 다른 검색어에 대해서는 예외가 발생하는 경우가 있었습니다.

현재 문제가 발생한 상황을 다시 정리해보겠습니다.

  1. JPA Specification을 이용하여 검색 로직을 구현한다.
  2. 검색 로직을 통해 Page 객체를 얻는다.

https://hepokon365.hatenablog.com/entry/2021/12/31/160502

조사한 바에 따르면 위 에러는 Page 객체를 만들기 위해 Count() 쿼리를 생성할 때 발생합니다. JPA에서 Page 객체를 만들 때는 1. 원래 객체를 조회하고, 2. 원소의 개수를 알기 위해 Count()를 호출합니다. JPA Specification를 Page 객체를 만들 때 사용하면 동일한 Specification을 (1) 원 객체 조회, (2) count()에 사용하는데, count() 동작을 위해 Specification을 동작할 때 ( result type = long ) fetch로 인해 추가적인 데이터가 응답에 포함되므로 예외가 발생합니다.

따라서, 현재 문제는 반환 타입이 long이 아닐 때만 fetch하도록 만들면 됩니다.

public class EventUserSpecification {
    public static Specification<EventUser> search(String search, String field) {
        return (user, query, cb) -> {
            if(Long.class != query.getResultType()) user.fetch("eventFrame", JoinType.LEFT);

            if("userName".equals(field)) return cb.like(user.get("userName"), "%" + search + "%");
            else if ("phoneNumber".equals(field)) return cb.like(user.get("phoneNumber"), "%" + search + "%");
            else if ("frameId".equals(field)) return cb.like(user.get("eventFrame").get("frameId"), "%" + search + "%");
            return cb.conjunction();
        };
    }
}

정상 동작

JPA Specification만으로 기능 구현이 가능하지만, 다음과 같은 이유로 인해 Spring Specification 대신 QueryDSL을 이용하여 문제를 해결하기로 했습니다.

  1. Page 사용을 고려하여 조건 처리를 할 필요가 있다는 사실을 알고 있어야 한다는 점이 불편하게 느껴졌습니다.
  2. 여전히 Projection은 지원되지 않습니다. 내부적으로 fetch graph를 이용하여 projection hint를 주도록 구현되어 있지만, 조사한 바에 따르면 현재 가장 많이 사용하는 JPA 구현체인 hibernate는 fetch graph를 통해 특정 필드만 가져오는 기능을 지원하지 않아 projection이 현재 시점에서는 불가능합니다.
  3. JPA Specification은 객체가 조건을 만족하는지 확인하는 DDD의 Specification 및 Where 절에 대한 추상화에서 시작된 기술로, 본질적으로 객체를 fetch하거나 projection하는 등 객체 자체를 조작(manipulate)하는 행위는 고려되지 않습니다. 따라서 Specification 내부에서 연관 관계를 fetch하는 것은 어색합니다.

실제로 field projection이 안되는지 실험해봤습니다. (참고 문서: 링크에 따르면, 특정 필드만을 힌트로 가져오는 기능은 JPA 표준에 존재)

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<EventMetadata> cq = cb.createQuery(EventMetadata.class);
Root<EventMetadata> root = cq.from(EventMetadata.class);
cq.select(root);

EntityGraph<EventMetadata> entityGraph = em.createEntityGraph(EventMetadata.class);
entityGraph.addAttributeNodes("id", "eventId", "startTime", "endTime", "eventFrame");
var result = em.createQuery(cq).setHint("jakarta.persistence.fetchgraph", entityGraph).getResultList();
Hibernate: 
    select
        em1_0.id,
        em1_0.description,
        em1_0.end_time,
        ef1_0.id,
        ef1_0.frame_id,
        ef1_0.name,
        em1_0.event_frame_id,
        em1_0.event_id,
        em1_0.event_type,
        em1_0.name,
        em1_0.start_time,
        em1_0.status,
        em1_0.url 
    from
        event_metadata em1_0 
    left join
        event_frame ef1_0 
            on ef1_0.id=em1_0.event_frame_id

FetchType = Lazy인 eventFrame에 대한 힌트는 동작하지만, 자체 필드 중 일부만 가져오는 기능은 동작하지 않습니다. JPA 공식 문서에 따르면 fetch graph hint에 표시된 필드만 가져와야 하지만 hibernate이 특정 필드만 가져오는 힌트를 지원하지 않기 때문에 field projection은 현재 시점에서 근본적으로 불가능합니다.

위와 같은 이유로 JPA Specification에서는 field projection이 불가능합니다. 조사하면서 알게 된 사실이지만, fetch를 Specification 내부에 포함할 필요는 없습니다. 위에서 언급한 page 관련 문제는 연관 엔티티를 hint(projection)로 가져오는 방식으로도 해결이 가능합니다.

public static Specification<EventUser> search(String search, String field) {
    return (user, query, cb) -> {
        if("userName".equals(field)) return cb.like(user.get("userName"), "%" + search + "%");

        if ("phoneNumber".equals(field)) return cb.like(user.get("phoneNumber"), "%" + search + "%");
        else if ("frameId".equals(field)) return cb.like(user.join("eventFrame").get("frameId"), "%" + search + "%");
        return cb.conjunction();
    };
}

Page<EventUser> userPage = eventUserRepository.findBy(
        searchSpec,
        (q) -> q.project("eventFrame").page(pageRequest)
);
Hibernate: 
    select
        eu1_0.id,
        ef1_0.id,
        ef1_0.frame_id,
        ef1_0.name,
        eu1_0.event_frame_id,
        eu1_0.phone_number,
        eu1_0.score,
        eu1_0.user_id,
        eu1_0.user_name 
    from
        event_user eu1_0 
    left join
        event_frame ef1_0 
            on ef1_0.id=eu1_0.event_frame_id 
    where
        1=1 
    limit
        ?, ?
Hibernate: 
    select
        count(eu1_0.id) 
    from
        event_user eu1_0 
    where
        1=1

따라서, 현재 JPA Specification이 가진 유일한 단점은 field projection이 지원되지 않는다는 점이며, 이는 JPA 자체의 문제라기보다는 Hibernate가 field projection을 지원하지 않기 때문에 발생하는 일입니다.


아래 코드는 기존 코드를 QueryDSL로 전환한 것입니다.

    @Override
    public Page<EventUser> findBySearch(String search, String field, Pageable pageable) {
        QEventUser user = QEventUser.eventUser;
        QEventFrame eventFrame = QEventFrame.eventFrame;

        var query =  queryFactory.select(user)
                .from(user)
                .leftJoin(user.eventFrame, eventFrame)
                .fetchJoin();

        if("userName".equals(field)) query.where(user.userName.contains(search));
        else if("phoneNumber".equals(field))query.where(user.phoneNumber.contains(search));
        else if("frameId".equals(field)) query.where(user.eventFrame.frameId.contains(search));

        var data = query.offset(pageable.getOffset()).limit(pageable.getPageSize()).fetch();
        return new PageImpl<>(data, pageable, data.size());
    }

정상 동작 0 페이지 쿼리

JPA Specification은 간단한 조건에 대해서는 충분히 좋은 선택지입니다. 하지만 데이터의 fetch, projection 등 객체 검증 이외의 목적이 포함되는 경우 좋은 선택지가 아닐 수 있습니다. 저희 팀은 데이터를 fetch 해야 하는 상황에 대해 JPA Specification이 적합하지 않다고 생각하여 QueryDSL을 추가적으로 도입했고, 장기적으로는 기존에 projection이 필요한 부분을 QueryDSL로 대체하고자 합니다.