728x90
반응형

Kotlin과 Spring Cloud Function 기반 애플리케이션을 AWS EventBridge와 Lambda에 배포하기개요

새롭게 B2C 서비스를 개발하며 배치 작업의 필요성이 발생했는데 기존 서비스의 배치는 SpringBatch로 작성하고 Jenkins를 트리거로 사용하고 있었습니다. 처음엔 기존 서비스와 같은 기술 스택을 고민했지만 과연 현재 요구사항에서 SpringBatch와 Jenkins를 사용할 정도의 규모일까라는 의문이 들었고 인프라 관리 부담 또한 적지 않은 고민이 되었습니다.

이와 같은 고민을 해결하기 위해 개발자가 쉽게 조작할 수 있어야 하고 트리거가 백엔드 API를 호출할 수 있는 형태를 고민했고 KotlinSpring Cloud Function으로 애플리케이션을 개발하고 AWS EventBridge와 Lambda를 같이 사용하는 구조를 생각했습니다.

아키텍쳐

AWS EventBridge, Lambda를 사용해 서비스 API를 호출하는 과정

 

이번 글에서는 위와 같은 고민을 해결한 과정과 애플리케이션 배포 과정을 소개하도록 하겠습니다.

Kotlin과 Spring Cloud Function의 조합

Kotlin 적용 이유

일반적으로 Lambda를 사용하면 Python과 Node.js을 많이 사용하지만 백엔드팀이 익숙한 기술 스택이 Kotlin이고 Lambda에서 JDK 최신 버전인 JVM 21 런타임도 지원하므로 Kotlin을 사용하게 됐습니다.

Kotlin의 장점

  • 간결한 문법
  • 안전한 타입 시스템
  • 자바와의 높은 호환성
  • 코루틴과 같은 현대적 동시성 모델 지원

Spring Cloud Function 이란

Spring Cloud Function은 서버리스 아키텍처를 위한 Spring Cloud 프로젝트 중 하나로 AWS Lambda, Azure Function, GCP Function 등 클라우드 위에서 동작하는 서버리스 플랫폼과 통합을 Adapter로써 지원하며 비즈니스 로직을 간단한 함수 형태로 작성할 수 있게 해줍니다.

제가 경험해본 Spring Cloud Function의 다른 사용 예시는 주식 시세 애플리케이션을 개발하며 Spring Cloud Stream 을 사용해 Source, Processor, Sink 단계별로 비즈니스 로직을 함수 단위 애플리케이션으로 만들어주던 경험이 너무 좋았는데 Spring Cloud Stream도 각 함수 단위 애플리케이션을 개발할때 Spring Cloud Function을 사용하고 있습니다.

Spring Cloud Function의 장점

  • 서버리스 환경에 최적화된 스프링 부트 모델
  • 다양한 클라우드 플랫폼과의 호환성
  • 함수 단위의 비즈니스 로직 구현 용이
  • 특정 플랫폼에 의존성이 없는 프로그래밍 모델 지원

AWS EventBridge와 Lambda

AWS EventBridge는 서버리스 이벤트 버스 서비스로, 다양한 AWS 서비스 및 외부 애플리케이션 간 이벤트 드리븐 아키텍쳐 형태로 쉽게 구축할 수 있도록 지원합니다. AWS Lambda는 서버리스 컴퓨팅을 제공하여, 코드 실행을 위한 서버 관리 필요 없이 비즈니스 로직에 집중할 수 있게 합니다.

EventBridge의 장점

  • 높은 확장성과 느슨한 결합 제공
  • 실시간 데이터 처리 및 반응형 시스템 구축 가능
  • cron, rate 기반의 스케쥴러 지원

Lambda의 장점

  • 빠른 시작 시간과 효율적인 리소스 관리
  • 다양한 프로그래밍 언어 지원
  • 간편한 배포 및 관리

Spring Cloud Function 애플리케이션 개발 과정

프로젝트 구성

예제로 사용한 기술 스택

  • Spring Boot 3.2.1
  • Spring Cloud 2023.0.0
  • Kotlin + Gradle Kotlin

Spring Initializr에 접속해서 아래와 같이 사용할 버전과 Spring Cloud Function 의존성을 추가한 뒤 초기 프로젝트 구성을 다운로드 받습니다.

다운로드한 프로젝트를 Intellij IDEA에 import 한 뒤 build.gradle.kts 를 열어 아래와 같이 설정합니다.

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
	id("org.springframework.boot") version "3.2.1"
	id("io.spring.dependency-management") version "1.1.4"
	id("com.github.johnrengelman.shadow") version "7.1.2"
	kotlin("jvm") version "1.9.21"
	kotlin("plugin.spring") version "1.9.21"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"

java {
	sourceCompatibility = JavaVersion.VERSION_21
}

repositories {
	mavenCentral()
}

extra["springCloudVersion"] = "2023.0.0"

dependencies {
	implementation("org.springframework.cloud:spring-cloud-function-web")
	implementation("org.springframework.cloud:spring-cloud-function-adapter-aws")
	implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("com.github.kittinunf.fuel:fuel:3.0.0-alpha1")

	testImplementation("org.springframework.boot:spring-boot-starter-test")
}

dependencyManagement {
	imports {
		mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
	}
}

tasks.withType<KotlinCompile> {
	kotlinOptions {
		freeCompilerArgs += "-Xjsr305=strict"
		jvmTarget = "21"
	}
}

tasks.withType<Test> {
	useJUnitPlatform()
}

tasks.withType<Jar> {
	manifest {
		attributes["Start-Class"] = "com.example.function.FunctionApplicationKt"
	}
}

tasks.assemble {
	dependsOn("shadowJar")
}

tasks.withType<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar> {
	archiveClassifier.set("aws")
	archiveFileName.set("batch.jar")
	dependencies {
		exclude("org.springframework.cloud:spring-cloud-function-web")
	}
	mergeServiceFiles()
	append("META-INF/spring.handlers")
	append("META-INF/spring.schemas")
	append("META-INF/spring.tooling")
	append("META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports")
	append("META-INF/spring/org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration.imports")
	transform(com.github.jengelman.gradle.plugins.shadow.transformers.PropertiesFileTransformer::class.java) {
		paths.add("META-INF/spring.factories")
		mergeStrategy = "append"
	}
}

위 설정에서 핵심적인 부분은 shadowJar 입니다. shadowJar는 프로젝트의 공통 의존성을 하나의 jar안에 모두 결합하기 위해 사용하는 Gradle 플러그인입니다. 이런 형태를 일반적으로 fat-jar라고도 부릅니다.

애플리케이션 개발

resources/application.yml 파일을 생성한 뒤 아래와 같이 작성한다. 이때 definition에 매핑되는 값은 Lambda를 통해 호출될 함수명과 일치해야합니다.

spring:
  cloud:
    function:
      definition: batchCaller

애플리케이션 코드

application.yml의 definition에 설정한대로 batchCaller 라는 이름의 함수를 Bean으로 구성한 애플리케이션입니다.

package com.example.function

import com.fasterxml.jackson.databind.ObjectMapper
import fuel.Fuel
import fuel.get
import kotlinx.coroutines.runBlocking
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean

@SpringBootApplication
class FunctionApplication(
    private val objectMapper: ObjectMapper,
) {

    data class Payload(
        val url: String,
    )

    @Bean
    fun batchCaller(): (String) -> (String) {
        return { payloadAsString ->
            val payload = objectMapper.readValue(payloadAsString, Payload::class.java)

            runBlocking {
                val response = Fuel.get(payload.url)
                response.statusCode.toString()
            }

        }
    }

}

