Home > Backend Development > πŸ“š[Backend Development] πŸ—οΈ JpaSpecificationExecutor

πŸ“š[Backend Development] πŸ—οΈ JpaSpecificationExecutor
Backend Ddevelopment Spring Dynamic Query JpaSpecificationExecutor

🎯 JpaSpecificationExecutor

JpaSpecificationExecutorλŠ” 동적 쿼리λ₯Ό λ‹€λ£¨λŠ” 데 μžˆμ–΄ 맀우 μ€‘μš”ν•œ κ°œλ…μž…λ‹ˆλ‹€.


πŸ€” JpaSpecificationExecutorλ₯Ό μΆ”κ°€λ‘œ μƒμ†λ°›λŠ” μ˜λ―ΈλŠ”?

OrderRepositoryκ°€ JpaSpecificationExecutorλ₯Ό μΆ”κ°€λ‘œ μƒμ†λ°›λŠ” 것은, κΈ°λ³Έ 리포지토리(JpaRepository)에 β€œλ™μ  쿼리 쑰립 λŠ₯λ ₯β€μ΄λΌλŠ” κ°•λ ₯ν•œ μΆ”κ°€ κΈ°λŠ₯을 μž₯μ°©ν•˜λŠ” 것을 μ˜λ―Έν•©λ‹ˆλ‹€.

πŸͺ 식당 λΉ„μœ λ‘œ μ΄ν•΄ν•˜κΈ°

🍽️ JpaRepository = κΈ°λ³Έ λ©”λ‰΄νŒ

public interface OrderRepository extends JpaRepository<Order, String> {
    // πŸ• μ •ν•΄μ§„ 메뉴듀
    List<Order> findAll();           // "λͺ¨λ“  μ£Όλ¬Έ 쑰회"
    Optional<Order> findById(String id);  // "ID둜 주문 찾기"
    Order save(Order order);         // "μ£Όλ¬Έ μ €μž₯"
    void deleteById(String id);      // "μ£Όλ¬Έ μ‚­μ œ"
}

νŠΉμ§•: findById, findAll, save λ“± μ •ν•΄μ§„ κ·œμΉ™μ˜ λ‹¨μˆœν•œ 쿼리만 μ‹€ν–‰ κ°€λŠ₯

🎨 JpaSpecificationExecutor = μ£Όλ°©μž₯ νŠΉμ„  μ£Όλ¬Έ

public interface OrderRepository extends JpaRepository<Order, String>, 
                                       JpaSpecificationExecutor<Order> {
    // ✨ 이제 μΆ”κ°€λ‘œ μ‚¬μš© κ°€λŠ₯ν•œ "λ§žμΆ€ν˜• μ£Όλ¬Έ" κΈ°λŠ₯λ“€
    List<Order> findAll(Specification<Order> spec);
    Page<Order> findAll(Specification<Order> spec, Pageable pageable);
    Optional<Order> findOne(Specification<Order> spec);
    long count(Specification<Order> spec);
}

νŠΉμ§•: μ •ν•΄μ§€μ§€ μ•Šμ€ μ—¬λŸ¬ 쑰건듀을 μ‘°ν•©ν•˜μ—¬ λ§žμΆ€ν˜• 쿼리 μ‹€ν–‰ κ°€λŠ₯

  • 예: β€œνšŒμ› 이름이 β€˜ν™β€™μœΌλ‘œ μ‹œμž‘ν•˜κ³ , μ£Όλ¬Έ μƒνƒœκ°€ β€˜λ°°μ†‘μ€‘β€™μ΄λ©°, μ§€λ‚œμ£Όμ— μ£Όλ¬Έν•œ 내역”

πŸ”„ λ³€ν™” μš”μ•½

