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

 

Project Reactor의 핵심 패키지 중 하나인 reactor.core.scheduler에는 Schedulers 라는 추상 클래스가 존재한다.

이 Schedulers는 Scheduler 인터페이스의 팩토리 클래스이고, publishOn과 subscribeOn 을 위한 여러가지 팩토리 메서드를 제공한다.

 

팩토리 메서드는 대표적으로 아래와 같다. 

  • parallel():  ExecutorService기반으로 단일 스레드 고정 크기(Fixed) 스레드 풀을 사용하여 병렬 작업에 적합함.
  • single(): Runnable을 사용하여 지연이 적은 일회성 작업에 최적화
  • elastic(): 스레드 갯수는 무한정으로 증가할 수 있고 수행시간이 오래걸리는 블로킹 작업에 대한 대안으로 사용할 수 있게 최적화 되어있다.
  • boundedElastic(): 스레드 갯수가 정해져있고 elastic과 동일하게 수행시간이 오래걸리는 블로킹 작업에 대한 대안으로 사용할 수 있게 최적화 되어있다.
  • immediate(): 호출자의 스레드를 즉시 실행한다.
  • fromExecutorService(ExecutorService) : 새로운 Excutors 인스턴스를 생성한다.

 

참고

https://projectreactor.io/docs/core/release/api/

728x90
반응형
728x90
반응형

개요

오늘 예제에서는 Spring WebFlux와 Kotlin으로 만드는 Todo 서비스 - 1편 에서 만들어본 예제를 기반으로 JDBC를 대체하는 R2DBC를 적용해보고 그 둘의 차이점과 R2DBC란 무엇인지 어떤 장단점이 있는지 알아보도록 하겠습니다.

R2DBC

피보탈에서 개발 중인 R2DBC는 Reactive Relational Database Connectivity의 약자로써, 작년 SpringOne Platform 2018에서 처음 발표 되었습니다. 이름에서도 추측 가능하듯이 리액티브 프로그래밍을 가능하게 하는 데이터베이스 인터페이스입니다. 그 말은 즉, JDBC에선 아직 지원하지 않는 비동기(asynchronous), 논 블로킹(non-blocking) 프로그래밍 모델을 지원한다는 이야기이고, 이는 Spring WebFlux의 성능을 최대치로 끌어올릴 수 있다는 이야기가 됩니다.  이 글을 쓰고 있는 시점에선 마일스톤 버전이 배포되고 있습니다. 그 때문에 실무에서의 적용은 부담이 있을 수 있지만 곧 정식 버전이 나올 것으로 보이며 점차 레퍼런스가 늘어날 것으로 기대하고 있습니다.

Spring Data R2DBC

먼저 설명한 대로 R2DBC는 피보탈의 주도로 개발 중입니다. 그렇기 때문에 스프링 프로젝트에서 아주 좋은 궁합을 보여줍니다.  R2DBC는 Spring Data R2DBC를 통해 기존 Spring Boot 프로젝트에 쉽게 통합될 수 있고, 다른 Spring Data 프로젝트들이 다르지 않듯이 데이터베이스 연동에 대한 뛰어난 추상화를 제공합니다. 이번 예제에선 Spring Data R2DBC를 적용하면서 JpaRepository 인터페이스를 걷어내고, WebFlux의 Flux와 Mono를 지원하는 ReactiveCrudRepository 인터페이스를 이용하겠습니다.

ReactiveCrudRepository 살펴보기

앞서 ReactiveCrudRepository라는 인터페이스에 대해 간략히 언급했었습니다.  ReactiveCrudRepository는  리액티브 스트림을 지원하는 CRUD 메서드들을 포함하는 인터페이스 입니다. 내부를 확인해보면 기존의 다른 Spring Data의 리파지토리 인터페이스인 CrudRepository와 크게 다르지 않습니다. 다만 전통적인 CrudRepository와 다른 점은 리턴 타입이 Flux 또는 Mono라는 것과 파라미터에 Publisher가 추가되었다는 점이 다를 뿐입니다.

사용된 툴과 기술들

  1. Spring boot 2.2+
  2. Spring Data R2DBC
  3. Maven 3+
  4. Kotlin 1.3
  5. IDE - IntelliJ
  6. H2DB

프로젝트 세팅

