Customizing Individual Repositories

2026. 2. 10. 21:55Spring Boot/Spring Data JPA

Spring Data JPA 커스텀 리포지토리 🧩

“Repository는 왜 Fragment(조각)로 조립되는가?”

Spring Data Repository는 겉으로는 UserRepository extends JpaRepository<...> 같은 “인터페이스” 하나로 끝나는 것처럼 보이지만, 내부적으로는 여러 조각(Fragment) 을 모아 만든 합성(Composition) 프록시입니다.
여기서 말하는 Fragment는 크게 세 종류입니다:

  • Base Repository (예: SimpleJpaRepository 기반 CRUD 구현)
  • Repository Aspects (예: Querydsl, QBE 같은 기능 조각)
  • Custom Fragment Interfaces + Implementations (우리가 직접 만드는 조각)

즉, Repository는 “상속”이 아니라 조립(Composition) 으로 확장됩니다. 🔧

 

1) Customizing Individual Repositories: 가장 기본 패턴 🧱

1-1. Fragment 인터페이스 + Impl 구현체 만들기

Spring Data에서 개별 리포지토리에 커스텀 기능을 추가하려면, 먼저 Fragment 인터페이스를 만들고, 그에 대응하는 구현체(Impl postfix) 를 만듭니다.

interface CustomizedUserRepository {
  void someCustomMethod(User user);
}

class CustomizedUserRepositoryImpl implements CustomizedUserRepository {

  @Override
  public void someCustomMethod(User user) {
    // Your custom implementation
  }
}

여기서 핵심 규칙 🔥

  • 인터페이스 이름 + Impl 이 기본 탐색 규칙입니다.
    • CustomizedUserRepositoryCustomizedUserRepositoryImpl

그리고 postfix(기본값 Impl)는 설정으로 바꿀 수 있습니다:

  • @Enable<StoreModule>Repositories(repositoryImplementationPostfix = …)

(JPA라면 @EnableJpaRepositories(...))

 

2) “예전 방식(Deprecated)” — Repository 이름 기반 단일 커스텀 구현 😵‍💫

문서가 말하는 “Historically…”는 과거 Spring Data가 커스텀 구현을 찾던 방식입니다.

  • 과거에는 “리포지토리 인터페이스 이름”에서 커스텀 구현체 이름을 유도해서
    사실상 1개의 커스텀 구현만 붙이기 쉬운 패턴이 있었습니다.

하지만 문서는 분명히 말합니다:

  • ❌ 이 “single-custom implementation naming”은 deprecated
  • ✅ 이제는 fragment-based programming model 로 옮기라고 권장

왜냐하면 단일 구현 naming 패턴은 원치 않는 동작(undesired behavior) 을 유발할 수 있기 때문입니다.
특히 아래 규칙이 중요합니다 👇

 

3) “같은 패키지 + 리포지토리이름+postfix” 규칙의 위험 ⚠️

Spring Data는 다음 규칙을 갖고 있습니다:

“리포지토리 인터페이스가 있는 같은 패키지에,
(리포지토리 인터페이스 이름 + postfix) 클래스가 있으면
그것을 커스텀 구현으로 간주한다.”

즉, UserRepository가 있는 패키지에 UserRepositoryImpl 같은 클래스가 있으면
원치 않게 커스텀 구현으로 인식될 수 있습니다. 😱

그래서 문서가 말하는 것:

  • “A class following that name can lead to undesired behavior.”
  • 그래서 deprecated이고 fragment 방식으로 가라.

4) 구현체는 Spring Data에 의존하지 않는다 ✅ (그냥 Spring Bean)

문서의 중요한 포인트 중 하나:

“The implementation itself does not depend on Spring Data and can be a regular Spring bean.”

즉, CustomizedUserRepositoryImpl은:

  • 그냥 일반 클래스/스프링 빈이 될 수 있고
  • DI로 JdbcTemplate 같은 것도 주입 가능하고
  • AOP(트랜잭션, 로깅 등)도 적용 가능

👉 “리포지토리 커스텀 구현은 Spring Data 전용 특수 객체가 아니라 평범한 빈이다” 라는 뜻입니다. 🌱

 

5) 리포지토리 인터페이스에 Fragment 인터페이스를 “추가”해서 조립하기 🧩

이제 실제로 조립합니다:

interface UserRepository
  extends CrudRepository<User, Long>, CustomizedUserRepository {

  // Declare query methods here
}

이렇게 하면 UserRepository 프록시가 내부적으로:

  • base CRUD 기능 + CustomizedUserRepository 구현체 기능

