728x90
반응형

문제 확인

r2dbc-mysql을 사용해 개발한 애플리케이션에서 netstat | grep 3306으로 조회시 ESTABLISHED로 조회되는 개수가 커넥션 풀에 설정한 값과 상이한것을 확인함 .

r2dbc 풀 설정시 initSize가 20, maxSize가 50인데 아래와 같이 설정과는 다르게 커넥션이 생성되 있는 것을 확인함.

  • A 인스턴스는 2개
  • B 인스턴스는 10개
  • C 인스턴스는 20개

테스트 환경

  • Kotlin:1.4.21
  • spring boot:2.3.7.RELEASE
  • dev.miku:r2dbc-mysql:0.8.2.RELEASE

원인 파악

as-is

import io.r2dbc.pool.ConnectionPool
import io.r2dbc.pool.ConnectionPoolConfiguration
import io.r2dbc.spi.ConnectionFactories
import io.r2dbc.spi.ConnectionFactory
import io.r2dbc.spi.ConnectionFactoryOptions.*

@Configuration
@EnableR2dbcRepositories
class DatabaseConfiguration: AbstractR2dbcConfiguration() {

    override fun connectionFactory(): ConnectionFactory {

        val connectionFactory = ConnectionFactories.get(
            builder()
                .option(DRIVER, "mysql")
                .option(HOST, host)
                .option(USER, userName)
                .option(PORT, port)
                .option(PASSWORD, passWord)
                .option(DATABASE, db)
                .build())

        val configuration = ConnectionPoolConfiguration.builder(connectionFactory)
            .maxIdleTime(Duration.ofSeconds(maxIdleTime))
            .maxCreateConnectionTime(Duration.ofSeconds(maxCreateConnectionTime))
            .maxLifeTime(Duration.ofMinutes(maxLife))
            .initialSize(initialSize)
            .maxSize(maxSize)
            .build()

        return ConnectionPool(configuration)
    }

}    

분명 ConnectionPoolConfiguration에 initialSize와 maxSize를 넣었는데 비정상적으로 동작하는 것을 확인.

to-be

option(DRIVER, "pool")을 추가하였음. 이 설정은 추가하는 이유는 github.com/r2dbc/r2dbc-pool를 보면 Supported ConnectionFactory Discovery Options섹션에 아래와 같이 무조건 pool(Must be pool)로 설정하라고 되어 있음

driver Must be pool

 

import io.r2dbc.pool.PoolingConnectionFactoryProvider.*
import io.r2dbc.spi.ConnectionFactories
import io.r2dbc.spi.ConnectionFactory
import io.r2dbc.spi.ConnectionFactoryOptions.*

@Configuration
@EnableR2dbcRepositories
class DatabaseConfiguration: AbstractR2dbcConfiguration() {

   override fun connectionFactory(): ConnectionFactory =
        ConnectionFactories.get(
            builder()
                .option(DRIVER, "pool")
                .option(PROTOCOL, "mysql")
                .option(HOST, readPoolProperties.host)
                .option(USER, readPoolProperties.username)
                .option(PORT, readPoolProperties.port)
                .option(PASSWORD, readPoolProperties.password)
                .option(DATABASE, readPoolProperties.db)
                .option(MAX_SIZE, r2dbcPoolProperties.maxSize)
                .option(INITIAL_SIZE, r2dbcPoolProperties.initialSize)
                .option(MAX_IDLE_TIME, Duration.ofSeconds(r2dbcPoolProperties.maxIdleTime))
                .option(MAX_CREATE_CONNECTION_TIME, Duration.ofSeconds(r2dbcPoolProperties.maxCreateConnectionTime))
                .option(MAX_LIFE_TIME, Duration.ofMinutes(r2dbcPoolProperties.maxLife))
                .build()
        )

}        

netstat으로 조회시 초기값에 맞춰서 ESTABLISHED된 것을 확인함.

r2dbc-pool 소스를 디버그해보니 option(DRIVER, "pool")이 있고 없고에 따라서 ConnectionFactoryProvider가 달라지는 것을 확인 함

