Spring Data R2DBC | 참조 문서


참조 문서

12. 도입

12.1. 문서 구조

참조 문서의 이 부분에서는 Spring Data R2DBC에서 제공하는 핵심 기능에 대해 설명한다.

R2DBC 지원“은 R2DBC 모듈의 기능 세트를 소개한다.

R2DBC Repository“는 R2DBC 저장소 지원을 소개한다.


13. R2DBC 지원

R2DBC에는 다양한 기능이 포함되어 있다.

  • R2DBC 드라이버 인스턴스의 Java 기반 @Configuration 클래스에 의한 Spring 구성 지원.
  • 행과 POJO 간의 통합 객체 매핑을 사용하여 일반적인 R2DBC 작업을 수행할 때 생산성을 향상시키는 엔터티 바인딩 작업의 주요 클래스인 R2dbcEntityTemplate.
  • Spring의 변환 서비스(Conversion Service)와 통합된 기능이 풍부한 객체 매핑.
  • 다른 메타데이터 형식을 지원하기 위해 확장 가능한 어노테이션 기반 매핑 메타데이터.
  • 사용자 지정 쿼리 메소드 지원을 포함한 리포지토리 인터페이스의 자동 구현.

대부분의 작업에서는 R2dbcEntityTemplate 또는 리포지토리 지원을 사용해야 하며, 모두 많은 매핑(rich mapping) 기능을 사용한다. R2dbcEntityTemplate는 Ad ad-hoc CRUD 작업과 같은 액세스 기능을 찾는다.

13.1. 입문

작업 환경을 설정하는 쉬운 방법은 start.spring.io을 통해 Spring 기반 프로젝트를 만드는 것이다. 이렇게 하려면 :

  1. 다음을 pom.xml 파일의 dependencies 요소에 추가한다.
    <dependencyManagement>
      <dependencies>
        <dependency>
          <groupId>io.r2dbc</groupId>
          <artifactId>r2dbc-bom</artifactId>
          <version>${r2dbc-releasetrain.version}</version>
          <type>pom</type>
          <scope>import</scope>
        </dependency>
      </dependencies>
    </dependencyManagement>
    
    <dependencies>
    
      <!-- other dependency elements omitted -->
    
      <dependency>
        <groupId>org.springframework.data</groupId>
        <artifactId>spring-data-r2dbc</artifactId>
        <version>1.4.0</version>
      </dependency>
    
      <!-- a R2DBC driver -->
      <dependency>
        <groupId>io.r2dbc</groupId>
        <artifactId>r2dbc-h2</artifactId>
        <version>Arabba-SR10</version>
      </dependency>
    
    </dependencies>
    
  2. pom.xml의 Spring 버전을 다음과 같이 변경한다.
    <spring-framework.version>5.3.13</spring-framework.version>
    
  3. Maven의 Spring 마일스톤 리포지토리의 다음 위치를 <dependencies/> 요소와 동일한 레벨이되도록 pom.xml에 추가한다.
    <repositories>
      <repository>
        <id>spring-milestone</id>
        <name>Spring Maven MILESTONE Repository</name>
        <url>https://repo.spring.io/libs-milestone</url>
      </repository>
    </repositories>
    

리포지토리는 여기에서 검색할 수도 있다.

로깅 레벨을 DEBUG로 설정하여, 추가 정보를 표시할 수도 있다. 그렇게 하려면 application.properties 파일에 다음와 같은 내용이 포함되도록 편집한다.

logging.level.org.springframework.r2dbc=DEBUG

그런 다음에는 예를 들어, Person 클래스를 만들어 다음과 같이 영속화할 수 있다.

public class Person {

  private final String id;
  private final String name;
  private final int age;

  public Person(String id, String name, int age) {
    this.id = id;
    this.name = name;
    this.age = age;
  }

  public String getId() {
    return id;
  }

  public String getName() {
    return name;
  }

  public int getAge() {
    return age;
  }

  @Override
  public String toString() {
    return "Person [id=" + id + ", name=" + name + ", age=" + age + "]";
  }
}

그 다음으로 다음과 같이 데이터베이스에 테이블 구조를 생성해야 한다.

CREATE TABLE person
  (id VARCHAR(255) PRIMARY KEY,
   name VARCHAR(255),
   age INT);

또한, 다음과 같이 실행할 메인 애플리케이션도 필요하다.

import io.r2dbc.spi.ConnectionFactories;
import io.r2dbc.spi.ConnectionFactory;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.test.StepVerifier;

import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;

public class R2dbcApp {

  private static final Log log = LogFactory.getLog(R2dbcApp.class);

  public static void main(String[] args) {

    ConnectionFactory connectionFactory = ConnectionFactories.get("r2dbc:h2:mem:///test?options=DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE");

    R2dbcEntityTemplate template = new R2dbcEntityTemplate(connectionFactory);

    template.getDatabaseClient().sql("CREATE TABLE person" +
        "(id VARCHAR(255) PRIMARY KEY," +
        "name VARCHAR(255)," +
        "age INT)")
      .fetch()
      .rowsUpdated()
      .as(StepVerifier::create)
      .expectNextCount(1)
      .verifyComplete();

    template.insert(Person.class)
      .using(new Person("joe", "Joe", 34))
      .as(StepVerifier::create)
      .expectNextCount(1)
      .verifyComplete();

    template.select(Person.class)
      .first()
      .doOnNext(it -> log.info(it))
      .as(StepVerifier::create)
      .expectNextCount(1)
      .verifyComplete();
  }
}

메인 프로그램을 실행하면, 앞의 예에서는 다음과 같이 출력된다.

2018-11-28 10:47:03,893 DEBUG amework.core.r2dbc.DefaultDatabaseClient: 310 - Executing SQL statement [CREATE TABLE person
  (id VARCHAR(255) PRIMARY KEY,
   name VARCHAR(255),
   age INT)]
2018-11-28 10:47:04,074 DEBUG amework.core.r2dbc.DefaultDatabaseClient: 908 - Executing SQL statement [INSERT INTO person (id, name, age) VALUES($1, $2, $3)]
2018-11-28 10:47:04,092 DEBUG amework.core.r2dbc.DefaultDatabaseClient: 575 - Executing SQL statement [SELECT id, name, age FROM person]
2018-11-28 10:47:04,436  INFO        org.spring.r2dbc.example.R2dbcApp:  43 - Person [id='joe', name='Joe', age=34]

이 간단한 예에서도 몇 가지 주의해야 할 사항이 있다

  • 표준의 io.r2dbc.spi.ConnectionFactory 객체를 사용하여, Spring Data R2DBC(R2dbcEntityTemplate)에서 주요 도우미 클래스의 인스턴스를 생성할 수 있다.
  • 매퍼는 추가 메타데이터 없이, 표준 POJO 객체에서 작동한다(다만, 선택적으로 해당 정보를 제공할 수 있다. 여기를 참조하길 바란다.).
  • 매핑 규칙에서는 필드 액세스를 사용할 수 있다. Person 클래스에는 getter 밖에 없다.
  • 생성자의 인수 이름이 저장된 행의 열 이름과 일치하면, 그걸로 객체를 인스턴스화하는데 사용된다.

13.2. 예제 리포지토리

GitHub 리포지토리 및 몇 가지 예를 다운로드하여 사용하여 라이브러리의 동작을 확인한다.

13.3. Spring을 사용하여 관계형 데이터베이스에 연결

관계형 데이터베이스와 Spring을 사용하는 경우에 첫번째 작업 중 하나는 IoC 컨테이너를 사용하여 io.r2dbc.spi.ConnectionFactory 객체를 만드는 것이다. 지원되는 데이터베이스와 드라이버를 사용한다.

13.3.1. Java 기반 메타데이터를 사용하여 ConnectionFactory 인스턴스 등록

다음 예는 Java 기반 Bean 메타데이터를 사용하여 io.r2dbc.spi.ConnectionFactory 인스턴스를 등록하는 예를 보여준다.

예 54: Java 기반 Bean 메타데이터를 사용하여 io.r2dbc.spi.ConnectionFactory 오브젝트 등록

@Configuration
public class ApplicationConfiguration extends AbstractR2dbcConfiguration {

  @Override
  @Bean
  public ConnectionFactory connectionFactory() {
    return 
  }
}

이 접근 방법(approach)에서는 표준 io.r2dbc.spi.ConnectionFactory 인스턴스를 사용할 수 있으며, 컨테이너는 Spring의 AbstractR2dbcConfiguration을 사용한다. ConnectionFactory 인스턴스를 직접 등록하는 것과 비교하여 구성 지원에는 @Repository 어노테이션으로 선언된 데이터 액세스 클래스에 대해 R2DBC 예외를 Spring의 이식 가능한 DataAccessException 계층의 예외로 변환하는 ExceptionTranslator 구현을 컨테이너에 제공하는 추가 이점이 있다. 이 계층 구조와 @Repository의 사용은 Spring의 DAO 지원 기능에 설명되어 있다.

AbstractR2dbcConfigurationDatabaseClient도 등록한다. 이는 데이터베이스 상호 작용 및 리포지토리 구현에 필요하다.

13.3.2. R2DBC 드라이버

Spring Data R2DBC는 R2DBC의 플러그 가능한 SPI 메커니즘을 통해 드라이버를 지원한다. Spring Data R2DBC으로 R2DBC 사양을 구현하는 임의의 드라이버를 사용할 수 있다. Spring Data R2DBC는 각 데이터베이스의 특정 기능에 반응하기 때문에 Dialect 구현이 필요한다. 그렇지 않으면, 응용 프로그램이 시작되지 않는다. Spring Data R2DBC에는, 다음의 드라이버용의 다이어렉트 구현이 함께 제공된다.

Spring Data R2DBC는 ConnectionFactory를 검사하는 것에 의해서 데이터베이스 세부 사항에 반응하고, 그에 따라 적절한 데이터베이스 다이렉트를 선택한다. 사용하는 드라이버가 Spring Data R2DBC 에 아직 인식되고 있지 않는 경우는, 독자적인 R2dbcDialect를 구성해야 한다.

13.4. R2dbcEntityOperations 데이터 액세스 API

R2dbcEntityTemplate는 Spring Data R2DBC의 핵심 진입점이다. 데이터를 쿼리, 삽입, 업데이트, 삭제와 같은 일반적인 임시(ad-hoc) 사용 사용를 위한 직접적인 엔티티 지향 방법과 보다 좁게 플루언트 인터페이스(fluent interface)를 제공한다.

플루언트 인터페이스(fluent interface)는 메소드 체이닝에 상당 부분 기반한 객체 지향 API 설계 메소드이며, 소스 코드의 가독성을 산문과 유사하게 만드는 것이 목적이다. - 출처: 위키백과

진입점(insert(), select(), update() 등)은 수행할 작업을 기반으로 하는 자연스러운 명명 스키마를 따른다. 진입점에 계속해서 API는 SQL문를 작성하고 실행하는 종료 메소드에 연결되는 문맥 의존의 메소드만을 제공하도록 설계되고 있다. Spring Data R2DBC는, R2dbcDialect 추상화를 하여 바인드 마커, 페이지네이션 지원 및 기본 드라이버에 의해 네이티브에 서포트되는 데이터형을 결정한다.

13.4.1. 엔티티를 삽입하고 업데이트하는 방법

R2dbcEntityTemplate에는 객체를 저장하고 삽입하는 편리한 방법이 있다. 변환 프로세스를 보다 세밀하게 제어하기 위해 Spring 컨버터를 R2dbcCustomConversions에 등록할 수 있다(예를 들면, Converter<Person, OutboundRow>, Converter<Row, Person>).

저장 작업을 사용하는 간단한 경우는 POJO를 저장하는 것이다. 이 경우에 테이블 이름은 클래스의 이름(전체 규정이 아님)에 의해 결정된다. 특정 컬렉션 이름을 사용하여 저장 작업을 호출할 수도 있다. 매핑 메타데이터를 사용하여 객체를 저장하는 컬렉션을 재정의할 수 있다.

삽입 또는 저장할 때에 Id 프로퍼티이 설정되지 않은 경우, 해당 값은 데이터베이스에서 자동으로 생성된다고 가정한다. 자동 생성의 경우에는 클래스내의 Id 프로퍼티 또는 필드의 타입은 Long 또는 Integer가 아니면 안된다.

다음 예제는 행을 삽입하고 그 내용을 얻는 방법을 보여준다.

예 55: R2dbcEntityTemplate를 사용하여 엔티티 삽입 및 검색

Person person = new Person("John", "Doe");

Mono<Person> saved = template.insert(person);
Mono<Person> loaded = template.selectOne(query(where("firstname").is("John")),
    Person.class);

다음 삽입 및 갱신 작업을 사용할 수 있다.

유사한 삽입 작업 세트도 사용할 수 있다.

  • Mono<T> insert (T objectToSave) : 객체를 기본 테이블에 삽입한다.

  • Mono<T> update (T objectToSave) : 객체를 기본 테이블에 삽입한다.

흐르는 API를 사용하여, 테이블 이름을 사용자 지정할 수 있다.

13.4.2. 데이터 선택

R2dbcEntityTemplateselect(…)selectOne(…) 메소드는 테이블로부터 데이터를 선택하는데 사용된다. 두 메소드 모두 Query 필드 프로젝션(field projection), WHERE절, ORDER BY절, 제한/오프셋 페이징을 정의하는 객체를 사용한다. 제한/오프셋 기능은 기본 데이터베이스에 관계없이 응용 프로그램에 투명한다. 이 기능은 각각의 SQL 플레이버간의 차이에 대응하기 위한 R2dbcDialect 추상화에 의해 지원되고 있다.

