CS/Spring

Spring Data JPA @Repository 내부 — JpaRepository 계층과 프록시 조립, PartTree까지

dding-shark 2026. 4. 13. 19:57
728x90

Spring Data JPA @Repository 내부 — JpaRepository 계층과 프록시 조립, PartTree까지


들어가며

Spring Data JPA를 처음 만났을 때 가장 낯선 장면은 이거다. 인터페이스 하나만 선언했는데 빈이 만들어지고, 메서드 이름만 바꿔도 SQL이 달라진다. 구현체는 눈에 보이지 않는다. new 키워드도 없고, @Component도 없는데 주입된다. "마법"이라는 말로 넘기기 쉬운 영역인데, 실제로는 정확히 설명되는 메커니즘이다. @EnableJpaRepositories가 스캔한 각 인터페이스에 대해 JpaRepositoryFactoryBeanBeanDefinition으로 등록하고, getObject() 시점에 JDK Dynamic Proxy로 런타임 구현체를 조립해서 반환하는 것뿐이다.

이 숨은 조립 과정을 모르면 자주 밟는 함정이 생긴다. 메서드 이름 오타가 기동 시점에 조용히 지나가고, @Transactional이 예상대로 걸리지 않고, 파생 쿼리가 성능 문제를 일으킬 때 어디서부터 읽어야 할지 모른다. 다음 세 가지는 실무에서 거의 매주 마주친다.

  • JpaRepository를 상속했을 뿐인데 빈이 만들어진다 — 스캔이 아니라 FactoryBean이 프록시를 조립한다
  • 메서드 이름을 그대로 쿼리로 바꿔주는 것 같지만 실패는 기동 시점에 조용히 지나간다QueryLookupStrategy 기본값이 범인이다
  • 같은 클래스 안에서 save()를 호출했더니 트랜잭션이 안 걸린다SimpleJpaRepository@Transactional도 프록시 계약의 예외가 아니다

이 글은 왜 → 계층 → 조립 → 호출 경로 → 쿼리 해석 → 실무 순으로, Spring Boot 3.x / Spring Data JPA 3.x / Java 17+ 기준으로 정리한다.


목차


1) @Repository 인터페이스가 빈이 되는 이유: 스캔이 아니라 팩토리

Spring Data의 리포지토리 인터페이스는 컴포넌트 스캔의 대상이 아니다. 인터페이스에 @Repository를 붙여서 빈이 되는 게 아니고, @EnableJpaRepositories(Spring Boot의 자동 설정이 자동으로 붙여준다)가 스캔 베이스 패키지에서 Repository 마커 인터페이스를 상속한 모든 인터페이스를 찾아 각각에 대해 JpaRepositoryFactoryBean 타입의 BeanDefinition을 하나씩 등록하는 방식이다. 핵심은 이것이다. 빈은 "인터페이스 자체"가 아니라 "그 인터페이스를 프록시로 만들어주는 FactoryBean"이고, 주입 시점에는 FactoryBean.getObject()가 반환한 프록시가 꽂힌다.

이 구분을 한 번에 이해하면 이후 모든 게 간단해진다. 리포지토리 인터페이스는 구현체가 없어도 된다. 구현체는 SimpleJpaRepository가 이미 가지고 있고, 이 인터페이스에만 있는 메서드(파생 쿼리, @Query, 커스텀 Fragment)는 FactoryBean이 조립 시점에 프록시의 Advice 체인으로 꿰어넣는다.

// 사용자 코드는 이것뿐
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    List<User> findByAgeGreaterThan(int age);
}

이 한 줄짜리 선언으로 UserRepository 타입의 빈이 컨테이너에 등록된다. 구현체를 쓴 적이 없다. 스프링이 기동 시점에 프록시를 조립해 꽂아준 결과다. 이 흐름 전체를 뒤집어 보자.


2) JpaRepository 계층: 상속 체인이 아니라 역할 분리

