Spring Data JPA 쿼리 메서드 완벽 가이드 – 메서드 이름부터 @Query·QueryDSL까지

Spring Data JPA 쿼리 메서드를 제대로 이해하지 못하면, 단순한 조회 하나에 JPQL을 직접 작성하느라 시간을 낭비하거나, 반대로 메서드 이름이 50자가 넘는 괴물 쿼리를 만들거나, 동적 조건이 필요한 검색 기능 앞에서 막막해집니다. “JPA는 SQL을 자동으로 만들어주니까 편하지 않나요?”라고 생각하는 순간, N+1 문제에 빠지거나 벌크 업데이트 후 영속성 컨텍스트가 오염되는 함정을 밟게 됩니다. 이 글에서는 JpaRepository의 기본 메서드부터 메서드 이름 기반 쿼리 생성 규칙, @Query JPQL과 네이티브 SQL, @Modifying 벌크 처리, 페이징, Projection, 동적 쿼리(Specification·QueryDSL), 커스텀 Repository까지 Spring Data JPA 쿼리의 모든 것을 실무 예제 코드와 함께 완벽하게 정리합니다.


목차

  1. Spring Data JPA 구조 – JpaRepository가 제공하는 것들
  2. 메서드 이름 기반 쿼리 – 규칙과 키워드 완전 정복
  3. @Query – JPQL과 네이티브 SQL 심화 활용
  4. 페이징·정렬·Projection – 실무 데이터 조회 패턴
  5. 동적 쿼리 – Specification과 QueryDSL 실전 비교
  6. 전문가 관점 – 커스텀 Repository·벌크 처리·N+1 해결 전략

1. Spring Data JPA 구조 – JpaRepository가 제공하는 것들

Spring Data JPA 전체 계층 구조

Spring Data JPA가 어떻게 구성되어 있는지 전체 계층을 먼저 이해해야 쿼리 메서드의 작동 원리를 파악할 수 있습니다.

[Spring Data JPA 인터페이스 계층]

Repository (마커 인터페이스)
  └── CrudRepository<T, ID>
        기본 CRUD: save, findById, findAll, delete, count, exists
        └── PagingAndSortingRepository<T, ID>
              페이징·정렬: findAll(Pageable), findAll(Sort)
              └── JpaRepository<T, ID>          ← 실무에서 주로 사용
                    JPA 특화: saveAll, flush, saveAndFlush
                    배치: deleteAllInBatch, deleteAllByIdInBatch
                    참조: getReferenceById (Lazy 참조)

java

// JpaRepository 기본 제공 메서드 전체 목록
public interface ProductRepository extends JpaRepository<Product, Long> {
    // 아무것도 추가하지 않아도 아래 메서드들이 자동으로 사용 가능

    // 저장·수정
    // Product save(Product entity)
    // List<Product> saveAll(Iterable<Product> entities)
    // Product saveAndFlush(Product entity)

    // 조회
    // Optional<Product> findById(Long id)
    // Product getReferenceById(Long id)   // Lazy 로딩 (실제 조회 지연)
    // List<Product> findAll()
    // List<Product> findAll(Sort sort)
    // Page<Product> findAll(Pageable pageable)
    // List<Product> findAllById(Iterable<Long> ids)

    // 존재 확인·개수
    // boolean existsById(Long id)
    // long count()

    // 삭제
    // void deleteById(Long id)
    // void delete(Product entity)
    // void deleteAll()
    // void deleteAllById(Iterable<Long> ids)
    // void deleteAllInBatch()              // DELETE 단건 반복 X → 단일 DELETE 쿼리
    // void deleteAllByIdInBatch(...)       // IN 절로 일괄 삭제
}

쿼리 생성 전략 – Spring Data JPA가 쿼리를 만드는 4가지 방법

[Spring Data JPA 쿼리 생성 우선순위]

1순위: @Query 어노테이션 → 개발자가 직접 작성한 JPQL/SQL 사용
2순위: Named Query (@NamedQuery 엔티티에 정의된 쿼리)
3순위: 메서드 이름 파싱 → 이름 규칙으로 자동 쿼리 생성
4순위: 기본 메서드 → JpaRepository 기본 구현체 사용

기본 엔티티 설정

이후 모든 예제에서 사용할 엔티티를 먼저 정의합니다.

java

@Entity
@Table(name = "products")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class Product {

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

    @Column(nullable = false, length = 200)
    private String name;

    @Column(nullable = false)
    private BigDecimal price;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private ProductCategory category;

    @Column(nullable = false)
    private Integer stock;

    @Column(nullable = false)
    private Boolean isActive;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "seller_id")
    private Seller seller;

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

    @LastModifiedDate
    private LocalDateTime updatedAt;

    public static Product create(String name, BigDecimal price,
            ProductCategory category, int stock, Seller seller) {
        Product product = new Product();
        product.name     = name;
        product.price    = price;
        product.category = category;
        product.stock    = stock;
        product.seller   = seller;
        product.isActive = true;
        return product;
    }
}

2. 메서드 이름 기반 쿼리 – 규칙과 키워드 완전 정복

