[Spring] SimpleJpaRepository: 기본 구현체의 동작 원리

메서드 호출 한 번이 거치는 경로

JpaRepository<Order, Long>을 상속한 OrderRepository는 구현 코드가 한 줄도 없다. 그런데도 orderRepository.save(order)를 호출하면 실제로 INSERT나 UPDATE가 나간다. 이 사이를 메꾸는 것이 애플리케이션 기동 시 조립되는 프록시와, 그 프록시가 표준 CRUD 메서드를 위임하는 기본 구현체 SimpleJpaRepository다.

구성 요소역할코드에서 확인하는 방법
XxxRepository 인터페이스개발자가 선언하는 표준 CRUD·파생 쿼리 메서드의 집합. 구현 코드는 없다아래 사용자 코드의 OrderRepository 선언
JpaRepositoryFactoryBean스프링 빈 생명주기(InitializingBean)에 올라타 애플리케이션 기동 시 리포지토리 프록시를 조립한다아래 조립 순서의 1번째 참가자
JpaRepositoryFactory엔티티 메타데이터를 읽어 SimpleJpaRepository 인스턴스와 JpaEntityInformation을 만든다아래 조립 순서의 2번째 참가자
프록시(JDK Dynamic Proxy)인터페이스 호출을 가로채 메서드별로 정해진 목적지로 넘기는 실제 구현체아래 조립 순서의 마지막 반환값. 애플리케이션이 실제로 주입받는 객체가 이것이다
QueryExecutorMethodInterceptor파생 쿼리·@Query 메서드를 담당하는 인터셉터. 목적지는 프록시가 만들어지는 시점에 이미 확정된다아래 조립 순서의 첫 번째 addAdvice 호출
ImplementationMethodExecutionInterceptor커스텀 Impl과 SimpleJpaRepository로 나머지 호출을 위임하는 인터셉터아래 조립 순서의 두 번째 addAdvice 호출
SimpleJpaRepositorysave·findById·delete 같은 표준 CRUD 메서드의 실제 구현체## save(), insert와 update의 갈림길의 인용 코드
JpaEntityInformation엔티티의 @Id·@Version을 읽어 isNew() 판정을 담당한다아래 조립 순서에서 JpaRepositoryFactory가 만들어 내는 산출물
EntityManager영속성 컨텍스트를 들고 실제 SQL 실행을 트리거하는 JPA 표준 인터페이스아래 사용자 코드의 OrderRepositoryImpl 생성자 주입

인터페이스가 프록시로 조립되는 과정

sequenceDiagram
    participant Boot as 애플리케이션 기동
    participant FB as JpaRepositoryFactoryBean
    participant F as JpaRepositoryFactory
    participant PF as ProxyFactory

    Boot->>FB: OrderRepository 빈 등록(@EnableJpaRepositories 스캔)
    FB->>FB: afterPropertiesSet()
    FB->>F: createRepositoryFactory() → new JpaRepositoryFactory(entityManager)
    FB->>F: getRepository(OrderRepository, fragments)
    F->>F: getEntityInformation(Order) → JpaEntityInformation 생성
    F->>F: getTargetRepository(...) → new SimpleJpaRepository(entityInformation, em)
    F->>PF: setTarget(simpleJpaRepository)
    PF->>PF: addAdvice(QueryExecutorMethodInterceptor)
    PF->>PF: addAdvice(ImplementationMethodExecutionInterceptor)
    F-->>FB: getProxy(classLoader)
    FB-->>Boot: OrderRepository 프록시(빈 등록 완료)

스프링 부트는 @SpringBootApplication에 포함된 @EnableJpaRepositories로 JpaRepository를 상속한 인터페이스를 스캔한다. 스캔된 인터페이스마다 JpaRepositoryFactoryBean이 빈으로 등록되고, 이 빈의 afterPropertiesSet()(스프링 빈 생명주기의 InitializingBean 콜백)이 실제 조립을 시작한다.

afterPropertiesSet()은 먼저 JpaRepositoryFactory를 만들어 entityManager를 넘긴다. 이 팩토리는 리포지토리 인터페이스의 도메인 타입(Order)을 읽어 JpaEntityInformation을 먼저 만들고, 리플렉션으로 SimpleJpaRepository(entityInformation, entityManager) 생성자를 호출해 타깃 인스턴스를 만든다. 이 entityInformation이 뒤에서 볼 isNew() 판정의 실체다.