예 56: R2dbcEntityTemplate을 사용하여 엔티티 선택

Flux<Person> loaded = template.select(query(where("firstname").is("John")),
    Person.class);

13.4.3. Fluent API

이 섹션에서는 플루언트 API의 사용 부분에 대해 설명한다. 다음 간단한 쿼리를 생각해 보자.

Flux<Person> people = template.select(Person.class) // (1)
    .all(); // (2)

(1) select(…) 메소드에서 Person를 사용하면, 테이블 형식의 결과가 Person 결과 객체에 매핑된다.
(2) all()행을 패칭(Fetching)하면 결과를 제한하지 않는 Flux<Person>가 반환된다.

다음 예제에서는 이름, WHERE 조건 및 ORDER BY 절로 테이블 이름을 지정하는 보다 복잡한 쿼리를 선언한다.

Mono<Person> first = template.select(Person.class)  // (1)
  .from("other_person")
  .matching(query(where("firstname").is("John")     // (2)
    .and("lastname").in("Doe", "White"))
    .sort(by(desc("id"))))                          // (3)
  .one();                                           // (4)

(1) 테이블에서 이름으로 선택하면, 지정된 도메인 유형을 사용하는 행의 결과가 반환된다. (2) 발행된 쿼리는 결과를 필터링으로 firstnamelastname 열에 WHERE 조건을 선언한다. (3) 결과는 각각의 컬럼 이름으로 재정렬될 수 있으며, 결과로 ORDER BY 절이 생성된다. (4) 하나의 결과를 선택하면 한 행만 페치된다. 행을 소비하는 이 방법에서는 쿼리가 정확히 단일 결과를 반환할 것으로 예상된다. 쿼리의 결과가 둘 이상인 경우에 Mono라면 IncorrectResultSizeDataAccessException이 발생된다.

다음 종료 방법을 사용하여 단일 엔티티 검색과 여러 엔터티 검색을 전환할 수 있다.

  • first(): 첫 번째 행만 사용하여 Mono를 반환한다. 쿼리가 결과를 반환하지 않으면, 반환된 Mono객체를 게시하지 않고 완료된다.
  • one(): Mono를 반환하는 한 줄만 사용한다. 쿼리가 결과를 반환하지 않으면 반환된 Mono 객체를 게시하지 않고 완료된다. 쿼리가 여러 행을 반환하면 MonoIncorrectResultSizeDataAccessException 예외가 발생한다.
  • all(): Flux을 반환하는 모든 반환된 행을 사용한다.
  • count(): Mono<Long>을 반환하는 카운트 프로젝션을 적용한다.
  • exists(): Mono<Boolean>를 반환하여, 쿼리가 행을 생성하는지 여부를 반환한다.

select() 진입점을 사용하여 SELECT 쿼리를 표현할 수 있다. 결과의 SELECT 쿼리는 자주 사용되는 절(WHEREORDER BY)을 지원하고 페이지네이션을 지원한다. 플루언트 API 스타일에 의해, 체인은 복수의 메소드를 함께 이해하면서, 코드를 이해하기 쉬워진다. 가독성을 높이기 위해 Criteria 인스턴스를 만드는 데 ‘새로운’ 키워드를 사용하지 않는 정적 가져오기를 사용할 수 있다.

Criteria 클래스의 메소드

Criteria 클래스는 다음의 메소드를 제공한다. 이러한 메소드는 모두 SQL 연산자에 대응하고 있다.

  • Criteria and (String column) : 지정된 property을 가진 연결된 Criteria를 현재의 Criteria에 추가하고 새로 작성된 Criteria을 반환한다.
  • Criteria or (String column) : 지정된 property을 가진 연결된 Criteria를 현재의 Criteria에 추가하고 새로 작성된 Criteria을 반환한다.
  • Criteria greaterThan (Object o) : > 연산자를 사용하여 기준을 만든다.
  • Criteria greaterThanOrEquals (Object o) : >= 연산자를 사용하여 기준을 만든다.
  • Criteria in (Object… o) : varargs 인수에 IN 연산자를 사용하여 기준을 만든다.
  • Criteria in (Collection<?> collection) : 컬렉션을 사용하여 IN 연산자를 사용하여 기준을 만든다.
  • Criteria is (Object o) : 열 매칭(property = value)를 사용하여 기준을 만든다.
  • Criteria isNull () : IS NULL 연산자를 사용하여 기준을 만든다.
  • Criteria isNotNull () : IS NOT NULL 연산자를 사용하여 기준을 만든다.
  • Criteria lessThan (Object o) : < 연산자를 사용하여 기준을 만든다.
  • Criteria lessThanOrEquals (Object o) : <= 연산자를 사용하여 기준을 만든다.
  • Criteria like (Object o) : 이스케이프 문자 처리 없이 LIKE 연산자를 사용하여 기준을 만든다.
  • Criteria not (Object o) : != 연산자를 사용하여 기준을 만든다.
  • Criteria notIn (Object… o) : varargs 인수에 NOT IN 연산자를 사용하여 기준을 만든다.
  • Criteria notIn (Collection<?> collection) : 컬렉션을 사용하여 NOT IN 연산자를 사용하여 기준을 만든다.

CriteriaSELECT, UPDATE, DELETE 쿼리를 사용할 수 있다.

13.4.4. 데이터 삽입

insert() 진입점을 사용하여 데이터를 삽입할 수 있다.

다음 간단한 형식화된 삽입 작업을 보도록 하자.

Mono<Person> insert = template.insert(Person.class) // (1)
    .using(new Person("John", "Doe")); // (2)

(1) into(…) 메소드로 Person를 사용하면, 매핑 메타데이터를 기반으로 INTO 테이블이 설정된다. 또한 삽입할 Person 객체를 허용하는 insert 문을 준비한다.
(2) 스칼라 Person 객체를 제공한다. 또는 Publisher를 지정하여 INSERT 명령문 스트림을 실행할 수 있다. 이 메소드는 모든 null이 아닌 값을 추출하고 삽입한다.

13.4.5. 데이터 업데이트

update() 진입점을 사용하여 행을 업데이트할 수 있다. 데이터 업데이트는 할당을 지정하는 Update을 수락하고 업데이트할 테이블을 지정하는 것으로 시작된다. 또한 Query을 수락하여 WHERE절을 만든다.

다음 간단한 유형이 지정된 업데이트 작업을 보도록 하자.

Person modified = 

    Mono<Integer> update = template.update(Person.class)  // (1)
        .inTable("other_table")                           // (2)
        .matching(query(where("firstname").is("John")))   // (3)
        .apply(update("age", 42));                        // (4)

(1) Person 객체를 업데이트하고, 매핑 메타데이터를 기반으로 매핑을 적용한다.
(2) inTable(…) 메소드를 호출하여, 다른 테이블 이름을 설정한다.
(3) WHERE 절로 변환되는 쿼리를 지정한다.
(4) Update 객체를 적용한다. 이 경우는 age42로 설정하고 영향을 받는 행 수를 반환한다.

13.4.6. 데이터 삭제

delete() 진입점을 사용하여 행을 삭제할 수 있다. 데이터 삭제는 삭제할 테이블 지정으로 시작하고 선택적으로 Criteria를 수락하여 WHERE 절을 만든다.

다음 간단한 삭제 작업을 보도록 하자.

    Mono<Integer> delete = template.delete(Person.class)  // (1)
        .from("other_table")                              // (2)
        .matching(query(where("firstname").is("John")))   // (3)
        .all();                                           // (4)

(1) Person 객체를 삭제하고, 매핑 메타데이터를 기반으로 매핑을 적용한다.
(2) from(…) 메소드를 호출하여, 다른 테이블 이름을 설정한다.
(3) WHERE 절로 변환되는 쿼리를 지정한다.
(4) 삭제 조작을 적용하여 영향을 받는 행 수를 리턴한다.


14. R2DBC Repository

이 장에서는 R2DBC의 리포지토리 지원 전문 분야에 대해 설명한다. 이 섹션에서는 Spring Data Repository 작업에서 설명한 핵심 저장소 지원을 기반으로 한다. 이 섹션을 읽기 전에 여기에 설명된 기본 개념을 확실히 이해해야 한다.

14.1. 사용방법

관계형 데이터베이스에 저장된 도메인 엔티티에 액세스하려면, 고급 리포지토리 지원을 사용하여 구현을 크게 용이하게 한다. 이렇게 하려면 리포지토리 인터페이스를 만든다. 다음의 Person 클래스를 보도록 하자.

예 57: 샘플 Person 엔티티

public class Person {

  @Id
  private Long id;
  private String firstname;
  private String lastname;

  // … getters and setters omitted
}

다음 예는 이전의 Person 클래스의 저장소 인터페이스를 보여준다.

예 58: Person 엔티티를 영속화하는 기본 리포지토리 인터페이스

public interface PersonRepository extends ReactiveCrudRepository<Person, Long> {

  // additional custom query methods go here
}

R2DBC 리포지토리를 구성하려면 @EnableR2dbcRepositories 어노테이션을 사용할 수 있다. 기본 패키지가 구성되지 않은 경우 인프라스트럭처는 어노테이션이 달린 구성 클래스의 패키지를 스캔한다. 다음 예는 리포지토리에 Java 구성을 사용하는 방법을 보여준다.

예 59: 리포지토리의 Java 구성

@Configuration
@EnableR2dbcRepositories
class ApplicationConfig extends AbstractR2dbcConfiguration {

  @Override
  public ConnectionFactory connectionFactory() {
    return 
  }
}

도메인 리포지토리는 ReactiveCrudRepository 상속되므로 엔터티에 액세스하기 위한 리액티브 CRUD 작업을 제공한다. ReactiveCrudRepository뿐만 아니라 PagingAndSortingRepository와 비슷한 정렬 기능을 추가하는 ReactiveSortingRepository도 있다. 리포지토리 인스턴스 작업은 클라이언트에 삽입하는 종속성 문제일 뿐이다. 다음 코드로 모든 Person 객체를 얻을 수 있다.

예 60: 개인 엔티티에 대한 페이징 액세스

@ExtendWith(SpringExtension.class)
@ContextConfiguration
class PersonRepositoryTests {

  @Autowired
  PersonRepository repository;

  @Test
  void readsAllEntitiesCorrectly() {

    repository.findAll()
      .as(StepVerifier::create)
      .expectNextCount(1)
      .verifyComplete();
  }

  @Test
  void readsEntitiesByNameCorrectly() {

    repository.findByFirstname("Hello World")
      .as(StepVerifier::create)
      .expectNextCount(1)
      .verifyComplete();
  }
}

앞의 예에서는 Spring 단위 테스트 지원을 사용하여 응용 프로그램 컨텍스트를 만든다. 이렇게 하면 테스트 케이스에 어노테이션 기반 의존 주입이 수행된다. 테스트 메소드 내에서는 리포지토리를 사용하여 데이터베이스에 쿼리를 실행한다. 결과에 대한 기대를 검증하기 위해 테스트 지원으로 StepVerifier 사용한다.

14.2. 쿼리 메소드

일반적으로 리포지토리에서 트리거하는 데이터 액세스의 대부분은 데이터베이스에 대해 실행되는 쿼리이다. 이러한 쿼리의 정의는 다음 예제와 같이 리포지토리 인터페이스에서 메소드를 선언하는 것이다.

예 61: PersonRepository 및 쿼리 메소드

interface ReactivePersonRepository extends ReactiveSortingRepository<Person, Long> {

  Flux<Person> findByFirstname(String firstname);                                   // (1)

  Flux<Person> findByFirstname(Publisher<String> firstname);                        // (2)

  Flux<Person> findByFirstnameOrderByLastname(String firstname, Pageable pageable); // (3)

  Mono<Person> findByFirstnameAndLastname(String firstname, String lastname);       // (4)

  Mono<Person> findFirstByLastname(String lastname);                                // (5)

  @Query("SELECT * FROM person WHERE lastname = :lastname")
  Flux<Person> findByLastname(String lastname);                                     // (6)

  @Query("SELECT firstname, lastname FROM person WHERE lastname = $1")
  Mono<Person> findFirstByLastname(String lastname);                                // (7)
}

(1) 이 메소드는 지정한 firstname을 가진 모든 사람들을 조회하는 쿼리이다. 쿼리는 AndOr를 연결할 수 있는 제약의 메소드 이름을 구문 분석하여 파생된다. 메소드 이름은 SELECT … FROM person WHERE firstname = :firstname와 같은 쿼리 식이 된다.
(2) 이 메소드는 지정된 Publisher에 의해 firstname 발급되면, 지정된 firstname을 가진 모든 사람의 쿼리를 나타낸다.
(3) Pageable를 사용하여 오프셋 및 정렬 매개 변수를 데이터베이스에 전달한다.
(4) 지정된 조건으로 단일 엔티티를 검색한다. 고유하지 않은 결과에 대해서는 IncorrectResultSizeDataAccessException으로 완료된다.
(5) <4>가 아니면 쿼리에서 더 많은 결과 행을 생성하더라도 첫 번째 엔터티만 항상 보내진다.
(6) findByLastname 메소드는 지정된 성을 가진 모든 사람들의 쿼리를 표시한다.
(7) firstnamelastname 열만 보여주는 단일 Person 엔티티를 조회한다. 어노테이션이 있는 쿼리는 이 예에서 Postgres 바인딩 마커인 네이티브 바인드 마커를 사용한다.

@Query 어노테이션에서 사용되는 select 문의 열은 각각의 속성에 대해 NamingStrategy에 의해 생성된 이름과 일치해야 한다. select 문과 일치하는 열이 포함되어 있지 않으면, 해당 속성이 설정되지 않는다. 해당 속성이 영속성 생성자에 필요한 경우는 null 또는 (프리미티브형의 경우) 디폴트치가 제공된다.

