자바 백엔드 입문 49편 — JPA 메서드 이름 쿼리 @Query

2026-05-16자바 백엔드 입문

자바 백엔드 입문 49편. JPA의 쿼리 작성 3가지 방식 — 메서드 이름 쿼리·@Query 어노테이션·QueryDSL을 자동완성 검색창 비유로 풀어쓴 학습 노트.

📚 자바 백엔드 입문 · 49편 — JPA 메서드 이름 쿼리 @Query

이 글은 자바 백엔드 입문 시리즈 59편 중 49편이에요. 45편에서 JpaRepository 의 16개 기본 CRUD를 봤다면, 이번 49편은 "기본 CRUD로 안 되는 커스텀 조회" 를 어떻게 짜는지 — JPA의 쿼리 작성 3가지 방식.

JPA 쿼리가 헷갈리는 이유

findByStatus("ACTIVE") 만 박으면 — JPA가 알아서 WHERE status = ? SQL을 만들어줘요. 어떻게? 그리고 더 복잡한 쿼리는 어떻게 짜나? "메서드 이름이 길어지면 어디까지 자동 처리되나" 가 막막.

이 글에서는 자동완성 검색창 비유로 풀어요. 메서드 이름 쿼리 = "검색창에 키워드 박으면 자동완성", @Query = "수동 검색식 직접 입력", QueryDSL = "검색 빌더 도구로 조건 조립". 세 방식이 한 그림에 들어와요.

방식 1 — 메서드 이름 쿼리 (Query Methods)

가장 마법 같은 기능. JpaRepository 인터페이스에 메서드 이름만 정의하면 — Spring Data JPA가 시작 시 메서드 이름을 분석해서 자동으로 SQL을 생성·실행해줘요.

public interface OrderRepository extends JpaRepository<Order, Long> {

    // 1. 단일 조건
    List<Order> findByStatus(String status);

    // 2. 복합 조건
    List<Order> findByStatusAndAmountGreaterThan(String status, int amount);

    // 3. 정렬
    List<Order> findByStatusOrderByCreatedAtDesc(String status);

    // 4. 카운트
    long countByStatus(String status);

    // 5. 존재 여부
    boolean existsByOrderNumber(String orderNumber);

    // 6. 첫 N개
    List<Order> findTop5ByOrderByAmountDesc();

    // 7. Optional 반환
    Optional<Order> findByOrderNumber(String orderNumber);

    // 8. 삭제
    void deleteByStatusAndCreatedAtBefore(String status, LocalDateTime cutoff);
}

이 8개 메서드 — 본문 한 줄도 안 짰는데 Spring Data JPA가 시작 시 메서드 이름 분석해서 SQL 자동 생성. 정말 마법.

메서드 이름 키워드 표준

메서드 이름에 박을 수 있는 키워드들.

비교 연산

키워드 SQL
Is·Equals (생략 가능) =
Not <>
LessThan·Before <
LessThanEqual <=
GreaterThan·After >
GreaterThanEqual >=
Between BETWEEN ? AND ?
Like·StartingWith·EndingWith·Containing LIKE
In IN (?, ?, ?)
IsNull·IsNotNull IS NULL·IS NOT NULL
True·False boolean 비교

정렬·페이징

키워드 역할
OrderBy{Field}Asc/Desc ORDER BY
Top<N>·First<N> LIMIT

논리 결합

키워드 역할
And AND
Or OR

조합 예:

// status = ? AND amount > ? ORDER BY created_at DESC
List<Order> findByStatusAndAmountGreaterThanOrderByCreatedAtDesc(String status, int amount);

// product_id IN (?, ?, ?)
List<Order> findByProductIdIn(List<Long> productIds);

// title LIKE '%검색어%'
List<Product> findByTitleContaining(String keyword);

// created_at BETWEEN ? AND ?
List<Order> findByCreatedAtBetween(LocalDateTime start, LocalDateTime end);

메서드 이름이 길어지면 가독성 낮아져요. 보통 "조건 3~4개까지가 한계", 그 이상은 @Query 방식으로.

방식 2 — @Query 어노테이션

메서드 이름이 너무 길어지거나 복잡한 조인이 필요하면 — @Query 에 SQL을 직접 작성.

public interface OrderRepository extends JpaRepository<Order, Long> {