fun main(args: Array<String>) {
    runApplication<FunctionApplication>(*args)
}

예제의 batchCaller Bean은 아래와 순서로 동작합니다.

  1. EventBridge로부터 넘어온 JSON payload를 함수의 인자로 전달
  2. 인자로 전달받은 JSON payload를 Kotlin의 data class인 Payload로 변환
  3. Fuel client를 사용해 Payload.url을 그대로 호출. 이때 호출된 url은 서비스 API의  엔드 포인트입니다.</aside>
  4. <aside> 💡 Fuel client는 코루틴 기반의 경량 HTTP Client입니다. 예제는 Spring Web이나 Spring WebFlux 의존성이 없으므로 Spring에서 기본 제공하는 RestClient, WebClient등을 사용할 수 없고 서버리스 플랫폼에 올라가는 특성상 가벼운 Client가 유리하므로 Fuel을 선택했습니다.

애플리케이션 빌드

프로젝트 최상위에서 shadowJar 플러그인를 사용해 애플리케이션을 jar로 빌드합니다.

./gradlew shadowJar

빌드 완료 예시

빌드가 완료되면 프로젝트의 build/libs/ 디렉토리에 jar 파일이 생성됩니다. 예제의 경우 batch.jar 로 빌드 되게 설정된 상태입니다. batch.jar는 이후 Lambda에 코드를 업로드할 때 사용됩니다.

AWS EventBridge와 Lambda에 애플리케이션 배포하기

Lambda에 배포

AWS Lambda 콘솔에 접속해서 아래와 같이 함수를 생성합니다.

저희가 중요한 것은 런타임이므로 아키텍쳐나 함수 이름 등 기타 설정은 상황에 맞게 설정하면 됩니다.

함수가 생성되면 코드를 업로드 합니다. 이때 S3 버킷에 링크를 연결하거나 .jar 파일을 직접 업로드할 수 있습니다.

업로드가 완료되었다면 런타임 설정에서 편집을 눌러 아래와 같이 설정을 변경합니다.

핸들러 설정을 org.springframework.cloud.function.adapter.aws.FunctionInvoker::handleRequest 로 변경해주셔야 정상적으로 동작합니다.

설정이 완료되었다면 정상적으로 동작하는지 테스트해 보겠습니다. 테스트 탭에 들어가서 이벤트 JSON을 수정합니다. 이벤트 JSON은 람다 함수로 전달되는 Payload이고 앞서 Spring Cloud Function 애플리케이션을 만들 때 data class로 만들었던 Payload 형태가 되어야 합니다.

아래와 같이 함수 실행 테스트가 완료되었습니다.

이제 EventBridge의 스케쥴러와 앞서 설정한 Lambda 함수를 연결해주겠습니다.

EventBridge 스케쥴러 설정

EventBridge 콘솔에 접속해 일정 생성 버튼을 눌러 스케쥴러를 생성해보겠습니다. 예제의 경우 Rate 기반 일정을 사용해 1분 마다 Event가 발생하도록 설정했습니다. EventBridge는 Rate 기반 일정과 Cron 기반 일정을 같이 지원하므로 편리하게 스케쥴러를 설정할 수 있습니다.

다음엔 EventBridge와 Lambda를 연결합니다. 페이로드에는 Lambda 테스트시 사용했던 것과 같이 Lambda에서 호출할 서비스 API 엔드포인트를 적어줍니다.

모든 설정이 완료되었습니다.

결론

AWS EventBridge와 Lambda에 애플리케이션을 배포하는 것은 서버리스 아키텍처를 활용하는 효과적인 방법입니다. 이 과정을 통해 개발자는 인프라 관리의 부담 없이 비즈니스 로직에 집중할 수 있으며, 빠르고 유연한 클라우드 애플리케이션 개발이 가능해집니다.

일반적으로 JVM 언어만 사용해본 개발자들은 Lambda 기반의 서버리스 애플리케이션을 개발하는데 있어서 부담을 느끼는 상황을 많이 봐왔습니다. 주로 Lambda 함수를 Python과 Node.js로 만드는 경우가 많기 때문인데 Spring Cloud Function을 사용하면 Java나 Kotlin과 같은 JVM 언어에서도 빠르고 쉽게 서버리스 애플리케이션을 개발할 수 있습니다. 또한 Spring Cloud Function은 플랫폼에 상관없이 독립 실행형(Standalone) 애플리케이션을 만들어주므로 비즈니스 로직에 집중한 서비스를 만드는데 도움을 줍니다.

예제 코드

https://github.com/digimon1740/spring-cloud-function-kotlin-aws-lambda

Reference

개요

새롭게 B2C 서비스를 개발하며 배치 작업의 필요성이 발생했는데 기존 서비스의 배치는 SpringBatch로 작성하고 Jenkins를 트리거로 사용하고 있었습니다. 처음엔 기존 서비스와 같은 기술 스택을 고민했지만 과연 현재 요구사항에서 SpringBatch와 Jenkins를 사용할 정도의 규모일까라는 의문이 들었고 인프라 관리 부담 또한 적지 않은 고민이 되었습니다.

이와 같은 고민을 해결하기 위해 개발자가 쉽게 조작할 수 있어야 하고 트리거가 백엔드 API를 호출할 수 있는 형태를 고민했고 KotlinSpring Cloud Function으로 애플리케이션을 개발하고 AWS EventBridge와 Lambda를 같이 사용하는 구조를 생각했습니다.

아키텍쳐

AWS EventBridge, Lambda를 사용해 서비스 API를 호출하는 과정

이번 글에서는 위와 같은 고민을 해결한 과정과 애플리케이션 배포 과정을 소개하도록 하겠습니다.

Kotlin과 Spring Cloud Function의 조합

Kotlin 적용 이유

일반적으로 Lambda를 사용하면 Python과 Node.js을 많이 사용하지만 백엔드팀이 익숙한 기술 스택이 Kotlin이고 Lambda에서 JDK 최신 버전인 JVM 21 런타임도 지원하므로 Kotlin을 사용하게 됐습니다.

Kotlin의 장점

  • 간결한 문법
  • 안전한 타입 시스템
  • 자바와의 높은 호환성
  • 코루틴과 같은 현대적 동시성 모델 지원

Spring Cloud Function 이란

Spring Cloud Function은 서버리스 아키텍처를 위한 Spring Cloud 프로젝트 중 하나로 AWS Lambda, Azure Function, GCP Function 등 클라우드 위에서 동작하는 서버리스 플랫폼과 통합을 Adapter로써 지원하며 비즈니스 로직을 간단한 함수 형태로 작성할 수 있게 해줍니다.

제가 경험해본 Spring Cloud Function의 다른 사용 예시는 주식 시세 애플리케이션을 개발하며 Spring Cloud Stream 을 사용해 Source, Processor, Sink 단계별로 비즈니스 로직을 함수 단위 애플리케이션으로 만들어주던 경험이 너무 좋았는데 Spring Cloud Stream도 각 함수 단위 애플리케이션을 개발할때 Spring Cloud Function을 사용하고 있습니다.

Spring Cloud Function의 장점

  • 서버리스 환경에 최적화된 스프링 부트 모델
  • 다양한 클라우드 플랫폼과의 호환성
  • 함수 단위의 비즈니스 로직 구현 용이
  • 특정 플랫폼에 의존성이 없는 프로그래밍 모델 지원

AWS EventBridge와 Lambda

