스프링 데이터 JPA에서 Specification으로 동적 WHERE절 구현하기

책 조회를 위해서는 동적 WHERE절 기능을 구현해야 할 것 같은데...

개인적으로 진행했던 도서 장부 프로젝트에서 외부에서 입력 받은 조회 조건들의 존재 여부에 따라 동적으로 WHERE절을 생성할 필요가 있었다.

-- 이해를 돕기위해 간략화 된 책 조회 쿼리입니다

SELECT *
  FROM BOOK
 WHERE TITLE          = '제목'
   AND AUTHOR_NAME    = '저자명'
   AND PUBLISHER_NAME = '출판사명'

위의 조회 쿼리를 보면 책 조회 기능은 제목/저자명/출판사명 총 세 가지 조건을 통해 조회하는 것을 볼 수 있다. 세 가지 조건은 모두 존재할 수도 있고 null일 수도 있다. 당연하게도 조건이 null이면 WHERE절에서 빠져야만 정상적으로 조회가 가능할 것이다.

동적 WHERE절을 구현하는 방법은 다음과 같이 크게 세가지가 있다.

  1. 조건문을 이용해 조건에 따라 문자열을 합쳐 JPQL/SQL을 생성하기
  2. Criteria 쿼리 빌더 클래스를 이용해 자동으로 JPQL을 생성하기
  3. QueryDSL 쿼리 빌더 클래스를 이용해 자동으로 JPQL을 생성하기

우선 JPQL에 대한 구체적인 설명은 다음 글에서 설명하기로 하겠다. 현재는 JPA에서 사용하는 SQL과 비슷한 언어라고 만 생각하면 될 것 같다.

단 개인적인 생각으로 1번은 별로 현명한 방법은 아닌 것 같다. 기껏 쿼리 중심적인 개발로부터 벗어나기 위해 JPA를 선택했는데 동적 WHERE절을 구현하기 위해 또다시 쿼리를 작성해야 한다면 그야말로 본말전도다. 더군다나 1번 방식은 실제 코드가 실행되기 전까지 오류가 있는지 알 수 있는 방법이 없다. 즉, 컴파일 단계에서 오류를 잡아낼 수가 없다. 따라서 나는 2번 아니면 3번 방식을 이용해 동적 WHERE절을 구현하기로 했다.

Criteria나 QueryDSL 쿼리 빌더 클래스는 JPQL을 자동으로 생성해준다는 공통점이 있으며 각각 다음과 같은 장단점이 존재한다.

Criteria QueryDSL
장점 JPA에서 지원하는 표준 방식이다. JPQL 생성을 위한 코드를 훨씬 직관적인 방식으로 작성이 가능하다.
단점 조건이 조금만 복잡해져도 코드가 장황해질 가능성이 높다. 비표준 오픈소스 프로젝트다.

둘 중에서 나는 Criteria를 선택하기로 했다. 여러가지 이유가 있긴 했지만, 일단 Criteria가 표준이라는 이유가 가장 컸다. 물론, 현업에서는 QueryDSL을 많이 사용한다고 하니 QueryDSL도 공부해볼 예정이다. 그런데 이쯤 되면 의문이 드는 사람도 있을 것이다.

아니, 글 제목은 S
아니 글 제목은 Specification으로 동적 WHERE절 구현하기인데, 뜬금없이 왜 Criteria가 나와요?

뭐, 너무 놀라지 말자. 사실 스프링 데이터 JPA는 원래 스프링 프레임워크의 하위 프로젝트인, 스프링 데이터 그룹에 포함되는 모듈 중 하나다. 스프링 데이터 JPA 모듈이 개발된 이유는 스프링 프레임워크에서 JPA를 쉽게 사용할 수 있도록 하기 위해서다. 스프링 데이터 JPA는 JPA의 핵심요소중 하나인 EntityManager를 개발자가 직접 호출하지 않아도 스프링 데이터 JPA Repository 내부에서 자동으로 처리해준다. 이 때문에 EntityManager를 직접 호출할 필요가 있는 Criteria를 사용하는 방법은 스프링 데이터 JPA에서 사용되지 않는다. 그 대신 내부에서 Criteria 관련 API를 호출해서 사용하는 Specification 이라는 새로운 인터페이스가 생기게 된 것이다.

