Spring WebFlux와 Kotlin으로 만드는 Todo 서비스 - 1편



개요

이 예제에서는 최근 적용 사례가 늘고 있는 Spring WebFlux 와 Kotlin을 이용하여 프로젝트를 구성해보고, 간단한 Todo 서비스를 만들어볼 것입니다. 이번 예제에선 Todo 서비스의 기본적인 기능인 내용 작성, 완료 처리, 목록 불러오기, 삭제 등을 같이 구현해보면서 Spring WebFlux와 Kotlin에 대한 이해도를 높이고 개선점을 찾아보는 것을 목표로 합니다. 이 예제에서는 또한 Spring Data JPA를 사용해서 DB에 CRUD를 수행하게 될 것입니다.

사전 지식

Kotlin, WebFulx 그리고 JPA에 대한 지식이 있다면 이해하기 수월하지만, 예제에선 기본적인 특징을 응용하는 수준으로 구성하였으므로 사전 지식이 없으셔도 문제는 없습니다.

왜 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 WebFulx는 또한 2가지의 프로그래밍 모델을 지원합니다.

  • 어노테이션 기반 리액티브 컴포넌트(Annotation-based reactive components)
    • @RequestMapping, @PathVariable, @RestController, @GetMapping 등 Spring MVC와 유사한 방식으로 구현이 가능하여 쉽게 적응할 수 있다는 장점이 있습니다.
  • 함수형 라우터와 핸들러(Functional routing and handling)
    • 라우터 함수(RouterFunction)은 RequestPredicate를 통해 클라이언트에서 들어온 request를 관리하는 라우터 역할을 하게 됩니다.
    • 핸들러 함수(HandlerFunction)는 라우터 함수로 들어온 request에 대한 처리를 정의합니다.

이번 예제에선 개인적으로 선호하는 함수형 프로그래밍 모델을 사용한 접근 방법에 대해 알아보겠습니다.

사용된 툴과 기술들

  1. Spring boot 2+
  2. Spring Data JPA
  3. Maven 3+
  4. Kotlin 1.3
  5. IDE - IntelliJ
  6. H2DB

프로젝트 구조

제가 생성한 프로젝트의 디렉터리 구조는 아래와 같습니다.

6CF8D8CA-B47B-4F92-B7BB-4FE68EE6BE74

  • domain
    •  이 패키지 하위에는 도메인에 관련된 Entity, DTO, Repository 등을 포함합니다.
  • router
    • 사용자의 요청을 전달받아 적절한 핸들러로 라우팅 해주는 역할을 합니다.
  • handler
    • 라우터로부터 전달받은 요청을 처리하고 응답을 생성합니다.

메이븐 세팅

이 예제에선 Maven 을 이용하여 의존성을 관리하도록 하겠습니다. 우선 pom.xml 을 생성해서 아래의 Spring 관련 의존성을 등록해주세요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!-- 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</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>
<!-- Spring Boot -->

그다음 Kotlin 의존성을 추가해줍니다.

1
2
3
4
5
6
7
8
9
<!-- Kotlin -->
<dependency>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-reflect</artifactId>
</dependency>
<dependency>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-stdlib-jdk8</artifactId>
</dependency>

마지막으로 임베디드로 사용할 H2DB 의존성을 추가해줍니다.

1
2
3
4
5
6
<!-- H2 Database -->
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

resources/application.yml 설정

1
2
3
4
5
6
7
spring:
  profiles:
    active: dev
  jpa:
    show-sql: true
  main:
    allow-bean-definition-overriding: true

설정은 Spring Boot에서 지원하는 2가지 방식인 properties, yml 중에서 yml로 구성하였습니다. 이유는 yml이 properties에 비해 각 설정을 구조화하기 쉽고 프로필을 나누기 쉽기 때문입니다.

