-
Spring Data R2DBCSpring Reactive Web Application/Spring WebFlux 2023. 9. 9. 07:00반응형
R2DBC
R2DBC(Reactive Relational Database Connectivity)는 관계형 데이터베이스에 리액티브 프로그래밍 API를 제공하기 위한 개방형 사양(Specification)이면서, 드라이버 벤더가 구현하고 클라이언트가 사용하기 위한 SPI(Service Provider Interface)입니다.
Note. 2022년 7월 기준 R2DBC를 지원하는 관계형 데이터베이스는 아래와 같습니다.
- H2
- MySQL
- jasync-sql MySQL
- MariaDB
- Microsoft SQL Server
- Postgres
- Oracle
- Cloud Spanner(Google Cloud Platform)
Spring Data R2DBC
Spring Data R2DBC는 R2DBC 기반 Repository를 좀 더 쉽게 구현하게 해주는 Spring Data Family 프로젝트의 일부입니다. Spring Data R2DBC는 Spring이 추구하는 추상화 기법이 적용되어 데이터 액세스 계층의 보일러플레이트(boilerplate) 코드의 양을 대폭 줄일 수 있습니다.
Spring Data R2DBC 설정
데이터베이스는 H2 인메모리 DB를 사용합니다.
build.gradle 설정
implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'io.projectreactor:reactor-test' implementation 'org.mapstruct:mapstruct:1.5.1.Final' annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.1.Final' runtimeOnly 'io.r2dbc:r2dbc-h2'
테이블 스키마 정의
Spring Data R2DBC는 Spring Data JPA처럼 엔티티에 정의된 매핑 정보로 데이터를 자동 생성해 주는 기능이 없기에 테이블 스크립트를 직접 작성해서 테이블을 생성해야 합니다.
'src/main/resources/db/h2' 디렉터리에 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) );
application.yml 파일 설정
애플리케이션 실행 시점에 'src/main/resources/db/h2/schema.sql' 파일을 읽어 테이블을 생성하도록 아래와 같이 프로퍼티 설정을 추가합니다. R2DBC의 로그 레벨을 DEBUG 모드로 지정합니다.
spring: sql: init: schema-locations: classpath*:db/h2/schema.sql data-locations: classpath*:db/h2/data.sql logging: level: org: springframework: r2dbc: DEBUG
R2DBC Repository와 Auditing 기능 활성화
다음과 같이 애플리케이션의 엔트리포인트 클래스에 @EnableR2dbcRepositories와 @EnableR2dbcAuditing을 추가합니다.
import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.r2dbc.config.EnableR2dbcAuditing; import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories; @EnableR2dbcRepositories @EnableR2dbcAuditing @SpringBootApplication public class Chapter18Application { public static void main(String[] args) { SpringApplication.run(Chapter18Application.class, args); } }
Spring Data R2DBC 도메인 엔티티 클래스 매핑
다음은 BOOK 테이블과 매핑되는 Book 도메인 엔티티 클래스 코드입니다.
import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.Id; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.relational.core.mapping.Column; import java.time.LocalDateTime; @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; }
- @Id: 테이블의 기본키에 해당하는 필드입니다.
- 클래스 레벨에 @Table 애너테이션을 생략하면 기본적으로 클래스 이름을 테이블 이름으로 사용합니다.
- 생성 일시와 수정 일시를 자동으로 테이블에 반영하기 위해 @CreatedDate, @LastModifiedDate 애너테이션을 추가하여 Auditing 기능을 적용합니다.
R2DBC Repositories를 이용한 데이터 액세스
Spring Data R2DBC는 다른 Spring Data 패밀리 프로젝트와 마찬가지로 Spring에서 추상화된 데이터 액세스 기술을 손쉽게 이용할 수 있는 Repository를 지원합니다.
다음은 BOOK 테이블과 인터랙션하기 위한 R2DBC Repository입니다. 리액티브를 지원하는 ReactiveCrudRepository를 상속하며, 쿼리 메서드의 리턴 타입은 Mono 또는 Flux입니다.
import org.springframework.data.repository.reactive.ReactiveCrudRepository; import org.springframework.stereotype.Repository; import reactor.core.publisher.Mono; @Repository("bookRepositoryV5") public interface BookRepository extends ReactiveCrudRepository<Book, Long> { Mono<Book> findByIsbn(String isbn); }
서비스 클래스
다음은 BookRepository를 사용해 데이터베이스와 인터랙션하는 BookService 클래스의 코드입니다.
import com.itvillage.exception.BusinessLogicException; import com.itvillage.exception.ExceptionCode; import com.itvillage.utils.CustomBeanUtils; import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; import java.util.List; @Slf4j @Service("bookServiceV5") @RequiredArgsConstructor public class BookService { private final @NonNull BookRepository bookRepository; private final @NonNull CustomBeanUtils<Book> beanUtils; public Mono<Book> saveBook(Book book) { return verifyExistIsbn(book.getIsbn()) .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) .flatMap(findBook -> { if (findBook != null) { return Mono.error(new BusinessLogicException( ExceptionCode.BOOK_EXISTS)); } return Mono.empty(); }); } private Mono<Book> findVerifiedBook(long bookId) { return bookRepository .findById(bookId) .switchIfEmpty(Mono.error(new BusinessLogicException( ExceptionCode.BOOK_NOT_FOUND))); } }
핸들러 클래스
import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import java.net.URI; @Slf4j @Component("BookHandlerV5") public class BookHandler { private final BookMapper mapper; private final BookValidator validator; private final BookService bookService; public BookHandler(BookMapper mapper, BookValidator validator, BookService bookService) { this.mapper = mapper; this.validator = validator; this.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))); } }
Pagination
페이지네이션 처리는 다른 Spring Data 패밀리 프로젝트에서의 페이지네이션 처리와 별반 다를 게 없습니다. 단, Pageable을 이용해서 페이지네이션을 적용하는 방법은 동일하지만 리턴 타입이 List<Book>이 아니라 Flux<Book> 이라는 차이점이 있습니다.
import org.springframework.data.domain.Pageable; import org.springframework.data.repository.reactive.ReactiveCrudRepository; import org.springframework.stereotype.Repository; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @Repository("bookRepositoryV7") public interface BookRepository extends ReactiveCrudRepository<Book, Long> { Mono<Book> findByIsbn(String isbn); Flux<Book> findAllBy(Pageable pageable); }
import com.itvillage.exception.BusinessLogicException; import com.itvillage.exception.ExceptionCode; import com.itvillage.utils.CustomBeanUtils; import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; import javax.validation.constraints.Positive; import java.util.List; /** * 페이지네이션 적용 */ @Slf4j @Service("bookServiceV7") @RequiredArgsConstructor public class BookService { private final @NonNull BookRepository bookRepository; private final @NonNull CustomBeanUtils<Book> beanUtils; ... public Mono<List<Book>> findBooks(@Positive int page, @Positive int size) { return bookRepository .findAllBy(PageRequest.of(page - 1, size, Sort.by("memberId").descending())) .collectList(); } ... }
[참고 정보]
반응형'Spring Reactive Web Application > Spring WebFlux' 카테고리의 다른 글
예외 처리 (0) 2023.09.16 R2dbcEntityTemplate (0) 2023.09.10 함수형 엔드포인트(Functional Endpoint) (0) 2023.08.26 애너테이션 기반 컨트롤러(Annotated Controller) (0) 2023.08.26 Spring WebFlux 개요 (0) 2023.08.22