본문 바로가기
공식문서

[Spring Docs] Data Access with R2DBC

by sangyunpark99 2025. 4. 17.

이번글은 공식 문서에서 소개하는 Data Access with R2DBC에 대해 정리했습니다.

 

R2DBC ("Reactive Relational Database Connectivity") is a community-driven specification effort to standardize access to SQL databases using reactive patterns.

 

R2DBC(“Reactive Relational Database Connectivity”)는 리액티브 패턴을 사용하여 SQL 데이터베이스에 접근하는 방식을 표준화하기 위한 커뮤니티 주도의 명세(specification) 노력입니다.

 

Package Hierarchy

The Spring Framework’s R2DBC abstraction framework consists of two different packages:

Spring Framework의 R2DBC 추상화 프레임워크는 두 개의 서로 다른 패키지로 구성되어 있습니다.

  • core: org.springframework.r2dbc.core 패키지는 DatabaseClient 클래스와 여러 관련 클래스를 포함한다. 기본적인 R2DBC 처리 및 오류 처리를 제어하는 방법은 Using the R2DBC Core Classes to Control Basic R2DBC Processing and Error Handling을 참조하세요.
  • connection: org.springframework.r2dbc.connection 패키지는 ConnectionFactory에 쉽게 접근할 수 있도록 해주는 유틸리티 클래스와, 테스트나 수정되지 않은 R2DBC 실행을 위해 사용할 수 있는 다양한 간단한 ConnectionFactory 구현체들을 포함한다. 데이터베이스 연결을 제어하는 방법은 Controlling Database Connections을 참조하세요.

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

This section covers how to use the R2DBC core classes to control basic R2DBC processing, including error handling. It includes the following topics:

이 섹션에서는 오류 처리를 포함하여 기본적인 R2DBC 처리를 제어하기 위해 R2DBC core 클래스를 사용하는 방법을 다룹니다. 다음 주제를 포함합니다.

  • DatabaseClient 사용하기
  • 명령문 실행하기
  • 조회(SELECT)하기
  • DatabaseClient를 이용한 데이터 수정(INSERT, UPDATE, DELETE)
  • 명령문 필터
  • 자동 생성 키 가져오기

Using DatabaseClient

DatabaseClient is the central class in the R2DBC core package. It handles the creation and release of resources, which helps to avoid common errors, such as forgetting to close the connection. It performs the basic tasks of the core R2DBC workflow (such as statement creation and execution), leaving application code to provide SQL and extract results. The DatabaseClient class:

 

DatabaseClient는 R2DBC core 패키지에서 중심이 되는 클래스이다. 이 클래스는 리소스의 생성과 해제를 처리하여, 연결을 닫는 것을 잊는 것과 같은 일반적인 오류를 방지해준다. 이 클래스는 core R2DBC 작업 흐름의 기본 작업(예: 명령문 생성 및 실행)을 수행하고, 애플리케이션 코드는 SQL을 제공하고 결과를 추출하는 역할만 하게 된다. DatabaseClient 클래스는

 

  • Runs SQL queries
  • Update statements and stored procedure calls
  • Performs iteration over Result instances
  • Catches R2DBC exceptions and translates them to the generic, more informative, exception hierarchy defined in the org.springframework.dao package. (See Consistent Exception Hierarchy.)SQL 쿼리를 실행한다
  • SQL 쿼리를 실행한다.
  • 업데이트 문과 저장 프로시저 호출을 실행한다.
  • Result 인스턴스에 대해 반복(iteration)을 수행한다.
  • R2DBC 예외를 포착하고, 이를 org.springframework.dao 패키지에 정의된 일반적이고 더 유용한 예외 계층 구조로 변환한다. (자세한 내용은 Consistent Exception Hierarchy 참조)

 

The client has a functional, fluent API using reactive types for declarative composition.

When you use the DatabaseClient for your code, you need only to implement java.util.function interfaces, giving them a clearly defined contract. Given a Connection provided by the DatabaseClient class, a Function callback creates a Publisher. The same is true for mapping functions that extract a Row result.

 

You can use DatabaseClient within a DAO implementation through direct instantiation with a ConnectionFactory reference, or you can configure it in a Spring IoC container and give it to DAOs as a bean reference.

The simplest way to create a DatabaseClient object is through a static factory method, as follows:

 

클라이언트는 선언형 조합을 위한 리액티브 타입 기반의 함수형(fluent) API를 제공합니다.

DatabaseClient를 사용할 때, 여러분은 단지 java.util.function 인터페이스를 구현하면 되며, 이 인터페이스들은 명확하게 정의된 계약을 제공합니다. DatabaseClient 클래스가 제공하는 Connection을 기반으로, Function 콜백은 Publisher를 생성한다. Row 결과를 추출하는 매핑 함수에 대해서도 동일하게 적용됩니다.

 

DatabaseClient는 DAO 구현 내에서 ConnectionFactory 참조를 통해 직접 인스턴스화하여 사용할 수 있으며, 또는 Spring IoC 컨테이너에 설정해서 빈 참조로 DAO에 주입할 수도 있습니다.

