사실 어제 쓰고 나서 다음거까지 또 한달 걸리겠네 싶었는데 일단 하나 투고
프로젝트 설정
나는 프론트와 백엔드가 한 프로젝트에 몰아넣길 원한다. 그러므로 gradle 하나 만들고 두개의 모듈을 넣을 것이다.
일단 Kotlin Gradle 프로젝트를 하나 만든다.

그리고 나서 일단 build.gradle.kts 를 다음과 같이 수정한다.
// build.gradle.kts
plugins {
// 일단 비워둠
}
subprojects {
group = "me.aosamesan"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
}
그러고나서 gradle 프로젝트 리로드를 한 번 하고 src 디렉터리를 지우고, 백엔드를 먼저 추가합시다.
backend
백엔드는 최상위 프로젝트 > 신규 > 모듈을 선택하고 Spring Boot Initializr 를 이용하여 대충 만든다. 템플릿 엔진은 필요 없고 이번엔 테스트용도로만 할거라서 아래 3개정도만으로 충분하다. 롬복은 코틀린쓰면 필요가 없다.

그러면 아래와 같이 backend (혹은 설정한 이름) 으로 만들어진다.

이제 필요없는 것들은 삭제한다. gradle은 부모 프로젝트에 있으므로 build.gradle.kts 빼고 나머지는 다 삭제해도 된다.

이제 backend/build.gradle.kts를 열어서 plugins에 있는 것들을 모두 최상단 build.gradle.kts에 붙여넣는다. 최상단에는 multiplatform 플러그인도 들어갈 것이므로 적용은 하지 말아야한다. 따라서 뒤에 apply false 를 붙여 넣는다. (이 부분은 여기를 참고)
// build.gradle.kts
plugins {
id("org.springframework.boot") version "3.3.0" apply false
id("io.spring.dependency-management") version "1.1.5" apply false
kotlin("jvm") version "1.9.24" apply false
kotlin("plugin.spring") version "1.9.24" apply false
}
subprojects {
group = "me.aosamesan"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
}
그다음 settings.gradle.kts를 열어서 backend를 볼 수 있도록 include해준다.
// settings.gradle.kts
// 생략...
include("backend") // 추가
그리고 backend의 build.gradle.kts로 가서 플러그인에 있는 버전을 모두 지운다.
// backend/build.gradle.kts
// 생략...
plugins {
id("org.springframework.boot")
id("io.spring.dependency-management")
kotlin("jvm")
kotlin("plugin.spring")
}
// 생략...
그리고 gradle 프로젝트 리로드. 만약 backend 관련 task 에러가 난다면 다음과 같이 IDE에서 아직 backend를 두 개로 잡고 있는게 문제일 것이다. 최상단 backend는 연결 해제해준다.

스프링 부트의 경우 평소랑 똑같이 메인함수를 실행할 수 있다. 실행해서 잘 뜨는지 확인해본다.

잘 된다.
frontend
프론트엔드는 저번에 한 것과 같이 그냥 코틀린 gradle 프로젝트를 추가하고, jsMain 설정하고… 이런걸 진행한다. 이렇게 추가할 경우 부모 프로젝트의 settings.gradle.kts 에 frontend가 자동으로 추가되고, 아래 스샷처럼 src와 build.gradle.kts만 추가된다.

frontend/build.gradle.kts 에 저번처럼 추가한다.
다만 저번에는 그냥 멀티플랫폼으로 하고 자바스크립트만 썼으나 나는 자바스크립트로만 할거니까 멀티플랫폼이 아니라 js로 추가한다.
다만 여기서도 plugins { kotlin(“js”) }은 버전 없이 추가하고, 부모 build.gradle.kts에는 버전 + appy false로 추가한다.
// build.gradle.kts
// 생략...
plugins {
id("org.springframework.boot") version "3.3.0" apply false
id("io.spring.dependency-management") version "1.1.5" apply false
kotlin("jvm") version "1.9.24" apply false
kotlin("plugin.spring") version "1.9.24" apply false
kotlin("js") version "2.0.0" apply false // 추가
}
// 생략...
// frontend/build.gradle.kts
plugins {
kotlin("js")
}
kotlin {
js {
binaries.executable()
browser {
commonWebpackConfig {
cssSupport { enabled = true }
scssSupport { enabled = true }
}
}
}
sourceSets {
main {
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")
}
}
test {
dependencies {
implementation(kotlin("test"))
}
}
}
}
’js’ 로만 하면 자바스크립트만 사용하므로 jsMain, jsTest가 아니라 main, test만으로 가능하다.
다음 저번에 했던 것 처럼 리소스에 index.html을 만들고 main도 대충 만든다.
<!-- frontend/src/main/resources/index.html -->
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>Kotlin/JS + Kotlin React + Spring Boot</title>
</head>
<body>
<div id="root"></div>
<script src="./frontend.js"></script>
</body>
</html>
// frontend/src/main/kotlin/index.kt
import react.FC
import react.create
import react.dom.client.createRoot
import web.dom.document
fun main() {
createRoot(document.getElementById("root")!!).render(App.create())
}
val App = FC {
+"Hello World!"
}
그다음 gradle의 :frontend:run으로 실행해본다.

