JPA

Spring Data JPA 고급 기능 - Specification, Projection, Auditing

백엔드 개발자 김승원 2026. 4. 3. 09:52

들어가며

Spring Data JPA는 기본 CRUD 외에도 강력한 고급 기능들을 제공합니다. 동적 쿼리를 위한 Specification, 필요한 컬럼만 조회하는 Projection, 생성/수정 시간을 자동 관리하는 Auditing 등을 제대로 활용하면 코드 품질과 생산성을 크게 높일 수 있습니다. 이 글에서는 실무에서 바로 적용 가능한 수준으로 각 기능을 다루겠습니다.

1. JpaSpecificationExecutor - 동적 쿼리 구현

검색 조건이 동적으로 변하는 경우(관리자 검색 화면 등), QueryDSL 없이 Specification만으로도 깔끔하게 동적 쿼리를 구현할 수 있습니다.

Repository에 JpaSpecificationExecutor 추가

public interface ProductRepository extends
        JpaRepository<Product, Long>,
        JpaSpecificationExecutor<Product> {
}

Specification 정의

public class ProductSpecification {

    // 이름 포함 검색
    public static Specification<Product> nameLike(String name) {
        return (root, query, cb) ->
            name == null ? null :
                cb.like(root.get("name"), "%" + name + "%");
    }

    // 카테고리 필터
    public static Specification<Product> categoryEquals(String category) {
        return (root, query, cb) ->
            category == null ? null :
                cb.equal(root.get("category"), category);
    }

    // 가격 범위
    public static Specification<Product> priceBetween(
            Integer minPrice, Integer maxPrice) {
        return (root, query, cb) -> {
            if (minPrice == null && maxPrice == null) return null;
            if (minPrice == null) return cb.lessThanOrEqualTo(root.get("price"), maxPrice);
            if (maxPrice == null) return cb.greaterThanOrEqualTo(root.get("price"), minPrice);
            return cb.between(root.get("price"), minPrice, maxPrice);
        };
    }

    // 상태 필터
    public static Specification<Product> statusIn(List<ProductStatus> statuses) {
        return (root, query, cb) ->
            (statuses == null || statuses.isEmpty()) ? null :
                root.get("status").in(statuses);
    }

    // 등록일 범위
    public static Specification<Product> createdBetween(
            LocalDate start, LocalDate end) {
        return (root, query, cb) -> {
            if (start == null && end == null) return null;
            if (start == null) return cb.lessThanOrEqualTo(root.get("createdAt"), end.atTime(23, 59, 59));
            if (end == null) return cb.greaterThanOrEqualTo(root.get("createdAt"), start.atStartOfDay());
            return cb.between(root.get("createdAt"),
                start.atStartOfDay(), end.atTime(23, 59, 59));
        };
    }

    // JOIN - 판매자명 검색
    public static Specification<Product> sellerNameLike(String sellerName) {
        return (root, query, cb) -> {
            if (sellerName == null) return null;
            Join<Product, Seller> seller = root.join("seller", JoinType.LEFT);
            return cb.like(seller.get("name"), "%" + sellerName + "%");
        };
    }
}

서비스에서 조합하여 사용

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ProductSearchService {

    private final ProductRepository productRepository;

    public Page<ProductResponse> search(ProductSearchCondition cond, Pageable pageable) {

        Specification<Product> spec = Specification
            .where(ProductSpecification.nameLike(cond.getName()))
            .and(ProductSpecification.categoryEquals(cond.getCategory()))
            .and(ProductSpecification.priceBetween(cond.getMinPrice(), cond.getMaxPrice()))
            .and(ProductSpecification.statusIn(cond.getStatuses()))
            .and(ProductSpecification.createdBetween(cond.getStartDate(), cond.getEndDate()))
            .and(ProductSpecification.sellerNameLike(cond.getSellerName()));

        return productRepository.findAll(spec, pageable)
            .map(ProductResponse::from);
    }
}

// 검색 조건 DTO
public record ProductSearchCondition(
    String name,
    String category,
    Integer minPrice,
    Integer maxPrice,
    List<ProductStatus> statuses,
    LocalDate startDate,
    LocalDate endDate,
    String sellerName
) {}

