토리맘의 한글라이즈 프로젝트 logo 토리맘의 한글라이즈 프로젝트

스프링 데이터 R2DBC 공식 레퍼런스를 한글로 번역한 문서입니다.

전체 목차는 여기에 있습니다.

목차


스프링 데이터 레포지토리의 추상화 목표는, persistence 저장소마다 데이터 접근 레이어를 구현하는데 필요한 보일러플레이트를 확 줄이는 데에 있다.

스프링 데이터 레포지토리 문서와 각자의 스프링 데이터 모듈

이 챕터에선 스프링 데이터 레포지토리의 핵심 개념과 인터페이스를 설명한다. 이 챕터에서 다루는 내용은 스프링 데이터 공통 모듈에서 가져왔다. 설정 예제와 샘플 코드는 JPA(Java Persistence API) 모듈을 활용한다. XML 네임스페이스 선언과 타입은 각자 사용하려는 모듈에 맞게 바꿔야 한다. “[레포지토리 네임스페이스 레퍼런스]“에선 레포지토리 API를 지원하는 모든 스프링 데이터 모듈에서 사용할 수 있는 XML 설정을 다룬다. “레포지토리 쿼리 키워드“에선 범용 레포지토리 인터페이스가 지원하는 쿼리 메소드 키워드를 설명한다. 원하는 모듈에 있는 기능을 자세히 알고싶다면 해당 모듈 챕터를 확인해라.


11.1. Core concepts

스프링 데이터 레포지토리 추상화에 있어 핵심 인터페이스는 Repository다. Repository 인터페이스는 관리할 도메인 클래스와 도메인 클래스의 ID 타입을 타입 인자로 사용한다. 일종의 마커 인터페이스로, 처리할 타입을 알아내고, 이 인터페이스를 확장한 다른 인터페이스를 발견할 수 있도록 만드는 게 주된 역할이다. CrudRepository 인터페이스는 관리 중인 엔티티 클래스를 위한 정교한 CRUD 기능을 제공한다.

Example 3. CrudRepository Interface

public interface CrudRepository<T, ID> extends Repository<T, ID> {

  <S extends T> S save(S entity);      // (1)

  Optional<T> findById(ID primaryKey); // (2)

  Iterable<T> findAll();               // (3)

  long count();                        // (4)

  void delete(T entity);               // (5)

  boolean existsById(ID primaryKey);   // (6)

  // … more functionality omitted.
}

(1) 전달받은 엔티티를 저장한다.
(2) 전달받은 ID로 식별한 엔티티를 반환한다.
(3) 모든 엔티티를 반환한다.
(4) 엔티티 수를 반환한다.
(5) 전달받은 엔티티를 삭제한다.
(6) 전달받은 ID에 해당하는 엔티티가 있는지 알려준다.

JpaRepository, MongoRepository같이 persistence 기술에 특화된 인터페이스도 제공한다. 이런 인터페이스들은 CrudRepository를 상속하고 있으며, CrudRepository같이 persistence 기술에 구애받지 않는 범용 인터페이스 외에도, persistence 기술에 따른 전용 기능도 노출하고 있다.

CrudRepository 위에는, 쉽게 페이지를 처리할 수 있게 메소드를 추가한 PagingAndSortingRepository 인터페이스가 있다:

Example 4. PagingAndSortingRepository interface

public interface PagingAndSortingRepository<T, ID> extends CrudRepository<T, ID> {

  Iterable<T> findAll(Sort sort);

  Page<T> findAll(Pageable pageable);
}

페이지 크기를 20으로 잡고 두 번째 User 페이지에 접근하고 싶다면, 다음처럼 코드를 작성하면 된다:

PagingAndSortingRepository<User, Long> repository = // … get access to a bean
Page<User> users = repository.findAll(PageRequest.of(1, 20));

쿼리 메소드에선 count와 delete 쿼리를 위한 파생 쿼리(query derivation)도 만들 수 있다. 다음은 count 쿼리를 파생시키는 인터페이스 정의다:

Example 5. Derived Count Query

interface UserRepository extends CrudRepository<User, Long> {

  long countByLastname(String lastname);
}

이번엔 delete 쿼리를 파생시키는 인터페이스 정의다:

Example 6. Derived Delete Query

interface UserRepository extends CrudRepository<User, Long> {

  long deleteByLastname(String lastname);

  List<User> removeByLastname(String lastname);
}

11.2. Query Methods

표준 CRUD를 제공하는 레포지토리는 보통 저장소에 대한 쿼리를 가지고 있다. 스프링 데이터에선 네 단계로 쿼리를 선언하게 된다:

  1. 레포지토리나 하위 인터페이스 중 하나를 확장한 인터페이스를 정의하고, 처리할 도메인 클래스와 ID 타입을 지정한다:

    interface PersonRepository extends Repository<Person, Long> {  }
    
  2. 인터페이스에 쿼리 메소드를 정의한다:

    interface PersonRepository extends Repository<Person, Long> {
      List<Person> findByLastname(String lastname);
    }
    
  3. 이 인터페이스를 위한 프록시 인스턴스를 만들도록 스프링 자바 설정이나 XML 설정을 작성한다.

    a. 자바 설정에선 아래와 유사한 클래스를 만든다:

       import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
          
       @EnableJpaRepositories
       class Config {  }
    

    b. XML 설정에선 아래와 유사한 빈을 정의한다:

       <?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>
    

    이 예제에선 JPA 네임스페이스를 사용한다. 다른 저장소 전용 레포지토리 인터페이스를 사용한다면, 저장소 모듈에 맞는 네임스페이스 선언으로 변경해야 한다. 다시 말해 jpa를 각자에 맞게, 이를테면 mongodb 등으로 바꿔야 한다.

    덧붙이자면, 자바 설정에선 어노테이션을 달아준 클래스가 있는 패키지를 기본으로 사용하기 때문에 패키지를 명시하지 않았다. 스캔할 패키지를 커스텀하고 싶다면, 저장소 전용 레포지토리의 @Enable${store}Repositories-어노테이션에 있는 basePackage… 속성 중 하나를 사용해라.

  4. 사용처에 레포지토리 인스턴스를 주입한다:

    class SomeClient {
       
      private final PersonRepository repository;
       
      SomeClient(PersonRepository repository) {
        this.repository = repository;
      }
       
      void doSomething() {
        List<Person> persons = repository.findByLastname("Matthews");
      }
    }
    

이어지는 섹션에서 단계별로 하나씩 설명하겠다:


11.3. Defining Repository Interfaces

레포지토리 인터페이스를 정의하려면 먼저, 도메인 클래스 전용 레포지토리 인터페이스부터 정의해야 한다. 이 인터페이스는 반드시 Repository를 상속하고, 해당 도메인 클래스와 ID 타입을 지정해야 한다. 도메인 타입에 맞는 CRUD 메소드를 노출하려면 Repository 대신 CrudRepository를 상속해라.

11.3.1. Fine-tuning Repository Definition

보통 레포지토리 인터페이스를 만들 땐 RepositoryCrudRepository, PagingAndSortingRepository를 확장한다. 스프링 데이터 인터페이스를 확장하고 싶지 않다면, 레포지토리 인터페이스에 @RepositoryDefinition 어노테이션을 붙여도 된다. CrudRepository를 확장하면 엔티티를 조작할 수 있는 완전한 메소드 셋이 생긴다. 그보단 노출할 메소드를 직접 선택하고 싶다면 CrudRepository에서 원하는 메소드를 도메인 레포지토리로 복사해가라.

이렇게 하면 기본 스프링 데이터 레포지토리 기능 위에 자체 인터페이스를 정의하게 된다.

원하는 CRUD 메소드만 노출하는 방법은 다음 예제를 참고해라 (이 예제에선 findByIdsave):

Example 7. Selectively exposing CRUD methods

@NoRepositoryBean
interface MyBaseRepository<T, ID> extends Repository<T, ID> {

  Optional<T> findById(ID id);

  <S extends T> S save(S entity);
}

interface UserRepository extends MyBaseRepository<User, Long> {
  User findByEmailAddress(EmailAddress emailAddress);
}

위 예제에선 모든 도메인 레포지토리에 활용할 공통 베이스 인터페이스를 정의하고, findById(…)save(…) 메소드를 노출했다. 이렇게 정의한 메소드들은 CrudRepository의 메소드 시그니처와 일치하기 때문에, 스프링 데이터가 제공하는, 각자의 저장소에 맞는 베이스 레포지토리 구현체로 라우팅된다 (예를 들어 JPA를 사용한다면 SimpleJpaRepository 구현체). 따라서 이제 UserRepository는 사용자를 저장할 수 있고, ID로 각 사용자를 검색할 수 있으며, 이메일 주소로 Users를 검색하는 쿼리를 트리거할 수 있다.

