728x90
반응형


프로젝트 리액터

프로젝트 리액터(Project Reactor)는 JVM기반의 환경에서 리액티브 애플리케이션 개발을 위한 오픈 소스 프레임워크입니다. 리액터는 스프링 에코시스템에서 리액티브 스택의 기반이 되는 프로젝트로 리액티브 스트림 사양을 구현하므로 리액티브 스트림에서 사용하는 용어와 규칙을 동일하게 사용하며 리액티브 스트림 사양에 포함되지 않은 리액터만의 다양한 기능들도 제공합니다.

리액터를 사용하면 손쉽게 애플리케이션에 리액티브 프로그래밍을 적용할 수 있고 애플리케이션의 모든 구간에 비동기-논블로킹을 적용할 수 있습니다. 또한 리액터에서 제공하는 백프레셔 기법을 사용해 시스템의 부하를 효율적으로 조절할 수 있습니다.

 

모노와 플럭스

리액터는 모노(Mono)플럭스(Flux)라는 두 가지 핵심 타입을 제공합니다. 먼저 모노는 0..1개의 단일 요소 스트림을 통지합니다. 이에 반해 플럭스는 0..N개로 이뤄진 복수개의 요소를 통지하는 발행자입니다. 플럭스와 모노는 리액티브 스트림 사양의 발행자(Publisher)를 구현하여 데이터를 통지하기 때문에 onComplete 또는 onError 시그널이 발생할 때까지 onNext를 사용해 구독자에게 데이터를 통지합니다.

모노와 플럭스는 리액터의 핵심이 되는 요소로써 리액티브 스트림 사양을 구현하는 하는 것 외에도 데이터를 가공하거나 필터링하는 기능과 에러가 발생하면 에러를 핸들링 할 수 있는 다양한 연산자(Operator)를 제공하며 두 타입이 공통으로 사용하는 연산자도 있고 자신만의 연산자도 있습니다. 이번장에서는 모노와 플럭스를 사용할때 자주 사용하는 연산자들에 대해 알아보도록 하겠습니다.

 

모노의 연산자

모노는 단일 요소에 대한 통지의 경우 사용됩니다. 모노의 사용 방법을 알아보기 위해 코틀린 그레이들 프로젝트를 생성합니다. 정상적으로 생성되었다면 예제와 같은 구조로 프로젝트가 생성됩니다.

 

├── build.gradle.kts
├── gradlew
├── settings.gradle.kts
└── src
    ├── main
    │   ├── kotlin
    │   └── resources
    └── test
        ├── kotlin
        └── resources

 

그다음 build.gradle.kts를 열어 예제 1.1과 같이 reactor-core 의존성을 추가합니다.

 

예제 1.1 프로젝트에 reactor-core 의존성 추가 - reactor/reactor-basic/build.gradle.kts

plugins {
    id("org.jetbrains.kotlin.jvm") version ("1.4.10")
}


group = "com.reactor.reactorbasic"
version = "1.0-SNAPSHOT"


repositories {
    mavenCentral()
}


dependencies {

    implementation("org.jetbrains.kotlin:kotlin-stdlib")

    implementation("io.projectreactor:reactor-core:3.4.0")

}

 

 

just

모노를 생성하는 가장 간단한 방법은 Mono.just를 사용하는 것입니다. Mono.just(T data)는 객체를 인자로 받은 뒤 모노로 래핑하는 팩토리 함수입니다.

예제 1.2 Mono.just 사용 - reactor/reactor-basic/src/mono/Mono1.kt

package mono

import reactor.core.publisher.Mono

fun main() {
    val mono: Mono<String> = Mono.just("Hello Reactive World")
    mono.subscribe(::println)
}

--------------------
출력 결과)
--------------------
Hello Reactive World


인자로 “Hello Reactive World”라는 String을 인자로 받았기 때문에 String이 감싸진 Mono<String>을 반환하며 그다음 라인에 작성된 mono.subscribe(::println)는 리액티브 스트림 사양의 Publisher.subscribe 함수를 구현하여 데이터를 구독합니다. 

중요한 점은 플럭스와 모노는 게으르게(lazy) 동작하여 subscribe를 호출하지 않으면 연산자에 작성한 로직이 동작하지 않는다는 것입니다. 이런 특징은 Java8 스트림 API의 특성과 동일합니다. 스트림 API 역시 연산자를 트리거 하는 최종 연산자를 호출하지 않으면 스트림 연산이 동작하지 않습니다.

 

justOrEmpty

Mono.justOrEmpty는 값이 null이 거나 null이 될 수 있는 데이터를 받아서 처리할 수 있습니다. 

 

예제 1.3  Mono.justOrEmpty 사용 - reactor/reactor-basic/src/mono/Mono2.kt

package mono
 
import reactor.core.publisher.Mono
 
fun main() {
    val mono: Mono<String> = Mono.justOrEmpty("Hello Reactive World")
    mono.subscribe(::println)
}
--------------------
출력 결과)
--------------------
Hello Reactive World


우선 greeting 변수에 null을 할당하고 Mono.justOrEmpty의 인자로 넣어줍니다. Mono.just의 경우 null인 데이터가 인자로 들어오면 NullPointerException이 발생하므로 주의해야 합니다.  

 

switchOrEmpty/defer

null인 데이터를 제공받을 경우 switchIfEmpty를 사용해 처리할 수 있습니다. switchIfEmpty는 전달받은 값이 null인 경우 새로운 데이터로 변환하는 연산자입니다. 

 

예제 1.4  통지할 데이터가 null인 경우 처리 방법 - reactor/reactor-basic/src/mono/Mono3.kt

package mono
 
import reactor.core.publisher.Mono
 
fun main() {
    val greeting: String? = null
    Mono.justOrEmpty(greeting)
        .switchIfEmpty(Mono.defer {
            Mono.just("Hello Reactive World")
        })
        .subscribe(::println)
}
--------------------
출력 결과)
--------------------
Hello Reactive World

greeting이 null로 전달되었으므로 switchIfEmpty에서 제공하는 Mono.just(“Hello Reactive World”)가 연산자의 결과로 전달됩니다. 이때 switchIfEmpty 내부에서 Mono.defer라는 연산자를 사용하면 내부 코드의 호출이 지연되어 실제 사용되는 시점에 호출됩니다.

만약 예제 1.5 같이 Mono.defer를 사용하지 않고 switchIfEmpty를 사용하게 되면 greeting의 값의 존재 유무에 상관없이 switchIfEmpty 내부의 코드가 동작하게 되는데 이는 불필요한 동작이므로 Mono.defer를 사용하여 실제 사용되는 시점까지 동작을 미루는 것이 유리합니다.

