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
반응형

코루틴과 동시성

코루틴은 매우 가볍다

  • 코루틴은 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
반응형

개요

최근 가장 성장하고 있는 언어인 코틀린에 대해 관심이 있으시다면 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