Spring Data JPA 쿼리 메서드의 가장 기본은 메서드 이름으로 쿼리를 자동 생성하는 방식입니다. Spring이 메서드 이름을 파싱해 JPQL을 자동으로 생성합니다.

메서드 이름 구조

[메서드 이름 구조 분석]

findBy  Name  And  Age  GreaterThan
  │      │     │    │        │
  │      │     │    │        └── 조건 키워드 (GreaterThan = >)
  │      │     │    └── 조건 필드 2 (age)
  │      │     └── 논리 연산자 (And)
  │      └── 조건 필드 1 (name)
  └── 동작 접두사 (find = SELECT)

→ 생성되는 JPQL:
   SELECT p FROM Product p WHERE p.name = :name AND p.age > :age

동작 접두사 – 무엇을 할 것인가

접두사동작반환 타입
findBy / readBy / getBy / queryBySELECT 조회List, Optional, 단건 등
existsBy존재 여부 확인boolean
countBy개수 집계long
deleteBy / removeByDELETEvoid, long
findFirst3By / findTop5By상위 N개 조회List
findDistinctBy중복 제거 조회List

java

public interface ProductRepository extends JpaRepository<Product, Long> {

    // findBy: 조회
    List<Product> findByCategory(ProductCategory category);
    Optional<Product> findByName(String name);

    // existsBy: 존재 확인 (SELECT 1 WHERE ... LIMIT 1)
    boolean existsByNameAndIsActive(String name, Boolean isActive);

    // countBy: 개수 (SELECT COUNT(*) WHERE ...)
    long countByCategory(ProductCategory category);

    // deleteBy: 삭제 (DELETE WHERE ...)
    void deleteByIsActiveFalse();
    long deleteByCreatedAtBefore(LocalDateTime cutoffDate); // 삭제된 건수 반환

    // findFirst / findTop: 상위 N개
    List<Product> findTop5ByOrderByPriceAsc();     // 가장 저렴한 5개
    Optional<Product> findFirstByOrderByCreatedAtDesc(); // 가장 최근 1개

    // findDistinct: 중복 제거
    List<Product> findDistinctByCategoryAndIsActive(
        ProductCategory category, Boolean isActive);
}

조건 키워드 완전 정복

java

public interface ProductRepository extends JpaRepository<Product, Long> {

    // ── 동등 비교 ──────────────────────────────────────────
    List<Product> findByName(String name);                 // WHERE name = ?
    List<Product> findByNameIs(String name);               // 위와 동일
    List<Product> findByNameEquals(String name);           // 위와 동일
    List<Product> findByNameNot(String name);              // WHERE name != ?
    List<Product> findByStockIsNull();                     // WHERE stock IS NULL
    List<Product> findByStockIsNotNull();                  // WHERE stock IS NOT NULL

    // ── 비교 연산 ──────────────────────────────────────────
    List<Product> findByPriceGreaterThan(BigDecimal price);       // price > ?
    List<Product> findByPriceGreaterThanEqual(BigDecimal price);  // price >= ?
    List<Product> findByPriceLessThan(BigDecimal price);          // price < ?
    List<Product> findByPriceLessThanEqual(BigDecimal price);     // price <= ?
    List<Product> findByPriceBetween(BigDecimal min, BigDecimal max); // BETWEEN

    // ── 문자열 패턴 ────────────────────────────────────────
    List<Product> findByNameLike(String pattern);          // LIKE ? (직접 % 포함)
    List<Product> findByNameNotLike(String pattern);       // NOT LIKE ?
    List<Product> findByNameContaining(String keyword);    // LIKE %?%
    List<Product> findByNameStartingWith(String prefix);   // LIKE ?%
    List<Product> findByNameEndingWith(String suffix);     // LIKE %?
    List<Product> findByNameContainingIgnoreCase(String keyword); // 대소문자 무시

    // ── 컬렉션 조건 ────────────────────────────────────────
    List<Product> findByCategoryIn(List<ProductCategory> categories);    // IN (...)
    List<Product> findByCategoryNotIn(List<ProductCategory> categories); // NOT IN

    // ── 논리 연산 ──────────────────────────────────────────
    List<Product> findByNameAndIsActive(String name, Boolean isActive);
    List<Product> findByNameOrCategory(String name, ProductCategory cat);

    // ── 불리언 ────────────────────────────────────────────
    List<Product> findByIsActiveTrue();           // WHERE is_active = true
    List<Product> findByIsActiveFalse();          // WHERE is_active = false

    // ── 날짜 비교 ──────────────────────────────────────────
    List<Product> findByCreatedAtBefore(LocalDateTime dateTime);  // 
    List<Product> findByCreatedAtAfter(LocalDateTime dateTime);   // >
    List<Product> findByCreatedAtBetween(
        LocalDateTime from, LocalDateTime to);

    // ── 연관 엔티티 조건 ───────────────────────────────────
    List<Product> findBySellerName(String sellerName);
    // → JOIN seller s WHERE s.name = ?
    // ⚠️ N+1 발생 위험! @Query + FETCH JOIN 권장

    List<Product> findBySellerIdAndIsActive(Long sellerId, Boolean isActive);
}

정렬 키워드

java

public interface ProductRepository extends JpaRepository<Product, Long> {

