인터페이스만 선언했는데 리포지토리 구현체는 누가 만드나 — Spring Data JPA 프록시와 쿼리 룩업

seonwoo_jung·2026년 7월 16일

interface UserRepository extends JpaRepository<User, Long> 한 줄만 쓰고, 구현 클래스는 어디에도 없는데 findByEmailAndActiveTrue(...)가 멀쩡히 동작한다. 그럼 그 구현체는 대체 누가, 언제, 어떻게 만드는가? 나는 오랫동안 "Spring이 런타임에 구현 클래스를 바이트코드로 찍어낸다"고 막연히 알고 있었는데, 소스를 따라가 보니 그게 아니었다. 이 글은 리포지토리 빈의 정체가 사실 디스패처 프록시라는 것, 그리고 메서드 이름 파싱이 언제 일어나는지를 Spring Data 소스 기준으로 정리한 것이다.

1. 리포지토리 빈의 정체는 프록시다

먼저 결론부터.

리포지토리 빈은 새로 생성된 구현 바이트코드가 아니라 JDK 동적 프록시(디스패처) 다. 이 프록시는 호출을 세 갈래 — 이미 존재하는 SimpleJpaRepository 인스턴스(CRUD) / 부트스트랩 때 미리 만들어둔 RepositoryQuery 객체(쿼리 메서드) / 사용자 커스텀 프래그먼트 — 로 나눠 위임할 뿐이다.

즉 Spring은 UserRepository를 구현한 새 클래스를 찍어내는 게 아니다. CRUD의 실체는 이미 라이브러리 안에 존재하는 SimpleJpaRepository라는 base class의 인스턴스이고, 프록시는 그저 "이 호출을 어디로 보낼지" 결정하는 교통정리 역할만 한다.

2. 등록: 인터페이스가 어떻게 빈이 되나

@EnableJpaRepositories(스프링 부트에서는 auto-configuration)가 RepositoryConfigurationDelegate로 스캔을 돌려, 마커 인터페이스 Repository를 상속한 인터페이스를 찾는다. 여기서 헷갈리기 쉬운 지점이 있다. 각 인터페이스마다 실제로 컨테이너에 등록되는 빈의 클래스는 그 인터페이스가 아니라 JpaRepositoryFactoryBean, 즉 FactoryBean이다.

FactoryBean은 "빈을 만들어내는 빈"이다. 컨테이너는 이 팩토리의 getObject()가 돌려주는 프록시를 UserRepository 타입의 빈으로 노출한다. 그래서 우리가 @Autowired UserRepository로 주입받는 것은 팩토리가 만들어 건넨 프록시 인스턴스다.

3. 프록시 조립과 3-way 디스패치

프록시는 FactoryBean.afterPropertiesSet() 단계에서 RepositoryFactorySupport.getRepository()가 조립한다. 소스를 의사코드로 옮기면 골격은 이렇다.

// RepositoryFactorySupport#getRepository (요지)
Object getRepository(Class<?> repositoryInterface, RepositoryFragments fragments) {
    Object target = getTargetRepository(...);   // = SimpleJpaRepository 인스턴스 (CRUD 실제 구현)

    ProxyFactory pf = new ProxyFactory();
    pf.setInterfaces(repositoryInterface, Repository.class, TransactionalProxy.class);
    pf.setTarget(target);
    pf.addAdvice(implementationInterceptor);         // (a) 커스텀 프래그먼트
    pf.addAdvice(queryExecutorMethodInterceptor);    // (b) 쿼리 메서드 라우팅
    return pf.getProxy();   // ← JDK 동적 프록시
}

여기서 눈여겨볼 것은 target이미 존재하는 클래스 SimpleJpaRepository의 인스턴스라는 점이다. save, findById, delete 같은 CRUD는 전부 이 인스턴스가 실제로 수행한다.

프록시에 메서드 호출이 들어오면 우선순위대로 판정된다.

proxy.someMethod()
  ├─ (a) 커스텀 프래그먼트에 있는 메서드?      → 사용자 구현체로
  ├─ (b) 쿼리 메서드(선언만 있고 base엔 없음)?  → RepositoryQuery.execute()
  └─ (c) 그 외(save/findById/delete...)         → target(SimpleJpaRepository)에 위임

핵심은 (b)의 라우팅 테이블이 부트스트랩 때 이미 채워져 있다는 것이다. QueryExecutorMethodInterceptor는 생성 시점에 인터페이스의 모든 쿼리 메서드를 순회하며 Map<Method, RepositoryQuery>를 구축해 둔다. 런타임 호출은 이 맵에서 객체를 꺼내 execute(args)를 부르는 사실상 O(1) 조회일 뿐, 매 호출마다 메서드 이름을 다시 파싱하는 게 아니다.

4. 쿼리는 어디서 오나 — QueryLookupStrategy

RepositoryQuery가 어떻게 결정되는지가 QueryLookupStrategy의 몫이다. 전략은 셋이고 기본값은 CREATE_IF_NOT_FOUND다.

