들어가며
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가 성능상 유리합니다.
이 기능들을 프로젝트 초기부터 적절히 설계해 놓으면, 이후 비즈니스 로직 개발에만 집중할 수 있습니다.
'JPA' 카테고리의 다른 글
| JPA 성능 최적화 실전 - 벌크 연산부터 2차 캐시까지 (0) | 2026.04.02 |
|---|---|
| QueryDSL 실전 가이드 - 동적 쿼리부터 페이징까지 (0) | 2026.03.26 |
| JPA 영속성 컨텍스트 완벽 이해 - 1차 캐시부터 변경 감지까지 (0) | 2026.03.26 |
| JPA N+1 문제 완벽 정리 - 원인부터 해결까지 실무 가이드 (0) | 2026.03.24 |