Specification 인터페이스 내부를 보면, Criteria API를 호출해 구현했다는 것을 알 수 있다.

즉, Sepcification을 사용한다는 것은 Criteria를 사용한다는 것과 동일한 말이다.


진짜로 Specification을 써보자

Specification을 사용하고자 한다면 당연히 Entity하고 Repository가 있어야 한다. 우선 Entity는 아래와 같이 구현되어 있다.

@Getter
@Builder
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "BOOK")
public class Book {
    
    @Id
    @Column(name = "BOOK_ID")
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @Column(name = "BOOK_TITLE", nullable = false)
    private String title;
    
    @Column(name = "BOOK_AUTHOR_NAME")
    private String authorName;

    @Column(name = "BOOK_PUBLISHER_NAME")
    private String publisherName;

    // 그 외, 기타 컬럼들...
}
Book Entity 코드

그 다음에는 이미 JpaRepository<T, ID>를 상속받아 구현되어 있는 Repository 인터페이스에 아래와 같이 추가로 JpaSpecificationExecutor<T> 인터페이스를 상속 받는다. (T = 엔티티 클래스, ID = 엔티티 ID의 타입)

public interface BookRepository extends JpaRepository<Book, Long>, JpaSpecificationExecutor<Book> {

}
BookRepository 코드

그 다음은 조건에 따라 Specification을 처리할 BookSpecs를 새로 만든다.

public class BookSpecs {

    // 인스턴스화 방지
    private BookSpecs() {
        throw new AssertionError();
    }

    public static Specification<Book> equalTitle(String title) {
        return (root, query, builder) ->
               builder.equal(root.get("title"), title);
    }

    public static Specification<Book> equalAuthorName(String authorName) {
        return (root, query, builder) ->
               builder.equal(root.get("authorName"), authorName);
    }

    public static Specification<Book> equalPublisherName(String publisherName) {
        return (root, query, builder) ->
               builder.equal(root.get("publisherName"), publisherName);
    }
}
BookSpecs 코드

위의 람다식은 모두 Specification 인터페이스의 toPredicate() 함수를 구현한 것이다. 이렇게 반환받은 Specification 객체를 findAll() 함수의 매개변수로 넘기면, 해당 조건을 만족하는 데이터만 조회가 가능하다. 그렇다면 이제 실제로 조회를 해보자.

...

/*
 * title, authorName, publisherName은 Controller단에서 넘겨 받는다.
 */

Specification<Book> spec = (root, query, criteriaBuilder) -> null;

// 제목
if(!"".equals(title)) {
    spec = spec.and(BookSpecs.equalTitle(title));
}

// 저자 이름
if(!"".equals(authorName)) {
    spec = spec.and(BookSpecs.equalAuthorName(authorName));
}

// 출판사 이름
if(!"".equals(publisherName)) {
    spec = spec.and(BookSpecs.equalPublisher(publisherName));
}

List<Book> books = bookRepository.findAll(spec);

...
BookService의 책을 조회하는 비즈니스 로직 중 일부

위의 코드가 순차적으로 실행되면 제목/저자명/출판사명의 존재여부를 파악한다. 이렇게 하면 존재하는 값만 조건으로 넣은 Specification<Book> 자료형의 spec 변수가 완성될 것이다. 이제 완성된 spec을 bookRepository.findAll() 함수의 매개변수로 넘기면 spec 변수 내부에 포함된 조건으로 원하는 데이터만 찾아 낼 것이다.


조건이 늘어나면 어떻게 하지?

하지만 위의 코드들은 문제가 많아 보인다. 가장 먼저 생각해볼 수 있는 건 조회 조건이 늘어나는 경우다. 처음에 조건이 3 ~ 4개 정도일 때 쯤이야 상관 없겠지만 조건이 자꾸 늘어나 두 자리 수가 되기 시작하면 얘기가 달라질 것이다. Service단 에서도 인수를 체크한 뒤 BookSpecs 함수를 호출하는 코드가 그만큼 늘어날 테고, 조건 수 만큼 BookSpecs 함수의 개수가 늘어나게 될 것이다. 소스코드가 늘어난다는 것은 당연히 그만큼 유지보수를 나쁘게 만든다.