    // JPQL — 객체 지향 쿼리 언어
    @Query("SELECT o FROM Order o WHERE o.status = :status AND o.amount > :min")
    List<Order> findActiveOrdersAbove(@Param("status") String status, @Param("min") int min);

    // 조인
    @Query("SELECT o FROM Order o JOIN o.user u WHERE u.email = :email")
    List<Order> findByUserEmail(@Param("email") String email);

    // 네이티브 SQL — DB 고유 함수 사용
    @Query(value = "SELECT * FROM orders WHERE created_at > NOW() - INTERVAL '7 days'",
           nativeQuery = true)
    List<Order> findRecent();

    // UPDATE·DELETE는 @Modifying 필요
    @Modifying
    @Query("UPDATE Order o SET o.status = 'EXPIRED' WHERE o.expiresAt < CURRENT_TIMESTAMP")
    int expireOldOrders();
}

JPQL vs 네이티브 SQL

JPQL 네이티브 SQL
대상 Entity 클래스·필드 이름 DB 테이블·컬럼 이름
DB 호환성 DB 바꿔도 동작 DB 종속
학습 곡선 JPQL 문법 익혀야 일반 SQL 그대로
사용 빈도 기본 (90%) 복잡 쿼리·DB 고유 기능 시

JPQL은 "Order"·"u.email" 처럼 Entity 클래스·필드 이름을 쓰는 객체 지향 쿼리 언어. DB 테이블·컬럼 이름은 안 써요. Spring Data JPA가 시작 시 JPQL → 실제 SQL 변환.

방식 3 — QueryDSL (보너스)

@Query 도 한계가 있어요 — 문자열이라 컴파일 시점에 오타·타입 오류 검출 X. 큰 시스템에서는 QueryDSL 같은 타입 안전 빌더를 추가로 씀.

QOrder order = QOrder.order;

List<Order> result = queryFactory
        .selectFrom(order)
        .where(
            order.status.eq("ACTIVE"),
            order.amount.gt(10000)
        )
        .orderBy(order.createdAt.desc())
        .limit(10)
        .fetch();

코드처럼 생긴 빌더로 쿼리 작성. 컴파일 시점에 오타 잡힘, IDE 자동완성 동작. 한국 회사 시스템 절반 이상이 "기본 Spring Data JPA + 복잡 쿼리에 QueryDSL" 조합.

QueryDSL은 별도 의존성 + 어노테이션 프로세서 설정 필요. 이 시리즈에서는 깊이 다루지 않지만 "이런 게 있다" 만 알아두세요.

페이징·정렬 — Pageable·Sort

JPA에는 페이징·정렬을 위한 표준 객체 두 개.

public interface OrderRepository extends JpaRepository<Order, Long> {

    Page<Order> findByStatus(String status, Pageable pageable);

    List<Order> findByStatus(String status, Sort sort);
}

// Service
public Page<Order> search(String status, int page, int size) {
    Pageable pageable = PageRequest.of(
            page,
            size,
            Sort.by(Sort.Direction.DESC, "createdAt")
    );
    return orderRepo.findByStatus(status, pageable);
}

Page<T> 가 페이징 결과 — 본문(getContent()) + 메타(getTotalElements()·getTotalPages()·hasNext()) 한 객체. 클라이언트가 "3페이지의 20개, 전체 100개 중 60~80번째" 정보를 한 번에 받기 쉬워요.

컨트롤러에서 매개변수로 받기:

@GetMapping
public Page<OrderResponse> list(
        @RequestParam(required = false) String status,
        @PageableDefault(size = 20, sort = "createdAt", direction = Direction.DESC)
        Pageable pageable) {

    return orderService.search(status, pageable);
}
// GET /orders?status=ACTIVE&page=0&size=20&sort=createdAt,desc

Spring Data가 ?page=0&size=20&sort=... 쿼리 스트링을 자동으로 Pageable 객체로 변환. 한국 회사 REST API의 거의 표준 페이징 패턴.

Projection — 일부 필드만 조회

SELECT * 는 성능 부담. Projection 으로 일부 필드만 가져오기.

인터페이스 기반

public interface OrderSummary {
    Long getId();
    String getStatus();
    int getAmount();
}