예제 1.5  Mono.defer를 사용하지 않은 경우 - reactor/reactor-basic/src/mono/Mono4.kt

package mono
 
import reactor.core.publisher.Mono
 
fun main() {
    val greeting = "Hello"
    Mono.justOrEmpty(greeting)
        .switchIfEmpty(greetIfEmpty()) // null이 아니지만 호출됨
        .subscribe(::println)
}
 
fun greetIfEmpty() : Mono<String> {
    val greeting = "Hello Reactive World"
    println(greeting)
    return Mono.just(greeting)
}
--------------------
출력 결과)
--------------------
Hello Reactive World
Hello

 

예제 1.6은 Mono.defer를 사용하여 greeting이 null인 경우에만 동작하므로 효율적입니다.

 

예제 1.6  Mono.defer를 사용한 경우 - reactor/reactor-basic/src/mono/Mono5.kt

package mono
 
import reactor.core.publisher.Mono
 
fun main() {
    val greeting = "Hello"
    Mono.justOrEmpty(greeting)
        .switchIfEmpty(Mono.defer {
            greetIfEmptyInDefer() // greeting이 null인 경우에만 동작
        })
        .subscribe(::println)
}
 
fun greetIfEmptyInDefer(): Mono<String> {
    val greeting = "Hello Reactive World"
    println(greeting)
    return Mono.just(greeting)
}
--------------------
출력 결과)
--------------------
Hello

 

 

defaultIfEmpty

전달받은 데이터가 null인 경우 예제 1.7의 defaultIfEmpty를 사용하여 기본 값을 제공할 수 있습니다. 

예제 1.7 defaultIfEmpty를 사용해 기본 값 제공 - reactor/reactor-basic/src/mono/Mono6.kt

package mono
 
import reactor.core.publisher.Mono
 
fun main() {
    val greeting: String? = null
    Mono.justOrEmpty(greeting)
        .defaultIfEmpty("Hello Reactive World")
        .subscribe(::println)
}
--------------------
출력 결과)
--------------------
Hello Reactive World

 
defaultIfEmtpy와 switchIfEmpty 연산자는 모두 전달받은 데이터가 null인 경우 사용할 수 있다는 것이 공통점입니다. 차이점은 switchIfEmpty는 내부에서 새로운 로직을 구현해 새로운 Mono를 생성할 수 있지만 defaultIfEmpty는 null을 대체하는 값을 지정한다는 점이 다릅니다.

 

fromSupplier

내부에서 로직에 대한 결과로 모노 객체를 생성할 경우 예제 1.8과 같이 Mono.fromSupplier를 사용할 수 있습니다.

예제 1.7 fromSupplier 예제 - reactor/reactor-basic/src/mono/Mono7.kt

package mono
 
import reactor.core.publisher.Mono
 
fun main() {
    Mono.fromSupplier {
        val greeting = "Reactive World"
        var welcome = "Welcome"
        welcome += " ${greeting.split(" ")[1]}"
        welcome += " ${greeting.split(" ")[2]}"
        welcome
    }.subscribe(::println)
}
--------------------
출력 결과)
--------------------
Welcome Reactive World


Mono.fromSupplier의 인자로 제공되는 Supplier는 Java8에 추가된 함수형 인터페이스입니다. Supplier는 별도의 인자는 없이 내부의 값을 반환하는 T get() 함수를 구현하도록 설계되었습니다.

@FunctionalInterface
public interface Supplier<T> {
    T get();
}


즉 Mono.fromSupplier{} 내부에 작성하는 코드는 T get() 함수의 구현이 되어 Mono.fromSupplier로 제공되고 내부 로직의 반환되는 구조입니다. 모노를 생성하는데 특정한 구현 로직이 필요한 경우나 늦은 초기화가 필요한 경우 유용하게 사용할 수 있습니다.

 

error

Mono.error는 로직을 처리하는 중 특정한 에러를 발생시키고 싶은 경우 유용하게 사용할 수 있습니다. 예제 1.8은 일부러 NullPointerException을 발생시키는 코드를 생성하고 예외가 발생했을때 사용자 정의 예외를 발생시킵니다.

예제 1.8 error를 사용자 사용자 정의 예외 발생 예제 - reactor/reactor-basic/src/mono/Mono8.kt

package mono
 
import reactor.core.publisher.Mono
 
fun main() {
    Mono.fromSupplier {
        val greeting: String? = null
        Mono.just(greeting)
    }.onErrorResume {
        Mono.error(GreetingEmptyException("Greeting is Empty"))
    }.subscribe(::println)
}
 
class GreetingEmptyException : Throwable {
    constructor() : super()
    constructor(message: String?) : super(message)
}
--------------------
출력 결과)
--------------------
Welcome Reactive World


가장 먼저 Mono.from Supplier 내부에서 null을 반환하는 greeting을 Mono.just로 생성하였습니다. Mono.just는 null인 객체를 전달받으면 NullPointerException을 발생시킵니다. 에러가 발생한 경우 onErrorResume를 사용하면 예외가 발생할 경우 별도의 핸들링이 가능합니다. 예제 1.8에서는 NullPointerException이 발생한 경우 직접 생성한 GreetingEmptyException을 대신 발생시키기 위해 Mono.error를 사용했습니다. Mono.error는 최상위 Exception인 Throwable을 인자로 전달받기 때문에 모든 사용자 정의 예외 객체에 사용이 가능합니다.

 

map

예제 1.9에서 소개하는 map 연산자는 전달받은 요소를 새로운 모노로 변환할 때 사용하는 연산자입니다. 

예제 1.9 map 사용 예제 - reactor/reactor-basic/src/mono/Mono9.kt

package mono
 
import reactor.core.publisher.Mono
 
fun main() {
   val mono: Mono<String> =
       Mono.just("Hello Reactive World")
           .map {
               it.toLowerCase()
           }
   mono.subscribe(::println)
}
--------------------
출력 결과)
--------------------
Hello Reactive World


map 내부에서는 Mono.just에 담긴 문자열(Hello Reactive World)을 전달받아서 소문자로 변환하는 toLowerCase()를 호출합니다. 이렇게 되면 map 내부에서 변환된 데이터를 모노로 감싼 뒤 데이터를 통지하게 됩니다. map은 한 개의 데이터만 제공할 수 있고 다수의 데이터를 제공할 수 없습니다. 

 

flatMap

예제 1.10은 “Hello Reactive World”라는 String을 받아서 flatMap을 통해 각 단어의 처음 단어를 뽑아내 약자로 변환하여 출력하는 예제입니다. 

예제 1.10 flatMap 사용 예제 - reactor/reactor-basic/src/mono/Mono10.kt

package mono
 
import reactor.core.publisher.Mono
 