JpaRepository는 "거대한 슈퍼 인터페이스"가 아니라, 역할별로 쪼개진 여러 인터페이스의 조합이다. 상속 체인을 외울 필요는 없지만, 어떤 레이어가 어떤 책임을 지는지는 구분해두면 나중에 커스텀 Fragment를 쓸 때 혼란이 없다.

인터페이스 제공 메서드 책임
Repository<T, ID> (없음, 마커) 리포지토리임을 스프링에 알리는 마커
CrudRepository<T, ID> save, findById, deleteById 저장소 독립적 CRUD
PagingAndSortingRepository<T, ID> findAll(Pageable), findAll(Sort) 페이징·정렬
ListCrudRepository<T, ID> findAll() 반환이 List Spring Data 3.0+, Iterable 대신 List
JpaRepository<T, ID> flush, saveAndFlush, deleteAllInBatch JPA 특화 API

Spring Data 3.0부터는 ListCrudRepository, ListPagingAndSortingRepository가 추가돼서 findAll() 반환 타입을 List로 받을 수 있다. 이전에는 Iterable이 돌아와서 스트림으로 쓰기 번거로웠다.

JpaRepository를 상속하는 순간 이 다섯 인터페이스의 메서드가 전부 딸려 들어온다. 그 메서드들의 실제 구현체는 하나, SimpleJpaRepository다. 파생 쿼리나 @Query로 선언한 메서드만 따로 처리되고, 나머지는 이 구현체로 라우팅된다. §4 프록시 조립에서 어떻게 라우팅되는지 본다.


3) RepositoryFactoryBean: 빈 등록과 프록시 생성의 단일 지점

@EnableJpaRepositories가 스캔을 마치면, 각 리포지토리 인터페이스마다 JpaRepositoryFactoryBean이 하나씩 BeanDefinition으로 등록된다. FactoryBean은 스프링이 오래전부터 제공하는 패턴으로, "빈 자체"가 아니라 "빈을 만드는 공장"을 컨테이너에 넣는 방식이다. 컨테이너는 주입 시점에 FactoryBean.getObject()를 호출해서 실제 객체를 꺼낸다.

JpaRepositoryFactoryBean.getObject()는 내부에 JpaRepositoryFactory를 두고 다음 순서로 프록시를 조립한다. 첫째, SimpleJpaRepository를 타깃 객체로 만든다. 둘째, ProxyFactory에 타깃을 세팅하고, 필요한 Advice(메서드 인터셉터)들을 순서대로 추가한다. 셋째, 인터페이스를 구현하는 JDK Dynamic Proxy를 생성해 반환한다. 이 전체 과정이 단일 지점에서 일어난다. 여기만 이해하면 Spring Data의 나머지는 파생 개념이다.

11_spring-data-jpa-repository-internals-01

이 그림에서 눈여겨볼 점은 BeanDefinition 등록 단계와 getObject() 실행 단계가 분리돼 있다는 것이다. 전자는 @EnableJpaRepositories 처리 시점에, 후자는 해당 리포지토리가 처음 주입되는 시점(보통 ApplicationContext 싱글턴 초기화)에 일어난다. 그래서 리포지토리 인터페이스에 오타가 있어도 BeanDefinition 등록까지는 성공하고, getObject() 시점에 와서야 터진다. 어떤 오류는 이 분리 때문에 기동 시점에 살짝 늦게 나온다.


4) 프록시 조립: Advice 체인의 순서

프록시가 조립될 때 Advice가 추가되는 순서가 실행 순서를 결정한다. 바깥쪽에 있는 Advice가 먼저 실행되고, 가장 안쪽이 타깃(SimpleJpaRepository)이다. Spring Data JPA 3.x에서 리포지토리 프록시의 전형적인 Advice 체인은 대략 이렇다.

