본문 바로가기

Lang

[Kotlin]Kotlin, Spring boot, Reactive Programming 조합으로 간단한 Rest API 구현해보기

Kotlin으로 Reactive Programming 개념 익혀보려다 엄한 짓 해본 후 사내 개발자들과 공유하려고 기록했던 것 옮겨 적어본다.

--- 시작 ---

- Rest API 구현 : Kotlin, Spring boot, Reactive Programming(R2DBC)

## 프로젝트 생성.

start.spring.io 에서 kotlin-gradle 프로젝트 선택해서 build.gradle.kts 파일이 생성.

 - xml 제약을 넘어서기 위해 groovy 스크립트 활용 가능한 gradle 이 나왔는데 코트린 풀젝에서는 여기서 한 걸음 더 나아가 코틀린 스크립트(kts)를 사용할 수 있다.

아래는 java 17, jpa, reactive(r2dbc), postgresql 를 사용하기 위한 기본적인 구성 담긴 build.gradle.kts 파일이다.

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

plugins {
 id("org.springframework.boot") version "2.6.7"
 id("io.spring.dependency-management") version "1.0.11.RELEASE"
 kotlin("jvm") version "1.6.21"
 kotlin("plugin.spring") version "1.6.21"
 kotlin("plugin.jpa") version "1.6.21"
}

group = "com.test.rest-kt"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_17

repositories {
 mavenCentral()
}

dependencies {
 implementation("org.springframework.boot:spring-boot-starter-data-jpa")
 implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")
 implementation("org.springframework.boot:spring-boot-starter-webflux")
 implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
 implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
 implementation("org.jetbrains.kotlin:kotlin-reflect")
 implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
 implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
 implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
 implementation("io.github.microutils:kotlin-logging:2.1.21")
 runtimeOnly("io.r2dbc:r2dbc-postgresql")
 runtimeOnly("org.postgresql:postgresql")
 testImplementation("org.springframework.boot:spring-boot-starter-test")
 testImplementation("io.projectreactor:reactor-test")
}

tasks.withType<KotlinCompile> {
 kotlinOptions {
   freeCompilerArgs = listOf("-Xjsr305=strict")
   jvmTarget = "17"
 }

}

tasks.withType<Test> {
 useJUnitPlatform()
}

## 애플리케이션 실행을 위한 메인 클래스.

@SpringBootApplication
class DpRestApplication

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

## application.yml 중 r2dbc 위한 주요 설정 부분만 발췌.

spring:
  ...
  data:
    r2dbc:
       repositories: enabled=true

  r2dbc:
    url: r2dbc:postgresql://postgres@...:5432/db_name 
    username: XXX
    password: XXX
    pool:
       ...
  ...
server.port: 8099

## 모델 클래스

얼마 전 java 의 record 란거 소개할 때 잠깐 언급했던 data 클래스를 이용해보았다.

@Table("aht_job")
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class)
@JsonIgnoreProperties("id")
data class AhtJob(
    @Id
    var id: Long = 0,
    val cheId: String,
    val jobType: String,

    @JsonProperty("status")
    val jobSts: String,

    @JsonProperty("from_location")
    val fromLoc: String = "",

    @JsonProperty("created")
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss[.SSS]")
    val creDt: LocalDateTime?,

    @JsonProperty("updated")
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss[.SSS]")
    val updDt: LocalDateTime
)

## 레포지토리

import org.springframework.data.r2dbc.repository.Query
import org.springframework.data.repository.reactive.ReactiveCrudRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
...

@Repository
interface AhtJobRepository: ReactiveCrudRepository<AhtJob, Long> {
    @Query("select * from aht_job where id > 1")
    fun getSomeAhtJobs(): Flux<AhtJob>
    fun findByJobId(jobId: String): Mono<AhtJob>
}

## RestController

import mu.KLogging
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
...

@RestController
@RequestMapping("/api/v1")
class PortController(private val portService: PortService) {
    companion object : KLogging()

    @GetMapping("/ahtjobs")
    fun getAhtJobs(): ResponseEntity<*> {
        logger.info("logger test")
        return ResponseEntity.ok(portService.getAhtJobs())
    }

    @GetMapping("/somejob")
    fun findAhtJob(): ResponseEntity<*> {
        logger.info("logger Native Query")
        return ok(portService.getSomeAhtJobs())
    }

    @GetMapping("/ahtjob/{jobId}")
    fun findAhtJobByJobId(@PathVariable("jobId") jobId: String): Mono<ResponseEntity<EvtAhtJob>> {
        logger.info("find job id")
        return portService.findAhtJobByJobId(jobId)
            .map { ahtJob -> ResponseEntity.ok(ahtJob) }
            .defaultIfEmpty(ResponseEntity.notFound().build())
    } 
}

## 서비스

import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
...

interface PortService {
    fun getAhtJobs(): Flux<AhtJob>
    fun findAhtJobByJobId(jobId: String): Mono<AhtJob>
    fun getSomeAhtJobs(): Flux<AhtJob>
}