fun main() {
    Mono.just("Hello Reactive World")
        .flatMap {
            val words = it.split(" ")
            val acronym = words.map { word -> word[0] }.toCharArray()
            Mono.just(acronym)
        }.subscribe(::println)
}
--------------------
출력 결과)
--------------------
HRW


flatMap은 주로 단일 요소를 복수개의 요소로 변환할 때 사용합니다. 유의할 점은 flatMap은 map과 다르게 마지막에서 반환할 요소를 앞서 소개한 Mono.just와 같은 팩토리 함수로 감싸서 반환해야 합니다.

예제 1.11은 전달 받은 문자열에 “Hello”가 포함 되었는지를 판단하여 true면 문자열로 새롭게 생성한 모노를 반환하고 거짓이라면 Mono.empty를 반환합니다.

예제 1.11 flatMap 사용 예제2 - reactor/reactor-basic/src/mono/Mono11.kt

package mono
 
import reactor.core.publisher.Mono
 
fun main() {
    Mono.just("Reactive World")
        .flatMap {
            if (it.contains("Hello")) {
                Mono.just(it)
            } else {
                Mono.empty()
            }
        }.subscribe(::println)
}
--------------------
출력 결과)
--------------------
없음


flatMap을 활용하면 특정 조건에 따라서 에러를 발생 시키거나 비어 있는 값을 리턴할 수 있습니다.


filter

filter 연산자는 특정 조건이 true인지 판단하여 true인 경우에만 데이터를 통지하는 연산자입니다. 예제 1.12는 전달받은 문자열 데이터가 존재하는 경우 정해진 문자열을 출력합니다.

예제 1.12 filter를 사용해 데이터 정제 - reactor/reactor-basic/src/mono/Mono12.kt

package mono
 
import reactor.core.publisher.Mono
 
fun main() {
    val mono: Mono<String> =
        Mono.just("Hello Reactive World")
            .filter {
                it.startsWith("Hello")
            }
            .map {
                "this operator is working"
            }
    mono.subscribe(::println)
}
--------------------
출력 결과)
--------------------
this operator is working


예제 1.12의 filter 연산자에서는 전달받은 문자열이 Hello로 시작하는지 판단하여 true이라면 다음 연산자인 map 연산자로 이어지고 정해진 문자열 “this operator is working”을 출력합니다.

filter 연산자는 Predicate라는 함수형 인터페이스를 전달받습니다. Predicate도 마찬가지로 Java8에 추가되었으며 test라는 함수를 구현해야 합니다. 

@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
}

test 함수는 boolean을 반환하여 test 함수를 구현하는 구현체에서는 조건식의 결과를 boolean으로 반환해야 합니다. 

예제 1.13은 filter 연산자 내부의 조건이 거짓인 경우 switchIfEmpty 내부의 로직이 동작하는 것을 확인 하는 예제입니다.

예제 1.13 filter를 사용해 데이터 정제2 - reactor/reactor-basic/src/mono/Mono13.kt

package mono
 
import reactor.core.publisher.Mono
 
fun main() {
    val mono: Mono<String> =
        Mono.just("Hello Reactive World")
            .filter {
                it.startsWith("Bye")
            }
            .map {
                "this operator is working"
            }
            .switchIfEmpty(Mono.defer {
                Mono.just("switchIfEmpty is working")
            })
    mono.subscribe(::println)
}
--------------------
출력 결과)
--------------------
switchIfEmpty is working


filter 연산자내에서 전달받은 데이가 Bye로 시작하면 조건은 true지만 전달 받은 문자열이 Hello Reactive World 이므로 이 조건 식은 거짓이 됩니다. 이 경우 바로 이어진 map 연산자가 동작하지 않고 switchIfEmpty 연산자가 동작하여 출력 결과는 switchIfEmpty is working이 됩니다. 이런 형태로 filter의 조건에 따른 분기가 이뤄지는 조합은 매우 빈번하게 사용됩니다.


zip

만약 여러 개의 모노 객체를 하나의 모노로 결합할 경우 zip을 사용해 처리할 수 있습니다. 예제 1.14는 zip을 사용해 3개의 모노를 합친 결과를 출력합니다.

예제 1.14 zip을 사용한 결합 예제1 - reactor/reactor-basic/src/mono/Mono14.kt

package mono
 
import reactor.core.publisher.Mono
 
fun main() {
    val mono1 = Mono.just("Hello")
    val mono2 = Mono.just("Reactive")
    val mono3 = Mono.just("World")
    Mono.zip(mono1, mono2, mono3)
        .map { tuple: Tuple3<String, String, String> ->
            "${tuple.t1} ${tuple.t2} ${tuple.t3}"
        }.subscribe(::println)
}
--------------------
출력 결과)
--------------------
Hello Reactive World


우선 3개의 모노를 생성한 후 Mono.zip 함수의 인자로 추가합니다.  zip 함수의 처리가 완료되어 새로운 모노를 생성한 뒤 다음 연산자에 통지를 보내는 시점은 인자로 제공된 모노 중 가장 오래 수행된 모노의 시간을 기준으로 결합되는 것이 특징입니다. 이런 특징 때문에 각 모노의 로직을 비동기 처리한 뒤 결과에 대한 결합을 위해 zip을 사용할 수 있습니다.

zip으로 합쳐진 모노 객체는 튜플(Tuple) 객체의 프로퍼티로 저장됩니다. 튜플에 들어가는 순서는 함수에 인자로 추가한 순서대로 t1, t2, t3와 같은 방식으로 사용할 수 있습니다. 튜플은 리액터에서 제공하는 데이터 저장을 위한 구조체의 종류로 비슷한 예로 코틀린의 페어(Pair)와 트리플(Triple)이 있습니다. 튜플은 현재 8개까지 제공되고 있으며 reactor.util.function 패키지를 보면 Tuple2, Tuple3, Tuple4… 과 같이 미리 정의되어 제공됩니다.

public Tuple2<T1, T2> implements Iterable<Object>, Serializable {
    final T1 t1;
    final T2 t2;
    // ...
}
 
public Tuple3<T1, T2, T3> extends Tuple2<T1, T2> {
    final T3, t3;
    // ...
}


기본적으로 Mono.zip 함수에는 최대 8개의 모노를 인자로 받아서 튜플로 사용할 수 있는 오버로딩 함수가 있지만 그 이상의 모노를 결합할 경우 컴비네이터 함수(Combinator Function)을 사용해 합칠 수 있습니다.

예제 1.15는 리스트로 전달받은 모노를 컴비네이터 함수로 결합하는 예제 입니다.

예제 1.15 zip을 사용한 결합 예제2 - reactor/reactor-basic/src/mono/Mono15.kt

package mono
 
import reactor.core.publisher.Mono
 