메서드 호출
  → ExposeInvocationInterceptor (현재 MethodInvocation을 ThreadLocal에 노출)
    → DefaultMethodInvokingMethodInterceptor (인터페이스 default 메서드 처리)
      → QueryExecutorMethodInterceptor (파생 쿼리 / @Query 라우팅)
        → ImplementationMethodExecutionInterceptor (Fragment 메서드 위임)
          → TransactionInterceptor (@Transactional 처리)
            → SimpleJpaRepository.xxx() (최종 타깃)

핵심은 QueryExecutorMethodInterceptor가 라우팅 분기점이라는 것이다. 호출된 메서드가 파생 쿼리거나 @Query가 붙어 있으면 이 인터셉터가 직접 RepositoryQuery.execute()를 호출해서 결과를 반환한다. 그 경우 타깃인 SimpleJpaRepository는 호출되지 않는다. 반대로 save()처럼 SimpleJpaRepository가 이미 구현한 메서드는 이 인터셉터가 "내가 처리할 메서드가 아니다"라고 판단하고 다음 Advice로 넘긴다. 그러면 TransactionInterceptor를 거쳐 SimpleJpaRepository.save()가 실제로 호출된다. Fragment 메서드는 ImplementationMethodExecutionInterceptor가 잡아 구현체로 위임한다.

여기서 틀리기 쉬운 지점이 하나 있다. 리포지토리 프록시도 결국 JDK Dynamic Proxy다. 같은 리포지토리 안에서 this.save(...)를 호출해도 프록시를 거치지 않으므로 @Transactional이 걸리지 않는다. 실제 상황에서는 리포지토리 안에 메서드를 덧대는 경우가 드물지만(default 메서드 예외), Fragment 구현체 안에서 같은 리포지토리의 다른 메서드를 호출할 때 정확히 같은 함정이 재현된다.


5) QueryExecutorMethodInterceptor: 메서드 호출이 쿼리로 바뀌는 단일 지점

이 인터셉터는 프록시 생성 시점에 인터페이스의 모든 메서드를 훑어 RepositoryQuery 객체를 미리 빌드한다. 기동 시점에 한 번 만들어두고, 이후 메서드 호출마다 해시 테이블 조회로 빠르게 라우팅한다. 그래서 "메서드 이름이 매번 파싱되나?"라는 질문의 답은 아니오, 기동 시점에 한 번뿐이다.

기동 시점에 QueryExecutorMethodInterceptorQueryLookupStrategy를 사용해 각 메서드에 대한 RepositoryQuery를 결정한다. 전략은 세 가지다.

전략 동작 적합한 경우
CREATE 메서드 이름만 보고 파생 쿼리를 만든다. @Query나 Named Query가 있어도 무시 항상 파생 쿼리만 쓰는 팀
USE_DECLARED_QUERY @Query 또는 Named Query만 인정. 없으면 메서드 바인딩 시점(기동 후반)에 실패 @Query만 쓰는 팀 (권장)
CREATE_IF_NOT_FOUND (기본) @Query/Named Query가 있으면 그걸 쓰고, 없으면 메서드 이름 파싱 둘 다 혼용

Spring Boot 자동 설정 기본값은 CREATE_IF_NOT_FOUND다. 편리하지만 함정도 같이 온다. 메서드 이름에 오타를 내도 파생 쿼리 파서가 일단 파싱을 시도하고, 속성 이름을 엔티티에서 못 찾으면 PropertyReferenceException으로 기동이 실패한다. 이건 좋은 동작이다. 문제는 오타가 엔티티에 우연히 존재하는 다른 필드와 매칭될 때다. findByNaame은 실패하지만, findByName으로 오탈자가 수정돼도 실제로 의도한 건 findByNickname이었다면 아무도 못 잡는다. 이 한계는 기동 검증의 영역이 아니라 테스트의 영역이다. §12 기동 시점 검증에서 이 경계를 다시 본다.


6) PartTree: 메서드 이름을 AST로 파싱한다

