Easy Understanding

코틀린(kotlin) + 스프링부트(springboot) + 웹플럭스(webflux) + 코루틴(coroutine) - 웹플럭스에서 코루틴 사용해보기 본문

Spring

코틀린(kotlin) + 스프링부트(springboot) + 웹플럭스(webflux) + 코루틴(coroutine) - 웹플럭스에서 코루틴 사용해보기

appleg1226 2020. 11. 23. 19:40

Non-Blocking Service

최근 웹 기술에는 많은 기술들이 화두가 되고 있지만,

그 중에서도 동시에 많은 요청을 처리하기 위한 기술들이 화두입니다.

 

Java에는 RxJava, Reactor 등 Reactive Streams API를 구현한 라이브러리들이 가장 유명합니다.

그리고 스프링에서는 Reactive Streams API의 구현체 중 하나인 Reactor를 이용하여

'Spring Webflux 프레임워크'를 제공하고 있습니다.

 

Webflux는 Non-Blocking한 서비스를 통해서 많은 요청을 효율적으로 처리할 수 있도록 해줍니다.

 

몇 년이 지나기는 했지만 아직까지는 이러한 기술들이 성숙기가 되고 한 기술로 정착되기에는 시간이 걸릴 것 같습니다.

Webflux를 전문적으로 설명하고 있는 국내 도서도 아직까지는 없는 상황이며, 

웹에서 문제 해결을 위한 검색을 하더라도, 만족할만한 결과를 찾기는 아직까진 쉽지 않습니다.

 


Reactive Streams 방식은 기존의 개발 방식과 패러다임 자체가 아예 다르기 때문에 많은 학습이 필요합니다.

 

대부분의 메서드들을 Mono와 Flux로 반환을 해야 하며,

함수형 프로그래밍에 익숙해져야 하기 때문에 빠르게 적용하기에는 어려움이 있습니다.

 

다음은 제가 프로젝트를 하면서 임의로 작성해본 코드입니다.

전형적인 Reactor 스타일의 코드인데,

각 라인에서 여러가지 Operator를 조합해서 Stream 방식으로 원하는 결과물을 낼 수 있도록 작성합니다.

 

확실히 이전에 우리가 코드를 명령적으로 하나씩 처리했던 것과는 생긴 것이 다릅니다.

public Flux<ItemInformation> getItemListByType(Mono<String> userId, ItemInformation.Type type) {
    return userId
        .flatMap(userRepo::findById)
        .switchIfEmpty(Mono.empty())
        .map(User::getInventory)
        .flatMapMany(Flux::fromIterable)
        .filter(item -> ItemInformation.isType(item, type));
}

 


Kotlin Coroutine

저는 요새 자바와 함께 코틀린을 주로 사용하고 있습니다.

코틀린은 JVM 언어로서 자바와 100% 호환되는 언어이며, Spring의 공식 지원 언어이기도 합니다.

최근에 많은 기업들에서 자바가 아닌 코틀린으로 개발하는 경우가 꽤 늘었다고 들었습니다.

 

코틀린의 여러 장점들 중에 하나는 자바에는 없는 Coroutine(코루틴)이라는 개념을 제공한다는 것입니다.

코루틴은 코드를 'Non-Blocking'하게 동작시켜주는 기술이며 Reactive Streams와 비교되는 기술입니다.

 

기존에 자바에서는 코드를 비동기적으로 작성하는 방법들에는 여러가지가 있었습니다.

주로 멀티 스레드를 활용하는 방법이었습니다.

'Future'를 이용하여 비동기를 처리했고, 이후에 가장 대중적인 방법으로 'Callback'을 이용했습니다.

또한 Callback Hell을 피하기 위한 방법으로 JAVA 8에서는 'CompletableFuture' 같은 방법이 등장했습니다.

 

그러나 코루틴은 이런 것들과 다소 다릅니다.

 

애초에 코루틴은 스레드를 전환하지 않고 자체적으로 코드를 비동기적으로 실행할 수 있습니다.

 

코루틴에는 이름은 Co-Routine으로서 'Routine(루틴)'들이 협동을 뜻하는 'Co'라는 단어와 붙어있습니다.

여러 루틴들이 협력을 이루어 서로 동시에 엄청난 속도로 왔다갔다하면서 실행이 됩니다.

이 방법은 스레드를 전환하지 않고 빠른 Non-Blocking 코드를 작성할 수 있게 해주며, 성능도 만족할만 합니다.

 

자세한 코루틴에 대한 설명은 제가 하는 것 보다는, 제가 코루틴을 공부한 곳의 링크를 남깁니다.

 