public interface OrderRepository extends JpaRepository<Order, Long> {
    List<OrderSummary> findByStatusOrderByCreatedAtDesc(String status);
}

JPA가 자동으로 — "OrderSummary 인터페이스에 박힌 getter 3개" 에 해당하는 컬럼만 SELECT.

DTO 클래스 기반 (생성자 표현식)

@Query("SELECT new com.example.OrderSummary(o.id, o.status, o.amount) FROM Order o WHERE o.status = :status")
List<OrderSummary> findSummariesByStatus(@Param("status") String status);

JPQL 안에서 DTO 생성자를 직접 호출. 결과를 DTO 리스트로 직접 받음.

Projection 활용하면 — 큰 Entity의 일부 필드만 조회해 메모리·네트워크 부담 절감.

N+1 함정 — JPA의 가장 큰 위험

JPA가 자동으로 처리하는 만큼, "한 쿼리""수십·수백 쿼리" 로 늘어나는 함정이 있어요. 대표가 N+1 문제.

List<Order> orders = orderRepo.findAll();   // 1번 쿼리: SELECT * FROM orders
for (Order order : orders) {
    System.out.println(order.getUser().getName());   // N번 쿼리: SELECT * FROM users WHERE id = ?
}

100개 주문이면 — 1번 + 100번 = 101번 쿼리. 정말 느려져요. 해결법 = Fetch Join.

@Query("SELECT o FROM Order o JOIN FETCH o.user")
List<Order> findAllWithUser();
// 1번 쿼리: SELECT o.*, u.* FROM orders JOIN users

32편 영속성 컨텍스트에서 더 자세히 다룰 예정. "JPA 잘하는 사람 = N+1 알고 회피하는 사람" 이라는 농담이 있을 만큼 핵심 함정.

🎯 쿼리 작성 3방식 — 언제 무엇을?

단순 조건 1~3개 → 메서드 이름 쿼리. 조인·복잡 조건@Query JPQL. DB 고유 함수·튜닝@Query 네이티브 SQL. 동적 조건 조합 많음 → QueryDSL. 큰 시스템은 4가지 다 혼용.

한 줄 정리 — JPA 쿼리 3방식 = 메서드 이름 쿼리·@Query·QueryDSL. 단순은 메서드 이름, 복잡은 @Query, 동적은 QueryDSL. 페이징은 Pageable+Page<T> 표준. N+1 함정 회피 = Fetch Join.

시험 직전 한 번 더 — JPA 쿼리 입문자가 매번 헷갈리는 것

  • 쿼리 작성 3방식 = 메서드 이름 쿼리 / @Query / QueryDSL
  • 메서드 이름 쿼리 = findByStatus(...) 같이 메서드 이름으로 자동 SQL
  • 비교 키워드 = LessThan·GreaterThan·Between·Like·In·IsNull
  • 논리 결합 = And·Or
  • 정렬 = OrderBy{Field}Asc/Desc
  • 제한 = Top<N>·First<N>
  • 조건 3~4개까지가 메서드 이름 한계 — 그 이상은 @Query
  • @Query = JPQL 또는 네이티브 SQL 직접 작성
  • JPQL = Entity 클래스·필드 이름 (Order·o.amount)
  • 네이티브 SQL = 실제 테이블·컬럼 이름. nativeQuery = true 옵션
  • JPQL = DB 호환성 유지, 네이티브 = DB 고유 기능
  • 매개변수 = @Param("name") + JPQL의 :name
  • @Modifying = UPDATE·DELETE 쿼리 필수 어노테이션
  • QueryDSL = 타입 안전 쿼리 빌더 (컴파일 시 검증)
  • 한국 회사 = Spring Data JPA + QueryDSL 조합 흔함
  • Pageable + Page<T> = 페이징 표준
  • 컨트롤러 매개변수로 Pageable 받으면 자동 변환
  • @PageableDefault = 기본 size·sort 지정
  • Projection = 일부 필드만 조회 (성능 최적화)
  • 인터페이스 기반 Projection 또는 DTO 생성자 표현식
  • N+1 문제 = 1번 쿼리가 N+1번으로 폭발하는 함정
  • 해결 = Fetch Join (JOIN FETCH)
  • 32편 영속성 컨텍스트에서 깊이 다룸

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!