본문 바로가기

프로그래밍(TA, AA)/JVM 언어

[Spring] Spring WebFlux와 Kotlin으로 만드는 Todo 서비스

spring webflux + kotlin => Spring WebFlux와 Kotlin으로 만드는 Todo 서비스

 

더보기

왜 kotlin을 사용하는가?

Kotlin은 Intellij로 유명한 JetBrains 사에서 개발한 정적 타입 언어이다. 구글의 안드로이드 공식 언어로도 채택되었고, JetBrains 사에서 개발하였으므로 IDE 지원 또한 완벽하다. Kotlin은 코드의 간결성, Java와 상호 운용이 가능하다는 장점 등으로 인해서 최근에 많은 인기를 끌고 있다.

 

코틀린이 정적타입언어인 이유는 런타임이 아닌 컴파일시점에 타입을 체크하기 때문이다. 코틀린은 자바와 비교해서 여러가지 장점들을 가지고 있는데 그중에서 신뢰성, 효율성, 런타임 성능 그리고, 유지보수 등이 이에 해당한다. 거기에 자바와 상호 운용할 수 있으며 많은 자바 프레임워크, 라이브러리를 지원하여 통합하거나 양립할 수 있다. 또한, 코틀린은 간결하고, 깔끔하고, 이해하기 쉽게 작성할 수 있으며 적은 코드 라인으로도 문제를 해결할 수 있다는 것이 장점이다. 코틀린은 전체적으로 자바와 상호운용 가능하다. 기존에 존재하는 코드 베이스, 라이브러리들 모두 코틀린과 같이 사용할 수 있다. 보다 적은 코드만으로 개발이 가능하여 더 나은 생산성을 가질 수 있다.

 

Spring WebFlux란?

Spring WebFlux는 Project Reactor 기반의 Reactive Extension이다. Reactive Extension(줄여서 RX)의 구현체로는 Reactor 외에도 ReactiveX(RxJava, RxKotlin, RxJS) 등이 존재한다. Reactor는 피보탈에서 개발하고 있기 때문에 스프링에 쉽게 통합될 수 있었다.

 

다만, Flux와 Mono로 대표되는 WebFlux의 상세한 개념을 이해하는데는 시간이 필요할 것이다. 간략하게 "논블로킹(non-blocking)과 배압(backpressure)이라는 특징을 가진 리액티브 프로그래밍 모델을 스프링 환경에서 쉽고 효율적으로 개발할 수 있게 하는 프레임워크" 정도로만 이해하고 넘어가도 좋다.

 

Spring WebFlux는 또한 2가지의 프로그래밍 모델을 지원한다.

 

1) 어노테이션 기반 리액티브 컴포넌트(Annotation-based reactive components)

@RequestMapping, @PathVariable, @RestController, @GetMpping 등 Spring MVC와 유사한 방식으로 구현이 가능하여 쉽게 적응할 수 있다는 장점이 있다.

2) 함수형 라우터와 핸들러(Functional routing and handling)

라우터 함수(RouterFunction)은 RequestPredicate를 통해 클라이언트에서 들어온 request를 관리하는 라우터 역할을 하게 된다. 핸들러 함수(HandlerFunction)은 라우터 함수로 들어온 request에 대한 처리를 정의한다.

 

프로젝트 구조

domain - 이 패키지 하위에는 도메인에 관련된 Entity, DTO, Repository 등을 포함한다.

router - 사용자의 요청을 전달받아 적절한 핸들러로 라우팅 해주는 역할을 한다.

handler - 라우터로부터 전달받은 요청을 처리하고 응답을 생성한다.

 

설정은 Spring Boot에서 지원하는 2가지 방식인 properties, yaml 중에 yaml로 구성한다. 이유는 yml이 properties에 비해 각 설정을 구조화하기 쉽고 프로필을 나누기 쉽기 때문이다. allow-bean-definition-overriding 설정은 Spring Boot 2.1 버전에서 기본값이 false로 변경되었다. 실수로 bean을 재정의하게 되는 것을 방지하기 위한 목적으로 만들어진 옵션이다.

라우터(Router)

package com.demo.router

import com.demo.handler.TodoHandler
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.reactive.function.server.requestPredicates.path
import org.springframework.web.reactive.function.server.RouterFunctions.nest
import org.springframework.web.reactive.function.server.router

