[Spring] SimpleJpaRepository: 기본 구현체의 동작 원리
JpaRepository<T, ID>를 상속만 하면 save, findById, delete가 바로 동작한다.
그런데 그 구현체는 클래스로 존재하지 않고, 런타임에 프록시가 대신 만들어진다.
save()가 INSERT를 낼지 UPDATE를 낼지는 SimpleJpaRepository의 isNew() 판정이 정한다.
이 기준을 모르면 Kotlin data class와 UUID 기본키 조합에서 이유 모를 SELECT가 새는 것을 알아차리기 어렵다.
이 글은 프록시 조립 과정부터 save, findById, delete, saveAll, findAll(Pageable)이 내는 SQL, 언제 커스터마이징할지를 다룬다.
기준은 Spring Data JPA 3.x, Hibernate 6.x다.
다이어그램
인터페이스가 코드 없이 구현체가 되는 과정
인터페이스만 선언했는데 빈은 어떻게 만들어지는가?
- 스프링 부트는
@SpringBootApplication에@EnableJpaRepositories가 포함되어 있어, 별도 선언 없이도JpaRepository를 상속한 인터페이스를 스캔한다. - 스캔된 인터페이스마다
JpaRepositoryFactoryBean이 빈으로 등록된다. 이 빈의afterPropertiesSet()이createRepositoryFactory()를 호출해JpaRepositoryFactory를 만든다. - 실제 조립은
RepositoryFactorySupport#getRepository(repositoryInterface)가 담당한다.- 대상 구현체(target)로
SimpleJpaRepository인스턴스를 만들고, 인터페이스와 마커 인터페이스를 구현하는 JDK 동적 프록시를 만든다. - 그 프록시에
QueryExecutorMethodInterceptor를 advice로 붙여 모든 메서드 호출을 가로챈다.
- 대상 구현체(target)로
- JDK 동적 프록시가 인터페이스 기반으로 호출을 가로채는 일반 원리는 스프링 AOP와 프록시에서 다뤘다. 여기서는 그 프록시가 가로챈 호출을 어디로 넘기는지를 본다.
메서드 호출은 커스텀 Impl, 쿼리 메서드, SimpleJpaRepository 중 어디로 가는가?
인터셉터는 매 호출마다 판단하지 않는다. 프록시가 만들어지는 시점에 리포지토리 인터페이스의 메서드마다 목적지 하나를 미리 정해 둔다.
| 목적지 | 결정 기준 |
|---|---|
| 커스텀 Impl | XxxRepositoryCustom 인터페이스에 선언되고 XxxRepositoryImpl 클래스가 구현한 메서드 |
| SimpleJpaRepository | Repository, CrudRepository, PagingAndSortingRepository, JpaRepository가 이미 선언한 표준 메서드(save, findById, delete 등) |
| 쿼리 메서드 실행기 | 위 두 곳에 없는 나머지. @Query 주석이 있으면 그 JPQL을, 없으면 메서드 이름(findBy..., existsBy..., deleteBy...)을 파싱해 쿼리를 만든다. 파싱·조립 과정은 Spring Data JPA 파생 쿼리 메서드 참고 |
sequenceDiagram participant App as 애플리케이션 participant Proxy as 리포지토리 프록시 participant Custom as 커스텀 Impl participant Base as SimpleJpaRepository participant Query as 쿼리 메서드 실행기 App->>Proxy: repository.method(args) Note over Proxy: 메서드별 목적지는 프록시 생성 시점에 이미 정해져 있다 alt XxxCustom 에 선언되고 XxxImpl 이 구현함 Proxy->>Custom: method(args) Custom-->>App: 결과 else Repository 계열 인터페이스가 선언한 표준 메서드 Proxy->>Base: method(args) Base-->>App: 결과 else 그 외 findBy*, existsBy*, @Query 메서드 Proxy->>Query: method(args) Query-->>App: 결과 end
- 커스텀 Impl에 표준 메서드와 같은 시그니처(예: save)를 직접 구현하면 그 구현이 SimpleJpaRepository보다 우선한다. 표준 동작 자체를 바꾸고 싶을 때 쓰는 경로다.
save()는 insert와 update를 어떻게 가르는가
SimpleJpaRepository.save()는 엔티티가 신규인지를 먼저 판정하고, 신규면 em.persist(), 아니면 em.merge()로 보낸다. isNew() 판정은 세 단계를 순서대로 확인한다.
| 순서 | 판정 기준 | 결과 |
|---|---|---|
| 1 | 엔티티가 Persistable<ID>를 구현 | entity.isNew()가 돌려주는 값을 그대로 사용 |
| 2 | @Version 필드가 존재(객체 타입) | 버전 값이 null이면 신규 |
| 3 | 그 외(@Id 기준) | @Id 값이 null(객체 타입) 또는 0(기본형)이면 신규 |
sequenceDiagram participant App as 애플리케이션 participant Repo as SimpleJpaRepository participant Info as EntityInformation participant EM as EntityManager participant DB as InnoDB App->>Repo: save(entity) Repo->>Info: isNew(entity) alt 신규로 판정됨 Info-->>Repo: true Repo->>EM: persist(entity) Note over EM,DB: 플러시 시점에 INSERT 1건만 나간다 EM->>DB: INSERT INTO ... DB-->>EM: 생성된 PK 또는 확인 EM-->>Repo: entity(영속 상태) else 기존으로 판정됨 Info-->>Repo: false Repo->>EM: merge(entity) EM->>DB: SELECT ... WHERE id = ? DB-->>EM: 기존 행 또는 없음 Note over EM,DB: 필드 값이 다를 때만 플러시 시점에 UPDATE EM-->>Repo: 병합된 관리 인스턴스 end Repo-->>App: 저장된 엔티티
id가 null이 아니면 무조건 merge로 가는가?
- 그렇다.
@GeneratedValue없이 애플리케이션이 직접 값을 채우는 PK(대표적으로 UUID)는, 최초 저장 시점에도 이미@Id값이 null이 아니다. - isNew() 3단계 중 1, 2번이 해당 없으면 3번(
@Idnull 여부)으로 떨어지고, 값이 존재하니 신규가 아니라고 판정된다. save()는 merge() 경로로 간다. - merge()는 먼저 SELECT로 같은 id의 행이 있는지 확인한다. 없으면 새 관리 인스턴스를 만들어 결국 INSERT로 이어지지만, 최초 저장 1건마다 SELECT 1회가 항상 추가로 나간다.
Kotlin data class와 UUID PK를 쓰면 왜 불필요한 update가 나가는가?
// 잘못된 패턴: @Id 를 직접 채우면 최초 저장에도 isNew() 가 false 로 떨어져 merge 경로를 탄다
@Entity
data class Order(
@Id
val id: UUID = UUID.randomUUID(),
val amount: Long,
)// 올바른 패턴: Persistable 구현으로 신규 여부를 명시한다. copy() 부작용을 피해 일반 class 로 뺀다
@Entity
class Order(
@Id
val id: UUID = UUID.randomUUID(),
val amount: Long,
) : Persistable<UUID> {
@CreatedDate
var createdAt: Instant? = null
override fun getId(): UUID = id
override fun isNew(): Boolean = createdAt == null
}@CreatedDate는 Spring Data Auditing(@EnableJpaAuditing)이 영속화 직전에 채워주므로, 최초 저장 시점에는 항상 null이다. isNew() 판정을 이 필드에 위임하면 UUID를 직접 채워도 신규 여부가 정확히 갈린다.- Persistable을 구현하면 위 표의 1번이 항상 우선하므로,
@Version이나@Id기준을 볼 필요조차 없어진다.
조회와 삭제에서 놓치기 쉬운 지점
findById와 getReferenceById는 언제 SELECT를 실행하는가?
findById(id)는 내부에서em.find(도메인클래스, id)를 호출한다. 1차 캐시(영속성 컨텍스트)에 같은@Id의 인스턴스가 이미 있으면 그것을 그대로 반환하고, 없으면 즉시 SELECT를 실행해 채운다.getReferenceById(id)(과거getOne()을 대체)는em.getReference(도메인클래스, id)를 호출한다. 이 메서드는 SELECT를 내지 않고 프록시(대리 객체)만 즉시 돌려준다.- 그 프록시는 id 외의 필드에 처음 접근하는 순간에야 SELECT를 실행한다(지연 로딩). 그때 영속성 컨텍스트가 닫혀 있으면 LazyInitializationException, 실제로 그 id의 행이 없으면 EntityNotFoundException이 난다.
delete가 remove 전에 SELECT를 한 번 더 하는 이유는?
delete(entity)에 넘어오는 entity는 다른 트랜잭션에서 조회했거나 요청 값으로 새로 만든 detached 상태일 수 있다.
JPA 스펙상 EntityManager.remove()는 managed 상태에만 안전하게 적용되므로, SimpleJpaRepository는 지우기 전에 관리 상태부터 확보한다.
// SimpleJpaRepository.delete() 의 핵심 흐름을 코틀린으로 옮긴 것. 세부는 버전마다 달라도 find 후 remove 흐름은 같다
@Transactional
override fun delete(entity: T) {
if (entityInformation.isNew(entity)) return
val existing = em.find(entityInformation.javaType, entityInformation.getId(entity)) ?: return
em.remove(if (em.contains(entity)) entity else em.merge(entity))
}- delete(entity) 한 번은 최소 SELECT 1회(managed 상태 확인) + DELETE 1회로 SQL 2개를 발생시킨다. deleteById(id)도 내부적으로 조회를 먼저 거치므로 마찬가지다.
- 대상이 이미 삭제되어 SELECT 결과가 없으면 조용히 끝난다. 이미 지워진 행을 다시 지우려 해도 예외가 나지 않는다.
deleteAllInBatch가 빠른 대신 잃는 것은?
| 구분 | 쿼리 수(N건 기준) | 영속성 컨텍스트 반영 | 라이프사이클 콜백 | cascade |
|---|---|---|---|---|
| deleteAll() | SELECT 1 + DELETE N | 반영됨(건별 remove) | 실행됨 | 적용됨 |
| deleteAllInBatch() | DELETE 1 | 반영 안 됨 | 건너뜀 | 적용 안 됨(DB 제약에 위임) |
- deleteAllInBatch()는 EntityManager로
DELETE FROM Entity형태의 JPQL 벌크 연산 1개를 직접 실행한다. 영속성 컨텍스트를 거치지 않고 DB에 바로 반영된다. - 이미 1차 캐시에 올라온 인스턴스는 삭제 사실을 모른 채 남는다. 같은 트랜잭션에서 findById로 그 id를 다시 조회하면 캐시가 먼저 응답해, DB에는 없는 행이 여전히 존재하는 것처럼 보인다.
@PreRemove/@PostRemove콜백과 cascade 삭제를 건너뛰므로, 자식 테이블에 외래키 제약이 있으면 벌크 DELETE 자체가 제약 위반으로 실패한다.
saveAll과 findAll(Pageable)이 실제로 발행하는 쿼리
saveAll을 쓰면 insert가 배치로 나가는가?
- saveAll(entities)는 entities를 순회하며 save(entity)를 한 건씩 호출하는 단순 루프다. JDBC 배치(여러 INSERT를 하나의 네트워크 왕복으로 묶는 것)를 그 자체로 구성하지 않는다.
- 실제 배치 여부는 하이버네이트 설정
hibernate.jdbc.batch_size에 달려 있다. 값을 켜면 하이버네이트가 flush 시점에 같은 모양의 INSERT/UPDATE를 모아PreparedStatement.addBatch()로 묶는다.
spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 100
order_inserts: true
order_updates: true@GeneratedValue(strategy = GenerationType.IDENTITY)를 쓰면 이 배치가 무력화된다. DB가 INSERT를 실행해야만 PK를 알 수 있어, 하이버네이트가 각 INSERT를 즉시 실행해 PK를 읽어야 하기 때문이다.- 그래서 대량 저장이 필요하면 PK 전략을 SEQUENCE나 TABLE로 바꾸거나, JdbcTemplate.batchUpdate로 우회한다.
- Hibernate 6.2 이후 일부 방언은 IDENTITY에서도 생성된 키를 배치로 돌려받는 방식을 지원하기 시작했다. 실제 적용 여부는 DB 방언과 하이버네이트 부버전을 SQL 로그로 직접 확인해야 한다.
findAll(Pageable)은 조회를 몇 번 하는가?
- findAll(Pageable)은
Page<T>를 반환한다. Page는 총 개수(getTotalElements)를 제공해야 하므로, 기본적으로 조회 2번을 낸다. 페이지 크기만큼 LIMIT/OFFSET을 건 SELECT 1회, COUNT(*) 1회다. - 예외가 하나 있다.
PageableExecutionUtils가 페이지 번호 0에서 조회된 결과 수가 요청한 페이지 크기보다 작으면(다음 페이지가 없다는 뜻) 그 자리에서 총 개수를 계산할 수 있어 COUNT 쿼리를 생략한다. - Slice
는 총 개수를 아예 제공하지 않는 대신, 요청한 페이지 크기보다 1건 더 조회해 다음 페이지 존재 여부만 판단하고 잘라서 반환한다. COUNT 쿼리 자체가 없다. - 무한 스크롤처럼 총 개수가 필요 없는 화면은 반환 타입을 Slice로 바꾸면 페이지마다 COUNT 쿼리 1회를 아낀다.
트랜잭션 경계와 1차 캐시 생명주기
SimpleJpaRepository 클래스 자체에 @Repository와 클래스 레벨 @Transactional(readOnly = true)가 붙어 있다.
save, delete, deleteAll, deleteAllInBatch 같은 쓰기 메서드에는 개별적으로 @Transactional을 다시 붙여 readOnly를 오버라이드한다.
전파와 격리 옵션 전반은 스프링 트랜잭션에서 다뤘다. 여기서는 서비스 계층에 트랜잭션이 없을 때 리포지토리 메서드가 각각 독립된 트랜잭션 경계가 되는 과정을 본다.
서비스 계층에 트랜잭션이 없으면 리포지토리 호출마다 무슨 일이 벌어지는가?
sequenceDiagram participant Svc as OrderService(트랜잭션 미선언) participant Repo as OrderRepository participant PC1 as 영속성 컨텍스트#1 participant PC2 as 영속성 컨텍스트#2 Svc->>Repo: findById(1) Note over Repo,PC1: 메서드 진입과 함께 트랜잭션 시작, PC1 생성 Repo->>PC1: em.find(Order, 1) PC1-->>Repo: Order(영속 상태) Note over Repo,PC1: 메서드 반환과 함께 커밋, PC1 종료 Repo-->>Svc: Order(이미 detached) Svc->>Svc: order.changeStatus(SHIPPED) Note over Svc: detached 상태라 이 변경은 아무 데도 반영되지 않는다 Svc->>Repo: save(order) Note over Repo,PC2: 새 트랜잭션 시작, PC1 과 무관한 PC2 생성 Repo->>PC2: em.merge(order) PC2-->>Repo: 병합된 관리 인스턴스 Note over Repo,PC2: 커밋, PC2 종료 Repo-->>Svc: 저장된 Order
- 기본 설정(트랜잭션 범위 EntityManager)에서 영속성 컨텍스트는 트랜잭션과 생명주기를 같이한다. 서비스에 트랜잭션이 없으면 리포지토리 호출마다 새 트랜잭션과 새 영속성 컨텍스트가 열렸다 닫힌다.
- findById로 가져온 엔티티는 메서드가 반환되는 순간 이미 detached다. 필드를 바꿔도 더티 체킹이 작동하지 않아 아무 데도 반영되지 않고, 반영하려면 다시 save를 호출해 명시적으로 merge해야 한다.
- 같은 id를 findById로 두 번 호출해도 1차 캐시가 공유되지 않아 SELECT가 매번 나간다.
- 리포지토리 호출 빈도가 늘면 커넥션 풀에서 커넥션을 빌리고 반납하는 빈도도 함께 늘어난다. 풀 자체의 동작은 HikariCP와 Connection Pool 참고.
readOnly=true는 실제로 무엇을 최적화하는가?
- 읽기 전용 트랜잭션에서는 하이버네이트 세션의 플러시 모드가 MANUAL로 바뀐다. 플러시는 쿼리 실행 직전과 커밋 시점에 변경분을 SQL로 내보내는 작업인데, MANUAL이면 자동으로 실행되지 않는다.
- 같은 트랜잭션 안에서 여러 SELECT를 실행해도 그 앞에서 도는 더티 체킹(영속 엔티티 전체를 원본 스냅샷과 비교하는 작업)을 건너뛴다.
- 조회된 엔티티도 기본적으로 읽기 전용으로 표시되어, 변경 여부를 비교할 원본 스냅샷 자체를 보관하지 않는다. 조회 건수가 많을수록 메모리 절감 폭이 커진다.
- 대가는 명확하다. readOnly 트랜잭션 안에서 엔티티 필드를 바꿔도 커밋 시점에 반영되지 않는다. 스냅샷이 없어 더티 체킹 자체가 성립하지 않는다.
- DB가 실제로 어떤 스냅샷(Read View)을 보게 되는지는 격리 수준이 정한다. 이 부분은 MySQL 트랜잭션 격리 수준과 MVCC 참고.
기본 구현을 바꾸는 세 가지 방법
| 방법 | 적용 범위 | 코드 위치 | 언제 |
|---|---|---|---|
| repositoryBaseClass 교체 | @EnableJpaRepositories 스캔 범위의 모든 리포지토리 | SimpleJpaRepository를 상속한 커스텀 base 클래스 | 모든 리포지토리에 공통 로직(예: soft delete, 감사 필드 자동 세팅)이 필요할 때 |
| 커스텀 fragment(~Custom + ~Impl) | 그 인터페이스를 상속한 리포지토리 하나 | XxxRepositoryCustom 인터페이스 + XxxRepositoryImpl 클래스 | 특정 도메인에만 필요한 복잡한 조회(QueryDSL, 네이티브 SQL)를 그 리포지토리 인터페이스 안에서 쓰고 싶을 때 |
| 별도 컴포넌트 | 호출하는 곳 어디서든 | 일반 @Component/@Service, 내부에서 JdbcTemplate·QueryDSL 직접 사용 | 도메인 하나에 리포지토리 하나라는 관례에 맞지 않는 조회(여러 애그리거트 조인, 통계성 배치 쿼리)일 때 |
// 방법 1: base class 교체. SimpleJpaRepository 와 같은 생성자 시그니처를 유지해야 한다
class AuditingJpaRepository<T, ID>(
entityInformation: JpaEntityInformation<T, ID>,
entityManager: EntityManager,
) : SimpleJpaRepository<T, ID>(entityInformation, entityManager) {
// 공통 감사 필드를 여기서 한 번만 채운다
override fun <S : T> save(entity: S): S = super.save(entity)
}// 방법 2: 커스텀 fragment. OrderRepository 하나에만 적용된다
interface OrderRepositoryCustom {
fun findTopSellingOrders(limit: Int): List<Order>
}
class OrderRepositoryImpl(
private val queryFactory: JPAQueryFactory,
) : OrderRepositoryCustom {
// SimpleJpaRepository 가 지원하지 않는 QueryDSL 조회
override fun findTopSellingOrders(limit: Int): List<Order> = TODO()
}
interface OrderRepository : JpaRepository<Order, Long>, OrderRepositoryCustom언제 무엇을 쓰는가
| 상황 | 선택 | 근거 |
|---|---|---|
| 단건 CRUD, 표준 조건 조회 | SimpleJpaRepository 그대로 | 코드가 가장 적고, 파생 쿼리와 @Query로 대부분 커버된다 |
| 수만 건 이상 대량 삭제 | 조건이 맞으면 deleteAllInBatch, 아니면 네이티브 DELETE | deleteAll()은 10만 건이면 SELECT 1 + DELETE 10만 건, SQL 10만 개가 나간다. deleteAllInBatch는 SQL 1개지만 1차 캐시 불일치를 감수해야 한다 |
| 수천 건 이상 배치 저장 | PK를 SEQUENCE로 바꾼 뒤 saveAll, 또는 JdbcTemplate.batchUpdate | IDENTITY 전략에서는 saveAll이 사실상 건별 즉시 INSERT라 hibernate.jdbc.batch_size가 무력화된다 |
| 여러 애그리거트를 조인하는 통계·리포트 조회 | QueryDSL 또는 별도 JdbcTemplate 컴포넌트 | 리포지토리와 파생 쿼리는 하나의 루트 엔티티 기준이라 조인 대상이 늘수록 N+1과 엔티티 그래프 관리 비용이 커진다 |
| 모든 리포지토리에 공통 로직 필요 | repositoryBaseClass 교체 | 커스텀 fragment는 리포지토리마다 반복 작성해야 하지만 base class 교체는 한 곳에서 끝난다 |
| 무한 스크롤처럼 총 개수가 필요 없는 목록 | findAll 대신 Slice를 반환하는 쿼리 메서드 | 페이지마다 COUNT 쿼리 1회를 아낀다 |
| UUID를 애플리케이션에서 직접 채우는 PK | Persistable 구현 | 그렇지 않으면 최초 저장마다 SELECT가 추가로 나간다 |