AWS EventBridge는 서버리스 이벤트 버스 서비스로, 다양한 AWS 서비스 및 외부 애플리케이션 간 이벤트 드리븐 아키텍쳐 형태로 쉽게 구축할 수 있도록 지원합니다. AWS Lambda는 서버리스 컴퓨팅을 제공하여, 코드 실행을 위한 서버 관리 필요 없이 비즈니스 로직에 집중할 수 있게 합니다.

EventBridge의 장점

  • 높은 확장성과 느슨한 결합 제공
  • 실시간 데이터 처리 및 반응형 시스템 구축 가능
  • cron, rate 기반의 스케쥴러 지원

Lambda의 장점

  • 빠른 시작 시간과 효율적인 리소스 관리
  • 다양한 프로그래밍 언어 지원
  • 간편한 배포 및 관리

Spring Cloud Function 애플리케이션 개발 과정

프로젝트 구성

예제로 사용한 기술 스택

  • Spring Boot 3.2.1
  • Spring Cloud 2023.0.0
  • Kotlin + Gradle Kotlin

Spring Initializr에 접속해서 아래와 같이 사용할 버전과 Spring Cloud Function 의존성을 추가한 뒤 초기 프로젝트 구성을 다운로드 받습니다.

다운로드한 프로젝트를 Intellij IDEA에 import 한 뒤 build.gradle.kts 를 열어 아래와 같이 설정합니다.

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
	id("org.springframework.boot") version "3.2.1"
	id("io.spring.dependency-management") version "1.1.4"
	id("com.github.johnrengelman.shadow") version "7.1.2"
	kotlin("jvm") version "1.9.21"
	kotlin("plugin.spring") version "1.9.21"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"

java {
	sourceCompatibility = JavaVersion.VERSION_21
}

repositories {
	mavenCentral()
}

extra["springCloudVersion"] = "2023.0.0"

dependencies {
	implementation("org.springframework.cloud:spring-cloud-function-web")
	implementation("org.springframework.cloud:spring-cloud-function-adapter-aws")
	implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("com.github.kittinunf.fuel:fuel:3.0.0-alpha1")

	testImplementation("org.springframework.boot:spring-boot-starter-test")
}

dependencyManagement {
	imports {
		mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
	}
}

tasks.withType<KotlinCompile> {
	kotlinOptions {
		freeCompilerArgs += "-Xjsr305=strict"
		jvmTarget = "21"
	}
}

tasks.withType<Test> {
	useJUnitPlatform()
}

tasks.withType<Jar> {
	manifest {
		attributes["Start-Class"] = "com.example.function.FunctionApplicationKt"
	}
}

tasks.assemble {
	dependsOn("shadowJar")
}

tasks.withType<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar> {
	archiveClassifier.set("aws")
	archiveFileName.set("batch.jar")
	dependencies {
		exclude("org.springframework.cloud:spring-cloud-function-web")
	}
	mergeServiceFiles()
	append("META-INF/spring.handlers")
	append("META-INF/spring.schemas")
	append("META-INF/spring.tooling")
	append("META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports")
	append("META-INF/spring/org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration.imports")
	transform(com.github.jengelman.gradle.plugins.shadow.transformers.PropertiesFileTransformer::class.java) {
		paths.add("META-INF/spring.factories")
		mergeStrategy = "append"
	}
}

위 설정에서 핵심적인 부분은 shadowJar 입니다. shadowJar는 프로젝트의 공통 의존성을 하나의 jar안에 모두 결합하기 위해 사용하는 Gradle 플러그인입니다. 이런 형태를 일반적으로 fat-jar라고도 부릅니다.

애플리케이션 개발

resources/application.yml 파일을 생성한 뒤 아래와 같이 작성한다. 이때 definition에 매핑되는 값은 Lambda를 통해 호출될 함수명과 일치해야합니다.

spring:
  cloud:
    function:
      definition: batchCaller

애플리케이션 코드

application.yml의 definition에 설정한대로 batchCaller 라는 이름의 함수를 Bean으로 구성한 애플리케이션입니다.

package com.example.function

import com.fasterxml.jackson.databind.ObjectMapper
import fuel.Fuel
import fuel.get
import kotlinx.coroutines.runBlocking
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean

@SpringBootApplication
class FunctionApplication(
    private val objectMapper: ObjectMapper,
) {

    data class Payload(
        val url: String,
    )

    @Bean
    fun batchCaller(): (String) -> (String) {
        return { payloadAsString ->
            val payload = objectMapper.readValue(payloadAsString, Payload::class.java)

            runBlocking {
                val response = Fuel.get(payload.url)
                response.statusCode.toString()
            }

        }
    }

}

fun main(args: Array<String>) {
    runApplication<FunctionApplication>(*args)
}

예제의 batchCaller Bean은 아래와 순서로 동작합니다.

  1. EventBridge로부터 넘어온 JSON payload를 함수의 인자로 전달
  2. 인자로 전달받은 JSON payload를 Kotlin의 data class인 Payload로 변환
  3. Fuel client를 사용해 Payload.url을 그대로 호출. 이때 호출된 url은 서비스 API의  엔드 포인트입니다.</aside>
  4. <aside> 💡 Fuel client는 코루틴 기반의 경량 HTTP Client입니다. 예제는 Spring Web이나 Spring WebFlux 의존성이 없으므로 Spring에서 기본 제공하는 RestClient, WebClient등을 사용할 수 없고 서버리스 플랫폼에 올라가는 특성상 가벼운 Client가 유리하므로 Fuel을 선택했습니다.

애플리케이션 빌드

프로젝트 최상위에서 shadowJar 플러그인를 사용해 애플리케이션을 jar로 빌드합니다.

./gradlew shadowJar

 

빌드 완료 예시

빌드가 완료되면 프로젝트의 build/libs/ 디렉토리에 jar 파일이 생성됩니다. 예제의 경우 batch.jar 로 빌드 되게 설정된 상태입니다. batch.jar는 이후 Lambda에 코드를 업로드할 때 사용됩니다.

 

AWS EventBridge와 Lambda에 애플리케이션 배포하기

Lambda에 배포

AWS Lambda 콘솔에 접속해서 아래와 같이 함수를 생성합니다.

 

저희가 중요한 것은 런타임이므로 아키텍쳐나 함수 이름 등 기타 설정은 상황에 맞게 설정하면 됩니다.

함수가 생성되면 코드를 업로드 합니다. 이때 S3 버킷에 링크를 연결하거나 .jar 파일을 직접 업로드할 수 있습니다.

 

업로드가 완료되었다면 런타임 설정에서 편집을 눌러 아래와 같이 설정을 변경합니다.

 

핸들러 설정을 org.springframework.cloud.function.adapter.aws.FunctionInvoker::handleRequest 로 변경해주셔야 정상적으로 동작합니다.

설정이 완료되었다면 정상적으로 동작하는지 테스트해 보겠습니다. 테스트 탭에 들어가서 이벤트 JSON을 수정합니다. 이벤트 JSON은 람다 함수로 전달되는 Payload이고 앞서 Spring Cloud Function 애플리케이션을 만들 때 data class로 만들었던 Payload 형태가 되어야 합니다.

 

아래와 같이 함수 실행 테스트가 완료되었습니다.

이제 EventBridge의 스케쥴러와 앞서 설정한 Lambda 함수를 연결해주겠습니다.

EventBridge 스케쥴러 설정

EventBridge 콘솔에 접속해 일정 생성 버튼을 눌러 스케쥴러를 생성해보겠습니다. 예제의 경우 Rate 기반 일정을 사용해 1분 마다 Event가 발생하도록 설정했습니다. EventBridge는 Rate 기반 일정과 Cron 기반 일정을 같이 지원하므로 편리하게 스케쥴러를 설정할 수 있습니다.

 