fun main() {
    val monoList = arrayListOf(
        Mono.just("Seoul"),
        Mono.just("Tokyo"),
        Mono.just("Washington, D.C"),
        Mono.just("Wellington"),
        Mono.just("Canberra"),
        Mono.just("Paris"),
        Mono.just("Prague"),
        Mono.just("Manila"),
        Mono.just("Doha"),
        Mono.just("Singapore")
    )
 
    Mono.zip(monoList) { array: Array<Any> -> // 컴비네이터 함수
        array.joinToString {
            (it as String).toUpperCase()
        }
    }.subscribe(::println)
}
 
--------------------
출력 결과)
--------------------
SEOUL, TOKYO, WASHINGTON, D.C, WELLINGTON, CANBERRA, PARIS, PRAGUE, MANILA, DOHA, SINGAPORE


우선 Mono.zip 함수의 인자에 리스트에 담긴 모노 객체들을 추가합니다. 그런 다음 컴비네이터 함수에서 데이터를 변환하게 되는데 이때 앞서 제공된 모노가 담긴 리스트는 튜플이 아닌 배열에 담겨서 전달됩니다. 그다음 joinToString 함수 내부에서 배열의 개수만큼 반복하면서 문자열을 대문자로 변환하고 콤마(,)를 구분자로 해서 배열을 문자열로 변환한 후 다음 연산자로 제공합니다.

 

zipWith, zipWhen

zipWith 연산자는 기존 모노 객체에 새로운 모노를 결합합니다. 얼핏 보면 zip 함수와 비슷하지만 zip은 여러 개의 모노를 결합할 수 있는 반면에 zipWith는 한 번에 1개의 모노 객체를 기존 모노에 결합합니다.  예제 1.16은 zipWith를 사용해 모노를 결합하는 예제입니다.

예제 1.16 zipWith을 사용한 결합 예제 - reactor/reactor-basic/src/mono/Mono16.kt

package mono
 
import reactor.core.publisher.Mono
import reactor.util.function.Tuple2
 
fun main() {
    val mono: Mono<Tuple2<String, String>> =
        Mono.just("Republic of Korea")
            .zipWith(Mono.just("Seoul"))
 
    mono.map {
        val country = it.t1
        val capital = it.t2
        "$capital is capital of $country"
    }.subscribe(::println)
}
 
--------------------
출력 결과)
--------------------
Seoul is capital of Republic of Korea

zipWith는 한 번에 1개의 모노를 결합하는 연산자이기 때문에 2개의 모노를 담는 튜플인 Tuple2<T1,T2>를 생성합니다. 그러므로 예제 1.16의 zipWith 연산자는 Mono<Tuple2<String, String>>과 같은 형태를 반환하게 되고 map 연산자 내부에서 전달받은 튜플을 이용해 문자열을 생성합니다. 이때 튜플의 순서는 전달받은 모노의 순서와 동일합니다.

zipWhen 연산자는 zipWith와 마찬가지로 기존 모노 객체에 새로운 모노를 결합하지만 함수의 인자로 모노를 직접 전달 받는 것이 아니라 함수형 인터페이스인 Function을 전달 받습니다.

@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);
}


예제 1.17은 앞선 1.16 예제에서 zipWith를 사용하는 코드를  zipWhen으로 변경하였습니다.

예제 1.17 zipWith을 사용한 결합 예제 - reactor/reactor-basic/src/mono/Mono17.kt

package mono
 
import reactor.core.publisher.Mono
 
fun main() {
    val mono =
       Mono.just("Republic of Korea")
             .zipWhen {
                Mono.just("Seoul")
            }
 
    mono.map {
        val country = it.t1
        val capital = it.t2
        "$capital is capital of $country"
    }.subscribe(::println)
}
 
--------------------
출력 결과)
--------------------
Seoul is capital of Republic of Korea


zipWhen도 zipWith와 마찬가지로 한 번에 1개의 모노를 결합하는 연산자이기 때문에 2개의 모노를 담는 튜플인 Tuple2<T1,T2>를 생성합니다. 하지만 인자를 함수 형태로 전달받았기 때문에 내부에서 특정 로직을 구현할 결과를 모노로 변환하는 경우 유용하게 사용할 수 있습니다.

 

block

리액터의 연산자는 게으르게 동작하여 subscribe가 호출되기 전까지 연산자가 동작하지 않는 것이 일반적입니다. 하지만 block을 사용하면 그 즉시 구독을 차단(block) 하고 이전 연산자로부터 수신된 데이터를 가져옵니다. 예제 1.18은 block을 사용해 데이터를 출력합니다.

예제 1.18 block을 사용한 데이터 출력 예제 - reactor/reactor-basic/src/mono/Mono18.kt

package mono
 
import reactor.core.publisher.Mono
 
fun main() {
    val mono: Mono<String> =
        Mono.just("Welcome Back")
            .zipWhen {
                Mono.just("Synchronous World")
            }.map {
                "${it.t1} to the ${it.t2}"
            }
 
    val text: String? = mono.block()
    println(text)
}
 
--------------------
출력 결과)
--------------------
Welcome Back to the Synchronous World


subscribe를 호출하지 않았지만 block 함수를 호출함으로써 구독이 완료되고 Mono<String>가 아닌 String이 반환되는 것을 확인할 수 있습니다.

block 함수는 논블로킹 스레드 내부에서 사용되면 IllegalStateException이 발생합니다. 예제 1.19는 리액터에서 지원하는 스케줄러를 사용해 단일 논블로킹 스레드를 생성하고 내부에서 block 함수를 호출하여 에러를 발생시키는 예제입니다. 

예제 1.19 논블로킹 스레드 내부에서 block을 사용 예제 - reactor/reactor-basic/src/mono/Mono19.kt

package mono
 
import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
 
fun main() {
    Schedulers.newSingle("NonBlockingSingleThread").schedule {
        val mono: Mono<String> =
            Mono.just("Welcome Back")
                .zipWhen {
                    Mono.just("Synchronous World")
                }.map {
                    "${it.t1} to the ${it.t2}"
                }
        val text: String? = mono.block()
        println(text)
    }
}
 
--------------------
출력 결과)
--------------------
java.lang.IllegalStateException: 
block()/blockFirst()/blockLast() are blocking, which is not supported in thread NonBlockingSingleThread-1

 

예제 1.19와 같이 논블로킹 스레드 내부에서 사용되면 경고와 함께 에러가 발생합니다. 일반적으로 block 함수를 사용하는 상황은 리액터의 비동기, 논블로킹 처리의 이점을 사용하지 않는다는 의미이기 때문에 한정적인 상황에서 사용됩니다.

 

728x90
반응형
728x90
반응형

개요

