Home > Backend Development > πŸ“š[Backend Development] πŸ—οΈ Spring Data JPA Specification

πŸ“š[Backend Development] πŸ—οΈ Spring Data JPA Specification
Backend Ddevelopment Spring JPA Specification Query OOP

πŸ—οΈ Spring Data JPA Specification

Spring Data JPA Specification은 동적 쿼리λ₯Ό 맀우 μš°μ•„ν•˜κ³  객체지ν–₯적으둜 μ²˜λ¦¬ν•  수 μžˆλŠ” κ°•λ ₯ν•œ λ„κ΅¬μž…λ‹ˆλ‹€.


πŸ€” Spring Data JPA Specificationμ΄λž€?

JPA Specification(사양) μ΄λž€, μ—¬λŸ¬ 검색 쑰건을 β€˜κ°μ²΄β€™μ²˜λŸΌ μ‘°λ¦½ν•˜μ—¬ 동적 쿼리λ₯Ό μƒμ„±ν•˜κ²Œ ν•΄μ£ΌλŠ” ν”„λ‘œκ·Έλž˜λ° μΈν„°νŽ˜μ΄μŠ€(API) μž…λ‹ˆλ‹€.

✨ 핡심 νŠΉμ§•

  • λ³΅μž‘ν•œ if-elseλ¬Έμ΄λ‚˜ λ¬Έμžμ—΄ 쿼리 μ‘°ν•© μ—†μŒ
  • νƒ€μž…μ— μ•ˆμ „ν•œ(Type-safe) μ½”λ“œλ‘œ 검색 쑰건 처리
  • ν•„μš”ν•œ 검색 쑰건듀을 마치 레고 λΈ”λ‘μ²˜λŸΌ λΆ™μ˜€λ‹€ 뗐닀 κ°€λŠ₯
  • λ‚΄λΆ€μ μœΌλ‘œλŠ” JPA의 Criteria APIλ₯Ό μ‚¬μš©ν•˜μ—¬ λ™μž‘

πŸ• 직관적 λΉ„μœ : λ§žμΆ€ν˜• ν”Όμž μ£Όλ¬Έ

Specification은 마치 ν”Όμž κ°€κ²Œμ—μ„œ 토핑을 자유둭게 μΆ”κ°€ν•˜κ³  λΉΌλŠ” 것과 κ°™μŠ΅λ‹ˆλ‹€.

❌ 전톡적인 방식 (Repository λ©”μ„œλ“œ 폭발)

// λͺ¨λ“  경우의 수λ₯Ό λ©”μ„œλ“œλ‘œ λ§Œλ“€μ–΄μ•Ό 함
List<Order> findByMemberName(String memberName);
List<Order> findByShippingStatus(ShippingStatus status);
List<Order> findByMemberNameAndShippingStatus(String memberName, ShippingStatus status);
List<Order> findByMemberNameAndShippingStatusAndCreatedAtBetween(...);
// πŸ”΄ 쑰합이 λŠ˜μ–΄λ‚ μˆ˜λ‘ λ©”μ„œλ“œκ°€ κΈ°ν•˜κΈ‰μˆ˜μ μœΌλ‘œ 증가!

문제점: β€œνŽ˜νΌλ‘œλ‹ˆλŠ” λΉΌκ³  μ˜¬λ¦¬λΈŒμ™€ 버섯을 μΆ”κ°€ν•΄μ£Όμ„Έμš”β€ 같은 λ³΅μž‘ν•œ μš”μ²­μ€ 메뉴에 μ—†μœΌλ©΄ μ²˜λ¦¬ν•˜κΈ° νž˜λ“­λ‹ˆλ‹€.

βœ… Specification 방식 (동적 μ‘°ν•©)

// 검색 쑰건듀을 레고 λΈ”λ‘μ²˜λŸΌ 자유둭게 μ‘°ν•©
Specification<Order> spec = Specification
    .where(OrderSpecification.withMemberName(memberName))      // 🧩 ν† ν•‘ 1
    .and(OrderSpecification.withShippingStatus(status))        // 🧩 ν† ν•‘ 2  
    .and(OrderSpecification.betweenDates(startDate, endDate)); // 🧩 ν† ν•‘ 3