잘되네
hotload
이게 코틀린을 자바스크립트로 컴파일? 트랜스파일? 한 걸 webpack-dev-server로 띄우는거라 수정하고 재시작하고 개귀찮으므로 핫로드 가능하도록 gradle 구성을 따로 만들어 놓읍시다.

요렇게 실행쪽에 frontend:run –continuous 로 해놓고 이걸 실행시키면 코틀린 코드 수정해서 저장하면 자동으로 갱신된다.
동시에 띄워서 소통하기
개발환경에서는 서버를 2개 (스프링부트 + webpack dev server)를 띄우게 되는데, IntelliJ에서 그냥 실행했으면 포트는 8080부터 차례대로 받는다.
사실 express + react 를 해봤으면 뭐가 문제인지는 대충 안다. 프론트쪽에서 백엔드의 API를 호출하면 CORS 에러가 날 것이다.
우선 그에 앞서… 프론트쪽에서 API 호출을 할 수 있어야하는데, Ktor client를 사용해봅시다. (사실 글 쓰기전 테스트에서는 axios를 썼는데 그러면 axios의 래퍼들이 필요하므로 매우 귀찮아진다.) 아마 순수 코틀린으로 이루어진 라이브러리면 사용해도 될 것 같다. 생각해보니 OkHttp도 코틀린으로 바꾸긴 했는데.. 하고 찾아보니 5부터는 multiplatform 지원을 계획하고 있다는데 잘 안된다는 듯…
Ktor client 는 코어가 있고 그 코어가 각 플랫폼별 구현체 엔진을 받아서 사용하는 걸로 되어있네 흠… 여튼 frontend/build.gradle.kts에 다음 디펜던시들을 추가합시다.
dependencies {
// 중략
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")
}
core는 코어, js는 자바스크립트용 엔진, content-negotiation은 express로 치면 bodyParser같은놈, serialization-kotlinx-json은 bodyParser의 json부분… 이라고 보면 될듯 다음은 테스트 코드
import io.ktor.client.*
import io.ktor.client.engine.js.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.promise
import kotlinx.serialization.json.Json
import react.FC
import react.create
import react.dom.client.createRoot
import react.useEffectOnce
import react.useState
import web.dom.document
fun main() {
createRoot(document.getElementById("root")!!).render(App.create())
}
@OptIn(DelicateCoroutinesApi::class)
val App = FC {
val (text, setText) = useState("Loading...")
useEffectOnce {
val client = HttpClient(Js) {
install(ContentNegotiation) {
json(Json)
}
}
GlobalScope.promise {
val response = client.get("http://date.jsontest.com/")
response.bodyAsText()
}.then { text ->
setText(text)
}
}
+text
}
http://date.jsontest.com/ 는 현재 시간관련 데이터를 json으로 준다. 봐야할 곳은… client의 요청 메서드들은 모두 suspend fun이므로 코루틴 스코프 내에서 호출해야한다. GlobalScope.promise는 suspend fun을 받아서 Promise형태로 만들어준다.. 고 생각하면 편할 것 같다. 대충 (async function() {…})() 같은 느낌? 그래서 then으로 이어서 쓸 수도 있고 .await() 를 써서 받을 수도 있으나 await는 suspend fun이기 때문에 또 다른 스코프로 감싸야한다… 깊게보면 다르겠지만 어쨋든 JS의 async function이 suspend fun과 유사하니… 이제 코드를 수정하면 새로고침되면서 Loading…이 잠깐 떴다가 시간 정보를 잘 불러오는 것을 확인할 수 있다.
잘 되네.
이제 Spring Boot에 테스트용 API를 작성합시다.
webpack dev server proxy
다시 본론으로 돌아가서… 사실 express + react 개발 시에 발생하는 CORS문제는 webpack 설정에 프록시를 추가하면 된다. 그리고 그걸 frontend/build.gradle.kts에 설정하는 것도 가능하다. 그런데 문제는 코틀린 js 플러그인에 있는 웹팩 설정 부분은 웹팩4기준이고 react wrapper에서는 웹팩5를 써서 서로 호환이 안된다… 이거 검색해보니 한 두달 전쯤에 누가 이슈업을 했고 (KT-67444) 관련하여 수정까지 해서 PR도 올라왔고, 머지가 아니라 수동으로 추가하는 방식으로 마스터에 추가 되었는데 현재 버전(2.0.0) 빌드(글 작성 기준 며칠전에 나온 버전임)에는 포함이 안되어있다 시발. 그래서 webpack dev server의 프록시로는 이걸 (덜 더럽게) 해결할 수 없고, 백엔드쪽에서 개발환경일때 프론트엔드쪽에서 오는 요청은 안막도록 설정해야한다.
webpack dev server 포트 고정
frontend hotload를 만들어서 이걸로만 실행시키면 어차피 재시작되므로 상관 없어지기는 한데, 프론트엔드의 포트를 고정시키고 싶으면 다음과 같이 commonWebpackConfig에 devServer 관련 설정을 추가한다.
// 생략...
js {
binaries.executable()
browser {
commonWebpackConfig {
cssSupport { enabled = true }
scssSupport { enabled = true }
devServer = devServer?.apply {
port = 3000
`open` = false
}
}
}
}
// 생략...
devServer = … 부분을 추가했다. 포트는 3000으로하고 open은 실행하면 브라우저를 새로 켤 지 여부이다. 포트를 고정했으므로 하나 켜진 상태에서 :frontend:run을 또 실행하면 포트가 이미 바인딩 되어있다고 에러나고 실행이 되지 않는다.
Spring Boot CORS 설정
우리가 원하는 것은 개발환경일 때에만 개발 프론트엔드의 요청에 대해서만 CORS를 허용하는 것이다.
개발환경일 때 -> 프로필 사용, 개발 프론트엔드 -> 이거때문에 포트 고정한거임 으로 보면 된다.
아까 Spring Boot Initializr에서 Configuration Processor를 추가했었으니 이걸 프로퍼티로 관리를 하겠다는 말이다…
// backend/src/main/kotlin/../backend/properties/CrossOriginProperties.kt
// package, import 생략
@ConfigurationProperties("cross-origin")
data class CrossOriginProperties(
val allowed: List<OriginMappingItem> = emptyList()
) {
data class OriginMappingItem(
var mapping: String,
var origin: String
)
}
// backend/src/main/kotlin/../backend/config/AppConfig.kt
// package, import 생략
@Configuration
@EnableWebFlux
@EnableConfigurationProperties(CrossOriginProperties::class)
class AppConfig(
private val crossOriginProperties: CrossOriginProperties
) : WebFluxConfigurer {
override fun addCorsMappings(registry: CorsRegistry) {
for (allowedOrigin in crossOriginProperties.allowed) {
registry.addMapping(allowedOrigin.mapping).allowedOrigins(allowedOrigin.origin)
}
}
@Bean
fun routes(): RouterFunction<ServerResponse> = RouterFunctions.nest(RequestPredicates.path("/api"),
RouterFunctions.route(RequestPredicates.GET("/hello")) {
ServerResponse.ok().body(BodyInserters.fromValue("Hello World!"))
}
)
}
개발환경 설정파일은 application-local.yaml로 하고 local 프로필로 실행하게 하면 된다.
# application-local.yaml
spring:
config:
import: classpath:application.properties
cross-origin:
allowed:
- mapping: "/api/**"
origin: "http://localhost:3000"
이제 프론트엔드쪽에서 호출하는 API를 http://localhost:8080/api/hello 로 수정해본다.

잘 된다.
참고로 cross-origin 설정이 안되어 있으면 호출하면서 에러가 나고 콘솔에 다음과 같이 찍힌다.


다음엔?
이제 개발환경 꾸미는 것을 했으니 다음에는 빌드를 어떻게 할 것인가… 이다.
이 부분은 내가 gradle을 잘 모르므로 또 삽질해야한다.
끗
답글 남기기