ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring Data JPA Custom Query Method는 왜 SimpleJpaRepository의 @Transactional을 상속받지 못할까
    Development/Spring 2026. 5. 4. 00:45

    TL;DR

    • SimpleJpaRepository에는 클래스 레벨 @Transactional(readOnly = true)와 쓰기 메서드(save, delete*)에 메서드 레벨 @Transactional이 붙어 있다.
    • 이 어노테이션들은 SimpleJpaRepository에 실제로 정의된 메서드를 호출할 때만 적용된다.
    • findByName(...), @Query("...") 같은 사용자 정의(custom) query method는 SimpleJpaRepository에 시그니처 자체가 없기 때문에, Spring Data가 사용하는 RepositoryAnnotationTransactionAttributeSource의 fallback 경로에서 조기 종료되어 트랜잭션 어트리뷰트가 매칭되지 않는다.
    • Repository proxy에 들러붙는 또 다른 interceptor인 TransactionInterceptor 역시 같은 이유로 매칭에 실패한다 (자세한 메커니즘은 본문 §3에서 설명).
    • 결과: custom query method는 별도 선언이 없는 한 @Transactional이 적용되지 않는다.

    1. 배경: Repository Proxy에는 두 개의 TransactionInterceptor가 있다

    Spring Data JPA Repository는 JDK Proxy로 만들어진 advice chain을 가진다. 이 chain에 들어가는 트랜잭션 관련 advice는 두 가지 경로로 구성된다.

    Path A. TransactionInterceptor

    @EnableTransactionManagement이 import하는 ProxyTransactionManagementConfiguration에서 컨테이너 기동 시 한 번 만들어진다.

    // AbstractTransactionManagementConfiguration 에서 설정
    @Bean
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
    public TransactionAttributeSource transactionAttributeSource() {
    	// Accept protected @Transactional methods on CGLIB proxies, as of 6.0
    	AnnotationTransactionAttributeSource tas = new AnnotationTransactionAttributeSource(false);
    	// Apply default rollback rule, as of 6.2
    	if (this.enableTx != null && this.enableTx.getEnum("rollbackOn") == RollbackOn.ALL_EXCEPTIONS) {
    		tas.addDefaultRollbackRule(RollbackRuleAttribute.ROLLBACK_ON_ALL_EXCEPTIONS);
    	}
    	return tas;
    }
    
    // ProxyTransactionManagementConfiguration 에서 설정
    @Bean
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
    public TransactionInterceptor transactionInterceptor(TransactionAttributeSource transactionAttributeSource) {
    	TransactionInterceptor interceptor = new TransactionInterceptor();
    	interceptor.setTransactionAttributeSource(transactionAttributeSource);
    	if (this.txManager != null) {
    		interceptor.setTransactionManager(this.txManager);
    	}
    	return interceptor;
    }
    
    // ProxyTransactionManagementConfiguration 에서 설정
    @Bean(name = TransactionManagementConfigUtils.TRANSACTION_ADVISOR_BEAN_NAME)
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
    public BeanFactoryTransactionAttributeSourceAdvisor transactionAdvisor(
    		TransactionAttributeSource transactionAttributeSource, TransactionInterceptor transactionInterceptor) {
    
    	BeanFactoryTransactionAttributeSourceAdvisor advisor = new BeanFactoryTransactionAttributeSourceAdvisor();
    	advisor.setTransactionAttributeSource(transactionAttributeSource);
    	advisor.setAdvice(transactionInterceptor);
    	if (this.enableTx != null) {
    		advisor.setOrder(this.enableTx.<Integer>getNumber("order"));
    	}
    	return advisor;
    }
    • 모든 빈에 적용 가능. TransactionAttributeSourcePointcut 이 매칭되는 Bean/Method 에만 advice 가 끼어든다.
    • 매칭 조건: 메서드 또는 (선언/타깃) 클래스에 @Transactional 이 발견되는가.

     

    Path B. Per-Repository TransactionInterceptor

     각 Repository 빈이 만들어질 때 TransactionalRepositoryProxyPostProcessor 가 추가한다.

    @Override
    public void postProcess(ProxyFactory factory, RepositoryInformation repositoryInformation) {
    
    	TransactionInterceptor transactionInterceptor = new TransactionInterceptor();
    	transactionInterceptor.setTransactionAttributeSource(
    			new RepositoryAnnotationTransactionAttributeSource(repositoryInformation, enableDefaultTransactions));
    	transactionInterceptor.setTransactionManagerBeanName(transactionManagerName);
    	transactionInterceptor.setBeanFactory(beanFactory);
    	transactionInterceptor.afterPropertiesSet();
    	factory.addAdvice(transactionInterceptor);
    }
    • Repository proxy마다 새 인스턴스가 만들어진다.
    • 핵심: 표준 AnnotationTransactionAttributeSource가 아니라 **RepositoryAnnotationTransactionAttributeSource**가 들어간다.

    같은 TransactionInterceptor 클래스지만 인스턴스가 다르고, 들고 있는 TransactionAttributeSource도 다르다. 이게 "@Transactional 마킹 유무에 따라 동작이 달라 보이는" 현상의 정체다.


    2. computeTransactionAttribute()는 언제 호출되나

     TransactionInterceptor가 메서드 호출을 가로챌 때, 어트리뷰트 lookup 이 트리거된다.

    JDK Proxy.invoke()
     └─ TransactionInterceptor.invoke(MethodInvocation)
       └─ TransactionAspectSupport#invokeWithinTransaction()
         └─ getTransactionAttributeSource().getTransactionAttribute(method, targetClass)
           └─ AbstractFallbackTransactionAttributeSource#getTransactionAttribute()
             ├─ attributeCache(MethodClassKey) HIT → 그대로 반환
             └─ MISS → computeTransactionAttribute(method, targetClass) ← 여기
                  └─ 결과를 attributeCache에 put
    
    • Proxy 생성 시점에는 호출되지 않는다. postProcess()는 wiring 만 한다.
    • 런타임에 각 메서드 첫 호출 시 단 1회 실행되고, 이후엔 MethodClassKey 기준으로 캐시된다.
    • 주의: null 반환도 캐시된다. 트랜잭션 어트리뷰트를 찾지 못한 경우, NULL_TRANSACTION_ATTRIBUTE가 캐시에 저장되어 같은 메서드의 두 번째 호출부터는 lookup 자체가 일어나지 않는다. 디버깅 시 "왜 브레이크포인트가 한 번만 걸리지?"의 답이다.

    3. 두 interceptor 모두 왜 custom query method 를 잡지 못하나

     Path A와 Path B를 각각 따져보자.

    3.1. Path B (RepositoryAnnotationTransactionAttributeSource)의 lookup 로직

     표준 AnnotationTransactionAttributeSource에 한 단계가 더 붙은 형태다.

    @Override
    protected TransactionAttribute computeTransactionAttribute(Method method, Class<?> targetClass) {
    	// Don't allow no-public methods as required.
    	if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
    		return null;
    	}
    
    	// The method may be on an interface, but we need attributes from the target class.
    	// If the target class is null, the method will be unchanged.
    	Method specificMethod = ClassUtils.getMostSpecificMethod(method, userClass);
    
    	// If we are dealing with method with generic parameters, find the original method.
    	specificMethod = BridgeMethodResolver.findBridgedMethod(specificMethod);
        
    	TransactionAttribute txAtt = null;
    
    	// (1) user interface 메서드의 @Transactional
    	txAtt = findTransactionAttribute(specificMethod);
    
    	if (txAtt != null) {
    		return txAtt;
    	}
    
    	// (2) declaring class 레벨 @Transactional
    	txAtt = findTransactionAttribute(specificMethod.getDeclaringClass());
    
    	if (txAtt != null) {
    		return txAtt;
    	}
    
    	if (!enableDefaultTransactions) {
    		return null;
    	}
    
    	// (3) 구현 클래스(SimpleJpaRepository)에서 동일 시그니처 lookup
    	Method targetClassMethod = repositoryInformation.getTargetClassMethod(method);
    
    	if (targetClassMethod.equals(method)) {
    		return null;  // ← 핵심 분기
    	}
    
    	// (4) 구현체 메서드의 @Transactional
    	txAtt = findTransactionAttribute(targetClassMethod);
    
    	if (txAtt != null) {
    		return txAtt;
    	}
    
    	// (5) 구현체 클래스의 @Transactional
    	txAtt = findTransactionAttribute(targetClassMethod.getDeclaringClass());
    
    	if (txAtt != null) {
    		return txAtt;
    	}
    
    	return null;
    }

     getTargetClassMethod(method)는 "user interface에 선언된 메서드와 같은 시그니처를 갖는 구현 클래스(SimpleJpaRepository)의 메서드"를 찾는다.

    • 구현체에 동일 메서드가 있으면 다른 Method 객체를 반환 → (4), (5)로 진행 → SimpleJpaRepository의 어노테이션이 적용된다.
    • 없으면 입력 메서드를 그대로 반환 → targetClassMethod.equals(method) == true → null 반환, 종료.

    Custom query method는 후자다. 여기서 Path B는 끝난다.

    3.2. 그럼 Path A는 왜 못 잡나

     여기서 자연스러운 질문이 생긴다. "Path B가 fallback에서 끊긴다 치자. Path A의 표준 AnnotationTransactionAttributeSource도 결국 같은 AbstractFallbackTransactionAttributeSource를 상속하는데, 이건 왜 SimpleJpaRepository 의 클래스 레벨 @Transactional(readOnly = true)를 매칭시키지 못하는가?"

    답은 fallback이 검사하는 타입 계층에 있다.

    표준 fallback 순서는 다음과 같다.

    1. specificMethod (가장 구체적인 메서드)의 어노테이션
    2. specificMethod.getDeclaringClass()의 어노테이션
    3. method (원본 인터페이스 메서드)의 어노테이션
    4. method.getDeclaringClass()의 어노테이션

    핵심은 getMostSpecificMethod(method, targetClass)가 무엇을 반환하느냐다. Repository proxy의 targetClass는 SimpleJpaRepository이지만, custom query method는 SimpleJpaRepository에 시그니처가 없다. 따라서 가장 구체적인 메서드는 user interface의 메서드 그대로다.

    그러면 fallback이 순회하는 대상은:

    1. user interface 메서드 → 어노테이션 없음
    2. user interface (= UserRepository) → 어노테이션 없음
    3. (1과 동일)
    4. (2와 동일)

     UserRepository는 JpaRepository 인터페이스 계층을 상속할 뿐, SimpleJpaRepository 구현 클래스를 상속하지 않는다. 따라서 SimpleJpaRepository의 클래스 레벨 @Transactional(readOnly = true)는 fallback이 닿는 타입 계층 어디에도 들어오지 않는다.

    정리: Path B는 "구현체에 같은 시그니처가 없으면 조기 종료"라는 명시적 분기 때문에, Path A는 "fallback이 인터페이스 계층만 훑고 구현 클래스에 도달하지 못하기 때문에" 매칭에 실패한다. 두 interceptor의 실패 이유는 미묘하게 다르지만, 결과적으로 custom query method는 어느 쪽으로도 트랜잭션 어트리뷰트를 받지 못한다.


    4. 메서드 종류별 @Transactional 상속 여부

    메서드 종류 출처 SimpleJpaRepository의 어트리뷰트 상속

    save, delete* CrudRepository 선언 + SimpleJpaRepository에서 override (메서드 레벨 @Transactional 직접 부여)
    findById, findAll, count, existsById 등 read-only CRUD CrudRepository 선언 + SimpleJpaRepository override (메서드 어노 없음, 클래스 레벨만 적용) ✅ — 클래스 레벨 @Transactional(readOnly = true) 상속
    findByName, @Query("..."), derived query user interface에만 존재
    @Modifying 붙은 custom query user interface에만 존재 ❌ — 별도 @Transactional 없으면 실행 시 InvalidDataAccessApiUsageException: No EntityManager with actual transaction available for current thread - cannot reliably process 'remove'/'persist' call 발생

    5. 왜 이렇게 설계되었나

    @Transactional은 메서드 단위 메타데이터다. 클래스 레벨 어노테이션이 자식 메서드 시그니처에 자동으로 "상속"되지 않는 이유는 명확하다.

    • SimpleJpaRepository의 @Transactional(readOnly = true)는 이 클래스에 실제로 정의된 메서드 호출에 한해 의미를 가진다.
    • Custom query method는 SimpleJpaRepository에 정의된 메서드가 아니다. 이름이 같다는 보장도 없고, 같다 한들 의미가 같다는 보장도 없다.
    • Spring Data가 fallback 경로를 만든 건 CRUD 메서드 override 한정 편의 기능이지, 사용자가 새로 만든 메서드까지 자동으로 묶어주려는 의도가 아니다.

    6. 디버깅 진입 포인트

    코드를 직접 따라가며 검증하고 싶다면 다음 위치에 브레이크포인트를 두면 된다.

    • 컨테이너 기동 시 global interceptor 생성: ProxyTransactionManagementConfiguration#transactionInterceptor
    • Repository proxy 빌드 시 per-repo interceptor 추가: TransactionalRepositoryProxyPostProcessor#postProcess
    • 런타임 어트리뷰트 lookup: AbstractFallbackTransactionAttributeSource#getTransactionAttribute
    • Spring Data 전용 lookup 로직: RepositoryAnnotationTransactionAttributeSource#computeTransactionAttribute

    TransactionInterceptor 인스턴스가 두 개라는 사실은, 위 두 생성 지점에서 System.identityHashCode(this.transactionAttributeSource)를 비교해보면 즉시 확인된다.

    캐시 동작 때문에 lookup 메서드의 브레이크포인트는 메서드당 단 한 번만 걸린다는 점도 기억해두자. 같은 메서드를 재호출하며 디버깅하려면 attributeCache를 직접 비우거나, 다른 메서드 호출로 전환해야 한다.

     

    반응형

    'Development > Spring' 카테고리의 다른 글

    [Spring Boot] logback 설정  (0) 2020.11.10
    [Spring Boot] JSP 설정하기  (0) 2019.12.04
    [Spring Security] CORS 에 대하여  (0) 2019.11.11
    Spring Security 정리  (1) 2019.10.05
    [Spring] @Transactional 속성 정리  (0) 2019.09.06

    댓글

Designed by Tistory.