컨트롤러

@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {

    private final ProductSearchService searchService;

    @GetMapping("/search")
    public ResponseEntity<Page<ProductResponse>> search(
            @ModelAttribute ProductSearchCondition condition,
            @PageableDefault(size = 20, sort = "createdAt",
                direction = Sort.Direction.DESC) Pageable pageable) {
        return ResponseEntity.ok(searchService.search(condition, pageable));
    }
}

2. Projection - 필요한 컬럼만 조회

엔티티 전체를 조회하면 불필요한 컬럼까지 가져와 성능이 낭비됩니다. Projection을 사용하면 필요한 필드만 SELECT 할 수 있습니다.

Interface Projection (Closed Projection)

// 인터페이스 정의만으로 프로젝션 가능
public interface ProductSummary {
    Long getId();
    String getName();
    Integer getPrice();
    String getCategoryName();  // 연관 엔티티 접근 가능

    // SpEL로 계산된 값
    @Value("#{target.price * 0.9}")
    Integer getDiscountedPrice();
}

public interface ProductRepository extends JpaRepository<Product, Long> {

    // 반환 타입을 인터페이스로 지정하면 자동으로 프로젝션
    List<ProductSummary> findByCategory(String category);

    // 실행되는 SQL: SELECT p.id, p.name, p.price, c.name
    // FROM product p JOIN category c ON p.category_id = c.id
    // WHERE p.category = ?
}

Class Projection (DTO Projection)

// record로 깔끔하게 정의
public record ProductListDto(
    Long id,
    String name,
    Integer price,
    String sellerName
) {}

public interface ProductRepository extends JpaRepository<Product, Long> {

    // JPQL에서 new 키워드로 DTO 직접 매핑
    @Query("SELECT new com.example.dto.ProductListDto(" +
           "p.id, p.name, p.price, s.name) " +
           "FROM Product p JOIN p.seller s " +
           "WHERE p.status = :status")
    List<ProductListDto> findProductListByStatus(
        @Param("status") ProductStatus status);

    // Native Query + Interface Projection
    @Query(value = "SELECT p.id, p.name, p.price, " +
           "s.name as sellerName " +
           "FROM products p JOIN sellers s ON p.seller_id = s.id " +
           "WHERE p.status = :status",
           nativeQuery = true)
    List<ProductSummary> findProductSummaryNative(
        @Param("status") String status);
}

Dynamic Projection (제네릭)

public interface ProductRepository extends JpaRepository<Product, Long> {

    // 호출 시 프로젝션 타입을 동적으로 결정
    <T> List<T> findByStatus(ProductStatus status, Class<T> type);
}

// 사용 예
List<ProductSummary> summaries =
    productRepository.findByStatus(ProductStatus.ACTIVE, ProductSummary.class);

List<ProductListDto> dtos =
    productRepository.findByStatus(ProductStatus.ACTIVE, ProductListDto.class);
프로젝션 타입 장점 단점
Interface Projection 코드 간결, SpEL 지원 프록시 생성 오버헤드
Class(DTO) Projection 성능 좋음, 타입 안전 JPQL new 키워드 필요
Dynamic Projection 유연함, 재사용 타입 안전성 약간 떨어짐

3. Auditing - 생성/수정 시간 자동 관리

거의 모든 테이블에 필요한 생성일시, 수정일시, 생성자, 수정자를 자동으로 관리하는 기능입니다.

Auditing 설정

@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {

    @Bean
    public AuditorAware<String> auditorProvider() {
        return () -> {
            Authentication auth =
                SecurityContextHolder.getContext().getAuthentication();
            if (auth == null || !auth.isAuthenticated()
                || auth instanceof AnonymousAuthenticationToken) {
                return Optional.of("SYSTEM");
            }
            return Optional.of(auth.getName());
        };
    }
}

BaseEntity 작성

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

    @CreatedBy
    @Column(updatable = false, length = 50)
    private String createdBy;

    @LastModifiedBy
    @Column(length = 50)
    private String updatedBy;
}

