bkdragon's log

Specification 과 Criteria API 본문

Java Spring

Specification 과 Criteria API

bkdragon 2024. 11. 8. 21:21

Specification

명세, 조건이라는 뜻으로 복잡한 데이터 검색 조건을 간단하고 유연하게 구현할 수 있는 Spring Data JPA의 기능이다.

public interface Specification<T> {
    Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}

Specification은 Predicate를 반환하는 toPredicate라는 함수를 가지는 인터페이스이다. Predicate는 아래에서 설명할 JPA의 Criteria API에서 사용하는 쿼리의 조건을 정의하는 객체이다.

즉 Specification은 Predicate을 추상화하여 사용하는 것이다.

Criteria API 란?

JPA(Java Persistence API)의 일부로, 프로그래매틱하게 타입 세이프한 쿼리를 생성할 수 있는 API이다.

주요 개념

  1. CriteriaBuilder: 쿼리의 구성요소를 생성한다. 조건 표현식, Predicate(술어), 정렬 순서 등을 생성한다. Equal, Like, GreaterThan 등의 조건을 만들고 (where), and 나 or을 통해 조건을 합치고 (and, or), 집계 함수나 정렬 조건을 만들 수 있다. EntityManagergetCriteriaBuilder() 메서드를 통해 인스턴스를 얻을 수 있다.

  2. CriteriaQuery: 쿼리 자체를 구성하고 수정하는데 사용된다.

  3. Root: 쿼리의 FROM 절에 해당하는 엔티티를 지정하는데 사용된다. 해당 엔티티의 속성에 접근할 수 있다.

  4. Predicate: 쿼리의 WHERE 절 조건을 나타냅니다. 조건을 구성하고, 조합하여 복잡한 검색 조건을 정의할 수 있다.

  5. Expression: 쿼리 내에서 사용되는 연산을 나타내는 객체입니다. 예를 들어, 엔티티의 속성 값, 집계 함수 등을 표현할 때 사용된다.

연관성

Criteria API 의 목적은 자바 코드로 쿼리를 작성함에 있는데 그것을 더 쉽게 사용할 수 있게 만들어진 레이어를 Specification으로 보면 될 것 같다.

사용법

우선 Criteria API 의 사용법을 알아보자.

CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Entity> cq = cb.createQuery(Entity.class);
Root<Entity> root = cq.from(Entity.class);

Predicate condition = cb.equal(root.get("attribute"), "value");
cq.where(condition);

List<Entity> result = entityManager.createQuery(cq).getResultList();

엔티티 메니저로 부터 쿼리빌더를 얻고 쿼리빌더로부터 쿼리를 얻고 쿼리로 부터 루트를 얻는다.
쿼리 빌더의 equal을 통해 루트의 특정 어트리뷰트와 특정 변수가 같아야한다는 조건을 만들고 쿼리에 포함시킨다.

포인트는 쿼리빌더가 Predicate를 반환하고 그 Predicate를 쿼리에 포함시켜 사용한다는 것이다.

이제 Specification의 사용법을 알아보자. Specification은 좀 더 조건을 더 쉽게 생성하는데 포커스가 맞춰진 느낌이다. (where 절을 쉽게)

처음에 Specification을 설명하는 부분에 보면 toPredicate라는 메서드가 있는데 Specification은 그 형태를 만들어서 사용하면 된다.

public class EntitySpecification {
    public static Specification<Entity> hasAttribute(String value) {
        return (root, query, cb) -> cb.equal(root.get("attribute"), value);
    }
}

hasAttribute 의 형태를 보면 toPredicate 메서드와 같은 형태를 반환한다. toPredicate가 반환하는 건 Predicate이다. Predicate를 한번 감싼 형태이다.

 public List<RequestResponse> findAllByCondition(UUID companyId, String designerId, UUID companyUserId, List<RequestStatus> status, String searchKeyword) {
        Specification<Request> spec = Specification.where(null);

        if (companyId != null) {
            spec = spec.and(RequestSpecification.withCompanyId(companyId));
        }

        if (designerId != null) {
            spec = spec.and(RequestSpecification.withDesignerId(designerId));
        }

        if (companyUserId != null) {
            spec = spec.and(RequestSpecification.withCompanyUserId(companyUserId));
        }

        if (status != null) {
            spec = spec.and(RequestSpecification.withStatusList(status));
        }

        if(searchKeyword != null) {
            spec = spec.and(RequestSpecification.withDynamicQuery(searchKeyword));
        }

        List<Request> all = requestRepository.findAll(spec);

        return RequestResponse.from(all);
    }

서비스 계층에서 이런 식으로 사용이 되는데 and 메서드로 Specification 들을 합치고 있다. 최종적으로 findAll에 인자로 넘겨져 쿼리를 구성하는데 사용될 것이다.