DatabaseClient 객체를 생성하는 가장 간단한 방법은 다음과 같이 정적 팩토리 메서드를 사용하는 것입니다.

 

DatabaseClient client = DatabaseClient.create(connectionFactory);

 

The ConnectionFactory should always be configured as a bean in the Spring IoC container.

ConnectionFactory는 항상 Spring IoC 컨테이너에 빈(bean)으로 등록되어야 한다.

 

The preceding method creates a DatabaseClient with default settings.

You can also obtain a Builder instance from DatabaseClient.builder(). You can customize the client by calling the following methods:

  • ….bindMarkers(…): Supply a specific BindMarkersFactory to configure named parameter to database bind marker translation.
  • ….executeFunction(…): Set the ExecuteFunction how Statement objects get run.
  • ….namedParameters(false): Disable named parameter expansion. Enabled by default.

 

앞선 메서드는 기본 설정으로 DatabaseClient를 생성합니다.

또한 DatabaseClient.builder()에서 Builder 인스턴스를 얻을 수도 있습니다. 다음과 같은 메서드들을 호출함으로써 클라이언트를 사용자 정의할 수 있습니다.

  • ...bindMarkers(...): 이름 있는 파라미터를 데이터베이스 바인드 마커로 변환하는 방식을 설정하기 위해 특정 BindMarkersFactory를 제공한다.
  • ...executeFunction(...): Statement 객체가 실행되는 방식을 정의하기 위해 ExecuteFunction을 설정합니다.
  • ...namedParameters(false): 이름 있는 파라미터 확장을 비활성화합니다. 기본적으로는 활성화되어 있습니다.

Currently supported databases are:

현재 지원되는 데이터베이스는 다음과 같습니다.

  • H2
  • MariaDB
  • Microsoft SQL Server
  • MySQL
  • Postgres

All SQL issued by this class is logged at the DEBUG level under the category corresponding to the fully qualified class name of the client instance (typically DefaultDatabaseClient). Additionally, each execution registers a checkpoint in the reactive sequence to aid debugging.

 

이 클래스에서 실행되는 모든 SQL은 DEBUG 수준으로 로깅되며, 로깅 카테고리는 해당 클라이언트 인스턴스의 완전한 클래스 이름(일반적으로 DefaultDatabaseClient)에 해당합니다.

또한, 각 SQL 실행은 리액티브 시퀀스에 체크포인트를 등록하여 디버깅을 돕습니다.

 

The following sections provide some examples of DatabaseClient usage. These examples are not an exhaustive list of all of the functionality exposed by the DatabaseClient. See the attendant javadoc for that.

 

다음 섹션들은 DatabaseClient 사용 예시들을 제공합니다. 이 예시들은 DatabaseClient가 제공하는 모든 기능을 포괄하지는 않으며,

자세한 기능 목록은 함께 제공되는 Javadoc 문서를 참고하면 됩니다.

 

Executing Statements

DatabaseClient provides the basic functionality of running a statement. The following example shows what you need to include for minimal but fully functional code that creates a new table:

 

DatabaseClient는 문(statement)을 실행하는 기본 기능을 제공합니다.

다음 예시는 새로운 테이블을 생성하기 위한 최소한이지만 완전히 동작하는 코드에 어떤 내용을 포함해야 하는지를 보여줍니다.

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

 

DatabaseClient is designed for convenient, fluent usage. It exposes intermediate, continuation, and terminal methods at each stage of the execution specification. The preceding example above uses then() to return a completion Publisher that completes as soon as the query (or queries, if the SQL query contains multiple statements) completes.

 

DatabaseClient는 편리하고 유창한(fluent) 사용을 위해 설계되었습니다.

실행 명세의 각 단계에서 중간 메서드, 연속 메서드, 종료 메서드를 제공합니다.

위의 예제에서는 then() 메서드를 사용하여 쿼리(또는 SQL 쿼리에 여러 문장이 포함되어 있는 경우 모든 쿼리)가 완료되자마자 완료되는 Completion Publisher를 반환합니다.

 

execute(…) accepts either the SQL query string or a query Supplier<String> to defer the actual query creation until execution.

execute(…)는 SQL 쿼리 문자열 자체 또는 Supplier<String>을 받아 실행 시점까지 실제 쿼리 생성을 지연시킬 수 있도록 해줍니다.

 

Querying (SELECT)

SQL queries can return values through Row objects or the number of affected rows. DatabaseClient can return the number of updated rows or the rows themselves, depending on the issued query.

The following query gets the id and name columns from a table:

 

SQL 쿼리는 Row 객체를 통해 값을 반환하거나, 영향을 받은 행(row)의 수를 반환할 수 있습니다. DatabaseClient는 실행한 쿼리에 따라 업데이트된 행 수 또는 행 자체를 반환할 수 있습니다.

 

다음은 테이블에서 idname 컬럼을 가져오는 쿼리 예시입니다:

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

 

 

The following query uses a bind variable:

다음 쿼리는 바인드 변수(bind variable)를 사용합니다:

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

 

You might have noticed the use of fetch() in the example above. fetch() is a continuation operator that lets you specify how much data you want to consume.

 