ꡬ뢄 JpaRepository만 상속 + JpaSpecificationExecutor 상속
쿼리 νƒ€μž… ❌ 정적 쿼리만 κ°€λŠ₯ βœ… 동적 쿼리 쑰립 κ°€λŠ₯
쑰건 μ‘°ν•© ❌ 미리 μ •μ˜λœ λ©”μ„œλ“œλ§Œ βœ… λŸ°νƒ€μž„μ— 자유둜운 μ‘°ν•©
μœ μ—°μ„± ❌ μ œν•œμ  βœ… λ¬΄ν•œν•œ ν™•μž₯μ„±

πŸ› οΈ μƒμ†μœΌλ‘œ μΈν•œ ꡬ체적 변경사항

κ°€μž₯ 큰 변경점은 OrderRepositoryλ₯Ό μ‚¬μš©ν•˜λŠ” μ„œλΉ„μŠ€ κ³„μΈ΅μ—μ„œ μƒˆλ‘œμš΄ λ©”μ„œλ“œλ“€μ„ μ‚¬μš©ν•  수 있게 λœλ‹€λŠ” κ²ƒμž…λ‹ˆλ‹€.

πŸ“‹ μΆ”κ°€λ˜λŠ” 핡심 λ©”μ„œλ“œλ“€

JpaSpecificationExecutor μΈν„°νŽ˜μ΄μŠ€κ°€ OrderRepository에 μΆ”κ°€ν•΄μ£ΌλŠ” λŒ€ν‘œμ μΈ λ©”μ„œλ“œλ“€:

// πŸ” 쑰건에 λ§žλŠ” λͺ¨λ“  κ²°κ³Ό 쑰회
List<Order> findAll(Specification<Order> spec);

// πŸ“„ 쑰건에 λ§žλŠ” κ²°κ³Όλ₯Ό νŽ˜μ΄μ§•ν•˜μ—¬ 쑰회  
Page<Order> findAll(Specification<Order> spec, Pageable pageable);

// 🎯 쑰건에 λ§žλŠ” 첫 번째 κ²°κ³Ό 쑰회
Optional<Order> findOne(Specification<Order> spec);

// πŸ“Š 쑰건에 λ§žλŠ” κ²°κ³Ό 개수 쑰회
long count(Specification<Order> spec);

// βœ… 쑰건에 λ§žλŠ” κ²°κ³Όκ°€ μ‘΄μž¬ν•˜λŠ”μ§€ 확인
boolean exists(Specification<Order> spec);

πŸŽͺ 핡심 νŠΉμ§•: Specification<Order> νŒŒλΌλ―Έν„°

λͺ¨λ“  λ©”μ„œλ“œκ°€ Specification<Order> νƒ€μž…μ˜ 객체λ₯Ό νŒŒλΌλ―Έν„°λ‘œ λ°›λŠ”λ‹€λŠ” 점이 κ°€μž₯ μ€‘μš”ν•©λ‹ˆλ‹€.

// ❌ 이전: μ •ν•΄μ§„ λ©”μ„œλ“œλ§Œ 호좜 κ°€λŠ₯
List<Order> orders = orderRepository.findAll();

// βœ… 이후: 동적 쑰건을 담은 Specification으둜 자유둜운 쿼리 μ‹€ν–‰
Specification<Order> spec = OrderSpecification.withMemberName("홍길동")
    .and(OrderSpecification.withShippingStatus(SHIPPED));
List<Order> orders = orderRepository.findAll(spec);

πŸš€ 베슀트 ν”„λž™ν‹°μŠ€: 싀무 예제 μ½”λ“œ

핡심 원칙: β€œμž¬μ‚¬μš© κ°€λŠ₯ν•œ 검색 쑰건듀을 λ³„λ„μ˜ Specification 클래슀둜 λΆ„λ¦¬ν•˜μ—¬ κ΄€λ¦¬ν•˜κΈ°β€

μ΄λ ‡κ²Œ ν•˜λ©΄ μ„œλΉ„μŠ€ λ‘œμ§μ€ 검색 쑰건이 μ–΄λ–»κ²Œ λ§Œλ“€μ–΄μ§€λŠ”μ§€μ— λŒ€ν•œ λ³΅μž‘ν•œ λ‚΄μš©κ³Ό λΆ„λ¦¬λ˜μ–΄ 훨씬 κΉ”λ”ν•΄μ§‘λ‹ˆλ‹€.