List<Order> orders = orderRepository.findAll(spec); // πŸ• λ§žμΆ€ν˜• ν”Όμž μ™„μ„±!

μž₯점: μ†λ‹˜(μ„œλΉ„μŠ€ 계측)이 ν† ν•‘(Specification 객체)듀을 κ³ λ₯΄λ©΄, μ£Όλ°©μž₯(JPA)이 μ¦‰μ„μ—μ„œ λ§žμΆ€ν˜• ν”Όμž(동적 쿼리)λ₯Ό λ§Œλ“€μ–΄ μ€λ‹ˆλ‹€.


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

κ°€μž₯ 효과적인 Specification μ‚¬μš©λ²•μ€ β€œμž¬μ‚¬μš© κ°€λŠ₯ν•œ Specification듀을 λ³„λ„μ˜ 클래슀둜 λΆ„λ¦¬ν•˜μ—¬ κ΄€λ¦¬ν•˜λŠ” 것” μž…λ‹ˆλ‹€.

πŸ“ Step 1: Specification 클래슀 생성 (Best Practice)

검색 쑰건듀을 μ •μ˜ν•˜λŠ” OrderSpecification 클래슀λ₯Ό μƒˆλ‘œ λ§Œλ“­λ‹ˆλ‹€.

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)
            );
        };
    }
}

πŸ”§ Step 2: Serviceμ—μ„œ Specification μ‘°ν•©ν•˜μ—¬ μ‚¬μš©

이제 OrderServiceImplμ—μ„œλŠ” μƒˆλ‘œ λ§Œλ“  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.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()));

        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 λ©”μ„œλ“œλŠ” 이제 μ‚­μ œ!
}

🎯 이 λ°©μ‹μ˜ 핡심 μž₯점

1. πŸ”„ μž¬μ‚¬μš©μ„± (Reusability)

// λ‹€λ₯Έ μ„œλΉ„μŠ€μ—μ„œλ„ λ™μΌν•œ Specification μž¬μ‚¬μš© κ°€λŠ₯
public class AdminService {
    public List<Order> getVipOrders() {
        return orderRepository.findAll(
            OrderSpecification.withShippingStatus(ShippingStatus.DELIVERED)
                .and(OrderSpecification.withMemberName("VIP"))
        );
    }
}

2. πŸ“– 가독성 (Readability)

// ❌ κΈ°μ‘΄: λ³΅μž‘ν•œ 쿼리 생성 둜직이 μ„œλΉ„μŠ€μ— μ„žμž„
if (memberName != null) {
    // λ³΅μž‘ν•œ Criteria API μ½”λ“œ...
}
if (status != null) {
    // 또 λ‹€λ₯Έ λ³΅μž‘ν•œ μ½”λ“œ...
}

// βœ… κ°œμ„ : 선언적이고 λͺ…ν™•ν•œ μ˜λ„ ν‘œν˜„
Specification<Order> spec = Specification
    .where(OrderSpecification.withMemberName(memberName))
    .and(OrderSpecification.withShippingStatus(status));

3. 🧩 μ‘°ν•©μ˜ μœ μ—°μ„± (Flexibility)

// πŸŽͺ λ³΅μž‘ν•œ 쑰건도 자유자재둜 μ‘°ν•© κ°€λŠ₯
Specification<Order> complexSpec = Specification
    .where(OrderSpecification.withMemberName("κΉ€μ² μˆ˜"))
    .and(OrderSpecification.withShippingStatus(ShippingStatus.SHIPPED))
    .or(OrderSpecification.betweenDates(startDate, endDate))
    .and(Specification.not(OrderSpecification.withShippingStatus(ShippingStatus.CANCELLED)));

πŸ“Š 전톡적 방식 vs Specification 비ꡐ