첫 번째 블로그 링크는 코루틴의 개념을 알기 쉽게 정리해 놓은 글이며,

두 번째 유튜브 링크는 코루틴의 기본에 대해서 예제들을 통해서 알기 쉽게, 그리고 길게 설명해준 좋은 강좌입니다.

 

wooooooak.github.io/kotlin/2019/08/25/%EC%BD%94%ED%8B%80%EB%A6%B0-%EC%BD%94%EB%A3%A8%ED%8B%B4-%EA%B0%9C%EB%85%90-%EC%9D%B5%ED%9E%88%EA%B8%B0/ 

 

코틀린 코루틴(coroutine) 개념 익히기 · 쾌락코딩

코틀린 코루틴(coroutine) 개념 익히기 25 Aug 2019 | coroutine study 앞서 코루틴을 이해하기 위한 두 번의 발악이 있었지만, 이번에는 더 원론적인 코루틴에 대해서 알아보려 한다. 코루틴의 개념이 정확

wooooooak.github.io

www.youtube.com/watch?v=Vs34wiuJMYk&list=PLbJr8hAHHCP5N6Lsot8SAnC28SoxwAU5A&index=1


Spring with Coroutine

스프링에서는 생각보다 코틀린에 많은 지원을 하고 있는 것 같습니다.

2017년부터 코틀린을 지원하였으며, 별 문제없이 스프링 환경 운용이 가능합니다.

 

코루틴은 Spring Framework 5.2, Spring Boot 2.2 버젼부터 사용이 가능했으며 이 때가 2019년 4월 쯤이었습니다.

생각보다 코루틴을 지원한 지는 시간이 좀 되었더라구요.

 

코루틴 지원을 통해서 Mono와 Flux가 아닌 코틀린의 문법인 suspend로 스프링 어플리케이션 작성이 가능해졌습니다.

 


Spring Webflux in Kotlin

코루틴을 이용한 코드를 설명하기 전에, 코틀린을 이용한 간단한 웹플럭스 어플리케이션 코드를 보겠습니다.

이 코드를 토대로 코루틴을 적용했을 때 달라진 코드와 비교를 하려고 합니다.

 

아래의 코드들을 보시면,

스프링 웹플럭스에서는 모든 메서드들이 Mono와 Flux를 타고 돌아갑니다.

 

커스텀 Service 메서드 등 거의 모든 메서드의 내부 작업과 처리들을 Mono와 Flux를 이용해서 해주어야 하며,

스프링 웹플럭스 프레임워크는 자동으로 Reactive한 서비스 환경을 만들어줍니다.

 

다음 코드들은 간단한 CRUD 환경을 Spring Boot 환경에서 구성한 코드입니다.

 

- ContentRepository.kt

interface ContentRepository: ReactiveMongoRepository<Content, String> {
    fun findByContentName(contentName: String): Mono<Content>
}

 

- ContentService.kt

@Service
class ContentService @Autowired constructor(val contentRepository: ContentRepository){

    private val log = LoggerFactory.getLogger(ContentService::class.java)

    fun addContent(content: Content): Mono<Content> {
        log.info("content added: ${content.contentName}")
        return contentRepository.save(content)
    }

    fun getContent(contentId: String): Mono<Content> {
        log.info("getting content: $contentId")
        return contentRepository.findById(contentId)
    }

    fun getContentByName(contentName: String): Mono<Content> {
        log.info("getting content by name: $contentName")
        return contentRepository.findByContentName(contentName)
    }
}

- ContentController.kt

@RestController
class ContentController @Autowired constructor(val contentService: ContentService) {

    @PostMapping("/content/add")
    fun addContent(@RequestBody content: Content): Mono<ResponseEntity<String>> {
        return contentService.addContent(content)
                .map { ResponseEntity("", HttpStatus.OK) }
    }

    @GetMapping("/content/{id}")
    fun getContentInfo(@PathVariable("id") contentId: String): Mono<ResponseEntity<Any>> {
        return contentService.getContent(contentId)
                .switchIfEmpty(Mono.just(Content("0", "0", Type.BOOK)))
                .map { content ->
                    if (content.id != "0"){
                        ResponseEntity(content, HttpStatus.OK)
                    } else {
                        ResponseEntity("not found", HttpStatus.NOT_FOUND)
                    }
                }
    }

    @GetMapping("/content/by/{name}")
    fun getContentInfoByName(@PathVariable("name") contentName: String): Mono<ResponseEntity<Any>> {
        return contentService.getContentByName(contentName)
                .switchIfEmpty(Mono.just(Content("0", "0", Type.BOOK)))
                .map { content ->
                    if (content.id != "0"){
                        ResponseEntity(content, HttpStatus.OK)
                    } else {
                        ResponseEntity("not found", HttpStatus.NOT_FOUND)
                    }
                }
    }
}

 