최근 가장 성장하고 있는 언어인 코틀린에 대해 관심이 있으시다면 Ktor에 대해서도 들어보셨을 거라 생각합니다. 오늘 소개해드릴Ktor (Kay-tor로 발음)는 코틀린과 마찬가지로 JetBrains에서 개발된 프레임워크 로써 멀티 플랫폼에 대한 지원을 목적으로 개발되었습니다. Ktor를 사용하면 코루틴 기반의 비동기 서버와 HTTP 클라이언트 모두 개발이 가능합니다.

오늘은 Ktor를 이용하여 간단한 Todo 웹 서비스를 만들어보고 다른 프레임워크들과는 어떤 차이점이 있는지 알아보도록 하겠습니다.

프로젝트 구조 만들기

수동으로 프로젝트를 만드는 방법도 있지만, Ktor 퀵 스타트에선 더 쉽게 프로젝트를 구성하기 위한 제너레이터인 start.ktor.io와 IntelliJ 플러그인을 제공하고 있습니다. 스프링에 익숙하신 분이라면 start.ktor.iostart.spring.io와 똑같다고 보시면 됩니다. 저는 IntelliJ 플러그인을 사용해 프로젝트 구조를 생성하겠습니다.

첫번째로 IntelliJ Ktor plugin을 사용해 프로젝트를 생성합니다.

저는 CallLogging, DefaultHeaders, Jackson을 선택하였습니다.

두번째로 프로젝트의 GroupId와 ArtifactId를 입력해주세요.

입력 후 Next를 누르시면 아래와 같은 형태의 프로젝트 구조가 만들어집니다.

build.gradle

이번 예제에선 아래와 같이 build.gradle을 세팅해 주었습니다.

group 'com.digimon'
version '1.0-SNAPSHOT'

buildscript {
    ext.kotlin_version = '1.3.61'
    ext.ktor_version = '1.3.1'
    ext.exposed_version = '0.21.+'
    ext.h2_version = '1.4.200'
    ext.jackson_version = '2.10.2'

    repositories {
        mavenCentral()
    }
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

apply plugin: 'java'
apply plugin: 'kotlin'
apply plugin: 'application'

sourceSets {
    main.kotlin.srcDirs = main.java.srcDirs = ['src']
    test.kotlin.srcDirs = test.java.srcDirs = ['test']
    main.resources.srcDirs = ['resources']
    test.resources.srcDirs = ['testresources']
}

sourceCompatibility = 1.8
compileKotlin { kotlinOptions.jvmTarget = "1.8" }
compileTestKotlin { kotlinOptions.jvmTarget = "1.8" }

repositories {
    mavenLocal()
    mavenCentral()
    jcenter()
}

dependencies {
    compile "io.ktor:ktor-server-netty:$ktor_version"
    compile "io.ktor:ktor-jackson:$ktor_version"

    compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
    compile "org.jetbrains.exposed:exposed-core:$exposed_version"
    compile "org.jetbrains.exposed:exposed-jdbc:$exposed_version"
    compile "org.jetbrains.exposed:exposed-dao:$exposed_version"
    compile "org.jetbrains.exposed:exposed-java-time:$exposed_version"

    compile "com.h2database:h2:$h2_version"
    compile "com.zaxxer:HikariCP:3.4.2"

    compile "com.fasterxml.jackson.core:jackson-databind:$jackson_version"
    compile "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jackson_version"

    compile "ch.qos.logback:logback-classic:1.2.3"

    testImplementation "io.ktor:ktor-server-tests:$ktor_version"
}

위 코드에서 중요한 부분은 io.ktor로 시작하는 설정들과 org.jetbrains.exposed로 시작하는 설정들 입니다. 이번 예제에선 Jetbrains에서 만든 경량 SQL 프레임워크인 Exposed도 함께 사용하겠습니다. Exposed는 데이터 엑세스를 위한 방식으로 SQL DSL과 DAO 방식을 지원합니다. 이번 예제에선 DAO 방식을 사용하여 CRUD를 구현해보겠습니다. 2가지 방식에 대한 차이점은여기를 눌러 확인해보시기 바랍니다.

resources/application.conf

ktor {
    deployment {
        port = 9999
    }

    application {
        modules = [ main.kotlin.MainKt.main ]
    }
}

application.conf는 Ktor 프로젝트에서 메인 설정 파일입니다. 스프링의 application.yml 또는 application.properties와 동일하다고 볼 수 있습니다. 주목할 점은 application.conf 파일은 HOCON (Human-Optimized Config Object Notation) 표기법을 기본으로 사용하고 있습니다. HOCON외에도 프로퍼티 표기법도 지원합니다.

  1. port는 9999로 설정하였습니다. 키-밸류를 보면 서버 포트가 9999라는것을 쉽게 예측 가능합니다.
  2. main.kotlin.MainKt.main은 엔트리 포인트(Application Entry Point)입니다. Ktor는 내부적으로 여기에 지정된 모듈을 사용해 서버를 동작시킵니다.

main/kotlin/Main.kt

package main.kotlin

import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer
import io.ktor.application.Application
import io.ktor.application.install
import io.ktor.features.CallLogging
import io.ktor.features.ContentNegotiation
import io.ktor.features.DefaultHeaders
import io.ktor.jackson.jackson
import io.ktor.routing.Routing
import main.kotlin.config.DatabaseInitializer
import main.kotlin.service.TodoService
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args) // 1

const val DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss"

fun Application.main(testing: Boolean = false) { // 2
    install(DefaultHeaders)
    install(CallLogging)
    install(ContentNegotiation) {
        jackson {
            enable(SerializationFeature.INDENT_OUTPUT)
            registerModule(JavaTimeModule().apply {
                addSerializer(LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DATE_TIME_FORMAT)))
                addDeserializer(LocalDateTime::class.java, LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DATE_TIME_FORMAT)))
            })
        }
    }
    install(Routing) {  // 3
        todo(TodoService())
    }
    DatabaseInitializer.init() // 4
}
  1. 서버를 동작시키는 main 함수를 정의하였습니다. io.ktor.server.netty.EngineMain.main(args) 코드를 보시면 저희가 만든 서버가 Netty를 사용하고 있다는 사실을 알 수 있습니다. Ktor는 기본적으로 Netty를 사용하지만 선택적으로 Tomcat, Jetty 등과 같은 웹서버도 지원합니다.
  2. Application.main : 애플리케이션에서 사용될 모듈들을 설정하였습니다. 설정된 모듈들은 이전에 프로젝트 구조 만들기에서 체크했었던 모듈들입니다.
  3. install(Routing) : todo()라는 함수를 호출하였습니다. Routing 모듈은 서버의 API Endpoint에 대한 설정입니다. todo() 함수의 내부는 다음 코드에서 확인하실 수 있습니다.
  4. 마지막으로 DatabaseInitializer.init()은 직접 작성한 데이터베이스 설정 초기화 코드입니다. DatabaseInitializer의 내부 코드도 이어서 확인해보겠습니다.

