Spring Data JPA Repository 내부 동작 — Proxy, Fragment, QueryLookupStrategy, 그리고 PartTree
Spring Data JPA를 처음 쓴 백엔드 개발자가 한 번쯤은 멈추는 지점이 있습니다. "분명 인터페이스만 만들었는데
findByEmailAndStatus가 어떻게 동작하지?" 이 글은 Spring Data JPA 4.0 / Spring Data Commons 4.0 기준으로,MemberRepository.findByEmail("a@b.c")가 SQL이 되어 데이터베이스에 닿기까지의 과정을 RepositoryFactory · ProxyFactory · QueryLookupStrategy · PartTree · SimpleJpaRepository의 다섯 조각으로 풀어 봅니다.
MemberRepository extends JpaRepository<Member, Long> 한 줄로 백엔드 개발자는 16개의 CRUD 메서드와 findByEmail, existsByEmail, countByStatus 같은 파생 쿼리를 거의 무료로 얻습니다. 메서드 본문을 한 줄도 쓰지 않고 말입니다. 이건 마법이 아니라 다음 다섯 가지의 합입니다.
- 컴포넌트 스캔 시점에 Repository 인터페이스를 찾아
FactoryBean으로 등록한다. FactoryBean은 부팅 시 한 번 JDK Dynamic Proxy 를 만들어 인터페이스의 모든 호출을 가로챈다.- 들어온 호출은 일정한 advice 체인 을 거치며 책임을 분배받는다.
- CRUD 메서드는 베이스 클래스인
SimpleJpaRepository로, 파생 쿼리는QueryLookupStrategy가 만든RepositoryQuery로 라우팅된다. - 파생 쿼리는
PartTree가 메서드 이름을 트리로 파싱해 만든 JPQL이다.
이 글은 다섯 단계로 그 흐름을 따라갑니다.
- Repository 인터페이스가 어떻게 빈으로 바뀌는지(컴포넌트 스캔과
RepositoryFactoryBeanSupport) - 부팅 시
RepositoryFactorySupport.getRepository가 만드는 프록시의 구조 - 프록시 안의 advice 체인 — 어떤 순서로 누가 처리를 가로채는가
- 쿼리 메서드는 누가 만드는가 —
JpaQueryLookupStrategy의 세 가지 모드와PartTree의 메서드 이름 파싱 - CRUD 메서드는 누가 실행하는가 —
SimpleJpaRepository의 베이스 구현
각 단계마다 실제 클래스 이름과 호출 흐름을 따라가면서, 마지막에는 단일 호출 한 번이 어떤 경로를 거치는지 한 장으로 정리합니다.
큰 그림 — 인터페이스 한 줄에서 SQL까지
먼저 사용자 코드부터 봅시다. 우리가 쓰는 코드는 보통 이 정도입니다.
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
long countByStatus(Status status);
}
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
@Transactional(readOnly = true)
public Member getByEmail(String email) {
return memberRepository.findByEmail(email)
.orElseThrow();
}
}
memberRepository는 무엇이 주입되는 걸까요. 디버거로 찍어 보면 class com.sun.proxy.$Proxy123처럼 보이는 JDK 동적 프록시입니다. MemberRepository 인터페이스를 구현하지만 우리가 작성한 클래스는 아닙니다. Spring이 부팅 시점에 이 프록시를 만들어 컨테이너에 넣어 두고, @Autowired/@RequiredArgsConstructor 시점에 그것이 주입된 것입니다.
이 한 줄짜리 호출이 어떤 경로를 거치는지를 먼저 한눈에 봅시다.
flowchart TB
A[memberRepository.findByEmail email] --> B[JDK Dynamic Proxy]
B --> C[Advice Chain]
C --> D[QueryExecutorMethodInterceptor]
D --> E[RepositoryQuery instance]
E --> F[PartTreeJpaQuery built from method name]
F --> G[JPQL select ... where m.email = ?1]
G --> H[EntityManager createQuery getResultList]
H --> I[Hibernate JDBC SQL]
CRUD 메서드(예: save, findById)라면 같은 advice 체인을 거치되, QueryExecutorMethodInterceptor 다음에 위치한 ImplementationMethodExecutionInterceptor가 SimpleJpaRepository의 메서드로 라우팅합니다. 본질은 같습니다 — 프록시가 호출을 받아 advice 체인을 따라가며, 메서드의 종류에 따라 다른 종착지(쿼리 메서드 vs 베이스 구현 vs 사용자 정의 fragment)로 분기합니다.
1단계 — 인터페이스가 어떻게 빈이 되는가
우리는 MemberRepository를 인스턴스화한 적이 없습니다. 그렇다면 누가 만들어 컨테이너에 넣었을까요. 출발점은 @EnableJpaRepositories(Spring Boot라면 자동 구성으로 켜진)가 등록하는 JpaRepositoriesRegistrar입니다. 이 등록자는 다음 일을 합니다.
basePackages또는 메인 클래스 패키지를 시작점으로 컴포넌트 스캔을 수행한다.Repository인터페이스를 상속한 모든 인터페이스를 찾는다.- 발견된 각 인터페이스마다
JpaRepositoryFactoryBean을 빈으로 등록한다 — 인터페이스 자체를 빈으로 등록하는 게 아닙니다.
JpaRepositoryFactoryBean은 Spring의 FactoryBean<T> 메커니즘을 이용한 빈 정의입니다. FactoryBean은 컨테이너가 getObject()를 호출했을 때 실제 빈을 만들어 돌려주는 "빈을 만드는 빈"입니다. 즉 컨테이너에는 두 종류의 객체가 있습니다.
&memberRepository—JpaRepositoryFactoryBean자체. 빈을 만드는 공장.memberRepository—JpaRepositoryFactoryBean.getObject()가 만들어 캐시한 프록시. 우리가 주입받는 것.
JpaRepositoryFactoryBean은 RepositoryFactoryBeanSupport를 상속합니다. 부모인 RepositoryFactoryBeanSupport.afterPropertiesSet()이 호출되는 시점이 핵심입니다.
public void afterPropertiesSet() {
this.factory = createRepositoryFactory();
// ... factory에 EntityManager, ProjectionFactory, listener 등 주입
this.repository = Lazy.of(() -> this.factory.getRepository(repositoryInterface, ...));
}
여기서 createRepositoryFactory()가 우리가 다음 단계에서 볼 JpaRepositoryFactory 를 만들고, 그 getRepository(...)가 실제 프록시를 만듭니다. 즉 부팅 시점에 인터페이스마다 한 번씩 프록시 인스턴스가 생성되고, 그 후로는 같은 프록시를 재사용합니다.
2단계 — JpaRepositoryFactory.getRepository가 만드는 프록시의 구조
JpaRepositoryFactory는 RepositoryFactorySupport를 상속합니다. JPA에 한정된 부분은 다음 세 곳입니다.
// 베이스 클래스를 SimpleJpaRepository로 고정
protected Class<?> getRepositoryBaseClass(RepositoryMetadata metadata) {
return SimpleJpaRepository.class;
}
// 베이스 클래스의 인스턴스를 만들어 target으로 넘김
protected JpaRepositoryImplementation<?, ?> getTargetRepository(
RepositoryInformation information, EntityManager entityManager) {
JpaEntityInformation<?, Serializable> entityInformation =
getEntityInformation(information.getDomainType());
return getTargetRepositoryViaReflection(
information, entityInformation, entityManager);
}
// 쿼리 메서드를 어떻게 해석할지 결정하는 전략
protected Optional<QueryLookupStrategy> getQueryLookupStrategy(
@Nullable QueryLookupStrategy.Key key,
QueryMethodEvaluationContextProvider evaluationContextProvider) {
return Optional.of(JpaQueryLookupStrategy.create(
entityManager, queryMethodFactory, key,
evaluationContextProvider, queryRewriterProvider, escapeCharacter));
}
세 책임이 명확합니다. 베이스 구현은 SimpleJpaRepository로 고정하고, 그 인스턴스를 리플렉션으로 만들어 advice 체인의 target에 둔다. 쿼리 메서드 해석은 JpaQueryLookupStrategy에 위임한다.
부모인 RepositoryFactorySupport.getRepository(...)가 실제 프록시를 만듭니다. 핵심은 ProxyFactory(Spring AOP의 그것)를 사용한다는 점입니다.
ProxyFactory result = new ProxyFactory();
result.setTarget(target); // SimpleJpaRepository 인스턴스
result.setInterfaces(repositoryInterface, // MemberRepository
Repository.class,
TransactionalProxy.class);
// ... advice 체인을 순서대로 추가
T repository = (T) result.getProxy(classLoader);
setInterfaces에 repositoryInterface 외에 Repository.class와 TransactionalProxy.class를 함께 넣는 것이 의미가 있습니다. TransactionalProxy는 마커 인터페이스로, 트랜잭션 인프라(AbstractFallbackTransactionAttributeSource)가 이 프록시를 보고 "트랜잭션 메서드는 인터페이스가 아닌 target 클래스에서 찾아야 한다"고 판단합니다. SimpleJpaRepository의 @Transactional이 그래서 작동합니다.
3단계 — Advice 체인의 순서
RepositoryFactorySupport.getRepository가 추가하는 advice는 정해진 순서가 있고, 그 순서가 동작 방식의 절반을 결정합니다. 4.x 기준으로 다음과 같이 추가됩니다(주요한 것만).
flowchart TB
A[Proxy invocation] --> B[MethodInvocationValidator]
B --> C[ExposeMetadataInterceptor optional]
C --> D[RepositoryProxyPostProcessors]
D --> E[DefaultMethodInvokingMethodInterceptor]
E --> F[QueryExecutorMethodInterceptor]
F --> G[ImplementationMethodExecutionInterceptor]
G --> H[Target SimpleJpaRepository]
각각이 어떤 호출을 처리하고, 어떤 호출을 통과시키는지를 차례로 봅시다.
MethodInvocationValidator
@Nullable / @NonNull 같은 nullness 어노테이션을 검증합니다. 호출 인자가 null이면 안 되는 자리에 null이 들어왔거나, 반환값이 null이면 안 되는 메서드가 null을 돌려줬을 때 EmptyResultDataAccessException 또는 IllegalArgumentException을 던집니다. JPA에 한정된 동작이 아닙니다.
ExposeMetadataInterceptor
exposeMetadata 옵션이 켜져 있을 때만 동작합니다. 호출 중인 Repository 메서드의 메타데이터를 ThreadLocal에 넣어, target(SimpleJpaRepository) 안에서 RepositoryMethodContext.getContext()로 꺼내 볼 수 있게 합니다. 보통은 비활성입니다.
RepositoryProxyPostProcessor 들
여기에 트랜잭션 인터셉터 가 끼어듭니다. Spring Data JPA가 등록하는 TransactionalRepositoryProxyPostProcessor가 TransactionInterceptor를 추가하는 곳입니다. 그래서 SimpleJpaRepository의 @Transactional(readOnly = true)가 호출 시점에 실제로 트랜잭션을 열고 닫습니다. CrudMethodMetadataPostProcessor 같은 다른 후처리기도 여기에 함께 들어갑니다.
DefaultMethodInvokingMethodInterceptor
Java 8의 default 메서드를 처리합니다. Repository 인터페이스에 default 메서드를 직접 구현했다면 이 인터셉터가 메서드 핸들로 그 default 구현을 호출합니다. 우리가 보통 의식하지 않는 동작입니다.
QueryExecutorMethodInterceptor — 쿼리 메서드의 분기점
여기가 첫 번째 분기점입니다. 호출된 메서드가 쿼리 메서드(예: findByEmail, countByStatus, @Query가 붙은 메서드)인지 확인하고, 맞다면 미리 만들어 둔 RepositoryQuery 인스턴스에 위임합니다. 쿼리 메서드가 아니라면 다음 인터셉터로 통과시킵니다.
이 인터셉터는 부팅 시점에 한 번 작업을 합니다. Repository 인터페이스의 모든 메서드를 훑어 쿼리 메서드 후보를 찾고, 각각에 대해 QueryLookupStrategy.resolveQuery(...)를 호출해 RepositoryQuery를 만들어 Map<Method, RepositoryQuery>에 넣어 둡니다. 호출 시점에는 단순한 맵 조회로 끝납니다 — 쿼리 파싱은 부팅 시 한 번만 이루어집니다. JPQL 컴파일이 매 호출마다 일어나지 않는 이유입니다.
ImplementationMethodExecutionInterceptor — 메서드의 종착지를 정한다
마지막 인터셉터입니다. 여기서 RepositoryComposition을 통해 메서드의 최종 종착지가 결정됩니다.
RepositoryComposition은 순서 있는 fragment 목록 입니다. 각 fragment는 메서드 시그니처들을 노출하고, composition은 호출된 메서드를 가지고 fragment를 순서대로 훑어 첫 번째로 일치하는 구현을 찾습니다.
기본 fragment 구성은 다음과 같습니다(우선순위 순서대로).
- 사용자 정의 Custom 구현 —
MemberRepositoryCustom+MemberRepositoryImpl패턴 JpaRepository/CrudRepository베이스 구현 —SimpleJpaRepositoryQuerydslPredicateExecutor같은 기능적 fragment(있을 때)
같은 메서드 시그니처가 여러 fragment에 있다면 더 앞쪽(우선순위 높은) fragment가 이깁니다. 이것이 Custom 구현이 베이스 메서드를 덮어쓸 수 있는 이유입니다 — fragment 목록의 가장 앞에 들어가기 때문입니다.
4단계 — 쿼리 메서드는 누가 만드는가
이제 핵심으로 들어갑니다. findByEmail은 어떻게 SQL이 되는지. 답은 JpaQueryLookupStrategy 와 그 안의 세 가지 전략입니다.
세 가지 전략 — Key enum
QueryLookupStrategy.Key는 enum입니다. @EnableJpaRepositories(queryLookupStrategy = ...)로 바꿀 수 있습니다.
| Key | 의미 |
|---|---|
CREATE |
메서드 이름만으로 쿼리를 만든다. @Query나 named query는 무시한다. |
USE_DECLARED_QUERY |
@Query 또는 named query만 본다. 메서드 이름 파싱은 안 한다. |
CREATE_IF_NOT_FOUND |
선언된 쿼리를 먼저 찾고, 없으면 메서드 이름으로 만든다. 기본값. |
JpaQueryLookupStrategy.create(...)는 Key에 따라 다음 세 nested 클래스 중 하나를 만듭니다.
// 단순화한 형태
public static QueryLookupStrategy create(EntityManager em,
JpaQueryMethodFactory queryMethodFactory,
@Nullable Key key, ...) {
return switch (key != null ? key : Key.CREATE_IF_NOT_FOUND) {
case CREATE -> new CreateQueryLookupStrategy(em, queryMethodFactory, ...);
case USE_DECLARED_QUERY -> new DeclaredQueryLookupStrategy(em, queryMethodFactory, ...);
case CREATE_IF_NOT_FOUND -> new CreateIfNotFoundQueryLookupStrategy(em, queryMethodFactory, ...);
};
}
각 전략의 resolveQuery(method, metadata, factory, namedQueries)는 RepositoryQuery를 돌려줍니다. 종류는 셋입니다.
PartTreeJpaQuery— 메서드 이름 파싱으로 만든 JPQL.SimpleJpaQuery/NativeJpaQuery—@Query가 붙은 JPQL/Native SQL.NamedQuery—Member.findByEmail같은 JPA named query.
CreateIfNotFoundQueryLookupStrategy는 내부적으로 DeclaredQueryLookupStrategy를 먼저 시도하고, 거기서 null/예외가 나오면 CreateQueryLookupStrategy로 폴백합니다. 그래서 같은 메서드에 @Query를 추가하면 즉시 그쪽이 우선합니다.
findByEmail이 만들어지는 순간
전략이 CreateQueryLookupStrategy로 결정됐다고 합시다(또는 CREATE_IF_NOT_FOUND에서 @Query가 없어 폴백된 경우). 이 전략의 resolveQuery는 한 줄짜리입니다.
return new PartTreeJpaQuery(method, em, escapeCharacter);
PartTreeJpaQuery의 생성자에서 메서드 이름을 파싱한 PartTree가 만들어집니다. 이 PartTree가 부팅 시점에 JPQL의 모양을 결정짓고, 호출 시점에는 파라미터만 바꿔 끼워서 EntityManager.createQuery로 실행합니다.
5단계 — PartTree가 메서드 이름을 트리로 푸는 법
PartTree는 메서드 이름을 받아 트리 구조의 추상 쿼리 표현으로 바꿉니다. org.springframework.data.repository.query.parser.PartTree에 있습니다.
Subject와 Predicate
메서드 이름은 By를 기준으로 잘립니다.
findDistinctTop3MemberByEmailAndStatusOrderByCreatedAtDesc
└─ subject ──────────────┘└──────── predicate ──────────────┘
- Subject = 쿼리의 의도.
find,count,exists,delete로 시작하고, 사이에Distinct,Top3,First10, 도메인 이름(Member) 등이 들어갑니다. - Predicate = WHERE 절과 ORDER BY 절.
By다음 부분 전체.
OrPart와 Part
predicate는 Or로 일차 분해되고, 각 OrPart는 And로 이차 분해되어 Part 리스트가 됩니다.
EmailAndStatusOrCreatedAtAfter
└─ OrPart 1 ──┘ └── OrPart 2 ─────┘
Part Email
Part Status
Part CreatedAtAfter
PartTree는 다음 인터페이스를 만족합니다.
Iterable<OrPart>— 외부 루프는 OR로 묶인 그룹들을 돈다.- 각
OrPart는Iterable<Part>— 내부 루프는 AND로 묶인 조건들을 돈다.
Part의 Type
Part.Type enum은 메서드 이름의 접미어를 SQL 연산자로 매핑합니다. 일부만 적으면 다음과 같습니다.
| 접미어 | Type | SQL/JPQL |
|---|---|---|
Equals, (없음) |
SIMPLE_PROPERTY |
x.prop = ? |
Like |
LIKE |
x.prop like ? |
Containing |
CONTAINING |
x.prop like %?% |
Between |
BETWEEN |
x.prop between ? and ? |
LessThan |
LESS_THAN |
x.prop < ? |
IsNull |
IS_NULL |
x.prop is null |
In |
IN |
x.prop in ? |
IgnoreCase |
(수식어) | lower(x.prop) = lower(?) |
도메인 검증
PartTree는 도메인 클래스도 함께 받습니다. findByEmal(오타) 같은 메서드는 부팅 시점에 PropertyReferenceException으로 실패합니다 — 호출 시점이 아니라 부팅 시점에 잡힌다는 점이 중요합니다. 메서드를 한 번도 호출하지 않아도 컨텍스트가 뜨지 않습니다.
내부적으로는 PropertyPath가 email → Member.email로 매핑을 시도하고, 실패하면 카멜케이스 분리를 다시 시도합니다(AddressZipCode → address.zipCode). 그래서 중첩 프로퍼티 탐색이 자연스럽게 됩니다.
만들어진 JPQL의 모양
findByEmailAndStatusOrderByCreatedAtDesc는 결국 다음 JPQL이 됩니다.
select m from Member m
where m.email = ?1 and m.status = ?2
order by m.createdAt desc
이 JPQL 문자열은 PartTreeJpaQuery가 부팅 시점에 한 번 만들어 보관합니다. 호출이 들어오면 EntityManager.createQuery(...)에 이 문자열을 넘겨 Query를 만들고, 인자를 바인딩한 뒤 getResultList()/getSingleResult()를 부릅니다. Optional<Member>나 List<Member> 같은 반환 타입 어댑팅은 ResultProcessor가 마지막에 처리합니다.
6단계 — CRUD 메서드는 누가 실행하는가: SimpleJpaRepository
쿼리 메서드는 QueryExecutorMethodInterceptor에서 분기되어 끝났습니다. 그러면 save, findById, findAll, delete 같은 CRUD 메서드는 어디로 가는지. 답은 advice 체인의 마지막인 ImplementationMethodExecutionInterceptor가 RepositoryComposition을 통해 SimpleJpaRepository 의 메서드로 라우팅한다는 것입니다.
SimpleJpaRepository<T, ID>는 JpaRepositoryImplementation<T, ID>(즉 JpaRepository<T, ID> + JpaSpecificationExecutor<T> + 내부 인터페이스)를 구현하는 평범한 클래스입니다. 클래스 선언과 핵심 필드만 보면 다음과 같습니다.
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
private final JpaEntityInformation<T, ?> entityInformation;
private final EntityManager entityManager;
private final PersistenceProvider provider;
public SimpleJpaRepository(JpaEntityInformation<T, ?> entityInformation,
EntityManager entityManager) {
this.entityInformation = entityInformation;
this.entityManager = entityManager;
this.provider = PersistenceProvider.fromEntityManager(entityManager);
}
// ...
}
@Transactional(readOnly = true)가 클래스에 붙고, 쓰기 메서드만 @Transactional로 덮어 씁니다. 이 메타데이터를 TransactionInterceptor(advice 체인의 RepositoryProxyPostProcessors 단계에서 추가됨)가 읽어 트랜잭션을 결정합니다.
대표적인 메서드 몇 개의 구현을 봅시다.
save
@Transactional
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
entityManager.persist(entity);
return entity;
} else {
return entityManager.merge(entity);
}
}
save가 INSERT인지 UPDATE인지를 결정하는 분기는 여기 한 줄에 있습니다 — entityInformation.isNew(entity). ID가 null인지(또는 @Version 필드가 null인지) 보고 새 엔티티이면 persist, 아니면 merge를 호출합니다. "왜 ID 있는 엔티티를 save했더니 SELECT가 한 번 더 나가지?"의 답은 merge의 의미론입니다 — merge는 동일 ID의 영속 엔티티가 1차 캐시에 없으면 SELECT로 로드한 뒤 변경을 옮기는 동작이기 때문입니다.
findById
public Optional<T> findById(ID id) {
Class<T> domainType = getDomainClass();
return Optional.ofNullable(entityManager.find(domainType, id, hints));
}
거의 EntityManager.find의 얇은 래퍼입니다. hints는 CrudMethodMetadata에서 가져오는데, @QueryHints로 지정한 힌트나 잠금 모드가 들어 있습니다.
findAll(Pageable)
public Page<T> findAll(Pageable pageable) {
if (isUnpaged(pageable)) {
return new PageImpl<>(findAll());
}
return findAll((Specification<T>) null, pageable);
}
Specification을 사용하는 변형으로 위임합니다. 내부적으로는 CriteriaBuilder로 동적 JPQL을 만들고, 별도로 count 쿼리를 한 번 더 실행해 총 개수를 채워 PageImpl을 돌려줍니다. 페이지 호출 한 번이 SQL 두 번이 되는 이유입니다.
delete
@Transactional
public void delete(T entity) {
Assert.notNull(entity, "Entity must not be null");
if (entityInformation.isNew(entity)) {
return;
}
// 영속 컨텍스트에 이미 attach 돼 있으면 그대로, 아니면 merge로 attach한 뒤 remove
T managed = entityManager.contains(entity)
? entity
: entityManager.merge(entity);
entityManager.remove(managed);
}
detached 엔티티에 delete를 부르면 내부에서 merge로 다시 영속 상태로 만든 뒤 remove합니다. 그래서 detached 엔티티 삭제도 동작하지만, merge 과정에서 영속 컨텍스트에 해당 식별자의 엔티티가 없으면 JPA 구현체(Hibernate 등)가 SELECT를 한 번 더 실행해 현재 DB 상태를 끌어오는 비용이 발생합니다.
한 호출의 전체 경로 — 한 장으로 정리
이제 memberRepository.findByEmail("a@b.c") 한 줄이 SQL이 되는 경로를 한 번에 봅시다.
flowchart TB
subgraph BootTime[Boot time, once]
A1[JpaRepositoriesRegistrar scans interfaces] --> A2[Register JpaRepositoryFactoryBean per interface]
A2 --> A3[FactoryBean.afterPropertiesSet calls JpaRepositoryFactory.getRepository]
A3 --> A4[ProxyFactory builds advice chain]
A4 --> A5[QueryExecutorMethodInterceptor caches Method to RepositoryQuery map]
A5 --> A6[PartTree parses findByEmail into JPQL where m.email = ?1]
end
subgraph CallTime[Per call]
B1[memberRepository.findByEmail email] --> B2[Proxy invocation]
B2 --> B3[MethodInvocationValidator]
B3 --> B4[TransactionInterceptor opens tx if needed]
B4 --> B5[QueryExecutorMethodInterceptor finds cached PartTreeJpaQuery]
B5 --> B6[EntityManager.createQuery with cached JPQL]
B6 --> B7[Bind email parameter]
B7 --> B8[getSingleResult or getResultList]
B8 --> B9[ResultProcessor wraps to Optional Member]
end
- 부팅 시 한 번 일어나는 일과 호출마다 일어나는 일이 명확히 분리됩니다.
- 인터페이스 메서드 한 줄에 들어 있는 정보(메서드 이름, 인자 타입, 반환 타입)만으로 부팅 시점에 JPQL이 결정되고, 호출 시점에는 단순한 맵 조회와
EntityManager호출만 남습니다. @Transactional이 인터페이스가 아니라 target(SimpleJpaRepository)에 붙어 있어도 작동하는 이유는TransactionalProxy인터페이스 마커 +TransactionInterceptor가 이 target을 보기 때문입니다.
자주 부딪히는 함정 다섯 가지
1. @Transactional 없이 @OneToMany lazy 컬렉션을 만지면 LazyInitializationException
Repository 메서드 자체는 SimpleJpaRepository의 @Transactional(readOnly = true)로 보호받습니다. 하지만 메서드 호출이 끝난 시점에 트랜잭션이 닫히고 영속성 컨텍스트가 함께 닫힙니다. 반환된 엔티티의 lazy 컬렉션을 컨트롤러/서비스에서 만지면 그 시점에는 컨텍스트가 없어 LazyInitializationException이 납니다. 호출자 쪽에 @Transactional을 두는 것이 정공법입니다.
2. findByEmal 같은 오타가 부팅 시점에 막힌다
PartTree가 도메인 클래스로 프로퍼티 검증을 하기 때문입니다. 이는 단점이 아니라 큰 장점입니다 — 컨트롤러 통합 테스트 없이도 @DataJpaTest만 돌리면 모든 Repository의 메서드 이름이 컴파일 타임에 가까운 시점에 검증됩니다.
3. Custom 구현이 베이스 메서드를 덮어쓰는 동작은 fragment 순서로 결정된다
MemberRepositoryCustom에 save(Member)를 정의하고 MemberRepositoryImpl에 그 구현을 두면, fragment 우선순위가 높은 Custom이 이깁니다. 즉 우리가 작성한 save가 SimpleJpaRepository.save를 가립니다. 이 사실을 모르면 "왜 @Transactional이 안 먹지?", "왜 merge가 안 일어나지?" 같은 디버깅에 시간을 쏟게 됩니다.
4. @Query와 메서드 이름이 충돌하면 @Query가 이긴다 — 단, 기본 전략에서
CREATE_IF_NOT_FOUND(기본값)는 선언된 쿼리를 먼저 봅니다. 그래서 @Query가 붙은 순간 메서드 이름 규칙은 무시됩니다. queryLookupStrategy = Key.CREATE로 바꾸면 그 반대로 동작합니다.
5. findAll(Pageable)이 SQL 두 번 나가는 건 정상
데이터 쿼리 한 번, count 쿼리 한 번. count 쿼리가 비싸면 Page 대신 Slice를 쓰는 것이 처방입니다 — Slice는 다음 페이지 존재 여부만 보고 limit + 1 트릭을 씁니다. 또는 @Query에 countQuery를 따로 지정해 더 가벼운 count로 대체할 수 있습니다.
정리
Spring Data JPA Repository는 다섯 조각의 합입니다.
- 컴포넌트 스캔이 인터페이스를 찾아
JpaRepositoryFactoryBean을 등록한다. 인터페이스 자체는 빈이 아닙니다. 빈은FactoryBean이고, 그getObject()가 프록시를 돌려줍니다. JpaRepositoryFactory.getRepository가 한 번 프록시를 만든다. target은SimpleJpaRepository, advice 체인은 정해진 순서로 추가됩니다.- Advice 체인이 호출의 종착지를 결정한다.
QueryExecutorMethodInterceptor는 쿼리 메서드를,ImplementationMethodExecutionInterceptor는RepositoryComposition을 통해 fragment(보통SimpleJpaRepository)를 부릅니다. - 쿼리 메서드는 부팅 시점에
JpaQueryLookupStrategy가 만든RepositoryQuery로 캐시된다.@Query/named query/메서드 이름 중 누가 이기는지는Key로 결정됩니다. - 메서드 이름 파생 쿼리는
PartTree가 만든 트리에서 출발한다. 도메인 클래스로 프로퍼티가 검증되고, JPQL이 한 번 만들어진 뒤 호출마다 재사용됩니다.
이 흐름을 한 번 따라가 보면, 평소 "그냥 인터페이스 만들면 동작한다"고 넘어가던 부분이 Key, fragment 우선순위, @Transactional 위치, Page vs Slice 같은 구체적인 결정으로 분해됩니다. 다음번에 findByEmal 오타로 부팅이 실패하거나, Custom 구현이 베이스를 덮어쓰는 동작에 부딪히면, 어느 단계에서 어떤 객체가 결정을 내리고 있는지가 보일 것입니다.
참고자료
- Spring Data JPA Reference (현재 버전) — 공식 레퍼런스. Defining Query Methods, Custom Implementations 섹션.
- Spring Data Commons Reference — Core Concepts — Repository fragment, composition 개념.
RepositoryFactorySupport소스 —getRepository구현, advice 체인 구성.JpaRepositoryFactory소스 —getRepositoryBaseClass,getQueryLookupStrategy.JpaQueryLookupStrategy소스 —CREATE/USE_DECLARED_QUERY/CREATE_IF_NOT_FOUND구현.PartTree소스 — 메서드 이름 파서.SimpleJpaRepository소스 —save/findById/delete베이스 구현.RepositoryCompositionAPI 문서 — fragment 라우팅.- Defining Query Methods (JPA) —
Keyenum과 메서드 이름 규칙.

