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

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

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

목차


MappingR2dbcConverter는 다양한 엔티티 매핑을 제공한다. MappingR2dbcConverter는 풍부한 메타데이터 모델을 사용해서 도메인 객체를 데이터 row에 매핑한다. 매핑 메타데이터 모델은 도메인 객체에 선언한 어노테이션을 통해 채워진다. 하지만 메타데이터 정보를 어노테이션으로만 확인하는 건 아니다. MappingR2dbcConverter를 사용하면 컨벤션 셋에 따라, 별도 메타데이터를 제공하지 않고도 객체를 row에 매핑할 수 있다.

이번 섹션에선 MappingR2dbcConverter 기능을 설명하며, 객체를 row에 매핑하기 위한 컨벤션 사용법과,어노테이션 기반 매핑 메타데이터 컨벤션을 재정의하는 방법을 알아본다.


16.1. Object Mapping Fundamentals

이번 섹션은 스프링 데이터의 객체 매핑, 생성, 필드와 프로퍼티 접근, mutability와 immutability의 기초를 다지는 섹션이다. 단, 여기서 설명하는 내용은 데이터 저장소의 객체 매핑을 사용하지 않는 스프링 데이터 모듈에만 해당하는 내용이다 (JPA 등). 인덱스, 컬럼/필드명 커스텀 등과 같은 저장소에 특화된 객체 매핑은 저장소 전용 섹션을 참고해라.

스프링 데이터가 객체를 매핑할 때 담당하는 핵심 역할은 도메인 객체 인스턴스를 생성하고, 여기에 저장소 네이티브 데이터 구조를 매핑하는 일이다. 따라서 두 핵심 단계를 거친다:

  1. 노출한 생성자 중 하나로 인스턴스 생성하기.
  2. 인스턴스를 채워 노출된 모든 프로퍼티 구체화하기.

16.1.1. Object creation

스프링 데이터는 persistent 객체 타입을 실체화할 엔티티 생성자를 자동으로 감지한다. 리졸브 알고리즘은 다음과 같이 동작한다:

  1. 인자가 없는 생성자가 있으면 우선 사용한다. 다른 생성자는 무시한다.
  2. 인자를 받는 생성자가 하나 있으면 이 생성자를 사용한다.
  3. 인자를 받는 생성자가 여러 개 있다면, 스프링 데이터가 사용할 생성자에 @PersistenceConstructor를 선언해야 한다.

생성자가 받는 인자 이름은 엔티티 프로퍼티명과 일치한다고 가정하고 값을 리졸브한다. 즉, 프로퍼티를 채우는 듯이 보인다. 커스텀 매핑(데이터 저장소 컬럼과 필드명이 다른 경우 등)도 마찬가지다. 단, 이때는 클래스 파일에 파라미터 이름 정보가 있거나, 생성자에 @ConstructorProperties 어노테이션이 있어야 한다.

리졸브 방식은 스프링 프레임워크의 @Value 어노테이션을 선언해 저장소 전용 SpEL 표현식으로 커스텀할 수 있다. 자세한 내용은 저장소 전용 매핑 섹션을 참고해라.

Object creation internals

스프링 데이터는 객체를 생성할 때 리플렉션 오버헤드를 피하기 위해 런타임에 디폴트로 생성되는 팩토리 클래스를 사용한다. 팩토리 클래스에선 도메인 클래스의 생성자를 직접 호출한다. 다음 타입으로 예를 들면:

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 클래스가 아닐 것
  • CGLib 프록시 클래스가 아닐 것
  • 스프링 데이터가 사용하는 생성자는 private이 아닐 것

이 기준에 해당되는 게 하나라도 있으면 스프링 데이터는 다시 리플렉션으로 엔티티 인스턴스를 만든다.

16.1.2. Property population

엔티티 인스턴스를 만들고나면 스프링 데이터는 클래스에 남은 모든 persistent 프로퍼티에 값을 채운다. 엔티티 생성자로 값을 채우지 않았다면 (생성자 인자 리스트로 컨슘하는 등), 순환 객체 참조를 리졸브할 수 있도록 식별자 프로퍼티를 먼저 채운다. 그 후에 아직 생성자로 채워지지 않은 모든 non-transient 프로퍼티를 엔터티 인스턴스에 설정한다. 이 땐 아래와 같은 알고리즘을 사용한다:

  1. 프로퍼티가 불변(immutable)이지만 with… 메소드를 노출하고 있으면 (아래 참고), with… 메소드를 사용해 프로퍼티를 채운 새 엔티티 인스턴스를 만든다.
  2. 프로퍼티 접근을 정의했다면(i.e. getter와 setter 접근), setter 메소드를 실행한다.
  3. 프로퍼티를 바꿀 수 있으면(mutable) 직접 필드를 설정한다.
  4. 프로퍼티가 불변(immutable)이면 persistence 연산(객체 생성 참조)에 사용하는 생성자로 인스턴스 복사본을 만든다.
  5. 기본 동작은 필드 값을 직접 설정하는 거다.

