[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로 붙여 모든 메서드 호출을 가로챈다.
  • JDK 동적 프록시가 인터페이스 기반으로 호출을 가로채는 일반 원리는 스프링 AOP와 프록시에서 다뤘다. 여기서는 그 프록시가 가로챈 호출을 어디로 넘기는지를 본다.

메서드 호출은 커스텀 Impl, 쿼리 메서드, SimpleJpaRepository 중 어디로 가는가?

인터셉터는 매 호출마다 판단하지 않는다. 프록시가 만들어지는 시점에 리포지토리 인터페이스의 메서드마다 목적지 하나를 미리 정해 둔다.

목적지결정 기준
커스텀 ImplXxxRepositoryCustom 인터페이스에 선언되고 XxxRepositoryImpl 클래스가 구현한 메서드
SimpleJpaRepositoryRepository, 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번(@Id null 여부)으로 떨어지고, 값이 존재하니 신규가 아니라고 판정된다. 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, 아니면 네이티브 DELETEdeleteAll()은 10만 건이면 SELECT 1 + DELETE 10만 건, SQL 10만 개가 나간다. deleteAllInBatch는 SQL 1개지만 1차 캐시 불일치를 감수해야 한다
수천 건 이상 배치 저장PK를 SEQUENCE로 바꾼 뒤 saveAll, 또는 JdbcTemplate.batchUpdateIDENTITY 전략에서는 saveAll이 사실상 건별 즉시 INSERT라 hibernate.jdbc.batch_size가 무력화된다
여러 애그리거트를 조인하는 통계·리포트 조회QueryDSL 또는 별도 JdbcTemplate 컴포넌트리포지토리와 파생 쿼리는 하나의 루트 엔티티 기준이라 조인 대상이 늘수록 N+1과 엔티티 그래프 관리 비용이 커진다
모든 리포지토리에 공통 로직 필요repositoryBaseClass 교체커스텀 fragment는 리포지토리마다 반복 작성해야 하지만 base class 교체는 한 곳에서 끝난다
무한 스크롤처럼 총 개수가 필요 없는 목록findAll 대신 Slice를 반환하는 쿼리 메서드페이지마다 COUNT 쿼리 1회를 아낀다
UUID를 애플리케이션에서 직접 채우는 PKPersistable 구현그렇지 않으면 최초 저장마다 SELECT가 추가로 나간다