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

스프링 프레임워크 데이터 액세스 공식 레퍼런스를 한글로 번역한 문서입니다.

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

목차


R2DBC(“Reactive Relational Database Connectivity”)는 SQL 데이터베이스에 접근하는 리액티브 패턴 표준화에 기여한 커뮤니티 주도 스펙이다.


4.1. Package Hierarchy

스프링의 R2DBC 추상화 프레임워크엔 두 종류의 패키지가 있다:


4.2. Using the R2DBC Core Classes to Control Basic R2DBC Processing and Error Handling

이번 섹션에선 R2DBC 코어 클래스로 에러 처리를 포함한 기본 R2DBC 동작을 제어하는 방법을 설명한다. 여기서는 다음과 같은 주제를 다룬다:

4.2.1. Using DatabaseClient

DatabaseClient는 R2DBC 코어 패키지의 핵심 클래스다. 리소스 생성과 해제를 알아서 처리해주기 때문에 커넥션 종료를 잊는 등의 흔한 에러를 방지할 수 있다. 코어 R2DBC 워크플로우의 기본 작업(구문 생성과 실행같은)을 수행하므로, 어플리케이션 코드에선 SQL을 제공하고 결과를 추출하기만 하면 된다. DatabaseClient 클래스는 다음과 같은 작업을 수행한다:

DatabaseClient는 선언적으로 설정을 만들 수 있는 함수형 fluent API를 가지고 있으며, 리액티브 타입을 사용한다.

DatabaseClient를 사용하기로 했다면, java.util.function 인터페이스 동작만 정확하게 구현하면 된다. Function 콜백 인터페이스는 DatabaseClient 클래스가 제공하는 Connection을 받아 Publisher를 만든다. Row 결과를 추출하는 매핑 펑션도 마찬가지다.

DatabaseClient는 DAO 구현체 안에서도 사용할 수 있다. 이 때는 ConnectionFactory를 참조해 직접 DatabaseClient 인스턴스를 만들어도 되고, DatabaseClient를 스프링 IoC 컨테이너 빈으로 설정해서 DAO에 주입해도 된다.

DatabaseClient 객체는 스태틱 팩토리 메소드로 만드는 게 가장 간단하다:

java kotlin
DatabaseClient client = DatabaseClient.create(connectionFactory);
val client = DatabaseClient.create(connectionFactory)

ConnectionFactory는 항상 스프링 IoC 컨테이너의 빈으로 설정해야 한다.

위에 있는 메소드는 디폴트 설정으로 DatabaseClient를 생성한다.

DatabaseClient.builder()Builder 인스턴스를 가져오는 방법도 있다. 다음과 같은 메소드로 클라이언트를 커스텀할 수 있다:

방언(dialect)은 BindMarkersFactoryResolverConnectionFactory를 통해 리졸브하며, 보통은 ConnectionFactoryMetadata를 검사한다. 자체 BindMarkersFactoryMETA-INF/spring.factoriesorg.springframework.r2dbc.core.binding.BindMarkersFactoryResolver$BindMarkerFactoryProvider를 구현한 클래스를 동록하면 스프링이 자동으로 발견할 수 있다. BindMarkersFactoryResolver는 스프링의 SpringFactoriesLoader를 사용해서 클래스패스에 있는 바인드 마커 프로바이더 구현체를 찾는다.

현재 지원하는 데이터베이스는 다음과 같다:

DatabaseClient 클래스가 발행한 모든 SQL은, 클라이언트 인스턴스(보통 DefaultDatabaseClient)의 클래스 풀 네임에 해당하는 범주 아래 DEBUG 레벨 로그를 남긴다. 더불어, 실행할 때마다 리액티브 시퀀스 디버깅을 위한 체크포인트를 등록한다.

다음 섹션에선 DatabaseClient를 사용하는 몇 가지 예시를 제공한다. 이 예제에서 보여주는 DatabaseClient 기능이 전부는 아니다. 전체 기능은 javadoc을 참고해라.

Executing Statements

DatabaseClient는 구문을 실행하는 기본 기능을 제공한다. 다음 예제는 최소한의 코드긴 하지만, 새 테이블을 만드는 완전한 기능을 갖춘 코드다:

java kotlin
Mono<Void> completion = client.sql("CREATE TABLE person (id VARCHAR(255) PRIMARY KEY, name VARCHAR(255), age INTEGER);")
        .then();
client.sql("CREATE TABLE person (id VARCHAR(255) PRIMARY KEY, name VARCHAR(255), age INTEGER);")
        .await()

