Spring Data JPA: Repository, Query Method 그리고 @Query

1. 개요

Spring Data JPA는 거의 구현 코드를 작성할 필요 없이 데이터 접근 계층을 작성하는 것을 도와줍니다. 인터페이스를 선언하기만 하면 Spring이 메서드 이름이나 @Query 애노테이션을 기반으로 SQL 문을 자동으로 생성합니다.

이 글은 세 가지 핵심 부분을 다룹니다: 기본 repository를 만드는 방법, Spring이 자동으로 WHERE 조건을 추론하도록 query method 이름을 지정하는 규칙, 그리고 메서드 이름으로는 충분히 표현할 수 없거나 복잡한 JPQL/native SQL이 필요할 때 @Query를 사용하는 방법입니다. 이 글을 다 읽고 나면 실제 상황에서 각각의 접근 방식을 올바르게 선택할 수 있을 것입니다.

2. 프로젝트 설정

Spring Data JPA 의존성과 데이터베이스 드라이버(데모용 H2의 예)를 추가하세요:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

글 전체에서 사용할 샘플 Entity:

@Entity
@Table(name = "orders")
public class Order {

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

    private String customerEmail;
    private BigDecimal totalAmount;
    private OrderStatus status;
    private LocalDateTime createdAt;

    // getters, setters
}

public enum OrderStatus {
    PENDING, PAID, SHIPPED, CANCELLED
}

Order는 이메일, 총액, 상태, 생성 시간과 같은 간단한 필드를 가지고 있습니다. 아래의 모든 쿼리 예제는 이 엔티티를 중심으로 진행됩니다.

3. 기본 Repository

JpaRepository를 상속하기만 하면 Spring Boot가 시작할 때 자동으로 bean 구현을 생성합니다:

public interface OrderRepository extends JpaRepository<Order, Long> {
}

JpaRepository<Order, Long>은 관리할 엔티티가 Order이고 기본 키 타입이 Long임을 나타냅니다. 이 인터페이스는 이미 save(), findById(), findAll(), deleteById()와 같은 메서드를 포함하고 있습니다 — 추가로 한 줄의 코드도 작성할 필요가 없습니다.

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public Order createOrder(Order order) {
        return orderRepository.save(order);
    }
}

save()를 호출하면 id가 null이면 INSERT를 수행하고, id가 이미 존재하면 UPDATE를 수행합니다. 이것은 Spring Data가 자동으로 생성하는 SimpleJpaRepository 구현의 기본 동작입니다.

주의: save()는 비즈니스 로직에 따라 “새로 생성”과 “업데이트”를 구분하지 않습니다. id가 null인지 아닌지에 따라 결정됩니다. save() 전에 id를 직접 설정하면 Spring은 이를 업데이트로 간주하고 불필요한 findById() + merge를 수행하여 추가 SELECT 쿼리를 야기합니다.

4. Query Method: 이름을 지정하여 쿼리 생성

4.1. 기본 규칙

Spring Data JPA는 메서드 이름을 findBy, existsBy, countBy, deleteBy 패턴과 필드 이름 및 조건 키워드의 조합으로 분석합니다:

public interface OrderRepository extends JpaRepository<Order, Long> {

    List<Order> findByStatus(OrderStatus status);

    List<Order> findByCustomerEmailAndStatus(String email, OrderStatus status);

    Optional<Order> findFirstByCustomerEmailOrderByCreatedAtDesc(String email);

    long countByStatus(OrderStatus status);
}
  • findByStatus는 WHERE status = ?를 생성합니다.
  • findByCustomerEmailAndStatus는 AND로 두 조건을 결합합니다.
  • findFirstByCustomerEmailOrderByCreatedAtDesc는 1개의 레코드를 가져오고 createdAt으로 내림차순 정렬합니다 — 고객의 최신 주문을 찾을 때 유용합니다.
  • countByStatus는 목록이 아니라 개수를 반환합니다.

4.2. 비교 및 범위 키워드

public interface OrderRepository extends JpaRepository<Order, Long> {

    List<Order> findByTotalAmountGreaterThan(BigDecimal amount);

    List<Order> findByCreatedAtBetween(LocalDateTime start, LocalDateTime end);

    List<Order> findByStatusIn(List<OrderStatus> statuses);

    List<Order> findByCustomerEmailContainingIgnoreCase(String keyword);
}

GreaterThan, Between, In, ContainingIgnoreCase는 Spring Data JPA가 지원하는 키워드로 SQL 절과 직접 대응됩니다. ContainingIgnoreCase는 LOWER(customer_email) LIKE LOWER('%keyword%')를 생성합니다 — 대소문자를 구분하지 않는 검색에 편리합니다.

동작을 확인하는 테스트:

@DataJpaTest
class OrderRepositoryTest {

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldFindOrdersByStatus() {
        Order order = new Order();
        order.setStatus(OrderStatus.PAID);
        order.setCustomerEmail("a@test.com");
        order.setTotalAmount(BigDecimal.valueOf(100));
        order.setCreatedAt(LocalDateTime.now());
        orderRepository.save(order);

        List<Order> result = orderRepository.findByStatus(OrderStatus.PAID);

        assertThat(result)
            .hasSize(1)
            .first()
            .extracting(Order::getCustomerEmail)
            .isEqualTo("a@test.com");
    }
}