main/kotlin/config/DatabaseInitializer.kt

package main.kotlin.config

import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import main.kotlin.entity.Todos
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils.create
import org.jetbrains.exposed.sql.transactions.transaction

object DatabaseInitializer {

    fun init() {
        Database.connect(HikariDataSource(hikariConfig()))
        transaction {
            create(Todos)
        }
    }
}

private fun hikariConfig() =
    HikariConfig().apply {
        driverClassName = "org.h2.Driver"
        jdbcUrl = "jdbc:h2:mem:test"
        maximumPoolSize = 3
        isAutoCommit = false
        username = "sa"
        password = "sa"
        transactionIsolation = "TRANSACTION_REPEATABLE_READ"
        validate()
    }

suspend fun <T> query(block: () -> T): T = withContext(Dispatchers.IO) {
    transaction {
        block()
    }
}
  • init : 함수 내부에선 Database.connect(HikariDataSource(hikariConfig()))를 사용하여 데이터베이스에 연결 합니다. 커넥션풀은 HikariCP를 사용하였습니다. 데이터베이스에 연결된 이후엔 transaction 내부에서 create(Todos)를 사용하여 Todos라는 테이블을 생성하게 됩니다. create는 Exposed에 포함된 SchemaUtils에 포함되어있으며, 테이블이 존재하지 않으면 자동으로 생성해줍니다.
  • hikariConfig : 기본적인 HikariCP 설정입니다. 예제에선 in-memory H2DB를 사용하였습니다.
  • query : block이라는 이름의 함수를 인자로 받아서 트랜잭션 범위에서 동작하게 만들었습니다. 이후에 보실 TodoService.kt에서 query 함수를 사용하게됩니다. 주목할 점은 suspend 키워드인데 사전적 의미는 연기하다, 중단하다라는 의미를 가진 단어입니다. suspend는 그 뜻처럼 코루틴 컨텍스트내에서 해당 함수를 일시중지, 재개할 수 있게 하는 일종의 표식(mark)입니다. 그러므로 withContext에 감싸진 block은 기본적으로 코루틴 스코프내에서 동작하게됩니다.
  • 코루틴에 대한 자세한 설명은 이번 예제의 범주를 벗어납니다. 코루틴에 대해 알고싶으다면 이곳에서 확인해 주세요.

main/kotlin/entity/Todo.kt

package main.kotlin.entity

import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.`java-time`.datetime
import java.time.LocalDateTime

// Table scheme
object Todos : IntIdTable() { // 1
    val content = text("content").default("")
    val done = bool("done").default(false)
    val createdAt = datetime("created_at").index().default(LocalDateTime.now())
    val updatedAt = datetime("updated_at").default(LocalDateTime.now())
}

// Entity
class Todo(id: EntityID<Int>) : IntEntity(id) { // 2
    companion object : IntEntityClass<Todo>(Todos)

    var content by Todos.content
    var done by Todos.done
    var createdAt by Todos.createdAt
    var updatedAt by Todos.updatedAt
}
  1. DAO 패키지에는 RDB의 테이블 구조와 매핑되는 Table이라는 최상위 클래스가 존재합니다. 이번 예제의 경우 PK인 id가 Int 타입이므로 Todos는 Table의 하위 클래스인 IntIdTable을 상속받았습니다. 이제 Todos 테이블에는 자동으로 id라는 이름의 PK가 생성됩니다.
  2. Todo 클래스는 Todos 테이블과 매핑되는 엔티티 객체입니다. 마찬가지로 IntEntity를 상속받아서 id를 부모 클래스인 IntEntity에 인자로 넣어줍니다. 그리고 by 키워드를 사용하여 컬럼과 프로퍼티를 매핑해줍니다.
  • by는 원래 위임(Delegation)이라고 하는게 맞지만 매핑이 더 쉽게 이해할 수 있다고 생각하였습니다.

main/kotlin/model/TodoRequest.kt, TodoResponse.kt

package main.kotlin.model

import java.time.LocalDateTime

data class TodoRequest(val content: String,
                       val done: Boolean?,
                       val createdAt: LocalDateTime?,
                       val updatedAt: LocalDateTime?)
package main.kotlin.model

import main.kotlin.entity.Todo
import java.time.LocalDateTime

data class TodoResponse(val id: Int,
                        val content: String,
                        val done: Boolean,
                        val createdAt: LocalDateTime,
                        val updatedAt: LocalDateTime) {

    companion object {
        fun of(todo: Todo) =
            TodoResponse(
                id = todo.id.value,
                content = todo.content,
                done = todo.done,
                createdAt = todo.createdAt,
                updatedAt = todo.updatedAt
            )
    }
}

request, response에 대응하는 DTO를 정의하였습니다.

main/kotlin/TodoRouter.kt

package main.kotlin

import io.ktor.application.call
import io.ktor.features.BadRequestException
import io.ktor.http.HttpStatusCode
import io.ktor.request.receive
import io.ktor.response.respond
import io.ktor.routing.Routing
import io.ktor.routing.delete
import io.ktor.routing.get
import io.ktor.routing.post
import io.ktor.routing.put
import io.ktor.routing.route
import io.ktor.util.KtorExperimentalAPI
import main.kotlin.model.TodoRequest
import main.kotlin.service.TodoService

@KtorExperimentalAPI
fun Routing.todo(service: TodoService) { // 1

    route("todos") {  // 2
        get {
            call.respond(service.getAll())
        }
        get("/{id}") {
            val id = call.parameters["id"]?.toIntOrNull()
                ?: throw BadRequestException("Parameter id is null")
            call.respond(service.getById(id))
        }
        post {
            val body = call.receive<TodoRequest>()
            service.new(body.content)
            call.response.status(HttpStatusCode.Created)
        }
        put("/{id}") {
            val id = call.parameters["id"]?.toIntOrNull()
                ?: throw BadRequestException("Parameter id is null")
            val body = call.receive<TodoRequest>()
            service.renew(id, body)
            call.response.status(HttpStatusCode.NoContent)
        }
        delete("/{id}") {
            val id = call.parameters["id"]?.toIntOrNull()
                ?: throw BadRequestException("Parameter id is null")
            service.delete(id)
            call.response.status(HttpStatusCode.NoContent)
        }
    }
}
  1. Routing.todo는 Ktor의 Routing 모듈에 대한 사용자 정의 확장 함수입니다. 만약 새로운 라우터를 만들고 싶으시다면 이처럼 Routing에 확장 함수를 제공하고 Main.kt에 정의한 함수를 등록해 주시면 됩니다.
  2. route("todos")의 "todos"는 URL 접두사입니다. 예를 들어 주소창에 localhost:9999/todos/1234 라는 경로로 호출하게 되면 route("todos") 내부의 get("/{id}") 라우터 함수가 동작합니다. 예제에선 기본 CRUD에 대응하는 get, post, put, delete를 만들어 두었습니다.