DatabaseClient는 fluent 방식으로 설계돼서 사용하기 편리하다. 이 클래스는 각 실행 스펙에서 호출할 수 있는 연결(intermediate), 지속(continuation), 종료(terminal) 메소드를 제공한다. 위에 있는 예제에선 then()을 사용해서 쿼리(구문을 여러 개를 가진 SQL 쿼리라면 여러 개일 수 있음)가 완료되는 즉시 종료하는 완료 Publisher를 반환한다.

execute(…)는 SQL 쿼리 문자열이나 쿼리 Supplier<String>을 받는다. Supplier<String>을 사용하면 실제로 실행할 때까지 쿼리 생성을 연기할 수 있다.

Querying (SELECT)

SQL 쿼리는 Row 객체를 반환할 수도 있고, 영향 받은 row 수를 반환할 수도 있다. DatabaseClient는 발행한 쿼리에 따라 업데이트한 row 수 또는 row 자체를 반환한다.

아래 쿼리는 테이블에서 id, name 컬럼을 조회한다:

java kotlin
Mono<Map<String, Object>> first = client.sql("SELECT id, name FROM person")
        .fetch().first();
val first = client.sql("SELECT id, name FROM person")
        .fetch().awaitSingle()

다음은 변수를 바인딩하는 쿼리다:

java kotlin
Mono<Map<String, Object>> first = client.sql("SELECT id, name FROM person WHERE first_name = :fn")
        .bind("fn", "Joe")
        .fetch().first();
val first = client.sql("SELECT id, name FROM person WHERE WHERE first_name = :fn")
        .bind("fn", "Joe")
        .fetch().awaitSingle()

위에서 fetch()를 사용한 것을 보고 이미 눈치챘을 거다. fetch()는 컨슘할 데이터의 양을 지정할 수 있게 해주는 지속(continuation) 연산자다.

별다른 매핑 정보를 지정하지 않으면, 쿼리는 테이블 구조의 결과를 Map으로 반환한다. 이때 키는 대소문자를 구분하지 않은 컬럼 이름이며, 값에는 컬럼에 해당하는 값을 저장한다.

Row마다 호출할 Function<Row, T>를 제공하면 임의의 값(단일 값, 컬렉션, 맵, 객체)을 반환하도록 결과 매핑을 제어할 수 있다.

다음 예제는 id 컬럼을 추출해서 그 값을 방출한다:

java kotlin
Flux<String> names = client.sql("SELECT id FROM person")
        .map(row -> row.get("id", String.class))
        .all();
val names = client.sql("SELECT id FROM person")
        .map{ row: Row -> row.get("id", String.class) }
        .flow()

null은 어떻게 처리할까?

관계형 데이터베이스 결과는 null 값을 가질 수도 있다. 하지만 리액티브 스트림 스펙에선 null 값 방출은 허용하지 않는다. 이 요구사항에 부합하려면 extractor 펑션에서 적절하게 null을 처리해야 한다. Row에서 null 값을 가져올 수도 있지만, null 값은 절대 방출하면 안 된다. 객체에 있는 null 값은 전부 래핑해서 (예를 들어 단일 값은 Optional로) extractor 펑션에서 null 값을 직접 반환하지 않도록 만들어야 한다.

Updating (INSERT, UPDATE, and DELETE) with DatabaseClient

수정을 가하는 구문은 보통 테이블 형식 데이터를 반환하지 않으므로 rowsUpdated()로 결과를 컨슘한다는 점만 다르다.

다음은 수정한 row 수를 반환하는 UPDATE 구문 예제다:

java kotlin
Mono<Integer> affectedRows = client.sql("UPDATE person SET first_name = :fn")
        .bind("fn", "Joe")
        .fetch().rowsUpdated();
val affectedRows = client.sql("UPDATE person SET first_name = :fn")
        .bind("fn", "Joe")
        .fetch().awaitRowsUpdated()

Binding Values to Queries

전형적인 어플리케이션은 입력을 받아 그에 맞는 row를 조회하거나 업데이트한다. 이때는 SQL 구문에 파라미터를 적용해야 한다. 보통 WHERE 절로 범위를 한정하는 SELECT 구문이나, 입력 파라미터를 받는 INSERT/UPDATE 구문이 그러하다. 구문 파라미터는, 파라미터를 제대로 이스케이프하지 않으면 SQL 인젝션 공격에 노출될 여지가 있다. DatabaseClient는 R2DBC의 bind API를 활용해서 쿼리 파라미터를 이용한 SQL 인젝션 공격을 방어해준다. execute(…) 연산자로 파라미터가 있는 SQL 구문을 제공하고 파라미터를 실제 Statement에 바인딩하면 된다. 그러면 R2DBC 드라이버는 prepared 구문을 사용해 파라미터를 대치하고 구문을 실행한다.