@DataJpaTest는 JPA 관련 context만 로드합니다(repository, entity manager) — @SpringBootTest보다 빠르게 실행됩니다. 기본적으로 in-memory 데이터베이스를 사용하고 각 테스트 후 롤백합니다.

4.3. 페이지네이션 및 정렬

public interface OrderRepository extends JpaRepository<Order, Long> {

    Page<Order> findByStatus(OrderStatus status, Pageable pageable);
}
Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending());
Page<Order> page = orderRepository.findByStatus(OrderStatus.PAID, pageable);

System.out.println(page.getTotalElements());
System.out.println(page.getContent());

Pageable은 특별한 매개변수입니다 — Spring Data가 이를 자동으로 인식하고 WHERE 조건으로 취급하지 않습니다. Page<Order>는 메타데이터와 함께 반환됩니다: 전체 요소 개수, 전체 페이지 수, 현재 페이지의 내용.

4.4. 메서드 이름이 복잡해질 때

// 읽기 어렵고 키워드를 잘못 입력하기 쉬움
List<Order> findByStatusAndCustomerEmailContainingIgnoreCaseAndCreatedAtAfterOrderByTotalAmountDesc(
    OrderStatus status, String keyword, LocalDateTime after);

위와 같은 긴 메서드 이름도 작동하지만 읽기 어렵고 유지보수가 힘들며 컴파일러가 구문 오류를 검사할 수 없습니다 — Spring Data는 context 시작 시에만 오류를 감지합니다(fail-fast이지만 여전히 런타임 오류입니다). 이것이 @Query로 전환해야 할 때입니다.

5. @Query: 쿼리를 직접 제어

5.1. 기본 JPQL

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o WHERE o.status = :status AND o.totalAmount > :minAmount")
    List<Order> findHighValueOrders(@Param("status") OrderStatus status,
                                     @Param("minAmount") BigDecimal minAmount);
}

@Query는 JPQL을 작성합니다 — entity 중심의 쿼리 언어이지 순수 SQL이 아닙니다(테이블 이름/컬럼 이름 대신 Order o, o.status를 사용합니다). @Param은 쿼리의 named parameter를 메서드 매개변수와 매핑하여 변수 이름이 유연해지고 순서에 의존하지 않습니다.

5.2. 필요할 때 Native SQL 사용

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query(value = "SELECT * FROM orders WHERE total_amount > :minAmount LIMIT :limit",
           nativeQuery = true)
    List<Order> findTopOrders(@Param("minAmount") BigDecimal minAmount,
                               @Param("limit") int limit);
}

nativeQuery = true를 설정하면 실제 SQL을 작성할 수 있고 LIMIT과 같은 데이터베이스별 구문을 사용할 수 있습니다. 대신 데이터베이스 간 이식성을 잃게 됩니다(예를 들어 Oracle은 LIMIT을 지원하지 않습니다).

주의: Native query가 entity를 반환할 때도 컬럼 이름이 매핑과 일치하면 작동합니다. 하지만 임의의 DTO를 SELECT하려면 @SqlResultSetMapping 또는 projection interface를 추가해야 하므로 JPQL보다 복잡합니다.

5.3. @Modifying으로 Update 및 Delete

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Modifying
    @Query("UPDATE Order o SET o.status = :newStatus WHERE o.status = :oldStatus")
    int updateStatusBulk(@Param("oldStatus") OrderStatus oldStatus,
                          @Param("newStatus") OrderStatus newStatus);
}

@Modifying은 Spring Data에 이것이 데이터 변경 쿼리(UPDATE/DELETE)이고 SELECT가 아님을 알립니다. 메서드는 int를 반환하며 이는 영향받은 행의 개수입니다. @Modifying이 없으면 JPA가 ResultSet을 매핑하기를 기대하므로 InvalidDataAccessApiUsageException이 발생합니다.

주의: @Modifying을 사용한 bulk update는 데이터베이스로 직접 이동하며 entity lifecycle을 거치지 않습니다(@PreUpdate를 트리거하지 않고 persistence context의 entity 캐시를 업데이트하지 않습니다). Service가 이전 Order 인스턴스를 메모리에 유지하고 있다면 데이터가 동기화되지 않을 것입니다 — @Modifying에서 clearAutomatically = true를 사용하거나 직접 entityManager.clear()를 호출해야 합니다.

5.4. Projection: 필요한 필드만 가져오기

전체 entity가 필요하지 않을 때는 interface projection을 사용하여 반환되는 데이터를 줄이세요:

public interface OrderSummary {
    Long getId();
    String getCustomerEmail();
    BigDecimal getTotalAmount();
}
public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o.id AS id, o.customerEmail AS customerEmail, o.totalAmount AS totalAmount " +
           "FROM Order o WHERE o.status = :status")
    List<OrderSummary> findSummariesByStatus(@Param("status") OrderStatus status);
}