위 예시에서 fetch()가 사용된 것을 눈치챘을 수도 있습니다.

fetch()는 얼마나 많은 데이터를 소비할 것인지 지정할 수 있게 해주는 연속(continuation) 연산자입니다.

 

Calling first() returns the first row from the result and discards remaining rows. You can consume data with the following operators:

  • first() return the first row of the entire result. Its Kotlin Coroutine variant is named awaitSingle() for non-nullable return values and awaitSingleOrNull() if the value is optional.
  • one() returns exactly one result and fails if the result contains more rows. Using Kotlin Coroutines, awaitOne() for exactly one value or awaitOneOrNull() if the value may be null.
  • all() returns all rows of the result. When using Kotlin Coroutines, use flow().
  • rowsUpdated() returns the number of affected rows (INSERT/UPDATE/DELETE count). Its Kotlin Coroutine variant is named awaitRowsUpdated().

first()를 호출하면 결과에서 첫 번째 행만 반환하고, 나머지 행은 버립니다. 데이터를 소비할 때 사용할 수 있는 연산자는 다음과 같습니다:

 

  • first(): 전체 결과에서 첫 번째 행을 반환합니다.
    • Kotlin Coroutine 버전은 awaitSingle() (값이 반드시 존재할 때),또는 awaitSingleOrNull() (값이 없을 수도 있을 때)입니다.
  • one(): 정확히 하나의 결과만 반환합니다.
    • 결과가 2개 이상이면 실패합니다. Kotlin에서는 awaitOne() 또는 awaitOneOrNull()을 사용합니다.
  • all(): 결과의 모든 행을 반환합니다.
    • Kotlin에서는 flow()를 사용합니다.
  • rowsUpdated(): INSERT, UPDATE, DELETE 쿼리 실행 시 영향을 받은 행의 수를 반환합니다.
    • Kotlin에서는 awaitRowsUpdated()로 사용합니다.

Without specifying further mapping details, queries return tabular results as Map whose keys are case-insensitive column names that map to their column value.

You can take control over result mapping by supplying a Function<Row, T> that gets called for each Row so it can return arbitrary values (singular values, collections and maps, and objects).

 

추가적인 매핑 정보를 지정하지 않으면, 쿼리는 Map 형태의 표 형식 결과를 반환합니다.

이 Map의 key는 대소문자를 구분하지 않는 컬럼 이름이며, value는 해당 컬럼의 값입니다.

 

하지만 결과 매핑을 더 세밀하게 제어하고 싶다면, Function<Row, T> 형태의 함수를 제공할 수 있습니다.

이 함수는 각 Row마다 호출되며, 단일 값, 컬렉션, Map, 객체 등 원하는 형태로 변환하여 반환할 수 있습니다.

 

The following example extracts the name column and emits its value:

다음 예시는 name 컬럼을 추출하고, 그 값을 반환하는 예시입니다:

Flux<String> names = client.sql("SELECT name FROM person")
        .map(row -> row.get("name", String.class))
        .all();

 

Alternatively, there is a shortcut for mapping to a single value:

대안으로, 단일 값에 매핑하기 위한 단축 방법(쇼트컷)이 있습니다:

Flux<String> names = client.sql("SELECT name FROM person")
			.mapValue(String.class)
			.all();

 

Or you may map to a result object with bean properties or record components:

또는 결과를 Bean 프로퍼티나 record 컴포넌트가 있는 결과 객체로 매핑할 수도 있습니다:

// assuming a name property on Person
Flux<Person> persons = client.sql("SELECT name FROM person")
			.mapProperties(Person.class)
			.all();

 

 

What about null?

Relation database results can contain null values. The Reactive Streams specification forbids the emission of null values. That requirement mandates proper null handling in the extractor function. While you can obtain null values from a Row, you must not emit a null value. You must wrap any null values in an object(for example, Optional for singular values) to make sure a null value is never returned directly by your extractor function.

 

관계형 데이터베이스 결과는 null 값을 포함할 수 있습니다. Reactive Streams 명세는 null 값을 방출하는 것을 금지합니다.

이 요구 사항은 추출 함수에서 null 값을 적절히 처리해야 함을 의미합니다. Row에서 null 값을 얻을 수는 있지만, 그 값을 직접 방출해서는 안 됩니다. null 값을 절대 직접 반환하지 않도록, 반드시 해당 값을 객체로 감싸야 합니다. (예: 단일 값의 경우 Optional로 감싸기)

 

Updating (INSERT, UPDATE, and DELETE) with DatabaseClient

The only difference of modifying statements is that these statements typically do not return tabular data so you use rowsUpdated() to consume results.

The following example shows an UPDATE statement that returns the number of updated rows:

 

DatabaseClient를 사용한 데이터 수정(INSERT, UPDATE, DELETE)은 일반적으로 테이블 형식의 데이터를 반환하지 않는다는 점에서 SELECT와 다릅니다.

따라서 수정 작업 결과를 확인하려면 rowsUpdated()를 사용하여 영향을 받은 행의 수를 소비해야 합니다.

 