pom.xml

제가 작성한 pom.xml 전체 코드입니다.  이 중에서 눈여겨보실 부분이 몇 군데 있습니다.

첫 번째는 "<version>2.2.0.M6</version>" 입니다. 글을 작성하는 시점에서 Spring Data R2DBC를 정식 지원하는 Spring Boot의 버전은 2.2+입니다. M6라는 작명으로 봐서 마일스톤 버전이라는 것을 알 수 있습니다.

 

두번째로 "org.springframework.boot.experimental" 라는 groupId가 눈에 띄실 것 입니다. experimental이라는 뜻답게 아직은 실험적인 프로젝트이므로, 정식 버전이 나오더라도 큰 틀이 변하진 않을 거라 예상되지만, 내부 구현이 조금 달라질 수 있습니다.

 

마지막으로 마일스톤 라이브러리가 mavencentral에 올라가있지 않아서 리파지토리 경로를 아래와 같이 추가해주셔야 합니다.
resources/application.yml

application.yml의 설정은 지난 편과 거의 동일합니다.  단지 spring.jpa.show-sql 설정을 R2DBC에서 지원하지 않으므로 제거하였습니다.

resources/schema.sql

R2DBC에선 ddl-auto 기능이 없어서 초기화 스크립트를 만들어주었습니다. schema.sql은 Spring Boot 프로젝트의 초기 스키마 생성 스크립트 스펙입니다.  data.sql로 만들면 schema.sql에서 생성된 스키마를 기준으로 데이터를 insert 할 수 있습니다. 이 예제는 임베디드 데이터베이스를 사용 중이라 애플리케이션 실행 시 이 스크립트가 자동으로 돌아가지만, 임베디드 데이터베이스가 아니라면 운영환경에선 리스크가 있어서  spring.datasource.initialization-mode 설정으로 on/off 할 수 있습니다. 관련하여 자세한 내용은 링크를 확인해주세요.  https://github.com/spring-projects-experimental/spring-boot-r2dbc/blob/master/documentation.adoc#user-content-database-initialization

 

spring-projects-experimental/spring-boot-r2dbc

Experimental Spring Boot support for R2DBC. Contribute to spring-projects-experimental/spring-boot-r2dbc development by creating an account on GitHub.

github.com

스프링 부트 설정

src/main/kotlin/com/digimon/demo/config/AppConfig.kt

이 클래스는 AbstractR2dbcConfiguration의 기본 사양인 connectionFactory를 구현한 클래스입니다.  저는 임베디드 H2DB를 사용하기 때문에 H2ConnectionFactory를 빌드 했지만, PostgreSQL 등 R2DBC가 지원하는 다른 데이터베이스를 사용하실 경우  해당 데이터베이스의 ConnectionFactory만 구성해주시면 쉽게 변경됩니다.

도메인(Domain)

src/main/kotlin/com/digimon/demo/domain/Todo.kt

1편의 도메인 코드를 보셨다면 도메인 클래스도 변경이 있다는 걸 아실 수 있으실 겁니다. 우선 @Entity, @Column 애노테이션이 제거되었습니다. 이 애노테이션들은 JPA의 스펙이므로 R2DBC 사용시엔 불필요합니다. 두 번째로 class 앞에 붙은 data 키워드가 보이실 겁니다. data 키워드는 Kotlin에 존재하는 키워드로써, 선언한 필드를 기준으로 equals, toString, hashCode 등의 메서드를 자동 생성해줍니다. Java의 경우 이 메서드들을 직접 구현하던가 Lombok을 이용해 Annotation Processing 단계에서 자동 생성해줬었습니다. data 클래스는 한 가지 제약조건이 있는데 최소한 1개 이상의 필드를 초기화하는 기본 생성자가 필요합니다. 예제에서는 모든 필드를 생성자에서 선언해주었습니다.

리파지토리(Repository)

src/main/kotlin/com/digimon/demo/todo/TodoRepository.kt

리파지토리는 딱 한 가지 변경되었습니다. 기존 JpaReqository를 제거하고 앞서 설명드린 ReactiveCrudRepository를 상속받도록 하였습니다. 이렇게 되면 findAll, save, delete 메서드 등을 리액티브 스트림으로 연결할 수 있게 됩니다.

핸들러(Handler)