중간 레포지토리 인터페이스는 @NoRepositoryBean 어노테이션이 달려있다. 스프링 데이터가 런타임에 인스턴스를 만들면 안 되는 모든 레포지토리 인터페이스에는 반드시 이 어노테이션을 선언해야 한다.

11.3.2. Using Repositories with Multiple Spring Data Modules

어플리케이션에서 사용하는 스프링 데이터 모듈이 하나 뿐이면, 정의한 스코프 내 있는 모든 레포지토리 인터페이스가 해당 스프링 데이터 모듈에 바인딩되기 때문에 별다른 어려움이 없다. 하지만 다른 스프링 데이터 모듈을 동시에 사용하기도 한다. 이럴 땐 반드시 레포지토리 정의로 저장소 persistence 기술을 구분해줘야 한다. 클래스패스에서 레포지토리 팩토리를 여러 개 발견하면, 스프링 데이터는 strict 레포지토리 설정 모드로 진입한다. strict 모드에선 레포지토리나 도메인 클래스의 세부 정보를 확인해 레포지토리가 바인딩할 스프링 데이터 모듈을 결정한다:

  1. 정의한 레포지토리가 모듈 전용 레포지토리를 상속하고 있다면, 해당 스프링 데이터 모듈에 바인딩한다.
  2. 도메인 클래스에 모듈 전용 타입 어노테이션이 있다면, 해당 스프링 데이터 모듈에 바인딩한다. 스프링 데이터 모듈은 제 3자의 어노테이션을 수용하기도하고 (JPA의 @Entity 같은), 자체 어노테이션을 제공하기도 한다 (스프링 데이터 MongoDB와 스프링 데이터 Elasticsearch을 위한 @Document 등).

다음은 모듈 전용 인터페이스를 사용하는 레포지토리 예시다 (여기선 JPA):

Example 8. Repository definitions using module-specific interfaces

interface MyRepository extends JpaRepository<User, Long> { }

@NoRepositoryBean
interface MyBaseRepository<T, ID> extends JpaRepository<T, ID> {  }

interface UserRepository extends MyBaseRepository<User, Long> {  }

MyRepositoryUserRepository는 타입 계층구조 상 JpaRepository를 상속한다. 따라서 두 레포지토리 모두 스프링 데이터 JPA 모듈에 바인딩한다.

다음은 범용 인터페이스를 사용하는 레포지토리 예시다:

Example 9. Repository definitions using generic interfaces

interface AmbiguousRepository extends Repository<User, Long> {  }

@NoRepositoryBean
interface MyBaseRepository<T, ID> extends CrudRepository<T, ID> {  }

interface AmbiguousUserRepository extends MyBaseRepository<User, Long> {  }

AmbiguousRepositoryAmbiguousUserRepository는 계층구조에서 RepositoryCrudRepository만 상속한다. 스프링 데이터 모듈을 하나만 사용한다면 문제 없지만, 모듈이 여러 개면 각 레포지토리가 바인딩할 스프링 데이터를 구분할 수 없다.

다음은 도메인 클래스에 어노테이션을 선언한 레포지토리다:

Example 10. Repository definitions using domain classes with annotations

interface PersonRepository extends Repository<Person, Long> {  }

@Entity
class Person {  }

interface UserRepository extends Repository<User, Long> {  }

@Document
class User {  }

PersonRepository가 참조하는 Person은 JPA @Entity 어노테이션을 선언했으므로, 이 레포지토리는 스프링 데이터 JPA에 속한다는 걸 분명히 알 수 있다. UserRepository에서 참조하는 User는 스프링 데이터 MongoDB의 @Document 어노테이션이 달려있다.

다음은 도메인 클래스에서 어노테이션을 섞어 쓰는 잘못된 예시다:

Example 11. Repository definitions using domain classes with mixed annotations

interface JpaPersonRepository extends Repository<Person, Long> {  }

interface MongoDBPersonRepository extends Repository<Person, Long> {  }

@Entity
@Document
class Person {  }

이 예제의 도메인 클래스는 JPA와 스프링 데이터 MongoDB 어노테이션을 둘 다 사용하고 있다. 그리고 JpaPersonRepository, MongoDBPersonRepository라는 레포지토리를 두 개 만들었다. 하나는 JPA, 다른 하나는 MongoDB를 의도한 듯하다. 하지만 이렇게 정의하면 스프링 데이터는 레포지토리를 구분할 수 없기 때문에, 정의되지 않은 동작(undefined behavior)을 이끌게 된다.

strict 레포지토리 설정에선 레포지토리 상세 타입도메인 클래스 어노테이션을 비교해서 스프링 데이터 모듈에 사용할 레포지토리 후보를 식별한다. 도메인 타입 하나에 다른 persistence 기술 전용 어노테이션을 여러 개 선언하는 것 자체는 가능한데, 이렇게 하면 도메인 클래스를 여러 persistence 기술에서 재사용할 수 있다. 하지만 스프링 데이터에선 레포지토리를 바인딩할 유일한 모듈을 결정할 수 없다.

레포지토리를 구분할 수 있는 최후의 방법은 레포지토리의 베이스 패키지 범위를 지정하는 거다. 베이스 패키지는 레포지토리 인터페이스를 스캔할 시작점을 정의하며, 그 패키지 안에 레포지토리 정의가 있다는 뜻이기도 하다. 어노테이션 기반 설정은 기본적으로 설정 클래스의 패키지를 사용한다. XML 기반 설정에선 베이스 패키지는 필수 값이다.

다음은 어노테이션 기반 설정에서 베이스 패키지를 지정하는 예시다:

Example 12. Annotation-driven configuration of base packages

@EnableJpaRepositories(basePackages = "com.acme.repositories.jpa")
@EnableMongoRepositories(basePackages = "com.acme.repositories.mongo")
class Configuration {  }

11.4. Defining Query Methods

레포지토리 프록시는 두 가지 방법으로 메소드에 해당하는 저장소 전용 쿼리를 만든다:

가능한 옵션은 실제 저장소에 따라 다르다. 하지만 실제 쿼리를 생성할 때 사용하는 전략은 동일하다. 다음 섹션에서 가능한 옵션들을 설명한다.

11.4.1. Query Lookup Strategies

다음은 레포지토리 인프라가 쿼리를 리졸브할 때 사용할 수 있는 전략들이다. XML 설정을 사용하면 네임스페이스의 query-lookup-strategy 속성으로 전략을 설정할 수 있다. 자바 설정에선 Enable${store}Repositories 어노테이션의 queryLookupStrategy 속성을 사용한다. 일부 전략을 지원하지 않는 저장소도 있을 수 있다.

11.4.2. Query Creation

스프링 데이터 레포지토리 인프라엔 쿼리 빌더 메커니즘이 내장돼 있으므로, 레포지토리 엔티티에 사용할 검색 쿼리 조건을 만들 때 유용하게 쓸 수 있다.

아래 예제에선 다양한 쿼리를 만드는 방법을 보여준다:

Example 13. Query creation from method names

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);
}

쿼리 메소드 이름은 주제(subject)와 판단식(predicate)으로 나누어 파싱한다. 앞 부분은 (find…By, exists…By) 쿼리 주제를 정의하고, 뒷 부분에선 판단식을 만든다. 도입부(주제)엔 추가 표현식이 있을 수도 있다. find와 (또는 다른 시작 키워드) By 사이에 있는 모든 텍스트는 서술부로 판단한다. 단, 쿼리에 distinct 플래그를 설정하는 Distinct 키워드나 쿼리 결과를 제한하는 Top/First 키워드는 예외다.

이 문서는 부록에 쿼리 메소드 주제 키워드 전체정렬과 대소문자 변형을 포함한 쿼리 메소드 판단식 키워드를 담고 있다. 단, 첫 번째로 나오는 By는 실제 범위 판단식의 시작을 가리키는 구분자다. 범위 판단식을 매우 단순하게 설명하자면, 엔티티 프로퍼티에 대한 조건을 정의하고, 이 조건을 AndOr로 연결한다.

실제로 메소드를 파싱한 결과는 쿼리를 사용하는 persistence 저장소에 따라 다르다. 그래도 공통 기능은 알아두면 좋다:

11.4.3. Property Expressions

위 예제에서 알 수 있듯, 프로퍼티 표현식은 관리하는 엔티티의 프로퍼티만 참조할 수 있다. 쿼리 생성 섹션에서 이미, 파싱한 프로퍼티는 관리 중인 도메인 클래스에 있는 프로퍼티라는 점을 익혔다. 하지만 중첩 프로퍼티도 순회할 수 있기 때문에, 엔티티 바로 밑에 있지 않은 프로퍼티도 제약 조건을 정의할 수 있다. 아래 메소드 시그니처를 생각해보자:

