[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'가 찍히고 애플리케이션이 뜨지 않는다.
-- JpaQueryCreator 가 조립한 JPQL을 Hibernate 가 SQL로 번역한 결과select o1_0.id, o1_0.column_a, o1_0.column_bfrom orders o1_0where 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의 BooleanBuilder나 Specification으로 조건을 조립하거나, 조합별로 파생 쿼리 메서드를 따로 만들어야 한다.
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로 넘어가야 하는 이유다.