혹시나 해서 구글링 해보니 역시나 이 내용에 관한 해결법을 찾을 수 있었다. 이 경우에는 일반적으로 for 문을 돌려서 해결한다고 한다. 이제 BookSpecs를 수정해보자.

public class BookSpecs {

    // 인스턴스화 방지
    private BookSpecs() {
        AssertionError();
    }

    public static Specification<Book> bookSpec(Map<String, Object> searchConditions) {
        return ((root, query, criteriaBuilder) -> {
			List<Predicate> predicates = new ArrayList<>();
			for(Map.Entry<String, Object> entry : searchConditions.entrySet()){
				predicates.add(criteriaBuilder.equal(root.get(entry.getKey()), searchConditions.get(entry.getKey())));
			}
            
			return criteriaBuilder.and(predicates.toArray(new Predicate[0]));
		});
    }
}
BookSpecs에서 모든 함수를 제거하고 bookSpec 함수만 새로 작성했다.

그리고 Service단은 다음과 같이 수정한다.

...

/*
 * Controller단에서 모든 파라미터를 Map<String, Object> 형태로 받은 뒤,
 * params 라는 이름의 변수로 받는다.
 */

if(params != null) {
    Specification<Book> spec = BookSepc.bookSpec(params);
    List<Book> books = bookRepository.findAll(spec);
}

...
조건별로 if문을 만들던 코드를 하나의 if문으로 대신할 수 있게 되었다.

이렇게 소스코드를 변경하면, params에 포함된 모든 매개변수를 순환하면서 하나의 Specification<Book> 객체를 반환하고 조회까지 할 것이다.


조건의 연산자 유형이 바뀌는건 답이 없네...

조건이 늘어나더라도 간단히 해결할 방법은 있지만 여전히 문제는 남아있다. 조건의 연산자 유형이 바뀌는 경우이다. 예를 들어 위에서는 제목/저자명/출판사명 조건 모두, 입력 받은 조회 조건과 동일한 값을 가진 데이터만 찾는 equal() 함수를 사용하고 있다.

// CriteriaBuilder의 equal 함수는 SQL의 '='와 동일한 역할을 한다.
builder.equal(root.get("title"), title);

하지만 이제 와서 생각해보니 제목이 엄청나게 긴 책도 있을 텐데, 그 책의 모든 제목을 토씨 하나 틀리지 않고 입력해서 조회하는 건 현실적이지 않다. 꼭 이런 이유가 아니더라도, 비슷한 제목을 가진 책들을 찾고 싶어하는 경우도 있을 것이다. 따라서, 비슷한 문자열을 가진 모든 책을 찾기 위해 제목은 equal이 아닌 like 함수를 사용하도록 변경하고 싶다고 가정해보자.

// CriteriaBuilder의 like 함수는 SQL의 LIKE절과 동일한 역할을 한다.
builder.like(root.get("title"), '%' + title + '%');

이렇게 되면, for문을 돌면서 builder.equal() 함수를 돌리던 코드는 더 이상 사용할 수 없다. 이 뿐만 아니라 조건절에는 연관관계를 가진 JOIN된 테이블도 조건절에 쓰는 경우가 생길 텐데, 이 경우에는 equal과 like가 아닌 또 다른 함수를 사용해야 한다. 결국 조건절이 복잡해질수록 코드가 복잡해지는 건 피할 수 없는 문제 같아 보인다.


정리하며

사실 지금까지 MyBatis만 써보고 본격적으로 JPA를 써보는 건 이번이 처음인 나로써는 모든 게 다 새로운 내용이었기 때문에 Specification이나 Criteria에 대해 잘못 이해한 내용도 많았을 것이라고 생각한다. 따라서 이 글에 대한 지적은 언제나 댓글로 받고 있는 중이다.

이 글에서 다루지 않았던 내용인 JOIN 테이블을 조건절에 쓰는 방법이나 QueryDSL 사용법에 대한 글은 추후 별도의 글로 다룰 예정이다.


참고한 문헌 및 블로그 글

  1. [Spring] JPA Specification 사용하여 다중 조건 검색 구현하기 (criteria API) (velog.io)
  2. JPA Specification으로 쿼리 조건 처리하기 (velog.io)
  3. 자바 ORM 표준 JPA 프로그래밍 - 김명환 저