main/kotlin/TodoService.kt

package main.kotlin.service

import io.ktor.features.NotFoundException
import io.ktor.util.KtorExperimentalAPI
import main.kotlin.config.query
import main.kotlin.entity.Todo
import main.kotlin.entity.Todos
import main.kotlin.model.TodoRequest
import main.kotlin.model.TodoResponse
import org.jetbrains.exposed.sql.SortOrder
import java.time.LocalDateTime

@KtorExperimentalAPI
class TodoService {

    suspend fun getAll() = query {
        Todo.all()
            .orderBy(Todos.id to SortOrder.DESC)
            .map(TodoResponse.Companion::of)
            .toList()
    }

    suspend fun getById(id: Int) = query {
        Todo.findById(id)?.run(TodoResponse.Companion::of) ?: throw NotFoundException()
    }

    suspend fun new(content: String) = query {
        Todo.new {
            this.content = content
            this.createdAt = LocalDateTime.now()
            this.updatedAt = this.createdAt
        }
    }

    suspend fun renew(id: Int, req: TodoRequest) = query {
        val todo = Todo.findById(id) ?: throw NotFoundException()
        todo.apply {
            content = req.content
            done = req.done ?: false
            updatedAt = LocalDateTime.now()
        }
    }

    suspend fun delete(id: Int) = query {
        Todo.findById(id)?.delete() ?: throw NotFoundException()
    }
}

TodoService 클래스에는 TodoRouter에서 정의한 get, post, put, delete에 해당하는 메소드들이 정의되어 있습니다.

  • getAll() : Todos 테이블내의 전체 Todo를 가져와서 각 요소를 TodoResponse 모델로 매핑 후 리스트로 반환합니다.
  • getById(id: Int) : id를 가지고 Todos 테이블에서 조회 후 존재한다면 TodoResponse 모델로 컨버팅하여 반환합니다.
  • new(content: String) : id, done, createdAt, updatedAt은 기본값이 존재하므로 content만 인자로 받아서 새로운 Todo를 생성합니다.
  • renew(id: Int, req: TodoRequest) : request로 들어온 변경사항으로 Todo를 업데이트합니다.
  • delete(id: Int) : id를 가지고 Todos 테이블에서 조회 후 존재한다면 삭제합니다.

동작 확인

IntelliJ에서 Main.kt의 main함수를 run하면 localhost:9999로 서버가 동작하게 됩니다. 정상적으로 빌드되었다면 서버가 동작하면서 기본 설정들과 데이터 베이스 초기화 로그를 확인할 수 있습니다.

2020-03-19 01:14:10.944 [main] INFO  com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
2020-03-19 01:14:11.123 [main] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection conn0: url=jdbc:h2:mem:test user=SA
2020-03-19 01:14:11.125 [main] INFO  com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed.
2020-03-19 01:14:11.229 [HikariPool-1 housekeeper] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Pool stats (total=1, active=0, idle=1, waiting=0)
2020-03-19 01:14:11.232 [HikariPool-1 connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection conn1: url=jdbc:h2:mem:test user=SA
2020-03-19 01:14:11.233 [HikariPool-1 connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection conn2: url=jdbc:h2:mem:test user=SA
2020-03-19 01:14:11.233 [HikariPool-1 connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - After adding stats (total=3, active=0, idle=3, waiting=0)
2020-03-19 01:14:11.441 [main] DEBUG Exposed - CREATE TABLE IF NOT EXISTS TODOS (ID INT AUTO_INCREMENT PRIMARY KEY, CONTENT TEXT DEFAULT '' NOT NULL, DONE BOOLEAN DEFAULT false NOT NULL, CREATED_AT DATETIME DEFAULT '2020-03-19T01:14:11.379841' NOT NULL, UPDATED_AT DATETIME DEFAULT '2020-03-19T01:14:11.379918' NOT NULL)
2020-03-19 01:14:11.443 [main] DEBUG Exposed - CREATE INDEX TODOS_CREATED_AT ON TODOS (CREATED_AT)
2020-03-19 01:14:11.458 [main] INFO  Application - Responding at http://0.0.0.0:9999
2020-03-19 01:14:11.458 [main] TRACE Application - Application started: io.ktor.application.Application@2a8a4e0c

Todo 추가

2개의 Todo를 추가해보겠습니다. curl을 사용하여 테스트하겠습니다. 아래의 명령어를 터미널에 입력해보세요.

curl --location --request POST 'localhost:9999/todos' \
--header 'Content-Type: application/json' \
--data-raw '{
    "content" : "첫글"
}'
curl --location --request POST 'localhost:9999/todos' \
--header 'Content-Type: application/json' \
--data-raw '{
    "content" : "두번째 글"
}'

1건 조회

curl --location --request GET 'localhost:9999/todos/1'
{
    "id": 1,
    "content": "첫글",
    "done": false,
    "createdAt": "2020-03-19 01:39:24",
    "updatedAt": "2020-03-19 01:39:24"
}

전체 조회

curl --location --request GET 'localhost:9999/todos'
[
  {
    "id": 2,
    "content": "두번째 글",
    "done": false,
    "createdAt": "2020-03-19 01:39:27",
    "updatedAt": "2020-03-19 01:39:27"
  },
  {
    "id": 1,
    "content": "첫글",
    "done": false,
    "createdAt": "2020-03-19 01:39:24",
    "updatedAt": "2020-03-19 01:39:24"
  }
]

수정

curl --location --request PUT 'localhost:9999/todos/1' \
--header 'Content-Type: application/json' \
--data-raw '{
    "content" : "변경합니다",
    "done" : true
}'
{
  "id": 1,
  "content": "변경합니다",
  "done": true,
  "createdAt": "2020-03-19 01:39:24",
  "updatedAt": "2020-03-19 01:40:22"
}

마치며

수고하셨습니다. Ktor를 사용하여 Todo 서비스를 간단히 만들어 봤는데요 어떠신가요? 저는 Ktor를 공부하면서 이렇게 쉽게 서버를 개발할 수 있다는 것이 축복이라고 느껴질 정도였습니다. 특히 설정이 간편하고, 경량 서버이기 때문에 정말 빠르게 구동할 수 있었습니다. 이러한 장점들은 빠르게 배포하고 유연하게 확장하는 현대적 MSA 구조에도 아주 잘 맞는다고 할 수 있습니다.

 