파라미터 바인딩은 두 가지 전략을 지원한다:

다음은 쿼리에 파라미터를 바인딩하는 예제다:

db.sql("INSERT INTO person (id, name, age) VALUES(:id, :name, :age)")
    .bind("id", "joe")
    .bind("name", "Joe")
    .bind("age", 34);

R2DBC 네이티브 바인드 마커

R2DBC는 실제 데이터베이스 벤더에 따라 다른 데이터베이스 네이티브 바인드 마커를 사용한다. 예를 들어 Postgres는 $1, $2, $n같은 인덱싱 마커를, SQL 서버는 @ 프리픽스를 붙인 named 바인드 마커를 사용한다.

R2DBC는 ? 바인드 마커가 필요한 JDBC와는 다르다. JDBC에선, 실제 드라이버가 구문을 실행하면서 ? 바인드 마커를 데이터베이스 네이티브 마커로 변환한다.

스프링 프레임워크 R2DBC를 사용하면 네이티브 바인딩 마커나 :name 문법을 사용하는 named 바인드 마커를 사용할 수 있다.

named 파라미터를 지원할 땐, BindMarkersFactory 인스턴스를 활용해서 쿼리 실행 시점에 named 파라미터를 네이티브 바인드 마커로 확장한다. 따라서 데이터베이스 벤더가 달라져도 어느 정도는 쿼리를 유지할 수 있다.

쿼리 전처리기가 named 파라미터 Collection을 일련의 바인드 마커로 풀어주기 때문에, 인자 수에 따라 동적으로 쿼리를 생성하지 않아도 된다. 중첩 객체 배열은 select 리스트 등에 사용할 수 있는 바인드 마커로 치환된다.

아래 쿼리를 생각해보자:

SELECT id, name, state FROM table WHERE (name, age) IN (('John', 35), ('Ann', 50))

이 쿼리는 다음과 같이 파라미터를 적용해 실행할 수 있다:

java kotlin
List<Object[]> tuples = new ArrayList<>();
tuples.add(new Object[] {"John", 35});
tuples.add(new Object[] {"Ann",  50});

client.sql("SELECT id, name, state FROM table WHERE (name, age) IN (:tuples)")
    .bind("tuples", tuples);
val tuples: MutableList<Array<Any>> = ArrayList()
tuples.add(arrayOf("John", 35))
tuples.add(arrayOf("Ann", 50))

client.sql("SELECT id, name, state FROM table WHERE (name, age) IN (:tuples)")
    .bind("tuples", tuples)

select 리스트 사용법은 벤더에 따라 다르다.

다음은 IN 판단식을 사용하는 더 간단한 코드다:

java kotlin
client.sql("SELECT id, name, state FROM table WHERE age IN (:ages)")
    .bind("ages", Arrays.asList(35, 50));
val tuples: MutableList<Array<Any>> = ArrayList()
tuples.add(arrayOf("John", 35))
tuples.add(arrayOf("Ann", 50))

client.sql("SELECT id, name, state FROM table WHERE age IN (:ages)")
    .bind("tuples", arrayOf(35, 50))

R2DBC 자체는 컬렉션같은 타입은 지원하지 않는다. 그렇지만, 위에 있는 예제에서 전달한 List는 스프링 R2DBC가 지원하는 named 파라미터로 확장된다. 따라서 위 예제처럼 IN 절에서는 컬렉션을 사용할 수 있다. 단, 배열 타입 컬럼(예를 들어 Postgres 등에서)을 삽입하거나 업데이트하려면, R2DBC 드라이버가 지원하는 배열 타입을 사용해야 한다: 전형적인 예시는 text[] 컬럼을 업데이트하기 위한 String[]같은 자바 배열이다. Collection<String>같은 타입은 배열 파라미터로 전달하면 안 된다.

Statement Filters

간혹 실제로 구문를 실행하기 전에 Statement 옵션을 세세하게 튜닝해야 할 때도 있다. 이때는 DatabaseClient를 통해 Statement 필터(StatementFilterFunction)를 등록해서 실행 중 구문을 가로채고 수정할 수 있다. 다음 예제를 참고해라:

java kotlin
client.sql("INSERT INTO table (name, state) VALUES(:name, :state)")
    .filter((s, next) -> next.execute(s.returnGeneratedValues("id")))
    .bind("name", )
    .bind("state", );