다음 표는 쿼리 메소드에서 지원되는 키워드를 보여준다.

표 2: 쿼리 메소드에서 지원되는 키워드

키워드 샘플 논리적 결과
After findByBirthdateAfter(Date date) birthdate > date
GreaterThan findByAgeGreaterThan(int age) age > age
GreaterThanEqual findByAgeGreaterThanEqual(int age) age >= age
Before findByBirthdateBefore(Date date) birthdate < date
LessThan findByAgeLessThan(int age) age < age
LessThanEqual findByAgeLessThanEqual(int age) age <= age
Between findByAgeBetween(int from, int to) age BETWEEN from AND to
NotBetween findByAgeNotBetween(int from, int to) age NOT BETWEEN from AND to
In findByAgeIn(Collection<Integer> ages) age IN (age1, age2, ageN)
NotIn findByAgeNotIn(Collection ages) age NOT IN (age1, age2, ageN)
IsNotNull, NotNull findByFirstnameNotNull() firstname IS NOT NULL
IsNull, Null findByFirstnameNull() firstname IS NULL
Like, StartingWith, EndingWith findByFirstnameLike(String name) firstname LIKE name
NotLike, IsNotLike findByFirstnameNotLike(String name) firstname NOT LIKE name
문자열의 Containing findByFirstnameContaining(String name) firstname LIKE '%' + name +'%'
문자열의 NotContaining findByFirstnameNotContaining(String name) firstname NOT LIKE '%' + name + '%'
(No keyword) findByFirstname(String name) firstname = name
Not findByFirstnameNot(String name) firstname != name
IsTrue, True findByActiveIsTrue() active IS TRUE
IsFalse, False findByActiveIsFalse() active IS FALSE

14.2.1. 쿼리 변경

이전 섹션에서는 특정 엔터티 또는 엔터티 컬렉션에 액세스하기 위한 쿼리를 선언하는 방법에 대해 설명하였다. 이전 표의 키워드를 사용하면 delete…By 또는 remove…By를 조합하여 일치하는 행을 삭제하는 파생 쿼리를 만들 수 있다.

예 62: Delete…By 쿼리

interface ReactivePersonRepository extends ReactiveSortingRepository<Person, String> {

  Mono<Integer> deleteByLastname(String lastname);            // (1)

  Mono<Void> deletePersonByLastname(String lastname);         // (2)

  Mono<Boolean> deletePersonByLastname(String lastname);      // (3)
}

(1) Mono<Integer> 반환 유형을 사용하면, 영향을 받는 행 수를 반환한다.
(2) Void를 사용하면 결과 값을 출력하지 않고 행이 성공적으로 삭제되었는지 여부를 보고한다.
(3) Boolean를 사용하면 적어도 하나의 행이 삭제되었는지 여부를 보고한다.

이 접근 방식은 포괄적인 사용자 지정 기능에 적합하므로, 다음 예제와 같이 쿼리 메소드에 @Modifying 어노테이션을 붙여 매개 변수 바인딩만 필요한 쿼리를 변경할 수 있다.

@Modifying
@Query("UPDATE person SET firstname = :firstname where lastname = :lastname")
Mono<Integer> setFixedFirstnameFor(String firstname, String lastname);

변경 쿼리의 결과는 다음과 같다.

  • Void(또는 Kotlin Unit)은 업데이트 카운트를 버리고 완료를 기다린다.
  • 영향을 받는 행 수를 출력하는 Integer 또는 다른 숫자 유형.
  • 적어도 1개의 행이 갱신되었는지 어떤지를 출력하는 Boolean.

@Modifying 어노테이션은 @Query 어노테이션과 결합한 경우에만 관련이 있다. 파생된 사용자 지정 메소드에서는 이 어노테이션이 필요하지 않는다.

또는 Spring Data 저장소의 커스텀 구현에서 설명하고 있는 있는 기능을 사용하여 커스텀 변경 동작을 추가할 수 있다.

14.2.2. SpEL 표현식을 사용한 쿼리

쿼리 문자열 정의를 SpEL 식과 함께 사용하여 런타임에 동적 쿼리를 만들 수 있다. SpEL 식은 쿼리를 실행하기 직전에 평가되는 서술 값(predicate values)을 제공할 수 있다.

표현식은 모든 인수를 포함하는 배열을 통해 메소드 인수를 공개한다. 다음 쿼리는 [0]를 사용하여, lastname의 서술 값을 선언한다(이는 :lastname 매개 변수 바인딩과 동일).

@Query("SELECT * FROM person WHERE lastname = :#{[0]}")
Flux<Person> findByQueryWithExpression(String lastname);

쿼리 문자열 SpEL은 쿼리를 향상시키는 강력한 방법이다. 그러나 이런 것들은 또한 불필요한 인수의 넓은 범위를 받아들일 수 있다. 쿼리에 불필요한 변경이 발생하지 않도록 쿼리에 전달하기 전에 문자열을 제거해야 한다.

표현식 지원은 Query SPI:org.springframework.data.spel.spi.EvaluationContextExtension를 통해 확장 가능하다. Query SPI는 속성과 기능을 제공하고, 루트 객체를 사용자 지정할 수 있다. 확장 기능은 쿼리를 만들 때 SpEL 평가할 시에 응용 프로그램 컨텍스트에서 가져온다.

14.2.3. 예시에 의한 문의 (Query By Example)

Spring Data R2DBC에서는 Query By Example을 사용하여 쿼리를 만들 수도 있다. 이 기술을 사용하면 “프로브(probe)” 객체를 사용할 수 있다. 기본적으로 비어 있거나 null이 아닌 모든 필드가 일치하는데 사용된다.

예를 들면 다음과 같다.

Employee employee = new Employee(); // (1)
employee.setName("Frodo");

Example<Employee> example = Example.of(employee); // (2)

Flux<Employee> employees = repository.findAll(example); // (3)

// do whatever with the flux

(1) 조건을 사용하여 도메인 객체를 만든다(null 필드는 무시된다).
(2) 도메인 객체를 사용하여, Example을 만든다.
(3) R2dbcRepository을 통해, 쿼리를 실행한다(Mono에는 findOne을 사용한다).

이는 도메인 객체를 사용하여 간단한 프로브를 만드는 방법을 보여준다. 이 경우는 Frodo와 같은 Employee 객체의 name 필드를 기반으로 쿼리를 실행한다. null 필드는 무시된다.

Employee employee = new Employee();
employee.setName("Baggins");
employee.setRole("ring bearer");

ExampleMatcher matcher = matching() // (1)
    .withMatcher("name", endsWith()) // (2)
    .withIncludeNullValues() // (3)
    .withIgnorePaths("role"); // (4)
Example<Employee> example = Example.of(employee, matcher); // (5)

Flux<Employee> employees = repository.findAll(example);

// do whatever with the flux

(1) 모든 필드와 일치하는 사용자 정의 ExampleMatcher를 만든다(matchingAny()를 사용하여 ANY 필드 일치) (2) name 필드는 필드의 뒷부분과 일치하는 와일드카드를 사용한다. (3) 열을 null과 일치시킨다(관계형 데이터베이스의 경우는 NULLNULL과 같지 않음을 잊지 말자). (4) 쿼리를 만들 때에 role 필드를 무시한다. (5) 사용자 정의 ExampleMatcher를 프로브에 연결한다.

모든 속성에 withTransform() 적용하여 쿼리를 만들기 전에 속성을 변환할 수도 있다. 예: 쿼리가 생성되기 전에 toUpperCase()String -based(기반) 속성에 적용할 수 있다.

예시적인 쿼리는 쿼리에 필요한 모든 필드를 미리 모르는 경우에 매우 유용하다. 사용자가 필드를 선택할 수 있는 웹 페이지에서 필터를 만드는 경우에 Query By Example은 이를 효율적인 쿼리에 유연하게 통합하는 좋은 방법이다.

14.2.4. 엔티티 상태 검출 전략

다음 표는 엔터티가 새로운지에 대한 여부를 감지하기 위해 Spring Data가 제공하는 전략을 설명한다.

표 3 : Spring Data에서 엔티티가 새로운지 여부를 감지하는 옵션

@Id - 속성 검사(기본값) 기본적으로 Spring Data는 지정된 엔티티의 식별자 속성을 검사한다. 기본 유형인 경우에는 식별자 속성이 null 또는 0인 엔티티는 새로운 것으로 간주된다. 그렇지 않으면 새로운 것이 아닌 것으로 간주된다.
@Version - 속성 검사 @Version으로 어노테이션이 달린 속성이 존재하는 null인 경우 또는 기본 유형인 0의 버전 속성의 경우에 엔터티는 새로운 것으로 간주된다. 버전 속성은 존재하는 값이 다른 경우, 엔티티는 신규가 아닌 것으로 간주된다. 버전 속성이 존재하지 않으면 Spring Data는 식별자 속성의 검사로 되돌아 간다.
Persistable 구현 엔티티가 Persistable를 구현하고 있는 경우, Spring Data 는 엔티티의 isNew(…) 메소드에 새로운 검출을 위양한다. 자세한 내용은 Javadoc 을 참조하라

참고: AccessType.PROPERTY를 사용하면 Persistable의 속성이 검색되고 유지된다. 이를 방지하려면 @Transient을 사용한다.
사용자 정의 EntityInformation 구현 제공 모듈 고유의 리포지토리 팩토리의 서브 클래스를 작성하여 getEntityInformation(…) 메소드를 오버라이드(override) 하는 것으로, 리포지터리 베이스의 구현으로 사용되는 EntityInformation 추상화를 커스터마이즈 할 수 있다. 다음에 모듈 고유의 리포지토리 팩토리의 커스텀 구현을 Spring Bean 로서 등록할 필요가 있다. 이것이 필요한 것은 거의 없다.

14.2.5. ID 생성(Generation)

Spring Data R2DBC는 ID를 사용하여 엔티티를 식별한다. 엔티티의 ID는 Spring Data의 @Id 어노테이션이 붙어야 한다.

데이터베이스에 ID 열의 자동 증가(auto-increment) 열이 있는 경우는 생성된 값은 데이터베이스에 삽입된 후 엔터티로 설정된다.

Spring Data R2DBC는 엔티티가 새롭고 식별자의 값이 디폴트로 초기값이 되어 있는 경우, 식별자의 열의 값을 삽입하려고 하지 않는다. 이는 일반 유형의 경우는 0이 되고, 식별자 프로퍼티가 Long등의 수치 래퍼 유형을 사용하고 있는 경우는 null 이다.

중요한 제약 중 하나는 엔티티를 저장한 후에 엔티티가 새로운 것이 아니어야 한다는 것이다. 그 엔티티가 새로운지 여부는 엔티티 상태의 일부이다. 자동 증가 열은 ID 열의 값을 사용하여 Spring Data에 의해 ID를 설정하기 때문에 자동으로 수행된다.

14.2.6. 낙관적 잠금 (Optimistic Locking)

@Version 어노테이션은 R2DBC 컨텍스트에서 JPA와 유사한 구문을 제공하여 업데이트가 일치하는 버전의 행에만 적용되도록 한다. 버전 속성의 실제 값은 다른 작업이 그 사이에 행을 변경할 때 업데이트가 영향을 주지 않도록 업데이트 쿼리에 추가된다. 이 경우는 OptimisticLockingFailureException가 throw 된다. 다음 예제는 이러한 기능을 보여준다.

@Table
class Person {

  @Id Long id;
  String firstname;
  String lastname;
  @Version Long version;
}

R2dbcEntityTemplate template = ;

Mono<Person> daenerys = template.insert(new Person("Daenerys"));                      // (1)

Person other = template.select(Person.class)
                 .matching(query(where("id").is(daenerys.getId())))
                 .first().block();                                                    // (2)

daenerys.setLastname("Targaryen");
template.update(daenerys);                                                            // (3)

template.update(other).subscribe(); // emits OptimisticLockingFailureException        // (4)

(1) 먼저 행을 삽입한다. version0으로 설정된다.
(2) 방금 삽입한 행을 로드한다. version 그것은 0으로 남아있다.
(3) 행을 version = 0으로 업데이트한다. lastname을 설정하고 version1로 범프(bump)한다. (4) version = 0이 아직 남아 있는 이전에 로드된 행을 업데이트해 보자. 현재 version1이므로 작업이 OptimisticLockingFailureException으로 실패한다.

14.2.7. 프로젝션 (Projections)

Spring Data 쿼리 메소드는 일반적으로 리포지토리에 의해 관리되는 집계 루트의 하나 이상의 인스턴스를 반환한다. 다만, 이러한 유형의 특정 속성을 기반으로 프로젝션을 만드는 것이 바람직할 수 있다. Spring Data 에서는 전용의 반환값형을 모델화하여 관리 대상 집합체의 부분 뷰를 보다 선택적으로 얻을 수 있다.

다음 예제와 같은 저장소 및 집계 루트 유형을 보도록 하자.

예 63: 샘플 집계 및 리포지토리

class Person {

  @Id UUID id;
  String firstname, lastname;
  Address address;

  static class Address {
    String zipCode, city, street;
  }
}

interface PersonRepository extends Repository<Person, UUID> {

  Flux<Person> findByLastname(String lastname);
}

여기에서 사람의 이름 속성만 검색한다고 해보자. Spring Data는 이를 달성하기 위해 어떤 의미를 가지고 있을까? 이 섹션의 나머지는 그 질문에 답한다.

인터페이스 기반 프로젝션

쿼리 결과를 이름 속성에만 제한하는 가장 쉬운 방법은 다음 예제와 같이 읽을 속성의 접근자 메소드를 공개하는 인터페이스를 선언하는 것이다.