다음엔 EventBridge와 Lambda를 연결합니다. 페이로드에는 Lambda 테스트시 사용했던 것과 같이 Lambda에서 호출할 서비스 API 엔드포인트를 적어줍니다.

 

모든 설정이 완료되었습니다.

결론

AWS EventBridge와 Lambda에 애플리케이션을 배포하는 것은 서버리스 아키텍처를 활용하는 효과적인 방법입니다. 이 과정을 통해 개발자는 인프라 관리의 부담 없이 비즈니스 로직에 집중할 수 있으며, 빠르고 유연한 클라우드 애플리케이션 개발이 가능해집니다.

일반적으로 JVM 언어만 사용해본 개발자들은 Lambda 기반의 서버리스 애플리케이션을 개발하는데 있어서 부담을 느끼는 상황을 많이 봐왔습니다. 주로 Lambda 함수를 Python과 Node.js로 만드는 경우가 많기 때문인데 Spring Cloud Function을 사용하면 Java나 Kotlin과 같은 JVM 언어에서도 빠르고 쉽게 서버리스 애플리케이션을 개발할 수 있습니다. 또한 Spring Cloud Function은 플랫폼에 상관없이 독립 실행형(Standalone) 애플리케이션을 만들어주므로 비즈니스 로직에 집중한 서비스를 만드는데 도움을 줍니다.

예제 코드

https://github.com/digimon1740/spring-cloud-function-kotlin-aws-lambda

Reference

728x90
반응형
728x90
반응형

Backend Engineer 채용 원칙

개요

지난 회사에서 작성했던 Backend Engineer 채용 원칙을 공유합니다. 채용 원칙을 작성했던 이유는 당시 채용을 위해 팀에서 지원자의 이력을 검토하고 인터뷰를 진행하며 각자의 평가 기준이 달라 불필요한 커뮤니케이션 비용이 발생하고 몇 번의 좋은 지원자를 놓치는 상황이 발생해 조직 내부에서 기대하는 필수 역량(Must Have)우대 조건(Nice To Have)을 정리해 팀원들과 인재상을 얼라인하고 지원자를 객관적으로 평가할 수 있었습니다.

채용 원칙의 모든 내용은 저의 과거 경험과 몇개의 아티클을 참고했고 Java/Kotlin 기반의 Backend Engineer 채용을 목적으로 작성했지만 범용적인 역량을 기준으로 작성해 기술 스택이 다른 직군에서도 충분히 활용 가능할 것이라 생각합니다.

이 글은 IC(Individual Contributor) 채용에 목적을 둔 원칙이므로 매니저를 채용할 때는 다른 기준이 필요합니다.

원칙

  1. 객관성을 유지하고 채용의 기준을 낮추지 않습니다.
  2. 서류 스크리닝 과정에서 우려가 있다면 시간을 충분히 확보하여 심도 있는 면접을 진행합니다.
  3. 각 면접 단계에선 각 레벨의 기대 역량을 기준으로 검증합니다.
  4. 지원자의 경험이 우리가 기대하는 경험과 얼마나 부합하는지 검토합니다.

주니어-미드 엔지니어 채용 기준

경력

  • 3 ~ 8년차 엔지니어

기대 역량

  • Must Have
    • 순수 백엔드 경험으로 최소 연차는 3년 이상 (타 직군 경험이 있어도 됨)
    • Java/Spring , Kotlin/Spring을 활용한 백엔드 개발 경험
    • 제품 개발을 통해 비즈니스 임팩트를 내본 경험이 있으신 분
    • 협업 및 프로젝트 주도 경험이 있으며, 다른 팀원과 원활하게 협력할 수 있는 분
    • 중/소규모 프로젝트를 리드하여 제품을 출시해본 경험을 가지신 분
  • Nice To Have
    • 주니어 레벨의 엔지니어를 멘토링하거나 도와준 경험
    • 소속팀에서 영향력있는 기술 결정을 내릴 수 있으신 분
    • 지속적인 학습과 공유를 하고 계신 분 (e.g. 블로그, 깃헙, 강의 등)
    • 플랫폼 엔지니어링 역량 또는 경험이 있으신 분 (e.g. 성능 최적화, 공통 플랫폼 개발, 트러블 슈팅)

시니어 엔지니어 채용 기준

경력

  • 8 ~ 20년차 엔지니어

기대 역량

  • Must Have
    • 순수 백엔드 경험으로 최소 연차는 8년 이상 (타 직군 경험이 있어도 됨)
    • Java/Spring , Kotlin/Spring을 활용한 백엔드 개발 경험
    • 제품 개발을 통해 비즈니스 임팩트를 내본 경험이 있으신 분
    • 협업 및 프로젝트 주도 경험이 있으며, 다른 팀원과 원활하게 협력할 수 있는 분
    • 플랫폼 엔지니어링 역량 또는 경험이 있으신 분 (e.g. 성능 최적화, 공통 플랫폼 개발, 트러블 슈팅)
    • 주니어/미드 레벨의 엔지니어를 멘토링하거나 도와준 경험
    • 소속팀과 회사 차원에서 영향력있는 기술 결정을 내릴 수 있으신 분
  • Nice To Have
    • 지속적인 학습과 공유를 하고 계신 분 (e.g. 블로그, 깃헙, 강의 등)
    • 중/대규모 프로젝트를 리드하여 제품을 출시해본 경험을 가지신 분
    • 팀 매니징이나 리드 경력을 가지신 분

시니어 엔지니어의 정의

시니어 엔지니어는 팀의 경계를 넘어 기여할 수 있어야 합니다. 또한, 아래와 같은 다섯 가지 리더십 레벨을 충족할 수 있는 분을 찾고 있습니다.

  1. 시간을 잘 맞추는 능력 (e.g. 일정 산정, 일정내에 프로젝트 완수)
  2. 본인의 역할을 완벽하게 이해하고 수행하는 능력
  3. 팀 전체에 긍정적인 영향을 줄 수 있는 능력
  4. 회사 목표와 방향성을 이해하고 기여하는 능력
  5. 다른 팀과 협력하여 팀과 회사의 목표를 달성하려는 자세

주니어가 시니어에 대해 갖는 오해 몇 가지

  1. 시니어는 모든 것을 알고 있어야 한다.
  2. 최신 기술을 모두 다루고 있어야 한다.
  3. 많은 책임을 한꺼번에 감당할 수 있어야 한다.
  4. 모든 결정은 시니어가 내린다.
  5. 결정에 대해 반박할 수 없는 사람이다.

참고

728x90
반응형
728x90
반응형

코루틴과 동시성

코루틴은 매우 가볍다

  • 코루틴은 JVM의 플랫폼 스레드보다 리소스 집약도가 낮다. 즉, 훨씬 적은 메모리를 사용하여 더 많은 일을 할 수 있게된다.
  • 예제는 1,000,000개의 코루틴을 만들어서 각각 5초를 기다린 다음 마침표(.)을 출력하는 예제이다.
import kotlinx.coroutines.*

fun main() = runBlocking {
    repeat(1_000_000) { // launch a lot of coroutines
        launch {
            delay(5000L)
            print(".")
        }
    }
}

스레드 예제) 테스트 PC 기준 4000개 정도 생성된 후 OOME가 발생한다.

fun main() {
    repeat(1_000_000) { // launch a lot of threads
        thread {
            Thread.sleep(5000L)
            print(".")
        }
    }
    Thread.sleep(10000L)
}