을 합쳐서 클라이언트에 제공합니다. 🎁

 

6) Repository Composition: “조각의 우선순위와 충돌 해결” 🥊

Spring Data는 “Fragments are imported in the order of their declaration.” 라고 합니다.

즉, 아래처럼 여러 fragment를 붙이면:

interface UserRepository
  extends CrudRepository<User, Long>, HumanRepository, ContactRepository { }

Spring Data는 선언된 순서대로 fragment를 가져오고(import),
그리고 이 규칙이 매우 중요합니다:

  • Custom implementations have a higher priority than
    • base implementation
    • repository aspects

즉, 커스텀 구현이 base 메서드를 “가로채서” 바꿀 수 있습니다. 😈 (정상 기능입니다)

6-1. 메서드 시그니처 충돌(ambiguity) 해결

두 fragment가 동일한 메서드 시그니처를 제공하면?

  • “This ordering … resolves ambiguity if two fragments contribute the same method signature.”

👉 선언 순서 + 우선순위 규칙으로 결정됩니다.

 

7) Fragment 재사용 가능 ♻️ (여러 리포지토리에서 공유)

Fragment는 특정 repository 전용이 아닙니다.

interface CustomizedSave<T> {
  <S extends T> S save(S entity);
}

class CustomizedSaveImpl<T> implements CustomizedSave<T> {
  @Override
  public <S extends T> S save(S entity) {
    // custom save
  }
}

interface UserRepository
  extends CrudRepository<User, Long>, CustomizedSave<User> { }

interface PersonRepository
  extends CrudRepository<Person, Long>, CustomizedSave<Person> { }

여기서 핵심은:

  • CustomizedSave<T> 라는 fragment를 만들어두면
  • 여러 리포지토리에 “플러그인처럼” 붙여서
  • 공통 save 정책(감사로그, 검증, soft delete 등)을 재사용 가능 🎯

8) Configuration: Impl 탐색 범위와 postfix 규칙 🔍

문서가 말하는 자동 탐색 규칙:

  • “repository가 발견된 base-package 아래를 스캔”
  • “구현체는 postfix(기본 Impl)로 끝나야 함”

그리고 postfix는 바꿀 수 있습니다:

@EnableJpaRepositories(repositoryImplementationPostfix = "MyPostfix")
class Configuration { … }

이때 탐색 대상 클래스명은:

  • 기본: com.acme.repository.CustomizedUserRepositoryImpl
  • 변경: com.acme.repository.CustomizedUserRepositoryMyPostfix

즉 postfix를 바꾸면 클래스명 규칙도 함께 바뀝니다. 🧷

 

9) Resolution of Ambiguity: 같은 이름 구현체가 여러 개면? 🤔

만약 같은 클래스명 패턴을 가진 구현체가 “서로 다른 패키지”에서 발견되면,
Spring Data는 bean name 으로 선택합니다.

예:

class CustomizedUserRepositoryImpl implements CustomizedUserRepository { }

@Component("specialCustomImpl")
class CustomizedUserRepositoryImpl implements CustomizedUserRepository { }

여기서 기본 선택은 첫 번째 구현체입니다. 이유는:

  • 첫 번째 구현체의 bean name이 기본 규칙인
    customizedUserRepositoryImpl
  • 이것이 fragment interface 이름 + postfix(Impl)과 매칭되기 때문

그런데 UserRepository 인터페이스에:

  • @Component("specialCustom")

를 붙이면, Spring Data는:

  • repository bean name(specialCustom) + Impl
  • specialCustomImpl

과 매칭되는 구현체를 선택합니다. 🎯

즉, “리포지토리의 빈 이름”이 구현체 선택에도 영향을 줍니다.

 

10) Manual Wiring: 구현체를 “내가 직접” 빈으로 등록하고 싶다 🧵

보통은 자동 스캔 + 자동 와이어링으로 충분하지만,
구현체 fragment에 특별한 설정이 필요하면 수동 등록을 할 수 있습니다.

문서 요지:

  • bean을 직접 선언하되
  • 이름을 규칙에 맞춰서 등록하면
  • 인프라가 그 빈을 “자동 생성 대신” 참조한다.

예시 코드(문서의 Example 3):

class MyClass {
  MyClass(@Qualifier("userRepositoryImpl") UserRepository userRepository) {
    …
  }
}

여기서 포인트는:

  • userRepositoryImpl 같은 이름이 “규칙”과 연결되어 있다는 것
  • 수동 빈 등록 시에도 명명 규칙이 중요하다는 것 🏷️