설정에 대해 설명을 드리면 현재 활성화된 프로필은 dev이고, show-sql 값을 true로 하게 되면 DB와 통신시 호출되는 SQL을 로그로 바로 확인할 수 있습니다. 이 설정은 개발 환경에서만 쓰시길 추천드립니다. 그다음 allow-bean-definition-overriding 설정은 Spring Boot 2.1 버전에서 기본값이 false로 변경되었습니다. 공식  문서에서는 실수로 bean을 재정의하게 되는 것을 방지하기 위함이라고 합니다. 관련해선 https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.1-Release-Notes 이곳을 참조해주세요.

도메인(Domain)

src/main/kotlin/com/digimon/demo/domain/todo/Todo.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.digimon.demo.domain.todo
import java.time.LocalDateTime
import javax.persistence.*
import javax.validation.constraints.NotNull
@Entity
@Table(name = "todos")
class Todo {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long = 0
    @Lob
    @Column(name = "content")
    var content: String? = null
    @Column(name = "done")
    var done: Boolean = false
    @Column(name = "created_at")
    var createdAt: LocalDateTime = LocalDateTime.now()
    @Column(name = "modified_at")
    var modifiedAt: LocalDateTime = createdAt
}

Todo 클래스는 JPA에서 말하는 Entity입니다. Entity는 데이터베이스의 테이블과 매핑이 됩니다.  JPA로 대표되는 ORM(Object Relational Mapping) 프레임워크는 데이터베이스와 객체 간의 패러다임 불일치를 해결해주는 솔루션입니다. 개인적으론 Entity를 Request와 Response 모델에 직접 사용하는 것을 추천하지 않고 DTO 와 같은 레이어를 두는 것을 추천드리지만 이번 예제에서 만들 서비스는 프로토타입 수준이기 때문에 우선 이대로 사용하도록 하겠습니다.

  • id는 테이블의 primary key입니다.
  • content는 todo의 내용을 String으로 저장합니다.
  • done은 해당 todo의 완료여부를 가지고 있습니다.
  • createdAt은 todo가 생성된 일시 입니다.
  • modifiedAt은 todo가 수정된 일시이고 기본값은 createdAt과 동일합니다.

리파지토리(Repository)

src/main/kotlin/com/digimon/demo/domain/todo/TodoRepository.kt

1
2
3
package com.digimon.demo.domain.todo
import org.springframework.data.jpa.repository.JpaRepository
interface TodoRepository : JpaRepository<Todo, Long>

TodoRepository는 JpaRepository를 상속받아 이 리파지토리가 Spring Data JPA에 의하여 관리되고 확장한다는 것을 알 수 있습니다. 실제로 TodoRepository에는 어떠한 메서드도 존재하지 않습니다. 바로 Spring Data JPA의 마법이죠 (JPA와 Spring Data JPA는 엄밀하게 구분이 필요합니다.)

라우터(Router)

src/main/kotlin/com/digimon/demo/router/TodoRouter.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.digimon.demo.router
import com.digimon.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)

src/main/kotlin/com/digimon/demo/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
51
52
53
54
55
56
57
58
59
60
package com.digimon.demo.handler
import com.digimon.demo.domain.todo.Todo
import com.digimon.demo.domain.todo.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(notFound().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))
                    }
            ).switchIfEmpty(notFound().build())
    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 등을 처리하는 비즈니스 로직이 구현되어 있습니다. 리액티브 스트림과 코틀린에 익숙하다면 함수가 차례로 연결된 스트림이 그리 복잡해 보이진 않을 것입니다. 오히려 함수의 이름만 봐도 어떤 일을 하는지 한눈에 파악할 수 있으실 것이며,  개선해야될 점들이 보이실 거라고 생각합니다. 이번 예제는 맛보기이므로 개선은 다음번에 이어서 하는 것으로 하고 간단하게 구조를 살펴보시길 추천드립니다.

동작 확인

우선 IDE에서 스프링 부트 애플리케이션을 실행해주세요. 각각의 CRUD는 HTTP 동사(verb)를 기준으로 구분 하게 됩니다.

시작으로 POST 메서드를 사용해 todo를 생성해 봅시다. 터미널에 아래와 같이 호출해보세요.

1
curl -X POST http://localhost:8080/todos -H 'Content-Type: application/json' -d '{ "content" : "내용1" }'
1
2
3
4
5
6
7
{
    "id": 3,
    "content": "내용1",
    "done": false,
    "createdAt": "2019-08-28T19:57:51.298743",
    "modifiedAt": "2019-08-28T19:57:51.298743"
}