메서드 이름 파싱은 문자열 치환이 아니다. PartTree라는 AST로 파싱한다. 이 AST는 세 부분으로 나뉜다.

  • Subject: 동작. find, read, get, query, count, exists, delete 중 하나. 수식어(Distinct, First10, Top5)도 여기에 붙는다
  • Predicate: By 이후 조건부. And, Or로 연결된 여러 Part의 트리
  • OrderBySource: OrderBy 이후 정렬 조건

예를 들어 findByNameAndAgeGreaterThanOrderByCreatedAtDesc라는 메서드 이름을 보자. 이건 아래처럼 쪼개진다.

11_spring-data-jpa-repository-internals-02

Part.TypeSIMPLE_PROPERTY, GREATER_THAN, LESS_THAN, BETWEEN, LIKE, IS_NULL, IS_NOT_NULL, IN, CONTAINING 등 수십 개가 있다. 각 타입이 JPQL 연산자와 1:1 대응된다. 여기서 중요한 건, 파서는 속성 이름을 엔티티 메타데이터와 교차 검증한다는 점이다. nameUser 엔티티에 실제로 존재하는지, 타입이 String인지, 중첩 속성(address.city)으로 내려갈 수 있는지까지 본다. 이 단계에서 실패하면 PropertyReferenceException이다.

파서는 greedy하게 가장 긴 속성 이름부터 시도한다. findByAddressZipCodeaddress.zipCode로 해석되지만, Address.zip 필드가 있으면 address.zip.code로도 시도해본다. 언더스코어(findByAddress_ZipCode)를 쓰면 속성 경계를 명시적으로 끊을 수 있다. 모호한 이름이 있을 때 이 문법이 구원이다.


7) PartTreeJpaQuery: AST가 JPQL로 바뀌는 경로

PartTree는 어디까지나 문법 트리다. 여기서 실제 JPQL로 가는 건 PartTreeJpaQuery다. 이 클래스는 PartTree를 받아 JPA Criteria API로 쿼리를 빌드하고, TypedQuery<?>를 만든다. Hibernate가 SQM 트리로 받아 직접 SQL을 생성한다. Criteria API를 거치는 이유는 정렬·페이징·동적 조건(Sort, Pageable, Specification 인자)을 안전하게 합성하기 위해서다.

// 사용자 선언
List<User> findByAgeGreaterThanOrderByCreatedAtDesc(int age, Pageable pageable);

// PartTreeJpaQuery가 내부적으로 생성하는 JPQL (대략)
// SELECT u FROM User u WHERE u.age > ?1 ORDER BY u.createdAt DESC
// + Pageable의 offset/limit은 JPA가 바인딩

여기서 Pageable이 붙으면 PartTreeJpaQuery가 count 쿼리도 자동 생성한다. 이 count 쿼리는 원본 쿼리에서 SELECT 절과 ORDER BY 절을 제거하고 count(*)로 바꾼 것이다. 조인이 복잡하면 count 쿼리가 느려지거나 중복 집계가 나오는데, 이럴 땐 @QuerycountQuery를 명시해 직접 최적화해야 한다.

파생 쿼리의 장점은 "이름만 바꿔도 쿼리가 달라진다"는 점이고, 단점은 이름이 길어지면 읽을 수 없다는 점이다. 경험상 조건이 세 개를 넘으면 @Query 또는 Querydsl로 넘기는 게 낫다. findByNameAndAgeGreaterThanAndEmailContainingAndStatusInOrderByCreatedAtDesc 같은 메서드 이름은 유지보수 부담이 파생 쿼리의 편의를 넘어선다.


8) SimpleJpaRepository: 기본 CRUD 구현체

CrudRepositoryJpaRepository가 선언한 CRUD 메서드의 실제 구현SimpleJpaRepository 하나가 전부 담당한다. save, findById, findAll, delete, count, flush, saveAndFlush, deleteAllInBatch 전부 여기 들어 있다. 구현을 한 번만 읽어보면 Spring Data의 나머지가 친숙해진다.

