Spring Data Repository — 인터페이스만 만들면 구현체가 생기는 원리
인터페이스만 정의했을 뿐인데, 누가 구현체를 만들어서 빈으로 등록해주는 걸까요?
Spring Data JPA를 처음 접하면 마법처럼 느껴집니다. 인터페이스에 메서드 이름만 적으면 쿼리가 자동으로 생성되고, 구현 클래스도 직접 작성할 필요가 없습니다. 이 원리를 이해하면 Spring Data의 강력함을 제대로 활용할 수 있습니다.
개념 정의
Spring Data Repository 는 데이터 접근 계층(DAO)을 인터페이스만으로 정의할 수 있게 해주는 추상화입니다. 개발자가 인터페이스를 선언하면, Spring이 런타임에 구현체를 자동 생성 하여 빈으로 등록합니다.
public interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findByName(String name);
}
이 한 줄의 인터페이스만으로 CRUD 메서드, 페이징, 정렬, 그리고 findByName 쿼리까지 모두 사용할 수 있습니다.
Repository 계층 구조
Spring Data JPA의 Repository는 여러 단계로 나뉘어 있습니다.
Repository (마커 인터페이스)
└── CrudRepository (기본 CRUD)
└── ListCrudRepository (List 반환)
└── ListPagingAndSortingRepository (페이징 + 정렬)
└── JpaRepository (JPA 특화: flush, batch 등)
각 계층이 제공하는 기능은 다음과 같습니다.
- Repository: 마커 인터페이스로, Spring이 빈으로 인식하는 기준입니다.
- CrudRepository:
save(),findById(),delete(),count()등 기본 CRUD를 제공합니다. - ListCrudRepository:
findAll()이Iterable대신List를 반환합니다. - JpaRepository:
flush(),saveAllAndFlush(),deleteInBatch()등 JPA 특화 메서드를 추가합니다.
구현체 자동 생성 원리
동작 과정
- ** 컴포넌트 스캔 **:
@EnableJpaRepositories(Spring Boot에서는 자동 설정)가Repository를 상속한 인터페이스를 찾습니다. - ** 프록시 생성 **: 각 인터페이스에 대해
JdkDynamicAopProxy를 사용하여 프록시 객체를 생성합니다. - SimpleJpaRepository: 프록시 내부에서 실제 로직은
SimpleJpaRepository클래스가 처리합니다. - ** 빈 등록 **: 생성된 프록시를 스프링 빈으로 등록합니다.
SimpleJpaRepository가 실제 CRUD 구현을 담당합니다. findById()는 내부적으로 em.find()를 호출합니다.
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
private final EntityManager em;
@Override
public Optional<T> findById(ID id) {
return Optional.ofNullable(em.find(getDomainClass(), id));
}
save() 메서드는 엔티티가 새것인지 판단하여 persist()와 merge()를 자동으로 분기합니다. @Id가 null이면 새 엔티티로 판단합니다.
@Override
@Transactional
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity); // 새 엔티티 → INSERT
return entity;
} else {
return em.merge(entity); // 기존 엔티티 → SELECT + UPDATE
}
}
}
메서드 이름 파싱 규칙
Spring Data는 메서드 이름을 분석하여 JPQL 쿼리를 자동 생성합니다.
기본 구조
find + By + 조건필드 + 키워드 + And/Or + 조건필드 + 키워드
주요 키워드
| 키워드 | 예시 | SQL 조건 |
|---|---|---|
| Is, Equals | findByName(String) | = ? |
| Between | findByAgeBetween(int, int) | BETWEEN ? AND ? |
| LessThan | findByAgeLessThan(int) | < ? |
| GreaterThanEqual | findByAgeGreaterThanEqual(int) | >= ? |
| Like | findByNameLike(String) | LIKE ? |
| Containing | findByNameContaining(String) | LIKE %?% |
| In | findByAgeIn(Collection) | IN (?, ?, ...) |
| OrderBy | findByNameOrderByAgeDesc(String) | ORDER BY age DESC |
| Not | findByNameNot(String) | <> ? |
| IsNull | findByEmailIsNull() | IS NULL |
반환 타입
// 단건
Optional<Member> findByEmail(String email);
Member findByName(String name);
// 다건
List<Member> findByAge(int age);
Stream<Member> findByAgeGreaterThan(int age);
// 페이징
Page<Member> findByName(String name, Pageable pageable);
Slice<Member> findByAge(int age, Pageable pageable);
// 존재 여부 / 개수
boolean existsByEmail(String email);
long countByAge(int age);
// 삭제
void deleteByName(String name);
long deleteByAge(int age);
@Query — 직접 쿼리 작성
메서드 이름 파싱으로 표현하기 어려운 쿼리는 @Query로 직접 작성합니다.
JPQL
@Query("SELECT m FROM Member m WHERE m.age > :age AND m.team.name = :teamName")
List<Member> findActiveMembers(@Param("age") int age, @Param("teamName") String teamName);
네이티브 SQL
@Query(value = "SELECT * FROM member WHERE age > :age", nativeQuery = true)
List<Member> findByAgeNative(@Param("age") int age);
벌크 수정 쿼리
@Modifying(clearAutomatically = true)
@Query("UPDATE Member m SET m.age = m.age + 1 WHERE m.team.id = :teamId")
int bulkAgePlus(@Param("teamId") Long teamId);
Projection — 필요한 데이터만 조회
엔티티 전체가 아닌 특정 필드만 조회하고 싶을 때 Projection을 사용합니다.
인터페이스 기반 Projection (Closed)
// 인터페이스 정의
public interface MemberNameOnly {
String getName();
String getTeamName(); // team.name을 자동 매핑
}
// Repository에서 사용
List<MemberNameOnly> findProjectionsByName(String name);
Closed Projection은 getter에 해당하는 컬럼만 SELECT하므로 성능상 유리합니다.
DTO 기반 Projection
// DTO 클래스
public record MemberDto(String name, int age) {}
// JPQL에서 생성자 표현식 사용
@Query("SELECT new com.example.dto.MemberDto(m.name, m.age) FROM Member m")
List<MemberDto> findMemberDtos();
동적 Projection
// 제네릭으로 반환 타입을 동적으로 결정
<T> List<T> findByName(String name, Class<T> type);
// 사용
List<MemberNameOnly> names = memberRepository.findByName("심", MemberNameOnly.class);
List<MemberDto> dtos = memberRepository.findByName("심", MemberDto.class);
커스텀 Repository 구현
Spring Data가 자동 생성해줄 수 없는 복잡한 로직이 필요할 때는 커스텀 구현을 추가합니다.
// 1. 커스텀 인터페이스 정의
public interface MemberRepositoryCustom {
List<Member> findMembersByComplexCondition(MemberSearchCondition condition);
}
// 2. 구현 클래스 (이름 규칙: 인터페이스명 + Impl)
public class MemberRepositoryImpl implements MemberRepositoryCustom {
private final EntityManager em;
@Override
public List<Member> findMembersByComplexCondition(MemberSearchCondition condition) {
// 복잡한 동적 쿼리 로직
return em.createQuery(/* ... */).getResultList();
}
}
// 3. Repository에서 상속
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
// JpaRepository 메서드 + 커스텀 메서드 모두 사용 가능
}
구현 클래스의 이름은 반드시 Impl 접미사 를 붙여야 합니다. Spring Data가 이 규칙으로 구현체를 찾아 연결합니다.
주의할 점
save()가 새 엔티티인지 판단하는 기준을 모르면 merge 함정에 빠진다
SimpleJpaRepository.save()는 엔티티가 새것인지(isNew) 판단하여 persist() 또는 merge()를 호출합니다. @Id가 null이면 새 엔티티로 판단하는데, ID를 직접 할당하는 전략에서는 항상 merge()가 호출되어 ** 불필요한 SELECT**가 발생합니다. 이 경우 Persistable 인터페이스를 구현하여 isNew 로직을 재정의해야 합니다.
커스텀 Repository 구현체의 이름 규칙을 지키지 않으면 동작하지 않는다
커스텀 구현 클래스의 이름은 ** 인터페이스명 + Impl** 접미사를 붙여야 합니다. MemberRepositoryCustom의 구현체는 MemberRepositoryImpl이어야 합니다. 이 규칙을 어기면 Spring Data가 구현체를 찾지 못합니다.
메서드 이름이 길어지면 @Query로 대체해야 한다
findByNameAndAgeGreaterThanAndTeamNameOrderByCreatedDateDesc 같은 메서드명은 가독성이 극도로 나쁩니다. 조건이 3개 이상이면 @Query로 직접 작성하는 것이 유지보수에 유리합니다.
정리
| 항목 | 설명 |
|---|---|
| 구현체 생성 | JDK Dynamic Proxy로 런타임에 자동 생성 |
| 메서드 이름 파싱 | findBy + 조건필드 + 키워드로 JPQL 자동 생성 |
| @Query | 복잡한 쿼리를 JPQL 또는 네이티브 SQL로 직접 작성 |
| Projection | 필요한 컬럼만 SELECT하여 성능 최적화 |
| 커스텀 Repository | Impl 접미사 규칙으로 복잡한 로직 확장 |