client.sql("INSERT INTO table (name, state) VALUES(:name, :state)")
            .filter { s: Statement, next: ExecuteFunction -> next.execute(s.returnGeneratedValues("id")) }
            .bind("name", )
            .bind("state", )

DatabaseClientFunction<Statement, Statement>를 받는 더 간단한 filter(…) 메소드도 오버로드하고 있다:

java kotlin
client.sql("INSERT INTO table (name, state) VALUES(:name, :state)")
    .filter(statement -> s.returnGeneratedValues("id"));

client.sql("SELECT id, name, state FROM table")
    .filter(statement -> s.fetchSize(25));
client.sql("INSERT INTO table (name, state) VALUES(:name, :state)")
    .filter { statement -> s.returnGeneratedValues("id") }

client.sql("SELECT id, name, state FROM table")
    .filter { statement -> s.fetchSize(25) }

StatementFilterFunction 구현체로는 StatementResult 객체를 필터링할 수 있다.

DatabaseClient Best Practices

DatabaseClient 클래스는 인스턴스를 설정하고나면 thread-safe하다. 여기서 중요한 점은 설정에 DatabaseClient 단일 인스턴스를 추가하고, DAO(또는 레포지토리) 여러 개에 같은 인스턴스 참조를 공유할 수 있다는 거다. DatabaseClientConnectionFactory 참조를 유지한다는 점에서는 stateful이지만, ConnectionFactory로 조회하는 커넥션은 스레드마다 독립적이다.

DatabaseClient 클래스를 사용할 땐 보통 스프링 설정 파일로 ConnectionFactory를 설정하고, 공유된 ConnectionFactory 빈을 DAO 클래스에 의존성으로 주입한다. DatabaseClientConnectionFactory setter에서 만든다. 따라서 DAO는 다음과 유사하게 만들 수 있다:

java kotlin
public class R2dbcCorporateEventDao implements CorporateEventDao {

    private DatabaseClient databaseClient;

    public void setConnectionFactory(ConnectionFactory connectionFactory) {
        this.databaseClient = DatabaseClient.create(connectionFactory);
    }

    // R2DBC-backed implementations of the methods on the CorporateEventDao follow...
}
class R2dbcCorporateEventDao(connectionFactory: ConnectionFactory) : CorporateEventDao {

    private val databaseClient = DatabaseClient.create(connectionFactory)

    // R2DBC-backed implementations of the methods on the CorporateEventDao follow...
}

명시적으로 설정하는 대신 컴포넌트 스캔과 어노테이션을 통해 의존성을 주입해도 된다. 이땐 클래스에 @Component 어노테이션을 달고 (컴포넌트 스캔 후보로 등록), ConnectionFactory setter 메소드에 @Autowired를 선언한다. 다음 예제를 참고해라:

java kotlin
@Component // (1)
public class R2dbcCorporateEventDao implements CorporateEventDao {

    private DatabaseClient databaseClient;

    @Autowired // (2)
    public void setConnectionFactory(ConnectionFactory connectionFactory) {
        this.databaseClient = DatabaseClient.create(connectionFactory); // (3)
    }

    // R2DBC-backed implementations of the methods on the CorporateEventDao follow...
}
@Component // (1)
class R2dbcCorporateEventDao(connectionFactory: ConnectionFactory) : CorporateEventDao { // (2)

    private val databaseClient = DatabaseClient(connectionFactory) // (3)

    // R2DBC-backed implementations of the methods on the CorporateEventDao follow...
}

(1) 클래스에 @Component 어노테이션을 선언한다.
(2) ConnectionFactory setter 메소드에 @Autowired를 선언한다.
(3) ConnectionFactory로 DatabaseClient를 생성한다.

(1) 클래스에 @Component 어노테이션을 선언한다.
(2) 생성자로 ConnectionFactory를 주입한다.
(3) ConnectionFactory로 DatabaseClient를 생성한다.

위에서 보여준 클라이언트 초기화 스타일 중 어떤 방법을 사용하더라도 (아니면 그 외 다른 방법을 쓰더라도), 웬만해선 SQL을 실행할 때마다 새 DatabaseClient 클래스 인스턴스를 생성할 필요는 없다. DatabaseClient 인스턴스는 설정만 하고나면 thread-safe하다. 단, 데이터베이스 여러 개에 접근하는 어플리케이션이라면 ConnectionFactory가 여러 개 필요하며, 그에 따라 DatabaseClient 인스턴스를 다르게 설정해야 할 수는 있다.


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

<< >>