이게 원래 3까지 갈건 아닌거 같은데 어제 쓴 대로 프로젝트 구조가 약간 이상하다.
그냥 맨 처음에 Spring Initializr로 만들고 나서 그 프로젝트 플러그인에 multiplatform 추가하고 그것만 미적용 해놓고 frontend를 추가해서 하는게 훨씬 간단하다.
프로젝트 설정은 또 하기 매우 귀찮으므로 그렇다는 것만 일단 알아두고…
프론트와 백은 같은 데이터를 취급해야하는데 이걸 양쪽에 모두 작성하는건 매우 비효율적이므로 shared같은 모듈을 만들고 거기에 interface같은걸로 만들어서 각 모듈에서 상속받아 쓰던지 하면 이쁠 것 같다.
그래서 간략히 써봅시다. 우선 프로젝트는 어제처럼 Initialzr에서 Webflux, Configuration Processor 등만 추가한 채로 만듭니다. 그리고 서브모듈로 shared와 frontend를 추가한다.
그다음 간단히 공통인데 플랫폼별로 나눠야할게 뭐가있는지 알아본다. DateTime이 있네.
기본적으로 코틀린은 JVM을 타겟으로 하는 경우가 많아서 시간은 자바의 것을 그대로 가져다 쓴다. 그런데 이번에는 타겟이 JVM뿐 아니라 JS도 있으므로 자바의 것을 쓸 수 없다. 물론 시간 자체는 표준표기법이 있어서 상관은 없긴하지만…
여튼 kotlinx-datetime을 이용하면 이걸 해결할 수 있다.
https://github.com/Kotlin/kotlinx-datetime
gradle을 이용하면 kotlinx-datetime을 가져오는 것 만으로 플랫폼 specific하게 알아서 처리해준다. 그리고 다음으로 볼 것은 JSON 직렬화 부분이다. 이것도 코틀린 자체 직렬화를 사용한다.
https://github.com/Kotlin/kotlinx.serialization
다만 얘는 직렬화 플러그인도 추가해야한다.
그래서 결국 다음과 같은 두 디펜던시를 세 모듈에 모두 추가해야하는데, 이렇게 나눠서 해놓으면 버전관리가 어려울 수 있다. 검색해보니 settings.gradle.kts 에 이걸 설정할 수 있다. settings.gradle.kts는 최상단 모듈에만 존재하므로 여기에 버전을 정의하여 alias로 등록해두면 하위 모듈에서 그 alias대로 가져다 쓸 수 있다. (여기 참고)
그래서 최종적으로 gradle은 다음과 같이 설정한다.
settings.gradle.kts
// 생략
include("shared")
include("frontend")
dependencyResolutionManagement {
versionCatalogs {
create("libs") {
library("kotlinx-serialization-json", "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
library("kotlinx-datetime", "org.jetbrains.kotlinx:kotlinx-datetime:0.6.0")
}
}
}
build.gradle.kts
// ...중략
plugin {
// ...중략
kotlin("multiplatform") version "2.0.0" apply false
kotlin("plugin.serialization") version "2.0.0"
}
// ...중략
dependencies {
// ...중략
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime)
implementation(project(":shared"))
// ...중략
}
// ...중략
shared/build.gradle.kts
// compilerOptions에 필요한 옵트인
@file:OptIn(ExperimentalKotlinGradlePluginApi::class)
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
}
repositories {
mavenCentral()
}
kotlin {
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
js {
browser()
}
sourceSets {
commonMain {
dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime)
}
}
commonTest {
dependencies {
implementation(kotlin("test"))
}
}
}
}
frontend/build.gradle.kts
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
}
repositories {
mavenCentral()
}
kotlin {
js {
browser {
binaries.executable()
commonWebpackConfig {
cssSupport { enabled = true }
devServer = devServer?.apply {
`open` = false
port = 3000
}
}
}
}
sourceSets {
jsMain {
dependencies {
implementation("org.jetbrains.kotlin-wrappers:kotlin-react:18.3.1-pre.751")
implementation("org.jetbrains.kotlin-wrappers:kotlin-react-dom:18.3.1-pre.751")
implementation("io.ktor:ktor-client-core:2.3.11")
implementation("io.ktor:ktor-client-js:2.3.11")
implementation("io.ktor:ktor-client-content-negotiation:2.3.11")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.11")
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime)
implementation(project(":shared"))
}
}
jsTest {
dependencies {
implementation(kotlin("test"))
}
}
}
}
그 다음 shared에 PublishedMessage라는 data class를 만든다.
shared/src/commonMain/…/PublishedMessage.kt
package me.aosamesan.shared
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Serializable
@Serializable
data class PublishedMessage(
val message: String,
val publishedAt: LocalDateTime
)
여기서 LocalDateTime은 자바의 것이 아니다. 애초에 commonMain은 공통코드를 두는 곳이라 LocalDateTime으로 자동완성 찾아봐도 자바의 것은 나오지 않는다.
그리고 스프링부트쪽 설정이 매우 귀찮으므로 그냥 하드코딩으로 AppConfig 파일 하나로 퉁친다.
src/main/kotlin/…/AppConfig.kt
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import me.aosamesan.shared.PublishedMessage
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.reactive.config.CorsRegistry
import org.springframework.web.reactive.config.EnableWebFlux
import org.springframework.web.reactive.config.WebFluxConfigurer
import org.springframework.web.reactive.function.BodyInserters
import org.springframework.web.reactive.function.server.ServerResponse
import org.springframework.web.reactive.function.server.router
@Configuration
@EnableWebFlux
class AppConfig : WebFluxConfigurer {
override fun addCorsMappings(registry: CorsRegistry) {
registry.addMapping("/api/**").allowedOrigins("http://localhost:3000")
}
@Bean
fun routes() = router {
path("/api").nest {
GET("/hello") {
ServerResponse.ok().body(
BodyInserters.fromValue(
PublishedMessage(
message = "Hello World!",
publishedAt = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
)
)
)
}
}
}
}
여기서 주의할 것은 스프링부트 쪽은 JVM타겟이기 때문에 Clock같은 것이 자바의 것일 수 있으므로 주의. 근데 어차피 IDE에서 다 가르쳐준다.
테스트 해보면 다음과 같이 잘 나온다.
프론트엔드 쪽도 간단하게 해본다.
frontend/src/jsMain/…/Main.kt
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.js.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.promise
import kotlinx.serialization.json.Json
import me.aosamesan.shared.PublishedMessage
import react.FC
import react.create
import react.dom.client.createRoot
import react.useEffectOnce
import web.dom.document
fun main() {
createRoot(document.getElementById("root")!!).render(App.create())
}
@OptIn(DelicateCoroutinesApi::class)
val App = FC {
useEffectOnce {
val client = HttpClient(Js) {
install(ContentNegotiation) {
json(Json)
}
}
GlobalScope.promise {
val response = client.get("http://localhost:8080/api/hello")
response.body<PublishedMessage>()
}.then {
val (message, publishedAt) = it
console.log("Message :", message)
console.log("Published At :", publishedAt)
}
}
+"Hello World!"
}
보면 body<T>를 이용해서 응답의 body를 deserialization 하는 부분이 있다. 실행을 해보면 다음과 같이 로그가 잘찍히는 것을 볼 수 있다.
끗.
일단 shared에는 commonMain만 썼는데 사실 여기에 인터페이스만 정해놓고 jsMain과 jvmMain으로 나눠서 따로 작성할 수도 있을 것 같다. 이건 나중에 해봐야지
답글 남기기