Spring Data JPA는 인터페이스 선언만으로 어떻게 동작하는가
Repository 프록시 생성부터 Query Method 파싱, Projection 최적화, Custom Repository 합성까지 — Spring Data JPA의 설계 철학을 내부 소스로 추적한다.
- 01 Spring Data JPA는 인터페이스 선언만으로 어떻게 동작하는가
- 02 Spring 트랜잭션은 어떻게 비즈니스 코드를 깨끗하게 유지하는가
- 03 JPA는 어떻게 객체와 DB를 동기화하는가
- 04 Spring Data JPA의 쿼리 전략은 왜 이렇게 많은가
- 05 JdbcTemplate이 JDBC 보일러플레이트를 제거하는 방법
- 06 HikariCP는 왜 다른 Connection Pool보다 빠른가
- 07 Spring Data 테스트는 왜 이렇게 설계됐는가
interface UserRepository extends JpaRepository<User, Long> — 구현체가 없다. 메서드 본문도 없다. 그런데 이 인터페이스를 @Autowired로 주입받으면 findById, save, findByEmail이 전부 동작한다. Spring Data JPA는 어떻게 아무것도 없는 인터페이스를 런타임에 작동하는 객체로 만드는가?
인터페이스가 객체가 되는 과정
Spring Data JPA의 출발점은 반복 코드 제거다. 엔티티마다 findById, save, delete를 똑같이 작성하는 대신, 제네릭과 JDK 동적 프록시로 런타임에 구현체를 생성한다.
@EnableJpaRepositories가 스캔을 트리거한다. JpaRepositoriesRegistrar가 Repository 인터페이스를 발견하고, JpaRepository 자체가 아닌 **JpaRepositoryFactoryBean**을 BeanDefinition으로 등록한다. getBean("userRepository") 시점에 이 FactoryBean의 getObject()가 호출되어 RepositoryFactorySupport.getRepository()가 실행된다.
이 메서드 안에서 세 가지가 조립된다.
JDK 동적 프록시 (UserRepository 인터페이스 구현)
├── QueryExecutorMethodInterceptor ← findByEmail 같은 쿼리 메서드
└── SimpleJpaRepository ← findById, save 등 기본 CRUD (target)
AopUtils.isJdkDynamicProxy(userRepository)는 true를 반환한다. 실제 구현체를 꺼내면 SimpleJpaRepository다.
SimpleJpaRepository는 클래스 레벨에 @Transactional(readOnly = true)가 선언되어 있다. findById, findAll은 이를 상속받아 readOnly로 실행된다. save, delete에는 @Transactional이 개별 선언되어 readOnly가 해제된다. 개발자가 별도 설정 없이도 기본 CRUD에 트랜잭션이 보장되는 이유다.
Query Method — 메서드 이름이 JPQL이 되는 원리
findByNameAndAgeGreaterThan(String name, int age) — 이 메서드 이름에서 어떻게 WHERE name = ? AND age > ?가 만들어지는가?
파싱은 애플리케이션 시작 시 1회만 발생한다. QueryExecutorMethodInterceptor 생성자에서 Repository의 모든 쿼리 메서드를 순회하며 RepositoryQuery 객체를 생성하고 Map<Method, RepositoryQuery>에 캐시한다. 런타임 호출 시에는 캐시에서 꺼내 파라미터만 바인딩한다.
파싱의 핵심은 PartTree다. findByNameAndAgeGreaterThan을 받으면 정규식으로 Subject(find)와 Predicate(NameAndAgeGreaterThan)를 분리한다. Predicate는 And/Or 키워드로 Part 목록으로 쪼개지고, 각 Part는 키워드(GreaterThan)와 프로퍼티(age)를 추출한다. 이 구조가 JPA Criteria API를 통해 TypedQuery로 변환된다.
// 잘못된 프로퍼티 참조 → 런타임이 아닌 시작 시 즉시 실패
List<User> findByAddress(String address);
// No property 'address' found for type 'User'!
시작 시 즉시 실패한다는 것은 장점이다. 오타가 프로덕션 요청이 들어온 뒤에야 발견되는 일이 없다.
쿼리 탐색 우선순위는 @Query 어노테이션 → Named Query → PartTree 파싱 순이다. @Query의 JPQL은 SimpleJpaQuery로, nativeQuery=true는 NativeJpaQuery로 처리되어 각각 em.createQuery()와 em.createNativeQuery()로 분기된다.
UPDATE/DELETE JPQL은 영속성 컨텍스트를 우회해 DB를 직접 변경한다. 같은 트랜잭션에서 이후 조회 시 1차 캐시에는 여전히 이전 상태가 남아 있다. @Modifying(clearAutomatically = true)를 선언해야 em.clear()가 호출되어 캐시가 비워진다.
Projection — SELECT 컬럼을 줄이는 두 가지 방법
목록 화면에 id와 name만 필요한데 20개 컬럼 엔티티 전체를 조회하는 것은 낭비다. 특히 BLOB 컬럼이 있다면 치명적이다.
Interface Projection은 선언만으로 동작한다.
public interface UserSummary {
Long getId();
String getName();
}
List<UserSummary> findByStatus(Status status);
Spring Data는 이 인터페이스를 분석해 @Value SpEL이 없고 모든 게터가 엔티티 프로퍼티에 직접 매핑됨을 확인한다 — Closed Projection이다. 이때 SELECT u.id, u.name FROM User u로 최적화된다. 반환 객체는 JDK 동적 프록시다.
반면 @Value("#{target.firstName + ' ' + target.lastName}")가 하나라도 있으면 Open Projection이 되어 전체 엔티티를 로드한 뒤 SpEL을 평가한다. SELECT 최적화가 사라진다.
DTO Projection은 프록시 없이 생성자를 직접 호출한다.
@Query("SELECT new com.example.dto.UserSummaryDto(u.id, u.name) FROM User u")
List<UserSummaryDto> findAllAsDto();
완전한 패키지명이 필수다. 영속성 컨텍스트에 등록되지 않으므로 변경 감지가 없고, 대용량 조회에 가장 효율적이다.
QueryDSL과 Specification — 동적 조건의 두 갈래
Query Method는 조건이 2개를 넘으면 메서드 이름이 문장 길이가 된다. OR 조건의 우선순위도 제어할 수 없다. 동적 조건 — 조건 유무가 런타임에 결정되는 경우 — 은 별도 접근이 필요하다.
Specification은 외부 라이브러리 없이 JPA Criteria API를 함수형으로 래핑한다.
Specification<User> spec = Specification
.where(UserSpec.hasStatus(condition.getStatus())) // null이면 무시
.and(UserSpec.ageGoe(condition.getMinAge()))
.and(UserSpec.nameLike(condition.getName()));
각 Specification은 null을 반환하면 해당 조건이 제외된다. SpecificationComposition이 이 null-safe 조합을 처리한다. JpaSpecificationExecutor.findAll(spec)은 내부에서 spec.toPredicate()를 호출해 CriteriaQuery를 완성한다.
QueryDSL은 빌드 시 APT가 Q타입을 생성해 컴파일 타임 타입 안전성을 추가한다. QUser.user.name은 문자열 "name"이 아닌 타입 안전한 경로 표현이다. JOIN, Projection, 집계가 필요한 복잡한 쿼리에서 Specification보다 표현력이 높다.
Custom Repository — Impl이라는 이름의 마법
QueryDSL이나 EntityManager 직접 사용이 필요하면 Custom Repository를 작성한다.
public interface UserRepositoryCustom {
List<User> findWithPessimisticLock(List<Long> ids);
}
public class UserRepositoryCustomImpl implements UserRepositoryCustom {
@PersistenceContext EntityManager em;
@Override @Transactional
public List<User> findWithPessimisticLock(List<Long> ids) {
return em.createQuery("SELECT u FROM User u WHERE u.id IN :ids", User.class)
.setParameter("ids", ids)
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
.setHint("javax.persistence.lock.timeout", 5000)
.getResultList();
}
}
public interface UserRepository extends JpaRepository<User, Long>, UserRepositoryCustom { }
UserRepositoryCustomImpl이라는 이름만으로 Spring Data가 자동 인식한다. RepositoryFactorySupport가 {커스텀인터페이스명}Impl → {Repository인터페이스명}Impl 순서로 탐색해 RepositoryFragments에 합성한다. 최종 프록시는 메서드 시그니처에 따라 Custom 구현체와 SimpleJpaRepository 중 올바른 쪽으로 호출을 위임한다.
Custom Repository의 메서드에는 @Transactional이 자동 적용되지 않는다. SimpleJpaRepository의 트랜잭션은 CRUD 메서드에만 한정된다. 쓰기 연산이 있다면 반드시 명시해야 한다.
트레이드오프
Spring Data JPA는 세 가지 선택지를 제공하고, 각각 비용이 다르다.
| 방식 | 장점 | 단점 |
|---|---|---|
| Query Method | 선언만으로 동작, 시작 시 오류 발견 | 조건 3개 이상 → 가독성 급락, 동적 조건 불가 |
| Specification | 외부 의존 없음, 재사용 가능한 조건 블록 | Criteria API 문자열 필드명, JOIN 복잡 시 가독성 저하 |
| QueryDSL | 타입 안전, 복잡한 쿼리 표현력 높음 | 빌드 설정 필요, Q타입 관리 |
Projection 선택도 마찬가지다. Closed Interface Projection은 SELECT 최적화가 자동이지만 SpEL 하나가 Open으로 바꾼다. DTO Projection은 프록시 오버헤드가 없지만 패키지명을 문자열로 관리해야 한다.
정리
- Spring Data JPA의 Repository는 JDK 동적 프록시로 생성되며 실제 구현은
SimpleJpaRepository가 담당한다. - Query Method 파싱은 시작 시 1회 발생하고
Map<Method, RepositoryQuery>에 캐시된다. 잘못된 프로퍼티 참조는 시작 시 즉시 발견된다. - Closed Projection은 SELECT 컬럼을 자동 최적화한다.
@ValueSpEL 하나가 Open으로 바꿔 최적화를 사라지게 만든다. - Specification과 QueryDSL은 동적 쿼리의 두 갈래다. 복잡도와 팀 역량에 따라 선택하거나 혼용한다.
- Custom Repository의
Impl접미사는 이름 규칙으로 탐색된다. 트랜잭션은 자동 적용되지 않으므로 명시가 필요하다.
다음 글에서는 @Transactional의 전파 규칙과 SimpleJpaRepository의 트랜잭션이 서비스 레이어 트랜잭션과 충돌하는 실제 시나리오를 추적한다.