JSON-OBJECT Software Engineering Blog

Professional Senior Backend Engineer. Specializing in high volume traffic and distributed processing with Kotlin and Spring Boot as core technologies.

View on GitHub
18 July 2023

Kotlin + Spring Boot, Virtual Thread 적용하기

by Taehyeong Lee

개요

Virtual Thread 특징

OpenJDK 21 설치

# SDKMAN 설치
$ curl -s "https://get.sdkman.io" | bash
$ source "$HOME/.sdkman/bin/sdkman-init.sh"

# Amazon Corretto 21 설치 및 기본 JDK 지정
$ sdk i java 21.0.1-amzn
$ sdk default java 21.0.1-amzn

$ sdk current java
Using java version 21.0.1-amzn

# 설치된 버전 확인
$ java --version
openjdk 21.0.1 2023-10-17 LTS
OpenJDK Runtime Environment Corretto-21.0.1.12.1 (build 21.0.1+12-LTS)
OpenJDK 64-Bit Server VM Corretto-21.0.1.12.1 (build 21.0.1+12-LTS, mixed mode, sharing)

IntelliJ IDEA에서 JDK 21 활성화

Settings → Project Structure
→ SDK: [corretto-21] 선택
→ Language Level: [21 (Preview) - String templates, unnamed classes and instance main methods etc.] 선택

build.gradle.kts

// BEFORE: java.sourceCompatibility = JavaVersion.VERSION_17
java.sourceCompatibility = JavaVersion.VERSION_21

tasks.withType<KotlinCompile> {
    kotlinOptions {
        // BEFORE: freeCompilerArgs = listOf("-Xjsr305=strict")
        // BEFORE: jvmTarget = "17"
        // AFTER: 컴파일 단계에 --release 21 --enable-preview 옵션을 추가
        freeCompilerArgs = listOf("-Xjsr305=strict -Xlint:preview --release 21 --enable-preview")
        jvmTarget = "21"
    }
}

tasks.withType<JavaExec> {
    // AFTER: 런타임 단계에 --enable-preview 옵션을 추가
    jvmArgs = listOf("--enable-preview")
}

HTTP 요청 처리를 Virtual Thread로 전환

import org.apache.coyote.ProtocolHandler
import org.springframework.boot.web.embedded.tomcat.TomcatProtocolHandlerCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.util.concurrent.Executors

@Configuration
class TomcatConfig {

    @Bean
    fun protocolHandlerVirtualThreadExecutorCustomizer(): TomcatProtocolHandlerCustomizer<*>? {
        return TomcatProtocolHandlerCustomizer<ProtocolHandler> { protocolHandler: ProtocolHandler ->
            protocolHandler.executor = Executors.newVirtualThreadPerTaskExecutor()
        }
    }
}

비동기 실행을 Virtual Thread로 전환

import org.slf4j.MDC
import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.task.AsyncTaskExecutor
import org.springframework.core.task.TaskDecorator
import org.springframework.core.task.support.TaskExecutorAdapter
import org.springframework.scheduling.annotation.EnableAsync
import java.util.concurrent.Executors

@Configuration
@EnableAsync
class AsyncConfig {

    @Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
    fun asyncTaskExecutor(): AsyncTaskExecutor {

        val taskExecutor = TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor())
        taskExecutor.setTaskDecorator(LoggingTaskDecorator())

        return taskExecutor
    }
}

class LoggingTaskDecorator : TaskDecorator {

    override fun decorate(task: Runnable): Runnable {

        val callerThreadContext = MDC.getCopyOfContextMap()

        return Runnable {
            callerThreadContext?.let {
                MDC.setContextMap(it)
            }
            task.run()
        }
    }
}

스케쥴러 실행을 Virtual Thread로 전환

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.TaskScheduler
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.concurrent.ConcurrentTaskScheduler
import java.util.concurrent.Executors
 
@Configuration
@EnableScheduling
class SchedulingConfig {
 
    @Bean
    fun taskScheduler(): TaskScheduler {

        return ConcurrentTaskScheduler(
            Executors.newScheduledThreadPool(0, Thread.ofVirtual().factory())
        )
    }
}