Property population internals

객체 생성 최적화와 유사하게, 스프링 데이터는 런타임에 접근자 클래스를 생성한다. 엔티티와의 상호 작용은 이 접근자 클래스를 통해 이루어진다.

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

Example 84. A generated Property Accessor

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는 변경할 수 있는(mutable) 엔티티 객체 인스턴스를 하나 가지고 있다. 덕분에 불변(immutable) 프로퍼티를 수정할 수 있다.
(2) 스프링 데이터는 기본적으로 필드 접근을 통해 프로퍼티 값을 읽고 수정한다. private 필드는 직접 접근할 수 없기 때문에 MethodHandles로 필드와 상호 작용한다.
(3) 이 클래스는 식별자 설정을 위한 withId(…) 메소드를 노출하고 있다. 예를 들어 데이터 저장소에 인스턴스를 삽입해 식별자를 생성했을 때 식별자를 설정한다. withId(…)를 호출하면 새 Person 객체를 만든다. 이후에 일어나는 모든 수정 작업은 새 인스턴스에서 일어나며, 이전 인스턴스는 손대지 않는다.
(4) 프로퍼티 접근을 사용하기 때문에 MethodHandles를 이용하지 않고 바로 메소드를 실행한다.

접근자 클래스를 활용하면 리플렉션에 비해 성능이 25% 정도 좋아진다. 단, 이렇게 도메인 클래스를 최적화려면 몇 가지 제약 조건을 준수해야 한다:

  • 타입이 디폴트 패키지나 java 패키지에 있으면 안 된다.
  • 타입과 생성자는 public이어야 한다.
  • inner 클래스라면 static이어야 한다.
  • 기존 ClassLoader에서 클래스를 선언할 수 있는 자바 런타임을 사용해야 한다. 자바 9 이상엔 제약이 있다.

스프링 데이터는 디폴트로 프로퍼티 접근자를 만들어 사용하며, 제약 사항을 하나라도 발견하면 리플렉션으로 폴백한다.

아래 엔티티를 살펴보자:

Example 85. A sample entity

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 인스턴스는 변경하지 않는다. 저장소가 관리하는 다른 프로퍼티에도 같은 패턴을 적용하지만, persistence 연산에 따라 바뀔 수 있다. wither 메소드는 필수는 아닌데, persistence 생성자(6번 참고)를 사실상 복사를 위해 사용하게 되며, 프로퍼티를 세팅하면 새 식별자를 적용한 인스턴스를 새로 만든다.
(2) firstname, lastname 프로퍼티는 getter를 통해 노출할 수 있는 평범한 불변(immutable) 프로퍼티다.
(3) age 프로퍼티는 불변(immutable)이지만 birthday 프로퍼티에서 파생된다. 스프링 데이터는 선언한 생성자만 사용하기 때문에 이 모델에선 데이터베이스에 있는 값이 디폴트보다 우선시된다. 직접 계산하는걸 의도했더라도, 생성자가 age 파라미터도 받아야 (나중에 무시하더라도) 값을 채울 때 실패하지 않는다. 그러지 않으면 따로 age 필드를 설정해야 하는데, age 필드는 불변(immutable)이면서 with… 메소드도 없기 때문이다.
(4) comment 프로퍼티는 변경할 수 있기 때문에(mutable), 필드를 직접 설정해서 값을 채운다.
(5) remarks 프로퍼티는 변경할 수 있기 때문에(mutable), 필드를 직접 설정하거나 setter 메소드를 실행한다.
(6) 이 클래스는 객체 생성을 위한 팩토리 메소드와 생성자를 하나씩 노출하고 있다. 여기서 핵심 아이디어는, 생성자를 추가하면 @PersistenceConstructor를 선언해야 하므로, 대신에 팩토리 메소드를 사용하는 거다. 프로퍼티 디폴트 값 세팅은 대신 팩토리 메소드 내에서 처리한다.

16.1.3. General recommendations

