[Spring] Spring Data JPA 파생 쿼리 메서드: 이름이 SQL이 되는 과정

SimpleJpaRepository의 3갈래 위임 중 쿼리 메서드 실행기로 가는 메서드는 몸체가 없다. 이름만 보고 findByColumnAAndColumnB 같은 메서드가 실제 SQL을 낸다. 이 변환이 언제 일어나고, 이름을 어떻게 쪼개 트리로 만들고, 그 트리가 어떻게 JPQL이 되는지를 모르면 오타 하나가 왜 런타임이 아니라 기동 시점에 애플리케이션을 죽이는지 설명할 수 없다. 프록시 조립까지는 SimpleJpaRepository에서 다뤘고, 이 글은 그 프록시가 위임하는 세 갈래 중 쿼리 메서드 실행기 내부만 파고든다.

다이어그램

쿼리 메서드는 언제 만들어지는가

왜 이름 오타가 첫 호출이 아니라 기동 시점에 걸리는가?

  • RepositoryFactorySupport가 프록시를 만들 때, 커스텀 Impl과 표준 메서드에 해당하지 않는 나머지 메서드를 전부 순회하며 QueryLookupStrategy로 실행자를 미리 만들어 맵에 담아둔다.
  • 이 순회는 애플리케이션 컨텍스트가 뜨는 동안(빈 생성 시점) 한 번만 일어난다. 이름을 실제 프로퍼티에 매칭하지 못하면 이 시점에 곧바로 예외를 던진다.
  • 예를 들어 엔티티에 없는 프로퍼티로 findByColumnX(value: String)처럼 이름을 지으면 파싱이 실패한다.
  • 기동 로그에 PropertyReferenceException: No property 'columnX' found for type 'Entity'가 찍히고 애플리케이션이 뜨지 않는다.
  • 이름 기반 쿼리는 오타를 컴파일 타임이 아니라 기동 시점에 잡아준다.
  • 첫 호출까지 기다릴 필요가 없다.

CREATE, USE_DECLARED_QUERY, CREATE_IF_NOT_FOUND는 무엇이 다른가?

모드동작@Query가 있을 때
CREATE메서드 이름만 파싱해 쿼리를 만든다. 선언된 쿼리가 있어도 무시한다무시됨
USE_DECLARED_QUERY선언된 쿼리(@Query, JPA Named Query)만 찾는다. 없으면 예외항상 사용
CREATE_IF_NOT_FOUND(기본값)선언된 쿼리를 먼저 찾고, 없으면 이름을 파싱해 만든다이름 파싱보다 우선
  • 기본값은 CREATE_IF_NOT_FOUND라서, @Query가 붙어 있으면 메서드 이름이 아무리 복잡해도 그 JPQL이 그대로 쓰이고 이름 파싱 자체를 건너뛴다.

메서드 이름은 어떻게 쿼리가 되는가

Subject와 Predicate는 어떻게 나뉘는가?

  • 이름은 PartTree가 파싱한다. 먼저 Subject(find, read, get, query, count, exists, delete 중 하나, 필요하면 First3·Top10 같은 제한자 포함)와 그 뒤 By부터 시작하는 Predicate로 나눈다.
  • Predicate는 Or로 먼저 OrPart 단위로 쪼개지고, 각 OrPart는 다시 And로 Part 단위로 쪼개진다. Part 하나가 프로퍼티 이름과 비교 키워드의 조합이다.
키워드의미
(없음)같음 비교findByStatus
Between범위findByAmountBetween
LessThan / GreaterThan부등호findByAmountLessThan
LikeLIKE 검색findByNameLike
InIN 절findByStatusIn
IsNullNULL 비교findByCanceledAtIsNull
IgnoreCase대소문자 무시findByNameIgnoreCase

findByUserAddressZipCode는 user.address.zipCode인가 userAddress.zipCode인가?

  • PropertyPath가 프로퍼티를 찾을 때 탐욕적으로 매칭한다. 먼저 전체 문자열(UserAddressZipCode)이 엔티티 프로퍼티 하나와 일치하는지부터 본다.
  • 없으면 뒤에서부터 한 단어씩 떼어내며, 남은 부분이 그 프로퍼티의 중첩 프로퍼티인지 재귀적으로 확인한다. 엔티티에 userAddress라는 단일 필드가 있으면 그것을 먼저 찾고, 없으면 user.address.zipCode로 3단 중첩을 시도한다.
  • 두 해석이 동시에 유효할 수 있으면 언더스코어(_)로 경계를 강제한다.
  • findByUser_AddressZipCodeuser.addressZipCode를, findByUser_Address_ZipCodeuser.address.zipCode를 가리킨다.

파싱 결과는 어떻게 JPQL/SQL이 되는가?

  • 완성된 Part 트리는 JpaQueryCreator로 넘어간다.
  • JpaQueryCreator는 트리를 순회하며 각 Part를 CriteriaBuilder 호출로 바꾸고 Predicate 객체를 조립한다. 문자열을 이어붙이는 방식이 아니다.
  • 조립된 CriteriaQuery가 JPQL로 변환되고, Hibernate가 그 JPQL을 다시 SQL로 번역해 DB로 보낸다.