Spring Data는 런타임에 OrderSummary를 구현하는 proxy를 생성하고 SELECT된 3개 필드만 매핑합니다. 요약 목록을 표시할 때만 이 방식을 사용하여 전체 entity를 로드하지 않으므로 쿼리와 메모리 모두에서 불필요한 오버헤드를 줄입니다.

@Query에 대한 테스트:

@DataJpaTest
class OrderRepositoryQueryTest {

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldFindHighValueOrders() {
        Order cheap = buildOrder(OrderStatus.PAID, BigDecimal.valueOf(50));
        Order expensive = buildOrder(OrderStatus.PAID, BigDecimal.valueOf(500));
        orderRepository.saveAll(List.of(cheap, expensive));

        List<Order> result = orderRepository.findHighValueOrders(
            OrderStatus.PAID, BigDecimal.valueOf(100));

        assertThat(result)
            .hasSize(1)
            .first()
            .extracting(Order::getTotalAmount)
            .isEqualTo(BigDecimal.valueOf(500));
    }

    private Order buildOrder(OrderStatus status, BigDecimal amount) {
        Order order = new Order();
        order.setStatus(status);
        order.setTotalAmount(amount);
        order.setCustomerEmail("test@test.com");
        order.setCreatedAt(LocalDateTime.now());
        return order;
    }
}

이 테스트는 totalAmount > minAmount 조건이 올바르게 작동함을 확인합니다 — 500인 주문만 반환되고 50인 주문은 제외됩니다.

6. Query Method와 @Query 비교

기준 Query Method @Query
작성 속도 간단한 조건에는 빠름 JPQL/SQL을 수동으로 작성해야 함
복잡도 지원 JOIN, subquery는 제한적 모든 JPQL/SQL을 지원하여 유연함
가독성 조건이 많으면 이름이 길어짐 명확하고 쿼리 로직이 분리됨
오류 검사 Context 시작 시 오류 감지 Context 시작 시 오류(JPQL), 런타임 오류(native SQL)
용도 기본 CRUD, 1-3개의 단순 조건 복잡한 쿼리, JOIN, aggregate, projection

선택 원칙: 간단한 경우부터 query method로 시작하세요. 메서드 이름이 3-4개 조건을 초과하거나 JOIN/subquery가 필요하면 @Query로 전환하여 가독성과 제어성을 높이세요.

7. 일반적인 오류

Named parameter를 사용할 때 @Param을 빠뜨림:

// 오류: @Param이 없어 Spring이 :status를 어느 매개변수에 매핑할지 모름
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<Order> findByStatusRaw(OrderStatus status);

수정 방법: 매개변수에 @Param("status")를 추가하거나 컴파일 시 -parameters 플래그를 사용하여 Spring이 bytecode에서 매개변수 이름을 자동으로 추출하도록 하세요(Spring Boot는 Maven/Gradle 플러그인에서 기본적으로 이 플래그를 활성화하지만 빌드 구성에 의존하지 않으려면 여전히 @Param을 명시하는 것이 좋습니다).

findAll()로 모든 데이터를 로드한 후 Java stream으로 필터링:

// 하지 마세요: 테이블 전체를 메모리로 로드한 후 필터링
List<Order> paidOrders = orderRepository.findAll().stream()
    .filter(o -> o.getStatus() == OrderStatus.PAID)
    .toList();

이 방식은 데이터베이스에서 모든 데이터를 애플리케이션으로 로드하여 대역폭과 메모리를 낭비합니다. 항상 query method 또는 @Query를 통해 필터 조건을 데이터베이스로 내려보내세요.

@Modifying을 호출할 때 @Transactional을 빠뜨림:

@Modifying
@Query("DELETE FROM Order o WHERE o.status = :status")
void deleteByStatusBulk(@Param("status") OrderStatus status);

이 메서드를 트랜잭션 없이 직접 호출하면 TransactionRequiredException이 발생합니다. 항상 service 계층의 @Transactional 메서드 안에 감싸세요. 컨트롤러에서 직접 호출하지 마세요.

8. 결론

Query method는 간단한 쿼리에 적합하여 조건이 2-3개 필드를 넘지 않을 때 코드가 짧고 읽기 쉽습니다. 로직이 더 복잡할 때 — JOIN, subquery, bulk update, 또는 커스텀 projection이 필요할 때 — @Query가 더 명확하고 유지보수하기 쉬운 선택입니다.

일반적인 원칙: 데이터 필터링 로직을 데이터베이스로 내려보내고 Java에서 처리하지 말고, 각 중요한 쿼리에 대해 테스트를 작성하고, native SQL은 JPQL로는 부족할 때만 사용하세요(예를 들어 데이터베이스별 함수가 필요할 때). 이 세 가지 기술을 숙달하면 실제 Spring Boot 애플리케이션에서 대부분의 데이터 접근 문제를 해결할 수 있는 충분한 도구를 갖추게 됩니다.

spring-bootspring-data-jpajavadatabaseorm