    // 메서드 이름에 정렬 포함 (고정 정렬)
    List<Product> findByIsActiveTrueOrderByPriceAsc();
    List<Product> findByIsActiveTrueOrderByPriceDescNameAsc();
    // → ORDER BY price DESC, name ASC

    // Sort 파라미터로 동적 정렬 (권장)
    List<Product> findByIsActive(Boolean isActive, Sort sort);
    // 호출 시: findByIsActive(true, Sort.by("price").ascending())
    //          findByIsActive(true, Sort.by(
    //              Sort.Order.desc("price"),
    //              Sort.Order.asc("name")))
}

메서드 이름 기반 쿼리의 한계

[메서드 이름 기반 쿼리 사용 권장 기준]

✅ 적합한 경우 (메서드 이름 기반 사용):
  - 조건이 1~3개 이내
  - 단순 동등 비교, 단순 범위 조건
  - 고정된 조건 (동적 조건 불필요)
  예: findByIsActiveTrueAndCategory(ProductCategory category)

❌ 부적합한 경우 (→ @Query 또는 QueryDSL 사용):
  - 조건이 4개 이상 (메서드 이름이 너무 길어짐)
  - JOIN이 필요한 복잡한 조건
  - 집계 함수(SUM, AVG, GROUP BY) 필요
  - 동적 조건 (있을 수도 없을 수도 있는 검색 조건)
  - 서브쿼리 필요

3. @Query – JPQL과 네이티브 SQL 심화 활용

메서드 이름으로 표현하기 어려운 복잡한 쿼리는 @Query로 직접 작성합니다.

JPQL – 엔티티 기반 쿼리

JPQL(Java Persistence Query Language)은 SQL과 비슷하지만 테이블이 아닌 엔티티 클래스와 필드명을 기준으로 작성합니다.

java

public interface ProductRepository extends JpaRepository<Product, Long> {

    // 기본 JPQL
    @Query("SELECT p FROM Product p WHERE p.isActive = true AND p.price < :maxPrice")
    List<Product> findActiveProductsUnderPrice(@Param("maxPrice") BigDecimal maxPrice);

    // JOIN (연관 엔티티 조건)
    @Query("""
        SELECT p FROM Product p
        JOIN p.seller s
        WHERE s.id = :sellerId
          AND p.isActive = true
        ORDER BY p.createdAt DESC
        """)
    List<Product> findActiveProductsBySeller(@Param("sellerId") Long sellerId);

    // FETCH JOIN – N+1 문제 해결 핵심
    @Query("""
        SELECT p FROM Product p
        JOIN FETCH p.seller s
        WHERE p.category = :category
          AND p.isActive = true
        """)
    List<Product> findByCategoryWithSeller(
        @Param("category") ProductCategory category);
    // → JOIN FETCH: seller를 즉시 로딩으로 한 번에 조회 (추가 쿼리 없음)

    // 집계 함수
    @Query("""
        SELECT p.category AS category,
               COUNT(p) AS productCount,
               AVG(p.price) AS avgPrice,
               MIN(p.price) AS minPrice,
               MAX(p.price) AS maxPrice
        FROM Product p
        WHERE p.isActive = true
        GROUP BY p.category
        ORDER BY productCount DESC
        """)
    List<CategoryStatDto> findCategoryStatistics();

    // 서브쿼리
    @Query("""
        SELECT p FROM Product p
        WHERE p.price > (
            SELECT AVG(p2.price) FROM Product p2
            WHERE p2.category = p.category
        )
        AND p.isActive = true
        """)
    List<Product> findAboveAveragePriceByCategory();

    // IN 절 + 복합 조건
    @Query("""
        SELECT p FROM Product p
        WHERE p.category IN :categories
          AND p.price BETWEEN :minPrice AND :maxPrice
          AND p.stock > 0
          AND p.isActive = true
        """)
    List<Product> findByConditions(
        @Param("categories") List<ProductCategory> categories,
        @Param("minPrice") BigDecimal minPrice,
        @Param("maxPrice") BigDecimal maxPrice);

    // DISTINCT + LEFT JOIN FETCH (컬렉션 패치 조인)
    @Query("""
        SELECT DISTINCT p FROM Product p
        LEFT JOIN FETCH p.tags
        WHERE p.isActive = true
        """)
    List<Product> findAllWithTags();
    // ⚠️ 컬렉션 FETCH JOIN + Pageable 조합 금지! (HHH90003004 경고)
    //   → countQuery 별도 지정 필요
}

네이티브 SQL – DB 기능을 직접 활용

java

public interface ProductRepository extends JpaRepository<Product, Long> {

    // 기본 네이티브 쿼리
    @Query(
        value = "SELECT * FROM products WHERE is_active = 1 AND price < :maxPrice",
        nativeQuery = true
    )
    List<Product> findActiveProductsNative(@Param("maxPrice") BigDecimal maxPrice);

    // 윈도우 함수 (JPQL에서 지원 안 됨 → 네이티브 사용)
    @Query(value = """
        SELECT
            p.*,
            RANK() OVER (
                PARTITION BY p.category
                ORDER BY p.price ASC
            ) AS price_rank
        FROM products p
        WHERE p.is_active = 1
        """, nativeQuery = true)
    List<Object[]> findProductsWithPriceRank();