타깃이 준비되면 ProxyFactory가 인터페이스와 두 개의 어드바이스를 붙여 프록시를 완성한다. 하나는 파생 쿼리와 @Query 메서드를 담당하는 QueryExecutorMethodInterceptor이고, 다른 하나는 커스텀 Impl과 SimpleJpaRepository로 나머지 호출을 위임하는 ImplementationMethodExecutionInterceptor다. RepositoryFactoryBeanSupport는 이 프록시를 Lazy로 감싸 두지만, afterPropertiesSet() 마지막 줄에서 그 Lazy를 즉시 풀기 때문에 프록시는 빈이 만들어지는 시점에 이미 완성된 상태로 존재한다.

두 인터셉터는 프록시가 완성되기 전에 인터페이스의 메서드를 하나씩 전부 훑는다. findByStatus는 쿼리 메서드 실행기로, save는 SimpleJpaRepository로, searchByFilter는 커스텀 Impl로 — 이렇게 메서드와 목적지를 짝지은 표를 만들어 프록시 안에 들고 있는다. @Query의 JPQL을 검증하고 findByStatusAndCreatedAtAfter 같은 이름을 프로퍼티 단위로 쪼개 쿼리 객체까지 만들어 두는 작업도 이때 끝낸다.

그래서 orderRepository.findByStatus(...)가 들어오는 순간 프록시가 하는 일은 그 표에서 목적지를 꺼내는 조회 한 번이다. 애너테이션을 다시 읽거나 메서드 이름을 다시 파싱하는 일은 호출 경로에 없다. 이득과 대가가 함께 온다. findByStatuss처럼 없는 프로퍼티를 가리키는 이름은 그 메서드를 처음 호출할 때가 아니라 기동 단계에서 PropertyReferenceException으로 바로 실패한다. 대신 기동 시간은 리포지토리 수와 메서드 수에 비례해 늘어난다.

메서드 호출의 라우팅: 커스텀 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()로 보낸다. 그 판정 로직은 save() 안에 있지 않고, 프록시 조립 시점에 만들어진 entityInformation에 위임되어 있다. isNew() 판정은 세 단계를 순서대로 확인한다.

순서판정 기준결과
1엔티티가 Persistable<ID>를 구현entity.isNew()가 돌려주는 값을 그대로 사용
2@Version 필드가 존재(객체 타입)버전 값이 null이면 신규
3그 외(@Id 기준)@Id 값이 null(객체 타입) 또는 0(기본형)이면 신규

1번과 2번은 각각 별도 EntityInformation 구현체(JpaPersistableEntityInformation, JpaMetamodelEntityInformation)가 담당한다. Persistable을 구현하지 않고 @Version도 없는 평범한 엔티티는 그 두 구현체를 거치지 않고 항상 3번, 마지막 fallback 클래스로 떨어진다.

📂 참고 소스: org.springframework.data.jpa.repository.support.SimpleJpaRepository#save

// spring-data-jpa 3.3.5 · SimpleJpaRepository#save (핵심 발췌)
@Override
@Transactional
public <S extends T> S save(S entity) {
 
    Assert.notNull(entity, "Entity must not be null");
 
    if (entityInformation.isNew(entity)) {
        entityManager.persist(entity);
        return entity;
    } else {
        return entityManager.merge(entity);
    }
}

📂 참고 소스: org.springframework.data.repository.core.support.AbstractEntityInformation#isNew

// spring-data-commons 3.3.5 · AbstractEntityInformation#isNew (3단계의 최종 fallback)
@Override
public boolean isNew(T entity) {
 
    ID id = getId(entity);
    Class<ID> idType = getIdType();
 
    if (!idType.isPrimitive()) {
        return id == null;             // Order.id: Long? → null 여부만 본다
    }
    if (id instanceof Number n) {
        return n.longValue() == 0L;
    }
    throw new IllegalArgumentException(String.format("Unsupported primitive id type %s", idType));
}

IDENTITY 전략인 엔티티는 INSERT 전엔 id가 null 혹은 0L 이라 fallback에서 신규로 판정되고 persist() 경로로 간다. 같은 order를 조회해 상태만 바꾼 뒤 다시 save()에 넘기면 이번엔 id가 채워져 있으니 merge() 경로로 간다. merge()는 detached 인스턴스를 그대로 영속화하지 않고 같은 id의 관리 인스턴스를 먼저 SELECT로 찾아 그 위에 필드 값을 옮겨 담기 때문에, 최초 저장이 아닌 갱신 한 건마다 SELECT 한 번이 수행된다.

// 잘못된 패턴: @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
}

UUID를 id 타입으로 가져간다면, 잘못된 패턴대로 저장하면 없는 행을 찾는 SELECT가 매번 먼저 나가고(select ... where id=? 다음 insert), Persistable을 구현한 올바른 패턴은 SELECT 없이 insert 하나로 끝난다.