List<Person> findByAddressZipCode(ZipCode zipCode);

Person에는 Address 프로퍼티가, AddressZipCode 프로퍼티가 있다고 해보자. 이 메소드는 x.address.zipCode 프로퍼티를 순회한다. 쿼리 리졸브 알고리즘은 먼저 판단식 전체(AddressZipCode)를 프로퍼티로 해석해서, 도메인 클래스에 이 이름에 해당하는 프로퍼티가 있는지 확인한다 (앞 글자를 소문자로 바꿔서). 이 알고리즘이 성공하면 해당 프로퍼티를 사용한다. 실패하면 알고리즘은 오른쪽에서부터 카멜 케이스를 헤드와 테일로 분할해 프로퍼티를 찾아본다 — 이 예제에선 AddressZipCode. 헤드와 일치하는 프로퍼티를 찾으면, 테일을 가져 와서 다시 트리를 만들고 방금 설명한 방법으로 테일을 분할한다. 첫 번째 분할한 헤드로 프로퍼티를 찾지 못하면, 분할 포인트를 왼쪽으로 (Address, ZipCode) 이동해서 같은 알고리즘을 다시 시작한다.

대부분 이 알고리즘으로 필드를 찾을 수 있긴 하지만, 간혹 의도하지 않은 프로퍼티를 선택하곤 한다. Person 클래스에 addressZip 프로퍼티도 있었다고 생각해보자. 알고리즘에선 첫 번째로 분할해본 헤드로 프로퍼티를 찾을 거고, 이 프로퍼티를 선택해 결국 실패한다 (addressZip 타입엔 code 프로퍼티가 없을 거기 때문에).

이렇게 필드가 애매할 때는 메소드명 중간에 _를 사용해서 직접 순회 포인트를 지정할 수 있다. 위 메소드명은 아래처럼 바뀐다:

List<Person> findByAddress_ZipCode(ZipCode zipCode);

스프링 데이터는 _ 문자를 예약 문자로 처리하므로, 표준 자바 네이밍 컨센션은 반드시 따르는게 좋다 (즉, 프로퍼티 이름엔 언더스코어 대신 카멜 케이스를 사용해라).

11.4.4. Special parameter handling

쿼리에 파라미터를 사용하려면 위에서 본 예제처럼 메소드 파라미터를 정의하면 된다. 그 밖에도 인식할 수 있는 특별한 타입이 있는데, PageableSort다. 이 파라미터로는 페이지를 처리하고 쿼리를 동적으로 정렬할 수 있다. 아래 예제이서 이 기능을 시연해 보겠다:

Example 14. Using Pageable, Slice, and Sort in query methods

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, Pageable pageable);

SortPageable을 처리하는 API에선 이 값을 모두 non-null로 간주한다. 정렬이나 페이징을 사용하지 않을 때는 Sort.unsorted()Pageable.unpaged()를 사용해라.

첫 번째 메소드에선 쿼리 메소드에 org.springframework.data.domain.Pageable 인스턴스를 넘겨 정적으로 정의한 쿼리에 동적인 페이징을 추가있다. Page 인터페이스는 전체 요소 갯수와 유효한 페이지 수를 담고있다. 따라서 인프라에서 카운트 쿼리를 트리거해 전체 숫자를 계산한다. 계산 비용이 부담스럽다면 (사용하는 스토어에 따라), Page 대신 Slice를 리턴해라. Slice로는 다음 Slice가 있는지만 알 수 있다. 데이터 셋이 매우 크다면 이 정보만으로도 충분할 거다.

Pageable 인스턴스는 정렬 옵션도 처리한다. 정렬만 필요하다면 org.springframework.data.domain.Sort 파라미터를 사용해라. 예제에서 보이듯이 List를 반환할 수도 있다. List를 리턴하면 실제 Page 인스턴스를 만들 때 필요한 메타데이터를 만들지 않는다 (다시 말해, 필요할지도 모르는 카운트 쿼리를 추가로 실행하지 않는다). 그보단 주어진 범위 내에 있는 엔티티들만 찾도록 쿼리를 제안하는 역할이라고 보면 된다.

쿼리 자체로 온전히 가져올 수 있는 페이지가 몇 개인지 알고싶다면, 카운트 쿼리도 함께 트리거해야 한다. 기본적으로 카운트 쿼리는 실제로 트리거하는 쿼리가 파생시킨다.

Paging and Sorting

간단한 정렬 표현식은 프로퍼티명으로 정의할 수 있다. 정렬할 조건이 많다면, 표현식들을 이어 하나의 표현식으로 만들면 된다.

Example 15. Defining sort expressions

Sort sort = Sort.by("firstname").ascending()
  .and(Sort.by("lastname").descending());

좀 더 안전한 방법으로(type-safe) 정렬 표현식을 정의하려면, 먼저 대상 타입을 가져와 메소드 레퍼런스로 정렬 프로퍼티를 정의해라.

Example 16. Defining sort expressions by using the type-safe API

TypedSort<Person> person = Sort.sort(Person.class);

Sort sort = person.by(Person::getFirstname).ascending()
  .and(person.by(Person::getLastname).descending());

TypedSort.by(…)는 (보통) CGlib를 사용한 런타임 프록시를 타게 되는데, Graal VM Native같은 툴을 사용하면 네이티브 이미지 컴파일과 충돌할 수도 있다.

Querydsl을 지원하는 저장소를 사용한다면, 만들어진 메타 모델 타입으로도 정렬 표현식을 정의할 수 있다:

Example 17. Defining sort expressions by using the Querydsl API

QSort sort = QSort.by(QPerson.firstname.asc())
  .and(QSort.by(QPerson.lastname.desc()));

11.4.5. Limiting Query Results

쿼리 메소드 결과는 firsttop 키워드로 제한할 수 있으며, 두 키워드의 의미는 같다. top, first 뒤에 숫자를 추가하면 결과의 최대 크기를 지정할 수 있다. 숫자를 생략하면 1로 간주한다. 쿼리 사이즈를 제한하는 방법은 다음 예시를 참고해라:

Example 18. Limiting the result size of a query with Top and First

User findFirstByOrderByLastnameAsc();

User findTopByOrderByAgeDesc();

Page<User> queryFirst10ByLastname(String lastname, Pageable pageable);

Slice<User> findTop3ByLastname(String lastname, Pageable pageable);

List<User> findFirst10ByLastname(String lastname, Sort sort);

List<User> findTop10ByLastname(String lastname, Pageable pageable);

distinct 쿼리를 지원하는 저장소에 한해 제한 표현식에 Distinct 키워드를 사용할 수 있다. 결과 셋을 인스턴스 하나로 제한하는 쿼리라면, 결과를 Optional로 감싸도 된다.

결과를 제한하는 동시에 페이징이나 슬라이싱을 적용하면 (유효한 페이지 수를 계산하는), 제한된 결과 내에서만 페이지를 처리한다.

결과 제한을 다이나믹 정렬 Sort 파라미터와 조합하면, 가장 작은 ‘K’개와 가장 큰 ‘K’개를 쿼리 메소드로 표현할 수 있다.

11.4.6. Repository Methods Returning Collections or Iterables

결과를 여러 개 리턴하는 쿼리 메소드엔 표준 자바 Iterable, List, Set을 사용할 수 있다. 그 밖에도 Vavr이 지원하는 컬렉션 타입과, Iterable을 커스텀해 확장한 스프링 데이터의 Streamable도 지원한다. 지원하는 모든 쿼리 메소드 리턴 타입은 부록을 참고해라.

Using Streamable as Query Method Return Type

Iterable이나 다른 컬렉션 타입 대신, Streamable을 사용해도 된다. Streamable로는 쉽게 non-parallel Stream에 접근할 수 있고 (Iterable엔 없는 기능), 곧바로 ….filter(…), ….map(…)을 사용할 수 있으며, 다른 Streamable과도 연결할 수 있다:

Example 19. Using Streamable to combine query method results

interface PersonRepository extends Repository<Person, Long> {
  Streamable<Person> findByFirstnameContaining(String firstname);
  Streamable<Person> findByLastnameContaining(String lastname);
}

Streamable<Person> result = repository.findByFirstnameContaining("av")
  .and(repository.findByLastnameContaining("ea"));

Returning Custom Streamable Wrapper Types