    // CTE (Common Table Expression)
    @Query(value = """
        WITH monthly_sales AS (
            SELECT
                product_id,
                DATE_FORMAT(created_at, '%Y-%m') AS sale_month,
                COUNT(*)                          AS sale_count,
                SUM(amount)                       AS total_amount
            FROM orders
            WHERE created_at >= :startDate
            GROUP BY product_id, DATE_FORMAT(created_at, '%Y-%m')
        )
        SELECT
            p.id,
            p.name,
            ms.sale_month,
            ms.sale_count,
            ms.total_amount
        FROM products p
        JOIN monthly_sales ms ON p.id = ms.product_id
        ORDER BY ms.total_amount DESC
        LIMIT :topN
        """, nativeQuery = true)
    List<Object[]> findTopSellingProducts(
        @Param("startDate") LocalDate startDate,
        @Param("topN") int topN);

    // 네이티브 쿼리 + Projection 인터페이스 (Object[] 대신 타입 안전)
    @Query(value = """
        SELECT
            p.id         AS id,
            p.name       AS name,
            p.price      AS price,
            s.name       AS sellerName
        FROM products p
        JOIN sellers s ON p.seller_id = s.id
        WHERE p.category = :category
        """, nativeQuery = true)
    List<ProductSellerProjection> findProductsWithSellerNative(
        @Param("category") String category);
}

// Projection 인터페이스 (네이티브 쿼리 결과 매핑)
public interface ProductSellerProjection {
    Long getId();
    String getName();
    BigDecimal getPrice();
    String getSellerName();
}

@Modifying – 수정·삭제 쿼리

java

public interface ProductRepository extends JpaRepository<Product, Long> {

    // 단건 UPDATE
    @Modifying
    @Query("UPDATE Product p SET p.isActive = false WHERE p.id = :id")
    int deactivateProduct(@Param("id") Long id);
    // 반환: 영향받은 행 수

    // 벌크 UPDATE – 대량 데이터 일괄 변경
    @Modifying
    @Query("""
        UPDATE Product p
        SET p.price = p.price * :multiplier
        WHERE p.category = :category
          AND p.isActive = true
        """)
    int bulkUpdatePriceByCategory(
        @Param("category") ProductCategory category,
        @Param("multiplier") BigDecimal multiplier);

    // 조건부 일괄 비활성화
    @Modifying
    @Query("""
        UPDATE Product p
        SET p.isActive = false,
            p.updatedAt = :now
        WHERE p.stock = 0
          AND p.isActive = true
        """)
    int deactivateOutOfStockProducts(@Param("now") LocalDateTime now);

    // 벌크 DELETE
    @Modifying
    @Query("""
        DELETE FROM Product p
        WHERE p.isActive = false
          AND p.updatedAt < :cutoffDate
        """)
    int deleteInactiveProductsBefore(@Param("cutoffDate") LocalDateTime cutoffDate);

    // 네이티브 벌크 INSERT (JPQL은 INSERT 미지원)
    @Modifying
    @Query(value = """
        INSERT INTO product_audit (product_id, action, acted_at)
        SELECT id, 'DEACTIVATED', NOW()
        FROM products
        WHERE is_active = 0 AND updated_at < :cutoff
        """, nativeQuery = true)
    int insertAuditForInactiveProducts(@Param("cutoff") LocalDateTime cutoff);
}

@Modifying 핵심 주의사항:

java

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;
    private final EntityManager entityManager;

    @Transactional
    public int deactivateProduct(Long productId) {
        // ① @Modifying은 반드시 @Transactional 안에서 실행해야 함

        // ② 벌크 연산 전 1차 캐시에 있는 엔티티가 있다면 먼저 플러시
        // (벌크 연산은 영속성 컨텍스트를 거치지 않고 DB 직접 실행)
        entityManager.flush();

        int updated = productRepository.deactivateProduct(productId);

        // ③ 벌크 연산 후 영속성 컨텍스트 초기화 (오염 방지)
        entityManager.clear();
        // 이후 조회 시 DB에서 최신 데이터 다시 로딩

        return updated;
    }

    // ✅ 더 간단한 방법: @Modifying의 clearAutomatically 속성 사용
    // @Modifying(clearAutomatically = true, flushAutomatically = true)
    // → Spring Data JPA 2.x 이상에서 자동으로 flush + clear 처리
}

// Repository에 속성 추가
public interface ProductRepository extends JpaRepository<Product, Long> {

    @Modifying(
        clearAutomatically = true,   // 실행 후 영속성 컨텍스트 자동 clear
        flushAutomatically = true    // 실행 전 자동 flush
    )
    @Query("UPDATE Product p SET p.isActive = false WHERE p.id = :id")
    int deactivateProduct(@Param("id") Long id);
}

4. 페이징·정렬·Projection – 실무 데이터 조회 패턴

Pageable – 페이징과 정렬의 핵심

java

