스프링 프레임워크 데이터 액세스 공식 레퍼런스를 한글로 번역한 문서입니다.
전체 목차는 여기에 있습니다.
목차
- 4.1. Package Hierarchy
- 4.2. Using the R2DBC Core Classes to Control Basic R2DBC Processing and Error Handling
R2DBC(“Reactive Relational Database Connectivity”)는 SQL 데이터베이스에 접근하는 리액티브 패턴 표준화에 기여한 커뮤니티 주도 스펙이다.
4.1. Package Hierarchy
스프링의 R2DBC 추상화 프레임워크엔 두 종류의 패키지가 있다:
core
: org.springframework.r2dbc.core 패키지엔DatabaseClient
클래스와 이와 관련된 여러 가지 클래스가 들어있다. R2DBC 코어 클래스로 기본 R2DBC 동작과 에러 처리 제어하기를 참고해라.connection
: org.springframework.r2dbc.connection 패키지엔ConnectionFactory
접근을 위한 유틸리티 클래스와, R2DBC 코드를 수정하지 않고 그대로 테스트하고 실행할 수 있는 간단한ConnectionFactory
구현체가 다양하게 준비돼 있다. 데이터베이스 커넥션 제어하기를 참고해라.
4.2. Using the R2DBC Core Classes to Control Basic R2DBC Processing and Error Handling
이번 섹션에선 R2DBC 코어 클래스로 에러 처리를 포함한 기본 R2DBC 동작을 제어하는 방법을 설명한다. 여기서는 다음과 같은 주제를 다룬다:
DatabaseClient
사용하기- 구문 실행하기
- 질의하기 (
SELECT
) DatabaseClient
로 업데이트하기 (INSERT
,UPDATE
,DELETE
)- 구문 필터
- 자동 생성한 키 가져오기
4.2.1. Using DatabaseClient
DatabaseClient
는 R2DBC 코어 패키지의 핵심 클래스다. 리소스 생성과 해제를 알아서 처리해주기 때문에 커넥션 종료를 잊는 등의 흔한 에러를 방지할 수 있다. 코어 R2DBC 워크플로우의 기본 작업(구문 생성과 실행같은)을 수행하므로, 어플리케이션 코드에선 SQL을 제공하고 결과를 추출하기만 하면 된다. DatabaseClient
클래스는 다음과 같은 작업을 수행한다:
- SQL 쿼리를 실행한다.
- 구문을 업데이트하고 저장 프로시저를 호출한다.
Result
인스턴스를 반복 처리한다.- R2DBC exception을 잡아,
org.springframework.dao
패키지에 있는 더 많은 정보를 제공하는 범용 exception 계층 구조로 변환한다. (예외 계층 구조 참고.)
DatabaseClient
는 선언적으로 설정을 만들 수 있는 함수형 fluent API를 가지고 있으며, 리액티브 타입을 사용한다.
DatabaseClient
를 사용하기로 했다면, java.util.function
인터페이스 동작만 정확하게 구현하면 된다. Function
콜백 인터페이스는 DatabaseClient
클래스가 제공하는 Connection
을 받아 Publisher
를 만든다. Row
결과를 추출하는 매핑 펑션도 마찬가지다.
DatabaseClient
는 DAO 구현체 안에서도 사용할 수 있다. 이 때는 ConnectionFactory
를 참조해 직접 DatabaseClient
인스턴스를 만들어도 되고, DatabaseClient
를 스프링 IoC 컨테이너 빈으로 설정해서 DAO에 주입해도 된다.
DatabaseClient
객체는 스태틱 팩토리 메소드로 만드는 게 가장 간단하다:
DatabaseClient client = DatabaseClient.create(connectionFactory);
val client = DatabaseClient.create(connectionFactory)
ConnectionFactory
는 항상 스프링 IoC 컨테이너의 빈으로 설정해야 한다.
위에 있는 메소드는 디폴트 설정으로 DatabaseClient
를 생성한다.
DatabaseClient.builder()
로 Builder
인스턴스를 가져오는 방법도 있다. 다음과 같은 메소드로 클라이언트를 커스텀할 수 있다:
….bindMarkers(…)
: named 파라미터를 데이터베이스 바인드 마커로 변환하기 위한BindMarkersFactory
를 지정한다.….executeFunction(…)
:Statement
객체를 실행시키는ExecuteFunction
을 설정한다.….namedParameters(false)
: named 파라미터 치환을 비활성화한다. 디폴트는 활성화되어 있다.
방언(dialect)은
BindMarkersFactoryResolver
가ConnectionFactory
를 통해 리졸브하며, 보통은ConnectionFactoryMetadata
를 검사한다. 자체BindMarkersFactory
는META-INF/spring.factories
에 org.springframework.r2dbc.core.binding.BindMarkersFactoryResolver$BindMarkerFactoryProvider를 구현한 클래스를 동록하면 스프링이 자동으로 발견할 수 있다.BindMarkersFactoryResolver
는 스프링의SpringFactoriesLoader
를 사용해서 클래스패스에 있는 바인드 마커 프로바이더 구현체를 찾는다.
현재 지원하는 데이터베이스는 다음과 같다:
- H2
- MariaDB
- Microsoft SQL Server
- MySQL
- Postgres
DatabaseClient
클래스가 발행한 모든 SQL은, 클라이언트 인스턴스(보통 DefaultDatabaseClient
)의 클래스 풀 네임에 해당하는 범주 아래 DEBUG
레벨 로그를 남긴다. 더불어, 실행할 때마다 리액티브 시퀀스 디버깅을 위한 체크포인트를 등록한다.
다음 섹션에선 DatabaseClient
를 사용하는 몇 가지 예시를 제공한다. 이 예제에서 보여주는 DatabaseClient
기능이 전부는 아니다. 전체 기능은 javadoc을 참고해라.
Executing Statements
DatabaseClient
는 구문을 실행하는 기본 기능을 제공한다. 다음 예제는 최소한의 코드긴 하지만, 새 테이블을 만드는 완전한 기능을 갖춘 코드다:
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
컬럼을 조회한다:
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()
다음은 변수를 바인딩하는 쿼리다:
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) 연산자다.
first()
는 전체 결과 중에서 첫 번째 row만 반환한다. 코틀린 코루틴 버전은, null일 수 없는 값은awaitSingle()
, 값이 없을 수도 있으면awaitSingleOrNull()
이다.one()
은 row를 정확히 한 개만 반환하며, 결과가 더 있으면 실패한다. 코틀린 코루틴을 사용한다면, 값이 정확히 하나일 땐awaitOne()
, 값이null
일 수도 있을 땐awaitOneOrNull()
을 사용해라.all()
은 결과에 있는 모든 row를 반환한다. 코틀린 코루틴을 사용한다면flow()
를 사용해라.rowsUpdated()
는 영향받은 row 수를 반환한다 (INSERT
/UPDATE
/DELETE
카운트). 코틀린 코루틴 버전은awaitRowsUpdated()
다.
별다른 매핑 정보를 지정하지 않으면, 쿼리는 테이블 구조의 결과를 Map
으로 반환한다. 이때 키는 대소문자를 구분하지 않은 컬럼 이름이며, 값에는 컬럼에 해당하는 값을 저장한다.
각 Row
마다 호출할 Function<Row, T>
를 제공하면 임의의 값(단일 값, 컬렉션, 맵, 객체)을 반환하도록 결과 매핑을 제어할 수 있다.
다음 예제는 id
컬럼을 추출해서 그 값을 방출한다:
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
구문 예제다:
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 구문을 사용해 파라미터를 대치하고 구문을 실행한다.
파라미터 바인딩은 두 가지 전략을 지원한다:
- 0부터 시작하는 파라미터 인덱스 사용
- 플레이스홀더 이름 사용
다음은 쿼리에 파라미터를 바인딩하는 예제다:
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))
이 쿼리는 다음과 같이 파라미터를 적용해 실행할 수 있다:
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
판단식을 사용하는 더 간단한 코드다:
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
)를 등록해서 실행 중 구문을 가로채고 수정할 수 있다. 다음 예제를 참고해라:
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", …)
DatabaseClient
는 Function<Statement, Statement>
를 받는 더 간단한 filter(…)
메소드도 오버로드하고 있다:
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
구현체로는 Statement
와 Result
객체를 필터링할 수 있다.
DatabaseClient
Best Practices
DatabaseClient
클래스는 인스턴스를 설정하고나면 thread-safe하다. 여기서 중요한 점은 설정에 DatabaseClient
단일 인스턴스를 추가하고, DAO(또는 레포지토리) 여러 개에 같은 인스턴스 참조를 공유할 수 있다는 거다. DatabaseClient
는 ConnectionFactory
참조를 유지한다는 점에서는 stateful이지만, ConnectionFactory
로 조회하는 커넥션은 스레드마다 독립적이다.
DatabaseClient
클래스를 사용할 땐 보통 스프링 설정 파일로 ConnectionFactory
를 설정하고, 공유된 ConnectionFactory
빈을 DAO 클래스에 의존성으로 주입한다. DatabaseClient
는 ConnectionFactory
setter에서 만든다. 따라서 DAO는 다음과 유사하게 만들 수 있다:
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
를 선언한다. 다음 예제를 참고해라:
@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
인스턴스를 다르게 설정해야 할 수는 있다.
Next :Retrieving Auto-generated Keys
스프링 프레임워크 R2DBC로 자동 생성 키를 가져오는 방법과 데이터 베이스 커넥션을 제어하는 방법을 설명합니다.
전체 목차는 여기에 있습니다.