[0.344s][warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN) for attributes: stacksize: 2048k, guardsize: 16k, detached.
[0.344s][warning][os,thread] Failed to start the native thread for java.lang.Thread "Thread-4074"
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
	at java.base/java.lang.Thread.start0(Native Method)
	at java.base/java.lang.Thread.start(Thread.java:1526)
	at kotlin.concurrent.ThreadsKt.thread(Thread.kt:42)
	at kotlin.concurrent.ThreadsKt.thread$default(Thread.kt:20)
	at Example1Kt.main(example1.kt:17)
	at Example1Kt.main(example1.kt)

코루틴 빌더

코루틴 빌더는 코루틴을 만드는 함수를 말한다.

1. runBlocking

  • runBlocking은 코루틴을 생성하는 코루틴 빌더이다.
  • runBlocking으로 감싼 코드는 코루틴 내부의 코드가 수행이 끝날때 까지 스레드가 블로킹된다
import kotlinx.coroutines.*

fun main() {
    
    runBlocking {
        println("Hello")
    }
     println("World")         
}

// Hello
// World
  • 일반적으로 코루틴은 스레드를 차단하지 않고 사용해야하므로 runBlocking을 사용하는 것은 좋지 않지만 꼭 사용해야하는 경우가 있다.
    • 코루틴을 지원하지 않는 경우 예) 테스트 코드, 스프링 배치 등
  • 실행옵션에 -Dkotlinx.coroutines.debug 을 붙여주면 코루틴에서 수행되는 스레드는 이름 뒤에 @coroutine#1 이 붙어있는 것을 볼 수 있다

2. launch

  • launch는 스레드 차단 없이 새 코루틴을 시작하고 결과로 job을 반환하는 코루틴 빌더이다
  • launch는 결과를 만들어내지 않는 비동기 작업에 적합하기 때문에 Unit을 반환하는 람다를 인자로 받는다
fun main() =  runBlocking<Unit> {
    launch {
        delay(500L)
        println("World!")
    }
    println("Hello")
}

// Hello
// World
  • delay() 함수는 코루틴 라이브러리에 정의된 일시 중단 함수이며 Thread.sleep() 과 유사하지만 현재 스레드를 차단하지 않고 일시 중단 시킨다. 이때 일시 중단 된 스레드는 코루틴내에서 다른 일시 중단 함수를 수행한다
  • launch를 사용해서 여러개의 작업을 동시에 수행할 수 있다
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis

fun main() {

    runBlocking {
        launch {
            val timeMillis = measureTimeMillis {
                delay(150)
            }
            println("async task-1 $timeMillis ms")
        }

        launch {
            val timeMillis = measureTimeMillis {
                delay(100)
            }
            println("async task-2 $timeMillis ms")
        }

    }

}
  • launch가 반환하는 Job 을 사용해 현재 코루틴의 상태를 확인하거나 실행 또는 취소도 가능하다
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis

fun main() = runBlocking<Unit> {
    val job1: Job = launch {
        val timeMillis = measureTimeMillis {
            delay(150)
        }
        println("async task-1 $timeMillis ms")
    }
    job1.cancel() // 취소 

		
    val job2: Job = launch(start = CoroutineStart.LAZY) {
        val timeMillis = measureTimeMillis {
            delay(100)
        }
        println("async task-2 $timeMillis ms")
    }

    println("start task-2")

    job2.start()

}
  • job1.cancel() 을 호출해 코루틴을 취소할 수 있다
  • launch(start = CoroutineStart.LAZY) 를 사용해서 start 함수를 호출하는 시점에 코루틴을 동작시킬 수 있다
    • start 함수를 주석처리하면 launch가 동작하지 않는다

3. async

  • async 빌더는 비동기 작업을 통해 결과를 만들어내는 경우에 적합하다
import kotlinx.coroutines.*

fun sum(a: Int, b: Int) = a + b

fun main() = runBlocking<Unit> {

    val result1: Deferred<Int> = async {
        delay(100)
        sum(1, 3)
    }

    println("result1 : ${result1.await()}")

    val result2: Deferred<Int> = async {
        delay(100)
        delay(100)
        sum(2, 5)
    }

    println("result2 : ${result2.await()}")
}
  • async는 비동기 작업의 결과로 Deferred 라는 특별한 인스턴스를 반환하는데 await 이라는 함수를 통해 async로 수행한 비동기 작업의 결과를 받아올 수 있다
  • 자바 스크립트나 파이썬과 같이 다른 언어의 async-await은 키워드 인 경우가 보통이지만 코틀린의 코루틴은 async-await이 함수인 점이 차이점이다

자바 스크립트의 async-await 예시)

async function showAvatar() {

  // JSON 읽기
  let response = await fetch('/article/promise-chaining/user.json');
  let user = await response.json();

  // github 사용자 정보 읽기
  let githubResponse = await fetch(`https://api.github.com/users/${user.name}`);
  let githubUser = await githubResponse.json();

  // 아바타 보여주기
  let img = document.createElement('img');
  img.src = githubUser.avatar_url;
  img.className = "promise-avatar-example";
  document.body.append(img);

  // 3초 대기
  await new Promise((resolve, reject) => setTimeout(resolve, 3000));

  img.remove();

  return githubUser;
}

showAvatar();

구조적 동시성

  • 동시 실행 가능한 작업을 구조화된 방식으로 관리하여 코드의 가독성을 높이고 오류 가능성을 줄인다.
  • 계층 구조로 관리되어 부모 코루틴은 자식 코루틴의 작업이 모두 끝나기 전까지 종료되지 않는다.
  • 자식 코루틴에서 발생한 에러는 부모 코루틴으로 전파된다. 이를 통해 에러 처리를 중앙화할 수있고, 동시성을 처리하는 개별 코루틴의 에러핸들링이 가능하다.

suspend 함수

  • suspend 함수는 코루틴의 핵심 요소로써 일시 중단이 가능한 함수를 말한다
  • suspend는 키워드이다
  • suspend 함수는 일반 함수를 마음껏 호출할 수 있지만 일반 함수에선 suspend 함수를 호출할 수 없다
package structuredconcurrency

fun main() {
    printHello() // 컴파일 에러
}

suspend fun printHello() = println("hello")
  • Caller 함수에서 suspend 키워드를 붙여주면 된다
package structuredconcurrency

suspend fun main() {
    printHello()
}

suspend fun printHello() = println("hello")
  • 일시 중단 함수는 IntelliJ 에서 suspension point가 표시된다.

coroutineScope

  • runBlocking은 현재 스레드를 블로킹시키고 결과를 기다리지만 coroutineScope 는 스레드가 블로킹되지 않고 결과를 기다린다.
package structuredconcurrency

import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun main() {
    doSomething()
}

private suspend fun doSomething() = coroutineScope {
    launch {
        delay(200)
        println("world!")
    }

    launch {
        println("hello")
    }

}

예외 처리

  • coroutineScope 내부의 자식 코루틴에서 에러가 발생하면 모든 코루틴이 종료된다.
private suspend fun doSomething() = coroutineScope {

    launch {
        delay(200)
        println("world!") // 실행안됨

    }

    launch {
        throw RuntimeException("error")
    }
}
Exception in thread "DefaultDispatcher-worker-2" java.lang.RuntimeException
	at structuredconcurrency.CoroutineScopeErrorKt$doSomething$2$2$1.invokeSuspend(coroutineScopeError.kt:21)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
	at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:570)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:677)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:664)
	Suppressed: kotlinx.coroutines.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@4bbea199, Dispatchers.Default]

  • 코루틴 빌더 내부에서 try-catch로 핸들링