// 컨트롤러에서 Pageable 파라미터 자동 바인딩
@RestController
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    // GET /products?page=0&size=20&sort=price,desc&sort=name,asc
    @GetMapping("/products")
    public Page<ProductDto> getProducts(
            @RequestParam(required = false) ProductCategory category,
            @PageableDefault(size = 20, sort = "createdAt",
                           direction = Sort.Direction.DESC) Pageable pageable) {
        return productService.getProducts(category, pageable);
    }
}

// Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // 메서드 이름 + Pageable
    Page<Product> findByIsActiveTrue(Pageable pageable);

    // @Query + Pageable
    @Query("""
        SELECT p FROM Product p
        JOIN FETCH p.seller s
        WHERE p.category = :category
          AND p.isActive = true
        """)
    Page<Product> findByCategoryWithSeller(
        @Param("category") ProductCategory category,
        Pageable pageable);

    // ⚠️ FETCH JOIN + Pageable 조합 시 countQuery 필수!
    // (COUNT 쿼리에는 FETCH JOIN 불필요 → 분리하면 성능 최적화)
    @Query(
        value = """
            SELECT p FROM Product p
            JOIN FETCH p.seller s
            WHERE p.category = :category
            """,
        countQuery = """
            SELECT COUNT(p) FROM Product p
            WHERE p.category = :category
            """    // COUNT 쿼리에서 JOIN FETCH 제거 → 불필요한 조인 방지
    )
    Page<Product> findByCategoryFetchSeller(
        @Param("category") ProductCategory category,
        Pageable pageable);
}

Page vs Slice vs List – 반환 타입 선택 기준

java

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Page<T>: 전체 개수 포함 (COUNT 쿼리 추가 실행)
    Page<Product> findByIsActive(Boolean isActive, Pageable pageable);
    // → totalElements, totalPages 포함
    // → 장점: 전체 페이지 수, 마지막 페이지 여부 알 수 있음
    // → 단점: COUNT 쿼리 추가 실행 (대용량 시 성능 부하)

    // Slice<T>: 다음 페이지 존재 여부만 확인 (COUNT 쿼리 없음)
    Slice<Product> findByCategory(ProductCategory category, Pageable pageable);
    // → hasNext() 만 제공 (총 개수 모름)
    // → 장점: COUNT 쿼리 없음 → 성능 우수
    // → 적합 용도: "더 보기" 버튼, 무한 스크롤
    // → size+1 건을 조회해 다음 페이지 존재 여부만 확인

    // List<T>: 단순 목록 (페이지 정보 없음)
    List<Product> findTop20ByIsActiveTrueOrderByCreatedAtDesc();
    // → 가장 가볍고 빠름
    // → 적합 용도: 고정 개수 조회, 전체 목록, 배치 처리
}

java

// 서비스에서 페이징 처리
@Service
@Transactional(readOnly = true)
public class ProductService {

    public PagedResponse<ProductDto> getProducts(
            ProductCategory category, Pageable pageable) {

        Page<Product> page = category != null
            ? productRepository.findByCategoryAndIsActive(category, true, pageable)
            : productRepository.findByIsActive(true, pageable);

        List<ProductDto> content = page.getContent().stream()
            .map(ProductDto::from)
            .collect(Collectors.toList());

        return PagedResponse.<ProductDto>builder()
            .content(content)
            .page(page.getNumber())
            .size(page.getSize())
            .totalElements(page.getTotalElements())
            .totalPages(page.getTotalPages())
            .isFirst(page.isFirst())
            .isLast(page.isLast())
            .hasNext(page.hasNext())
            .build();
    }
}

Projection – 필요한 컬럼만 조회

전체 엔티티 대신 필요한 필드만 조회해 성능을 최적화합니다.

java

// ① 인터페이스 기반 Projection (Closed Projection)
public interface ProductSummary {
    Long getId();
    String getName();
    BigDecimal getPrice();
    String getCategoryName();     // 연관 엔티티 필드도 접근 가능

    // SpEL 표현식으로 가공 (Open Projection)
    @Value("#{target.name + ' (' + target.price + '원)'}")
    String getDisplayName();
}

public interface ProductRepository extends JpaRepository<Product, Long> {

    // 인터페이스 Projection 사용
    List<ProductSummary> findByIsActiveTrue();
    // → SELECT p.id, p.name, p.price FROM products p WHERE is_active = 1
    // → 전체 컬럼이 아닌 필요한 컬럼만 조회 → 네트워크·메모리 절약

    // 동적 Projection (타입 파라미터로 반환 타입 결정)
    <T> List<T> findByCategory(ProductCategory category, Class<T> type);
    // 호출: repository.findByCategory(ELECTRONICS, ProductSummary.class)
    //       repository.findByCategory(ELECTRONICS, ProductDto.class)
}

// ② DTO 기반 Projection (Class-Based)
@Getter
@AllArgsConstructor  // JPQL 생성자 표현식 사용 → 반드시 필요
public class ProductListDto {
    private Long id;
    private String name;
    private BigDecimal price;
    private String sellerName;
}

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Query("""
        SELECT new com.example.dto.ProductListDto(
            p.id, p.name, p.price, s.name
        )
        FROM Product p
        JOIN p.seller s
        WHERE p.isActive = true
        ORDER BY p.createdAt DESC
        """)
    List<ProductListDto> findProductListDtos();
    // → DTO 직접 매핑 → 불필요한 엔티티 로딩 없음
}