πŸ”§ Step 1: Specification 클래슀 생성 (Best Practice)

검색 쑰건듀을 μ •μ˜ν•˜λŠ” OrderSpecification 클래슀λ₯Ό μƒˆλ‘œ λ§Œλ“­λ‹ˆλ‹€. 각 쑰건은 μž¬μ‚¬μš© κ°€λŠ₯ν•˜λ„λ‘ static λ©”μ„œλ“œλ‘œ μ •μ˜ν•©λ‹ˆλ‹€.

repository/specification/OrderSpecification.java (μ‹ κ·œ 파일)

package com.kobe.productmanagement.repository.specification;

import com.kobe.productmanagement.common.ShippingStatus;
import com.kobe.productmanagement.domain.Member;
import com.kobe.productmanagement.domain.Order;
import jakarta.persistence.criteria.Join;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.util.StringUtils;

import java.time.LocalDate;
import java.time.LocalTime;

public class OrderSpecification {

    // πŸ” νšŒμ› μ΄λ¦„μœΌλ‘œ κ²€μƒ‰ν•˜λŠ” Specification
    public static Specification<Order> withMemberName(String memberName) {
        return (root, query, criteriaBuilder) -> {
            // 🟒 null/빈 λ¬Έμžμ—΄ 체크둜 μ•ˆμ „μ„± 확보
            if (!StringUtils.hasText(memberName)) {
                return null; // 쑰건 값이 μ—†μœΌλ©΄ λ¬΄μ‹œ
            }
            Join<Order, Member> memberJoin = root.join("member");
            return criteriaBuilder.like(memberJoin.get("memberName"), "%" + memberName + "%");
        };
    }

    // πŸ“¦ 배솑 μƒνƒœλ‘œ κ²€μƒ‰ν•˜λŠ” Specification
    public static Specification<Order> withShippingStatus(ShippingStatus status) {
        return (root, query, criteriaBuilder) -> {
            if (status == null) return null;
            return criteriaBuilder.equal(root.get("shippingStatus"), status);
        };
    }

    // πŸ“… μ£Όλ¬Έ λ‚ μ§œ λ²”μœ„λ‘œ κ²€μƒ‰ν•˜λŠ” Specification
    public static Specification<Order> betweenDates(LocalDate startDate, LocalDate endDate) {
        return (root, query, criteriaBuilder) -> {
            if (startDate == null || endDate == null) return null;
            return criteriaBuilder.between(
                root.get("createdAt"), 
                startDate.atStartOfDay(), 
                endDate.atTime(LocalTime.MAX)
            );
        };
    }

    // πŸ’° μ΅œμ†Œ μ£Όλ¬Έ κΈˆμ•‘μœΌλ‘œ κ²€μƒ‰ν•˜λŠ” Specification
    public static Specification<Order> withMinAmount(Integer minAmount) {
        return (root, query, criteriaBuilder) -> {
            if (minAmount == null) return null;
            return criteriaBuilder.greaterThanOrEqualTo(root.get("totalAmount"), minAmount);
        };
    }

    // ⭐ VIP νšŒμ› μ£Όλ¬Έ κ²€μƒ‰ν•˜λŠ” Specification  
    public static Specification<Order> isVipOrder() {
        return (root, query, criteriaBuilder) -> {
            Join<Order, Member> memberJoin = root.join("member");
            return criteriaBuilder.equal(memberJoin.get("memberType"), "VIP");
        };
    }
}

🎨 Step 2: Serviceμ—μ„œ Specification μ‘°ν•©ν•˜μ—¬ μ‚¬μš©