전략동작
USE_DECLARED_QUERY@Query 또는 named query만 사용. 없으면 예외
CREATE메서드 이름을 파싱해 쿼리 파생. @Query는 무시
CREATE_IF_NOT_FOUND(기본)선언 쿼리(@Query/named)를 먼저 찾고, 없으면 이름 파싱으로 파생

선언 쿼리는 SimpleJpaQuery(JPQL) 또는 NativeJpaQuery(nativeQuery=true)로 감싸고, 이름 파싱으로 파생된 쿼리는 PartTreeJpaQuery로 감싼다. 이 판정 역시 부트스트랩 때 한 번 일어나 맵에 저장된다.

5. 메서드 이름 파싱: PartTree

CREATE 경로에서는 PartTree가 메서드 이름을 세 조각으로 쪼갠다.

findByEmailAndAgeGreaterThanOrderByCreatedAtDesc
└subject┘└──────── predicate ────────┘└── order ──┘

subject   : find|read|get|query|count|exists|delete (+Distinct, +First/Top N)
predicate : "By" 뒤. Or 로 OrPart 분해 → 각 OrPart를 And 로 Part 분해
Part      : 프로퍼티 + 키워드(GreaterThan/Like/Between/In/IsNull/After...)
order     : OrderBy 프로퍼티 + Asc/Desc

Part는 프로퍼티명을 엔티티 메타모델에 대고 검증한다. AgeGreaterThan이면 프로퍼티 age + 키워드 GREATER_THAN으로 해석하는 식이다. 카멜케이스 경계로 프로퍼티를 먼저 시도하고, 실패하면 점진적으로 짧게 잘라 address.city 같은 중첩 프로퍼티로 폴백한다.

파생된 PartTree는 실행 시 JPA Criteria API(CriteriaBuilder)로 조립돼 TypedQuery가 된다. 문자열 JPQL을 이어 붙이는 게 아니라 Criteria 트리로 만들어진다는 점도 기억해 둘 만하다.

6. 오타는 런타임이 아니라 기동 시점에 터진다

파싱과 프로퍼티 검증이 부트스트랩 때 일어난다는 사실은 실무에서 아주 유용한 성질로 이어진다. 존재하지 않는 프로퍼티를 참조하는 쿼리 메서드는 그 메서드를 호출하지 않아도 앱이 뜨지 않는다.

interface UserRepository extends JpaRepository<User, Long> {
    List<User> findByEmial(String email);   // 오타: emial
}
// 애플리케이션 기동 중:
// org.springframework.data.mapping.PropertyReferenceException:
//   No property 'emial' found for type 'User'
// → 컨텍스트 로딩 실패. 이 메서드를 호출하지 않아도 앱이 안 뜬다.

이 조기 실패(fail-fast)는 "파싱은 매 호출마다 일어난다"는 오해를 깨는 직접 증거다. 만약 런타임 파싱이라면 오타는 첫 호출 때까지 잠복해 있어야 한다. 하지만 실제로는 기동 로그에서 바로 드러난다.

마지막으로 반환 타입 처리 한 가지. RepositoryQuery.execute()가 돌려준 원시 결과(대개 List)는 QueryExecutionResultHandler가 메서드 선언 타입으로 변환한다 — Optional, 단건, Stream, Page, Slice 등. 특히 Page를 선언하면 total count를 위한 별도 count 쿼리가 추가로 나가고, Slicelimit + 1로 조회해 다음 페이지 존재 여부만 판정한다. 반환 타입 하나로 쿼리 개수가 달라진다는 얘기다.

7. 정리

전체 흐름을 한 장으로 요약하면 이렇다.

[부트스트랩]  인터페이스 스캔 → JpaRepositoryFactoryBean 등록
                 → getRepository(): SimpleJpaRepository target + ProxyFactory
                 → QueryExecutorMethodInterceptor 생성 시 Map<Method,RepositoryQuery> 구축
                     (QueryLookupStrategy: @Query? 아니면 PartTree 파생 — 프로퍼티 검증)
[런타임 호출]  proxy.findByEmail(x)
                 → 맵에서 RepositoryQuery 조회 → Criteria/JPQL 실행 → ResultHandler 변환

핵심 한 줄: 리포지토리 빈은 구현 바이트코드가 아니라 위임을 나누는 프록시이고, 쿼리 메서드의 무거운 일(파싱·검증·쿼리 객체 생성)은 전부 앱 기동 때 끝나 있다.

더 파고들 만한 주제는 두 가지다. Page 반환 시 나가는 count 쿼리의 최적화(@Query(countQuery=...))와, 커스텀 프래그먼트 조합(UserRepositoryCustom + UserRepositoryImpl)의 우선순위를 다루는 RepositoryComposition.

참고 자료

  • Spring Data Commons 소스: RepositoryFactorySupport, QueryExecutorMethodInterceptor, repository.query.QueryLookupStrategy, repository.query.parser.PartTree
  • Spring Data JPA 소스: JpaRepositoryFactory, SimpleJpaRepository, repository.query.PartTreeJpaQuery
  • Spring Data JPA Reference — Query Methods / Query Lookup Strategies / Defining Query Methods

0개의 댓글