// ③ 레코드 기반 Projection (Java 16+, 가장 간결)
public record ProductRecord(Long id, String name, BigDecimal price) {}

public interface ProductRepository extends JpaRepository<Product, Long> {
    @Query("""
        SELECT new com.example.dto.ProductRecord(p.id, p.name, p.price)
        FROM Product p WHERE p.isActive = true
        """)
    List<ProductRecord> findProductRecords();
}

5. 동적 쿼리 – Specification과 QueryDSL 실전 비교

실무 검색 기능의 핵심은 조건에 따라 쿼리가 달라지는 동적 쿼리입니다. Spring Data JPA는 두 가지 공식 방법을 제공합니다.

Specification – JPA Criteria API 추상화

java

// ① JpaSpecificationExecutor 인터페이스 추가
public interface ProductRepository extends JpaRepository<Product, Long>,
        JpaSpecificationExecutor<Product> {
}

// ② Specification 구현 – 조건별 메서드 정의
public class ProductSpecification {

    public static Specification<Product> isActive() {
        return (root, query, builder) ->
            builder.equal(root.get("isActive"), true);
    }

    public static Specification<Product> hasCategory(ProductCategory category) {
        return (root, query, builder) ->
            category == null ? null :   // null 반환 시 이 조건 무시
            builder.equal(root.get("category"), category);
    }

    public static Specification<Product> nameLike(String keyword) {
        return (root, query, builder) ->
            !StringUtils.hasText(keyword) ? null :
            builder.like(
                builder.lower(root.get("name")),
                "%" + keyword.toLowerCase() + "%"
            );
    }

    public static Specification<Product> priceBetween(
            BigDecimal minPrice, BigDecimal maxPrice) {
        return (root, query, builder) -> {
            if (minPrice == null && maxPrice == null) return null;
            if (minPrice == null) return builder.lessThanOrEqualTo(
                root.get("price"), maxPrice);
            if (maxPrice == null) return builder.greaterThanOrEqualTo(
                root.get("price"), minPrice);
            return builder.between(root.get("price"), minPrice, maxPrice);
        };
    }

    public static Specification<Product> sellerIdEquals(Long sellerId) {
        return (root, query, builder) -> {
            if (sellerId == null) return null;
            Join<Product, Seller> sellerJoin = root.join("seller", JoinType.INNER);
            return builder.equal(sellerJoin.get("id"), sellerId);
        };
    }

    // N+1 방지: Fetch Join을 Specification에 포함
    public static Specification<Product> withSellerFetch() {
        return (root, query, builder) -> {
            // COUNT 쿼리 시에는 Fetch 불필요 (중복 방지)
            if (Long.class != query.getResultType()) {
                root.fetch("seller", JoinType.LEFT);
            }
            return builder.conjunction();  // 항상 참 조건 (필터 역할 아님)
        };
    }
}

// ③ 서비스에서 Specification 조합
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProductSearchService {

    private final ProductRepository productRepository;

    public Page<Product> searchProducts(ProductSearchRequest request, Pageable pageable) {

        Specification<Product> spec = Specification
            .where(ProductSpecification.isActive())
            .and(ProductSpecification.hasCategory(request.getCategory()))
            .and(ProductSpecification.nameLike(request.getKeyword()))
            .and(ProductSpecification.priceBetween(
                request.getMinPrice(), request.getMaxPrice()))
            .and(ProductSpecification.sellerIdEquals(request.getSellerId()))
            .and(ProductSpecification.withSellerFetch());

        return productRepository.findAll(spec, pageable);
    }
}

QueryDSL – 타입 안전한 동적 쿼리 (실무 권장)

xml

<!-- pom.xml QueryDSL 의존성 -->
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
    <classifier>jakarta</classifier>
</dependency>
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-apt</artifactId>
    <classifier>jakarta</classifier>
    <scope>provided</scope>
</dependency>

java

// QueryDSL로 자동 생성되는 Q클래스 활용
// (APT가 엔티티를 분석해 QProduct 자동 생성)
// QProduct.product, QSeller.seller 등

// ① QuerydslPredicateExecutor 사용 (간단한 경우)
public interface ProductRepository extends JpaRepository<Product, Long>,
        QuerydslPredicateExecutor<Product> {
}

// ② JPAQueryFactory 활용 (복잡한 쿼리, 실무 권장)
@Repository
@RequiredArgsConstructor
public class ProductQueryRepository {

    private final JPAQueryFactory queryFactory;

    // QType 선언
    private final QProduct product = QProduct.product;
    private final QSeller seller = QSeller.seller;