src/main/kotlin/com/digimon/demo/handler/TodoHandler.kt

리파지토리가 변경되면서 새롭게 리팩토링 된 핸들러 클래스입니다. 더욱 단순하고 명확하게 바뀌었습니다.
첫 번째 변화는 404 Not Found의 추가입니다. 1편의 소스를 기반으로 애플리케이션을 실행했을 때 존재하지 않는 Todo를 요청하면 어떻게 될까요? 예를 들어 GET http://localhost:8080/todos/100 로 요청하면 스테이터스 코드 200 OK이면서 빈 본문 응답이 내려옵니다. 관점의 차이지만 저는 RESTful 한 설계를 선호하므로 404 Not Found를 반환해서 좀 더 시맨틱 한 응답을 구현하였습니다.
두 번째는 findAll, findById, save, delete 메서드를 수행할 때 Mono또는 Flux로 변환할 필요 없이 바로 리액티브 스트림을 연결하였다는 점이 이전 코드와 다릅니다. 이말은 즉 데이터베이스와의 모든 통신이 논 블로킹(non-blocking)으로 실행된다는 것입니다. 또한 스트림으로 연결된 함수의 동작은 Java8에 추가된 Stream API와 매우 유사하게 게으른 로딩(lazy loading)으로 지연 종결 연산자(terminal operations)에 의해 트리거 되기 전까진 무의미하게 코드가 실행되지 않습니다.

코드 해설

  1. getAll
    • 전체 리스트를 조회하고 Flux의 2차원적인 데이터를 collect 함수를 이용해 데이터들을 Mono로 변환한 뒤 마지막으로 flatMap 안에서 응답 본문을 구성합니다.
  2. getById
    • 파라미터로 들어온 id로 데이터를 조회하고 존재하면 flatMap에서 응답 본문을 구성합니다. 데이터가 존재하지 않는다면 404 Not Found를 발생시킵니다.
  3. save
    • bodyToMono로 request의 body를 Todo 클래스에 매핑합니다. 그리고 정상적으로 데이터베이스에 생성 되었다면 201 Created 응답을 리턴합니다.
  4. done
    • 파라미터로 들어온 id로 데이터를 조회하고 존재하면 첫 번째 flatMap에서 업데이트를 수행합니다. 두 번째 flatMap에선 kotlin의 let 구문을 이용해 정상적으로 업데이트되었다면 200 OK 응답을 리턴하고, 데이터가 존재하지 않는다면 404 Not Found를 발생시킵니다.
    • let 구문과 아래 코드의 동작은 동일합니다.  변수로 사용한 it은 첫 번째 flatMap의 람다식을 간결하게 표현한것입니다. 수신자 객체로도 불리는 it은 클로저(closure)내에서 기본 매개변수라고 이해할 수 있습니다.
  5. delete
    • 마찬가지로 파라미터로 들어온 id로 데이터를 조회하고 존재하면 데이터베이스에서 삭제하고 , 데이터가 존재하지 않는다면 404 Not Found를 발생시킵니다.

마치며

이번 예제에선 R2DBC를 적용하여 진정한 의미의 리액티브 프로그래밍을 구현해보았습니다. 적은 변경만으로도 성능이 개선되었고, 코드는 더욱 간결해졌습니다. 다만, R2DBC는 아직 마일스톤버전으로 실무에서 적용하기엔 부담이 있을것입니다.  이에 대한 대안으로 RDB가 아닌 NoSQL솔루션을 적용하는 방법도 있을것입니다.  이를테면 Spring Data MongoDB, Spring Data Redis 등이 이에 해당합니다. 이 역시 Spring Data의 뛰어난 추상화로 인해 큰 어려움없이 적용 가능합니다.  NoSQL로 전환하는 예제는 다음에 하기로 하고 그 전에 앞서 다음엔 WebFlux 프로젝트에선 어떤식으로 테스트 코드를 작성하고 자동화하는지 같이 고민해보겠습니다.

오늘 예제로 보신 코드는  https://github.com/spring-webflux-with-kotlin/todo-R2DBC 에서 확인 가능합니다.

참고자료

https://spring.io/blog/2016/11/28/going-reactive-with-spring-data
https://www.baeldung.com/spring-data-r2dbc

 

728x90
반응형

+ Recent posts