다음은 업데이트된 행의 수를 반환하는 UPDATE 쿼리 예시입니다:

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

 

Binding Values to Queries

A typical application requires parameterized SQL statements to select or update rows according to some input. These are typically SELECT statements constrained by a WHERE clause or INSERT and UPDATE statements that accept input parameters. Parameterized statements bear the risk of SQL injection if parameters are not escaped properly. DatabaseClient leverages R2DBC’s bind API to eliminate the risk of SQL injection for query parameters. You can provide a parameterized SQL statement with the execute(…) operator and bind parameters to the actual Statement. 

 

일반적인 애플리케이션은 입력값에 따라 행을 선택하거나 수정하기 위해 파라미터화된 SQL 문장을 필요로 합니다. 이러한 문장은 일반적으로 WHERE 절이 포함된 SELECT 문이나 입력 파라미터를 받는 INSERT, UPDATE 문입니다.

파라미터화된 문장은 파라미터가 적절히 이스케이프 처리되지 않으면 SQL 인젝션의 위험이 있습니다.

DatabaseClient는 R2DBC의 bind API를 활용하여 쿼리 파라미터에 대한 SQL 인젝션 위험을 제거합니다.

execute(…) 연산자를 통해 파라미터화된 SQL 문을 제공하고, 실제 Statement에 파라미터를 바인딩할 수 있습니다.

 

Parameter binding supports two binding strategies:

  • By Index, using zero-based parameter indexes.
  • By Name, using the placeholder name.

The following example shows parameter binding for a query:

 

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

  1. 인덱스로 바인딩 (By Index)
    • 0부터 시작하는 파라미터 인덱스를 사용합니다.
  2. 이름으로 바인딩 (By Name)
    • 플레이스홀더(placeholder)에 지정한 이름을 사용합니다.

아래는 쿼리에 파라미터를 바인딩하는 예시입니다:

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

 

Alternatively, you may pass in a map of names and values:

또는, 이름과 값을 매핑한 Map을 전달하는 방식도 사용할 수 있습니다:

Map<String, Object> params = new LinkedHashMap<>();
params.put("id", "joe");
params.put("name", "Joe");
params.put("age", 34);
db.sql("INSERT INTO person (id, name, age) VALUES(:id, :name, :age)")
		.bindValues(params);

 

Or you may pass in a parameter object with bean properties or record components:

또는, 빈 속성(bean properties)이나 레코드 컴포넌트(record components)를 가진 파라미터 객체를 전달할 수도 있습니다:

// assuming id, name, age properties on Person
	db.sql("INSERT INTO person (id, name, age) VALUES(:id, :name, :age)")
			.bindProperties(new Person("joe", "Joe", 34);

 

Alternatively, you can use positional parameters for binding values to statements. Indices are zero based.

또는, 값을 바인딩할 때 위치 기반 매개변수(positional parameters)를 사용할 수도 있습니다. 인덱스는 0부터 시작합니다.

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

 

In case your application is binding to many parameters, the same can be achieved with a single call:

만약 애플리케이션에서 많은 파라미터를 바인딩해야 한다면, 동일한 작업을 한 번의 호출로도 처리할 수 있습니다:

List<?> values = List.of("joe", "Joe", 34);
    db.sql("INSERT INTO person (id, name, age) VALUES(:id, :name, :age)")
	    .bindValues(values);

 

 

R2DBC Native Bind Markers

R2DBC uses database-native bind markers that depend on the actual database vendor. As an example, Postgres uses

indexed markers, such as $1, $2, $n. Another example is SQL Server, which uses named bind markers prefixed with @.

This is different from JDBC which requires ? as bind markers. In JDBC, the actual drivers translate ? bind markers to database-native markers as part of their statement execution.

Spring Framework’s R2DBC support lets you use native bind markers or named bind markers with the :name syntax.
Named parameter support leverages a BindMarkersFactory instance to expand named parameters to native bind

markers at the time of query execution, which gives you a certain degree of query portability across various database vendors.

 

R2DBC는 실제 데이터베이스 벤더에 따라 데이터베이스 고유의 바인드 마커(bind marker) 를 사용합니다.

예를 들어, PostgreSQL은 $1, $2, $n과 같은 인덱스 기반 마커를 사용하고, SQL Server는 @로 시작하는 이름 기반 바인드 마커(예: @name)를 사용합니다.

 

이는 JDBC와는 다릅니다.

JDBC에서는 바인드 마커로 항상 ? 를 사용하며, 실제 실행 시점에 드라이버가 ? 를 데이터베이스 고유 마커로 변환합니다.

 

Spring Framework의 R2DBC는 다음을 지원합니다:

  • 데이터베이스 고유의 바인드 마커
  • :name 형식의 이름 기반 바인드 마커

이름 기반 매개변수(:name)는 BindMarkersFactory 인스턴스를 통해 실제 쿼리 실행 시점에 데이터베이스 고유의 마커로 변환됩니다.

이러한 방식은 다양한 데이터베이스 벤더 간에 어느 정도의 쿼리 이식성(portability) 을 제공합니다.

 

The query-preprocessor unrolls named Collection parameters into a series of bind markers to remove the need of dynamic query creation based on the number of arguments. Nested object arrays are expanded to allow usage of (for example) select lists.

Consider the following query:

 

쿼리 전처리기(query-preprocessor)는 이름 기반 컬렉션(Collection) 매개변수를 일련의 바인드 마커(bind marker) 로 풀어(unroll) 처리합니다.

이렇게 하면 인자의 개수에 따라 동적으로 쿼리를 생성할 필요가 없어집니다.

또한, 중첩된 객체 배열(nested object arrays)도 확장되어, 예를 들어 SELECT 리스트에서도 사용할 수 있게 됩니다.

 

다음은 이를 보여주는 예시 쿼리입니다:

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

 

The preceding query can be parameterized and run as follows:

앞서 나온 쿼리는 다음과 같이 매개변수를 사용하여 실행할 수 있습니다:

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

 

Usage of select lists is vendor-dependent.

select 리스트의 사용은 벤더(제조사, 공급업체)에 따라 달라집니다.

 

The following example shows a simpler variant using IN predicates:

다음 예시는 IN 조건절을 사용하는 더 간단한 형태를 보여줍니다.

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

 

R2DBC itself does not support Collection-like values. Nevertheless, expanding a given 
List in the example above works for named parameters in Spring’s R2DBC support, for example, for use in 
IN clauses as shown above. However, inserting or updating array-typed columns (for example, in Postgres) requires an array type that is supported by the underlying R2DBC driver: typically a Java array, for example, 
String[] to update a text[] column. Do not pass Collection<String> or the like as an array parameter.

 

R2DBC 자체는 List나 Set 같은 컬렉션 타입의 값을 지원하지 않습니다.

그렇지만 Spring에서 제공하는 R2DBC 지원 기능을 사용하면, 위 예시처럼 IN 조건절에서 컬렉션을 전달하는 게 가능합니다.

Spring이 내부적으로 리스트를 자동으로 펼쳐서 처리해줍니다.

 

하지만 배열 타입의 컬럼(예시 : PostgreSQL의 text[] 컬럼)에 데이터를 넣거나 업데이트하려면, 컬렉션이 아니라 Java 배열(String[])을 사용해야 합니다.예를 들어, Collection<String> 같은 것을 넘기면 작동하지 않습니다. 이런 경우엔 꼭 String[]처럼 배열 타입을 명확하게 써주어야 드라이버가 제대로 처리할 수 있습니다.


Statement Filters

Sometimes you need to fine-tune options on the actual Statement before it gets run. To do so, register a Statement filter (StatementFilterFunction) with the DatabaseClient to intercept and modify statements in their execution, as the following example shows:

 

가끔은 쿼리가 실행되기 직전의 Statement 객체에 세부 설정을 해줘야 할 때가 있습니다.

그럴 때는 DatabaseClientStatement 필터(StatementFilterFunction) 를 등록하면 됩니다.

 

이 필터를 사용하면, Statement가 실행되기 전에 중간에 가로채서 수정할 수 있습니다.

예를 들어, 쿼리 타임아웃을 설정하거나, 특정 옵션을 켜는 등의 작업을 할 수 있습니다.

 

아래 예시는 그 사용법을 보여줍니다.

client.sql("INSERT INTO table (name, state) VALUES(:name, :state)")
	    .filter((s, next) -> next.execute(s.returnGeneratedValues("id")))
	    .bind("name", …)
	    .bind("state", …);

 

Staement

데이터베이스에 보낼 SQL 명령어를 담은 객체입니다.

JDBC나 R2DBC 같은 데이터 접근 기술에선 이 객체를 통해서 SQL을 실행합니다.

 

 

DatabaseClient also exposes a simplified filter(…) overload that accepts a Function<Statement, Statement>:

 

DatabaseClientfilter(...) 메서드를 좀 더 간단하게 쓸 수 있는 버전도 제공합니다.

이 간단한 버전은 Function<Statement, Statement> 형태의 람다를 받기 때문에, Statement를 받아서 가공한 다음 다시 돌려주는 방식으로 간단히 수정 로직을 작성할 수 있습니다.

즉, 복잡한 인터페이스 구현 없이, statement -> statement.setTimeout(...) 이런 식으로 바로 쓸 수 있습니다.

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 implementations allow filtering of the Statement and filtering of Result objects.

 

StatementFilterFunction을 구현하면 2가지를 할 수 있습니다.

1. Statement를 수정하거나 가로채기

2. Result 객체도 가로채서 가공하기

즉, 쿼리 전처리와 결과 후처리 모두 개입할 수 있습니다.

 

DatabaseClient Best Practices

Instances of the DatabaseClient class are thread-safe, once configured. This is important because it means that you can configure a single instance of a DatabaseClient and then safely inject this shared reference into multiple DAOs (or repositories). The DatabaseClient is stateful, in that it maintains a reference to a ConnectionFactory, but this state is not conversational state.

 

DatabaseClient한 번 설정하면 스레드에 안전하게 사용할 수 있는 객체입니다.

이 말은, 하나의 DatabaseClient 인스턴스를 여러 DAO나 레포지토리에 공유해서 안전하게 주입해도 된다는 뜻입니다.

 

물론 DatabaseClient는 내부적으로 ConnectionFactory를 참조하고 있어서 상태를 가지는 객체지만,

그 상태는 요청 간 대화를 유지하는 식의 상태(conversational state)는 아닙니다.

그래서 여러 스레드나 컴포넌트가 동시에 사용해도 문제가 되지 않습니다.

 

A common practice when using the DatabaseClient class is to configure a ConnectionFactory in your Spring configuration file and then dependency-inject that shared ConnectionFactory bean into your DAO classes. The DatabaseClient is created in the setter for the ConnectionFactory. This leads to DAOs that resemble the following:

실제 사용 시 일반적인 방법은 이렇습니다

  1. Spring 설정 파일이나 설정 클래스에서 ConnectionFactory를 Bean으로 등록합니다.
  2. DAO 클래스에 그 ConnectionFactory의존성 주입합니다.
  3. 그리고 그 주입받은 ConnectionFactoryDatabaseClient를 만들어서 사용합니다.(ConnectionFactory의 setter 메서드에서 DatabaseClient를 생성하는 방식이 흔합니다.)

 

이렇게 하면 각각의 DAO에서 별도로 DatabaseClient를 생성하지 않고, 공통된 설정을 재사용하면서도 유지보수하기 쉬운 구조를 만들 수 있어요.

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

 

An alternative to explicit configuration is to use component-scanning and annotation support for dependency injection. In this case, you can annotate the class with @Component (which makes it a candidate for component-scanning) and annotate the ConnectionFactory setter method with @Autowired. The following example shows how to do so:

 

명시적으로 설정 파일에서 일일이 객체를 등록하지 않고도 의존성을 주입하는 방법이 있습니다.

그게 바로 컴포넌트 스캔과 어노테이션을 활용한 방식입니다.

 

이 방식에서는 다음과 같이 합니다.

  1. DAO나 서비스 클래스를 @Component로 표시하면, Spring이 이 클래스를 자동으로 찾아서 Bean으로 등록해줍니다.
  2. ConnectionFactory를 주입받을 메서드(예: setter)에 @Autowired를 붙이면, Spring이 자동으로 ConnectionFactory를 주입해줍니다.

즉, 설정 파일에서 일일이 Bean을 등록할 필요 없이, 어노테이션만으로 필요한 의존성을 자동 주입받는 구조가 됩니다.

@Component 
public class R2dbcCorporateEventDao implements CorporateEventDao {

	private DatabaseClient databaseClient;

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

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

 

(1) Annotate the class with @Component

(2) Annotate the ConnectionFactory setter method with @Autowired

(3) Create a new DatabaseClient with the ConnectionFactory

 

Regardless of which of the above template initialization styles you choose to use (or not), it is seldom necessary to create a new instance of a DatabaseClient class each time you want to run SQL. Once configured, a DatabaseClient instance is thread-safe. If your application accesses multiple databases, you may want multiple DatabaseClient instances, which requires multiple ConnectionFactory and, subsequently, multiple differently configured DatabaseClient instances.

 

위에서 소개한 설정 방식들 중 어떤 걸 쓰든 상관없이, SQL을 실행할 때마다 매번 DatabaseClient 객체를 새로 만들 필요는 거의 없습니다.

한 번 설정된 DatabaseClient스레드에 안전하게 재사용할 수 있기 때문에, 대부분의 경우에는 하나만 만들어서 계속 사용하는 게 좋습니다.

다만 예외적으로, 여러 개의 데이터베이스에 접근해야 하는 애플리케이션이라면 DB마다 각각 다른 설정이 필요하니까,

ConnectionFactory도 DB 수만큼 필요하고, 그에 따라 DatabaseClient도 각각 따로 생성해야 합니다.

 

Retrieving Auto-generated Keys

INSERT statements may generate keys when inserting rows into a table that defines an auto-increment or identity column. To get full control over the column name to generate, simply register a StatementFilterFunction that requests the generated key for the desired column.

 

INSERT 문을 실행하면, 테이블에 자동 증가(auto-increment) 또는 identity 컬럼이 있을 경우 DB가 자동으로 기본 키 값을 생성해줍니다.

이 자동 생성된 키 값을 조회하고 싶다면, 어떤 컬럼의 값을 가져올지 정확히 지정할 수 있습니다.

지정 하는 방법은 StatementFilterFunction을 등록해서, 원하는 컬럼에 대해 생성된 키 값을 요청하도록 Statement를 설정하면 됩니다.

Mono<Integer> generatedId = client.sql("INSERT INTO table (name, state) VALUES(:name, :state)")
		.filter(statement -> s.returnGeneratedValues("id"))
		.map(row -> row.get("id", Integer.class))
		.first();

// generatedId emits the generated key once the INSERT statement has finished

 

Using ConnectionFactory

Spring obtains an R2DBC connection to the database through a ConnectionFactory. A ConnectionFactory is part of the R2DBC specification and is a common entry-point for drivers. It lets a container or a framework hide connection pooling and transaction management issues from the application code. As a developer, you need not know details about how to connect to the database. That is the responsibility of the administrator who sets up the ConnectionFactory. You most likely fill both roles as you develop and test code, but you do not necessarily have to know how the production data source is configured.

 

Spring에서 R2DBC를 통해 데이터베이스에 연결할 때는 ConnectionFactory를 사용합니다.

ConnectionFactory는 R2DBC 사양의 일부로, R2DBC 드라이버의 공통 진입점입니다.

 

이 객체를 사용하면, 커넥션 풀 관리나 트랜잭션 처리 같은 복잡한 내부 동작을 프레임워크나 컨테이너가 대신 처리해주기 때문에,

개발자는 그런 세부 사항을 직접 신경 쓰지 않아도 됩니다.

즉, 개발자는 “DB에 어떻게 연결하느냐”에 대한 내부 구조를 몰라도 되고, 그건 보통 ConnectionFactory를 설정한 운영 환경 담당자(관리자)의 몫입니다.

 

물론 개발하고 테스트할 땐 개발자가 스스로 ConnectionFactory를 구성해야 하겠지만, 운영 환경에서 어떤 방식으로 구성되는지는 몰라도 상관없다는 뜻입니다.

When you use Spring’s R2DBC layer, you can configure your own with a connection pool implementation provided by a third party. A popular implementation is R2DBC Pool (r2dbc-pool). Implementations in the Spring distribution are meant only for testing purposes and do not provide pooling.

 

추가로, Spring에서 제공하는 R2DBC 설정을 사용할 땐, 직접 써드파티 라이브러리의 커넥션 풀을 설정해서 사용하는 것이 일반적입니다.

대표적인 예로 r2dbc-pool이라는 라이브러리가 많이 사용됩니다.

Spring 자체에서 제공하는 기본 구현은 테스트용으로만 적합하고, 커넥션 풀 기능은 제공하지 않기 때문에 운영 환경에서는 사용하지 않는 것이 좋습니다.

 

To configure a ConnectionFactory:

  1. Obtain a connection with ConnectionFactory as you typically obtain an R2DBC ConnectionFactory.
  2. Provide an R2DBC URL (See the documentation for your driver for the correct value).

The following example shows how to configure a ConnectionFactory:

 

ConnectionFactory를 설정하려면 다음과 같은 단계를 따릅니다:

  1. 먼저, 일반적인 R2DBC 방식과 마찬가지로 ConnectionFactory를 생성해서 커넥션을 얻을 수 있어야 합니다.
  2. 이때 필요한 건 R2DBC URL인데, 이 URL 형식은 사용하는 드라이버마다 다를 수 있기 때문에 드라이버 공식 문서를 참고해서 올바른 형식으로 작성해야 합니다.

아래 예시는 실제로 ConnectionFactory를 어떻게 설정하는지 보여주는 코드입니다.

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

 

Using ConnectionFactoryUtils

The ConnectionFactoryUtils class is a convenient and powerful helper class that provides static methods to obtain connections from ConnectionFactory and close connections (if necessary).

It supports subscriber Context-bound connections with, for example R2dbcTransactionManager.

 

ConnectionFactoryUtils는 R2DBC에서 자주 쓰이는 편리하고 강력한 유틸 클래스입니다.

이 클래스는 ConnectionFactory에서 커넥션을 가져오거나 필요 시 닫는 작업을 쉽게 처리할 수 있도록

정적 메서드들을 제공합니다.

 

또한, 예를 들어 R2dbcTransactionManager처럼 리액티브 스트림의 Context에 연결된 커넥션도 잘 지원해서,

트랜잭션 흐름 안에서 커넥션을 안전하게 관리할 수 있습니다.

 

Using SingleConnectionFactory

The SingleConnectionFactory class is an implementation of DelegatingConnectionFactory interface that wraps a single Connection that is not closed after each use.

If any client code calls close on the assumption of a pooled connection (as when using persistence tools), you should set the suppressClose property to true. This setting returns a close-suppressing proxy that wraps the physical connection. Note that you can no longer cast this to a native Connection or a similar object.

SingleConnectionFactory is primarily a test class and may be used for specific requirements such as pipelining if your R2DBC driver permits for such use. In contrast to a pooled ConnectionFactory, it reuses the same connection all the time, avoiding excessive creation of physical connections.

 

SingleConnectionFactoryDelegatingConnectionFactory 인터페이스를 구현한 클래스로,

항상 하나의 Connection만 사용하고, 그 연결을 매번 닫지 않는 방식으로 동작합니다.

 

주의할 점으로는 어떤 클라이언트 코드가 “풀에서 가져온 커넥션이겠지” 하고 close()를 호출할 수도 있는데,

이런 상황을 방지하려면 suppressClose 속성을 true로 설정해야 합니다.

이렇게 하면 실제 커넥션을 감싸는 proxy 객체가 반환되는데, 이 객체는 close()를 호출해도 실제 연결은 유지되도록 동작합니다.

단, 이렇게 되면 원래의 커넥션 객체로 캐스팅해서 사용하는 건 불가능해집니다. 즉, 네이티브한 DB 커넥션 객체처럼 직접 다루는 건 안 된다는 뜻입니다.

 

SingleConnectionFactory는 주로 테스트 용도로 사용됩니다.

또는, R2DBC 드라이버가 허용한다면 파이프라이닝(pipelining) 같은 특별한 상황에서도 쓸 수 있습니다.

기본적으로는 항상 같은 물리적 커넥션을 재사용하니까, 풀 방식처럼 커넥션을 계속 만들고 닫는 오버헤드를 줄일 수 있습니다.

 

 

Using TransactionAwareConnectionFactoryProxy

TransactionAwareConnectionFactoryProxy is a proxy for a target ConnectionFactory. The proxy wraps that target ConnectionFactory to add awareness of Spring-managed transactions.

 

TransactionAwareConnectionFactoryProxy기존의 ConnectionFactory를 감싸는 프록시(proxy) 역할을 해요.

이 프록시는 Spring이 관리하는 트랜잭션을 인식할 수 있도록 기능을 추가해 줍니다.

즉, 기존 ConnectionFactory 자체를 바꾸지 않고, 트랜잭션 동작에 맞게 커넥션을 자동으로 연동해줍니다.

 

Using this class is required if you use a R2DBC client that is not integrated otherwise with Spring’s R2DBC support. In this case, you can still use this client and, at the same time, have this client participating in Spring managed transactions. It is generally preferable to integrate a R2DBC client with proper access to ConnectionFactoryUtils for resource management.

 

만약 사용하는 R2DBC 클라이언트가 Spring의 R2DBC 지원 기능과 직접 통합되어 있지 않다면, R2dbcTransactionManager반드시 직접 사용해야 합니다.

 

이렇게 하면, 그 클라이언트가 Spring이 관리하는 트랜잭션 안에서 함께 동작할 수 있게 만들 수 있습니다.

즉, Spring 트랜잭션의 흐름 안에서 트랜잭션 커넥션을 공유하거나 연동할 수 있다는 뜻입니다.

 

Using R2dbcTransactionManager

The R2dbcTransactionManager class is a ReactiveTransactionManager implementation for a single R2DBC ConnectionFactory. It binds an R2DBC Connection from the specified ConnectionFactory to the subscriber Context, potentially allowing for one subscriber Connection for each ConnectionFactory.

Application code is required to retrieve the R2DBC Connection through ConnectionFactoryUtils.getConnection(ConnectionFactory), instead of R2DBC’s standard ConnectionFactory.create(). All framework classes (such as DatabaseClient) use this strategy implicitly. If not used with a transaction manager, the lookup strategy behaves exactly like ConnectionFactory.create() and can therefore be used in any case.

 

R2dbcTransactionManager리액티브 환경에서 트랜잭션을 관리해주는 클래스입니다.

하나의 ConnectionFactory에 대해 작동하는 ReactiveTransactionManager 구현체고, 해당 ConnectionFactory에서 얻은 커넥션을 리액티브 스트림의 Context에 묶어서 관리해줍니다. 이렇게 하면, 각 ConnectionFactory에 대해 구독자(Subscriber)마다 하나의 커넥션을 사용할 수 있게 됩니다.

 

애플리케이션 코드에서 R2DBC 커넥션을 직접 얻고 싶을 때는

ConnectionFactory.create()를 사용하지 말고, 반드시 ConnectionFactoryUtils.getConnection(ConnectionFactory) 를 사용해야 합니다.

 

왜냐하면 getConnection()은 트랜잭션 매니저가 관리하는 커넥션을 Context에서 찾아서 가져오기 때문입니다.

 

Spring 프레임워크의 주요 클래스들(예: DatabaseClient)은 이미 내부적으로 이 방식을 사용하고 있기 때문에

별도 설정 없이도 트랜잭션에 잘 연동됩니다.

 

트랜잭션 매니저 없이 사용할 경우, ConnectionFactoryUtils.getConnection()

그냥 ConnectionFactory.create()처럼 동작하므로 트랜잭션을 쓰든 안 쓰든 안전하게 쓸 수 있는 방식이라고 보면 됩니다.

 

 

정리

  • DatabaseClient는 R2DBC의 중심 도구로, 리액티브 방식의 SQL 실행과 자원 관리를 안전하고 선언적으로 처리할 수 있게 해줍니다.
  • ConnectionFactory는 R2DBC 드라이버의 진입점이며, 커넥션 풀과 트랜잭션 관리를 프레임워크가 대신 처리하도록 해줍니다.
  • SQL 실행 시에는 ConnectionFactoryUtils.getConnection()을 통해 트랜잭션 Context에 묶인 커넥션을 안전하게 얻어야 합니다.
  • StatementFilterFunction을 사용하면 쿼리 실행 전 Statement를 수정하거나, 실행 후 결과(Result)를 가공하는 전후처리가 가능합니다.
  • R2dbcTransactionManager를 사용하면 리액티브 트랜잭션이 Subscriber Context와 연동되어 작동하며, Spring의 트랜잭션 체계에 자연스럽게 통합됩니다.