-
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 순서는 다음과 같다.
- specificMethod (가장 구체적인 메서드)의 어노테이션
- specificMethod.getDeclaringClass()의 어노테이션
- method (원본 인터페이스 메서드)의 어노테이션
- method.getDeclaringClass()의 어노테이션
핵심은 getMostSpecificMethod(method, targetClass)가 무엇을 반환하느냐다. Repository proxy의 targetClass는 SimpleJpaRepository이지만, custom query method는 SimpleJpaRepository에 시그니처가 없다. 따라서 가장 구체적인 메서드는 user interface의 메서드 그대로다.
그러면 fallback이 순회하는 대상은:
- user interface 메서드 → 어노테이션 없음
- user interface (= UserRepository) → 어노테이션 없음
- (1과 동일)
- (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