예 64 : 속성의 서브 세트를 취득하는 프로젝션 인터페이스

interface NamesOnly {

  String getFirstname();
  String getLastname();
}

여기서 중요한 것은 여기에 정의된 속성이 집계 경로의 속성과 정확히 일치한다는 것이다. 이렇게 하면 쿼리 메소드를 다음과 같이 추가할 수 있다.

예 65: 쿼리 메소드에서 인터페이스 기반 프로젝션을 사용하는 리포지토리

interface PersonRepository extends Repository<Person, UUID> {

  Flux<NamesOnly> findByLastname(String lastname);
}

쿼리 실행 엔진은 반환된 각 요소에 대해 런타임에 해당 인터페이스의 프록시 인스턴스를 만들고 게시된 메소드에 대한 호출을 대상 객체로 전달한다.

프로젝션은 재귀적으로 사용할 수 있다. Address 정보의 일부를 포함하고 싶다면, 다음의 예와 같이 그렇게 하기 위해 투용 인터페이스를 작성하여 getAddress()의 선언으로부터 그 인터페이스를 반환한다.

예 66 : 속성의 서브 세트를 취득하는 프로젝션 인터페이스

interface PersonSummary {

  String getFirstname();
  String getLastname();
  AddressSummary getAddress();

  interface AddressSummary {
    String getCity();
  }
}

메소드의 호출시에, 타겟 인스턴스의 address 프로퍼티를 받아와서 순서대로 프로젝션 프록시에 감싸지게 된다.

닫힌 프로젝션 (Closed Projections)

접근자 메소드가 모든 대상 집합의 속성과 일치하는 프로젝션 인터페이스는 닫힌 프로젝션으로 간주된다. 다음 예제(이 섹션의 전반부에서도 사용했다)는 닫힌 프로젝션이다.

예 67: 닫힌 프로젝션

interface NamesOnly {

  String getFirstname();
  String getLastname();
}

닫힌 프로젝션을 사용하는 경우는 Spring Data는 쿼리 실행을 최적화할 수 있다. 이는 프로젝션 프록시 백업에 필요한 모든 속성을 알고 있기 때문이다. 자세한 내용은 참조 문서의 모듈별 부분을 참조하라.

열린 프로젝션 (Open Projections)

다음 예제와 같이 @Value 어노테이션을 사용하여 프로젝션 인터페이스의 접근자 메소드를 사용하여 새 값을 계산할 수도 있다.

예 68: 열린 프로젝션

interface NamesOnly {

  @Value("#{target.firstname + ' ' + target.lastname}")
  String getFullName();
  
}

프로젝션을 지원하는 집계 루트는 target 변수에서 사용할 수 있다. @Value를 사용한 프로젝션 인터페이스는 개방 프로젝션이다. 이 경우 Spring Data는 쿼리 실행 최적화를 적용할 수 없다. 이는 SpEL 표현식이 집계 루트의 모든 속성을 사용할 수 있기 때문이다.

@Value에서 사용되는 표현식은 너무 복잡해서는 안된다. String 변수에서 프로그래밍을 피하고 싶을 것이다. 매우 간단한 식의 경우에 첫번째 옵션은 다음의 예제와 같이 기본 메소드(Java 8에서 도입)를 사용하는 것이다.

예 69: 사용자 정의 로직에 기본 메소드를 사용하는 프로젝션 인터페이스

interface NamesOnly {

  String getFirstname();
  String getLastname();

  default String getFullName() {
    return getFirstname().concat(" ").concat(getLastname());
  }
}

이 접근 방식은 프로젝션 인터페이스에서 공개되는 다른 접근자 메소드에 순수한 기반으로 논리를 구현할 수 있어야 합니다. 더 유연한 두번째 옵션은 Spring Bean에 사용자 정의 로직을 구현하고 다음 예제와 같이 SpEL 표현식에서 호출하는 것이다.

예 70: 간단한 Person 객체

@Component
class MyBean {

  String getFullName(Person person) {
    
  }
}

interface NamesOnly {

  @Value("#{@myBean.getFullName(target)}")
  String getFullName();
  
}

SpEL 표현식이 myBean 을 참조하여 getFullName(…) 메소드를 호출하고, 프로젝션 대상을 메소드 매개 변수로 전송하는 방법에 주목하자. SpEL식의 평가를 뒷받침하는 메소드는 메소드 매개 변수를 사용할 수도 있다. 이 매개 변수는 표현식에서 참조할 수 있다. 메소드의 매개 변수는 args라는 Object 배열을 통해 사용할 수 있다. 다음 예제에서는 args 배열에서 메소드 매개 변수를 검색하는 방법을 보여 준다.

예 71: 샘플 Person 객체

interface NamesOnly {

  @Value("#{args[0] + ' ' + target.firstname + '!'}")
  String getSalutation(String prefix);
}

다시 말하지만, 더 복잡한 표현식의 경우 이전에 설명한 대로 Spring Bean을 사용하고 표현식이 메소드를 호출하도록 해야 합니다.

null 허용 래퍼 (Nullable Wrappers)

프로젝션 인터페이스의 Getter는 null 허용 래퍼를 사용하여 null의 안전성을 향상시킬 수 있다. 현재 지원되는 래퍼 유형은 다음과 같다.

  • java.util.Optional
  • com.google.common.base.Optional
  • scala.Option
  • io.vavr.control.Option

예 72: null 허용 래퍼를 사용한 프로젝션 인터페이스

interface NamesOnly {

  Optional<String> getFirstname();
}

기본 프로젝션 값이 null이 아닌 경우, 값은 래퍼 유형의 현재 표현을 사용하여 반환된다. 백킹값(backing value)이 null의 경우, getter 메소드는 사용된 래퍼 타입의 빈 상태로 반환한다.

클래스 기반 프로젝션(DTO)

프로젝션을 정의하는 또 다른 방법은 검색할 필드의 속성을 보유하는 값 유형 DTO(데이터 전송 객체)를 사용하는 것이다. 이러한 DTO 유형은 프록시가 발생하지 않고 중첩된 프로젝션을 적용할 수 없다는 점을 제외하면 프로젝션 인터페이스와 정확히 동일한 방식으로 사용할 수 있다.

저장소가 로드하는 필드를 제한하는 것으로 쿼리 실행을 최적화하는 경우, 로드되는 필드는 공개된 생성자의 매개변수 이름에서 결정된다.

다음 예는 프로젝션 DTO를 보여준다.

예 73: 프로젝션 DTO

class NamesOnly {

  private final String firstname, lastname;

  NamesOnly(String firstname, String lastname) {

    this.firstname = firstname;
    this.lastname = lastname;
  }

  String getFirstname() {
    return this.firstname;
  }

  String getLastname() {
    return this.lastname;
  }

  // equals(…) and hashCode() implementations
}
동적 프로젝션

지금까지 컬렉션의 반환 유형 또는 요소 유형으로 프로젝션 유형을 사용했다. 단, 호출할 때에 사용할 유형을 선택할 수도 있다(이를 통해 동적으로 된다). 동적 프로젝션을 적용하려면 다음 예제와 같은 쿼리 메소드를 사용한다.

예 74: 동적 프로젝션 매개변수를 사용하는 리포지토리

interface PersonRepository extends Repository<Person, UUID> {

  <T> Flux<T> findByLastname(String lastname, Class<T> type);
}

이 방법을 사용하면 다음 예제와 같이 메소드를 사용하여 그대로 두거나 프로젝션을 적용하여 집계를 얻을 수 있다.

예 75: 동적 프로젝션에 리포지토리 사용

void someMethod(PersonRepository people) {

  Flux<Person> aggregates =
    people.findByLastname("Matthews", Person.class);

  Flux<NamesOnly> aggregates =
    people.findByLastname("Matthews", NamesOnly.class);
}
결과 매핑

인터페이스 또는 DTO 프로젝션을 반환하는 쿼리 메소드는 실제 쿼리에서 생성된 결과를 기반으로 한다. 인터페이스 프로젝션은 일반적으로 잠재적인 @Column 유형 매핑을 고려하기 위해 먼저 도메인 유형에 대한 매핑 결과에 의존 하고, 실제 프로젝션 프록시는 잠재적으로 부분적으로 구체화된 엔터티를 사용하여 프로젝션 데이터를 공개한다.

DTO 프로젝션의 결과 매핑은 실제 쿼리 유형에 따라 다르다. 파생 쿼리는 도메인 유형을 사용하여 결과를 매핑하고 Spring Data는 도메인 유형에서 사용 가능한 속성에서만 DTO 인스턴스를 만든다. 도메인 유형에서 사용할 수 없는 DTO 속성 선언은 지원되지 않는다.

문자열 기반 쿼리는 실제 쿼리, 특히 필드 프로젝션과 결과 유형 선언이 밀접하게 관련되어 있기 때문에 서로 다른 접근 방식을 사용한다. @Query으로 어노테이션이 달린 쿼리 메소드에서 사용되는 DTO 프로젝션은 쿼리 결과를 DTO 유형에 직접 매핑한다. 도메인 유형의 필드 매핑은 고려되지 않는다. DTO 유형을 직접 사용하면 쿼리 메소드는 도메인 모델로 제한되지 않는 보다 동적인 프로젝션의 이점을 얻을 수 있다.

14.3. 엔티티 콜백

Spring Data 인프라는, 특정의 메소드가 불려 가기 전후에 엔티티를 변경하기 위한 훅을 제공한다. 이른바 EntityCallback 인스턴스는 콜백 형식의 엔티티를 확인하고, 잠재적으로 변경하는 편리한 방법을 제공한다.
EntityCallback는 특화된 ApplicationListener와 매우 비슷하다. 일부의 Spring Data 모듈은, 특정 엔티티의 변경을 허가하는 저장소 고유의 이벤트(BeforeSaveEvent 등)를 공개한다. 불변의 형태를 조작하는 경우 등, 이러한 이벤트는 문제를 일으킬 가능성이 있다. 또한 이벤트 발행은 ApplicationEventMulticaster에 따라 다르다. 비동기 TaskExecutor로 구성하면, 이벤트 처리를 스레드로 분기할 수 있으므로 예측할 수 없는 결과를 초래할 수 있다.

엔티티 콜백은 동기화 포인트와 리액티브 API를 모두 통합 포인트에 제공하여, 처리 체인의 명확하게 정의된 체크 포인트에서 순서 실행을 보장하며 잠재적으로 변경된 엔티티 또는 리액티브 래퍼 유형을 반환한다.

엔티티 콜백은 일반적으로 API 유형으로 분리된다. 이 분리는 동기 API가 동기 엔티티 콜백만을 고려하여 리액티브 구현이 리액티브 엔티티 콜백만을 고려하는 것을 의미한다.

14.3.1. 엔티티 콜백 구현

EntityCallback는 제네릭스 유형 인수를 통해 도메인 유형과 직접 연결된다. 각 Spring Data 모듈에는 일반적으로 EntityCallback 엔티티 라이프사이클을 다루는 사전에 정의된 인터페이스 세트와 함께 제공된다.

예 76: EntityCallback 구조

@FunctionalInterface
public interface BeforeSaveCallback<T> extends EntityCallback<T> {

  /**
   * Entity callback method invoked before a domain object is saved.
   * Can return either the same or a modified instance.
   *
   * @return the domain object to be persisted.
   */
  T onBeforeSave(T entity <2>, String collection <3>); // (1)
}

(1) 엔티티가 저장되기 전에 호출되는 BeforeSaveCallback 고유한 메소드. 잠재적으로 변경된 인스턴스를 반환한다.
(2) 영속화하기 직전의 엔티티이다.
(3) 엔터티가 유지되는 컬렉션과 같은 여러 저장소 특정 인수이다.

예 77: 리액티브 EntityCallback 구조

@FunctionalInterface
public interface ReactiveBeforeSaveCallback<T> extends EntityCallback<T> {

  /**
   * Entity callback method invoked on subscription, before a domain object is saved.
   * The returned Publisher can emit either the same or a modified instance.
   *
   * @return Publisher emitting the domain object to be persisted.
   */
  Publisher<T> onBeforeSave(T entity <2>, String collection <3>); // (1)
}

(1) 엔티티가 저장되기 전에 구독 시에 호출되는 BeforeSaveCallback 고유 메소드. 잠재적으로 변경된 인스턴스를 발행한다.
(2) 영속화하기 직전의 엔티티이다.
(3) 엔티티가 영속화되는 컬렉션과 같은 여러 저장소 특정 인수이다.

다음 예제와 같이 애플리케이션 요구에 맞는 인터페이스를 구현한다.

예 78: 예 BeforeSaveCallback

class DefaultingEntityCallback implements BeforeSaveCallback<Person>, Ordered {      // (2)

  @Override
  public Object onBeforeSave(Person entity, String collection) {                   // (1)

    if(collection == "user") {
        return // ...
    }

    return // ...
  }

  @Override
  public int getOrder() {
    return 100;                                                                  // (2)
  }
}

(1) 요구 사항에 따라 콜백 구현. (2) 동일한 도메인 유형의 엔티티 콜백이 여러 개인 경우에 엔티티 콜백을 순서를 정할 수 있다. 순서는 가장 낮은 우선 순위를 따른다.

14.3.2. 엔티티 콜백 등록

EntityCallback Bean은 ApplicationContext에 등록되어 있는 경우에 저장소 고유의 구현에 의해 선택된다. 대부분의 템플릿 API는 이미 ApplicationContextAware가 구현되어 있으므로 ApplicationContext에 액세스할 수 있다.

다음 예는 유효한 엔티티 콜백 등록 모음을 설명한다.

