Spring Data JPA는 Spring Data Commons 를 바탕으로 Jakarta Persistence API( JPA )를 지원하는 라이브러리다.
간단하게 JPA의 개념을 알아보고 Spring Data JPA 에서 어떻게 지원하는지를 알아보자
ORM( Object-Relational Mapping ) 은 명칭에서 볼 수 있듯이 객체와 관계형 DB를 서로 매핑 시키는 것을 의미한다.
일반적인 방법에서 DB 데이터에 접근하기 위해 SQL 쿼리를 보낸다면 ORM 에서는 객체와 테이블을 매핑시킴으로써 객체의 조작을 통해 DB 데이터를 조작할 수 있다.
즉, SQL 쿼리없이 프로그래밍 언어의 객체를 다룸으로써 DB를 제어할 수 있게 해준다.
대표적인 라이브러리로는 Java의 Hibernate가 있다.
MySQL, Oracle, MSSQL 등 DB들은 서로 약간씩 SQL문법이 다르다.
ORM은 SQL쿼리를 직접 작성하지 않기때문에 DB와의 결합도를 낮춰 다른 DB로의 전환을 쉽게 해준다.
하지만 ORM이 작성해주는 쿼리가 항상 완벽하지는 않다.
일반적인 상황에서의 쿼리작성을 전제로 한 기술이기 때문에 특정 상황에서는 직접 쿼리를 작성하는 것보다 성능이 떨어질 수도 있다. 비슷한 의미에서 복잡한 쿼리가 필요한 상황에서도 직접 쿼리를 작성하는 것이 효과적이다.
마지막으로 객체 모델과 DB 모델이 완벽하게 일치하지 않는다는 점에 주의해야한다.
간단한 예시로 Java에는 상속이 존재하지만 DB에는 상속이 존재하지 않는다.
ORM에 대해서는 간단하게 알아봤고, 이어서 JPA에 대해 알아보자
JPA 관련 정보를 찾다보면 Jakarta Persistence API와 Java Persistence API가 둘 다 JPA라고 불린다는 사실을 알 수 있다.
삼성 SDS 포스팅에 의하면 2018년 JavaEE에서 JakartaEE로 명칭을 바꿨다는 사실을 알 수 있다.즉, Java Persistence API는 Oracle 휘하의 JavaEE 일때의 명칭이고,
Jakarta Persistence API는 Eclipse 휘하의 JakartaEE로 명칭을 바꾸면서 함께 바뀌어진 케이스라고 볼 수 있다.
JPA는 Java환경에서 영속성과 ORM을 관리하기 위한 표준 인터페이스를 의미한다.
가장 잘 알려진 JPA의 구현체로는 Hibernate가 있으며, Spring Data JPA에서도 기본적으로 Hibernate를 사용한다.

Spring Data JPA의 핵심 인터페이스는 Repository다.
도메인 클래스와 해당 클래스의 식별자를 인수로 받으며,
이를 구현하는 CrudRepository와 ListCrudRepository를 통해 Entity 클래스에 대한 CRUD 기능을 제공한다.
또한, PagingAndSortingRepository와 ListPagingAndSortingRepository라는 페이징 기능을 제공하는 구현체도 존재한다.