    // 동적 쿼리 – 조건이 있을 때만 WHERE 절 추가
    public Page<ProductDto> searchProducts(
            ProductSearchRequest request, Pageable pageable) {

        // 동적 WHERE 조건 빌드 (null 안전)
        BooleanBuilder where = buildWhereConditions(request);

        // 메인 쿼리
        List<ProductDto> content = queryFactory
            .select(Projections.constructor(ProductDto.class,
                product.id,
                product.name,
                product.price,
                product.category,
                seller.name.as("sellerName")
            ))
            .from(product)
            .join(product.seller, seller).fetchJoin()
            .where(where)
            .orderBy(getOrderSpecifiers(pageable.getSort()))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

        // COUNT 쿼리 (별도 최적화)
        Long total = queryFactory
            .select(product.count())
            .from(product)
            .join(product.seller, seller)   // fetchJoin 없이 일반 JOIN
            .where(where)
            .fetchOne();

        return new PageImpl<>(content, pageable,
            total != null ? total : 0L);
    }

    // 동적 조건 빌더
    private BooleanBuilder buildWhereConditions(ProductSearchRequest request) {
        BooleanBuilder builder = new BooleanBuilder();

        // 항상 활성 상태만 조회
        builder.and(product.isActive.isTrue());

        // 카테고리 조건 (값 있을 때만)
        if (request.getCategory() != null) {
            builder.and(product.category.eq(request.getCategory()));
        }

        // 이름 검색 (키워드 있을 때만)
        if (StringUtils.hasText(request.getKeyword())) {
            builder.and(product.name.containsIgnoreCase(request.getKeyword()));
        }

        // 가격 범위 (각각 독립적으로 처리)
        if (request.getMinPrice() != null) {
            builder.and(product.price.goe(request.getMinPrice()));
        }
        if (request.getMaxPrice() != null) {
            builder.and(product.price.loe(request.getMaxPrice()));
        }

        // 판매자 조건
        if (request.getSellerId() != null) {
            builder.and(seller.id.eq(request.getSellerId()));
        }

        // 재고 있는 상품만
        if (Boolean.TRUE.equals(request.getInStockOnly())) {
            builder.and(product.stock.gt(0));
        }

        // 복잡한 OR 조건
        if (StringUtils.hasText(request.getSearchAll())) {
            String keyword = request.getSearchAll();
            builder.and(
                product.name.containsIgnoreCase(keyword)
                    .or(seller.name.containsIgnoreCase(keyword))
            );
        }

        return builder;
    }

    // 동적 정렬
    private OrderSpecifier<?>[] getOrderSpecifiers(Sort sort) {
        return sort.stream()
            .map(order -> {
                PathBuilder<Product> path =
                    new PathBuilder<>(Product.class, "product");
                return new OrderSpecifier(
                    order.isAscending() ? Order.ASC : Order.DESC,
                    path.get(order.getProperty())
                );
            })
            .toArray(OrderSpecifier[]::new);
    }

    // 복잡한 집계 쿼리
    public List<CategorySalesDto> findCategorySalesStats(
            LocalDate startDate, LocalDate endDate) {

        QOrder order = QOrder.order;

        return queryFactory
            .select(Projections.constructor(CategorySalesDto.class,
                product.category,
                product.count(),
                order.amount.sum(),
                order.amount.avg()
            ))
            .from(order)
            .join(order.product, product)
            .where(
                order.createdAt.between(
                    startDate.atStartOfDay(),
                    endDate.atTime(23, 59, 59))
            )
            .groupBy(product.category)
            .having(order.count().gt(10))
            .orderBy(order.amount.sum().desc())
            .fetch();
    }
}

Specification vs QueryDSL 비교

구분SpecificationQueryDSL
타입 안전성낮음 (문자열 필드명)높음 (Q클래스 컴파일 타임 검증)
가독성낮음 (Criteria API 장황)높음 (SQL과 유사한 문법)
복잡한 쿼리어려움쉬움 (JOIN·서브쿼리·집계 모두 지원)
초기 설정추가 설정 없음APT 플러그인·Q클래스 생성 필요
학습 곡선낮음중간
재사용성높음 (Spec 조합)높음 (메서드 분리)
실무 권장간단한 동적 조건복잡한 검색·집계·리포팅

6. 전문가 관점 – 커스텀 Repository·벌크 처리·N+1 해결 전략

커스텀 Repository – 기본 Repository 기능 확장

java

// ① 커스텀 인터페이스 정의
public interface ProductRepositoryCustom {
    List<Product> findByComplexCondition(ProductComplexSearch search);
    void bulkSoftDelete(List<Long> ids, LocalDateTime deletedAt);
}

// ② 구현체 (이름 규칙: 인터페이스명 + Impl 필수)
@Repository
@RequiredArgsConstructor
public class ProductRepositoryCustomImpl implements ProductRepositoryCustom {

    private final JPAQueryFactory queryFactory;
    private final EntityManager entityManager;
    private final QProduct product = QProduct.product;

    @Override
    public List<Product> findByComplexCondition(ProductComplexSearch search) {
        return queryFactory
            .selectFrom(product)
            .where(/* 복잡한 조건 */)
            .fetch();
    }

    @Override
    @Transactional
    public void bulkSoftDelete(List<Long> ids, LocalDateTime deletedAt) {
        // 네이티브 쿼리로 고성능 벌크 처리
        entityManager.createNativeQuery("""
            UPDATE products
            SET is_active = 0, deleted_at = :deletedAt
            WHERE id IN :ids
            """)
            .setParameter("deletedAt", deletedAt)
            .setParameter("ids", ids)
            .executeUpdate();

        entityManager.clear();  // 영속성 컨텍스트 초기화
    }
}