이제 OrderServiceImplμ—μ„œλŠ” private λ©”μ„œλ“œλ‘œ 검색 λ‘œμ§μ„ 직접 κ΅¬ν˜„ν•˜λŠ” λŒ€μ‹ , μƒˆλ‘œ λ§Œλ“  OrderSpecification의 λ©”μ„œλ“œλ“€μ„ 레고 λΈ”λ‘μ²˜λŸΌ μ‘°ν•©ν•˜μ—¬ μ‚¬μš©ν•©λ‹ˆλ‹€.

service/OrderServiceImpl.java (λ¦¬νŒ©ν† λ§ ν›„)

package com.kobe.productmanagement.service;

import com.kobe.productmanagement.domain.Order;
import com.kobe.productmanagement.dto.request.OrderSearchRequest;
import com.kobe.productmanagement.dto.request.OrderStatusUpdateRequest;
import com.kobe.productmanagement.dto.response.OrderResponse;
import com.kobe.productmanagement.repository.OrderRepository;
import com.kobe.productmanagement.repository.specification.OrderSpecification; // 🟒 μ‹ κ·œ import
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
@Transactional
public class OrderServiceImpl implements OrderService {

    private final OrderRepository orderRepository;

    @Override
    @Transactional(readOnly = true)
    public List<OrderResponse> getAllOrders(OrderSearchRequest searchRequest) {
        // 🧩 Specification듀을 레고 λΈ”λ‘μ²˜λŸΌ 체인 ν˜•νƒœλ‘œ μ‘°ν•©
        Specification<Order> spec = Specification
            .where(OrderSpecification.withMemberName(searchRequest.getMemberName()))
            .and(OrderSpecification.withShippingStatus(searchRequest.getShippingStatus()))
            .and(OrderSpecification.betweenDates(searchRequest.getStartDate(), searchRequest.getEndDate()))
            .and(OrderSpecification.withMinAmount(searchRequest.getMinAmount()));

        // ✨ JpaSpecificationExecutorκ°€ μ œκ³΅ν•˜λŠ” findAll(spec) λ©”μ„œλ“œ μ‚¬μš©
        return orderRepository.findAll(spec).stream()
                .map(OrderResponse::from)
                .collect(Collectors.toList());
    }

    // πŸ†• νŽ˜μ΄μ§• μ²˜λ¦¬κ°€ ν¬ν•¨λœ 검색 λ©”μ„œλ“œ μΆ”κ°€
    @Override
    @Transactional(readOnly = true)
    public Page<OrderResponse> getOrdersWithPaging(OrderSearchRequest searchRequest, Pageable pageable) {
        Specification<Order> spec = Specification
            .where(OrderSpecification.withMemberName(searchRequest.getMemberName()))
            .and(OrderSpecification.withShippingStatus(searchRequest.getShippingStatus()))
            .and(OrderSpecification.betweenDates(searchRequest.getStartDate(), searchRequest.getEndDate()));

        // ✨ νŽ˜μ΄μ§•κΉŒμ§€ μ§€μ›ν•˜λŠ” findAll(spec, pageable) λ©”μ„œλ“œ μ‚¬μš©
        return orderRepository.findAll(spec, pageable)
                .map(OrderResponse::from);
    }

    // πŸ†• λ³΅μž‘ν•œ 쑰건의 검색 μ˜ˆμ‹œ
    @Override
    @Transactional(readOnly = true) 
    public List<OrderResponse> getVipOrders(String memberName) {
        Specification<Order> spec = Specification
            .where(OrderSpecification.withMemberName(memberName))
            .and(OrderSpecification.isVipOrder())
            .or(OrderSpecification.withMinAmount(100000)); // 10λ§Œμ› 이상 μ£Όλ¬Έ

        return orderRepository.findAll(spec).stream()
                .map(OrderResponse::from)
                .collect(Collectors.toList());
    }

    @Override
    public OrderResponse updateOrderState(String orderId, OrderStatusUpdateRequest request) {
        Order order = orderRepository.findById(orderId)
                .orElseThrow(() -> new IllegalArgumentException("ν•΄λ‹Ή 주문을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€ ID: " + orderId));

        order.updateDetails(request.getStatus());
        return OrderResponse.from(order);
    }