검색 조건을 넣으면 조금 복잡해진다.

 public static Specification<Request> withDynamicQuery(String search) {
        return (root, query, builder) -> {
            List<Predicate> predicates = new ArrayList<>();

            if (search != null && !search.isEmpty()) {
                // 제목 검색 조건
                predicates.add(builder.like(builder.lower(root.get("name")), "%" + search.toLowerCase() + "%"));
                // 디자이너 이름 검색 조건, 연관 필드 검색
                predicates.add(builder.like(builder.lower(root.join("designerUser").get("username")), "%" + search.toLowerCase() + "%"));
                // 회사 이름 검색 조건, 연관 필드 검색
                predicates.add(builder.like(builder.lower(root.join("company").get("name")), "%" + search.toLowerCase() + "%"));
                // 회사 사용자 이름 검색 조건, 연관 필드 검색 및 null 체크
                Predicate companyUserPredicate = builder.isNotNull(root.get("companyUser"));
                predicates.add(builder.and(companyUserPredicate, builder.like(builder.lower(root.join("companyUser").get("username")), "%" + search.toLowerCase() + "%")));
            }

            return builder.or(predicates.toArray(new Predicate[0]));
        };
    }

bulider의 like를 통해 Predicate를 만들어서 predicates list 에 추가하고 있다.
그냥 name, designerUser의 username, company의 name, companyUser 의 username까지 비교를한다.

companyUser의 경우는 null 일 수도 있어서 null이 아니여야 한다는 Predicate를 만들고 and를 사용해 두 Predicate 결합한다. 그래서 null 아니면 companyUser의 username까지 비교하게 된다.

마지막에 builder.or로 모든 Predicate를 합친다.

query는 그룹화, 정렬, 조인등에 사용된다.

root.join("associatedEntity", JoinType.LEFT); // query 매개변수는 사용하지 않지만, query의 컨텍스트 내에서 조인을 정의

query.groupBy(root.get("attribute")); // 결과를 특정 속성으로 그룹화
query.having(builder.gt(builder.count(root), 1)); // 그룹화된 결과에 대한 조건 적용
query.orderBy(builder.asc(root.get("attribute"))); // 특정 속성에 대해 오름차순 정렬
 public static Specification<Todo> orderByEndDateDesc() {
        return (root, query, builder) -> {
            if (query.getResultType() != Long.class && query.getResultType() != long.class) {
                query.orderBy(builder.desc(root.get("endDate")));
            }
            return builder.conjunction();
        };
    }

query 를 사용하는 부분은 Criteria API 와 똑같다. 여기부턴 Specification의 장점이 없어진다고 생각한다.

conjunction는 Predicate를 추가하지 않았을 때 사용할 수 있다.

서브쿼리를 사용하는 예시도 살펴"는" 보자 (기억하진 말자 .. 굳이...)

SELECT r.*
FROM requests r
WHERE r.designer_user_id IN (
    SELECT dgm.designer_user_id
    FROM designer_group_members dgm
    JOIN designer_groups dg ON dgm.designer_group_id = dg.id
    WHERE dg.id = :designerGroupId
)
 public static Specification<Request> withDesignerGroupId(UUID designerGroupId) {
        return (root, query, builder) -> {
            if (designerGroupId == null) return null;

            Subquery<DesignerUser> designerUserSubquery = query.subquery(DesignerUser.class); // DesignerUser 를 반환하는 서브쿼리를 생성
            Root<DesignerGroupMember> designerGroupMemberRoot = designerUserSubquery.from(DesignerGroupMember.class); //  Root 설정, from 뒤에 오는 엔티티
            designerUserSubquery.select(designerGroupMemberRoot.get("designerUser")); // SELECT dgm.designerUser FROM designerGroupMembers dgm
            Predicate designerGroupPredicate = builder.equal(designerGroupMemberRoot.get("designerGroup").get("id"), designerGroupId); // 조건 추가, dgm.designerGroup.id = :designerGroupId
            designerUserSubquery.where(designerGroupPredicate); // 조건을 WHERE 로 합침.

            return builder.in(root.get("designerUser")).value(designerUserSubquery); // 서브쿼리와 in 으로 조합, 결국 이 조건에 맞는 Request 를 얻게 된다.
        };
    }

Specification은 조건부 조회에만 사용되는게 좋아보인다. 검색 조건이라던가 기간에 대한 조건이 있을 때 활용하면 가장 장점을 보이는 것 같다.

grouBy 나 subquery 같은 복잡한 쿼리는 당연하고, orderBy와 같은 비교적 간단한 쿼리도 Specification으로 사용하는 건 좋은 선택이 아닌 것 같다.

'Java Spring' 카테고리의 다른 글

웹 기술의 발전과정  (0) 2024.11.12
JPA와 하이버네이트  (2) 2024.10.24
Transactional 원리  (0) 2024.09.24
JDBC 부터 Spring Data JPA 까지  (0) 2024.09.20