16.1.4. Kotlin support

스프링 데이터는 코틀린 자체 문법도 수용해서 객체를 만들고 수정한다.

Kotlin object creation

코틀린 클래스로도 인스턴스를 만들 수 있다. 모든 클래스는 기본적으로 변경이 불가능하며(immutable), 변경 가능한 프로퍼티를 정의하려면 프로퍼티 선언에 명시해야 한다. 아래 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")
}

코틀린에선 원한다면 파라미터를 제공하지 않았을 때 사용할 기본 값을 지정할 수 있다. 스프링 데이터는 생성자에서 파라미터 디폴트 값을 발견하면, 데이터 저장소가 제공하지 않은 값은(단순히 null을 반환했거나) 코틀린이 파라미터 기본값을 적용하도록 파라미터 값이 없는 채로 나둔다. name에 파라미터 기본값을 적용하는 아래 클래스를 살펴보자:

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

name 파라미터가 결과에 없거나 값 자체가 null이었다면, name은 무조건 unknown으로 맞춰진다.

Property population of Kotlin data classes

코틀린에선 모든 클래스가 기본적으로 변경이 불가능하며(immutable), 변경 가능한 프로퍼티를 정의하려면 프로퍼티 선언에 명시해야 한다. 아래 data 클래스 Person을 살펴보자:

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

이 클래스는 사실상 불변이다. 하지만 코틀린이 copy(…) 메소드를 생성하기 때문에 새 인스턴스를 만들 수 있다. copy(…) 메소드는 기존 객체의 모든 프로퍼티 값을 복사해 메소드 파라미터로 넘기고, 객체 인스턴스를 새로 만든다.


16.2. Convention-based Mapping

별도 매핑 메타데이터가 없을 때 MappingR2dbcConverter는 몇 가지 컨벤션에 따라 객체를 row에 매핑한다. 이 컨벤션은 다음과 같다:


16.3. Mapping Configuration

기본적으로 (직접 설정을 명시하지 않으면) DatabaseClient를 생성할 때 MappingR2dbcConverter 인스턴스도 만들어진다. 원한다면 자체 MappingR2dbcConverter 인스턴스도 생성할 수 있다. 자체 인스턴스를 만들면 데이터베이스와 특정 클래스 간 상호 매핑에 활용할 스프링 컨버터를 등록할 수 있다.

MappingR2dbcConverterDatabaseClientConnectionFactory처럼 자바 기반 메타데이터로 설정할 수 있다. 다음 예시는 스프링의 자바 설정을 사용한다:

Example 86. @Configuration class to configure R2DBC mapping support

@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 메소드를 재정의해서 MappingR2dbcConverter에 별도 컨버터를 더 추가할 수 있다.

AbstractR2dbcConfigurationDatabaseClient 인스턴스를 만들어 databaseClient라는 이름으로 컨테이너에 등록한다.


16.4. Metadata-based Mapping

스프링 데이터 R2DBC 내부에서 지원하는 객체 매핑 기능을 최대한 활용하려면, 매핑할 객체에 @Table 어노테이션을 달아야 한다. 매핑 프레임워크에서 이 어노테이션이 필요한 건 아니지만 (어노테이션이 없어도 POJO를 매핑할 수 있음), 어노테이션 있으면 클래스패스 스캐너가 도메인 객체를 찾아 필요한 메타데이터를 미리 추출할 수 있다. 어노테이션을 선언하지 않으면 도메인 객체를 처음 저장할 때 어플리케이션 성능이 약간 저하된다. 매핑 프레임워크가 도메인 객체 프로퍼티를 알아내고 저장 방법을 결정하기 위해 내부 메타데이터 모델을 만들기 때문이다. 다음은 도메인 객체 예시다:

Example 87. Example domain object

package com.mycompany.domain;

@Table
public class Person {

  @Id
  private Long id;

  private Integer ssn;

  private String firstName;

  private String lastName;
}

매퍼는 @Id 어노테이션을 보고 기본 키로 사용할 프로퍼티를 알 수 있다.

16.4.1. Default Type Mapping

아래 테이블은 엔티티의 프로퍼티 타입에 따른 매핑 정보를 담고 있다:

Source Type Target Type Remarks
원시 타입과 래퍼 타입 그대로 Explicit Converters로 커스텀할 수 있음.
JSR-310 Date/Time 타입 그대로 Explicit Converters로 커스텀할 수 있음.
String, BigInteger, BigDecimal, UUID 그대로 Explicit Converters로 커스텀할 수 있음.
Enum String Explicit Converters를 등록해 커스텀할 수 있음.
Blob, Clob 그대로 Explicit Converters로 커스텀할 수 있음.
byte[], ByteBuffer 그대로 바이너리 페이로드로 간주함.
Collection<T> T 타입의 배열 설정한 드라이버가 지원하는 타입이면 배열로 변환. 그 외는 지원하지 않음.
원시타입, 래퍼타입, String의 배열 래퍼 타입의 배열 (e.g. int[]Integer[]) 설정한 드라이버가 지원하는 타입이면 배열로 변환. 그 외는 지원하지 않음.
드라이버 전용 타입 그대로 사용하는 R2dbcDialect가 심플 타입으로 지원.
복잡한 객체 등록한 Converter에 따라 다름. Explicit Converters가 없으면 지원하지 않음

컬럼의 네티이브 데이터 타입은 R2DBC 드라이버 타입 매핑에 따라 다르다. 드라이버는 Geometry 타입같은 별도 심플 타입을 지원할 수 있다.

16.4.2. Mapping Annotation Overview

MappingR2dbcConverter는 메타데이터를 사용해서 객체를 row에 매핑한다. 다음과 같은 어노테이션을 지원한다:

매핑 메타데이터 관련 기반 코드는 기술에 구애받지 않는 별도 spring-data-commons 프로젝트에 정의돼 있다. R2DBC에선 전용 하위 클래스를 통해 어노테이션 기반 메타데이터를 지원한다. 물론 다른 전략을 사용할 수도 있다 (필요하면).

16.4.3. Customized Object Construction

매핑 시스템은 생성자에 @PersistenceConstructor 어노테이션을 달아 객체 생성을 커스텀할 수 있도록 지원한다. 생성자 파라미터 값은 다음과 같은 방식으로 리졸브한다:

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. Overriding Mapping with Explicit Converters

객체를 저장하고 질의할 땐, 모든 자바 타입을 R2dbcConverter 인스턴스가 OutboundRow 인스턴스로 매핑하도록 두는 게 간편하다. 하지만 R2dbcConverter 인스턴스로 대부분을 처리하더라도, 특정 타입은 직접 변환해야 할 때도 있다. 보통 성능을 최적화해야 할 때가 그렇다.

필요할 때만 직접 변환하려면 R2dbcConverterorg.springframework.core.convert.converter.Converter를 한 개 이상 등록해라.

컨버터를 설정할 땐 AbstractR2dbcConfigurationr2dbcCustomConversions 메소드를 사용하면 된다. 자바 설정은 이 챕터 시작 부분에 있는 예제를 참고해라.

최상위 엔티티 변환을 커스텀하려면 양방향 변환을 둘 다 제공해야 한다. 인바운드 데이터는 R2DBC의 Row에서 추출한다. 아웃바운드 데이터(INSERT/UPDATE 문에서 사용하는)는 OutboundRow로 표현한 뒤에 SQL 문으로 합쳐진다.

다음 예제는 RowPerson 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;
  }
}

컨버터는 단일 프로퍼티에 적용된다는 점에 주목해라. 컬렉션 프로퍼티(e.g. Collection<Person>)는 순회해서 요소별로 각각 변환한다. 컬렉션 컨버터(e.g. Converter<List<Person>>, OutboundRow)는 지원하지 않는다.

R2DBC는 원시 타입을 리턴할 땐 원시 타입을 박싱한다 (int.class 대신 Integer.class).

다음은 PersonOutboundRow로 변환하는 예제다:

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

Overriding Enum Mapping with Explicit Converters

Postgres 등 일부 데이터베이스는 자체 열거형 컬럼 타입을 활용해 enum 값을 저장할 수 있다. 스프링 데이터는 범용성을 위해 기본적으로 Enum 값을 String으로 변환한다. 실제 enum 값을 유지하려면, 소스와 타겟 타입에 실제 enum을 사용하는 @Writing 컨버터를 등록하고 Enum.name() 변환을 쓰지 않으면 된다. 추가로, 드라이버 레벨에도 enum 타입을 설정해야 드라이버가 enum 타입을 표현하는 방법을 알 수 있다.

다음 예제는 네티이브로 Color enum 값을 읽고 쓰는 관련 컴포넌트 보여준다:

enum Color {
    Grey, Blue
}

class ColorConverter extends EnumWriteSupport<Color> {

}


class Product {
    @Id long id;
    Color color;

    // …
}

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

<< >>