Spring Webflux with Coroutine

코루틴을 사용하는 가장 쉬운 시작 방법은 Spring Starter를 이용하는 것입니다.

start.spring.io를 이용하시면 간단하게 구성할 수 있습니다.

 

Spring Starter에서 Kotlin과 Reactive Web을 선택해주면 코루틴의 의존성이 자동으로 'gradle.build.kts'에 추가됩니다.

자동 작성된 build.gradle.kts

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-webflux")
	implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
	implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
	implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
	testImplementation("org.springframework.boot:spring-boot-starter-test")
	testImplementation("io.projectreactor:reactor-test")
}

 

먼저 ContentRepository.kt 코드의 변경사항은 다음과 같습니다.

// Reactor 스타일
interface ContentRepository: ReactiveMongoRepository<Content, String> {
    fun findByContentName(contentName: String): Mono<Content>
}

////////////////////////////////////////////////////////////////////////////

// Coroutine 스타일
interface ContentRepository: ReactiveMongoRepository<Content, String> {
    suspend fun findByContentName(contentName: String): Content?
}

repository 파일에서 커스텀 메서드는 suspend를 걸어주었고, 반환형에서 Mono를 제거해 주었습니다.

 


다음으로 ContentService.kt 코드의 변경사항입니다.

// Reactor 스타일
@Service
class ContentService @Autowired constructor(val contentRepository: ContentRepository){

    private val log = LoggerFactory.getLogger(ContentService::class.java)

    fun addContent(content: Content): Mono<Content> {
        log.info("content added: ${content.contentName}")
        return contentRepository.save(content)
    }

    fun getContent(contentId: String): Mono<Content> {
        log.info("getting content: $contentId")
        return contentRepository.findById(contentId)
    }

    fun getContentByName(contentName: String): Mono<Content> {
        log.info("getting content by name: $contentName")
        return contentRepository.findByContentName(contentName)
    }
}

////////////////////////////////////////////////////////////////////////////

// Coroutine 스타일
@Service
class ContentService @Autowired constructor(private val contentRepository: ContentRepository){

    private val log = LoggerFactory.getLogger(ContentService::class.java)

    suspend fun addContent(content: Content): Content {
        log.info("content added: ${content.contentName}")
        return contentRepository.save(content)
                .awaitSingle()
    }

    suspend fun getContent(contentId: String): Content? {
        log.info("getting content: $contentId")
        return contentRepository.findById(contentId)
                .awaitSingleOrNull()
    }

    suspend fun getContentByName(contentName: String): Content? {
        log.info("getting content by name: $contentName")
        return contentRepository.findByContentName(contentName)
    }
}

코드를 보시면 모든 fun에는 suspend 를 붙여주었습니다.

그리고 반환형을 기존 Mono를 제거한 원래의 타입을 붙여줍니다.

 

그러나 이 상태까지 하면 마무리가 아니며, Repository 메서드의 반환값을 보시면,

뒤에 awaitSingle()과 awaitSingleOrNull() 함수가 붙어 있습니다.

여전히 Repository 메서드에서 반환하는 값은 Mono값이며,

await로 시작하는 함수들은 이것을 코루틴으로 바꿔주기 위한 코루틴 확장함수입니다.

 

그래서 이렇게 

- suspend 추가

- 반환형에서 Mono 제거

- repository 결과에 awaitXXX() 추가

세 가지 단계를 거쳐 코루틴으로 변경했습니다.

 

물론 서비스 코드 쪽은 이 외에도 코루틴을 이용한 다양한 코드가 올 수 있기 때문에 위의 간단한 코드의 모양은

웬만하면 나오지는 않을 겁니다.

 


마지막으로 ContentController.kt 의 변경사항입니다.

// Reactor 스타일
@RestController
class ContentController @Autowired constructor(val contentService: ContentService) {

    @PostMapping("/content/add")
    fun addContent(@RequestBody content: Content): Mono<ResponseEntity<String>> {
        return contentService.addContent(content)
                .map { ResponseEntity("", HttpStatus.OK) }
    }

    @GetMapping("/content/{id}")
    fun getContentInfo(@PathVariable("id") contentId: String): Mono<ResponseEntity<Any>> {
        return contentService.getContent(contentId)
                .switchIfEmpty(Mono.just(Content("0", "0", Type.BOOK)))
                .map { content ->
                    if (content.id != "0"){
                        ResponseEntity(content, HttpStatus.OK)
                    } else {
                        ResponseEntity("not found", HttpStatus.NOT_FOUND)
                    }
                }
    }