@Configuration
class TodoRouter(private val handler: TodoHandler) {
    @Bean
    fun routerFunction() = nest(path("/todos"),
            router {
                listOf(
                        GET("/", handler::getAll),
                        GET("/{id}", handler::getById),
                        POST("/", handler::save),
                        PUT("/{id}/done", handler::done),
                        DELETE("/{id}", handler::delete))
            }
}
더보기

routerFunction 안에 정의된 라우터들은 API의 엔드포인트가 된다. 기본적인 구현은 REST 설계 방식의 GET,POST, PUT, DELETE 4가지 동사에 대응하도록 정의되어 있다. 각각의 라우터들은 클라이언트로부터 요청이 들어오면 handler에게 처리를 위임한다.

핸들러(Handler)

package com.demo.handler
import com.demo.domain.todo.Todo
import com.demo.domain.toto.TodoRepository
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.server.ServerRequest
import org.springframework.web.reactive.function.server.ServerResponse
import org.springframework.web.reactive.function.server.ServerResponse.notFound
import org.springframework.web.reactive.function.server.ServerResponse.ok
import org.springframework.web.reactive.function.server.body
import reactor.core.publisher.Mono
import java.time.LocalDateTime
import java.util.*

@Component
class TodoHandler(private val repo: TodoRepository) {
    fun getAll(req: ServerRequest) : Mono<ServerResponse> = ok()
            .contentType(MediaType.APPLICATION_JSON)
            .body<List<Todo>>(Mono.just(repo.findAll()))
            .switchIfEmpty(notFound().build())
    fun getById(req: ServerRequest) : Mono<ServerResponse> = ok()
            .contentType(MediaType.APPLICATION_JSON)
            .body<Todo>(Mono.justOrEmpty(repo.findById(req.pathVariable("id").toLong())))
            .switchIfEmpty(noFound().build())
    fun save(req: ServerRequest) : Mono<ServerResponse> = ok()
            .contentType(MediaType.APPLICATION_JSON)
            .body(req.bodyToMono(Todo::class.java)
                    .switchIfEmpty(Mono.empty())
                    .filter(Objects::nonNull)
                    .flatMap { todo ->
                        Mono.fromCallable {
                            repo.save(todo)
                        }.then(Mono.just(todo))
                    }
            ).switchIfEmpty(notFound().build())
    fun done(req: ServerRequest) : Mono<ServerResponse> = ok()
            .contentType(MediaType.APPLICATION_JSON)
            .body(Mono.justOrEmpty(repo.findById(req.pathVariable("id").toLong())))
                    .switchIfEmpty(Mono.empty())
                    .filter(Objects::nonNull)
                    .flatMap { todo ->
                        Mono.fromCallable {
                            todo.done = true
                            todo.modifiedAt = LocalDateTime.now()
                            repo.save(todo)
                        }.then(Mono.just(todo))
                    }
    fun delete(req: ServerRequest) : Mono<ServerResponse> = ok()
            .contentType(MediaType.APPLICATION_JSON)
            .body(Mono.justOrEmpty(repo.findById(req.pathVariable("id").toLong()))
                    .switchIfEmpty(Mono.empty())
                    .filter(Objects::nonNull)
                    .flatMap { todo ->
                        Mono.fromCallable {
                            repo.delete(todo)
                        }.then(Mono.just(todo))
                    }
            ).switchIfEmpty(notFound().build())
}
더보기

핸들러 로직은 가장 복잡한 로직을 갖고 있다. 일반적으로 핸들러에서 사용자의 요청을 받아 save, delete 등을 처리하는 비즈니스 로직이 구현되어 있다. 리액티브 스트림과 코틀린에 익숙하다면 함수가 차례로 연결된 스트림이 그리 복잡해 보이지는 않을 것이다. 오히려 함수의 이름만 봐도 어떤 일을 하는지 한눈에 파악할 수 있을 것이다.

 

JDBC는 Java 진영의 가장 대표적인 SPI이지만 논블로킹 방식을 지원하지 않는다. JDBC를 대신하는 R2DBC를 이용하여 좀더 개선된 예제를 만들 수 있다. 

R2DBC

피보탈에서 개발중인 R2DBC는 Reactive Relational Database Connectivity의 약자로써, SpringOne Platform 2018에서 처음 발표되었다. 이름에서도 추측 가능하듯이 리액티브 프로그래밍을 가능하게 하는 데이터베이스 인터페이스이다. JDBC에서 아직 지원하지 않는 비동기(asynchronous), 논블로킹(non-blocking) 프로그래밍 모델을 지원한다. 이는 Spring WebFlux의 성능을 최대치로 끌어올릴수 있다는 이야기가 된다.  다른 Spring Data 프로젝트와 마찬가지로 데이터베이스 연동에 대한 뛰어난 추상화를 제공한다. 

 

ReactiveCrudRepository는 리액티브 스트림을 지원하는 CRUD 메서드들을 포함하는 인터페이스이다. 내부를 확인해보면 기존의 다른 Spring Data의 리포지토리 인터페이스인 CrudRepository와 크게 다르지 않다. 다만 전통적인 CrudRepository와 다른 점은 리턴 타입이 Flux 또는 Mono라는 것과 파라미터에 Publisher가 추가되었다는 점이 다르다.

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();
}
package com.demo.handler
import com.demo.domain.todo.Todo
import com.demo.domain.todo.TodoRepository
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.BodyInterters.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(i)) }
                    .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() }
    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())
}
더보기

findAll, findById, save, delete 메서드를 수행할때 Mono 또는 Flux로 변환할 필요없이 바로 리액티브 스트림을 연결했다는 점이 이전 코드와 다르다. 데이터베이스와의 모든 통신이 논블로킹으로 실행된다는 것이다.