private suspend fun doSomething() = coroutineScope {

    launch {
        delay(200)
        println("world!")

    }

    launch {
        try {
            throw RuntimeException()
        } catch (e: Exception) {
            println("hello")
        }
    }
}

hello
world!
  • supervisorScope 를 사용해서 예외를 부모 코루틴으로 전파하지않는 방법
private suspend fun doSomething() = coroutineScope {

    launch {
        delay(200)
        println("world!")

    }

    supervisorScope {
        launch {
            throw RuntimeException()
        }
    }
}

Exception in thread "DefaultDispatcher-worker-2" java.lang.RuntimeException
	at structuredconcurrency.CoroutineScopeErrorKt$doSomething$2$2$1.invokeSuspend(coroutineScopeError.kt:21)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
	at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:570)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:677)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:664)
	Suppressed: kotlinx.coroutines.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@4bbea199, Dispatchers.Default]
world!
  • 코루틴의 예외 상황을 체계적으로 관리하고 싶을때 CEH(CoroutineExceptionHandler 를 사용한다
  • supervisorScope 와 CoroutineExceptionHandler 함께 사용
private suspend fun doSomething() = coroutineScope {

    launch {
        delay(200)
        println("world!")
    }

    supervisorScope {
        launch(handler) {
            throw RuntimeException()
        }
    }

}

val handler = CoroutineExceptionHandler { _, throwable ->
    println("Caught $throwable")
}

Caught java.lang.RuntimeException
world!

코루틴 채널

  • **Channel** 은 코루틴간의 통신을 위한 파이프라인이다
  • 기본 컨셉은 BlockingQueue 와 유사한 자료구조로 볼 수 있다.
  • 데이터 발신자가 채널에 send 로 데이터를 전달하고 수신 측에서 receive 를 사용해 채널로 부터 데이터를 받아온다.

BlockingQueue 를 사용해 주식 시세를 처리하는 처리하는 예제

package channel

import kotlinx.coroutines.*
import java.util.concurrent.BlockingQueue
import java.util.concurrent.LinkedBlockingQueue
import kotlin.random.Random

fun main() = runBlocking {
    val queue: BlockingQueue<String> = LinkedBlockingQueue()

    // 생산자와 소비자 코루틴을 실행
    producer(queue, 20)
    consumer(queue)
}

suspend fun producer(queue: BlockingQueue<String>, count: Int) = coroutineScope {
    launch {
        repeat(count) {
            val stockPrice = "APPLE : ${Random.nextInt(100, 200)}"
            queue.put(stockPrice)
            delay(100L) // 0.1초 간격으로 데이터 생성
        }
    }
}

fun consumer(queue: BlockingQueue<String>) {
    for (stockPrice in queue) {
        println("Processed $stockPrice")
    }
}
  • put, take 가 모두 블로킹으로 이뤄진다

Channel 를 사용해 주식 시세를 처리하는 처리하는 예제

package channel

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import java.util.concurrent.BlockingQueue
import java.util.concurrent.LinkedBlockingQueue
import kotlin.random.Random

fun main() = runBlocking {
    val channel: Channel<String> = Channel()

    // 생산자와 소비자 코루틴을 실행
    producer(channel, 20)
    consumer(channel)
}

suspend fun CoroutineScope.producer(channel: Channel<String>, count: Int) {
    launch {
        repeat(count) {
            val stockPrice = "APPLE : ${Random.nextInt(100, 200)}"
            channel.send(stockPrice)
            delay(100L) // 0.1초 간격으로 데이터 생성
        }
        channel.close()  // 모든 데이터를 보냈으면 채널을 닫습니다.
    }
}

suspend fun consumer(channel: Channel<String>) {
    for (stockPrice in channel) {
        println("Processed $stockPrice")
    }
}
  • 데이터 송수신 연산(send, receive)이 모두 일시중단되므로 논-블로킹으로 이뤄진다.

플로우 : 비동기 데이터 스트림

  • **Flow** 는 코루틴에서 리액티브 프로그래밍 스타일로 작성할 수 있도록 만들어진 비동기 스트림 API이다
  • 코루틴의 suspend 함수는 단일 값을 반환하지만 Flow를 사용하면 무한대 값을 반환할 수 있다

플로우 빌더

  • flow 빌더를 사용해 코드를 작성하고 emit 을 사용해 데이터를 전달한다
package flow

import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val flow = simple()
    flow.collect { value -> println(value) }
}

fun simple(): Flow<Int> = flow {
    println("Flow started")
    for (i in 1..3) {
        delay(100)
        emit(i)
    }
}
  • 리액티브 스트림과 같이 Terminal Operator(최종 연산자) 인 collect 를 호출하지 않으면 아무런 일도 일어나지 않는다

flowOf 를 사용하면 여러 값을 인자로 전달해서 플로우를 만들 수 있다.

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun main() = runBlocking {
    flowOf(1, 2, 3, 4, 5).collect { value ->
        println(value)
    }
}

asFlow 를 사용하면 존재하는 컬렉션이나 시퀀스를 플로우로 변환할 수 있다

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun main() = runBlocking<Unit> {
    listOf(1, 2, 3, 4, 5).asFlow().collect { value ->
        println(value)
    }
    (6..10).asFlow().collect {
        println(it)
    }
}

다양한 연산자 제공

package flow

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

suspend fun filterExample() {
    (1..10)
        .asFlow()
        .filter { (it % 2) == 0 }
        .collect {
            println(it)
        }
}

suspend fun takeExample() {
    (1..10)
        .asFlow()
        .take(2)
        .collect {
            println(it)
        }
}

suspend fun mapExample() {
    (1..10)
        .asFlow()
        .map { it * it }
        .collect {
            println(it)
        }
}

suspend fun zipExample() {
    val nums = (1..3).asFlow()
    val strs = listOf("one", "two", "three").asFlow()

    nums.zip(strs) { a, b -> "$a -> $b" }
        .collect { println(it) }
}

suspend fun flattenExample() {
    val left = flowOf(1, 2, 3)
    val right = flowOf(4, 5, 6)
    val flow = flowOf(left, right)

    flow.flattenConcat().collect { println(it) }
}
728x90
반응형
728x90
반응형

1. 비동기-논블로킹 프로그래밍

전통적 웹 방식 (스프링 MVC)

  • 전통적 웹 방식은 1개 요청당 1개의 스레드를 사용하는 Thread per Request 모델
  • 요청 당 스레드를 사용하는 방식은 DB, Network IO 등이 발생할 경우 결과를 받기전까지 해당 스레드는 블로킹 상태가 됨


  • 요청을 처리하기 위해 스레드를 생성하고 전환하는데 따른 Context Switching 비용이 발생
  • 요청에 따라 무한정 스레드를 생성할 수 없기 때문에 스레드풀을 사용해 스레드를 재사용(톰캣 기본 200개)

 

 

비동기-논블로킹 방식

  • IO(DB, File, Network) 처리 시 스레드가 대기하지 않고 처리가 완료되면 다양한 방식으로 이벤트를 통지받는 방식 (콜백, 프로미스, async-await 등)
  • 더 적은 스레드로 같은 일을 할 수 있기 때문에 Context Switching에 따른 비용이 훨씬 적다. 즉 하드웨어 자원을 더 적게 사용한다
  • 순차적으로 동작하지 않으므로 코드의 흐름을 파악하기 어렵고 콜백헬(Callback Hell)로 인해 가독성과 유지보수성이 현저히 떨어짐


 