interface OrderRepository : JpaRepository<Order, Long> {
    fun findByColumnAAndColumnB(columnA: String, columnB: Long): List<Order>
}
-- JpaQueryCreator 가 조립한 JPQL을 Hibernate 가 SQL로 번역한 결과
select o1_0.id, o1_0.column_a, o1_0.column_b
from orders o1_0
where o1_0.column_a = ? and o1_0.column_b = ?

호출 시점에는 무엇이 실행되는가

파라미터는 어떻게 바인딩되는가?

  • 부트 시점에 만들어진 실행자(PartTreeJpaQuery 계열)가 호출마다 재사용된다. 매 호출 파싱을 다시 하지 않는다.
  • 파라미터 접근자가 호출 인자를 각 Part가 요구하는 위치에 순서대로 꽂아 넣는다. 메서드 시그니처의 인자 순서가 곧 Part가 등장하는 순서와 같아야 한다.

반환 타입에 따라 무엇이 달라지는가?

  • List, Optional, Stream은 조회 결과를 그대로(또는 단건 검증 후) 반환한다. Optional인데 결과가 2건 이상이면 IncorrectResultSizeDataAccessException이 난다.
  • Page를 반환 타입으로 쓰면 이름 파싱만으로 만든 쿼리에도 count 쿼리가 자동으로 붙는다. count 쿼리 생략 최적화와 Slice 차이는 SimpleJpaRepository 참고.
  • 프로젝션 인터페이스(getter만 있는 인터페이스)를 반환 타입으로 쓰면 SELECT 절 자체가 그 인터페이스의 프로퍼티로 좁혀진다.
sequenceDiagram
    participant App as 애플리케이션
    participant Query as PartTreeJpaQuery
    participant Acc as 파라미터 접근자
    participant EM as EntityManager
    participant DB as InnoDB

    App->>Query: findByColumnAAndColumnB(a, b)
    Query->>Acc: 인자를 Part 순서대로 바인딩
    Query->>EM: createQuery(JPQL).setParameter(...)
    EM->>DB: SELECT ...
    DB-->>EM: 결과 행
    alt 반환 타입이 Page
        Query->>EM: count 쿼리 실행
        EM->>DB: SELECT count(*) ...
        DB-->>EM: 총 개수
        EM-->>Query: 결과와 총 개수
    else List, Optional, Stream 등
        EM-->>Query: 결과만
    end
    Query-->>App: 반환 타입에 맞게 변환된 결과

이름 기반 쿼리는 언제까지 쓰는가

조건이 몇 개부터 이름이 감당 안 되는가?

  • 조건 1~2개는 이름이 곧 문서다. findByStatusAndCreatedAtAfter처럼 읽으면 뜻이 바로 보인다.
  • 조건이 3개를 넘어가면 이름 길이가 감당 안 된다. 조건 5개짜리 이름은 70자를 넘기기 쉽고, 인자 순서와 Part 순서를 맞추는 실수도 늘어난다.
  • 이 지점부터는 @Query로 JPQL을 직접 쓰거나 QueryDSL로 옮긴다.

값이 있을 때만 조건을 걸고 싶으면?

  • 이름 기반 파생 쿼리는 정적이다. 조건 중 일부가 null이면 그 조건을 빼고 나머지만 거는 동적 쿼리를 이름만으로 표현할 방법이 없다.
  • 이런 요구는 QueryDSL의 BooleanBuilderSpecification으로 조건을 조립하거나, 조합별로 파생 쿼리 메서드를 따로 만들어야 한다.

findByAAndBAndCOrDAndE는 실제로 (A and B and C) or (D and E)인가?

  • 맞다. And가 Or보다 먼저 묶인다. PartTree는 Or로 먼저 OrPart 단위로 자르고 그 안에서 And를 묶으므로, 위 이름은 (A and B and C) or (D and E)로 해석된다.
  • 자바·코틀린의 &&/|| 우선순위와 같은 순서라 낯설지는 않지만, 메서드 이름에는 괄호를 쓸 수 없어 의도한 그룹과 실제 그룹이 갈리기 쉽다. 이 모호함도 조건 3개 이상에서 @Query로 넘어가야 하는 이유다.

언제 무엇을 쓰는가

상황선택근거
조건 1~2개, 단순 비교·범위파생 쿼리 메서드(이름 기반)이름이 곧 문서고, 오타는 기동 시점에 잡힌다
조건 3개 이상 또는 And/Or 혼용@Query로 JPQL 직접 작성이름 길이·인자 순서 실수·And/Or 우선순위 모호함을 없앤다
조건 중 일부가 null이면 제외해야 함(동적 검색)QueryDSL 또는 Specification파생 쿼리는 정적이라 조건 유무에 따른 분기를 표현하지 못한다
특정 컬럼만 조회해 전송량을 줄이고 싶음프로젝션 인터페이스 반환SELECT 절이 그 인터페이스의 프로퍼티로 좁혀진다
총 개수가 필요 없는 목록 조회Slice를 반환하는 파생 쿼리count 쿼리를 아낀다. 근거는 SimpleJpaRepository 참고