option(DRIVER, "mysql")로 설정시 dev.miku.r2dbc.MySqlConnectionFactoryProvider가 동작하고
option(DRIVER, "pool")로 설정시 io.r2dbc.pool.PoolingConnectionFactoryProvider가 동작하는데
dev.miku.r2dbc.MySqlConnectionFactoryProvider에는 initSize, maxSize에 대한 설정이 없고(github.com/mirromutth/r2dbc-mysqlgithub.com/mirromutth/r2dbc-mysql) 풀링되는 커넥션 팩토리가 아니기 때문에 그때그때마다 필요한 커넥션을 생성하는 방식이었음.

결론

r2dbc-pool을 사용해 커넥션 풀링을 정상적으로 이용하고 싶다면 option(DRIVER, "pool") 옵션은 필수이다.

728x90
반응형
728x90
반응형

이 글은 제가 레진 기술블로그에 동명의 제목으로 공유한 글입니다.

 

제가 일하고 있는 서비스 개발팀은 레진코믹스의 백엔드 서비스를 책임지고 있는 팀입니다. 저희는 작년부터 KotlinSpring WebFlux를 메인 스택으로 선정하여 개발하고 있습니다. 이 글에선 WebFlux 기반의 컨텐츠 인증 서비스를 개발하면서 경험한 이슈들을 공유하려 합니다.

이 글은 WebFlux 또는 리액티브 프로그래밍에 대한 기초 지식을 다루진 않으므로 다소 불친절하게 느껴질 수 있는 점 양해 바랍니다. WebFlux에 대한 기초적인 내용은 제 블로그의 Spring WebFlux와 Kotlin으로 만드는 Todo 서비스 - 1편을 참고하시기 바랍니다.

이 글의 예제는 저희가 서비스하고 있는 코드를 공개용으로 변경했기 때문에 정상 동작을 보장하지 않습니다. 참고 부탁드립니다

이 글에서 다루고 있는 내용

컨텐츠 인증 서비스

컨텐츠 인증 서비스는 유저가 레진코믹스 컨텐츠의 구매 여부를 판별하는 서비스입니다. 기본적으로 이미지 서버와 통신하며 유료 작품이라면 유저가 해당 작품을 구매했는지 판별하거나 무료 작품이라면 회원인지 비회원인지 판별하여 컨텐츠 접근 여부를 응답하는 단순하지만 없어선 안되는 서비스입니다.

컨텐츠 인증 서비스 기술 스택

  • Kotlin 1.3.x
  • Spring Boot 2.3.x
  • Spring WebFlux
  • Spring Data R2DBC 1.1.x
  • Spring Data Redis Reactive
  • Spring Boot Actuator

시스템 구성도

이해를 돕기 위해 작성한 간략한 시스템 구성도입니다.

 

R2DBC(Reactive Relational Database Connectivity)

컨텐츠 인증 서버에 앞서 전에도 WebFlux 기반의 서비스를 출시했었지만 JPA를 사용해 데이터베이스를 엑세스하고 있었습니다. JPA는 내부적으로 JDBC를 사용하기 때문에 DB I/O가 블로킹으로 동작합니다. 기본적으로 리액티브 스택은 블로킹 되는 구간이 있다면 전통적인 MVC 방식에 비해 얻는 이점이 거의 없기 때문에 리액터 공식 문서에서 설명하는 것처럼 아래와 같은 패턴을 사용해 해결하고 있었습니다.

Mono blockingWrapper = Mono.fromCallable(() -> { 
    return /* make a remote synchronous call */ 
});
blockingWrapper = blockingWrapper.subscribeOn(Schedulers.boundedElastic());

하지만 원칙적으로 리액티브 스택은 비동기-논블로킹 형태로 개발하는 것이 자연스럽고 최적의 성능을 보여줍니다. 그리고 때마침 리액티브 기반의 비동기-논블로킹을 지원하는 R2DBC의 GA 버전이 릴리즈 되면서 새로운 서비스에 적용하게 되었습니다.

Spring Data R2DBC 적용