C10K 문제

  • 컴퓨터 과학에서 C10K 문제란 1만명의 사용자가 동시에 접속했을때(1만개의 소켓이 열림) 하드웨어가 충분함에도 기존의 I/O 통지 방식(e.g. select)으로는 제대로 처리하지 못하는 것을 말함
  • Event-Driven 모델을 사용해 해결한다.
    • 커널 레벨에선 OS에서 제공하는 epoll, kqueue와 같은 통지 모델을 사용해 해결할 수 있다.
    • 프로그래머는 로우레벨을 직접 다루지 않고 Netty, Node.js, Nginx, Vert.x 등을 사용한다.


 

2. 리액티브 프로그래밍

  • 비동기-논블로킹을 처리하는 새로운 패러다임
  • 2010년 에릭마이어에 의해 마이크로소프트 .NET 에코 시스템으로 정의됨
  • 리액티브 프로그래밍은 기본적으로 옵저버 패턴 처럼 데이터 제공자가 소비하는 측에 데이터를 통지하는 푸시 기반(Push-Based)
  • 구현체에 따라서 풀과 푸시를 모두 제공하는 경우도 있음 (Pull-Push Hybrid)
  • 데이터의 통지, 완료, 에러 처리를 옵저버 패턴에 영감을 받아 설계되었고 데이터의 손쉬운 비동기 처리를 위해 함수형 언어의 접근 방식을 사용

 

옵저버 패턴

  • 관찰 대상이 되는 객체가 변경되면 대상 객체를 관찰하고 있는 옵저버(Observer)에게 변경사항을 통지하는 디자인 패턴

직접 구현한 옵저버 패턴의 예시

import java.util.*

class Coffee(val name: String)


// 데이터 제공자
class Barista : Observable() {

    private fun makeCoffee(name: String) = Coffee(name)

    fun serve(name: String) {
         setChanged()
         notifyObservers(makeCoffee(name))
    }
}

// 데이터 구독자
class Customer : Observer {

    fun orderCoffee() = "Iced Americano"

    override fun update(o: Observable?, arg: Any?) {
         val coffee = arg as Coffee
         println("I got a cup of ${coffee.name}")
    }
}


fun main(args: Array<String>) {
    val barista = Barista()
    val customer = Customer()

    barista.addObserver(customer)
    barista.serve(customer.orderCoffee())
}

--------------------
출력 결과)
--------------------
I got a cup of Iced Americano

 

리액티브 스트림

  • JVM 환경에서 리액티브 프로그래밍의 표준 API 사양으로 비동기 데이터 스트림논블로킹-백프레셔(Back-Pressure)에 대한 사양을 제공
  • 리액티브 스트림 이전의 비동기식 애플리케이션에서는 멀티 코어를 제대로 활용하기 위해 복잡한 병렬 처리 코드가 필요
  • 처리할 데이터가 무한정 많아져서 시스템의 한계를 넘어서는 경우 애플리케이션은 병목 현상(bottleneck)이 발생하거나 심각한 경우 애플리케이션이 정지되는 경우도 발생할 수 있음(논블로킹-백프레셔로 해결)
  • Netflix, Vmware(Pivotal), Lightbend, Red Hat과 같은 유명 회사들이 표준화에 참여 중
  • 리액티브 스트림 인터페이스

  • Publisher : 데이터를 생성하고 구독자에게 통지
  • Subscriber : 데이터를 구독하고 통지 받은 데이터를 처리
  • Subscription : Publisher, Subscriber간의 데이터를 교환하도록 연결하는 역할을 하며 전달받을 데이터의 개수와 구독을 해지할 수 있다
  • Processor : Publisher, Subscriber을 모두 상속받은 인터페이스
  • 리액티브 스트림에서 Publisher와 Subscriber 간의 데이터 처리 흐름

리액티브 스트림을 표준 사양을 채택한 대표적인 구현체들

  • Project Reactor
  • RxJava
  • JDK9 Flow
  • Akka Streams
  • Vert.x
  • Hibernate Reactive(Vert.x 사용)

 

3. 프로젝트 리액터

  • 프로젝트 리액터(Project Reactor)는 리액티브 스트림의 구현체 중 하나로 스프링의 에코시스템 범주에 포함된 프레임워크이다
  • 리액티브 스트림 사양을 구현하고 있으므로 리액티브 스트림에서 사용하는 용어와 규칙을 그대로 사용한다
  • 리액터를 사용하면 애플리케이션에 리액티브 프로그래밍을 적용할 수 있고 비동기-논블로킹을 적용할 수 있다
  • 함수형 프로그래밍의 접근 방식을 사용해서 비동기-논블로킹 코드의 난해함을 해결한다
  • 백프레셔(Backpressure) 를 사용해 시스템의 부하를 효율적으로 조절할 수 있다
  • 리액터는 리액티브 스트림의 Publisher 인터페이스를 구현하는 모노(Mono)플럭스(Flux)라는 두 가지 핵심 타입을 제공한다
  • 두 타입 모두 리액티브 스트림 데이터 처리 프로토콜대로 onComplete 또는 onError 시그널이 발생할 때 까지 onNext를 사용해 구독자에게 데이터를 통지한다

모노

  • 0..1개의 단일 요소 스트림을 통지하는 발행자(Publisher)

플럭스

  • 0..N개로 이뤄진 무한대 요소를 통지하는 발행자

  • 연산자를 사용하기 위해서는 터미널 오퍼레이터인 구독(subcribe)이 필수이다. 구독하지 않으면 연산자는 실행되지 않는다. (이 개념은 Java8 스트림과 유사하며 WebFlux에선 컨트롤러에서 Mono,Flux 타입 반환시 자동으로 해줌)
  • CompletableFuture, DefferedResult, ListenableFuture 등 기존의 비동기 처리 방식은 구독 형태가 아니며, 단순한 기능만 가지고 있다 (async, join, callback 등)

 

4. 스프링 웹플럭스

  • 기본적으로 Project Reactor 기반 (RxJava와 같은 다른 구현체도 사용 가능함)
  • 서블릿 기반인 스프링 MVC와 대비되는 리액티브 기반의 웹 스택
  • 스프링 MVC와 베타적으로 동작하지만 부분적으로 상호 운용이 가능함 예) WebClient, 애노테이션 등

  • 내부적으로 블로킹 API가 있으면 성능이 현저하게 떨어짐
  • 예시로 Spring Data JPA는 내부에서 JDBC API를 사용하는데 JDBC 드라이버는 블로킹 API (Thread per Connection)이므로 JPA를 쓰는 경우 Spring MVC 쓰는게 더 나은 옵션이다
  • 꼭 블로킹 API 써야한다면 아래와 같이 별도의 스케쥴러로 동작시켜야한다
val blockingWrapper = Mono.fromCallable {

        /* make a remote synchronous call */ 

}.subscribeOn(Schedulers.boundedElastic())

  • 그 외에도 깊게 공부하면 연산자 융합(operator-fusion), Hot-Cold 퍼블리셔 등등 공부할게 정말 많다.

 

스프링 웹플럭스의 코루틴 지원

  • 리액터의 연산자 스타일에 비해 직관적인 스타일(진리의 사바사)
  • 리액터에 비해 러닝커브가 적은 편
  • 공식 레퍼런스 문서의 코틀린 예제들을 보면 코루틴 기반으로 소개하고 있다


