[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 호출 |
| SimpleJpaRepository | save·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
프록시로 들어온 호출은 세 곳 중 하나로 위임된다.
| 목적지 | 결정 기준 |
|---|---|
| 커스텀 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()로 보낸다. 그 판정 로직은 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, 아니면 네이티브 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 쿼리 한 번을 아낀다 |
| UUID를 애플리케이션에서 직접 채우는 PK | Persistable 구현 | 그렇지 않으면 최초 저장마다 SELECT가 추가로 나간다 |