// ③ JpaRepository에 합치기 (다중 상속)
public interface ProductRepository extends JpaRepository<Product, Long>,
        JpaSpecificationExecutor<Product>,
        ProductRepositoryCustom {   // ← 커스텀 인터페이스 추가

    // 기본 메서드 이름 기반 쿼리도 함께 사용 가능
    List<Product> findByIsActiveTrue();
}

N+1 문제 완전 해결 전략

java

// N+1 문제 발생 시나리오
@Transactional(readOnly = true)
public List<ProductDto> getProductsWithSeller() {
    List<Product> products = productRepository.findAll();
    // 쿼리 1번: SELECT * FROM products

    return products.stream()
        .map(p -> {
            String sellerName = p.getSeller().getName();
            // ← getSeller()마다 쿼리 실행: N번 추가 쿼리!
            // 상품 100개 → 101번 쿼리 실행
            return ProductDto.of(p, sellerName);
        })
        .collect(Collectors.toList());
}

// 해결책 1: FETCH JOIN (@Query)
@Query("""
    SELECT p FROM Product p
    JOIN FETCH p.seller
    WHERE p.isActive = true
    """)
List<Product> findAllWithSeller();
// → SELECT p.*, s.* FROM products p JOIN sellers s ... (1번만 실행)

// 해결책 2: @EntityGraph (어노테이션 방식)
@EntityGraph(attributePaths = {"seller", "tags"})   // 즉시 로딩할 경로 지정
List<Product> findByIsActiveTrue();
// → LEFT OUTER JOIN FETCH (명시적 JOIN FETCH 없이)

// 해결책 3: Batch Size (글로벌 설정)
// application.yml
// spring.jpa.properties.hibernate.default_batch_fetch_size: 100
// → SELECT ... WHERE id IN (1,2,3,...,100) (100개씩 IN절로 조회)
// → 100개 상품 조회 시 1번(상품) + 1번(판매자 IN절) = 2번만 실행

// 해결책 4: QueryDSL + fetchJoin
List<ProductDto> findProductsOptimized() {
    return queryFactory
        .select(Projections.constructor(ProductDto.class, ...))
        .from(product)
        .join(product.seller, seller).fetchJoin()
        .where(product.isActive.isTrue())
        .fetch();
}

쿼리 메서드 설계 체크리스트

[Spring Data JPA 쿼리 메서드 실무 체크리스트]

메서드 이름 기반:
□ 조건 3개 이하인가? (초과 시 @Query 또는 QueryDSL 사용)
□ 메서드 이름이 60자 이하인가? (초과 시 가독성 위험)
□ 연관 엔티티 조건 사용 시 N+1 방지책 마련?

@Query:
□ JPQL에서 엔티티명·필드명(DB 컬럼명 아님) 사용?
□ @Param 어노테이션으로 파라미터 명확히 매핑?
□ FETCH JOIN + Pageable 조합 시 countQuery 별도 지정?
□ 네이티브 쿼리 사용 시 DB 방언 의존성 문서화?

@Modifying:
□ @Transactional 함께 선언?
□ clearAutomatically = true로 영속성 컨텍스트 초기화?
□ 벌크 연산 후 재조회 시 DB에서 최신 데이터 반영 확인?

페이징:
□ FETCH JOIN + Page 조합 시 countQuery 최적화?
□ 무한 스크롤·더보기는 Slice 사용 (COUNT 쿼리 제거)?
□ Pageable 최대 size 제한 설정 (악의적 대량 조회 방지)?

Projection:
□ 전체 엔티티 대신 필요한 컬럼만 조회하는 DTO Projection 사용?
□ Open Projection(@Value SpEL) 남용 주의 (전체 엔티티 로딩)?

N+1:
□ FETCH JOIN 또는 @EntityGraph로 즉시 로딩 필요 연관 처리?
□ 컬렉션 FETCH JOIN 복수 사용 금지 (MultipleBagFetchException)?
□ Batch Size 설정으로 IN절 최적화?

결론

Spring Data JPA 쿼리 메서드는 단순한 CRUD에서 시작해 복잡한 동적 검색까지 4가지 방법으로 구현할 수 있습니다. 조건이 단순하다면 메서드 이름 기반 자동 생성을, 복잡한 고정 쿼리는 @Query JPQL을, 동적 조건이 필요하다면 QueryDSL을 선택하는 것이 실무의 정석입니다. 페이징은 PageSlice를 용도에 맞게 구분하고, N+1 문제는 FETCH JOIN·@EntityGraph·Batch Size 중 상황에 맞는 전략을 적용해야 합니다. @Modifying 벌크 연산 후 영속성 컨텍스트를 반드시 초기화하는 것, FETCH JOIN과 Pageable 조합 시 countQuery를 분리하는 것, 이 두 가지만 지켜도 실무에서 발생하는 JPA 트러블의 절반을 예방할 수 있습니다.

지금 바로 본문의 체크리스트로 현재 프로젝트의 Repository 코드를 점검하고, 50자가 넘는 메서드 이름이나 FETCH JOIN 없는 연관 엔티티 조회부터 개선해 보세요.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다