@CreatedDate는 Spring Data Auditing(@EnableJpaAuditing)이 영속화 직전에 채워주므로 최초 저장 시점에는 항상 null이다.

조회할 때 SELECT 시점이 갈리는 지점: findById 대 getReferenceById

두 메서드 모두 SimpleJpaRepository 안에서 EntityManager의 표준 스펙 메서드이다. findById는 entityManager.find(...)를 호출해 1차 캐시에 같은 @Id의 인스턴스가 있으면 그것을 돌려주고, 없으면 즉시 SELECT를 실행해 채운다. getReferenceById(과거 getOne()의 대체)는 entityManager.getReference(...)를 호출해 SELECT 없이 프록시(대리 객체)만 즉시 돌려준다.

val order = orderRepository.findById(1L)      // 즉시 SELECT(또는 1차 캐시 히트)
// DEBUG org.hibernate.SQL: select o1_0.id,o1_0.amount,o1_0.status from orders o1_0 where o1_0.id=?
val ref = orderRepository.getReferenceById(1L) // 이 시점엔 SELECT 없음. id 외 필드 접근 시 지연 로딩

같은 트랜잭션 안에서 findById(1L)을 두 번 호출해도 위 SELECT는 한 번만 나간다. 두 번째 호출은 1차 캐시가 같은 인스턴스를 그대로 돌려주기 때문이다. getReferenceById가 돌려준 프록시는 id 외의 필드에 처음 접근하는 순간에 SELECT를 실행한다. 그때 영속성 컨텍스트가 닫혀 있으면 LazyInitializationException, 실제로 그 id의 행이 없으면 EntityNotFoundException이 난다.

삭제 시 SELECT가 한 번 더 나가는 이유와 deleteAllInBatch의 트레이드오프

delete(entity)에 넘어오는 엔티티는 다른 트랜잭션에서 조회했거나 요청 값으로 새로 만든 detached 상태일 수 있다. JPA 스펙상 EntityManager.remove()는 managed 상태에만 안전하게 적용되므로, SimpleJpaRepository는 지우기 전에 관리 상태부터 확인한다.

val managed = orderRepository.findById(1L).orElseThrow()
orderRepository.delete(managed)              // em.contains(managed) == true → find 생략, DELETE만
 
val detached = Order(id = 2L, status = OrderStatus.CREATED, amount = 500)
orderRepository.delete(detached)             // em.contains(detached) == false → SELECT 후 DELETE
-- managed: SELECT는 findById가 낸 것 하나뿐, delete는 DELETE만 추가된다
DEBUG org.hibernate.SQL: select o1_0.id,o1_0.amount,o1_0.status from orders o1_0 where o1_0.id=?
DEBUG org.hibernate.SQL: delete from orders where id=?
 
-- detached: delete 안에서 SELECT가 한 번 더 나간다
DEBUG org.hibernate.SQL: select o1_0.id,o1_0.amount,o1_0.status from orders o1_0 where o1_0.id=?
DEBUG org.hibernate.SQL: delete from orders where id=?

deleteById(id)는 findById로 조회한 결과를 delete(entity)에 그대로 넘기므로 조회된 엔티티는 항상 managed 상태다. SELECT는 findById 쪽에서 최대 1회만 나가고 delete 쪽에서는 추가되지 않는다. 대상이 이미 삭제되어 SELECT 결과가 없으면 끝나고, 이미 지워진 행을 다시 지우려 해도 예외는 나지 않는다.

deleteAllInBatch()는 이 확인 절차 전체를 건너뛰고 DB에 즉시 반영한다.

구분쿼리 수(N건 기준)영속성 컨텍스트 반영라이프사이클 콜백cascade
deleteAll()SELECT 1 + DELETE N반영됨(건별 remove)실행됨적용됨
deleteAllInBatch()DELETE 1반영 안 됨건너뜀적용 안 됨(DB 제약에 위임)

📂 참고 소스: org.springframework.data.jpa.repository.support.SimpleJpaRepository#deleteAllInBatch

// spring-data-jpa 3.3.5 · SimpleJpaRepository#deleteAllInBatch (핵심 발췌)
@Override
@Transactional
public void deleteAllInBatch() {
    // getDeleteAllQueryString() → "delete from %s x" 에 엔티티 이름을 채운 JPQL
    Query query = entityManager.createQuery(getDeleteAllQueryString());
    applyQueryHints(query);
    query.executeUpdate();   // 벌크 연산: 영속성 컨텍스트를 거치지 않고 DB에 바로 반영
}