    // πŸ—‘οΈ 기쑴의 λ³΅μž‘ν•œ private searchOrders λ©”μ„œλ“œλŠ” 이제 μ‚­μ œ!
}

πŸ“Š λ¦¬νŒ©ν† λ§ μ „ν›„ 비ꡐ

❌ λ¦¬νŒ©ν† λ§ μ „: λ³΅μž‘ν•œ μ„œλΉ„μŠ€ 둜직

@Service
public class OrderServiceImpl {
    
    // πŸ”΄ μ„œλΉ„μŠ€μ— λ³΅μž‘ν•œ 쿼리 생성 둜직이 μ„žμ—¬ 있음
    private List<Order> searchOrders(OrderSearchRequest request) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Order> query = cb.createQuery(Order.class);
        Root<Order> root = query.from(Order.class);
        
        List<Predicate> predicates = new ArrayList<>();
        
        if (StringUtils.hasText(request.getMemberName())) {
            Join<Order, Member> memberJoin = root.join("member");
            predicates.add(cb.like(memberJoin.get("memberName"), "%" + request.getMemberName() + "%"));
        }
        
        if (request.getShippingStatus() != null) {
            predicates.add(cb.equal(root.get("shippingStatus"), request.getShippingStatus()));
        }
        
        // ... 더 λ³΅μž‘ν•œ λ‘œμ§λ“€
        
        query.where(predicates.toArray(new Predicate[0]));
        return entityManager.createQuery(query).getResultList();
    }
}

βœ… λ¦¬νŒ©ν† λ§ ν›„: κΉ”λ”ν•œ 선언적 μ½”λ“œ

@Service  
public class OrderServiceImpl {
    
    // 🟒 선언적이고 λͺ…ν™•ν•œ μ˜λ„ ν‘œν˜„
    public List<OrderResponse> getAllOrders(OrderSearchRequest searchRequest) {
        Specification<Order> spec = Specification
            .where(OrderSpecification.withMemberName(searchRequest.getMemberName()))
            .and(OrderSpecification.withShippingStatus(searchRequest.getShippingStatus()))
            .and(OrderSpecification.betweenDates(searchRequest.getStartDate(), searchRequest.getEndDate()));

        return orderRepository.findAll(spec).stream()
                .map(OrderResponse::from)
                .collect(Collectors.toList());
    }
}

πŸ’‘ 싀무 ν™œμš© 팁

🎯 λ³΅μž‘ν•œ 쑰건 μ‘°ν•© μ˜ˆμ‹œ

// πŸŽͺ μ‹€λ¬΄μ—μ„œ 자주 μ‚¬μš©λ˜λŠ” λ³΅μž‘ν•œ 검색 쑰건
public List<OrderResponse> getComplexOrderSearch(OrderSearchRequest request) {
    Specification<Order> spec = Specification
        .where(OrderSpecification.withMemberName(request.getMemberName()))
        .and(OrderSpecification.withShippingStatus(request.getStatus()))
        .and(OrderSpecification.betweenDates(request.getStartDate(), request.getEndDate()))
        .and(OrderSpecification.withMinAmount(request.getMinAmount()))
        .or(OrderSpecification.isVipOrder())
        .and(Specification.not(OrderSpecification.withShippingStatus(ShippingStatus.CANCELLED)));
        
    return orderRepository.findAll(spec).stream()
            .map(OrderResponse::from)
            .collect(Collectors.toList());
}

πŸ”§ μ„±λŠ₯ μ΅œμ ν™” 팁

// πŸ“ˆ 카운트 쿼리 μ΅œμ ν™”
public long getOrderCount(OrderSearchRequest request) {
    Specification<Order> spec = Specification
        .where(OrderSpecification.withMemberName(request.getMemberName()))
        .and(OrderSpecification.withShippingStatus(request.getStatus()));
        
    return orderRepository.count(spec); // ✨ count μ „μš© λ©”μ„œλ“œ ν™œμš©
}

