[Spring-Reactive] Spring Data R2DBC
in Spring on Spring
Spring Data R2DBC
R2DBC란?
- R2DBC (Reactive Relational Database Connectivity): 관계형 DB를 위한 비동기/논블로킹 데이터 접근 API.
- JDBC는 Blocking API이기 때문에 WebFlux와 부적합 → R2DBC가 이를 대체.
- R2DBC를 통해 관계형 DB에서도 완전한 리액티브 애플리케이션 구현 가능.
지원 데이터베이스 (2022년 기준)
- H2, MySQL, MariaDB, Postgres, Oracle, MSSQL 등
Spring Data R2DBC란?
- Spring Data R2DBC는 R2DBC 기반 Repository를 쉽게 구현하게 해주는 Spring Data Family의 일부.
- 기존 JPA와 유사한 추상화 제공 (Entity, Repository 등).
- 캐싱, 지연 로딩(lazy loading) 등의 JPA 기능은 미지원이지만, 단순하고 일관된 방식으로 사용 가능.
- 데이터 액세스 계층을 Spring 방식으로 통일 가능.
Spring Data R2DBC 설정
- Gradle 의존성 추가
- SQL 스키마 파일 작성
- application.yml에서 schema.sql 로드 설정
- 애플리케이션 클래스에 @EnableR2dbcRepositories, @EnableR2dbcAuditing 추가
1. Gradle 설정
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
runtimeOnly 'io.r2dbc:r2dbc-h2'
...
}
2. 테이블 스키마 정의 (예: schema.sql)
CREATE TABLE IF NOT EXISTS BOOK (
BOOK_ID BIGINT NOT NULL AUTO_INCREMENT,
TITLE_KOREAN VARCHAR(100) NOT NULL,
TITLE_ENGLISH VARCHAR(100) NOT NULL,
DESCRIPTION VARCHAR(100) NOT NULL,
AUTHOR VARCHAR(100) NOT NULL,
ISBN VARCHAR(100) NOT NULL UNIQUE,
PUBLISH_DATE VARCHAR(100) NOT NULL,
CREATED_AT DATETIME NOT NULL,
LAST_MODIFIED_AT DATETIME NOT NULL,
PRIMARY KEY (BOOK_ID)
);
3. application.yml 설정
spring:
sql:
init:
schema-locations: classpath*:db/h2/schema.sql
logging:
level:
org:
springframework:
r2dbc: DEBUG
- 위 설정은
src/main/resources/db/h2/schema.sql
을 자동 실행하도록 함 - 로그 레벨을 DEBUG로 지정해 실행 시 쿼리 확인 가능
4. R2DBC Repository 및 Auditing 활성화
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories;
import org.springframework.data.r2dbc.config.EnableR2dbcAuditing;
@EnableR2dbcRepositories
@EnableR2dbcAuditing
@SpringBootApplication
public class Chapter18Application {
public static void main(String[] args) {
SpringApplication.run(Chapter18Application.class, args);
}
}
@EnableR2dbcRepositories
: R2DBC Repository 스캔 활성화@EnableR2dbcAuditing
: 생성일/수정일 자동 처리 활성화
Spring Data R2DBC의 도메인 엔티티 클래스 매핑
- @Id: 기본 키 필드 지정 (필수)
- @CreatedDate, @LastModifiedDate: 생성 및 수정 일시 자동 반영 (Auditing)
- @Column: 필드와 DB 컬럼 명 다를 때 사용
- @Table은 명시하지 않으면 클래스명이 테이블명으로 사용됨
// 예제 코드
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Setter
public class Book {
@Id
private long bookId;
private String titleKorean;
private String titleEnglish;
private String description;
private String author;
private String isbn;
private String publishDate;
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
@Column("last_modified_at")
private LocalDateTime modifiedAt;
}
R2DBC Repositories를 이용한 데이터 엑세스
- Repository 정의
ReactiveCrudRepository<Book, Long>
상속만 하면 기본 CRUD 기능을 사용할 수 있으며,findByIsbn(String isbn)
처럼 커스텀 쿼리 메서드도 정의 가능함.
- 서비스 클래스 구현
saveBook()
: ISBN 중복 여부 확인 후 저장updateBook()
: 기존 데이터 조회 후 일부 필드만 복사해서 업데이트findBook()
: ID 기준 조회findBooks()
: 전체 목록 조회- 모든 메서드는
Mono
,Flux
를 리턴하여 Non-Blocking하게 처리
- 핸들러 클래스 구현
- 요청 본문(
request.bodyToMono(...)
)을 DTO로 변환하고 유효성 검사 후 BookService
의 메서드를 호출해 DB와 상호작용- 결과는
ServerResponse
로 반환
BookRepository.java
public interface BookRepository extends ReactiveCrudRepository<Book, Long> {
Mono<Book> findByIsbn(String isbn);
}
BookService.java
@Slf4j
@Service("bookServiceV5")
@RequiredArgsConstructor
public class BookService {
private final BookRepository bookRepository;
private final CustomBeanUtils<Book> beanUtils;
public Mono<Book> saveBook(Book book) {
return verifyExistIsbn(book.getIsbn()) // ISBN 중복 체크
.then(bookRepository.save(book)); // 중복 없으면 저장
}
public Mono<Book> updateBook(Book book) {
return findVerifiedBook(book.getBookId())
.map(findBook -> beanUtils.copyNonNullProperties(book, findBook))
.flatMap(updatingBook -> bookRepository.save(updatingBook));
}
public Mono<Book> findBook(long bookId) {
return findVerifiedBook(bookId);
}
public Mono<List<Book>> findBooks() {
return bookRepository.findAll().collectList();
}
private Mono<Void> verifyExistIsbn(String isbn) {
return bookRepository.findByIsbn(isbn) // DB에 ISBN이 있는지 찾아봄
.flatMap(findBook -> { // ISBN이 존재하면 예외
return Mono.error(new BusinessLogicException(ExceptionCode.BOOK_EXISTS));
})
.switchIfEmpty(Mono.empty()); //존재하지 않으면
}
private Mono<Book> findVerifiedBook(long bookId) {
return bookRepository.findById(bookId)
.switchIfEmpty(Mono.error(new BusinessLogicException(ExceptionCode.BOOK_NOT_FOUND)));
}
}
BookHandler.java (BookHandlerV5)
@Slf4j
@Component("bookHandlerV5")
public class BookHandler {
...
private final BookService bookService;
...
public Mono<ServerResponse> createBook(ServerRequest request) {
return request.bodyToMono(BookDto.Post.class)
.doOnNext(post -> validator.validate(post))
.flatMap(post -> bookService.saveBook(mapper.bookPostToBook(post)))
.flatMap(book -> ServerResponse
.created(URI.create("/v5/books/" + book.getBookId()))
.build());
}
public Mono<ServerResponse> updateBook(ServerRequest request) {
final long bookId = Long.valueOf(request.pathVariable("book-id"));
return request.bodyToMono(BookDto.Patch.class)
.doOnNext(patch -> validator.validate(patch))
.flatMap(patch -> {
patch.setBookId(bookId);
return bookService.updateBook(mapper.bookPatchToBook(patch));
})
.flatMap(book -> ServerResponse.ok()
.bodyValue(mapper.bookToResponse(book)));
}
public Mono<ServerResponse> getBook(ServerRequest request) {
long bookId = Long.valueOf(request.pathVariable("book-id"));
return bookService.findBook(bookId)
.flatMap(book -> ServerResponse.ok()
.bodyValue(mapper.bookToResponse(book)));
}
public Mono<ServerResponse> getBooks(ServerRequest request) {
return bookService.findBooks()
.flatMap(books -> ServerResponse.ok()
.bodyValue(mapper.booksToResponse(books)));
}
}
R2dbcEntityTemplate을 이용한 데이터 액세스
- R2dbcEntityTemplate은 Repository 없이 SQL 쿼리 중심으로 R2DBC를 사용할 수 있는 유연한 방법이다.
- JdbcTemplate과 유사하지만 Reactive 방식이며, SQL을 직접 구성하거나 Criteria DSL로 표현할 수 있다.
- selectOne, insert, update, delete 등 다양한 메서드로 데이터 조작 가능.
- Criteria와 Terminating method를 조합하여 쿼리 조건 지정 및 실행 가능.
BookService.java
@Slf4j
@Service("bookServiceV6")
@RequiredArgsConstructor
public class BookService {
private final @NonNull R2dbcEntityTemplate template;
private final @NonNull CustomBeanUtils<Book> beanUtils;
public Mono<Book> saveBook(Book book) {
return verifyExistIsbn(book.getIsbn())
.then(template.insert(book));
}
public Mono<Book> updateBook(Book book) {
return findVerifiedBook(book.getBookId())
.map(findBook -> beanUtils.copyNonNullProperties(book, findBook))
.flatMap(updatingBook -> template.update(updatingBook));
}
public Mono<Book> findBook(long bookId) {
return findVerifiedBook(bookId);
}
public Mono<List<Book>> findBooks() {
return template.select(Book.class).all().collectList();
}
private Mono<Void> verifyExistIsbn(String isbn) {
return template.selectOne(query(where("ISBN").is(isbn)), Book.class)
.flatMap(findBook -> {
if (findBook != null) {
return Mono.error(new BusinessLogicException(
ExceptionCode.BOOK_EXISTS));
}
return Mono.empty();
});
}
private Mono<Book> findVerifiedBook(long bookId) {
return template.selectOne(query(where("BOOK_ID").is(bookId)), Book.class)
.switchIfEmpty(Mono.error(new BusinessLogicException(
ExceptionCode.BOOK_NOT_FOUND)));
}
}
Method
Method | 설명 |
---|---|
first() | 첫 row만 |
one() | 단일 결과 |
all() | 모든 결과 |
count() | 개수 |
exists() | 존재 여부 |
Criteria
Criteria method | SQL 연산자 |
---|---|
is(...) | = |
and(...) | AND |
or(...) | OR |
greaterThan(...) | > |
lessThanOrEquals(...) | <= |
in(...) | IN |
isNull() | IS NULL |
like(...) | LIKE |
Spring Data R2DBC에서의 페이징 처리
- Spring Data R2DBC Repository로 페이징 처리
- 기존 Pageable 객체를 그대로 사용해 페이징 처리 가능
- 반환 타입은 Flux
(비동기 스트림) - PageRequest.of(page - 1, size, Sort.by(“컬럼명”).descending()) 형식 사용
- R2dbcEntityTemplate로 직접 페이징 처리
- .limit(), .offset() 같은 메서드 대신 Reactor의 skip(), take() 사용
- .count()로 총 개수 구한 뒤, Tuple2로 skip, take 계산
- 페이징 로직을 직접 구현할 수 있어 유연한 조작 가능
BookRepository.java
public interface BookRepository extends ReactiveCrudRepository<Book, Long> {
Mono<Book> findByIsbn(String isbn);
Flux<Book> findAllBy(Pageable pageable);
}
Repository 방식 페이징
@Slf4j
@Service("bookServiceV7")
@RequiredArgsConstructor
public class BookService {
private final @NonNull BookRepository bookRepository;
public Mono<List<Book>> findBooks(@Positive int page, @Positive int size) {
return bookRepository
.findAllBy(PageRequest.of(page - 1, size, Sort.by("memberId").descending()))
.collectList();
}
...
}
R2dbcEntityTemplate + Reactor Operator 방식 페이징
@Slf4j
@Validated
@Service("bookServiceV8")
@RequiredArgsConstructor
public class BookService {
private final @NonNull R2dbcEntityTemplate template;
private final @NonNull CustomBeanUtils<Book> beanUtils;
...
public Mono<List<Book>> findBooks(@Positive long page, @Positive long size) {
return template
.select(Book.class)
.count()
.flatMap(total -> {
Tuple2<Long, Long> skipAndTake = getSkipAndTake(total, page, size);
return template
.select(Book.class)
.all()
.skip(skipAndTake.getT1())
.take(skipAndTake.getT2())
.collectSortedList((Book b1, Book b2) ->
(int)(b2.getBookId() - b1.getBookId())
);
});
}
private Tuple2<Long, Long> getSkipAndTake(long total, long movePage, long size) {
long totalPages = (long) Math.ceil((double) total / size);
long page = movePage > totalPages ? totalPages : movePage;
long skip = total - (page * size) < 0 ? 0 : total - (page * size);
long take = total - (page * size) < 0 ? total - ((page - 1) * size) : size;
return Tuples.of(skip, take);
}
}