ꡬ뢄 전톡적 방식 Specification 방식
λ©”μ„œλ“œ 수 ❌ μ‘°ν•©λ³„λ‘œ λ©”μ„œλ“œ 폭발 βœ… μž¬μ‚¬μš© κ°€λŠ₯ν•œ 쑰각듀
μœ μ§€λ³΄μˆ˜ ❌ 쑰건 μΆ”κ°€ μ‹œ λ©”μ„œλ“œ 증가 βœ… Specification만 μΆ”κ°€
가독성 ❌ λ³΅μž‘ν•œ 쿼리 생성 둜직 βœ… 선언적이고 λͺ…ν™•ν•œ μ˜λ„
νƒ€μž… μ•ˆμ •μ„± ❌ λ¬Έμžμ—΄ 기반 쿼리 μœ„ν—˜ βœ… 컴파일 νƒ€μž„ 체크
ν…ŒμŠ€νŠΈ ❌ 각 λ©”μ„œλ“œλ³„ κ°œλ³„ ν…ŒμŠ€νŠΈ βœ… Specification λ‹¨μœ„ ν…ŒμŠ€νŠΈ

πŸ’‘ 싀무 팁

🎨 넀이밍 μ»¨λ²€μ…˜

// βœ… 쒋은 넀이밍: μ˜λ„κ°€ λͺ…확함
OrderSpecification.withMemberName()
OrderSpecification.betweenDates()
OrderSpecification.hasShippingStatus()

// ❌ λ‚˜μœ 넀이밍: μ˜λ„κ°€ λΆˆλΆ„λͺ…
OrderSpecification.memberName()
OrderSpecification.dates()
OrderSpecification.status()

πŸ” null μ²΄ν¬λŠ” ν•„μˆ˜

// 🟒 항상 null 체크λ₯Ό 톡해 μ•ˆμ „μ„± 확보
public static Specification<Order> withMemberName(String memberName) {
    return (root, query, criteriaBuilder) -> {
        if (!StringUtils.hasText(memberName)) {
            return null; // null λ°˜ν™˜ μ‹œ ν•΄λ‹Ή 쑰건은 λ¬΄μ‹œλ¨
        }
        // μ‹€μ œ 쑰건 둜직...
    };
}

πŸŽͺ λ³΅μž‘ν•œ 쑰건 μ‘°ν•© μ˜ˆμ‹œ

// μ‹€λ¬΄μ—μ„œ 자주 μ‚¬μš©λ˜λŠ” λ³΅μž‘ν•œ 검색 쑰건
Specification<Order> spec = Specification
    .where(OrderSpecification.withMemberName(searchRequest.getMemberName()))
    .and(OrderSpecification.withShippingStatus(searchRequest.getStatus()))
    .and(OrderSpecification.betweenDates(searchRequest.getStartDate(), searchRequest.getEndDate()))
    .and(OrderSpecification.withMinAmount(searchRequest.getMinAmount()))
    .or(OrderSpecification.isVipOrder());

πŸ† κ²°λ‘ 

Spring Data JPA Specification은 λ³΅μž‘ν•œ 동적 쿼리λ₯Ό μš°μ•„ν•˜κ³  객체지ν–₯적으둜 μ²˜λ¦¬ν•  수 μžˆλŠ” 졜고의 μ†”λ£¨μ…˜ 쀑 ν•˜λ‚˜μž…λ‹ˆλ‹€.

🎯 핡심 κ°€μΉ˜

  • 🧩 레고 블둝식 μ‘°ν•©: ν•„μš”ν•œ 검색 쑰건듀을 자유자재둜 쑰립
  • πŸ›‘οΈ νƒ€μž… μ•ˆμ •μ„±: 컴파일 νƒ€μž„μ— 였λ₯˜ 발견 κ°€λŠ₯
  • πŸ“– 선언적 μ½”λ“œ: λΉ„μ¦ˆλ‹ˆμŠ€ μ˜λ„κ°€ λͺ…ν™•ν•˜κ²Œ λ“œλŸ¬λ‚˜λŠ” μ½”λ“œ
  • πŸ”„ 높은 μž¬μ‚¬μš©μ„±: ν•œ 번 λ§Œλ“  Specification을 μ—¬λŸ¬ κ³³μ—μ„œ ν™œμš©

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