R2DBC를 스프링 환경에서 쉽게 사용하는 방법은 Spring Data R2DBC를 사용하는 것입니다. Spring Data R2DBC 공식 깃헙을 보면 This is Not an ORM 이라는 문구가 가장 먼저 눈에 띕니다. 이것은 JPA가 ORM 프레임 워크임을 어필하는 것과 대조적인 부분입니다.
Spring Data R2DBC는 단순함을 지향하여 일반적인 ORM 프레임 워크가 지원하는 caching, lazy loading, write behind 등을 지원하지 않습니다.

ReactiveCrudRepository를 상속하는 리파지토리 인터페이스

Spring Data JPA나 Spring Data JDBC을 이미 사용하고 있어, Spring Data R2DBC를 적용할때 큰 어려움이 없었습니다.
Spring Data 프로젝트는 리액티브 패러다임을 지원하는 ReactiveCrudRepository를 제공하므로 ReactiveCrudRepository 인터페이스를 상속하는 인터페이스를 만들어 주면 쉽게 Spring Data R2DBC를 사용할 수 있습니다.

interface ContentRepository : ReactiveCrudRepository<Content, Long> {
    fun findFirstByUserIdAnContentIdOrderByIdDesc(userId: Long, contentId:Long) : Mono<Content>
}

멀티 데이터 소스 구현

실무에서 서비스를 개발하다 보면 하나의 서버에서 여러 데이터 소스에 대한 접근이 필요할 수 있습니다. Spring Data R2DBC는 이런 경우에 사용할 수 있도록 AbstractRoutingConnectionFactory라는 추상 클래스를 지원합니다.

MultiTenantRoutingConnectionFactory

MultiTenantRoutingConnectionFactory는 AbstractRoutingConnectionFactory를 상속받아서 determineCurrentLookupKey 함수를 오버라이드 하였습니다.

class MultiTenantRoutingConnectionFactory : AbstractRoutingConnectionFactory() {

    override fun determineCurrentLookupKey(): Mono<Any> {
        return TransactionSynchronizationManager.forCurrentTransaction().map {
            if (it.isActualTransactionActive) {
                if (it.currentTransactionName?.contains(SecondaryDataSourceProperties.BASE_PACKAGE)!!) {
                    SecondaryDataSourceProperties.KEY
                } else {
                    PrimaryDataSourceProperties.KEY
                }
            } else {
                PrimaryDataSourceProperties.KEY
            }
        }
    }
}

determineCurrentLookupKey에선 현재 트랜잭션의 이름을 읽어서 @Transactional을 선언한 서비스의 패키지 기준으로 분기 처리하였습니다. determineCurrentLookupKey의 상세 구현은 개발자가 다양한 방법을 사용할 수 있으므로 이것과 같은 방법을 사용하지 않아도 됩니다.예를 들면 @Transactional(readOnly=true)인 경우엔 리플리케이션으로 라우팅하는 경우도 있을 수 있습니다. 이런 경우엔 현재 트랜잭션이 readOnly인지 판단하는 로직을 내부에 구현해주면 됩니다.

application.yml

application.yml에는 두개의 데이터 소스 설정을 추가하였습니다.

datasource:
  primary:
    url:
    username: 
    password: 
  secondary:
    url:
    username:
    password:

DatasourceProperties

각 데이터 소스의 설정 정보는 DatasourceProperties라는 이름으로 추가하였습니다.@ConfigurationProperties는 application.yml에 지정한 설정 정보의 prefix를 기준으로 동일한 이름의 프로퍼티가 존재하면 자동으로 값을 세팅해 줍니다.@ConstructorBinding은 setter가 아닌 생성자를 사용해 값을 바인딩 해주는 애노테이션입니다. 코틀린의 Data class와 조합하면 설정 클래스를 Immutable 하게 사용할 수 있습니다.

@ConstructorBinding
@ConfigurationProperties(prefix = "datasource.primary")
data class PrimaryDataSourceProperties(val username: String, val password: String, val url: String) {

    companion object {
        const val KEY = "primary"
        const val BASE_PACKAGE = "com.lezhin.backend.service.primary"
        const val TRANSACTION_MANAGER = "primaryTransactionManager"
    }
}

@ConstructorBinding
@ConfigurationProperties(prefix = "datasource.secondary")
data class SecondaryDataSourceProperties(val username: String, val password: String, val url: String) {