// SimpleJpaRepository (간소화)
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {

    @Override
    @Transactional
    public <S extends T> S save(S entity) {
        if (entityInformation.isNew(entity)) {
            em.persist(entity);
            return entity;
        } else {
            return em.merge(entity);
        }
    }

    @Override
    public Optional<T> findById(ID id) {
        return Optional.ofNullable(em.find(getDomainClass(), id));
    }
}

여기서 두 가지 지점을 짚는다. 첫째, save는 새 엔티티 판별을 entityInformation.isNew(entity)에 위임한다. 기본 구현은 @Id 필드가 null 또는 primitive의 기본값(0)인지 확인하는 방식이다. 그래서 DB에서 ID를 할당받지 않고 애플리케이션이 ID를 직접 넣어주는 엔티티(예: UUID)는 save를 호출해도 항상 merge 경로로 간다. mergeSELECT를 한 번 더 때리기 때문에 UUID 기반 엔티티에서는 성능 이슈를 만든다. 해결은 엔티티가 Persistable<ID>를 구현해서 isNew()를 직접 재정의하는 것이다.

둘째, SimpleJpaRepository@Transactional(readOnly = true)가 클래스 레벨로 붙어 있다는 점이다. 다음 섹션에서 이 의미를 짚는다.


9) 트랜잭션 계약과 readOnly의 전파

SimpleJpaRepository의 트랜잭션 선언은 이렇게 생겼다. 클래스에 @Transactional(readOnly = true)가 있고, 쓰기 메서드(save, delete 등)에는 메서드 레벨 @Transactional이 덮어쓴다. 결과는 "읽기 메서드는 readOnly = true, 쓰기 메서드는 readOnly = false"다.

문제는 이게 실무에서 거의 항상 무효화된다는 점이다. 서비스 레이어가 거의 반드시 @Transactional을 먼저 열기 때문이다. Spring의 트랜잭션 전파 기본값은 REQUIRED고, 이미 활성 트랜잭션이 있으면 바깥 트랜잭션에 참여한다. 참여하는 쪽의 readOnly는 무시된다. 즉, OrderService@Transactional(기본 readOnly = false)로 열어놓고 그 안에서 userRepository.findById(...)를 호출하면, 이 findById는 바깥의 쓰기 트랜잭션 안에서 실행된다. readOnly = true라는 힌트는 하이버네이트에 전달되지 않는다.

경로 활성 트랜잭션 readOnly 값
서비스 @Transactional → 리포지토리 읽기 서비스의 쓰기 트랜잭션 false (서비스 쪽 기준)
서비스 @Transactional(readOnly = true) → 리포지토리 읽기 서비스의 읽기 트랜잭션 true
서비스 @Transactional → 리포지토리 쓰기 서비스의 쓰기 트랜잭션 false
서비스 없이 리포지토리 직접 호출 리포지토리가 새로 연다 메서드 기준

그래서 서비스 레이어에서 읽기 전용 메서드에 @Transactional(readOnly = true)를 명시적으로 선언하는 관행이 실무의 기본이다. 리포지토리의 readOnly는 "서비스 없이 리포지토리만 쓰는 경우"의 안전 장치일 뿐, 일반적인 계층 구조에서는 효과가 없다. 여기서 틀리기 쉽다.


10) @Query · Named Query · 파생 쿼리 우선순위

QueryLookupStrategyCREATE_IF_NOT_FOUND(기본값)일 때 우선순위는 명시적이다.

  1. @Query 어노테이션 — 메서드에 직접 붙은 @Query(value = "...")
  2. Named Query@NamedQuery가 엔티티에 있고 이름이 {도메인}.{메서드명}과 일치
  3. 파생 쿼리 — 메서드 이름 파싱 결과
public interface UserRepository extends JpaRepository<User, Long> {

    // 1순위: @Query
    @Query("SELECT u FROM User u WHERE u.email = :email AND u.deleted = false")
    Optional<User> findByEmail(String email);