    @GetMapping("/content/by/{name}")
    fun getContentInfoByName(@PathVariable("name") contentName: String): Mono<ResponseEntity<Any>> {
        return contentService.getContentByName(contentName)
                .switchIfEmpty(Mono.just(Content("0", "0", Type.BOOK)))
                .map { content ->
                    if (content.id != "0"){
                        ResponseEntity(content, HttpStatus.OK)
                    } else {
                        ResponseEntity("not found", HttpStatus.NOT_FOUND)
                    }
                }
    }
}

////////////////////////////////////////////////////////////////////////////

// Coroutine 스타일
@RestController
class ContentController @Autowired constructor(private val contentService: ContentService) {

    @PostMapping("/content/add")
    suspend fun addContent(@RequestBody content: Content): ResponseEntity<String> {
        contentService.addContent(content)
        return ResponseEntity("added", HttpStatus.OK)
    }

    @GetMapping("/content/{id}")
    suspend fun getContentInfo(@PathVariable("id") contentId: String): ResponseEntity<Any> {
        val result = contentService.getContent(contentId)
        return if(result != null){
            ResponseEntity(result, HttpStatus.OK)
        } else {
            ResponseEntity("not found", HttpStatus.NOT_FOUND)
        }
    }

    @GetMapping("/content/by/{name}")
    suspend fun getContentInfoByName(@PathVariable("name") contentName: String): ResponseEntity<Any> {
        val result = contentService.getContentByName(contentName)
        return if(result != null){
            ResponseEntity(result, HttpStatus.OK)
        } else{
            ResponseEntity("not found", HttpStatus.NOT_FOUND)
        }

    }
}

 

컨트롤러의 경우에도 다음의 과정을 거쳤습니다.

1. suspend를 붙여주고

2. 반환형에서 Mono를 지우고

3. Service에서 받은 결과에 맞게 메서드를 약간 수정해주었습니다.

 

전체적으로 보았을 때 세 가지 부분 다 마찬가지로 suspend 키워드를 붙여주면 

스프링 웹플럭스 프레임워크에서 자동으로 코루틴을 이용하여 환경을 구성해주는 것을 확인할 수 있습니다.

 

일단 코루틴 기반으로 전환하는 과정은 그렇게 복잡하지 않았습니다.

 


그렇지만 프로그램이 위보다 조금만 더 복잡해지더라도, 코루틴이 쉽다고만은 할 수 없을 것 같습니다.

실제로 구현해야할 복잡한 서비스 로직들이 코루틴으로 이루어져야 하기 때문입니다.

 

각 루틴을 어떻게 구성할 것인가, 그리고 그것을 어떻게 코드에 녹여낼 것인가는 

많은 공부와 경험들이 필요한 부분입니다.

 

즉, Coroutine이든 Reactor이든 Non-Blocking 코드를 작성하는 것은 어떤 것이든,

거저 먹을 수 있는 것이 아닌, 노력의 결과인 것 같습니다.

 


결론

사실 이전에 Webflux에서 Mono와 Flux를 이용해서 코드를 짜는 것에서 어려움을 많이 느꼈습니다.

체계적으로 정리된 교육 자료가 많이 없고, 검색해서 나오는 자료들도 괜찮은 정보가 별로 없었기 때문입니다.

그러다보니 '아 너무 어렵다 다른 쉽고 재밌는건 없을까' 하다가 코루틴을 공부하게 되었습니다.

 

코루틴 공부는 나름 재미있었습니다.

"동기식으로 코드를 짜는데 이게 비동기로 돌아간다니?"

"리액티브 스트림보다 훨씬 좋은데?" 라는 생각도 했었고,

아예 이렇게 된김에 앞으로는 무조건 코틀린으로 개발해야겠다라는 생각도 하게 되었습니다.

 

하지만 이 글을 작성하면서 느낀 것은 '공부에 쉬운 길은 없다'는 것입니다.

Coroutine이든 Reactive Stream이든 제대로 사용하려면 더 많은 공부가 필요한 것 같습니다.

 

그리고 괜찮은 코드들을 더 많이 보면서 실력을 늘려나가야겠다는 생각을 해봅니다.

언젠가 자료가 풍부해져서 서로의 노하우들을 공유할 그 날을 생각하며...

 

추가 참고)

docs.spring.io/spring-framework/docs/5.2.0.M1/spring-framework-reference/languages.html#coroutines

spring.io/blog/2019/04/12/going-reactive-with-spring-coroutines-and-kotlin-flow

www.baeldung.com/spring-boot-kotlin-coroutines