Skip to main content

Command Palette

Search for a command to run...

Spring Data JPA Repository 내부 동작 — Proxy, Fragment, QueryLookupStrategy, 그리고 PartTree

Updated
14 min read

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이다.

이 글은 다섯 단계로 그 흐름을 따라갑니다.

  1. Repository 인터페이스가 어떻게 빈으로 바뀌는지(컴포넌트 스캔과 RepositoryFactoryBeanSupport)
  2. 부팅 시 RepositoryFactorySupport.getRepository가 만드는 프록시의 구조
  3. 프록시 안의 advice 체인 — 어떤 순서로 누가 처리를 가로채는가
  4. 쿼리 메서드는 누가 만드는가 — JpaQueryLookupStrategy의 세 가지 모드와 PartTree의 메서드 이름 파싱
  5. 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 다음에 위치한 ImplementationMethodExecutionInterceptorSimpleJpaRepository의 메서드로 라우팅합니다. 본질은 같습니다 — 프록시가 호출을 받아 advice 체인을 따라가며, 메서드의 종류에 따라 다른 종착지(쿼리 메서드 vs 베이스 구현 vs 사용자 정의 fragment)로 분기합니다.

1단계 — 인터페이스가 어떻게 빈이 되는가

우리는 MemberRepository를 인스턴스화한 적이 없습니다. 그렇다면 누가 만들어 컨테이너에 넣었을까요. 출발점은 @EnableJpaRepositories(Spring Boot라면 자동 구성으로 켜진)가 등록하는 JpaRepositoriesRegistrar입니다. 이 등록자는 다음 일을 합니다.

  • basePackages 또는 메인 클래스 패키지를 시작점으로 컴포넌트 스캔을 수행한다.
  • Repository 인터페이스를 상속한 모든 인터페이스를 찾는다.
  • 발견된 각 인터페이스마다 JpaRepositoryFactoryBean을 빈으로 등록한다 — 인터페이스 자체를 빈으로 등록하는 게 아닙니다.

JpaRepositoryFactoryBean은 Spring의 FactoryBean<T> 메커니즘을 이용한 빈 정의입니다. FactoryBean은 컨테이너가 getObject()를 호출했을 때 실제 빈을 만들어 돌려주는 "빈을 만드는 빈"입니다. 즉 컨테이너에는 두 종류의 객체가 있습니다.

  • &memberRepositoryJpaRepositoryFactoryBean 자체. 빈을 만드는 공장.
  • memberRepositoryJpaRepositoryFactoryBean.getObject()가 만들어 캐시한 프록시. 우리가 주입받는 것.

JpaRepositoryFactoryBeanRepositoryFactoryBeanSupport를 상속합니다. 부모인 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가 만드는 프록시의 구조

JpaRepositoryFactoryRepositoryFactorySupport를 상속합니다. 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);

setInterfacesrepositoryInterface 외에 Repository.classTransactionalProxy.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가 등록하는 TransactionalRepositoryProxyPostProcessorTransactionInterceptor를 추가하는 곳입니다. 그래서 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 구성은 다음과 같습니다(우선순위 순서대로).

  1. 사용자 정의 Custom 구현 — MemberRepositoryCustom + MemberRepositoryImpl 패턴
  2. JpaRepository / CrudRepository 베이스 구현 — SimpleJpaRepository
  3. QuerydslPredicateExecutor 같은 기능적 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.
  • NamedQueryMember.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로 묶인 그룹들을 돈다.
  • OrPartIterable<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으로 실패합니다 — 호출 시점이 아니라 부팅 시점에 잡힌다는 점이 중요합니다. 메서드를 한 번도 호출하지 않아도 컨텍스트가 뜨지 않습니다.

내부적으로는 PropertyPathemailMember.email로 매핑을 시도하고, 실패하면 카멜케이스 분리를 다시 시도합니다(AddressZipCodeaddress.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 체인의 마지막인 ImplementationMethodExecutionInterceptorRepositoryComposition을 통해 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);
    }
}

saveINSERT인지 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의 얇은 래퍼입니다. hintsCrudMethodMetadata에서 가져오는데, @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 순서로 결정된다

MemberRepositoryCustomsave(Member)를 정의하고 MemberRepositoryImpl에 그 구현을 두면, fragment 우선순위가 높은 Custom이 이깁니다. 즉 우리가 작성한 saveSimpleJpaRepository.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 트릭을 씁니다. 또는 @QuerycountQuery를 따로 지정해 더 가벼운 count로 대체할 수 있습니다.

정리

Spring Data JPA Repository는 다섯 조각의 합입니다.

  1. 컴포넌트 스캔이 인터페이스를 찾아 JpaRepositoryFactoryBean을 등록한다. 인터페이스 자체는 빈이 아닙니다. 빈은 FactoryBean이고, 그 getObject()가 프록시를 돌려줍니다.
  2. JpaRepositoryFactory.getRepository가 한 번 프록시를 만든다. target은 SimpleJpaRepository, advice 체인은 정해진 순서로 추가됩니다.
  3. Advice 체인이 호출의 종착지를 결정한다. QueryExecutorMethodInterceptor는 쿼리 메서드를, ImplementationMethodExecutionInterceptorRepositoryComposition을 통해 fragment(보통 SimpleJpaRepository)를 부릅니다.
  4. 쿼리 메서드는 부팅 시점에 JpaQueryLookupStrategy가 만든 RepositoryQuery로 캐시된다. @Query/named query/메서드 이름 중 누가 이기는지는 Key로 결정됩니다.
  5. 메서드 이름 파생 쿼리는 PartTree가 만든 트리에서 출발한다. 도메인 클래스로 프로퍼티가 검증되고, JPQL이 한 번 만들어진 뒤 호출마다 재사용됩니다.

이 흐름을 한 번 따라가 보면, 평소 "그냥 인터페이스 만들면 동작한다"고 넘어가던 부분이 Key, fragment 우선순위, @Transactional 위치, Page vs Slice 같은 구체적인 결정으로 분해됩니다. 다음번에 findByEmal 오타로 부팅이 실패하거나, Custom 구현이 베이스를 덮어쓰는 동작에 부딪히면, 어느 단계에서 어떤 객체가 결정을 내리고 있는지가 보일 것입니다.

참고자료

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