11) Registering Fragments with spring.factories: 외부 라이브러리처럼 배포하기 📦

문서의 큰 제약:

“infrastructure only auto-detects fragments within repository base-package”

즉, fragment 구현이 다른 namespace(패키지)에 있거나
외부 jar로 제공되면 자동 탐색이 안 될 수 있습니다.

이를 우회하는 방식이:

  • META-INF/spring.factories 에 fragment 등록 ✅

11-1. 예: 조직 공통 SearchExtension 만들기 🔎

Fragment 인터페이스

public interface SearchExtension<T> {
    List<T> search(String text, Limit limit);
}
  • 제네릭 <T> 로 repository domain type에 맞추는 설계가 핵심입니다.

Fragment 구현체 + SearchService DI

class DefaultSearchExtension<T> implements SearchExtension<T> {

    private final SearchService service;

    DefaultSearchExtension(SearchService service) {
        this.service = service;
    }

    @Override
    public List<T> search(String text, Limit limit) {
        return search(RepositoryMethodContext.getContext(), text, limit);
    }

    List<T> search(RepositoryMethodContext metadata, String text, Limit limit) {

        Class<T> domainType = metadata.getRepository().getDomainType();

        String indexName = domainType.getSimpleName().toLowerCase();
        List<String> jsonResult = service.search(indexName, text, 0, limit.max());

        return jsonResult.stream().map(…).collect(toList());
    }
}

여기서 아주 중요한 문장 🧨

  • RepositoryMethodContext.getContext() 로 invocation metadata를 얻는다.
  • metadata에서 domain type을 얻어 index 이름을 만든다.
  • 즉 “어떤 리포지토리에서 호출되었는지”를 런타임에 알아내어 동작을 일반화한다.

12) RepositoryMethodContext 메타데이터 노출은 “비싸다” 💸 (기본 OFF)

문서가 직접 말합니다:

“Exposing invocation metadata is costly, hence it is disabled by default.”

그래서 RepositoryMethodContext.getContext() 를 쓰려면
repository factory에 metadata 노출을 켜야 합니다.

12-1. 해결책: RepositoryMetadataAccess 마커 인터페이스 ✅

구현체에 RepositoryMetadataAccess 를 추가하면 인프라가 감지하고 켭니다.

class DefaultSearchExtension<T>
  implements SearchExtension<T>, RepositoryMetadataAccess {
    // ...
}

 

13) spring.factories 등록 🧾

이제 외부 제공을 위해:

META-INF/spring.factories 에 등록합니다.

com.acme.search.SearchExtension=com.acme.search.DefaultSearchExtension

이렇게 패키징하면, repository base-package 스캔에 안 걸려도
해당 fragment를 “등록 기반”으로 주입할 수 있게 됩니다. 🚚

13-1. 사용하기: 리포지토리에 인터페이스만 추가하면 끝 ✅

interface MovieRepository
  extends CrudRepository<Movie, String>, SearchExtension<Movie> { }

즉, 사용자는 구현체를 몰라도 되고
interface만 추가하면 repository가 확장됩니다. 👌

 

14) Customize the Base Repository: 모든 리포지토리에 공통 적용 🌍

앞의 fragment 방식은 “각 repository interface에 붙여야” 합니다.
하지만 전체 리포지토리에 공통 정책을 강제하고 싶다면?

커스텀 base repository class 를 만들고,
repositoryBaseClass 로 갈아끼웁니다.

14-1. SimpleJpaRepository 상속

class MyRepositoryImpl<T, ID> extends SimpleJpaRepository<T, ID> {

  private final EntityManager entityManager;

  MyRepositoryImpl(JpaEntityInformation entityInformation,
                   EntityManager entityManager) {
    super(entityInformation, entityManager);
    this.entityManager = entityManager;
  }

  @Override
  @Transactional
  public <S extends T> S save(S entity) {
    // implementation goes here
  }
}

핵심 규칙 ⚙️

  • store-specific repository factory가 사용할 수 있도록
    슈퍼 클래스가 요구하는 생성자 형태를 반드시 제공해야 합니다.
  • 생성자가 여러 개면, 보통:
    • EntityInformation + EntityManager(또는 template) 받는 생성자를 오버라이드하라.

14-2. 설정으로 적용

@Configuration
@EnableJpaRepositories(repositoryBaseClass = MyRepositoryImpl.class)
class ApplicationConfiguration { … }

이제 모든 repository의 base가 MyRepositoryImpl이 됩니다. 🌐

 