쿼리가 요소를 여러 개 반환하면, 흔히 컬렉션 전용 래퍼 타입을 사용해 API를 만들곤 한다. 컬렉션 전용 래퍼 타입은 자주 쓰는 패턴이다. 보통은 컬렉션 등의 타입을 반환하는 레포지토리 메소드를 호출하고, 수동으로 래퍼 타입 인스턴스를 생성한다. 스프링 데이터는에선 다음 기준을 충족한다면 래퍼 타입을 쿼리 메소드 리턴 타입에 바로 사용할 수 있으므로, 수동으로 쿼리 결과를 래핑하지 않아도 된다:

  1. Streamable을 구현한 타입.
  2. Streamable을 인자로 받는 생성자나 스태틱 팩토리 메소드 of(…) 또는 valueOf(…)를 노출하는 타입.

아래 예제를 참고해라:

class Product {                                         // (1)
  MonetaryAmount getPrice() {  }
}

@RequiredArgConstructor(staticName = "of")
class Products implements Streamable<Product> {         // (2)

  private Streamable<Product> streamable;

  public MonetaryAmount getTotal() {                    // (3)
    return streamable.stream()
      .map(Priced::getPrice)
      .reduce(Money.of(0), MonetaryAmount::add);
  }


  @Override
  public Iterator<Product> iterator() {                 // (4)
    return streamable.iterator();
  }
}

interface ProductRepository implements Repository<Product, Long> {
  Products findAllByDescriptionContaining(String text); // (5)
}

(1) 상품 가격에 접근할 수 있는 API를 노출하는 Product 엔티티.
(2) Products.of(…)(롬복 어노테이션으로 만들어진 팩토리 메소드)로 생성할 수 있는, Streamable<Product>의 래퍼 타입. Streamable<Product>를 받는 표준 생성자로도 만들 수 있다.
(3) 래퍼 타입은 Streamable<Product>에 있는 값들을 새로 계산하는 별도 API를 노출한다.
(4) Streamable 인터페이스를 구현해 실제 결과를 위임한다.
(5) 래퍼 타입 Products는 쿼리 메소드 리턴 타입에 바로 사용할 수 있다. 레포지토리 클라이언트에서 직접 Streamable<Product>를 받아 래핑하지 않아도 된다.

Support for Vavr Collections

Vavr은 자바의 함수형 프로그래밍 개념을 아우르는 라이브러리다. 이 라이브러리가 제공하는 커스텀 컬렉션 타입 셋도 쿼리 메소드 리턴 타입으로 사용할 수 있다. 커스텀 셋은 타임 테이블을 참고해라:

Vavr collection type Used Vavr implementation type Valid Java source types
io.vavr.collection.Seq io.vavr.collection.List java.util.Iterable
io.vavr.collection.Set io.vavr.collection.LinkedHashSet java.util.Iterable
io.vavr.collection.Map io.vavr.collection.LinkedHashMap java.util.Map

첫 번째 열에 있는 타입(또는 하위 타입)을 쿼리 메소드 리턴 타입으로 사용할 수 있으며, 그러면 두 번째 열에 있는 구현 타입을 받게된다. 구현 타입은 실제 쿼리 결과의 자바 타입(세 번째 열)에 따라서 달라진다. 아니면 Traversable(Vavr 버전의 Iterable)을 선언하고, 실제 반환 값을 가지고 구현 클래스를 알아내도 된다. 즉, java.util.List는 Vavr ListSeq로, java.util.Set은 Vavr LinkedHashSet Set이 되는 식이다.

11.4.7. Null Handling of Repository Methods

스프링 데이터 2.0부터, 개별 집계 인스턴스를 반환하는 레포지토리 CRUD 메소드는 자바 8의 Optional로 값이 없을 수도 있음을 표현한다. Optional 외에 다음 래퍼 타입도 쿼리 메소드 반환 타입으로 지원한다:

쿼리 메소드에서 아예 래퍼 타입을 빼도 된다. 이럴 땐 null을 리턴하면 쿼리 결과가 없다는 뜻이다. 컬렉션이나 래퍼, 스트림 등을 리턴하는 레포지토리 메소드는 null을 리턴하지 않음을 보장하며, 대신 비어 있음을 뜻하는 적절한 인스턴스를 반환한다. 자세한 정보는 레포지토리 쿼리 리턴 타입을 참고해라.

Nullability Annotations

레포지토리 메소드는 스프링 프레임워크의 nullability 어노테이션으로도 null 가능 여부를 표현할 수 있다. nullability 어노테이션은 사용하기도 편하며, 다음과 같은 런타임 null 검사를 지원한다:

스프링 어노테이션엔 JSR 305 어노테이션(추가 개발은 진행하지 않지만 많이들 사용하는)이 선언돼 있다. JSR 305 메타 어노테이션 덕분에 툴 벤더(IDEA, Eclipse, Kotlin같은)는 스프링 어노테이션 전용 하드 코딩 없이, 일반적인 방식으로 null safety를 지원할 수 있다. 런타임에 모든 쿼리 메소드의 nullability 제약 조건을 체크하도록 만들려면, 다음 예제처럼 package-info.java에 스프링의 @NonNullApi를 선언해 패키지 레벨에서 non-nullability를 활성화해야 한다:

Example 20. Declaring Non-nullability in package-info.java

@org.springframework.lang.NonNullApi
package com.acme;

디폴트로 non-null을 지정하면, 레포지토리 쿼리 메소드를 호출할 때마다 런타임에 null 허용 조건에 대한 유효성을 검사한다. 쿼리 결과가 정의한 제약 조건을 위반하면 예외가 발생한다. non-nullable(레포지토리가 있는 패키지에 정의한 어노테이션의 디폴트 값)로 선언한 메소드가 null을 반환했을 때에 해당한다. null이 될 수 있는 결과를 따로 지정하려면 원하는 메소드에 별도로 @Nullable을 선언해라. 앞에서 언급했던 결과 래퍼 타입을 사용하면, 예상했겠지만, 결과가 없으면 비어 있음을 나타내는 값으로 변환한다.

다음은 지금까지 설명했던 기법들을 종합한 예제다:

Example 21. Using different nullability constraints

package com.acme;                                                       // (1)

import org.springframework.lang.Nullable;

interface UserRepository extends Repository<User, Long> {

  User getByEmailAddress(EmailAddress emailAddress);                    // (2)

  @Nullable
  User findByEmailAddress(@Nullable EmailAddress emailAdress);          // (3)

  Optional<User> findOptionalByEmailAddress(EmailAddress emailAddress); // (4)
}

(1) 이 레포지토리는 non-null로 정의했던 패키지(또는 하위 패키지)에 있다.
(2) 쿼리 결과가 없으면 EmptyResultDataAccessException을 던진다. 메소드로 전달한 emailAddressnull이면 IllegalArgumentException을 던진다.
(3) 쿼리 결과가 없으면 null을 리턴한다. emailAddressnull을 허용한다.
(4) 쿼리 결과가 없으면 Optional.empty()를 리턴한다. 메소드로 전달한 emailAddressnull이면 IllegalArgumentException을 던진다.

Nullability in Kotlin-based Repositories

코틀린은 언어 자체에 nullability 제약 조건 정의할 수 있다. 코틀린 코드를 바이트 코드로 컴파일하면, 메소드 시그니처대신 컴파일된 메타데이터로 null 허용 여부를 표현한다. 코틀린의 nullability 제약 조건을 검사하려면 프로젝트에 kotlin-reflect JAR를 추가해야 한다. 스프링 데이터 레포지토리는 언어 메커니즘을 활용해 다음처럼 제약 조건을 정의하고, 동일하게 런타임에 유효성을 검사한다:

Example 22. Using nullability constraints on Kotlin repositories

interface UserRepository : Repository<User, String> {

  fun findByUsername(username: String): User     // (1)

  fun findByFirstname(firstname: String?): User? // (2)
}

(1) 이 메소드는 파라미터와 결과 모두 null이 될 수 없는 값으로 정의한다 (코틀린 디폴트). 코틀린 컴파일러가 메소드에 null을 넘길 수 없도록 막는다. 쿼리 결과가 없으면 EmptyResultDataAccessException을 던진다.
(2) 이 메소드는 firstname 파라미터에 null 값을 허용하고, 쿼리 결과가 없으면 null을 리턴한다.

11.4.8. Streaming Query Results

리턴 타입에 자바 8 Stream<T>을 사용하면 쿼리 메소드 결과를 점진적으로 처리할 수 있다. 쿼리 결과를 직접 Stream으로 감싸는 대신, 아래 예제처럼 저장소 전용 메소드로도 스트리밍 처리가 가능하다:

Example 23. Stream the result of a query with Java 8 Stream<T>