또한, 코틀린을 좀 더 깊이 있게 사용해 볼 수 있는 기회가 됩니다. 이번 예제에서도 여러 가지 코틀린의 특징들을 간단하게나마 사용해 볼 수 있었습니다. 이런 특징들은 개발자로 하여금 좀 더 나은 코틀린 개발자로 성장하게 합니다.

 

사실 Ktor 외에도 정말 많은 경량 서버 프레임워크들이 출시되어 널리 사용되고 있습니다. 하지만 언어, 프레임워크, IDEA를 같은 회사에서 만들고 있는 경우는 흔치 않기 때문에 많은 개발자들이 Ktor의 성장 가능성을 높게 보고 있는 이유 중 하나입니다.

 

마지막으로 이번 예제는 최대한 심플하게 만들기 위해 핵심적인 부분만 구성하여 개발해봤습니다. 실무에서 사용하게 된다면 좀 더 유지보수를 고려한 구조로 개선할 필요가 있을겁니다. 개선사항은 직접 고민해보시기 바랍니다. 감사합니다.

 

오늘 만든 예제 코드는 https://github.com/digimon1740/todo-ktor 에서 확인 가능합니다.

참고자료

https://www.raywenderlich.com/7265034-ktor-rest-api-for-mobile
https://movile.blog/quickly-building-a-kotlin-rest-api-server-using-ktor
https://github.com/JetBrains/Exposed/wiki/Transactions

728x90
반응형
728x90
반응형


독자분들의 이해를 돕기 위해 역자의 설명을 많이 추가하여 원본 글의 의도와는 다소 다를 수가 있으니 원본글도 같이 참고해주세요. 본문의 예제 코드 대부분은 Java10+ 문법을 기반으로 작성되었으나, 몇 개 예제는 Kotlin으로 작성되었습니다.

 

Javalin은 자바와 코틀린을 위한 경량 웹 프레임워크입니다. Javalin은 기본적으로 웹소켓, HTTP2 그리고 비동기 요청을 지원하며 구조가 심플하고 블로킹 모델로 설계되었습니다. 처음에는 SparkJava 프레임워크를 기반으로 만들어졌지만, 자바스크립트 프레임워크인 Koa.js로부터 영향을 받아 재작성되었습니다.

 

Javalin은 제티(Jetty)위에서 돌아가며, 제티로만 작성한 코드와 성능이 동일합니다. 개발자는 기존의 Servlet와 같은 프레임워크 상에서 정의한 클래스를 확장한다거나 이와 유사한 형태의 에노테이션을 사용할 필요가 없습니다.  또한, 자바와 코틀린 둘 중 어떤 언어를 쓰더라도 동일한 Javalin을 사용할 수 있습니다.

 

자바로 Javalin을 시작할때 개발자는 아래의 코드와 같이 오직 public static void main만 필요합니다.

Javalin.create().start(7000);부분은 내장된 제티 서버를 기반으로 실행되는 코드입니다. 덕분에 아주 심플하게 웹 애플리케이션 서버를 기동할 수 있습니다. 그리고 특정 URL에 대한 처리 부분도 특별한 어노테이션 등 없이 Path와 이를 처리하는 함수를 등록함으로써 쉽게 구현할 수 있습니다.

 

예제로 환경 설정 코드의 일부를 보겠습니다.

config로 바인딩한 부분이 기본적인 HTTP 설정들 이고, routes 함수를 보시면 path라는 함수안에 다시 get, post에 대응하는 함수가 있는것을 확인하실 수 있습니다. ws는 웹소켓 프로토콜을 지원하는 함수입니다.

 

Javalin위에서 패스 파라미터(path params), 쿼리 파라미터(query params) 그리고 폼 파라미터(form params)의 유효성을 체크하는 방법은 매우 단순합니다.

위 코드에서 보다시피 요청으로 들어온 파라미터를 쉽게 검증하고, 원하는 타입에 맞게 바인딩 할 수 있습니다.

 

Java의 RequestFilter나 Golang의 Middleware와 같이 여러 프레임워크에서 지원하는 Request의 앞, 뒤단에서 공통적으로 처리해야 하는 기능은 Handler라는 이름으로 제공합니다. Javalin은 before-handlers, endpoint-handlers, after-handlers, exception-handlers, error-handlers등 총 5개의 Handler가 있습니다.

위 코드는 각각의 Handler들이 어떤 시점에 동작하는지 알 수 있습니다. 각각의 Handler들은 개발할 때 정말 유용하게 사용할 수 있을거라 생각합니다.

 

Javalin에선 함수형 인터페이스(functional interface)인 액세스 매니저(AccessManager)를 제공하여 인증/인가(authentication/authorization)를 처리 할수 있습니다. 개발자는 원한다면 자신만의 액세스 매니저를 구현할 수 있습니다.

액세스 매니저를 설정합니다. 이것은 Spring security의 UserDetailsService를 구현하는것과 비슷합니다. 권한이 없는 요청인 경우 HTTP 401 Unauthorized를 응답으로 내려줍니다.

 

app.routes의 get("/un-secured")와 get("/secured") 부분을 보면 roles에 따라 요청을 분기하는것을 알 수 있습니다.

Javalin 3.0 버전부터 OpenAPI(Swagger)도 플러그인으로 제공합니다. OpenAPI 3.0 스펙의 전체 구현은 DSL과 에노테이션 두 가지 방법으로 이용 가능합니다.

 

OpenAPI DSL

OpenAPI 에노테이션

Javalin 애플리케이션을 배포하는데 개발자는 그저 의존성을 포함하는(maven-assembly-plugin 같은 플러그인을 이용하여) jar 파일을 만든 다음, java -jar filename.jar를 실행하기만 하면 됩니다. Javalin은 임베디드 제티를 포함하고 있어서 별도의 애플리케이션 서버는 필요하지 않습니다.  또한, 교육자를 위한 공식 페이지가 제공되고 있으며, 임베디드 제티가 포함되어 있기 때문에 서버 코딩에 필요한 서블릿 컨테이너(Servlet Container)/애플리케이션 서버 설정(Application Server configuration) 등과 같은 어려운 설정과 배경지식이 필요하지 않으므로 학습자가 쉽게 익힐 수 있다고 강조합니다.

 

공식 사이트에는 Running on GraalVMKotlin CRUD REST API 등과 같이 몇 개의 대표적인 예제들이 있습니다. 이 외에도  tutorials page에서 전체 목록을 확인할 수 있습니다.

 

Javalin에 대해 더 많은 정보를 찾고 싶다면 documentation page를 참고하세요. Javalin은 maven central에서 다운로드 가능합니다.

참고자료

https://javalin.io/

https://github.com/tipsy/javalin

728x90
반응형

+ Recent posts