// πŸ“„ νŽ˜μ΄μ§• μ„±λŠ₯ μ΅œμ ν™”
public Page<OrderResponse> getOrdersWithOptimizedPaging(OrderSearchRequest request, Pageable pageable) {
    Specification<Order> spec = Specification
        .where(OrderSpecification.withMemberName(request.getMemberName()));
        
    return orderRepository.findAll(spec, pageable) // ✨ νŽ˜μ΄μ§• μ΅œμ ν™”λœ 쿼리
            .map(OrderResponse::from);
}

πŸ§ͺ λ‹¨μœ„ ν…ŒμŠ€νŠΈ μž‘μ„±

@Test
public void testOrderSpecification() {
    // 🧩 각 Specification을 λ…λ¦½μ μœΌλ‘œ ν…ŒμŠ€νŠΈ κ°€λŠ₯
    Specification<Order> spec = OrderSpecification.withMemberName("홍길동");
    List<Order> orders = orderRepository.findAll(spec);
    
    assertThat(orders).allMatch(order -> 
        order.getMember().getMemberName().contains("홍길동"));
}

πŸ† κ²°λ‘ 

JpaSpecificationExecutorλŠ” JpaRepository의 κΈ°λ³Έ κΈ°λŠ₯에 κ°•λ ₯ν•œ 동적 쿼리 쑰립 λŠ₯λ ₯을 μΆ”κ°€ν•΄μ£ΌλŠ” 핡심 μΈν„°νŽ˜μ΄μŠ€μž…λ‹ˆλ‹€.

🎯 핡심 κ°€μΉ˜

  • 🧩 레고 블둝식 μ‘°ν•©: 검색 쑰건듀을 자유자재둜 쑰립
  • πŸ“– 선언적 μ½”λ“œ: λΉ„μ¦ˆλ‹ˆμŠ€ μ˜λ„κ°€ λͺ…ν™•ν•˜κ²Œ λ“œλŸ¬λ‚˜λŠ” μ½”λ“œ
  • πŸ”„ 높은 μž¬μ‚¬μš©μ„±: ν•œ 번 λ§Œλ“  Specification을 μ—¬λŸ¬ κ³³μ—μ„œ ν™œμš©
  • πŸ›‘οΈ 관심사 뢄리: λ³΅μž‘ν•œ 쿼리 생성 λ‘œμ§μ„ μ„œλΉ„μŠ€μ—μ„œ 뢄리
  • πŸ“„ νŽ˜μ΄μ§• 지원: λŒ€μš©λŸ‰ 데이터 처리λ₯Ό μœ„ν•œ 효율적인 νŽ˜μ΄μ§•

πŸš€ 적용 효과

이처럼 Specification을 별도 클래슀둜 λΆ„λ¦¬ν•˜λ©΄, μ„œλΉ„μŠ€ 계측은 β€œμ–΄λ–€ 쑰건으둜 κ²€μƒ‰ν•˜λŠ”μ§€β€λ§Œ μ„ μ–Έμ μœΌλ‘œ λͺ…μ‹œν•˜λ©΄ λ˜λ―€λ‘œ μ½”λ“œκ°€ 훨씬 더 깔끔해지고, 각 Specification은 λ‹€λ₯Έ κ³³μ—μ„œλ„ μž¬μ‚¬μš©ν•  수 있게 λ˜μ–΄ μœ μ§€λ³΄μˆ˜μ„±μ΄ 크게 ν–₯μƒλ©λ‹ˆλ‹€!

λ³΅μž‘ν•œ 검색 κΈ°λŠ₯이 ν•„μš”ν•œ 싀무 ν”„λ‘œμ νŠΈμ—μ„œλŠ” λ°˜λ“œμ‹œ κ³ λ €ν•΄λ³Ό λ§Œν•œ κ°•λ ₯ν•œ λ„κ΅¬μž…λ‹ˆλ‹€.