    // 3순위: 파생 쿼리 (위 메서드에 @Query가 없으면 이 방식으로 해석됨)
    // Optional<User> findByEmail(String email);

    // 네이티브 쿼리
    @Query(value = "SELECT * FROM users WHERE email = ?1", nativeQuery = true)
    Optional<User> findByEmailNative(String email);
}

@Query를 붙이면 파서는 메서드 이름을 파싱하지 않는다. 그래서 @Query가 붙은 메서드는 이름에 오타가 있어도 기동이 실패하지 않는다. 이 특성이 @Query 전략의 장점이자 함정이다. 장점은 자유로운 이름이고, 함정은 기동 시점 안전망이 사라진다는 것이다.

Named Query는 실무에서 거의 안 쓰이지만, 레거시 프로젝트에서 발견되면 "메서드명과 일치하는 @NamedQuery가 있는지"를 먼저 확인해야 한다. 파생 쿼리로 보이는데 실제로는 Named Query가 실행되고 있는 경우가 있다. Spring Data는 이 라우팅을 로그로 남기지 않으므로 spring.jpa.show-sql=true로 실제 SQL을 찍어 봐야 한다.


11) 커스텀 구현: Fragment 패턴이 정답인 이유

파생 쿼리와 @Query로는 표현하기 어려운 로직(복잡한 동적 쿼리, Querydsl, JDBC Template 직접 호출)을 섞고 싶을 때 Fragment 패턴을 쓴다. 이 패턴의 본질은 "리포지토리 인터페이스를 여러 인터페이스의 조합으로 쪼개고, 각 조각에 대해 구현체를 별도 제공"하는 방식이다.

// Fragment 인터페이스
public interface UserRepositoryCustom {
    List<User> searchByDynamicConditions(UserSearchCriteria criteria);
}

// Fragment 구현체 — 네이밍 규칙: {인터페이스}Impl
public class UserRepositoryCustomImpl implements UserRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    public UserRepositoryCustomImpl(JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    @Override
    public List<User> searchByDynamicConditions(UserSearchCriteria criteria) {
        return queryFactory.selectFrom(user)
            .where(
                criteria.getName() != null ? user.name.eq(criteria.getName()) : null,
                criteria.getMinAge() != null ? user.age.goe(criteria.getMinAge()) : null
            )
            .fetch();
    }
}

// 메인 리포지토리가 Fragment를 상속
public interface UserRepository extends JpaRepository<User, Long>, UserRepositoryCustom {
    // 파생 쿼리, @Query, CRUD는 여전히 여기서 받는다
}

핵심은 Impl 접미사 규칙이다. Spring Data는 UserRepositoryCustom 인터페이스의 구현체를 찾을 때 기본적으로 UserRepositoryCustomImpl이라는 빈을 찾는다. 이 접미사는 @EnableJpaRepositories(repositoryImplementationPostfix = "Impl")로 바꿀 수 있지만 거의 건드리지 않는다.

이 구조가 왜 정답이냐는 질문에는 두 가지 답이 있다. 첫째, 리포지토리 인터페이스 하나에 모든 호출이 모인다. 서비스 레이어는 UserRepository 하나만 주입하면 되고, 그 뒤로 파생 쿼리·@Query·커스텀 로직이 전부 같은 타입으로 호출된다. 둘째, 프록시 조립 시점에 Fragment 구현체가 Advice 체인에 합류한다. Fragment 메서드 호출은 QueryExecutorMethodInterceptor가 "내가 처리할 메서드가 아니다"라고 판단하면 다음 Advice로 흘려, 최종적으로 Fragment 구현체의 메서드를 호출한다. 트랜잭션, AOP, 테스트 프레임워크의 관점에서 Fragment는 리포지토리 프록시의 일부로 보인다.


12) 기동 시점 검증: 파싱 실패를 앞당기는 법