entityManager.createQuery(...).executeUpdate() 로 수행된다.entityManager.remove()를 건별로 호출하는 deleteAll()과 달리 영속성 컨텍스트를 거치지 않고 DB에 바로 반영된다. 이미 1차 캐시에 올라온 인스턴스는 삭제 사실을 모른 채 남아, 같은 트랜잭션에서 findById로 그 id를 다시 조회하면 캐시가 먼저 응답해 DB에는 없는 행이 여전히 존재하는 것처럼 보인다. @PreRemove/@PostRemove 콜백과 cascade 삭제도 건너뛰므로, 자식 테이블에 외래키 제약이 있으면 벌크 DELETE 자체가 제약 위반으로 실패한다.

saveAll과 벌크 저장

saveAll(entities)는 entities를 순회하며 save(entity)를 한 건씩 호출하는 단순 루프다. JDBC 배치(여러 INSERT를 하나의 네트워크 왕복으로 묶는 것)처럼 쿼리가 발생하지 않는다. 실제 배치 여부는 하이버네이트 설정 spring.jpa.properties.hibernate.jdbc.batch_size에 달려 있다. 이 값을 100 정도로 켜면 하이버네이트가 flush 시점에 같은 모양의 INSERT/UPDATE를 모아 PreparedStatement.addBatch()로 묶는다.

orderRepository.saveAll(
    listOf(
        Order(status = OrderStatus.CREATED, amount = 1_000),
        Order(status = OrderStatus.CREATED, amount = 2_000),
        Order(status = OrderStatus.CREATED, amount = 3_000),
    ),
)
// @GeneratedValue(strategy = GenerationType.IDENTITY) 라면 batch_size 를 켜도 무력화된다.
// DB가 INSERT를 실행해야만 PK를 알 수 있어, 하이버네이트가 각 INSERT를 즉시 실행해 PK를 읽어야 하기 때문이다.

대량 저장이 필요하면 PK 전략을 SEQUENCE나 TABLE로 바꾸거나 JdbcTemplate.batchUpdate로 우회한다.

트랜잭션 경계와 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가 매번 나가고, 리포지토리 호출 빈도가 늘면 커넥션 풀에서 커넥션을 빌리고 반납하는 빈도도 함께 늘어난다.

readOnly=true는 하이버네이트 세션의 플러시 모드를 MANUAL로 바꾼다. 플러시는 쿼리 실행 직전과 커밋 시점에 변경분을 SQL로 내보내는 작업인데, MANUAL이면 자동으로 실행되지 않는다. 같은 트랜잭션 안에서 여러 SELECT를 실행해도 영속 엔티티 전체를 원본 스냅샷과 비교하는 더티 체킹을 건너뛰고, 조회된 엔티티도 변경 여부를 비교할 원본 스냅샷 자체를 보관하지 않는다. 조회 건수가 많을수록 메모리 절감 폭이 커지는 대신, readOnly 트랜잭션 안에서 엔티티 필드를 바꿔도 커밋 시점에 반영되지 않는다. DB가 실제로 어떤 스냅샷(Read View)을 보게 되는지는 격리 수준이 정한다.

기본 구현을 바꾸는 세 가지 방법

방법적용 범위코드 위치언제
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. 여는 절의 OrderRepositoryCustom/OrderRepositoryImpl과 같은 구조이고,
// 구현체만 EntityManager 대신 QueryDSL의 JPAQueryFactory로 바꿀 수 있다
class OrderRepositoryImpl(
    private val queryFactory: JPAQueryFactory,
) : OrderRepositoryCustom {
    override fun sumAmountAbove(threshold: Long): Long =
        queryFactory.select(order.amount.sum())
            .from(order)
            .where(order.amount.gt(threshold))
            .fetchOne() ?: 0L
}

방법 2는 여는 절에서 본 OrderRepository/OrderRepositoryCustom 선언을 그대로 두고 구현체만 바꾼 예다. OrderRepository는 표준 CRUD, 파생 쿼리, sumAmountAbove(커스텀 Impl) 세 목적지를 한 인터페이스 안에 모두 갖는다. 목적지 결정은 앞서 본 라우팅 표 그대로, 한 인터페이스 안에 세 갈래가 겹쳐 적용되는 예다.

언제 무엇을 쓰는가

상황선택근거
단건 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 쿼리 한 번을 아낀다
UUID를 애플리케이션에서 직접 채우는 PKPersistable 구현그렇지 않으면 최초 저장마다 SELECT가 추가로 나간다