    companion object {
        const val KEY = "secondary"
        const val BASE_PACKAGE = "com.lezhin.backend.service.secondary"
        const val TRANSACTION_MANAGER = "secondaryTransactionManager"
    }
}

R2dbcConfig

그 다음 R2dbc 설정 클래스를 만들어서 connectionFactory빈을 생성해 주면 됩니다.

@Configuration
@EnableR2dbcRepositories
class R2dbcConfig(val primaryDataSourceProperties: PrimaryDataSourceProperties,
                  val secondaryDataSourceProperties: SecondaryDataSourceProperties) : AbstractR2dbcConfiguration() {
    @Bean
    override fun connectionFactory(): ConnectionFactory {
        val multiTenantRoutingConnectionFactory = MultiTenantRoutingConnectionFactory()

        val factories = HashMap<String, ConnectionFactory>()
        factories[PrimaryDataSourceProperties.KEY] = primaryConnectionFactory()
        factories[SecondaryDataSourceProperties.KEY] = secondaryConnectionFactory()

        multiTenantRoutingConnectionFactory.setDefaultTargetConnectionFactory(primaryConnectionFactory())
        multiTenantRoutingConnectionFactory.setTargetConnectionFactories(factories)
        return multiTenantRoutingConnectionFactory
    }

    @Bean
    fun primaryConnectionFactory() =
        parseAndGet(Triple(primaryDataSourceProperties.url, primaryDataSourceProperties.username, primaryDataSourceProperties.password))

    @Bean
    fun secondaryConnectionFactory() =
        parseAndGet(Triple(secondaryDataSourceProperties.url, secondaryDataSourceProperties.username, secondaryDataSourceProperties.password))

    private fun parseAndGet(propertiesAsTriple: Triple<String, String, String>): ConnectionFactory {
        val (url,username,password) = propertiesAsTriple

        val properties = URLParser.parseOrDie(url)
        return JasyncConnectionFactory(MySQLConnectionFactory(
            com.github.jasync.sql.db.Configuration(
                username = username,
                password = password,
                host = properties.host,
                port = properties.port,
                database = properties.database,
                charset = properties.charset,
                ssl = properties.ssl
            )))
    }

    @Bean
    fun primaryTransactionManager(@Qualifier("primaryConnectionFactory") connectionFactory: ConnectionFactory?) =
        R2dbcTransactionManager(connectionFactory!!)

    @Bean
    fun secondaryTransactionManager(@Qualifier("secondaryConnectionFactory") connectionFactory: ConnectionFactory?) =
        R2dbcTransactionManager(connectionFactory!!)
}

Reactive Redis를 사용한 캐시 적용

저희는 인프라에 따라서 Redis와 Memcached를 모두 사용하고 있습니다. 이번 컨텐츠 인증 서비스에선 Redis를 사용해 DB에서 읽어온 데이터를 캐시하였습니다. WebFlux를 적용하면서 @Cacheable 사용이 불가하여 별도로 Reactor 호환 캐시 유틸을 만들어서 사용했었습니다만 이번에는 Spring Data Redis Reactive를 직접 사용하는 방법으로 구현하게 되었습니다.

RedisConfig

Redis 클라이언트로 사용될 Lettuce 설정과 ReactiveRedisTemplate에 대한 설정 입니다.

@Configuration
class RedisConfig(@Value("\${spring.redis.host}") val host: String,
                   @Value("\${spring.redis.port}") val port: Int,
                   @Value("\${spring.redis.database}") val database: Int) {

    @Primary
    @Bean("primaryRedisConnectionFactory")
    fun connectionFactory(): ReactiveRedisConnectionFactory? {
        val connectionFactory = LettuceConnectionFactory(host, port)
        connectionFactory.database = database
        return connectionFactory
    }

    @Bean
    fun contentReactiveRedisTemplate(factory: ReactiveRedisConnectionFactory?): ReactiveRedisTemplate<String, Content>? {
        val keySerializer = StringRedisSerializer()
        val redisSerializer = Jackson2JsonRedisSerializer(Content::class.java)
            .apply {
                setObjectMapper(
                    jacksonObjectMapper()
                        .registerModule(JavaTimeModule())
                )
            }
        val serializationContext = RedisSerializationContext
            .newSerializationContext<String, Content>()
            .key(keySerializer)
            .hashKey(keySerializer)
            .value(redisSerializer)
            .hashValue(redisSerializer)
            .build()
        return ReactiveRedisTemplate(factory!!, serializationContext)
    }
}