흔히 사용하는 JpaRepository에서 해당 구현체들을 모두 상속하고 있는 것을 확인할 수 있다.
커스텀 Repository 인터페이스를 정의하기 위해서는 도메인 클래스와 Repository 상속이 반드시 필요하다.
도메인 클래스는 각 객체를 식별할 수 있는 식별자를 반드시 가지고 있어야 한다.
CRUD 기능을 확장하는 Repository를 정의하고 싶다면 CrudRepository나 ListCrudRepository를 상속하면 되고,
코틀린에서는 CoroutineCrudRepository,
reactive store에는 ReactiveCrudRepository나 RxJava3CrudRepository,
페이징 기법이 필요하면 PagingAndSortingRepository 등을 추가로 상속하면 된다.
@NoRepositoryBean
interface MyBaseRepository<T, ID> extends Repository<T, ID> {
Optional<T> findById(ID id);
<S extends T> S save(S entity);
}
만약, 애플리케이션의 Repository 들에서 공통적으로 사용하는 메서드 세트가 있다면 상속을 통해 한 인터페이스에서 관리하고 싶을 수 있다. 이땐, 위 예시처럼 공통 인터페이스에 @NoRepositoryBean 를 달아주면 해당 인터페이스는 Bean으로 관리되지 않는다.
애플리케이션에서 다양한 Spring Data 모듈( JPA나 Mongo 등 )을 사용하고 있다면 단순하게 Repository나 CrueRepository를 상속하는 것이 어떤 저장소와의 연결을 의미하는지 파악할 수 없다.
이러한 상황에서는 특정 모듈의 인터페이스를 구현하거나, Entity 클래스 생성에 관련된 애노테이션을 사용해야한다.
interface MyRepository extends JpaRepository<User, Long> { }
@NoRepositoryBean
interface MyBaseRepository<T, ID> extends JpaRepository<T, ID> { … }
interface UserRepository extends MyBaseRepository<User, Long> { … }
위 예시처럼 JPA 환경에서는 JpaRepository를 구현하여 해당 모듈을 사용한다고 명시할 수 있다.
또는 @Entity를 도메인 클래스에 작성하면 JPA 모듈을 사용한다는 것을 알려줄 수 있고,
@Document를 사용하면 Mongo 모듈을 사용한다는 것을 알려줄 수 있다.
@EnableJpaRepositories(basePackages = "com.acme.repositories.jpa")
@EnableMongoRepositories(basePackages = "com.acme.repositories.mongo")
class Configuration { … }
이 외의 방법으로 설정파일에 basePackages를 지정하여 특정 경로 하위의 Repository들에 적용되는 모듈을 지정하는 것도 가능하다.
Spring Data JPA는 애노테이션 기반의 Java Configuration과 XML Configuration 두 개의 설정파일 작성을 지원한다.
하단에 소개되는 예시코드에 대한 자세한 설명이 필요하다면 공식 문서를 참고하자
Spring Boot 환경이라면 자동구성에 의해 설정파일이 등록되므로, 별도의 설정없이 JPA 기능을 바로 사용할 수 있다.
@Configuration
@EnableJpaRepositories
@EnableTransactionManagement
class ApplicationConfig {
@Bean
public DataSource dataSource() {
EmbeddedDatabaseBuilder builder = new EmbeddedDatabaseBuilder();
return builder.setType(EmbeddedDatabaseType.HSQL).build();
}
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory() {
HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
vendorAdapter.setGenerateDdl(true);
LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
factory.setJpaVendorAdapter(vendorAdapter);
factory.setPackagesToScan("com.acme.domain");
factory.setDataSource(dataSource());
return factory;
}
@Bean
public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
JpaTransactionManager txManager = new JpaTransactionManager();
txManager.setEntityManagerFactory(entityManagerFactory);
return txManager;
}
}
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:jpa="http://www.springframework.org/schema/data/jpa"
xsi:schemaLocation="http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/data/jpa
https://www.springframework.org/schema/data/jpa/spring-jpa.xsd">
<jpa:repositories base-package="com.acme.repositories" />
</beans>
Repository의 save()를 이용하면 객체를 실제 DB에 저장할 수 있다.
JPA에서는 Entity가 DB와 매핑된 객체인지, 새롭게 생성된 객체인지 판단하기 위한 전략이 존재한다.
null이라면 새로운 객체라고 판단한다.null이라면 새로운 객체라고 판단한다. Persistable 구현 :Persistable를 구현한 Entity라면 isNew()를 호출하여 새로운 객체인지 판단한다.EntityInformation 구현 :JpaRepositoryFactory의 getEntityInformation()를 재정의하는 방식으로, 구현체를 Bean으로 등록해야한다.2번 전략의 경우, 아래 코드와 같이 구현이 가능하다.
@MappedSuperclass
public abstract class AbstractEntity<ID> implements Persistable<ID> {
@Transient
private boolean isNew = true;
@Override
public boolean isNew() {
return isNew;
}
@PrePersist
@PostLoad
void markNotNew() {
this.isNew = false;
}
// More code…
}
Spring Data JPA에서는 두 가지의 방법으로 쿼리를 파생시켜낸다.
- 메서드 이름에서 파생된 쿼리 사용
- 수동으로 정의된 쿼리 사용
기본적으로는 수동으로 작성된 쿼리가 존재하지 않을 경우, 메서드 이름에서 파생된 쿼리를 사용하는 CREATE_IF_NOT_FOUND 전략이 사용된다.
XML 설정에서 쿼리 생성 전략을 설정하는 방법으로는 query-lookup-strategy 속성을 이용하는 방법이 있고,
Java 설정에서 쿼리 생성 전략을 설정하는 방법으로는 @EnableJpaRepositories의 queryLookupStrategy 속성을 이용하는 방법이 있다.
설정 가능한 전략으로는 CREATE, USE_DECLARED_QUERY, CREATE_IF_NOT_FOUND 3가지가 존재한다.
각각 메서드 이름으로 생성, 수동 작성된 쿼리사용, 앞선 두 방식의 혼용 방식으로 동작한다.
만약,
USE_DECLARED_QUERY전략에서 수동작성된 쿼리가 없다면 예외가 발생한다.
interface PersonRepository extends Repository<Person, Long> {
List<Person> findByEmailAddressAndLastname(EmailAddress emailAddress, String lastname);
// Enables the distinct flag for the query
List<Person> findDistinctPeopleByLastnameOrFirstname(String lastname, String firstname);
List<Person> findPeopleDistinctByLastnameOrFirstname(String lastname, String firstname);
// Enabling ignoring case for an individual property
List<Person> findByLastnameIgnoreCase(String lastname);
// Enabling ignoring case for all suitable properties
List<Person> findByLastnameAndFirstnameAllIgnoreCase(String lastname, String firstname);
// Enabling static ORDER BY for a query
List<Person> findByLastnameOrderByFirstnameAsc(String lastname);
List<Person> findByLastnameOrderByFirstnameDesc(String lastname);
}
쿼리 메서드의 이름은 주어와 술어로 구분된다.
주어에 해당하는 부분은 처음 find..By나 exsists..By을 의미하며, 나머지 부분은 술어에 해당된다.
주어와 술어에 올 수 있는 키워드들은 이 문서에서 확인할 수 있다.
위 코드 예시에서 확인할 수 있듯이 Entity 객체의 특정 속성을 이용한 조건작성이 가능하다.
List<Person> findByAddressZipCode(ZipCode zipCode);
예를들어, 위 코드는 Person 객체가 Address 속성을 가지고있고 그 안에 ZipCode 속성이 존재하는 상황이다.
이때 확인 알고리즘은 AddressZipCode 전체와 일치하는 속성이 있는지를 먼저 확인한다.
만약 존재하지 않는다면 카멜케이스를 오른쪽 부터 끊어서 탐색한다.
즉,
AddressZipCode이후에는AddressZip과Code를 확인한다.
이 과정에서 Head(AddressZip)에 해당하는 속성이 있다면 그 안에Code속성이 존재하는지 확인한다.
이러한 확인과정에서 알 수 있듯이 모호함때문에 잘못된 결과가 출력될 가능성이 있다.
List<Person> findByAddress_ZipCode(ZipCode zipCode);
따라서, 이를 해결하기 위해서는 _를 이용하여 속성을 구분시켜주는게 좋다.
DB 쿼리를 작성하다보면 단일 결과가 아닌, 여러개의 결과값이 반환되는 쿼리가 존재한다.
쿼리 메서드에는 이를 위해 Iterable, List, Set, Streamable 타입 등으로 결과값을 받는 것이 가능하다.
쿼리 메서드가 반환하는 전체 타입은 이 문서를 확인하자.
Streamable는 Spring Data Commons에서Iterable를 확장한 함수형 인터페이스다
Iterable대신하여Stream으로 쉽게 변환할 수 있게 해준다
@Query("select u from User u")
Stream<User> findAllByCustomQueryAndStream();
Stream<User> readAllByFirstnameNotNull();
@Query("select u from User u")
Stream<User> streamAllPaged(Pageable pageable);
Streamable대신 Stream<T>를 직접적인 반환타입으로 사용할 수도 있다.
이 경우, 반드시 사용한 다음 close() 등을 통해 Stream을 닫아줘야한다.
Page<User> findByLastname(String lastname, Pageable pageable);
Slice<User> findByLastname(String lastname, Pageable pageable);
List<User> findByLastname(String lastname, Sort sort);
List<User> findByLastname(String lastname, Sort sort, Limit limit);
List<User> findByLastname(String lastname, Pageable pageable);
페이지처리를 위해서 제공하는 타입으로 Pageable, Slice, Sort, Limit 이 존재한다.
주의사항으로는 인수로 작성되는 Pageable, Sort, Limit은 null이 아닌 값이 올 것으로 간주된다는 점이 있다.
Pageable은 내부에 Sort, Limit을 포함하고 있다. 이들을 파라미터 조합에 함께 사용해서는 안된다.
반환결과로 사용되는 Page는 전체 페이지와 데이터 수를 포함하고 있다.
전체 데이터를 조회하는 비용이 추가되므로 상황에 따라서 Slice를 사용하자.
각 타입별 발생하는 쿼리 개수와 가져오는 데이터 양, 제약사항 및 세부사항은 공식 문서를 참고하자