파생 쿼리의 안전망은 "기동 시점에 메서드 이름을 엔티티 속성과 교차 검증한다"는 점이다. 하지만 앞서 봤듯 @Query가 붙으면 이 검증은 건너뛴다. 게다가 @Query의 JPQL 자체는 기본적으로 첫 실행 시점까지 파싱되지 않는다. 즉 오타가 들어간 JPQL은 프로덕션에서 해당 API가 처음 호출될 때 터진다.

Spring Data JPA는 이 늦은 검증을 앞당길 수 있는 옵션을 제공한다.

Spring Data JPA에는 @Query 전체를 기동 시점에 일괄 검증하는 단일 플래그가 없다. bootstrap-mode는 리포지토리 프록시 생성 시점을 조절할 뿐, 쿼리 검증을 앞당기지 않는다.

spring:
  data:
    jpa:
      repositories:
        bootstrap-mode: default  # default | deferred | lazy

Hibernate는 전통적으로 SessionFactory 빌드 시점에 @NamedQuery를 파싱한다. Hibernate 6는 새 HQL 파서로 에러 타이밍과 메시지가 개선됐다. Spring Data의 @QueryQueryMethod 생성 시점(기동 직후)에 기본 파싱을 시도한다. 다만 파라미터 바인딩 검증은 실행 시점까지 미뤄진다. 그래서 @Query("SELECT u FROM User u WHERE u.naame = :name") 같은 오타는 기동 시점에는 속성 검증이 없어 지나갈 수 있다.

이 틈을 메꾸는 건 결국 테스트다. @DataJpaTest 슬라이스 테스트로 각 리포지토리 메서드가 최소 한 번은 실제로 실행되게 하면, CI 시점에 오타가 드러난다. 리포지토리 테스트를 "커버리지 채우기용"이 아니라 "JPQL 파서 돌리기용"으로 여겨야 한다. 파생 쿼리는 기동 시점에, @Query는 테스트 시점에 잡는다 — 이 경계를 명확히 두면 디버깅 비용이 크게 준다.


13) 실무에서 이렇게 읽고 쓴다

  • 인터페이스가 빈이 되는 경로를 풀고 싶으면 JpaRepositoryFactoryBean.getObject()부터 디버거로 걸어본다. ProxyFactory에 Advice가 추가되는 순서가 보이면 나머지는 전부 설명된다
  • 파생 쿼리는 조건 3개까지만. 그 이상은 @Query 또는 Querydsl로 넘긴다. 메서드 이름이 한 줄을 넘기면 이미 선을 넘었다
  • 서비스 레이어의 읽기 메서드는 @Transactional(readOnly = true)를 명시적으로 선언. SimpleJpaRepositoryreadOnly는 전파 규칙 때문에 대부분 무효화된다
  • UUID 기반 엔티티는 Persistable<ID> 구현. savemerge 경로로 빠져 SELECT 한 번이 더 나가는 걸 막는다
  • @Query@DataJpaTest로 반드시 한 번 실행. 기동 시점 검증이 놓치는 파라미터 바인딩 오류를 테스트가 잡는다

14) 한 줄 정리

Spring Data JPA 리포지토리는 마법이 아니라 FactoryBean이 조립하는 JDK Dynamic Proxy다. QueryExecutorMethodInterceptor가 호출을 파생 쿼리·@Query·SimpleJpaRepository로 라우팅하고, 메서드 이름은 PartTree AST로 파싱된 뒤 PartTreeJpaQuery를 거쳐 JPQL이 된다. 프록시 계약은 리포지토리에도 예외 없이 적용되므로, self-invocation과 트랜잭션 전파는 서비스 레이어와 동일한 규칙으로 읽어야 한다.


태그: Spring Data JPA, JpaRepository, RepositoryFactoryBean, SimpleJpaRepository, PartTree, QueryExecutorMethodInterceptor, @Transactional, Repository Fragment, QueryLookupStrategy, Spring Boot 3

728x90