코루틴이란?

  • 코루틴(Coroutine)은 코틀린에서 비동기-논블로킹 프로그래밍을 명령형 스타일로 작성할 수 있도록 도와주는 라이브러리이다
  • 코루틴은 멀티 플랫폼을 지원하여 코틀린을 사용하는 안드로이드, 서버 등 여러 환경에서 사용할 수 있다
  • 코루틴은 일시 중단 가능한 함수(suspend function) 를 통해 스레드가 실행을 잠시 중단했다가 중단한 지점부터 다시 재개(resume) 할 수 있다
  • 다른 언어와는 다르게 코틀린의 코루틴은 특별하다

 

코루틴을 사용한 구조적 동시성 예시

suspend fun combineApi() = coroutineScope {
	val response1 = async { getApi1() }
	val response2 = async { getApi2() }
	
	return ApiResult (
		response1.await()
		response2.await()
	)
}

 

 

리액티브가 코루틴으로 변환되는 방식

//Mono → suspend 
fun handler(): Mono<Void> -> suspend fun handler()

//Flux → Flow
fun handler(): Flux<T> -> fun handler(): Flow<T>
  • 모노는 서스펜드 함수로 플럭스는 플로우로 변환되는 걸 알 수 있다. 반대도 성립된다.

 

코루틴을 적용한 컨트롤러 코드

@RestController
class UserController(
	private val userService : UserService,
	private val userDetailService: UserDetailService
) {
	
	@GetMapping("/{id}")
	suspend fun get(@PathVariable id: Long) : User {
		return userService.getById(id)
	}
	
	@GetMapping("/users")
	suspend fun gets() = withContext(Dispatchers.IO) {
		val usersDeffered = async { userService.gets() }
		val userDetailsDeffered = async { userDetailService.gets() }
				
		return UserList(usersDeffered.await(), userDetailsDeffered.await())	
	}

}

 

  • Spring Data R2DBC는 아래와 같이 CoroutineCrudRepository를 지원한다

기존 리액티브 스택의 Spring Data R2DBC, Spring Data Mongo Reactive 등에서 ReactiveRepository 로 개발된 코드에서 코루틴으로 변환한 예제

interface ContentReactiveRepository : ReactiveCrudRepository<Content, Long> {

    fun findByUserId(userId: Long) : Mono<Content>

    fun findAllByUserId(userId: Long): Flux<Content>
}


class ContentService (
	val repository : ContentReactiveRepository
) {

	
	fun findByUserIdMono(userId: Long) : Mono<Content> {
		return repository.findByUserId(userId)
	}	

	suspend findByUserId (userId: Long) : Content {
		return repository.findByUserId(userId).awaitSingle()
	}

}

더 좋은 방법 : CoroutineCrudRepository 를 사용하면 awaitXXX 확장 함수 없이 사용 가능

interface ContentCouroutineRepository : CoroutineCrudRepository<Content, Long> {

    suspend fun findByUserId(userId:Long) : Content?

    fun findAllByUserId(userId: Long): Flow<Content>
}


class ContentService (
	val repository : ContentCouroutineRepository
) {


	suspend findByUserId (userId: Long) : Content {
		return repository.findByUserId(userId)
	}

}

 

 

 

Flow

  • Flow 는 코루틴에서 리액티브 프로그래밍 스타일로 작성할 수 있도록 만들어진 API이다
  • 코루틴의 suspend 함수는 단일 값을 비동기로 반환하지만 Flow를 사용하면 무한대 값을 반환할 수 있다
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking


fun main() = runBlocking<Unit> {
    val flow = simple()
    flow.collect { value -> println(value) }
}

fun simple(): Flow<Int> = flow {
    println("Flow started")
    for (i in 1..3) {
        delay(100)
        emit(i)
    }
}

// Flow started
// 1
// 2
// 3

 

  • 리액티브 스트림과 같이 Terminal Operator(최종 연산자)collect 를 호출하지 않으면 아무런 일도 일어나지 않는다
  • 코루틴의 플로우는 리액티브 스트림에서 영감을 받아 제작
    • 리액터의 플럭스(Flux)는 Pull-Push Hybrid
    • 코틀린의 플로우(Flow)는 Push Only
    • 아직은 리액터 플럭스에 비해 기능이 적다

 

스프링 MVC에서도 리액티브와 코루틴을 쓸수 있다

  • 코루틴, 리액티브를 쓰고 싶은 경우 의존성 추가
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
  • WebClient와 같은 비동기-논블로킹 라이브러리가 필요하다면 의존성 추가
implementation("org.springframework.boot:spring-boot-starter-webflux")
  • 웹플럭스를 바로 도입하기 어렵거나 MVC에서 비동기-논블로킹 구현을 우아하게 작성하고 싶은 경우 이 방법을 추천

 

 

DEMO

예제 코드

https://github.com/digimon1740/backend-techtalk

부하 테스트 시나리오

  1. Mock 데이터를 응답하는 유저 API 서버가 존재함
  2. 유저 API 서버는 API 서버로 부터 요청을 받으면 응답하는데까지 2초가 소요된다
  3. 유저 API 서버가 최초 실행되면 균형있는 테스트를 위해 warm-up을 수행한다
  4. 10초 동안 MVC, WebFlux 서버에 아래 조건으로 요청을 보내고 Vegeta 리포트와, VisualVM 모니터를 확인한다
  5. 100/s 요청 전송
  6. 200/s 요청 전송
  7. 300/s 요청 전송
  8. 1000/s 요청 전송

 

# MVC test
echo 'GET http://localhost:8080/mvc/users/1?delay=2000' | vegeta attack -duration=10s -rate=300/s | vegeta report

 

 

# WebFlux test
echo 'GET http://localhost:8081/webflux/users/1?delay=2000' | vegeta attack -duration=10s -rate=300/s | vegeta report

 

6. 정리

  • 전통적 블로킹 방식에 비해 비동기-논블로킹 모델은 더 적은 리소스로 같거나 더 많은 일을 할 수 있다
  • 필연적으로 비동기-논블로킹 모델은 코드의 가독성이 떨어지고 디버깅이 힘들 수 있다
  • 리액티브 프로그래밍은 비동기-논블로킹 개발을 더 쉽게 해주는 패러다임이다
  • 스프링 MVC는 느리고 구리다? 스프링 웹플럭스는 빠르고 멋지다?
  • 일반적으론 스프링 MVC도 충분하지만 한정된 자원에서 대량의 트래픽이 요구되는 서비스거나 비동기-논블로킹 IO가 필요한 경우 추천
  • 기존 스프링 MVC 프로젝트에 스프링 웹플럭스 의존성을 추가해서 써보는 것도 좋은 방법이다
  • 블로킹 API(JDBC, RestTemplate 등)을 쓸거면 그냥 마음 편히 스프링 MVC로 개발하자
  • 코틀린 + 웹플럭스 조합의 경우 메인은 코루틴을 사용하고 필요에 따라 리액티브를 같이 적용하는 것을 추천
  • 코루틴의 지원 범위를 넘어선 기능을 찾거나 연산자에 대한 학습이 충분하다면 리액티브는 여전히 매력적인 기술이다
  • 새로운 패러다임을 공부하는 것은 항상 어렵지만 도전했을때 얻는 것도 많다
  • 은탄환은 없다.

레퍼런스

https://devsh.tistory.com/category/Reactive Programming

https://tech.lezhin.com/2020/07/15/kotlin-webflux

https://github.com/digimon1740/webflux-demo

https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-reactor/index.html

https://docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html

https://docs.spring.io/spring-framework/docs/current/reference/html/languages.html#coroutines

728x90
반응형

+ Recent posts