@Service
class PortServiceImpl(val ahtJobRepository: AhtJobRepository): PortService {
    override fun getAhtJobs(): Flux<AhtJob> = ahtJobRepository.findAll()
    override fun findAhtJobByJobId(jobId: String): Mono<AhtJob> {
          return ahtJobRepository.findByJobId(jobId)
    }
   
    override fun getSomeAhtJobs(): Flux<AhtJob> {
          return ahtJobRepository.getSomeAhtJobs()
    }
}

--- 샘플 소스 끝 ---

* 소스 전체를 보면 사소한 구문 차이, 타입 차이 외에는 Java, Spring boot, JPA, WebMVC와 거의 비슷해서 이해에는 큰 어려움 없을거라 몇 부분만 살짝 얘기해보자.

#### 엔티티 정의해놓은 거에서는

* jackson 애노테이션 쓰는 방법이 조금씩 다른 거 외에는 별다른 건 없었다(json 메시지에서의 null 값 처리나 jpa 활용 위한 ID 생성 규칙 적용 등등의 좀 더 상세 구현 필요한 부분 있기는 하지만 초간단 구현 목표라 그런 것들은 제외).

* 내 경우 자바 구현 시 Lombok 사용 하기에 형태는 자바 구현과 별 차이 없지만 애노테이션 형식이 조금 다른데 아래는 기존 자바에서 애노테이션 썼던 방식.

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties({ "id" })

* val creDt: LocalDateTime?

테스트하다보니 creDt 속성이 널인 경우가 있어 이 속성에 한해 '?' 를 붙여주었다.

코틀린 특징 소개하는 문서에 빠지지 않는게 코틀린의 null 처리 방식이다. 많이 언급되다보니 아는 분들 역시 많겠지만코틀린은 NPE 를 명확하게 관리하기 위해 변수에 널을 허용하지 않는다.

널을 허용해야 하는 경우가 있다면 위 처럼 타입 뒤에 '?' 를 붙여주면된다.

좀 더 덧붙이자면 Null Safe Operator 라고 '?.' 도 제공되는데 가령,

val jobSts: String? = s?.toUpperCase()

은 자바에서 다음과 같이 구현한 것에 해당한다.

if (jobSts != null) jobSts.toUpperCase() else null

#### repository 에서는

* R2DBC 쓰기 위해 ReactiveCrudRepository 를 상속받았다.

* 함수의 리턴 타입이 좀 낯설텐데 Reactive Streams 의 Publisher 인터페이스 구현체는 기본적으로 다음 두 가지가 있다.

  - Mono : 0~1 개의 데이터 전달

  - Flux : 0~N 개의 데이터 전달

제대로 설명하려면 reactive programming(WebFlux) 에 대한 이해가 좀 필요한 부분이라 상세 설명은 스킵.

* @Query 애노테이션을 이용해서 JPA 에서 처럼 native query 도 사용 가능하다.

* r2dbc 는 ORM 같은거 아니고 그저 reactive 방식을 지원하기 위해 만들어진 Non-blocking 애플리케이션 스택 중 하나일 뿐고 아직은 JPA 에서 적용한 기술 중 일부만 가능하다. 대표적으로 OneToMany, ManyToOne 등의 연관관계는 아직 사용할 수 없다.

#### 컨트롤러 단에서는

* spring WebMVC 쓸 때랑 별 차이 없어 보인다. ResponseEntity 써보았고 기능 자체 구현과는 상관없지만 코틀린에서 logger 쓰는거 테스트해볼 겸 KLogging 이용해서 로그를 추가로 찍어보았다.

* 다른 두 개는 워낙 간단하니 넘어가고 findAhtJobByJobId 함수만 살짝 더 얘기해보자.

* Mono<> 타입으로 결과 리턴 받아서 response 하는데 이 때 데이터가 하나도 없을 경우 Rest API 에서 종종 사용하는 방식처럼 404 코드 응답하도록 map 과 defaultIfEmpty 이용해서 어렵지 않게 구현해보았다.

#### 서비스 단은

 * 간단한 샘플에서 굳이 이렇게 분리할 필요까지는 없었지만 그래도 학습 차원에서 interface 를 추가해놓은 거외에는 따로 설명할게 거의 없다.

--- 소스 설명 끝 ---

이게 거의 전부고 API 호출하면 잘 동작.

- 설정에서 서버 포트를 8099 로 설정했으니 'http://...:8099/api/v1/ahtjob/1234' 과 같은 식으로 호출해볼 수 있다.

코딩한 후 간단히 리뷰하면서 메모하고 보니 글 처음에 적었듯 '엄한 짓'이었다 싶어요.

이건 Kotlin 공부도 아니고 Reactive Programming 이해에도 별 도움 안되고 ...

이번은 그냥 소위 '코프링'이란거 이런 식으로 쓸 수 있다 정도 실습한 셈치고 다음에 여유 생기면 애초 목표인 Reactive Programming 이 무엇인지 따로 정리해서 공유토록 해보겠습니다(r2dbc 는 또 뭔가 까지도).