Spring WebFlux와 Kotlin으로 만드는 Todo 서비스 – 2편
- Spring WebFlux와 Kotlin으로 만드는 Todo 서비스 – 1편
- Spring WebFlux와 Kotlin으로 만드는 Todo 서비스 – 2편
- Spring WebFlux와 Kotlin으로 만드는 Todo 서비스 – 테스트 슬라이스 적용하기
개요
오늘 예제에서는 Spring WebFlux와 Kotlin으로 만드는 Todo 서비스 - 1편 에서 만들어본 예제를 기반으로 JDBC를 대체하는 R2DBC를 적용해보고 그 둘의 차이점과 R2DBC란 무엇인지 어떤 장단점이 있는지 알아보도록 하겠습니다.
R2DBC
피보탈에서 개발 중인 R2DBC는 Reactive Relational Database Connectivity의 약자로써, 작년 SpringOne Platform 2018에서 처음 발표 되었습니다. 이름에서도 추측 가능하듯이 리액티브 프로그래밍을 가능하게 하는 데이터베이스 인터페이스입니다. 그 말은 즉, JDBC에선 아직 지원하지 않는 비동기(asynchronous), 논 블로킹(non-blocking) 프로그래밍 모델을 지원한다는 이야기이고, 이는 Spring WebFlux의 성능을 최대치로 끌어올릴 수 있다는 이야기가 됩니다. 이 글을 쓰고 있는 시점에선 마일스톤 버전이 배포되고 있습니다. 그 때문에 실무에서의 적용은 부담이 있을 수 있지만 곧 정식 버전이 나올 것으로 보이며 점차 레퍼런스가 늘어날 것으로 기대하고 있습니다.
Spring Data R2DBC
먼저 설명한 대로 R2DBC는 피보탈의 주도로 개발 중입니다. 그렇기 때문에 스프링 프로젝트에서 아주 좋은 궁합을 보여줍니다. R2DBC는 Spring Data R2DBC를 통해 기존 Spring Boot 프로젝트에 쉽게 통합될 수 있고, 다른 Spring Data 프로젝트들이 다르지 않듯이 데이터베이스 연동에 대한 뛰어난 추상화를 제공합니다. 이번 예제에선 Spring Data R2DBC를 적용하면서 JpaRepository 인터페이스를 걷어내고, WebFlux의 Flux와 Mono를 지원하는 ReactiveCrudRepository 인터페이스를 이용하겠습니다.
ReactiveCrudRepository 살펴보기
앞서 ReactiveCrudRepository라는 인터페이스에 대해 간략히 언급했었습니다. ReactiveCrudRepository는 리액티브 스트림을 지원하는 CRUD 메서드들을 포함하는 인터페이스 입니다. 내부를 확인해보면 기존의 다른 Spring Data의 리파지토리 인터페이스인 CrudRepository와 크게 다르지 않습니다. 다만 전통적인 CrudRepository와 다른 점은 리턴 타입이 Flux 또는 Mono라는 것과 파라미터에 Publisher가 추가되었다는 점이 다를 뿐입니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
package org.springframework.data.repository.reactive; import org.reactivestreams.Publisher; import org.springframework.data.repository.NoRepositoryBean; import org.springframework.data.repository.Repository; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @NoRepositoryBean public interface ReactiveCrudRepository<T, ID> extends Repository<T, ID> { <S extends T> Mono<S> save(S var1); <S extends T> Flux<S> saveAll(Iterable<S> var1); <S extends T> Flux<S> saveAll(Publisher<S> var1); Mono<T> findById(ID var1); Mono<T> findById(Publisher<ID> var1); Mono<Boolean> existsById(ID var1); Mono<Boolean> existsById(Publisher<ID> var1); Flux<T> findAll(); Flux<T> findAllById(Iterable<ID> var1); Flux<T> findAllById(Publisher<ID> var1); Mono<Long> count(); Mono<Void> deleteById(ID var1); Mono<Void> deleteById(Publisher<ID> var1); Mono<Void> delete(T var1); Mono<Void> deleteAll(Iterable<? extends T> var1); Mono<Void> deleteAll(Publisher<? extends T> var1); Mono<Void> deleteAll(); }
사용된 툴과 기술들
- Spring boot 2.2+
- Spring Data R2DBC
- Maven 3+
- Kotlin 1.3
- IDE - IntelliJ
- H2DB
프로젝트 세팅
pom.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.2.0.M6</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.digimon</groupId> <artifactId>todo-R2DBC</artifactId> <version>0.0.1-SNAPSHOT</version> <name>todo-R2DBC</name> <description>Demo project for Spring Boot</description> <properties> <java.version>1.8</java.version> <kotlin.version>1.3.50</kotlin.version> <spring-boot-data-r2dbc.version>0.1.0.BUILD-SNAPSHOT</spring-boot-data-r2dbc.version> </properties> <dependencies> <!-- Spring Boot --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>io.projectreactor</groupId> <artifactId>reactor-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot.experimental</groupId> <artifactId>spring-boot-starter-data-r2dbc</artifactId> <version>${spring-boot-data-r2dbc.version}</version> </dependency> <dependency> <groupId>org.springframework.boot.experimental</groupId> <artifactId>spring-boot-starter-r2dbc-h2</artifactId> <version>${spring-boot-data-r2dbc.version}</version> </dependency> <!-- Spring Boot --> <!-- Kotlin --> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-reflect</artifactId> </dependency> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib-jdk8</artifactId> <version>${kotlin.version}</version> </dependency> </dependencies> <build> <sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory> <testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> <plugin> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-maven-plugin</artifactId> <configuration> <args> <arg>-Xjsr305=strict</arg> </args> <compilerPlugins> <plugin>spring</plugin> </compilerPlugins> </configuration> <dependencies> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-maven-allopen</artifactId> <version>${kotlin.version}</version> </dependency> </dependencies> </plugin> </plugins> </build> <repositories> <repository> <id>spring-snapshots</id> <name>Spring Snapshots</name> <url>https://repo.spring.io/snapshot</url> <snapshots> <enabled>true</enabled> </snapshots> </repository> <repository> <id>spring-milestones</id> <name>Spring Milestones</name> <url>https://repo.spring.io/milestone</url> </repository> </repositories> </project>
제가 작성한 pom.xml 전체 코드입니다. 이 중에서 눈여겨보실 부분이 몇 군데 있습니다.
첫 번째는 "<version>2.2.0.M6</version>" 입니다. 글을 작성하는 시점에서 Spring Data R2DBC를 정식 지원하는 Spring Boot의 버전은 2.2+입니다. M6라는 작명으로 봐서 마일스톤 버전이라는 것을 알 수 있습니다.
1 2 3 4 5 6 7 8 9 10
<dependency> <groupId>org.springframework.boot.experimental</groupId> <artifactId>spring-boot-starter-data-r2dbc</artifactId> <version>${spring-boot-data-r2dbc.version}</version> </dependency> <dependency> <groupId>org.springframework.boot.experimental</groupId> <artifactId>spring-boot-starter-r2dbc-h2</artifactId> <version>${spring-boot-data-r2dbc.version}</version> </dependency>
두번째로 "org.springframework.boot.experimental" 라는 groupId가 눈에 띄실 것 입니다. experimental이라는 뜻답게 아직은 실험적인 프로젝트이므로, 정식 버전이 나오더라도 큰 틀이 변하진 않을 거라 예상되지만, 내부 구현이 조금 달라질 수 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
<repositories> <repository> <id>spring-snapshots</id> <name>Spring Snapshots</name> <url>https://repo.spring.io/snapshot</url> <snapshots> <enabled>true</enabled> </snapshots> </repository> <repository> <id>spring-milestones</id> <name>Spring Milestones</name> <url>https://repo.spring.io/milestone</url> </repository> </repositories>
마지막으로 마일스톤 라이브러리가 mavencentral에 올라가있지 않아서 리파지토리 경로를 아래와 같이 추가해주셔야 합니다.
resources/application.yml
1 2 3 4 5
spring: profiles: active: dev main: allow-bean-definition-overriding: true
application.yml의 설정은 지난 편과 거의 동일합니다. 단지 spring.jpa.show-sql 설정을 R2DBC에서 지원하지 않으므로 제거하였습니다.
resources/schema.sql
1 2 3 4 5 6 7 8 9
drop table if exists todos; create table todos ( id bigint generated by default as identity, content varchar(2000), created_at timestamp, done boolean, modified_at timestamp, primary key (id) );
R2DBC에선 ddl-auto 기능이 없어서 초기화 스크립트를 만들어주었습니다. schema.sql은 Spring Boot 프로젝트의 초기 스키마 생성 스크립트 스펙입니다. data.sql로 만들면 schema.sql에서 생성된 스키마를 기준으로 데이터를 insert 할 수 있습니다. 이 예제는 임베디드 데이터베이스를 사용 중이라 애플리케이션 실행 시 이 스크립트가 자동으로 돌아가지만, 임베디드 데이터베이스가 아니라면 운영환경에선 리스크가 있어서 spring.datasource.initialization-mode 설정으로 on/off 할 수 있습니다. 관련하여 자세한 내용은 링크를 확인해주세요. https://github.com/spring-projects-experimental/spring-boot-r2dbc/blob/master/documentation.adoc#user-content-database-initialization
스프링 부트 설정
src/main/kotlin/com/digimon/demo/config/AppConfig.kt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
package com.digimon.demo.config import io.r2dbc.h2.H2ConnectionConfiguration import io.r2dbc.h2.H2ConnectionFactory import io.r2dbc.spi.ConnectionFactory import org.springframework.context.annotation.Configuration import org.springframework.data.r2dbc.config.AbstractR2dbcConfiguration import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories @Configuration @EnableR2dbcRepositories internal class AppConfig : AbstractR2dbcConfiguration() { override fun connectionFactory(): ConnectionFactory = H2ConnectionFactory(H2ConnectionConfiguration.builder() .inMemory("todo") .build()) }
이 클래스는 AbstractR2dbcConfiguration의 기본 사양인 connectionFactory를 구현한 클래스입니다. 저는 임베디드 H2DB를 사용하기 때문에 H2ConnectionFactory를 빌드 했지만, PostgreSQL 등 R2DBC가 지원하는 다른 데이터베이스를 사용하실 경우 해당 데이터베이스의 ConnectionFactory만 구성해주시면 쉽게 변경됩니다.
도메인(Domain)
src/main/kotlin/com/digimon/demo/domain/Todo.kt
1 2 3 4 5 6 7 8 9 10 11 12 13
package com.digimon.demo.domain.todo import org.springframework.data.annotation.Id import org.springframework.data.relational.core.mapping.Table import java.time.LocalDateTime @Table("todos") data class Todo( @Id var id: Long? = null, var content: String? = null, var done: Boolean = false, var createdAt: LocalDateTime = LocalDateTime.now(), var modifiedAt: LocalDateTime = createdAt )
1편의 도메인 코드를 보셨다면 도메인 클래스도 변경이 있다는 걸 아실 수 있으실 겁니다. 우선 @Entity, @Column 애노테이션이 제거되었습니다. 이 애노테이션들은 JPA의 스펙이므로 R2DBC 사용시엔 불필요합니다. 두 번째로 class 앞에 붙은 data 키워드가 보이실 겁니다. data 키워드는 Kotlin에 존재하는 키워드로써, 선언한 필드를 기준으로 equals, toString, hashCode 등의 메서드를 자동 생성해줍니다. Java의 경우 이 메서드들을 직접 구현하던가 Lombok을 이용해 Annotation Processing 단계에서 자동 생성해줬었습니다. data 클래스는 한 가지 제약조건이 있는데 최소한 1개 이상의 필드를 초기화하는 기본 생성자가 필요합니다. 예제에서는 모든 필드를 생성자에서 선언해주었습니다.
리파지토리(Repository)
src/main/kotlin/com/digimon/demo/todo/TodoRepository.kt
1 2 3
package com.digimon.demo.domain.todo import org.springframework.data.repository.reactive.ReactiveCrudRepository interface TodoRepository : ReactiveCrudRepository<Todo, Long>
리파지토리는 딱 한 가지 변경되었습니다. 기존 JpaReqository를 제거하고 앞서 설명드린 ReactiveCrudRepository를 상속받도록 하였습니다. 이렇게 되면 findAll, save, delete 메서드 등을 리액티브 스트림으로 연결할 수 있게 됩니다.
핸들러(Handler)
src/main/kotlin/com/digimon/demo/handler/TodoHandler.kt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
package com.digimon.demo.handler import com.digimon.demo.domain.todo.Todo import com.digimon.demo.domain.todo.TodoRepository import org.springframework.http.HttpStatus import org.springframework.stereotype.Component import org.springframework.web.reactive.function.BodyInserters.fromObject import org.springframework.web.reactive.function.server.ServerRequest import org.springframework.web.reactive.function.server.ServerResponse import org.springframework.web.reactive.function.server.ServerResponse.* import reactor.core.publisher.Mono import java.net.URI import java.time.LocalDateTime import java.util.* import java.util.stream.Collectors.toList @Component class TodoHandler { private val repo: TodoRepository constructor(repo: TodoRepository) { this.repo = repo } fun getAll(req: ServerRequest): Mono<ServerResponse> = repo.findAll().filter(Objects::nonNull) .collect(toList()) .flatMap { ok().body(fromObject(it)) } fun getById(req: ServerRequest): Mono<ServerResponse> = repo.findById(req.pathVariable("id").toLong()) .flatMap { ok().body(fromObject(it)) } .switchIfEmpty(status(HttpStatus.NOT_FOUND).build()) fun save(req: ServerRequest): Mono<ServerResponse> = repo.saveAll(req.bodyToMono(Todo::class.java)) .flatMap { created(URI.create("/todos/${it.id}")).build() } .next() fun done(req: ServerRequest): Mono<ServerResponse> = repo.findById(req.pathVariable("id").toLong()) .filter(Objects::nonNull) .flatMap { todo -> todo.done = true todo.modifiedAt = LocalDateTime.now() repo.save(todo) } .flatMap { it?.let { ok().build() } } .switchIfEmpty(status(HttpStatus.NOT_FOUND).build()) fun delete(req: ServerRequest): Mono<ServerResponse> = repo.findById(req.pathVariable("id").toLong()) .filter(Objects::nonNull) .flatMap { todo -> ok().build(repo.deleteById(todo.id!!)) } .switchIfEmpty(status(HttpStatus.NOT_FOUND).build()) }
리파지토리가 변경되면서 새롭게 리팩토링 된 핸들러 클래스입니다. 더욱 단순하고 명확하게 바뀌었습니다.
첫 번째 변화는 404 Not Found의 추가입니다. 1편의 소스를 기반으로 애플리케이션을 실행했을 때 존재하지 않는 Todo를 요청하면 어떻게 될까요? 예를 들어 GET http://localhost:8080/todos/100 로 요청하면 스테이터스 코드 200 OK이면서 빈 본문 응답이 내려옵니다. 관점의 차이지만 저는 RESTful 한 설계를 선호하므로 404 Not Found를 반환해서 좀 더 시맨틱 한 응답을 구현하였습니다.
두 번째는 findAll, findById, save, delete 메서드를 수행할 때 Mono또는 Flux로 변환할 필요 없이 바로 리액티브 스트림을 연결하였다는 점이 이전 코드와 다릅니다. 이말은 즉 데이터베이스와의 모든 통신이 논 블로킹(non-blocking)으로 실행된다는 것입니다. 또한 스트림으로 연결된 함수의 동작은 Java8에 추가된 Stream API와 매우 유사하게 게으른 로딩(lazy loading)으로 지연 종결 연산자(terminal operations)에 의해 트리거 되기 전까진 무의미하게 코드가 실행되지 않습니다.
코드 해설
- getAll
- 전체 리스트를 조회하고 Flux의 2차원적인 데이터를 collect 함수를 이용해 데이터들을 Mono로 변환한 뒤 마지막으로 flatMap 안에서 응답 본문을 구성합니다.
- getById
- 파라미터로 들어온 id로 데이터를 조회하고 존재하면 flatMap에서 응답 본문을 구성합니다. 데이터가 존재하지 않는다면 404 Not Found를 발생시킵니다.
- save
- bodyToMono로 request의 body를 Todo 클래스에 매핑합니다. 그리고 정상적으로 데이터베이스에 생성 되었다면 201 Created 응답을 리턴합니다.
- done
- 파라미터로 들어온 id로 데이터를 조회하고 존재하면 첫 번째 flatMap에서 업데이트를 수행합니다. 두 번째 flatMap에선 kotlin의 let 구문을 이용해 정상적으로 업데이트되었다면 200 OK 응답을 리턴하고, 데이터가 존재하지 않는다면 404 Not Found를 발생시킵니다.
- let 구문과 아래 코드의 동작은 동일합니다. 변수로 사용한 it은 첫 번째 flatMap의 람다식을 간결하게 표현한것입니다. 수신자 객체로도 불리는 it은 클로저(closure)내에서 기본 매개변수라고 이해할 수 있습니다.
1 2 3
if (it != null) { ok().build() }
- delete
- 마찬가지로 파라미터로 들어온 id로 데이터를 조회하고 존재하면 데이터베이스에서 삭제하고 , 데이터가 존재하지 않는다면 404 Not Found를 발생시킵니다.
마치며
이번 예제에선 R2DBC를 적용하여 진정한 의미의 리액티브 프로그래밍을 구현해보았습니다. 적은 변경만으로도 성능이 개선되었고, 코드는 더욱 간결해졌습니다. 다만, R2DBC는 아직 마일스톤버전으로 실무에서 적용하기엔 부담이 있을것입니다. 이에 대한 대안으로 RDB가 아닌 NoSQL솔루션을 적용하는 방법도 있을것입니다. 이를테면 Spring Data MongoDB, Spring Data Redis 등이 이에 해당합니다. 이 역시 Spring Data의 뛰어난 추상화로 인해 큰 어려움없이 적용 가능합니다. NoSQL로 전환하는 예제는 다음에 하기로 하고 그 전에 앞서 다음엔 WebFlux 프로젝트에선 어떤식으로 테스트 코드를 작성하고 자동화하는지 같이 고민해보겠습니다.
오늘 예제로 보신 코드는 https://github.com/spring-webflux-with-kotlin/todo-R2DBC 에서 확인 가능합니다.
참고자료
https://spring.io/blog/2016/11/28/going-reactive-with-spring-data