15) Customizing the Repository Factory: “프록시 생성 과정”에 개입 🏭

fragment/base class보다 더 깊게 들어가면
repository instance 생성 파이프라인 자체를 만질 수 있습니다.

15-1. RepositoryFactoryCustomizer

문서 예시:

factoryBean.addRepositoryFactoryCustomizer(repositoryFactory -> {
  repositoryFactory.addInvocationListener(…);
  repositoryFactory.addQueryCreationListener(…);

  repositoryFactory.addRepositoryProxyPostProcessor((factory, repositoryInformation) ->
    factory.addAdvice(…)
  );
});

이 방식의 성격 🎛️

  • “완전한 커스텀 팩토리”까지는 아니고
  • “선택적 조정(리스너/어드바이스 추가)”에 좋음
  • 특정 repository만 영향 주도록 BeanPostProcessor로 연결하는 게 이상적
  • 그리고 아주 중요한 주의:

customizer beans are not applied automatically
(특히 multi-repository / multi-module에서 원치 않는 결합 방지)

 

16) Customize the Repository Factory Bean: 가장 강력하지만 가장 무겁다 🧨

문서가 말하는 최강 옵션:

  • 커스텀 repository factory bean 제공
    (RepositoryFactorySupport, TransactionalRepositoryFactoryBeanSupport, store-specific factory bean 상속)

이 방식은:

  • repository 생성 전체를 바꿀 수 있지만
  • 노력도 가장 크고
  • 보통 “코어 생성 메커니즘을 바꿔야 할 때만” 필요합니다.

문서가 요약한 핵심 고려사항 3개 🎯

  1. repositoryBaseClass
  • base class가 어떤 메서드를 구현하고
  • 어떤 메서드는 aspect/custom impl이 처리해야 하는지 결정
  1. repositoryFragmentsContributor
  • 표준 fragment 수집 이후 조립에 추가 기여
  • store module이 Querydsl/QBE 같은 기능을 붙일 때도 사용
  • third-party extension SPI 역할도 함
  1. exposeMetadata
  • RepositoryMethodContext.getContext() 메타데이터 노출 여부

17) Using JpaContext in Custom Implementations: 다중 EntityManager 환경 🧠

마지막 파트는 실전에서 매우 자주 터지는 문제입니다.

여러 EntityManager가 있을 때
커스텀 구현체에 “올바른” EntityManager를 어떻게 주입할 것인가?

일반적 방법:

  • @PersistenceContext(name=...) 로 명시
  • @Autowired + @Qualifier 로 특정 EM 주입

그런데 Spring Data JPA 1.9부터는 더 편한 도구가 있습니다:

JpaContext

17-1. 도메인 타입으로 EntityManager 얻기

class UserRepositoryImpl implements UserRepositoryCustom {

  private final EntityManager em;

  @Autowired
  public UserRepositoryImpl(JpaContext context) {
    this.em = context.getEntityManagerByManagedType(User.class);
  }

  …
}

장점 ✨

  • User가 다른 persistence unit으로 옮겨가도,
  • repository 구현 코드를 바꾸지 않고도
  • context가 적절한 EntityManager를 찾아줍니다.

단, 전제 조건이 있습니다:

  • 해당 도메인 타입이 하나의 EntityManager에 의해 관리된다고 “가정”할 수 있어야 합니다.

 

마무리: 언제 무엇을 써야 하나요? 🧭 (정리)

  • 특정 리포지토리만 기능 추가: Fragment 인터페이스 + Impl (권장 패턴)
  • 여러 리포지토리에 공통 기능 재사용: Generic Fragment (SearchExtension<T>, CustomizedSave<T> 같은)
  • 외부 라이브러리/다른 패키지에서 fragment 제공: spring.factories 등록
  • 모든 리포지토리 save/find 등 동작을 일괄 변경: repositoryBaseClass (커스텀 SimpleJpaRepository)
  • 프록시 생성/리스너/어드바이스 수준에서 개입: RepositoryFactoryCustomizer
  • 생성 메커니즘 자체를 바꿔야 함: Custom Repository Factory Bean (최후의 수단)
  • 다중 EntityManager에서 도메인별 EM 자동 선택: JpaContext

'Spring Boot > Spring Data JPA' 카테고리의 다른 글

Spring Data  (0) 2025.03.07
Spring Data JPA  (0) 2024.10.24
JPA Query Methods  (0) 2024.10.23
Auditing  (0) 2024.10.22
OPTIMISTIC, OPTIMISTIC_FORCE_INCREMENT  (0) 2024.10.22