예 79: EntityCallback Bean 등록 예

@Order(1)                                                           // (1)
@Component
class First implements BeforeSaveCallback<Person> {

  @Override
  public Person onBeforeSave(Person person) {
    return // ...
  }
}

@Component
class DefaultingEntityCallback implements BeforeSaveCallback<Person>,
                                                           Ordered { // (2)

  @Override
  public Object onBeforeSave(Person entity, String collection) {
    // ...
  }

  @Override
  public int getOrder() {
    return 100;                                                   // (2)
  }
}

@Configuration
public class EntityCallbackConfiguration {

    @Bean
    BeforeSaveCallback<Person> unorderedLambdaReceiverCallback() {    // (3)
        return (BeforeSaveCallback<Person>) it -> // ...
    }
}

@Component
class UserCallbacks implements BeforeConvertCallback<User>,
                                        BeforeSaveCallback<User> {    // (4)

  @Override
  public Person onBeforeConvert(User user) {
    return // ...
  }

  @Override
  public Person onBeforeSave(User user) {
    return // ...
  }
}

(1) BeforeSaveCallback@Order 어노테이션에 선언된 순서대로 수신한다.
(2) BeforeSaveCallbackOrdered 인터페이스 구현을 통해 순서대로 수신한다. (3) 람다 식을 사용한 BeforeSaveCallback. 기본적으로 순서 지정되지 않고 마지막에 호출된다. 람다 식으로 구현된 콜백은 입력 정보를 공개하지 않으므로 할당할 수 없는 엔터티로 호출하면 콜백 처리량에 영향을 준다. class 또는 enum를 사용하여, 콜백 Bean 유형 필터링을 사용할 수 았다.
(4) 단일 구현 클래스에 여러 엔티티 콜백 인터페이스를 결합한다.

14.3.3. 특정 EntityCallbacks 저장

Spring Data R2DBC는 검사 지원에 EntityCallback API를 사용하여 다음 콜백에 응답한다.

표 4: 지원되는 엔티티 콜백

콜백 방법 설명 순서
BeforeConvertCallback onBeforeConvert(T entity, SqlIdentifier table) 도메인 객체가 OutboundRow로 변환되기 전에 호출된다. Ordered.LOWEST_PRECEDENCE
AfterConvertCallback onAfterConvert(T entity, SqlIdentifier table) 도메인 객체가 로드된 후에 호출된다.
행에서 읽은 후에 도메인 객체를 변경할 수 있다.
Ordered.LOWEST_PRECEDENCE
AuditingEntityCallback onBeforeConvert(T entity, SqlIdentifier table) 생성 또는 수정은 검사 가능한 엔터티를 표시하는 것이다. 100
BeforeSaveCallback onBeforeSave(T entity, OutboundRow row, SqlIdentifier table) 도메인 객체가 저장되기 전에 호출된다.
맵핑된 모든 엔티티 정보를 포함하는 OutboundRow 영속성을 위해 대상을 변경할 수 있다.
Ordered.LOWEST_PRECEDENCE
AfterSaveCallback onAfterSave(T entity, OutboundRow row, SqlIdentifier table) 도메인 객체가 저장된 후에 호출된다.
도메인 객체를 변경하여 저장 후 반환되도록 할 수 있다. OutboundRow에는 맵핑된 모든 엔티티 정보가 포함된다.
Ordered.LOWEST_PRECEDENCE

14.4. 여러 데이터베이스에서 작업

여러 개의 잠재적으로 다른 데이터베이스로 작업하는 경우에는 응용 프로그램은 구성에 다른 접근 방식을 필요하다. 제공되는 AbstractR2dbcConfiguration 지원 클래스는 Dialect이 파생된 하나의 ConnectionFactory을 가정한다. 즉, 여러 데이타베이스에서 동작하도록 Spring Data R2DBC를 구성하려면 몇개의 Bean 를 스스로 정의해야 한다.

R2DBC 리포지토리는 리포지토리를 구현하기 위해 R2dbcEntityOperations를 필요로 한다. AbstractR2dbcConfiguration를 사용하지 않고 리포지토리를 스캔하는 간단한 구성은 다음과 같다.

@Configuration
@EnableR2dbcRepositories(basePackages = "com.acme.mysql", entityOperationsRef = "mysqlR2dbcEntityOperations")
static class MySQLConfiguration {

    @Bean
    @Qualifier("mysql")
    public ConnectionFactory mysqlConnectionFactory() {
        return 
    }

    @Bean
    public R2dbcEntityOperations mysqlR2dbcEntityOperations(@Qualifier("mysql") ConnectionFactory connectionFactory) {

        DatabaseClient databaseClient = DatabaseClient.create(connectionFactory);

        return new R2dbcEntityTemplate(databaseClient, MySqlDialect.INSTANCE);
    }
}