@Query("select u from User u")
Stream<User> findAllByCustomQueryAndStream();

Stream<User> readAllByFirstnameNotNull();

@Query("select u from User u")
Stream<User> streamAllPaged(Pageable pageable);

Stream은 본래 저장소 전용 리소스를 래핑한 것이기 때문에, 반드시 사용 후 닫아줘야 한다. close() 메소드로 직접 Stream을 닫거나, 아래 예제처럼 자바 7 try-with-resources 블록을 사용하면 된다:

Example 24. Working with a Stream<T> result in a try-with-resources block

try (Stream<User> stream = repository.findAllByCustomQueryAndStream()) {
  stream.forEach();
}

현재는 모든 스프링 데이터 모듈이 리턴 타입에 Stream<T>을 지원하진 않는다.

11.4.9. Asynchronous Query Results

스프링의 비동기 메소드 실행 기능을 이용하면 레포지토리 쿼리를 비동기로 실행할 수 있다. 비동기 메소드는 즉시 반환되고, 실제 쿼리는 스프링 TaskExecutor로 제출한 태스크가 실행한다. 비동기 쿼리는 리액티브 쿼리와는 다르며 혼용하면 안 된다. 리액티브 지원에 관한 자세한 내용은 저장소 문서를 참고해라. 다음 예제는 다양한 비동기 쿼리를 보여준다:

@Async
Future<User> findByFirstname(String firstname);               // (1)

@Async
CompletableFuture<User> findOneByFirstname(String firstname); // (2)

@Async
ListenableFuture<User> findOneByLastname(String lastname);    // (3)

(1) java.util.concurrent.Future를 리턴 타입으로 사용한다.
(2) 자바 8 java.util.concurrent.CompletableFuture를 리턴 타입으로 사용한다.
(3) org.springframework.util.concurrent.ListenableFuture를 리턴 타입으로 사용한다.


11.5. Creating Repository Instances

이번 섹션에선 지금까지 정의한 레포지토리 인터페이스로 인스턴스를 만들고, 빈을 정의하는 방법에 대해 다룬다. 레포지토리 메커니즘을 지원하는 각 스프링 데이터 모듈이 제공하는 스프링 네임스페이스를 사용할 수도 있지만, 그래도 보통은 자바 설정을 추천한다.

11.5.1. XML Configuration

각 스프링 데이터 모듈에 repositories 요소를 추가하면 스프링이 스캔할 베이스 패키지를 지정할 수 있다:

Example 25. Enabling Spring Data repositories via XML

<?xml version="1.0" encoding="UTF-8"?>
<beans:beans xmlns:beans="http://www.springframework.org/schema/beans"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns="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">

  <repositories base-package="com.acme.repositories" />

</beans:beans>

위 예제에선 스프링이 com.acme.repositories와 모든 하위 패키지를 스캔해 Repository나 하위 인터페이스를 확장한 인터페이스를 찾는다. 인터페이스를 찾을 때마다 persistence 기술 전용 FactoryBean을 등록해 쿼리 메소드를 처리할 적당한 프록시를 만든다. 빈 이름은 인터페이스 이름에 따라 등록하므로, UserRepository 인터페이스는 userRepository로 등록된다. 중첩 레포지토리 인터페이스는 둘러싸고 있는 타입명을 프리픽스로 사용한다. base-package 속성은 와일드카드를 허용하므로 스캔할 패키지 패턴을 정의해도 된다.

Using Filters

기본적으로 Repository 하위 인터페이스를 확장한, 설정한 베이스 패키지 아래 있는 모든 인터페이스로 빈 인스턴스를 생성한다. 반면 어떤 인터페이스로 빈 인스턴스를 만들지 직접 세세하게 제어하고 싶을 수 있다. 이럴 땐 <repositories /> 요소 안에 <include-filter /><exclude-filter /> 요소를 사용하면 된다. 이 요소가 의미하는 바는 스프링 컨텍스트 네임스페이스 요소와 완전히 동일하다. 자세한 내용은 스프링 레퍼런스 문서를 참고해라.

예를 들어 레포지토리 빈 인스턴스에서 제외할 인터페이스는 아래와 같이 설정한다:

Example 26. Using exclude-filter element

<repositories base-package="com.acme.repositories">
  <context:exclude-filter type="regex" expression=".*SomeRepository" />
</repositories>

위 예제는 SomeRepository로 끝나는 모든 인터페이스를 인스턴스로 만들지 않는다.

11.5.2. Java Configuration

자바 설정 클래스에 저장소별 @Enable${store}Repositories 어노테이션을 사용해도 레포지토리 인프라를 트리거할 수 있다. 스프링 컨테이너의 자바 기반 설정은 스프링 레퍼런스 문서 자바 설정 섹션에서 소개하고 있다.

스프링 데이터 레포지토리는 아래와 유사한 방식으로 활성화한다:

Example 27. Sample annotation-based repository configuration

@Configuration
@EnableJpaRepositories("com.acme.repositories")
class ApplicationConfiguration {

  @Bean
  EntityManagerFactory entityManagerFactory() {
    // …
  }
}

위 예제에선 JPA 전용 어노테이션을 사용하는데, 실제로 사용하는 저장소 모듈에 맞게 바꿔 사용하면 된다. EntityManagerFactory 빈 정의도 마찬가지다. 저장소별 설정을 다루는 섹션을 참고해라.

11.5.3. Standalone Usage

레포지토리 인프라를 꼭 스프링 컨테이너 안에서만 사용해야 하는 건 아니다 — 예를 들어 CDI 환경에서도 가능하다. 클래스패스에 일부 스프링 라이브러리가 있어야 하긴 하지만, 일반적으로는 프로그래밍 방식으로 레포지토리를 세팅할 수 있다. 레포지토리를 지원하는 스프링 데이터 모듈은 persistence 기술 전용 RepositoryFactory도 제공하므로, 아래 예제처럼 활용하면 된다:

Example 28. Standalone usage of the repository factory

RepositoryFactorySupport factory =  // Instantiate factory here
UserRepository repository = factory.getRepository(UserRepository.class);

11.6. Custom Implementations for Spring Data Repositories

이번 섹션에선 레포지토리 커스텀을 다루며, 어떻게 여러 인터페이스 조각을 모아 하나의 레포지토리를 구성하는지를 설명한다.

쿼리 메소드에서 다른 동작을 실행해야 하거나 파생 쿼리로는 구현할 수 없는 동작이 있다면, 커스텀 구현체가 필요하다. 스프링 데이터 레포지토리는 커스텀 레포지토리 코드를, 일반 CRUD 추상화와 쿼리 메소드 기능과 통합해준다.

11.6.1. Customizing Individual Repositories

레포지토리에 커스텀 기능을 추가하려면, 우선 인터페이스 조각을 정의하고 커스텀 기능을 구현해야 한다:

Example 29. Interface for custom repository functionality

interface CustomizedUserRepository {
  void someCustomMethod(User user);
}

Example 30. Implementation of custom repository functionality

class CustomizedUserRepositoryImpl implements CustomizedUserRepository {

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

여기 인터페이스 조각을 구현한 클래스 이름에선 Impl 접미사가 가장 중요하다.

이 구현체 자체는 스프링 데이터에 의존성이 없으며, 정식 스프링 빈이 될 수 있다. 따라서 표준 의존성 주입을 이용해 다른 빈(JdbcTemplate 등)에 참조를 주입할 수 있고, aspect에도 참여할 수 있다.

이제 다음과 같이 레포지토리 인터페이스가 이 조각 인터페이스를 상속하도록 만들 수 있다:

Example 31. Changes to your repository interface

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

  // Declare query methods here
}

인터페이스 조각과 레포지토리 인터페이스를 함께 상속했기 때문에 클라이언트는 CRUD와 커스텀 기능을 모두 사용할 수 있다.

각 인터페이스 조각들이 스프링 데이터 레포지토리를 구현해 하나의 레포지토리를 구성한다. 여기서 인터페이스 조각은 베이스 레포지토리, 함수형 aspect(QueryDsl 등), 구현체를 가진 커스텀 인터페이스를 뜻한다. 레포지토리 인터페이스에 새 인터페이스를 추가할 때마다, 최종 레포지토리 구성에 조각을 추가해 확장하는 거다. 베이스 레포지토리 구현체와 레포지토리 aspect 구현체는 각 스프링 데이터 모듈이 제공한다.

다음은 커스텀 인터페이스와 그에 따른 구현체 예시다:

Example 32. Fragments with their implementations

interface HumanRepository {
  void someHumanMethod(User user);
}

class HumanRepositoryImpl implements HumanRepository {

  public void someHumanMethod(User user) {
    // Your custom implementation
  }
}