Kotlin Coroutine 실행을 Virtual Thread로 전환

import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import java.util.concurrent.Executors

val Dispatchers.LOOM: CoroutineDispatcher
    get() = Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher()

Amazon ECS 컨테이너 기동을 위한 Dockerfile 제작

# Rate Limit 제한을 예방하기 위해 DockerHub을 사용하지 않고 AWS Public ECR을 이용, 그리고 Amazon ECS와의 호환성이 검증된 베이스 이미지를 사용했다.
FROM public.ecr.aws/ews-network/amazoncorretto:17-debian
ENV SPRING_OUTPUT_ANSI_ENABLED=ALWAYS \
      HTTP_PROXY=http:... \
      HTTPS_PROXY=http:...
EXPOSE 8080
USER root
# OpenJDK 21 설치, 자신의 환경에 맞게 21 기반의 베이스 이미지를 사용하면 된다.
RUN apt update -y
RUN apt install wget gnupg -y
RUN update-ca-certificates
RUN wget https://apt.corretto.aws/corretto.key
RUN apt-key add corretto.key
RUN echo 'deb https://apt.corretto.aws stable main' | tee /etc/apt/sources.list.d/corretto.list
RUN apt-get update -y
RUN apt-get install java-21-amazon-corretto-jdk -y
# 앞서 Gradle 설정을 통해 빌드된 .jar 파일의 경로가 build/libs/app.jar 라고 가정, 자신의 환경에 맞게 수정하면 된다.
COPY build/libs/app.jar /app.jar
COPY buildspec/entrypoint.sh /
ENTRYPOINT ["sh", "/entrypoint.sh"]
#!/bin/sh
export ECS_INSTANCE_IP_TASK=$(curl --retry 5 -connect-timeout 3 -s ${ECS_CONTAINER_METADATA_URI})
export ECS_INSTANCE_HOSTNAME=$(cat /proc/sys/kernel/hostname)
export ECS_INSTANCE_IP_ADDRESS=$(echo ${ECS_INSTANCE_IP_TASK} | jq -r '.Networks[0] | .IPv4Addresses[0]')
echo "${ECS_INSTANCE_IP_ADDRESS} ${ECS_INSTANCE_HOSTNAME}" | sudo tee -a /etc/hosts
exec java ${JAVA_OPTS} -server -XX:+UseZGC -XX:+ZGenerational --enable-preview -jar /app.jar
version: 0.2

phases:
  install:
    runtime-versions:
      java: corretto21
    run-as: root
    commands:
      - update-ca-trust
      - javac --version
  pre_build:
    commands:
      - REGION={region}
      - REPOSITORY_URI={repository-uri}
      - IMAGE_NAME={image-name}
      - IMAGE_TAG=latest
      - DEPLOY_TAG=dev
      - COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
      - BUILD_TAG=${COMMIT_HASH:=dev}
      - CONTAINER_NAME={container-name}
      - DOCKERFILE_PATH=buildspec/Dockerfile
      - echo Logging in to Amazon ECR...
      - aws --version
      - aws ecr get-login-password --region $REGION | docker login -u AWS --password-stdin $REPOSITORY_URI
  build:
    commands:
      - echo Building the Docker image...
      - chmod +x ./gradlew
      - ./gradlew build -x test
      - docker build -f $DOCKERFILE_PATH -t $IMAGE_NAME .
      - docker tag $IMAGE_NAME:$IMAGE_TAG $REPOSITORY_URI/$IMAGE_NAME:$DEPLOY_TAG
  post_build:
    commands:
      - echo Pushing the Docker images...
      - docker push $REPOSITORY_URI/$IMAGE_NAME:$DEPLOY_TAG
      - printf '[{"name":"%s","imageUri":"%s"}]' $CONTAINER_NAME $REPOSITORY_URI/$IMAGE_NAME:$DEPLOY_TAG > imagedefinitions.json
      - cat imagedefinitions.json

cache:
  paths:
    - '/root/.m2/**/*'
    - '/root/.gradle/caches/**/*'

artifacts:
  files:
    - imagedefinitions.json

참고 글

tags: Spring Boot - Virtual Thread