ContentService

구현은 일반적인 캐시처리 방식으로 cache hit 여부를 판단하여 fallback 처리 하는 구조입니다.

@Service
class ContentService(val contentRepository: ContentRepository,
                      val contentRedisOps: ReactiveRedisOperations<String, Content>) {

    companion object {
        const val DAYS_TO_LIVE = 1L
    }

    @Transactional("primaryTransactionManager")
    fun findById(id: Long): Mono<Content> {
        val key = "content:${id}"
        return contentRedisOps.opsForValue().get(key).switchIfEmpty {
            contentRepository.findById(id).doOnSuccess {
                    contentRedisOps.opsForValue()
                        .set(key, it, Duration.ofDays(DAYS_TO_LIVE))
                        .subscribe()
            }.onErrorResume {
                Mono.empty()
            }
        }
    }
}
  1. Redis에 캐시된 데이터가 있는지 확인하여 존재하지 않으면 switchIfEmpty 내부 코드 동작
    • 이때 사용하는 switchIfEmpty는 Mono.defer로 감싸진 Reactor 코틀린 확장 함수이므로 Mono.defer를 직접 사용하지 않아도 됩니다.
    • fun <T> Mono<T>.switchIfEmpty(s: () -> Mono<T>): Mono<T> = this.switchIfEmpty(Mono.defer { s() })
  2. switchIfEmpty 내부 코드에는 우선 DB에서 조회하여 데이터가 존재하면 에서 내부 코드 동작
  3. 데이터가 존재하면 Redis에 캐시위와 같은 방식으로 캐시 레이어를 리액티브하게 구현할 수 있었습니다.

RestTemplate 대신 WebClient

외부 서비스와 HTTP로 통신해야 하는 경우 가장 흔한 방법은 RestTemplate을 사용하는 것입니다. RestTemplate은 Spring 애플리케이션에서 가장 일반적인 웹 클라이언트지만 블로킹 API이므로 리액티브 기반의 애플리케이션에서 성능을 떨어트리는 원인이 될 수 있습니다.
이런 이유로 Spring5에서 추가된 WebClient를 사용해 리액티브 기반의 비동기-논블로킹 통신을 구현하였습니다.

UserTokenRepositoryImpl

UserTokenRepositoryImpl은 유저의 토큰을 가지고 WebClient를 사용해 유저 서비스를 호출해 받아온 결과를 유저 객체 매핑합니다. 이러한 동작은 모두 리액티브 하게 비동기-논블로킹 형태로 동작합니다.

@Repository
class UserTokenRepositoryImpl(val webClientBuilder: WebClient.Builder) : UserTokenRepository {

    private lateinit var webClient: WebClient

    @PostConstruct
    fun setWebClient() {
        webClient = webClientBuilder.build()
    }

    override fun fetchUser(uri: String, accessToken: String) =
        webClient.get()
            .uri(uri)
            .header("Authorization", "Bearer $accessToken")
            .retrieve()
            .onStatus(HttpStatus::isError) {
                Mono.error(ForbiddenException("Connection Failed"))
            }
            .bodyToMono(User::class.java)
}

@Scheduled 대신 Flux.interval

WebFlux로 서비스를 만들다 보면 기존에는 당연하게 사용하던 기능이 지원되지 않는 경우가 많습니다. @Scheduled도 그중 하나입니다. 만약 일정 간격으로 처리할 작업이 있는 경우 @Scheduled의 대안으로 Flux.interval을 사용할 수 있습니다.

Flux.interval 예시

아래 예제는 90초 간격으로 로그를 출력하도록 만든 예제입니다.

    @PostConstruct
    fun doProcess() = Flux.interval(Duration.ofSeconds(90))
            .map {
                // 구현
                logger.info("hello world")
            }
            .subscribe()

쿠버네티스 환경 모니터링 시스템 구축