interface ContactRepository {

  void someContactMethod(User user);

  User anotherContactMethod(User user);
}

class ContactRepositoryImpl implements ContactRepository {

  public void someContactMethod(User user) {
    // Your custom implementation
  }

  public User anotherContactMethod(User user) {
    // Your custom implementation
  }
}

다음은 CrudRepository를 확장한 커스텀 레포지토리 인터페이스 예시다:

Example 33. Changes to your repository interface

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

  // Declare query methods here
}

레포지토리는 둘 이상의 커스텀 구현체로 구성할 수 있으며, 커스텀 구현체는 선언한 순서대로 이입된다. 커스텀 구현체는 베이스 구현체와 레포지토리 aspect보다 우선순위가 높다. 이 순서 덕분에 베이스 레포지토리와 aspect 메소드를 재정의할 수 있으며, 두 커스텀 인터페이스가 같은 메소드 시그니처를 사용해도 충돌하지 않는다. 커스텀 레포지토리 조각을 레포지토리 인터페이스 하나에서만 사용해야 한다는 제한은 없다. 같은 인터페이스 조각을 여러 레포지토리에 사용할 수 있으므로, 다른 레포지토리에서 커스텀 기능을 재사용할 수 있다.

다음은 커스텀 레포지토리 조각과 구현체 예시다:

Example 34. Fragments overriding save(…)

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

class CustomizedSaveImpl<T> implements CustomizedSave<T> {

  public <S extends T> S save(S entity) {
    // Your custom implementation
  }
}

다음은 위 커스텀 조각을 사용하는 레포지토리다:

Example 35. Customized repository interfaces

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

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

Configuration

네임스페이스 설정을 사용한다면, 레포지토리를 발견한 패키지 아래에 있는 클래스를 스캔해 커스텀 구현체 조각을 자동으로 감지한다. 클래스는 네이밍 컨벤션이 있는데, 인터페이스 조각 이름 뒤에 네임스페이스 요소의 repository-impl-postfix 속성 값을 붙인 이름을 사용해야 한다. 디폴트 접미사는 Impl이다. 다음은 디폴트 접미사를 사용한 레포지토리와, 커스텀 접미사를 사용하는 레포지토리 예시다:

Example 36. Configuration example

<repositories base-package="com.acme.repository" />

<repositories base-package="com.acme.repository" repository-impl-postfix="MyPostfix" />

첫 번째 설정은 커스텀 레포지토리 구현체로 com.acme.repository.CustomizedUserRepositoryImpl이란 클래스를 찾는다. 두 번째 예시는 com.acme.repository.CustomizedUserRepositoryMyPostfix를 찾는다.

Resolution of Ambiguity

위 조건에 해당하는 클래스 명을 사용하는 구현체가 다른 패키지에서 동시에 발견된다면, 스프링 데이터는 빈 이름으로 사용할 구현체를 식별한다.

앞에서 보여준 CustomizedUserRepository를 구현한 커스텀 구현체가 아래와 같이 두 개 있다면, 첫 번째 구현체를 사용한다. 이 구현체의 빈 이름은 인터페이스 조각(CustomizedUserRepository)에 Impl 접미사를 더한 customizedUserRepositoryImpl이다.

Example 37. Resolution of ambiguous implementations

package com.acme.impl.one;

class CustomizedUserRepositoryImpl implements CustomizedUserRepository {

  // Your custom implementation
}
package com.acme.impl.two;

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

  // Your custom implementation
}

UserRepository 인터페이스에 @Component("specialCustom") 어노테이션을 달면, 이제 빈 이름에 Impl을 더해주면 com.acme.impl.two에 있는 레포지토리 구현체와 일치하므로, 첫 번째 구현체 대신 이 구현체를 사용한다.

Manual Wiring

커스텀 구현체가 어노테이션 기반 설정과 자동 주입만 사용하고 있다면, 다른 스프링 빈과 동일하게 처리하기 때문에 위에서 처럼 접근하면 문제 없이 동작한다. 구현체 조각에 특별한 빈을 연결해야 한다면, 앞 섹션에서 설명한 컨벤션에 따라 빈을 선언하고 이름을 지어주면 된다. 그러면 인프라에선 직접 빈을 만드는 대신, 이름을 통해 수동으로 정의한 빈을 참조한다. 다음 예시는 수동으로 커스텀 구현체를 연결하는 방법을 보여준다:

Example 38. Manual wiring of custom implementations

<repositories base-package="com.acme.repository" />

<beans:bean id="userRepositoryImpl" class="…">
  <!-- further configuration -->
</beans:bean>

11.6.2. Customize the Base Repository

이전 섹션에서 설명한 방법대로면, 베이스 레포지토리를 커스텀해 모든 레포지토리 동작을 바꾸려면 레포지토리 인터페이스를 일일이 커스텀해야 했다. 번거롭게 인터페이스를 전부 커스텀하지 않고도, persistence 기술 전용 레포지토리에 있는 베이스 클래스를 확장한 구현체를 만드는 방법도 있다. 이 커스텀 클래스는 레포지토리 프록시의 베이스 클래스로 활용된다:

Example 39. Custom repository base class

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

  private final EntityManager entityManager;

  MyRepositoryImpl(JpaEntityInformation entityInformation,
                          EntityManager entityManager) {
    super(entityInformation, entityManager);

    // Keep the EntityManager around to used from the newly introduced methods.
    this.entityManager = entityManager;
  }

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

이 클래스엔 저장소 전용 레포지토리 팩토리 구현체가 사용할 상위 클래스의 생성자가 있어야 한다. 레포지토리 베이스 클래스에 생성자가 여러 개라면, EntityInformation과 저장소 전용 인프라 객체(EntityManager나 템플릿 클래스같은)를 사용하는 생성자를 재정의해라.

마지막으로 스프링 데이터 인프라에도 커스텀한 레포지토리 베이스 클래스를 알려줘야 한다. 자바 설정에선 다음 예제처럼 @Enable${store}Repositories 어노테이션의 repositoryBaseClass 속성을 사용한다:

Example 40. Configuring a custom repository base class using JavaConfig

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

XML 네임스페이스에도 동일한 속성이 있다:

Example 41. Configuring a custom repository base class using XML

<repositories base-package="com.acme.repository"
     base-class="….MyRepositoryImpl" />

11.7. Publishing Events from Aggregate Roots

레포지토리가 관리하는 엔티티들은 aggregate root다. 도메인 주도 설계 어플리케이션에서 보통 aggregate root는 도메인 이벤트를 발생시킨다. 스프링 데이터는 @DomainEvents 어노테이션을 제공하므로, 다음 예제처럼 aggregate root에 있는 메소드에 사용하면 쉽게 이벤트를 발생시킬 수 있다:

Example 42. Exposing domain events from an aggregate root

class AnAggregateRoot {

    @DomainEvents // (1)
    Collection<Object> domainEvents() {
        // … return events you want to get published here
    }

    @AfterDomainEventPublication // (2) 
    void callbackMethod() {
       // … potentially clean up domain events list
    }
}

(1) @DomainEvents를 사용하는 메소드는 단일 이벤트 인스턴스 또는 이벤트 컬렉션을 리턴할 수 있다. 메소드 인자는 받지 않아야 한다.
(2) 모든 이벤트를 발행하고 난 뒤엔 @AfterDomainEventPublication 어노테이션을 활용한다. 발행한 이벤트 리스트를 정리하는 등에 활용할 수 있다.

어노테이션을 선언한 메소드는 스프링 데이터 레포지토리의 save(…), saveAll(…), delete(…), deleteAll(…) 메소드를 실행할 때마다 호출된다.


11.8. Spring Data Extensions

이번 섹션에선 스프링 데이터를 다른 컨텍스트에서도 사용할 수 있게 해주는 여러 가지 스프링 데이터 익스텐션을 설명한다. 현재는 스프링 MVC와의 통합을 주로 사용한다.

11.8.1. Querydsl Extension

Querydsl은 정적으로 타입을 지정하는 SQL과 유사한 쿼리를 만들 수 있는 API를 제공하는 프레임워크다.

다양한 스프링 데이터 모듈이 아래 QuerydslPredicateExecutor를 통한 Querydsl 통합을 지원하고 있다:

Example 43. QuerydslPredicateExecutor interface

public interface QuerydslPredicateExecutor<T> {

  Optional<T> findById(Predicate predicate);  // (1)

  Iterable<T> findAll(Predicate predicate);   // (2)

  long count(Predicate predicate);            // (3)

  boolean exists(Predicate predicate);        // (4)

  // … more functionality omitted.
}