@EnableR2dbcRepositories에서는 databaseClientRef 또는 entityOperationsRef 중에 하나를 통해 구성할 수 있다. 다양한 DatabaseClient Bean을 사용하면 동일한 유형의 여러 데이터베이스에 연결할 때 유용하다. 서로 다른 다이렉트 데이터베이스 시스템을 사용하는 경우에는 @EnableR2dbcRepositories(entityOperationsRef = …)`를 대신에 사용하도록 한다.


15. 감사

15.1. 기본

Spring Data는 엔티티를 만들거나 변경한 사용자와 변경이 발생한 시기를 투명하게 추적하기 위한 고급 지원을 제공한다. 이 기능을 활용하려면 어노테이션을 사용하거나 인터페이스를 구현하여 정의할 수 있는 감사 메타데이터를 엔터티 클래스에 설치해야 한다. 또한 필요한 인프라 구성 요소를 등록하려면 어노테이션 구성 또는 XML 구성을 통해 감사를 활성화해야 한다. 구성 샘플에 대해서는 저장소별 섹션을 참조하라.

15.1.1. 어노테이션 기반 감사 메타데이터

엔티티를 만들거나 수정한 사용자 기록하려면 @CreatedBy@LastModifiedBy, 변경이 발생하였을 때 기록하려면 @CreatedDate@LastModifiedDate를 제공한다.

예 80: 감사 대상 엔티티

class Customer {

  @CreatedBy
  private User user;

  @CreatedDate
  private Instant createdDate;

  // … further properties omitted
}

보시다시피, 어떤 정보를 기록하는지에 따라 어노테이션을 선택적으로 적용할 수 있다. 변경이 수행되었을 때에 기록하는 어노테이션은 타입 Joda-Time, DateTime, 레거시 Java DateCalendar, JDK8의 일자와 시각 타입와 long 또는 Long의 프로퍼티로 사용할 수 있다.

감사 메타데이터는 반드시 루트 레벨 엔터티에 존재할 필요는 없지만, 아래에 표시된 대로 포함된 엔터티에 추가할 수 있다(실제로 사용되는 저장소에 따라 다름).

예 81: 포함된 엔터티의 메타데이터 감사

class Customer {

  private AuditMetadata auditingMetadata;

  // … further properties omitted
}

class AuditMetadata {

  @CreatedBy
  private User user;

  @CreatedDate
  private Instant createdDate;

}

15.1.2. 인터페이스 기반 감사 메타데이터

어노테이션을 사용하여 감사 메타데이터를 정의하지 않으려면 도메인 클래스에 Auditable 인터페이스를 구현할 수 있다. 모든 감사 속성의 setter 메소드를 공개한다.

15.1.3. AuditorAware

@CreatedBy 또는 @LastModifiedBy를 사용하는 경우는 감사 인프라는 어떤 식으로든 현재 보안 주체를 인식해야 한다. 이를 위해 현재 사용자 또는 애플리케이션과 상호 작용하는 시스템이 누구인지를 인프라에 알리기 위해 구현해야 하는 AuditorAware<T> SPI 인터페이스를 제공한다. 제네릭 클래스 T@CreatedBy 또는 @LastModifiedBy 어노테이션이 붙은 속성 유형을 정의한다.

다음 예제는 스프링 시큐리티의 Authentication 객체를 사용하여 인터페이스를 구현을 보여준다.

예 82: Spring Security 기반 AuditorAware 구현

class SpringSecurityAuditorAware implements AuditorAware<User> {

  @Override
  public Optional<User> getCurrentAuditor() {

    return Optional.ofNullable(SecurityContextHolder.getContext())
            .map(SecurityContext::getAuthentication)
            .filter(Authentication::isAuthenticated)
            .map(Authentication::getPrincipal)
            .map(User.class::cast);
  }
}

구현은 Spring Security에서 제공하는 Authentication 객체에 액세스하여 UserDetailsService 구현으로 작성한 커스텀 UserDetails 인스턴스를 검색한다. 여기에서는 UserDetails 구현을 통해 도메인 사용자를 공개하고 있지만, 발견된 Authentication에 따라 어디서나 검색할 수 있다고 가정한다.

15.1.4. ReactiveAuditorAware

리액티브 인프라를 사용하는 경우는 컨텍스트 정보를 활용하여 @CreatedBy 또는 @LastModifiedBy 정보를 제공할 수 있다. 애플리케이션과 상호 작용하는 현재 사용자 또는 시스템이 누구인지를 인프라에 알리기 위해 구현해야 하는 ReactiveAuditorAware<T> SPI 인터페이스를 제공한다. 제네릭 형식 T@CreatedBy 또는 @LastModifiedBy 어노테이션이 달린 속성이 어떤 형식이어야 하는지를 정의한다.

다음의 예는 리액티브 Spring Security의 Authentication 객체를 사용하는 인터페이스의 구현을 나타내고 있다.

예 83: Spring Security 기반 ReactiveAuditorAware 구현

class SpringSecurityAuditorAware implements ReactiveAuditorAware<User> {

  @Override
  public Mono<User> getCurrentAuditor() {

    return ReactiveSecurityContextHolder.getContext()
                .map(SecurityContext::getAuthentication)
                .filter(Authentication::isAuthenticated)
                .map(Authentication::getPrincipal)
                .map(User.class::cast);
  }
}

구현은 Spring Security가 제공하는 Authentication 객체에 액세스하여, UserDetailsService 구현으로 작성한 사용자 정의 UserDetails 인스턴스를 검색한다. 여기에서는 UserDetails 구현을 통해 도메인 사용자를 공개하고 있지만 발견된 Authentication 에 따라 어디서나 검색할 수 있다고 가정한다.

15.2. R2DBC의 일반적인 감사 구성

Spring Data R2DBC 1.2부터 다음 예제와 같이 구성 클래스에 @EnableR2dbcAuditing 어노테이션을 달아 감사를 활성화 할 수 있다.

예 84: JavaConfig를 사용하여 감사 활성화

@Configuration
@EnableR2dbcAuditing
class Config {

  @Bean
  public ReactiveAuditorAware<AuditableUser> myAuditorProvider() {
      return new AuditorAwareImpl();
  }
}

유형 ReactiveAuditorAware의 Bean을 ApplicationContext에 공개하면 감사 인프라는 자동으로 이를 가져오고, 이를 사용하여 도메인 유형으로 설정할 현재 사용자를 결정한다. ApplicationContext에 복수의 구현이 등록되어 있는 경우는 @EnableR2dbcAuditingauditorAwareRef 속성을 명시적으로 설정하는 것으로 사용할 구현을 선택할 수 있다.


16. 매핑

MappingR2dbcConverter에서는 풍부한 매핑 지원을 제공한다. MappingR2dbcConverter는 도메인 객체를 데이터 행에 매핑할 수 있는 풍부한 메타데이터 모델이 있다. 매핑 메타데이터 모델은 도메인 객체의 어노테이션을 사용하여 만들어 진다. 다만 인프라는 메타데이터 정보의 유일한 소스로 어노테이션을 사용하는 것에 제한되지 않는다. MappingR2dbcConverter에는 다음 일련의 규칙에 따라 추가 메타데이터를 제공하지 않고 객체를 행에 매핑할 수 있다.

이 섹션에서는 객체를 행에 매핑하는 규칙을 사용하는 방법과 어노테이션 기반 매핑 메타데이터에서 이러한 규칙을 재정의하는 방법과 MappingR2dbcConverter의 기능에 대해 설명한다.

16.1. 객체 매핑의 기초

이 섹션에서는 Spring Data 객체 매핑, 객체 생성, 필드 및 속성 액세스, 가변성 및 불변성의 기초에 대해 설명한다. 이 섹션은 기본이 되는 데이터 스토어(JPA 등)의 오브젝트 매핑을 사용하지 않는 Spring Data 모듈에게만 적용되는 것에 주의하자. 또한 인덱스, 컬럼 이름 및 필드 이름의 사용자 정의와 같이 저장소별 오브젝트 맵핑에 대해서는 저장소 특정 섹션을 참조하라.

Spring Data 객체 매핑의 중심적인 역할은 도메인 객체의 인스턴스를 작성하여 스토어 네이티브 데이터 구조를 그것들에 매핑 하는 것이다. 즉, 두 가지 기본 단계가 필요하다.

  1. 공개된 생성자 중에 하나를 사용하여 인스턴스 생성.
  2. 공개된 모든 속성을 구체화하는 인스턴스 설정.

16.1.1. 객체 생성

Spring Data는 그 형태의 오브젝트의 구체화에 사용되는 영속 엔티티의 생성자를 자동적으로 검출하려고 한다. 해결 알고리즘은 다음과 같이 동작한다.

  1. 생성자가 1개 밖에 없는 경우는 그 생성자가 사용된다.
  2. 생성자가 여러 개 있고 그 중 하나만 @PersistenceConstructor 어노테이션이 달린 경우는 그 생성자가 사용된다.
  3. 인수가 없는 생성자가 있는 경우는 그 생성자가 사용된다. 다른 생성자는 무시된다.

값의 결정은 생성자의 인수 이름이 엔티티의 속성 이름과 일치한다고 가정한다. 즉, 매핑의 모든 커스터마이즈(다른 데이터스토어 열 또는 필드명 등)를 포함한 속성이 설정되는 것처럼 결정된다. 또한 클래스 파일에서 사용할 수 있는 매개변수 이름 정보 또는 생성자에 존재하는 @ConstructorProperties 어노테이션이 필요하다.

값의 결정은은 스토어 고유의 SpEL 식을 사용한 Spring Framework의 @Value값 어노테이션을 사용해 커스터마이즈 할 수 있다. 자세한 내용은 저장소별 맵핑 섹션을 참조하라.

객체 생성에 대해 자세히 알아보기

리플렉션의 오버헤드를 피하기 위해 Spring Data 객체의 생성은 기본적으로 런타임에 생성된 팩토리 클래스를 사용하며 도메인 클래스 생성자를 직접 호출한다. 즉, 이 예제의 유형:

class Person {
  Person(String firstname, String lastname) {  }
}

런타임 시에 이것과 의미적으로 동등한 팩토리 클래스를 생성한다.

class PersonObjectInstantiator implements ObjectInstantiator {

  Object newInstance(Object... args) {
    return new Person((String) args[0], (String) args[1]);
  }
}

이렇게하면 리플렉션보다 약 10% 성능이 향상된다. 도메인 클래스가 이러한 최적화의 대상이 되기 위해서는 일련의 제약을 따를 필요가 있다.

  • private 클래스가 아니어야 한다.
  • 비정적(non-static) 내부 클래스(inner class)가 아니어야 한다.
  • CGLib 프록시 클래스가 아니어야 한다.
  • Spring Data에서 사용되는 생성자는 private이 아니어야 한다.

이러한 조건 중 하나라도 일치하게 되면 Spring Data는 리플렉션을 통해 엔터티 인스턴스화로 대체된다.

16.1.2. 속성 설정

엔티티의 인스턴스가 생성되면 Spring Data는 해당 클래스의 나머지 모든 영구 속성을 설정한다. 엔티티의 생성자에 의해 이미 입력되어 있지 않은 경우(즉, 생성자 인수 목록을 통해 사용), ID property가 최초로 입력되어 순환 오브젝트 참조의 해결이 가능하게 된다. 그런 다음 생성자에 의해 아직 설정되지 않은 모든 비일시적 속성이 엔터티 인스턴스로 설정된다. 이를 위해 다음과 같은 알고리즘을 사용한다.

  1. 속성이 불변인 with… 메소드를 공개하는 경우(아래 참조), with… 메소드를 사용하여 새 속성 값을 갖는 새로운 엔터티 인스턴스를 만든다.
  2. 속성의 접근(즉, getter 및 setter를 통한 액세스)가 정의되고 있는 경우, setter 메소드를 호출하고 있다.
  3. 속성을 변경할 수 있는 경우는 필드를 직접 설정한다.
  4. 속성이 불변의 경우는 영속화 조작(객체 생성을 참조)로 사용되는 생성자를 사용하여 인스턴스의 복사본을 생성한다.
  5. 기본적으로 필드 값을 직접 설정한다.

속성 설정에 대해 자세히 알아보기

객체 생성 최적화와 마찬가지로 Spring Data 런타임 생성되는 접근자 클래스(accessor classes)를 사용하여 엔터티 인스턴스와 상호 작용한다.

class Person {

  private final Long id;
  private String firstname;
  private @AccessType(Type.PROPERTY) String lastname;

  Person() {
    this.id = null;
  }

  Person(Long id, String firstname, String lastname) {
    // Field assignments
  }

  Person withId(Long id) {
    return new Person(id, this.firstname, this.lastame);
  }

  void setLastname(String lastname) {
    this.lastname = lastname;
  }
}

예 85: 생성된 속성 접근자

class PersonPropertyAccessor implements PersistentPropertyAccessor {

  private static final MethodHandle firstname;              // (2)

  private Person person;                                    // (1)

  public void setProperty(PersistentProperty property, Object value) {

    String name = property.getName();

    if ("firstname".equals(name)) {
      firstname.invoke(person, (String) value);             // (2)
    } else if ("id".equals(name)) {
      this.person = person.withId((Long) value);            // (3)
    } else if ("lastname".equals(name)) {
      this.person.setLastname((String) value);              // (4)
    }
  }
}

(1) PropertyAccessor는 기본이 되는 오브젝트의 가변 인스턴스를 보관 유지한다. 이렇게 하지 않으면 불변 속성을 변경할 수 있기 때문이다.
(2) 기본적으로 Spring Data는 필드 액세스를 사용하여 속성 값을 읽거나 쓸수 있다. private 필드의 가시성 규칙에 따라 MethodHandles 필드와 상호 작용하는데 사용된다.
(3) 클래스는 식별자의 설정에 사용되는 withId(…) 메소드를 공개한다. 인스턴스가 데이터 스토어에 삽입되어 식별자가 생성되었을 때에 withId(…)를 호출하면, 새로운 Person 객체가 생성된다. 이후에 모든 변경은 새로운 인스턴스에서 수행되며, 이전 인스턴스는 변경되지 않는다.
(4) property-access를 사용하면, MethodHandles를 사용하지 않고 직접 메소드를 호출할 수가 있다.

이렇게 하면 리플렉션보다 약 25% 성능이 향상된다. 도메인 클래스가 이러한 최적화의 대상이 되기 위해서는 일련의 제약을 따를 필요가 있다.

  • 유형은 디폴트 또는 java 패키지에 존재해서는 안된다.
  • 형식과 그 생성자는 public이여야 한다.
  • 내부(inner) 클래스인 형태는 static이여야 한다.
  • 사용되는 Java 런타임은 원래 ClassLoader 클래스를 선언할 수 있도록 해야 한다. Java 9 이후에는 특정의 제한이 있다.

기본적으로 Spring Data는 생성된 속성 접근자를 사용하려고 하고, 제한이 감지되면 리플렉션 기반의 접근자로 폴백(falls back)한다.

다음 엔티티를 살펴보자.

예 86: 샘플 엔티티

class Person {

  private final @Id Long id;                                                // (1)
  private final String firstname, lastname;                                 // (2)
  private final LocalDate birthday;
  private final int age;                                                    // (3)

  private String comment;                                                   // (4)
  private @AccessType(Type.PROPERTY) String remarks;                        // (5)

  static Person of(String firstname, String lastname, LocalDate birthday) { // (6)

    return new Person(null, firstname, lastname, birthday,
      Period.between(birthday, LocalDate.now()).getYears());
  }

  Person(Long id, String firstname, String lastname, LocalDate birthday, int age) { // (6)

    this.id = id;
    this.firstname = firstname;
    this.lastname = lastname;
    this.birthday = birthday;
    this.age = age;
  }

  Person withId(Long id) {                                                  // (1)
    return new Person(id, this.firstname, this.lastname, this.birthday, this.age);
  }

  void setRemarks(String remarks) {                                         // (5)
    this.remarks = remarks;
  }
}

(1) 식별자 속성은 final 이지만, 생성자로 null이 설정된다. 클래스는 식별자의 설정에 사용되는 withId(…) 메소드를 공개한다. 인스턴스가 데이터 스토어에 삽입되어 식별자가 생성되었을 때. 원래 Person 인스턴스는 새로운 인스턴스가 생성될 때 변경되지 않는다. 일반적으로 저장소 관리되는 다른 속성에도 동일한 패턴이 적용되지만 영속화 작업을 위해 변경해야 할 수도 있다. 영속화 생성자(6을 참조)는 사실상 본사본 생성자이며, 속성의 설정은 새로운 식별자 값이 적용된 새로운 인스턴스의 생성으로 변환되기 때문에, wither 메소드는 옵션이다.
(2) firstnamelastname 속성은 getter를 개입시켜 잠재적으로 공개되는 통상의 불변의 속성이다.
(3) age 속성은 불변이지만, birthday 속성에서 파생된다. 표시된 설계에서 Spring Data는 선언된 유일한 생성자를 사용하기 때문에 데이터베이스 값은 기본 설정보다 우선한다. 계산이 선호되는 경우에도 이 생성자는 매개 변수로 age 받는 것이 중요하다(무시될 수 있음). 그렇지 않으면 속성 생성 단계가 age 필드를 설정하려고 하고, 불변으로 with… 메소드가 있다.
(4) comment 속성은 가변적이며, 필드를 직접 설정하는 것으로 입력된다.
(5) remarks 속성은 가변적이며, comment 필드를 직접 설정하거나 setter 메소드를 호출하여 설정한다.
(6) 이 클래스는 오브젝트 생성 위한 팩토리 메소드와 생성자를 을 공개한다. 여기에서 핵심이 되는 아이디어는 추가 생성자 대신 팩토리 메소드를 사용하여 @PersistenceConstructor 생성자를 명확히 할 필요성을 피하는 것이다. 대신 속성의 기본 설정은 팩토리 메소드 내에서 처리된다.

16.1.3. 일반적인 권장 사항

  • 불변 객체를 고집 — 불변 객체는 생성자만 호출하여 객체를 구체화하므로 쉽게 생성할 수 있다. 또, 이것에 의해, 클라이언트 오브젝트가 오브젝트의 상태를 조작할 수 있도록 하는 setter 메소드가 도메인 오브젝트에 흩어지는 것을 방지한다. 필요한 경우는 패키지가 동일한 위치에 배치된 제한된 유형에서만 호출할 수 있도록 패키지를 보호하는 것이 좋다. 생성자 전용 실체화는 속성 설정보다 최대 30% 빠르다.
  • all-args 생성자 제공 — 엔터티를 불변의 값으로 모델링할 수 없거나, 원하지 않는 경우에도 객체 매핑이 속성 설정을 건너뛸 수 있으므로 엔터티의 모든 속성을 인수로 사용한다. 제공하는 것은 가치가 있다. 최적의 성능을 위해.
  • @PersistenceConstructor을 회피하기 위해 오버로드 된 생성자 대신 팩토리 메소드를 사용한다. — 최적의 성능에 필요한 모든 인수 생성자는 일반적으로 자동 생성 식별자 등을 생략한 애플리케이션 사용 사례별 생성자를 공개한다. 이러한 all-args 생성자의 변형을 공개하는 정적 팩토리 메소드.
  • 생성된 인스턴스 생성 클래스와 프로퍼티 액세서 클래스를 사용할 수 있도록 하는 제약을 반드시 지켜야 한다.
  • 생성되는 식별자에 대해서는 모든 인수의 영속화 생성자(권장) 또는 with… 메소드와 조합된 final 필드를 사용한다.
  • Lombok을 사용하여 보일러 플레이트 코드를 피한다. — 영속화 작업은 일반적으로 모든 인수를 취하는 생성자를 필요로하므로 선언은 필드 할당에 대한 보일러 플레이트 매개 변수의 지루한 반복이지만 Lombok의 @AllArgsConstructor을 사용하면 피할 수 있다.
속성 재정의

Java를 사용하면 도메인 클래스를 유연하게 설계할 수 있다. 이 경우에 서브 클래스는 슈퍼 클래스로 같은 이름으로 벌써 선언되고 있는 property를 정의할 수 있다. 다음 예제를 보도록 하자.

public class SuperType {

   private CharSequence field;

   public SuperType(CharSequence field) {
      this.field = field;
   }

   public CharSequence getField() {
      return this.field;
   }

   public void setField(CharSequence field) {
      this.field = field;
   }
}

public class SubType extends SuperType {

   private String field;

   public SubType(String field) {
      super(field);
      this.field = field;
   }

   @Override
   public String getField() {
      return this.field;
   }

   public void setField(String field) {
      this.field = field;

      // optional
      super.setField(field);
   }
}

두 클래스 모두 할당 가능한 유형을 사용하여 field 를 정의한다. 그러나 SubTypeSuperType.field을 쉐도우(shadow)한다. 클래스 설계에 따라서는 생성자를 사용하는 것은 SuperType.field을 설정을 위한 유일한 기본 접근 방식일 수 있다. 또는 setter인 super.setField(…)를 호출하여 SuperType으로 field 설정할 수 있다. 속성은 동일한 이름을 공유하지만, 두 가지 다른 값을 나타낼 수 있으므로 이러한 메커니즘은 어느 정도 충돌을 일으킬 수 있다. Spring Data는 형태가 할당 가능하지 않은 경우, 수퍼 유형의 속성을 스킵 한다. 즉, 재정의된 속성의 유형은 재정의로 등록된 수퍼 유형의 속성 유형에 할당할 수 있어야 한다. 그렇지 않은 경우, 슈퍼 타입의 속성는 일시적인 것으로 간주된다. 일반적으로 개별 속성 이름을 사용하는 것이 좋다.

Spring Data 모듈은 일반적으로 다른 값을 유지하는 재정의된 속성을 지원한다. 프로그래밍 모델의 관점에서 고려해야 할 몇 가지 사항이 있다.

  1. 어떤 속성을 영속화해야 하나(기본적으로 선언된 모든 속성이 된다)? 이는 @Transient 어노테이션을 달면 속성을 제외할 수 있다.
  2. 데이터 스토어의 속성을 나타내는 방법은? 다른 값에 대해 동일한 필드/열 이름을 사용하면, 일반적으로 데이터가 손상되므로 명시적 필드/열 이름을 사용하여 속성 중 하나 이상에 어노테이션을 달아야 한다.
  3. 슈퍼 프로퍼티는 일반적으로 setter의 구현을 한층 더 상정하지 않고 설정할 수 없기 때문에, @AccessType(PROPERTY)의 사용은 사용할 수 없다.

16.1.4. Kotlin 지원

Spring Data는 Kotlin 사양을 준수하여 객체를 만들고 수정할 수 있다.

Kotlin 객체 만들기

Kotlin 클래스는 인스턴스화가 지원되고 있어 모든 클래스는 디폴트가 불변이며, 가변 속성를 정의하려면 명시적인 프로퍼티 선언이 필요하다. 다음의 data 클래스 Person를 보도록 하자.

data class Person(val id: String, val name: String)

위의 클래스는 명시적인 생성자를 가진 일반적인 클래스로 컴파일된다. 다른 생성자를 추가하여 이 클래스를 사용자 지정한 다음 @PersistenceConstructor에 어노테이션을 달아 생성자 설정을 보여준다.

data class Person(var id: String, val name: String) {

    @PersistenceConstructor
    constructor(id: String) : this(id, "unknown")
}

Kotlin은 매개변수가 제공되지 않을 때 기본값을 사용할 수 있도록 하여 매개변수 옵션을 지원한다. Spring Data가 파라미터의 디폴트 설정을 가지는 생성자를 검출했을 경우, 데이터 스토어가 값을 제공하지 않는 (또는 단순히 null를 반환하는) 경우. Kotlin는 파라미터의 디폴트 설정을 적용할 수 있기 때문에, 이러한 파라미터는 존재하지 않는다. name 매개 변수의 기본 설정을 적용하는 다음 클래스를 보도록 하자.

data class Person(var id: String, val name: String = "unknown")

name 매개 변수 중 하나 결과의 일부가 아니거나 해당 값이 null일 경우에는 name"unknown"으로 기본 설정이 된다.

Kotlin 데이터 클래스의 속성 설정

Kotlin 에서는 모든 클래스는 디폴트로 불변이며, 가변 속성를 정의하려면 명시적인 속성 선언이 필요하다. 다음의 data 클래스 Person를 보도록 하자.

data class Person(val id: String, val name: String)

이 클래스는 사실상 불변이다. Kotlin이 기존 객체의 모든 속성 값을 복사하여 메소드에 인수로 제공된 속성 값을 적용하는 새로운 객체 인스턴스를 만드는 copy(…) 메소드를 생성할 때 새 인스턴스를 만들 수 있다.

Kotlin 재정의 속성

Kotlin에서 속성 재정의를 선언하여, 하위 클래스의 속성을 변경할 수 있다.

open class SuperType(open var field: Int)

class SubType(override var field: Int = 1) :
  SuperType(field) {
}

이러한 처리 방식은 field라는 이름의 2개 속성이 렌더링 된다. Kotlin은 각 클래스의 각 속성에 대한 속성 액세서(getter 및 setter)를 생성한다. 사실상 코드는 다음과 같다.

public class SuperType {

   private int field;

   public SuperType(int field) {
      this.field = field;
   }

   public int getField() {
      return this.field;
   }

   public void setField(int field) {
      this.field = field;
   }
}

public final class SubType extends SuperType {

   private int field;

   public SubType(int field) {
      super(field);
      this.field = field;
   }

   public int getField() {
      return this.field;
   }

   public void setField(int field) {
      this.field = field;
   }
}

SubType의 Getter와 setter는 SubType.field만을 설정하여, SuperType.field는 설정하지 않는다. 이러한 처리 방식에서는 생성자를 사용하는 것이 SuperType.field를 설정하는 유일한 기본 방법이다. SubType에 메소드를 추가하여 this.SuperType.field = …를 통해 SuperType.field을 설정할 수 있지만, 지원되는 규칙의 범위를 벗어난다. 속성은 동일한 이름을 공유하지만 두 가지 다른 값을 나타낼 수 있으므로 속성 재정의로 인해 어느 정도 충돌이 발생한다. 일반적으로 개별 속성 이름을 사용하는 것이 좋다.

Spring Data 모듈은 일반적으로 다른 값을 유지하는 재정의된 속성을 지원한다. 프로그래밍 모델의 관점에서 고려해야 할 몇 가지 사항이 있다.

  1. 어떤 속성을 영속화해야 하나(기본적으로 선언된 모든 속성이 된다)? 이들에 @Transient 어노테이션을 달면 속성을 제외할 수 있다.
  2. 데이터 스토어의 속성을 나타내는 방법은? 다른 값에 대해 동일한 필드/열 이름을 사용하면 일반적으로 데이터가 손상되므로 명시적 필드/열 이름을 사용하여 속성 중 하나 이상에 어노테이션을 달아야 한다.
  3. 슈퍼 속성을 설정할 수 없기 때문에 @AccessType(PROPERTY)은 사용할 수 없다.

16.2. 약관 기반 매핑

MappingR2dbcConverter에는 추가 매핑 메타데이터가 제공되지 않은 경우에 객체를 행에 매핑하는 몇 가지 규칙이 있다. 규칙은 다음과 같다.

  • 짧은 Java 클래스명은, 다음의 방법으로 테이블명에 맵 된다. com.bigbank.SavingsAccount 클래스는 SAVINGS_ACCOUNT 테이블 이름에 매핑된다. 동일한 이름의 매핑이 필드를 열 이름에 매핑하는데 적용된다. 예: firstName 필드는 FIRST_NAME 열에 맵핑된다. 사용자 지정 NamingStrategy을 제공하여 이 매핑을 제어할 수 있다. 자세한 내용은 “매핑 설정"을 참조한다. 속성 이름이나 클래스 이름에서 파생된 테이블 이름과 열 이름은 기본적으로 따옴표 없이 SQL 문에서 사용된다. R2dbcMappingContext.setForceQuote(true)를 설정하여 이 동작을 제어할 수 있다.
  • 중첩된 객체는 지원되지 않는다.
  • 컨버터는 등록되어 있는 Spring 컨버터를 사용하여, 객체 속성의 디폴트의 매핑을 행의 열과 값에 덮어쓴다.
  • 오브젝트의 필드는 행의 열과의 변환에 사용된다. public JavaBean 속성은 사용되지 않는다.
  • 생성자 인수 이름이 행의 최상위 컬럼 이름과 일치하는 단일 0이 아닌 인수 생성자가 있는 경우는 해당 생성자가 사용된다. 그렇지 않으면, 인수가 없는 생성자가 사용된다. 인수가 0이 아닌 생성자가 복수인 경우, 예외가 슬로우 된다.

16.3. 매핑 설정

기본적으로 (명시적으로 설정되지 않는 한) DatabaseClient을 생성하면 MappingR2dbcConverter 인스턴스가 만들어 진다. MappingR2dbcConverter 자신의 인스턴스를 만들 수 있다. 독자적인 인스턴스를 작성하는 것으로, Spring 컨버터를 등록하여 데이타베이스와의 사이에 특정의 클래스를 매핑 할 수 있다.

Java 기반 메타데이터를 사용하여 MappingR2dbcConverter, DatabaseClient, ConnectionFactory을 구성할 수 있다. 다음 예제에서는 Spring의 Java 기반 구성을 사용한다.

R2dbcMappingContext tosetForceQuotetrue로 설정하면, 클래스 및 속성으로부터 파생한 테이블명과 열명이 데이타베이스 고유의 인용부호와 함께 사용된다. 이는 이러한 이름에 예약된 SQL 단어(예: 정렬)를 사용해도 문제가 없음을 의미한다. 이렇게 하려면 AbstractR2dbcConfigurationr2dbcMappingContext(Optional<NamingStrategy>)를 재정의한다. Spring Data는 그러한 이름의 대문자 소문자를 인용부호가 사용되어 있지 않은 경우에 구성 끝난 데이타베이스에서도 사용되는 형식으로 변환한다. 이름에 키워드나 특수 문자를 사용하지 않는 한, 테이블을 만들 때 따옴표로 묶지 않은 이름을 사용할 수 있다. SQL 표준을 준수하는 데이터베이스의 경우, 이는 이름이 대문자로 변환됨을 의미한다. 인용 문자와 이름의 대문자화의 방법은 사용되는 Dialect에 의해 제어된다. 사용자 정의 다이렉트를 구성하는 방법은 “R2DBC 드라이버"를 참조한다.

예 87: R2DBC 매핑 지원을 구성하는 @Configuration 클래스

@Configuration
public class MyAppConfig extends AbstractR2dbcConfiguration {

  public ConnectionFactory connectionFactory() {
    return ConnectionFactories.get("r2dbc:…");
  }

  // the following are optional

  @Override
  protected List<Object> getCustomConverters() {

    List<Converter<?, ?>> converterList = new ArrayList<Converter<?, ?>>();
    converterList.add(new org.springframework.data.r2dbc.test.PersonReadConverter());
    converterList.add(new org.springframework.data.r2dbc.test.PersonWriteConverter());
    return converterList;
  }
}

AbstractR2dbcConfiguration에는 ConnectionFactory를 정의하는 메소드를 구현할 필요가 있다.

r2dbcCustomConversions 메소드를 재정의하는 것으로 컨버터에 컨버터를 추가할 수 있다.

사용자 정의 NamingStrategy는 Bean으로 등록하여 구성할 수 있다. NamingStrategy는 클래스와 속성의 이름을 테이블과 열의 이름으로 변환하는 방법을 제어한다.

16.4. 메타데이터 기반 매핑

Spring Data R2DBC 지원 내부의 객체 매핑 기능을 최대한으로 활용하려면 패핑된 객체에 @Table 어노테이션을 붙여야 한다. 매핑 프레임워크에 이 어노테이션을 붙일 필요는 없지만(어노테이션이 없어도 POJO는 올바르게 매핑된다), 클래스 경로 스캐너로 도메인 오브젝트를 찾아 전처리하여 필요한 메타데이터를 추출할 수 있다. 이 어노테이션을 사용하지 않는 경우에는 매핑 프레임워크는 도메인 객체의 속성과 메소드를 인식할 수 있도록 내부 메타데이터 모델을 구축해야 하므로 도메인 객체를 처음 저장할 때 응용 프로그램의 성능이 약간 떨어진다. 영속화한다. 다음 예는 도메인 객체를 보여준다.

예 88: 도메인 객체의 예

package com.mycompany.domain;

@Table
public class Person {

  @Id
  private Long id;

  private Integer ssn;

  private String firstName;

  private String lastName;
}

16.4.1. 기본 유형 매핑

다음 표에서는 엔터티의 속성 유형이 매핑에 어떤 영향을 주는지 설명한다.

소스 유형 타겟 유형 댓글
Primitive 유형과 wrapper 유형 패스스루(Passthru) “명시적인 컨버터"를 사용하여 사용자 정의할 수 있다.
JSR-310 날짜/시간형 패스스루(Passthru) “명시적인 컨버터"를 사용하여 사용자 정의할 수 있다.
String, BigInteger, BigDecimal, UUID 패스스루(Passthru) “명시적인 변환기"를 사용하여 사용자 정의할 수 있다.
Enum String “명시적인 컨버터"를 등록하여 사용자 정의할 수 있다.
Blob, Clob 패스스루(Passthru) 명시적인 변환기 를 사용하여 사용자 정의할 수 있다.
byte[], ByteBuffer 패스스루(Passthru) 바이너리 페이로드로 간주된다.
Collection<T> T의 배열 구성된 “드라이버"에서 지원되는 경우는 배열 유형으로의 변환, 그렇지 않으면 지원되지 않는다.
Primitive 유형, wrapper 유형, String 배열 래퍼형 배열(예: int[]Integer[]) 구성된 드라이버 에서 지원되는 경우는 배열 유형으로의 변환, 그렇지 않으면 지원되지 않는다.
드라이버 특정 유형 패스스루(Passthru) 사용된 R2dbcDialect로부터 심플 타입으로서 컨트리뷰트(Contributed).
복잡한 객체 대상 유형은 등록 Converter에 따라 다르다. “명시적인 컨버터"가 필요한다. 그렇지 않으면 지원되지 않는다.

16.4.2. 매핑 어노테이션 개요

MappingR2dbcConverter는 메타데이터를 사용하여 객체의 행에 매핑을 구동할 수 있다. 다음 어노테이션을 사용할 수 있다.

  • @Id: 기본 키를 표시하기 위해 필드 레벨에서 적용된다.
  • @Table: 이 클래스가 데이터베이스에 매핑의 후보인 것을 나타내기 위해서, 클래스 레벨로 적용된다. 데이터베이스가 저장된 테이블의 이름을 지정할 수 있다.
  • @Transient: 기본적으로 모든 필드가 행에 매핑된다. 이 어노테이션은 적용되는 필드를 데이터베이스에 저장하는 것에서 제외된다. 컨버터는 생성자 인수의 값을 구체화할 수 없으므로 영속적 생성자 내에서 임시 속성을 사용할 수 없다.
  • @PersistenceConstructor: 지정된 생성자 매핑한다 — 패키지에서 보호된 것 — 데이터베이스에서 객체를 인스턴스화할 때 사용한다. 생성자의 인수는 이름에 의해 취득한 행의 값에 매핑된다.
  • @Value: 이 어노테이션은 Spring Framework의 일부이다. 매핑 프레임워크 내에서 생성자 인수에 적용할 수 있다. 이렇게 하면 Spring 표현식 언어 문을 사용하여 도메인 객체를 만드는데 사용되기 전에 데이터베이스에서 검색된 키 값을 변환할 수 있다. 특정 행의 열을 참조하려면 다음과 같은 표현식을 사용해야 한다. : @Value("#root.myProperty") 여기에서 root는 지정된 Row 루트를 가리킨다.
  • @Column: 필드 레벨에서 적용되어 행에 표시되는 열의 이름을 기술하여 클래스의 필드명과는 다른 이름으로 한다. @Column 어노테이션에 지정된 이름은 SQL문에서 사용되는 경우 항상 따옴표로 묶는다. 대부분의 데이터베이스에서는 이는 이러한 이름으로 대소문자를 구별한다는 것을 의미한다. 또한 이러한 이름에 특수 문자를 사용할 수 있음을 의미한다. 그러나 다른 도구에서 문제가 발생할 수 있으므로 권장하지 않는다.
  • @Version: 필드 레벨에서 적용되며 낙관적 잠금에 사용되며 저장 조작 변경 사항을 확인한다. 값은 null(일반 유형의 경우는 zero)이며, 엔티티가 신규이기 위한 마커로 간주된다. 최초로 저장되는 값은 zero(일반 유형의 경우는 one)이다. 버전은 업데이트할 때마다 자동으로 증가한다. 자세한 내용은 “낙관적 잠금"을 참조한다.

매핑 메타데이터 인프라는 기술 독립적인 별도의 spring-data-commons 프로젝트에 정의되어 있다. 어노테이션 기반 메타데이터를 지원하기 위해 R2DBC 지원은 특정 서브클래스를 사용한다. 다른 전략을 도입할 수도 있다(수요가 있는 경우).

16.4.3. 커스터마이즈 된 객체 구축

맵핑 서브시스템을 사용하면 생성자에 @PersistenceConstructor 어노테이션을 추가하여 오브젝트의 구성을 사용자 정의할 수 있다. 생성자 매개 변수에 사용되는 값은 다음과 같은 방법으로 확인된다.

  • 매개 변수에 @Value 어노테이션이 지정된 경우, 지정된 표현식이 평가되고 결과가 매개변수 값으로 사용된다.
  • Java 유형에 입력 행의 지정된 필드와 이름이 일치하는 프로퍼티가 있는 경우, 그 프로퍼티 정보를 사용하여 입력 필드 값를 전달하는 적절한 생성자 파라미터를 선택힌다. 이는 매개변수 이름 정보가 Java .class 파일에 있는 경우에만 작동한다. 이는 디버그 정보를 사용해 소스를 컴파일 하는지, Java8의 javac-parameters 커멘드 라인 스위치를 사용해 실현할 수 있다.
  • 그렇지 않은 경우, MappingException는 throw 되어 지정된 생성자 파라미터를 바인드 할 수 없었던 것을 나타낸다.
class OrderItem {

  private @Id final String id;
  private final int quantity;
  private final double unitPrice;

  OrderItem(String id, int quantity, double unitPrice) {
    this.id = id;
    this.quantity = quantity;
    this.unitPrice = unitPrice;
  }

  // getters/setters ommitted
}

16.4.4. 명시적인 컨버터를 사용한 매핑 재정의

객체를 보관 및 조회하는 경우, R2dbcConverter인스턴스를 사용하여 모든 Java 형태로부터 OutboundRow 인스턴스에의 매핑을 처리하면 편리한 일이 자주 있다. 다만 R2dbcConverter 인스턴스에서 대부분의 작업을 수행할 수 있지만 성능을 최적화하기 위해 특정 유형의 변환을 선택적으로 처리할 수 있다.

변환을 직접 선택적으로 처리하려면 하나 이상의 org.springframework.core.convert.converter.Converter 인스턴스를 R2dbcConverter에 등록한다.

AbstractR2dbcConfigurationr2dbcCustomConversions 메소드를 사용하여, 컨버터를 구성할 수 있다. 이 섹세의 첫번째 예는 Java를 사용하여 구성을 수행하는 방법을 보여준다.

Spring 컨버터 구현의 다음의 예는, Row 로부터 Person POJO 로 변환한다.

@ReadingConverter
 public class PersonReadConverter implements Converter<Row, Person> {

  public Person convert(Row source) {
    Person p = new Person(source.get("id", String.class),source.get("name", String.class));
    p.setAge(source.get("age", Integer.class));
    return p;
  }
}

컨버터는 특정 속성에 적용된다. 컬렉션 속성(예: Collection<Person>)은 반복되며 요소별로 변환된다. 컬렉션 컨버터(예: Converter<List<Person>>, OutboundRow)는 지원되지 않는다.

다음 예제는 Person에서 OutboundRow으로 변환한다.

@WritingConverter
public class PersonWriteConverter implements Converter<Person, OutboundRow> {

  public OutboundRow convert(Person source) {
    OutboundRow row = new OutboundRow();
    row.put("id", SettableValue.from(source.getId()));
    row.put("name", SettableValue.from(source.getFirstName()));
    row.put("age", SettableValue.from(source.getAge()));
    return row;
  }
}

명시적인 컨버터를 사용한 열거형 매핑 재정의

Postgres와 같은 일부 데이터베이스는 데이터베이스별 열거형 열 유형을 사용하여 열거형 값을 네이티브에 쓸 수 있다. Spring Data는 이식성을 극대화하기 위해 기본적으로 Enum 값을 String 값으로 변환한다. 실제 열거 값을 유지하려면 소스 유형과 대상 유형이 실제 열거 유형을 사용하여 Enum.name() 변환을 사용하지 않도록 하는 @Writing 변환기를 등록하한다. 또한 드라이버가 열거형을 나타내는 방법을 인식할 수 있도록 드라이버 레벨에서 열거형을 구성해야 한다.

다음의 예는, Color 열거치를 네이티브에 읽어내기 위한 관련 컴퍼넌트를 나타내고 있다.

enum Color {
    Grey, Blue
}

class ColorConverter extends EnumWriteSupport<Color> {

}


class Product {
    @Id long id;
    Color color;

    // …
}

17. Kotlin 지원

Kotlin는 JVM(및 기타 플랫폼)을 타겟으로 하는 정적으로 형식화된 언어로, 간결하고 우아한 코드를 작성할 수 있는 동시에, Java로 작성된 기존의 라이브러리와의 뛰어난 상호 운용성을 제공합니다.

Spring Data는 Kotlin에 대한 퍼스트 클래스 지원을 제공하며 개발자는 Spring Data가 Kotlin 네이티브 프레임워크인 것처럼 Kotlin 응용 프로그램을 만들 수 있다.

Kotlin에서 Spring 애플리케이션을 빌드하는 가장 쉬운 방법은 Spring Boot와 전용 Kotlin을 활용하는 것이다. 이 포괄적인 자습서 에서는 start.spring.io를 사용하여 Kotlin에서 Spring Boot 애플리케이션을 빌드하는 방법을 설명한다.

17.1. 요구사항

Spring Data는 1.3 Kotlin (영어)을 지원합니다. kotlin-stdlib(또는 kotlin-stdlib-jdk8 다음과 같은 변형 중 하나 및 kotlin-reflect 클래스 경로에 있어야 한다. start.spring.io를 통해 Kotlin 프로젝트를 부트 스트랩하는 경우 기본적으로 제공된다.

17.2. null 안전 (nullsafe)

Kotlin의 주요 기능 중 하나는 컴파일 할시에 null 값을 깔끔히 처리하는 null 안전이다. 이는 Optional 래퍼의 비용을 들이지 않아도 null 값 선언과 ‘값 또는 값 없음’ 시멘틱을 표현하여 응용 프로그램의 안전성을 높일 수 있다. (Kotlin에서는 null 허용 값을 가진 함수 구조를 사용할 수 있다. “Kotlin null 안전에 대한 포괄적인 가이드”를 참조하라.)

Java 에서는 유형 시스템으로 null 안전성을 표현할 수 없지만, Spring Data API에서는 org.springframework.lang 패키지로 선언된 JSR-305 도구 친화적인 어노테이션을 넣었다. 기본적으로 Kotlin에서 사용되는 Java API의 유형은 플랫폼 유형로서 인식된다. JSR-305 어노테이션 및 Spring null 허용 여부 주석에 대한 Kotlin 지원은 컴파일 시간에 null 관련 문제를 처리할 수 있는 이점과 함께 Kotlin 개발자에게 전체 Spring Data API에 대한 null 안전성을 제공한다.

Spring Data 리포지터리에 null 안전성이 어떻게 적용되는가에 대해서는 “리포지터리 메소드의 null 처리”를 참조하길 바란다.

17.3. 객체 매핑

Kotlin 객체를 구체화하는 방법에 대한 자세한 내용은 Kotlin 지원을 참조한다.

17.4. 확장

Kotlin 확장는 기존 클래스를 추가 기능으로 확장하는 기능을 제공한다. Spring Data Kotlin API는 이러한 확장 기능을 사용하여 기존 Spring API에 새로운 Kotlin 고유의 유용한 기능을 추가한다.

예를 들어, Kotlin 구체화된 유형 매개변수는 JVM 제네릭스 유형 삭제에 대한 해결 방법을 제공하고 Spring Data는 이 기능을 활용하기 위한 몇 가지 확장 기능을 제공한다. 이를 통해 더 나은 Kotlin API를 사용할 수 있다.

Java 로 SWCharacter 객체 목록를 검색하려면, 일반적으로 다음과 같이 작성한다.

Flux<SWCharacter> characters = client.select().from(SWCharacter.class).fetch().all();

Kotlin 및 Spring Data 확장을 사용하면 대신 다음과 같이 작성할 수 있다.

val characters =  client.select().from<SWCharacter>().fetch().all()
// or (both are equivalent)
val characters : Flux<SWCharacter> = client.select().from().fetch().all()

Java와 마찬가지로 Kotlin의 characters은 엄격하게 형식화되었지만, Kotlin의 정교한 형식 추론으로 구문을 줄일 수 있다.

Spring Data R2DBC는 다음과 같은 확장 기능을 제공한다.

  • DatabaseClient 그리고 Criteria의 제네릭 지원을 구체화하였다.
  • DatabaseClient의 코루틴 확장.

17.5. 코루틴

Kotlin 코루틴는 논블록킹 코드를 명령적으로 기술하는 것을 가능하게 하는 경량 thread이다. 언어 측면에서 suspend 함수는 비동기 작업의 추상화를 제공하며, 라이브러리 측면에서는 kotlinx.coroutinesasync { }와 같은 함수와 Flow와 같은 유형을 제공한다.

Spring Data 모듈은 다음 범위에서 코루틴에 대한 지원을 제공한다.

17.5.1. 종속성

코루틴 지원, kotlinx-coroutines-core, kotlinx-coroutines-reactive, kotlinx-coroutines-reactor 종속성은 클래스 경로에 있을 때 활성화된다.

예 89: Maven pom.xml에 추가할 종속성

<dependency>
  <groupId>org.jetbrains.kotlinx</groupId>
  <artifactId>kotlinx-coroutines-core</artifactId>
</dependency>

<dependency>
  <groupId>org.jetbrains.kotlinx</groupId>
  <artifactId>kotlinx-coroutines-reactive</artifactId>
</dependency>

<dependency>
  <groupId>org.jetbrains.kotlinx</groupId>
  <artifactId>kotlinx-coroutines-reactor</artifactId>
</dependency>

17.5.2. Reactive는 코루틴으로 어떻게 변환될까?

반환 값의 경우은 Reactive API에서 Coroutines API로의 변환은 다음과 같다.

  • fun handler(): Mono<Void>suspend fun handler()이 된다.
  • fun handler(): Mono<T>Mono 비울 수 있는지 여부에 따라 suspend fun handler(): T 또는 suspend fun handler(): T?이 된다. (보다 정적으로 형식화되는 이점이 있다)
  • fun handler(): Flux<T>fun handler(): Flow<T>이 된다.

Flow는 코루틴 세계 Flux 에 해당하며 핫 스트림 또는 콜드 스트림, 유한 스트림 또는 무한 스트림에 적합하지만 주요 차이점은 다음과 같다.

  • Flow 는 푸시(push) 기반이고, Flux는 푸시풀 하이브리드(push-pull hybrid)이다.
  • 백 프레셔는 일시 중단 기능을 통해 구현된다.
  • Flow 에는 단일 중단 collect 메소드만 있고, 운영자는 확장 기능으로 구현된다.
  • 코루틴 덕분에 연산자는 쉽게 구현할 수 있다.
  • 확장을 통해 사용자 정의 연산자를 Flow에 추가할 수 있다.
  • 수집 작업이 기능을 중단한다.
  • map 연산자는 일시 중단 기능 매개 변수를 사용하므로 비동기 조작을 지원한다(flatMap필수 없음).

코루틴과 동시에 코드를 실행하는 방법과 같은 자세한 내용은 Spring, 코루틴 및 Kotlin 플로우로 반응합니다. 이 블로그 게시물을 참조하십시오.

코루틴과 동시에 코드를 실행하는 방법을 비롯한 자세한 내용은 Spring, Coroutines 및 Kotlin Flow로 반응하기에 대한 이 블로그 게시물을 참조한다.

17.5.3. 리포지토리

코루틴 리포지토리의 예는 다음과 같다

interface CoroutineRepository : CoroutineCrudRepository<User, String> {

    suspend fun findOne(id: String): User

    fun findByFirstname(firstname: String): Flow<User>

    suspend fun findAllByFirstname(id: String): List<User>
}

코루틴 리포지토리는 리액티브 리포지토리에 구축되어 Kotlin의 코루틴을 통한 데이터 액세스의 비 차단성을 공개한다. 코루틴 리포지토리의 메소드는 쿼리 메소드 또는 커스텀 구현의 어느쪽이든에 의해 서포트할 수 있다. 커스텀 구현 메소드를 호출하면, 코루틴의 호출이 실제의 구현 메소드에 전파된다. 커스텀 메소드가 suspend-able의 경우, 구현 메소드가 MonoFlux등의 리액티브형을 반환할 필요는 없다.