// 시간만 필요한 경우 별도의 베이스 클래스
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;
}

엔티티에서 상속

@Entity
@Table(name = "products")
public class Product extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private Integer price;

    @Enumerated(EnumType.STRING)
    private ProductStatus status;

    // createdAt, updatedAt, createdBy, updatedBy는
    // 자동으로 관리됨 - 별도 코드 불필요!
}

4. Query by Example (QBE)

간단한 동적 쿼리는 Specification보다 QBE가 더 간편할 수 있습니다. 엔티티 인스턴스를 검색 조건으로 직접 사용합니다.

public interface UserRepository extends
        JpaRepository<User, Long>,
        QueryByExampleExecutor<User> {
}

@Service
@RequiredArgsConstructor
public class UserSearchService {

    private final UserRepository userRepository;

    public List<User> searchUsers(String name, String email,
                                   UserStatus status) {
        // probe: 검색 조건이 되는 엔티티 인스턴스
        User probe = new User();
        probe.setName(name);
        probe.setEmail(email);
        probe.setStatus(status);

        // 매칭 규칙 정의
        ExampleMatcher matcher = ExampleMatcher.matching()
            .withIgnoreNullFields()                          // null 필드 무시
            .withMatcher("name",
                ExampleMatcher.GenericPropertyMatchers.contains())  // LIKE %name%
            .withMatcher("email",
                ExampleMatcher.GenericPropertyMatchers.startsWith()) // LIKE email%
            .withIgnorePaths("id", "createdAt");             // 특정 필드 제외

        Example<User> example = Example.of(probe, matcher);

        return userRepository.findAll(example);
    }

    // 페이징과 함께 사용
    public Page<User> searchUsersPaged(User probe, Pageable pageable) {
        ExampleMatcher matcher = ExampleMatcher.matching()
            .withIgnoreNullFields()
            .withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING);

        return userRepository.findAll(Example.of(probe, matcher), pageable);
    }
}
동적 쿼리 방식 적합한 상황 한계
Query by Example 간단한 동등/LIKE 조건 범위 검색, OR 조건 불가
Specification 복잡한 동적 조건, JOIN 코드가 다소 장황
QueryDSL 매우 복잡한 쿼리 별도 라이브러리, 설정 필요

5. Custom Repository 구현

Spring Data JPA가 제공하지 않는 복잡한 로직은 Custom Repository 패턴으로 구현합니다.

커스텀 인터페이스 정의

public interface ProductRepositoryCustom {
    List<ProductStatDto> getProductStatistics(LocalDate from, LocalDate to);
    void batchInsert(List<Product> products);
}

구현 클래스 (Impl 접미사 규칙 준수)

@Repository
@RequiredArgsConstructor
public class ProductRepositoryCustomImpl implements ProductRepositoryCustom {

    private final EntityManager em;
    private final JdbcTemplate jdbcTemplate;

    @Override
    public List<ProductStatDto> getProductStatistics(
            LocalDate from, LocalDate to) {
        String jpql = """
            SELECT new com.example.dto.ProductStatDto(
                p.category, COUNT(p), AVG(p.price), SUM(p.salesCount)
            )
            FROM Product p
            WHERE p.createdAt BETWEEN :from AND :to
            GROUP BY p.category
            ORDER BY SUM(p.salesCount) DESC
            """;

        return em.createQuery(jpql, ProductStatDto.class)
            .setParameter("from", from.atStartOfDay())
            .setParameter("to", to.atTime(23, 59, 59))
            .getResultList();
    }

    @Override
    public void batchInsert(List<Product> products) {
        // JDBC 직접 사용으로 대량 INSERT 최적화
        String sql = "INSERT INTO products (name, price, category, status, created_at) " +
                     "VALUES (?, ?, ?, ?, ?)";

        jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                Product p = products.get(i);
                ps.setString(1, p.getName());
                ps.setInt(2, p.getPrice());
                ps.setString(3, p.getCategory());
                ps.setString(4, p.getStatus().name());
                ps.setTimestamp(5, Timestamp.valueOf(LocalDateTime.now()));
            }