제대로 세팅되었다면 위와 같은 응답이 서버로부터 내려올 것입니다.

이제 GET 메서드를 사용해 리스트를 호출해보죠.

1
curl -X GET http://localhost:8080/todos
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[
        {
            "id": 1,
            "content": "내용1",
            "done": false,
            "createdAt": "2019-08-28T19:57:43.862524",
            "modifiedAt": "2019-08-28T19:57:43.862524"
        },
        {
            "id": 2,
            "content": "내용2",
            "done": false,
            "createdAt": "2019-08-28T19:57:47.3808",
            "modifiedAt": "2019-08-28T19:57:47.3808"
        },
        {
            "id": 3,
            "content": "내용3",
            "done": false,
            "createdAt": "2019-08-28T19:57:51.298743",
            "modifiedAt": "2019-08-28T19:57:51.298743"
        }
]

저의 경우 3개를 등록하여서 위와 같은 응답을 확인할 수 있습니다.

이번엔 id가 1인 todo의 상태를 완료(done)로 처리하고 싶습니다.  HTTP 동사 중 PUT을 이용해 update 동작을 수행해보겠습니다. 호출 방식은 아래와 같습니다.

1
curl -X PUT http://localhost:8080/todos/1/done
1
2
3
4
5
6
7
{
    "id": 1,
    "content": "내용1",
    "done": true,
    "createdAt": "2019-08-28T20:14:51.153641",
    "modifiedAt": "2019-08-28T20:24:17.196938"
}

정상적으로 done 값이 true로 변경된 것을 확인할 수 있습니다.

이번엔 id가 2인 todo를 삭제해 보겠습니다. 이 경우 HTTP 동사 DELETE를 사용하면 됩니다.

1
curl -X DELETE http://localhost:8080/todos/2
1
2
3
4
5
6
7
{
    "id": 2,
    "content": "내용2",
    "done": false,
    "createdAt": "2019-08-28T19:57:47.3808",
    "modifiedAt": "2019-08-28T19:57:47.3808"
}

삭제한 todo를 그대로 응답으로 내려주도록 하였으므로, id가 2인 todo가 삭제된 것을 확인했습니다.

마지막으로 전체 리스트를 호출해보겠습니다.

1
curl -X GET http://localhost:8080/todos
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[
        {
            "id": 1,
            "content": "내용3",
            "done": true,
            "createdAt": "2019-08-28T20:14:51.153641",
            "modifiedAt": "2019-08-28T20:24:17.196938"
        },
        {
            "id": 3,
            "content": "내용3",
            "done": false,
            "createdAt": "2019-08-28T20:15:21.171624",
            "modifiedAt": "2019-08-28T20:15:21.171624"
        }
]

위와 같이 id가 1인 todo는 완료(done) 처리 되었고, id가 2인 todo는 삭제되어 더 이상 리스트에서 확인할 수 없습니다.

마치며

이렇게 간단한 Todo 예제를 WebFlux 와 Kotlin으로 구현해보았습니다. 혹시 이 예제를 보시면서 의아하거나 문제점이 보이시지 않으셨나요? 물론 개선할 부분은 많이 있습니다. 누군가 저에게 제일 먼저 바꾸고 싶은 부분이 어디냐고 물어보신다면 JDBC를 사용한 코드라고 하겠습니다. JDBC는 Java 진영의 가장 대표적인 SPI(Service Provider Interface)이지만 논 블로킹(non-blocking) 방식을 지원하지 않습니다.  다음 예제에선 JDBC를 대신하는 R2DBC를 이용하여 좀 더 개선된 예제를 만들어보겠습니다.

오늘 예제로 보신 코드는  https://github.com/spring-webflux-with-kotlin/todo 에서 확인 가능합니다.

참고자료

https://spring.io/guides/gs/reactive-rest-service/

https://www.baeldung.com/spring-webflux

https://www.baeldung.com/spring-webflux-kotlin


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.