저희 레진은 새로 개발하는 서비스는 모두 쿠버네티스 환경에서 운영 중 이고 prometheus를 쿠버네티스 환경에서 기본 모니터링 툴로 사용하고 있습니다.
prometheus는 방식 으로 데이터를 수집합니다. Spring Boot2 부터 메트릭 표준이 된 micrometer를 사용해 prometheus를 지원하는 메트릭을 추가하였습니다.

build.gradle.kts

우선 메트릭 생성에 필요한 의존성을 build.gradle.kts에 추가하였습니다.

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation(group = "io.micrometer", name = "micrometer-registry-prometheus", version = "1.5.1")
}

application.yml

그다음 아래와 같은 형태로 메트릭 엔드포인트를 추가해줍니다. 기본적으로 Spring Boot Actuator는 include에 포함된 엔드포인트만 엑세스가 가능합니다.

management:
  endpoints:
    web:
      exposure:
        include: metrics, prometheus

더 많은 엔드포인트에 대한 정보는 엔드포인트 공식 문서에서 확인할 수 있습니다.

prometheus 엔드포인트

웹 브라우저에서 /actuator/prometheus로 접속해보면 이미지와 같이 prometheus 형식의 메트릭 정보가 출력되는 것을 확인 할 수 있습니다.

)

Grafana 대쉬보드

최종적으로 엔드포인트에서 출력된 정보를 prometheus에서 10초 간격으로 데이터를 풀링해가고 Grafana 대쉬보드에서 시각화된 정보를 관측할 수 있게되었습니다.

)

회고

이번 컨텐츠 인증 서버 개발은 리액티브 프로그래밍과 WebFlux에 대해 좀 더 익숙해지는 기회가 되었습니다. 또 Spring Data R2DBC의 GA 버전이 릴리즈되면서 모든 구간에서 리액티브 스택을 적용할 수 있었습니다.
이외에도 WebFlux와 R2DBC에 대해 공유하고 싶은 주제들이 더 있지만 한편의 블로그로 작성하기엔 너무 길어지는 내용이 될 것 같아서 생략하였습니다.

 

원문

 

Kotlin과 Spring WebFlux 기반의 컨텐츠 인증 서비스 개발 후기

프리미엄 웹툰 서비스 - “레진코믹스” 를 만들고 있는 레진엔터테인먼트가 운영하는 기술 블로그입니다. 글로벌 콘텐츠 플랫폼을 만들면서 익힌 실전 경험과 정보, 최신 기술, 팁들을 공유하�

tech.lezhin.com

 

 

728x90
반응형
728x90
반응형

개요

API를 개발하다보면 처리에 따라서 응답에 특정한 헤더를 내려준다던지, 스테이터스 코드를 조작하여 응답하는 경우가 있다. 이럴때 전통적인 Spring MVC를 사용하면 문제가 되지 않지만 Spring WebFlux로 개발하게 되면 기본적으로 Mono 또는 Flux로 리턴하는것이 원칙이기 때문에 어떻게 처리해야할지 난감스러울 수 있다. 


Spring WebFlux는 함수형 방식과 애노테이션 방식 이렇게 2가지 프로그래밍 모델을 지원한다. 함수형 방식은 ServerResponse를 사용해서 부가적인 응답을 처리할 수 있지만, 애노테이션 방식으로 개발을 하면서 ServerResponse를 사용하게 되면
Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.springframework.web.reactive.function.server.DefaultServerResponseBuilder$WriterFunctionResponse and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)과 같은 에러를 볼수있다.


이에 대한 해결책으로 Spring MVC에서 사용하던 ResponseEntity를 그대로 사용하면된다. 아래 코드를 보고 확인해보자.

예제

아래 코드는 사용자가 성인인지 체크한 후 맞다면 "X-User-Adult" 헤더와 스테이터스 코드 200 Ok를 응답으로 내려주고, 성인이 아니라면 403 Fobidden을 응답하도록 만든 예제이다.

결론

예제처럼 Mono 또는 Flux로 ResponseEntity를 감싸면 쉽게 헤더 및 스테이터스 코드 조작이 가능하다.

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

+ Recent posts