            @Override
            public int getBatchSize() {
                return products.size();
            }
        });
    }
}

public record ProductStatDto(
    String category,
    Long count,
    Double avgPrice,
    Long totalSales
) {}

메인 Repository에서 통합

// 기본 JpaRepository + 커스텀 인터페이스 상속
public interface ProductRepository extends
        JpaRepository<Product, Long>,
        JpaSpecificationExecutor<Product>,
        ProductRepositoryCustom {

    // Spring Data JPA 쿼리 메서드
    List<Product> findByCategory(String category);

    // 커스텀 메서드는 ProductRepositoryCustomImpl에서 자동 연결됨
    // getProductStatistics(), batchInsert() 사용 가능
}

6. Pageable 커스터마이징

기본 Pageable 파라미터를 커스터마이징하여 API를 더 유연하게 만들 수 있습니다.

기본 사용

@GetMapping
public ResponseEntity<Page<ProductResponse>> getProducts(
        @PageableDefault(size = 20, sort = "createdAt",
            direction = Sort.Direction.DESC) Pageable pageable) {
    return ResponseEntity.ok(productService.findAll(pageable));
}
// 요청: GET /api/products?page=0&size=20&sort=price,desc

커스텀 Pageable Resolver

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(
            List<HandlerMethodArgumentResolver> resolvers) {

        SortHandlerMethodArgumentResolver sortResolver =
            new SortHandlerMethodArgumentResolver();
        sortResolver.setSortParameter("orderBy");     // sort -> orderBy
        sortResolver.setPropertyDelimiter("-");        // price,desc -> price-desc

        PageableHandlerMethodArgumentResolver pageResolver =
            new PageableHandlerMethodArgumentResolver(sortResolver);
        pageResolver.setPageParameterName("pageNo");   // page -> pageNo
        pageResolver.setSizeParameterName("pageSize");  // size -> pageSize
        pageResolver.setMaxPageSize(100);               // 최대 100개
        pageResolver.setOneIndexedParameters(true);     // 1부터 시작

        resolvers.add(pageResolver);
    }
}
// 요청: GET /api/products?pageNo=1&pageSize=20&orderBy=price-desc

Slice 기반 무한 스크롤

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Slice: 전체 개수 조회(COUNT) 없이 다음 페이지 존재 여부만 확인
    Slice<Product> findByCategory(String category, Pageable pageable);
}

@GetMapping("/infinite")
public ResponseEntity<SliceResponse<ProductResponse>> getProductsInfinite(
        @RequestParam String category,
        @PageableDefault(size = 20) Pageable pageable) {

    Slice<ProductResponse> slice = productService
        .findByCategory(category, pageable);

    return ResponseEntity.ok(new SliceResponse<>(
        slice.getContent(),
        slice.hasNext(),
        slice.getNumber()
    ));
}

public record SliceResponse<T>(
    List<T> content,
    boolean hasNext,
    int currentPage
) {}

마치며

Spring Data JPA의 고급 기능들은 실무에서 코드의 양과 복잡도를 크게 줄여줍니다. 핵심을 정리합니다.

  • Specification: 복잡한 동적 쿼리를 타입 안전하게 구현할 수 있습니다. 검색 조건이 많은 관리자 화면에 특히 적합합니다.
  • Projection: 필요한 컬럼만 SELECT하여 성능을 개선합니다. Interface Projection이 가장 간편하고, DTO Projection이 성능이 가장 좋습니다.
  • Auditing: BaseEntity를 만들어 상속하면 모든 엔티티의 생성/수정 이력이 자동 관리됩니다.
  • Query by Example: 간단한 동적 쿼리는 Specification보다 QBE가 더 간결합니다.
  • Custom Repository: 복잡한 통계 쿼리나 JDBC 직접 사용이 필요한 경우 활용하세요.
  • Pageable: 무한 스크롤에는 Slice를, 일반 페이징에는 Page를 사용하세요. COUNT 쿼리가 필요 없다면 Slice가 성능상 유리합니다.

이 기능들을 프로젝트 초기부터 적절히 설계해 놓으면, 이후 비즈니스 로직 개발에만 집중할 수 있습니다.