[Spring] JPA Specification과 한계
결론
현재 글이 작성된 시점 ( 2024.09 ) 에서는 hibernate가 fetch graph 기반 field projection을 지원하지 않는다. JPA Specification은 fetch graph 기반으로 projection을 처리하므로 JPA 구현체로 hibernate을 이용하고 projection이 필요하다면 QueryDSL을 이용하는 것이 더 적합할 수 있다.
연관 관계를 fetch하는 작업은 Specification 내부가 아니라, findBy 메서드에서 project 힌트를 줘서 처리할 수 있다.
Page<EventUser> userPage = eventUserRepository.findBy(
searchSpec,
(q) -> q.project("eventFrame").page(pageRequest)
);
JPA Specification
Spring에서 DB 데이터를 조회하는 기능을 구현할 때, 동적으로 조회 조건이 달라지는 요구사항이 존재할 수 있다. 검색이 대표적인 예시인데, search 파라미터가 있으면 검색 - 없으면 모든 결과를 반환하는 경우가 있겠다.
이때, Spring JPA는 메서드 이름에 대응되는 동작을 수행하므로, 쿼리가 고정되어 있어 동적 쿼리에 대한 요구사항을 처리하는 것이 쉽지 않다. 대안으로 Jdbc, JdbcTemplate, Criteria API ( 객체 기반 쿼리 ) 등을 선택할 수도 있겠지만, JPA와는 달리 코드가 장황해지는 경향이 있다. 그렇다면 개발자 입장에서 좀 더 가볍게 동적 쿼리를 다룰 수는 없을까?
JPA Specification은 이러한 간단한 동적 쿼리 요구사항을 만족하는 기술 중 하나로, Criteria API의 반복되는 보일러 플레이트를 숨기고 where 절을 간단하게 추상화한 방법이다.
아래 코드는 동일한 로직을 Criteria API와 JPA Specification으로 구현한 것이다.
// Criteria API
public class EmployeeRepositoryImpl {
@PersistenceContext
private EntityManager entityManager;
public List<Employee> findByCriteria(String name, Integer age) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Employee> query = cb.createQuery(Employee.class);
Root<Employee> employee = query.from(Employee.class);
Predicate criteria = cb.conjunction();
if (name != null && !name.isEmpty()) {
criteria = cb.and(criteria, cb.equal(employee.get("name"), name));
}
if (age != null) {
criteria = cb.and(criteria, cb.equal(employee.get("age"), age));
}
query.where(criteria);
return entityManager.createQuery(query).getResultList();
}
}
Criteria API를 사용하기 위해서는 다음 3가지 요소를 정의해야 한다.
- CriteriaBuilder: Criteria Query를 작성하기 위한 빌더
- CriteriaQuery<T>: 쿼리 구조를 정의하는 객체. T는 반환값에 해당한다.
- Root<T>: 조회의 기준이 되는 엔티티를 설정하는 부분. FROM절에 해당한다.
위 코드를 보면 입력에 따라 변경되는 부분은 where 절에 해당하는 if문 정도 뿐이다. 그렇다면, 변경되는 부분만을 추상화한다면 복잡한 코드를 숨길 수 있지 않을까? JPA Specification 기반으로 작성된 코드를 보자.
public class EmployeeSpecification {
public static Specification<Employee> hasName(String name) {
return (Root<Employee> root, CriteriaQuery<?> query, CriteriaBuilder cb) -> {
if (name == null || name.isEmpty()) {
return cb.conjunction();
}
return cb.equal(root.get("name"), name);
};
}
public static Specification<Employee> hasAge(Integer age) {
return (Root<Employee> root, CriteriaQuery<?> query, CriteriaBuilder cb) -> {
if (age == null) {
return cb.conjunction();
}
return cb.equal(root.get("age"), age);
};
}
}
public interface EmployeeRepository extends JpaRepository<Employee, Long>,
JpaSpecificationExecutor<Employee> {
default List<Employee> findBySpecifications(String name, Integer age) {
return findAll(Specification.where(EmployeeSpecification.hasName(name))
.and(EmployeeSpecification.hasAge(age)));
}
}
where 절은 Specification<T> 타입으로 추상화되었고, CriteriaBuilder, CriteriaQuery, Root 등 Criteria에서 직접 처리해야 했던 로직은 findAll 내부로 숨겨졌다. 이를 통해 사용자는 where 절에 해당하는 내용만 구현하더라도 동적 쿼리를 작성할 수 있게 되었다!
그렇다면, JPA Specification이 최고일까? 아쉽게도 아니다. JPA Specification에는 한계점이 존재한다.
Specification은 Where절의 추상이다
- https://github.com/spring-projects/spring-data-jpa/issues/2253
- https://github.com/spring-projects/spring-data-jpa/pull/2376
프로젝트를 진행하던 중 projection이 안되는 문제, 페이징 시 에러가 발생하는 문제가 있어 Spring Data JPA 공식 github 페이지를 살펴보던 도중, JPA 프로젝트 관리자 중 한명이 일관되게 "Specification은 Where 조건의 추상이다"라고 설명하는 모습을 볼 수 있었다.
2021년에 작성된 댓글이긴 하지만 Specification 자체는 여전히 Where 절의 추상화인 상태를 유지하고 있으며, group, projection 등 단순한 조건 처리 이상의 작업을 처리하는 것은 Criteria API를 직접 사용하는 것이 나을 정도로 불편하거나 지원되지 않는 상태다.
장기적으로 JPA Specification이 현재 지원하지 않는 다양한 기능을 추가하지 않는 이상, 우리가 기대하는 동적 쿼리에 비해 아쉬운 부분이 많을 것이라 생각한다. 하지만, Criteria API와 함께 사용할 생각이 있다면 동적 쿼리를 작성할 때 JPA 이외의 외부 라이브러리에 전혀 의존하지 않는다는 측면에서 좋은 선택이 될 수 있다고 생각한다.
트러블 슈팅
본격적으로 내가 JPA Specification에서 한계를 느낀 부분에 대해 설명해보고자 한다. 프로젝트는 다음과 같다.
https://github.com/softeerbootcamp4th/Team6-AwesomeOrange-BE
GitHub - softeerbootcamp4th/Team6-AwesomeOrange-BE: 현대자동차그룹 소프티어 부트캠프 6조 어썸오렌지 팀 프
현대자동차그룹 소프티어 부트캠프 6조 어썸오렌지 팀 프로젝트 . Contribute to softeerbootcamp4th/Team6-AwesomeOrange-BE development by creating an account on GitHub.
github.com
당시 어드민 페이지는 꽤 복잡한 검색 조건을 요구했다. 대략 정리해보면 아래와 같다.
- search: 검색어를 입력하면 이벤트 id 또는 제목에 대해 검색을 진행한다. 검색어가 없으면 모두 반환한다.
- sort: 이벤트 id, 이름 등 페이지 내에서 시각화되는 모든 요소에 대한 정렬이 가능해야 한다.
- type: 이벤트 타입에 맞는 행을 필터링한다. 지정되지 않았다면 모두 반환한다.
- 반환된 데이터는 페이징 정보를 포함해야 한다.
위 상황 이외에도 프로젝트에는 복잡한 조건을 동적으로 다뤄야 하는 부분이 많았기에, 나는 동적 쿼리가 필요하다고 판단했다. 정보를 찾으면서 QueryDSL과 JPA Specification이라는 대안을 발견했다.
이후 두 대안 중 JPA Specification을 선택했다. 당시 나는 명확한 이득 없이 추가적인 라이브러리를 도입하는 행위를 지양하고자 했다. 불필요한 라이브러리는 빌드 사이즈, 부팅 시간 등 시스템 성능에 나쁜 영향을 줄 수 있으며, 오히려 코드에 대한 이해도를 낮추거나 시스템의 복잡성만 높일 수 있기 때문이다. 이러한 측면에서 JPA Specification은 QueryDSL과는 달리 Spring Data JPA에 기본적으로 포함된 기능이므로, 추가적인 라이브러리에 대한 의존 없이 동적 쿼리를 처리할 수 있다는 점이 매력적이라 느껴 채택하게 되었다.
그런데, 실제 개발 단계로 들어갔을 때 생각보다 많은 문제를 발견했다.
field Projection이 SQL 수준에서 지원되지 않음
Page<BriefEventDto> eventPage = emRepository.findBy(
searchOnName.or(searchOnEventId)
.and(eventTypeIn),
(p) -> p.as(BriefEventDto.class)
.sortBy(sort)
.page(pageInfo)
);
JPA Specification의 findBy 메서드는 두번째 인자를 통해 조회 결과에 대해 project 또는 as을 통해 특정 열의 정보만 projection 할 수 있다. 그러나 실제 쿼리는 SQL 수준에서 엔티티의 모든 정보를 가져오고 있었다.
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 |
이에 대한 정보를 찾다가, 공식 문서 issue에 언급된 내용을 볼 수 있었다.
https://github.com/spring-projects/spring-data-jpa/issues/2499
언급된 내용에 따르면 projection을 jakarta.persistence.fetchgraph 쿼리 힌트 기반으로 지원하는데, field projection은 JPA provider의 재량이라는 것이다. 실제로 JPA 문서에는 fetchgraph 기반으로 projection하는 방법이 소개되어 있다.
이게 어떤 의미인가 싶어 코드에 중단점을 걸어 어떻게 동작하는지 확인해봤다. 결론만 말하자면, project( ) 메서드에 등록된 데이터들은 실제로 fetchgraph의 힌트로 전달되고 있었다.
JPA Specification API를 이용하기 위해서는 JPASpecificationExecutor을 레포지토리 인터페이스에서 상속해야 한다.
@Repository
public interface EventMetadataRepository extends JpaRepository<EventMetadata, Long>,
JpaSpecificationExecutor<EventMetadata> {
// 쿼리 메서드 생략
}
// 쿼리를 실행하는 코드
한편, JpaSpecificationExecutor에 정의된 메서드들은 SimpleJpaRepository에 구현되어 있다.
@Override
public List<T> findAll(Specification<T> spec) {
return getQuery(spec, Sort.unsorted()).getResultList();
}
@Override
public Page<T> findAll(@Nullable Specification<T> spec, Pageable pageable) {
TypedQuery<T> query = getQuery(spec, pageable);
return pageable.isUnpaged() ? new PageImpl<>(query.getResultList())
: readPage(query, getDomainClass(), pageable, spec);
}
@Override
public <S extends T, R> R findBy(Specification<T> spec, Function<FetchableFluentQuery<S>, R> queryFunction) {
Assert.notNull(spec, "Specification must not be null");
Assert.notNull(queryFunction, "Query function must not be null");
return doFindBy(spec, getDomainClass(), queryFunction);
}
코드를 거슬러 올라가면 findBy에서 실행하는 doFindBy는 메서드는 최종적으로 FetchableFluentQueryBySpecification 타입의 fluentQuery 쿼리를 실행해 우리가 원하는 데이터를 가져온다.
FetchableFluentQueryBySpecification<?, T> fluentQuery = new FetchableFluentQueryBySpecification<>(spec, domainClass,
finder, scrollDelegate, this::count, this::exists, this.entityManager, getProjectionFactory());
return queryFunction.apply((FetchableFluentQuery<S>) fluentQuery);
여기서, 나는 findBy의 2번째 인자로 다음 쿼리를 전달했었다.
(p) -> p.as(BriefEventDto.class)
.sortBy(sort)
.page(pageInfo)
해당 쿼리 중 page의 코드를 살펴보자. ( 링크 )
@Override
public Page<R> page(Pageable pageable) {
return pageable.isUnpaged() ? new PageImpl<>(all()) : readPage(pageable);
}
page 메서드는 내부적으로 all( ) 또는 readPage 메서드를 호출한다.
@Override
public List<R> all() {
return convert(createSortedAndProjectedQuery().getResultList());
}
private Page<R> readPage(Pageable pageable) {
TypedQuery<S> pagedQuery = createSortedAndProjectedQuery();
if (pageable.isPaged()) {
pagedQuery.setFirstResult(PageableUtils.getOffsetAsInteger(pageable));
pagedQuery.setMaxResults(pageable.getPageSize());
}
List<R> paginatedResults = convert(pagedQuery.getResultList());
return PageableExecutionUtils.getPage(paginatedResults, pageable, () -> countOperation.apply(spec));
}
두 메서드는 공통적으로 내부에서 createSortedAndProjectedQuery( ) 메서드와 convert( ) 를 호출하는데, 호출된 메서드들의 역할은 다음과 같다.
- createSortedAndProjectedQuery(): 쿼리에 sort, limit, projection을 적용한다. projection은 hint를 지정하는 방식으로 동작하며, HINT = jakarta.persistence.fetchgraph다.
- convert(): fetch 결과로 가져온 데이터를 as로 전달된 인터페이스에 매칭하여 담는다.
private TypedQuery<S> createSortedAndProjectedQuery() {
TypedQuery<S> query = finder.apply(sort);
if (!properties.isEmpty()) {
query.setHint(EntityGraphFactory.HINT, EntityGraphFactory.create(entityManager, entityType, properties));
}
if (limit != 0) {
query.setMaxResults(limit);
}
return query;
}
private List<R> convert(List<S> resultList) {
Function<Object, R> conversionFunction = getConversionFunction();
List<R> mapped = new ArrayList<>(resultList.size());
for (S s : resultList) {
mapped.add(conversionFunction.apply(s));
}
return mapped;
}
내부 코드를 살펴본 결과 projection은 쿼리에 project메서드로 전달한 필드를 fetchgraph 힌트로 전달하는 방식으로 동작하고 있었으며, as에 전달된 인터페이스는 projection 된 결과물을 담는 dto에 불과했다.
이 사실을 알게 된 이후 project에 힌트를 주지 않아 field projection이 동작하지 않았을 가능성이 있다고 생각해 project에 필드 명을 입력했지만, 여전히 쿼리는 모든 필드를 가져왔다. JPA Specification의 한계인가 싶어 동일한 기능을 Criteria API로 구현해보기도 했다.
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 |
여전히 동작하지 않았다. 이를 통해 fetchgraph 힌트를 통해 특정 필드만을 가져오는 작업 자체가 동작하지 않는다는 것을 알게 되었다.
정보를 찾아본 결과, 실제로 Hibernate는 항상 엔티티 객체의 (연관관계 제외) 모든 필드를 가져온다고 한다. 댓글 내용에 따르면 CriteriaBuilder.projection으로 처리할 수 있지만, 이는 이미 JPA Specification이 아니라 Criteria API의 영역이므로 JPA Specification만으로는 projection을 지원하기 어렵다는 결론을 얻었다.
Paging 중 겪은 예외
요구사항에 따라 event를 연관 관계인 eventFrame와 함께 가져와야 했다. 이때 event와 eventFrame은 FetchType.LAZY를 적용하고 있으므로 event를 가져올 때 N+1 문제가 발생하고 있었다.
최초에는 Specification 내부에서 fetch를 통해 eventFrame 정보를 가져왔다.
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();
};
}
}
이 코드는 간헐적으로 예외를 발생시키는 문제가 있었다.
이를 해결하기 위해 정보를 찾다 보니, JPA Specification을 Paging에 사용할 때 동일한 Specification이 count query에도 사용되는 바람에 발생하는 문제임을 알게 되었고, result type이 long이 아닌 경우에만 eventFrame 정보를 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();
};
}
}
인터넷 상에는 여기까지의 정보만 존재했다. 하지만 앞서 언급한 조사 내용을 통해 findBy 메서드의 project에 전달된 필드는 힌트로 전달되어 함께 가져올 수 있음을 알 수 있었다. 즉, eventFrame과 같은 연관 관계는 Specification 자체에서 처리하는게 아니라, projection에서 hint로 전달하여 fetch 할 수 있다.
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 |
결과를 보면 event_frame 정보까지 한번의 쿼리로 가져오고 있으며, count에 대해 별다른 설정을 하지 않더라도 정상적으로 처리되고 있음을 알 수 있다. 따라서 나의 글을 찾아온 사람들이라면 Specification 자체에서 fetch하지 말고 project에 힌트로 전달해서 연관 관계를 가져오기를 바란다. 이 방식은 Specification에서 페이징이나 연관 관계를 고려하지 않아도 되기 때문에 유지보수에 더 효율적이다.
참고
@Override
public Page<R> page(Pageable pageable) {
return pageable.isUnpaged() ? new PageImpl<>(all()) : readPage(pageable);
}
private Page<R> readPage(Pageable pageable) {
TypedQuery<S> pagedQuery = this.createSortedAndProjectedQuery();
if (pageable.isPaged()) {
pagedQuery.setFirstResult(PageableUtils.getOffsetAsInteger(pageable));
pagedQuery.setMaxResults(pageable.getPageSize());
}
List<R> paginatedResults = this.convert(pagedQuery.getResultList());
return PageableExecutionUtils.getPage(paginatedResults, pageable, () -> {
return (Long)this.countOperation.apply(this.spec);
});
}
page 관련 문제는 FluentQuery의 page 로직에서 getPage() 을 호출할 때 count query로 인해 에러가 발생하는 것이다. Long 타입 반환값이 나와야 하는데 fetch로 인해 eventFrame이라는 연관 관계 정보가 count query에 포함되어 문제가 발생한 것으로 보인다.
QueryDSL로의 전환
Specification이 가지고 있는 한계로 인해, 프로젝트 막바지에 기존 코드를 QueryDSL로 전환했다. 결과적으로는 필요 없는 과정을 거친 것이라고 생각할 수도 있지만, 기술을 선택할 때도 팀 내에서 강조했던 reasonable - 합리적 이유를 기반으로 현재 시점에서 가장 적합한 기술을 선택했던 것이므로 오히려 좋은 경험이었다고 생각한다. 단순히 QueryDSL을 이용하기보다는, 대안인 Specification이 가지고 있는 단점이 무엇인지 한번 쯤은 고민해보는 것이 어떨까.
@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());
}
JPA Specification은 별도 라이브러리 없이 Spring Data JPA만으로 간단한 동적 쿼리를 작성할 수 있다는 점에 장점이 있다고 생각한다. 하지만, Where 절의 추상에서 시작한 기술 특성상 완벽한 동적 쿼리에 사용하기는 어려움이 있다. Criteria API도 그렇게 복잡하지 않기 때문에 외부 라이브러리를 사용하고 싶지 않다면 Specification + Criteria API를, 아니면 QueryDSL을 사용하는 것이 좋다고 생각한다.