(1) Predicate 조건에 맞는 단일 엔티티를 찾아 리턴한다.
(2) Predicate 조건에 맞는 모든 엔티티를 찾아 리턴한다.
(3) Predicate 조건에 맞는 엔티티 수를 반환한다.
(4) Predicate 조건에 맞는 엔티티 존재 여부를 반환한다.

Querydsl을 사용하려면 다음 예제처럼 레포지토리 인터페이스가 QuerydslPredicateExecutor를 상속하도록 만들어라:

Example 44. Querydsl integration on repositories

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

이렇게 하면 아래 예제처럼 Querydsl Predicate 인스턴스를 사용해 type-safe한 쿼리를 작성할 수 있다:

Predicate predicate = user.firstname.equalsIgnoreCase("dave")
  .and(user.lastname.startsWithIgnoreCase("mathews"));

userRepository.findAll(predicate);

11.8.2. Web support

이 섹션은 스프링 데이터 Commons 현재 버전이 지원하는 스프링 데이터 웹 기능을 설명한다. 새로 도입한 기능으로 많은 것들이 변경됨에 따라 구버전 동작에 대한 문서는 [web.legacy]에 남겨놨다.

레포지토리 프로그래밍 모델을 지원하는 스프링 데이터 모듈들은 다양한 웹 기능을 함께 제공한다. 웹 관련 컴포넌트를 사용하려면 클래스패스에 스프링 MVC JAR가 필요하다. 스프링 HATEOAS 통합을 지원하는 모듈도 있다. 자바 설정을 사용할 땐, 보통 아래 예제처럼 설정 클래스에 @EnableSpringDataWebSupport 어노테이션을 선언해 통합 지원을 활성화한다.

Example 45. Enabling Spring Data web support

@Configuration
@EnableWebMvc
@EnableSpringDataWebSupport
class WebConfiguration {}

@EnableSpringDataWebSupport 어노테이션을 선언하면 몇 가지 컴포넌트가 등록된다. 이 컴포넌트들은 뒤에서 설명하겠다. 클래스패스의 스프링 HATEOAS도 감지하며 통합 컴포넌트도 함께 등록된다 (있으면).

XML 설정을 사용한다면, 다음 예제처럼 SpringDataWebConfiguration이나 HateoasAwareSpringDataWebConfiguration을 스프링 빈으로 등록해라:

Example 46. Enabling Spring Data web support in XML

<bean class="org.springframework.data.web.config.SpringDataWebConfiguration" />

<!-- If you use Spring HATEOAS, register this one *instead* of the former -->
<bean class="org.springframework.data.web.config.HateoasAwareSpringDataWebConfiguration" />

Basic Web Support

위에서 본 설정은 몇 가지 컴포넌트를 등록한다:

Using the DomainClassConverter Class

DomainClassConverter 클래스를 사용하면 도메인 타입을 스프링 MVC 컨트롤러 메소드 시그니처에 바로 사용할 수 있기 때문에, 레포지토리에서 직접 인스턴스를 찾지 않아도 된다. 다음 예제를 참고해라:

Example 47. A Spring MVC controller using domain types in method signatures

@Controller
@RequestMapping("/users")
class UserController {

  @RequestMapping("/{id}")
  String showUserForm(@PathVariable("id") User user, Model model) {

    model.addAttribute("user", user);
    return "userForm";
  }
}

이 메소드에선 User 인스턴스를 직접 받으므로 별도로 인스턴스를 검색할 필요가 없다. 스프링 MVC가 path variable을 도메인 클래스의 id 타입으로 변환한 뒤, 도메인 타입을 등록한 레포지토리 인스턴스에서 findById(…)를 호출해 인스턴스를 리졸브한다.

이 기능은 현재로서는 CrudRepository를 구현한 레포지토리만 지원한다.

HandlerMethodArgumentResolvers for Pageable and Sort

앞에서 보여준 설정은 PageableHandlerMethodArgumentResolverSortHandlerMethodArgumentResolver 인스턴스도 함께 등록한다. 덕분에 컨트롤러 메소드 인자에 PageableSort도 사용할 수 있다. 다음 예제를 참고해라:

Example 48. Using Pageable as a controller method argument

@Controller
@RequestMapping("/users")
class UserController {

  private final UserRepository repository;

  UserController(UserRepository repository) {
    this.repository = repository;
  }

  @RequestMapping
  String showUsers(Model model, Pageable pageable) {

    model.addAttribute("users", repository.findAll(pageable));
    return "users";
  }
}

위 메소드 시그니처를 사용하면 스프링 MVC는 다음과 같은 설정을 통해 요청 파라미터로 Pageable 인스턴스를 만든다:

Table 1. Request parameters evaluated for Pageable instances

page 조회할 페이지. 0부터 시작하며, 디폴트도 0이다.
size 조회하고 싶은 페이지 크기. 디폴트는 20이다.
sort 정렬할 프로퍼티들로, property,property(,ASC|DESC)(,IgnoreCase) 형식을 사용한다. 디폴트 정렬 방향은 오름차순이며, 대소문자를 구분한다. 프로퍼티별로 방향이나 대소문자 구분 여부를 다르게 지정하고 싶다면 sort 파라미터를 여러 개 사용해라.
 — 예를 들어 ?sort=firstname&sort=lastname,asc&sort=city,ignorecase.

이 설정을 커스텀하려면 PageableHandlerMethodArgumentResolverCustomizerSortHandlerMethodArgumentResolverCustomizer 인스턴스를 구현한 빈을 등록해라. customize() 메소드 호출을 통해 설정을 바꿀 수 있다:

@Bean SortHandlerMethodArgumentResolverCustomizer sortCustomizer() {
    return s -> s.setPropertyDelimiter("<-->");
}

기존 MethodArgumentResolver 프로퍼티를 바꾸는 걸로 충분하지 않다면, SpringDataWebConfiguration이나 HATEOAS 활성화에 해당하는 설정 클래스를 상속해 pageableResolver(), sortResolver() 메소드를 재정의하고, @Enable 어노테이션 대신 커스텀 설정 파일을 임포트해라.

단일 요청으로 Pageable이나 Sort 인스턴스를 여러 개 리졸브해야 한다면, 스프링의 @Qualifier 어노테이션으로 구분해줄 수 있다. 이렇게 하면 요청 파라미터에 ${qualifier}_란 프리픽스가 달린다. 다음은 그에 따른 메소드 시그니처다:

String showUsers(Model model,
      @Qualifier("thing1") Pageable first,
      @Qualifier("thing2") Pageable second) {  }

이제 thing1_page, thing2_page 등의 파라미터를 사용하면 된다.

메소드에 전달하는 Pageable 디폴트 값은 PageRequest.of(0, 20)인데, Pageable 파라미터에 @PageableDefault 어노테이션을 달면 커스텀할 수 있다.

Hypermedia Support for Pageables

스프링 HATEOAS는 Page 인스턴스에 필요한 Page 메타데이터와, 클라이언트가 쉽게 페이지를 탐색할 수 있게 링크를 추가해주는 representation 모델 클래스(PagedResources)를 제공한다. PagePagedResources로 변환해주는 일은 스프링 HATEOAS ResourceAssembler 인터페이스 구현체 PagedResourcesAssembler가 담당한다. 다음 예제는 PagedResourcesAssembler를 컨트롤러 메소드 인자에 사용하는 방법을 보여준다:

Example 49. Using a PagedResourcesAssembler as controller method argument

@Controller
class PersonController {

  @Autowired PersonRepository repository;

  @RequestMapping(value = "/persons", method = RequestMethod.GET)
  HttpEntity<PagedResources<Person>> persons(Pageable pageable,
    PagedResourcesAssembler assembler) {

    Page<Person> persons = repository.findAll(pageable);
    return new ResponseEntity<>(assembler.toResources(persons), HttpStatus.OK);
  }
}

위에 있는 설정을 활성화했다면 PagedResourcesAssembler를 컨트롤러 메소드 인자에 사용할 수 있다. toResources(…)를 호출하면 PagedResourcesAssembler는 다음과 같은 처리를 한다:

데이터베이스에 Person 인스턴스가 30개 저장돼 있다고 가정해보자. 이제 요청을 보내면 (GET http://localhost:8080/persons) 다음과 유사한 응답을 받을 거다:

{ "links" : [ { "rel" : "next",
                "href" : "http://localhost:8080/persons?page=1&size=20" }
  ],
  "content" : [
      // 20 Person instances rendered here
  ],
  "pageMetadata" : {
    "size" : 20,
    "totalElements" : 30,
    "totalPages" : 2,
    "number" : 0
  }
}

assembler는 정확한 URI를 만들었고, 다음 요청에 사용할 파라미터도 이어지는 Pageable로 리졸브하기 위해 디폴트 설정을 사용했다. 다시 말해, 해당 설정을 변경하면 링크에도 자동으로 반영된다. 기본적으로 assembler는 실행한 컨트롤러 메소드를 가리키지만, PagedResourcesAssembler.toResource(…)의 오버로드 메소드에 링크를 빌드할 기본 커스텀 Link를 전달해 변경할 수 있다.

Web Databinding Support

스프링 데이터 프로젝션(Projections 섹션에서 설명)을 사용하면, 다음 예제처럼 요청 페이로드를 JSONPath 표현식(Jayway JsonPath 필요)이나 XPath 표현식(XmlBeam 필요)으로 바인딩할 수 있다.

Example 50. HTTP payload binding using JSONPath or XPath expressions

@ProjectedPayload
public interface UserPayload {

  @XBRead("//firstname")
  @JsonPath("$..firstname")
  String getFirstname();

  @XBRead("/lastname")
  @JsonPath({ "$.lastname", "$.user.lastname" })
  String getLastname();
}

이 타입은 스프링 MVC 핸들러 메소드 인자로 사용하거나, RestTemplate 메소드에서 ParameterizedTypeReference와 함께 사용할 수 있다. 이 메소드 선언대로면 도큐먼트 전체에서 firstname을 찾는다. XML은 lastname을 문서 최상위 레벨에서 조회한다. JSON도 최상위에서 lastname을 먼저 찾아보지만, 못 찾으면 하위 도큐먼트 user가 감싸고 있는 lastname을 찾는다. 이렇게하면 클라이언트가 노출된 메소드를 호출하지 않아도 (일반적인 클래스 기반 페이로드 바인딩의 단점) 쉽게 도큐먼트 구조 변경에 대응할 수 있다.

Projections에서도 설명하지만, 중첩 프로젝션(Nested projection)도 지원한다. 메소드가 인터페이스 타입 대신 중첩 타입을 반환하면, Jackson ObjectMapper로 최종 값을 매핑한다.

스프링 MVC에선 클래스패스에 필수 의존성이 있다면 @EnableSpringDataWebSupport를 활성화하는 즉시 자동으로 필요한 컨버터들을 등록해 준다. RestTemplate과 함께 사용하려면 직접 ProjectingJackson2HttpMessageConverter(JSON)나 XmlBeamHttpMessageConverter를 등록해라.

자세한 내용은 공식 스프링 데이터 예제 레포지토리에 있는 웹 프로젝센 예제를 참고해라.

Querydsl Web Support

QueryDSL 통합을 지원하는 저장소에선 Request 쿼리 스트링에 있는 속성으로 쿼리를 파생시킬 수 있다.

다음과 같은 쿼리 스트링을 생각해 보자:

?firstname=Dave&lastname=Matthews

위 예제에서 다뤘던 User 객체가 있다고 가정하면, QuerydslPredicateArgumentResolver를 사용해 쿼리 스트링을 아래 있는 값으로 리졸브할 수 있다:

QUser.user.firstname.eq("Dave").and(QUser.user.lastname.eq("Matthews"))

이 기능은 클래스패스에 Querydsl이 있고, @EnableSpringDataWebSupport를 사용했다면 자동으로 활성화된다.

메소드 시그니처에 @QuerydslPredicate를 추가하면 바로 사용할 수 있는 Predicate를 제공하므로 QuerydslPredicateExecutor를 통해 바로 실행할 수 있다.

타입 정보는 보통 메소드 리턴 타입을 통해 결정한다. 도메인 타입이 반드시 리턴 타입과 일치해야 하는건 아니므로, QuerydslPredicateroot 속성을 사용해도 좋다.

다음은 메소드 시그니처에 @QuerydslPredicate를 사용하는 예제다:

@Controller
class UserController {

  @Autowired UserRepository repository;

  @RequestMapping(value = "/", method = RequestMethod.GET)
  String index(Model model, @QuerydslPredicate(root = User.class) Predicate predicate, // (1)
          Pageable pageable, @RequestParam MultiValueMap<String, String> parameters) {

    model.addAttribute("users", repository.findAll(predicate, pageable));

    return "index";
  }
}

(1) 쿼리 스트링 인자로 User를 검색할 Predicate를 리졸브한다.

디폴트 바인딩 규칙 다음과 같다:

바인딩 규칙은 @QuerydslPredicatebindings 속성을 사용해서 커스텀할 수 있고, 자바 8 디폴트 메소드를 활용해 다음과 같이 레포지토리 인터페이스에 QuerydslBinderCustomizer 메소드를 추가해도 된다:

interface UserRepository extends CrudRepository<User, String>,
                                 QuerydslPredicateExecutor<User>,                // (1)
                                 QuerydslBinderCustomizer<QUser> {               // (2)

  @Override
  default void customize(QuerydslBindings bindings, QUser user) {

    bindings.bind(user.username).first((path, value) -> path.contains(value))    // (3)
    bindings.bind(String.class)
      .first((StringPath path, String value) -> path.containsIgnoreCase(value)); // (4)
    bindings.excluding(user.password);                                           // (5)
  }
}

(1) QuerydslPredicateExecutorPredicate를 사용하는 검색 메소드를 제공한다.
(2) 레포지토리 인터페이스에 QuerydslBinderCustomizer를 정의하면 자동으로 반영되며, 더 간단하게는 @QuerydslPredicate(bindings=…)를 사용해도 된다.
(3) username 프로퍼티는 contains로 바인딩하도록 정의한다.
(4) String 프로퍼티는 디폴트로 contains로 바인딩하고, 대소문자를 구분하지 않도록 정의한다.
(5) Predicate를 만들 때 password 프로퍼티는 제외한다.

11.8.3. Repository Populators

스프링 JDBC 모듈을 사용하고 있다면, SQL 스크립트로 DataSource에 데이터를 채우는 작업이 익숙할 거다. 레포지토리 레벨에서도 비슷한 기능을 제공한다. 단, 레포지토리 레벨은 저장소와는 독립적이기 때문에, SQL로 데이터를 정의하지 않는다. 여기서 설명할 populator는 XML(스프링 OXM 사용)과 JSON(Jackson 사용)으로 레포지토리에 저장할 데이터를 정의할 수 있다.

data.json 파일에 다음과 컨텐츠가 있다고 해보자:

Example 51. Data defined in JSON

[ { "_class" : "com.acme.Person",
 "firstname" : "Dave",
  "lastname" : "Matthews" },
  { "_class" : "com.acme.Person",
 "firstname" : "Carter",
  "lastname" : "Beauford" } ]

레포지토리에 데이터를 추가하려면 스프링 데이터 Commons가 제공하는 레포지토리 네임스페이스에 populator 요소를 선언하면 된다. 이 데이터를 PersonRepository에 넣으려면 아래와 같은 populator를 선언해라:

Example 52. Declaring a Jackson repository populator

<?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:repository="http://www.springframework.org/schema/data/repository"
  xsi:schemaLocation="http://www.springframework.org/schema/beans
    https://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/data/repository
    https://www.springframework.org/schema/data/repository/spring-repository.xsd">

  <repository:jackson2-populator locations="classpath:data.json" />

</beans>

이렇게 설정하면 data.json 파일을 읽어 Jackson ObjectMapper로 역직렬화한다.

JSON 객체를 역직렬화할 타입은 JSON 도큐먼트에 있는 _class 속성을 보고 결정한다. 그 다음 역직렬화한 객체를 처리할 적절한 레포지토리를 선택한다.

레포지토리에 저장할 데이터를 XML로 정의하고 싶다면 unmarshaller-populator 요소를 사용해라. 스프링 OXM에 있는 XML 마샬러 옵션 중 하나를 사용하면 된다. 자세한 내용은 스프링 레퍼런스 문서를 참고해라. 다음은 JAXB로 언마샬하는 예제다:

Example 53. Declaring an unmarshalling repository populator (using JAXB)

<?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:repository="http://www.springframework.org/schema/data/repository"
  xmlns:oxm="http://www.springframework.org/schema/oxm"
  xsi:schemaLocation="http://www.springframework.org/schema/beans
    https://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/data/repository
    https://www.springframework.org/schema/data/repository/spring-repository.xsd
    http://www.springframework.org/schema/oxm
    https://www.springframework.org/schema/oxm/spring-oxm.xsd">

  <repository:unmarshaller-populator locations="classpath:data.json"
    unmarshaller-ref="unmarshaller" />

  <oxm:jaxb2-marshaller contextPath="com.acme" />

</beans>

전체 목차는 여기에 있습니다.

<< >>

TOP