
Spring Boot와 Kotlin을 사용해 대기열 관리 시스템을 구현하던 중, 여러 컴파일 오류와 의존성 문제를 마주쳤습니다.
이 포스팅에서는 Redis 설정, Coroutines 확장 함수, Spring 빈 주입, 그리고 WebFlux 컨트롤러에서 발생한 오류들을 해결한 과정을 정리합니다.
각 문제의 배경, 변경 전/후 코드, 해결 방법, 개선 사항, 그리고 배운 점을 공유하여 비슷한 문제를 겪는 개발자들에게 도움을 주고자 합니다.
프로젝트는 Spring Boot 3.4.4와 Kotlin 1.9.25를 기반으로, WebFlux와 Redis를 활용한 비동기 대기열 관리 시스템을 구현하는 것이 목표였습니다.
주요 기능은 다음과 같습니다.
그러나 개발 과정에서 다음과 같은 오류들이 발생했습니다.
이 오류들은 Spring Boot, Kotlin Coroutines, Reactor, 그리고 의존성 관리의 복잡한 상호작용에서 비롯된 문제들이었습니다.
아래는 각 파일에서 오류가 발생했던 원본 코드의 주요 부분입니다.
package com.docqueue.global.config
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory
import org.springframework.data.redis.core.ReactiveRedisTemplate
import org.springframework.data.redis.serializer.RedisSerializationContext
import org.springframework.data.redis.serializer.StringRedisSerializer
@Configuration
class RedisConfig {
@Bean
fun reactiveRedisTemplate(connectionFactory: ReactiveRedisConnectionFactory): ReactiveRedisTemplate<String, String> {
val serializer = StringRedisSerializer()
val serializationContext = RedisSerializationContext
.<String, String>newSerializationContext()
.key(serializer)
.value(serializer)
.hashKey(serializer)
.hashValue(serializer)
.build()
return ReactiveRedisTemplate(connectionFactory, serializationContext)
}
}
문제: key, value 등의 메서드가 RedisSerializationContext에서 존재하지 않아 컴파일 오류 발생.
package com.docqueue.global.exception
import kotlinx.coroutines.flow.Flow
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
fun <T> Mono<T>.asKotlinFlow(): Flow<T> = this.asFlow()
fun <T> Flow<T>.asFlux(): Flux<T> = Flux.from(this)
문제: asFlow()와 asPublisher()가 제네릭 타입 T : Any를 요구하여 타입 불일치 오류 발생.
package com.docqueue.domain.flow.service
import org.springframework.stereotype.Service
import java.util.UUID
@Service
class FunctionalUserQueueService(
private val repository: UserQueueRepository,
private val tokenGenerator: TokenGenerator = TokenGenerator { UUID.randomUUID().toString() }
) {
fun createToken(queue: String, userId: Long): Mono<String> {
val token = tokenGenerator.generate()
return repository.addToken(queue, userId, token).map { token }
}
}
문제: TokenGenerator 타입의 빈이 Spring 컨텍스트에 없어 자동 주입 실패.
package com.docqueue.domain.flow.controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Flux
@RestController
class QueueEventController(
private val userQueueService: UserQueueService
) {
@GetMapping(path = ["/flow-events"], produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun streamQueueEventsFlow(
@RequestParam queue: String,
@RequestParam userId: Long
): Flux<QueueUpdateEvent> {
val statusFlow: Flow<QueueUpdateEvent> = userQueueService
.getQueueStatusAsFlow(queue, userId)
.map { status -> QueueUpdateEvent(status.first, status.second, status.third) }
return statusFlow.asFlux()
}
}
문제: asFlux() 확장 함수가 참조되지 않음 (임포트 누락).
각 문제에 대해 다음과 같은 해결 방법을 적용했습니다.
문제 원인: RedisSerializationContext의 빌더 API를 잘못 사용 (key, value 메서드 대신 SerializationPair 사용 필요).
해결
문제 원인: kotlinx-coroutines-reactive의 asFlow()와 asPublisher()가 T : Any를 요구하여 제네릭 타입 불일치.
해결
문제 원인: Spring이 TokenGenerator 빈을 찾지 못함 (디폴트 값 무시).
해결
문제 원인: asFlux() 확장 함수 임포트 누락.
해결
문제 원인: spring-boot-starter-web과 spring-boot-starter-webflux 충돌 가능성, 의존성 버전 최신화 필요.
해결
아래는 각 파일의 최종 수정된 코드입니다.
package com.docqueue.global.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory
import org.springframework.data.redis.core.ReactiveRedisTemplate
import org.springframework.data.redis.serializer.RedisSerializationContext
import org.springframework.data.redis.serializer.StringRedisSerializer
@Configuration
class RedisConfig {
@Bean
fun reactiveRedisTemplate(connectionFactory: ReactiveRedisConnectionFactory): ReactiveRedisTemplate<String, String> {
val serializer = StringRedisSerializer()
val serializationContext = RedisSerializationContext.newSerializationContext<String, String>()
.key(RedisSerializationContext.SerializationPair.fromSerializer(serializer))
.value(RedisSerializationContext.SerializationPair.fromSerializer(serializer))
.hashKey(RedisSerializationContext.SerializationPair.fromSerializer(serializer))
.hashValue(RedisSerializationContext.SerializationPair.fromSerializer(serializer))
.build()
return ReactiveRedisTemplate(connectionFactory, serializationContext)
}
}
package com.docqueue.global.util
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.reactive.asFlow
import kotlinx.coroutines.reactive.asPublisher
import kotlinx.coroutines.reactive.awaitSingle
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
fun <T : Any> Mono<T>.asKotlinFlow(): Flow<T> = this.asFlow()
fun <T : Any> Flow<T>.asFlux(): Flux<T> = Flux.from(this.asPublisher())
suspend inline fun <T : Any, R : Any> Mono<T>.mapAwait(crossinline transform: suspend (T) -> R): R {
return transform(this.awaitSingle())
}
fun <T : Any, R : Any> Flow<T>.mapNotNull(transform: suspend (T) -> R?): Flow<R> =
this.map { transform(it) }.filterNotNull()
package com.docqueue.domain.flow.service
import com.docqueue.domain.flow.repository.UserQueueRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.reactive.awaitSingle
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
@Service
class FunctionalUserQueueService(
private val repository: UserQueueRepository,
private val tokenGenerator: TokenGenerator
) {
fun registerUser(queue: String, userId: Long): Mono<Long> =
repository.findWaitingOrder(queue)
.flatMap { currentOrder ->
val nextOrder = currentOrder + 1
repository.addWaitQueue(queue, userId, nextOrder)
.map { nextOrder }
}
fun allowUsers(queue: String, count: Long): Mono<Long> =
repository.findAllowedOrder(queue)
.flatMap { currentAllowed ->
val newAllowed = currentAllowed + count
repository.setAllowedOrder(queue, newAllowed)
.map { count }
}
fun createToken(queue: String, userId: Long): Mono<String> {
val token = tokenGenerator.generate()
return repository.addToken(queue, userId, token)
.map { token }
}
fun verifyUserAccess(queue: String, userId: Long, token: String): Mono<Boolean> =
if (token.isBlank()) Mono.just(false)
else repository.isTokenValid(queue, userId, token)
.flatMap { isValid ->
if (isValid) checkUserAllowed(queue, userId)
else Mono.just(false)
}
private fun checkUserAllowed(queue: String, userId: Long): Mono<Boolean> =
Mono.zip(
repository.findAllowedOrder(queue),
repository.findUserWaitOrder(queue, userId)
).map { tuple ->
val (allowedOrder, userOrder) = tuple.t1 to tuple.t2
userOrder > 0 && userOrder <= allowedOrder
}
fun getUserQueueStatus(queue: String, userId: Long): Mono<QueueStatus> =
Mono.zip(
repository.findUserWaitOrder(queue, userId),
repository.findWaitingOrder(queue),
repository.findAllowedOrder(queue)
).map { tuple ->
val (userOrder, waitingOrder, allowedOrder) = Triple(tuple.t1, tuple.t2, tuple.t3)
val queueFront = if (userOrder > 0) userOrder - 1 else 0
val queueBack = if (waitingOrder >= userOrder) waitingOrder - userOrder else 0
val progress = calculateProgress(allowedOrder, waitingOrder)
QueueStatus(queueFront, queueBack, progress)
}
private fun calculateProgress(allowedOrder: Long, waitingOrder: Long): Double =
if (allowedOrder > 0 && waitingOrder > 0) {
(allowedOrder.toDouble() / waitingOrder.toDouble()) * 100.0
} else {
0.0
}
fun streamQueueStatus(queue: String, userId: Long, intervalMs: Long = 1000): Flow<QueueStatus> = flow {
while (true) {
emit(getUserQueueStatus(queue, userId).awaitSingle())
kotlinx.coroutines.delay(intervalMs)
}
}
suspend fun registerOrGetStatus(queue: String, userId: Long): QueueStatus =
repository.findUserWaitOrder(queue, userId)
.flatMap { userOrder ->
if (userOrder > 0) {
getUserQueueStatus(queue, userId)
} else {
registerUser(queue, userId)
.flatMap { _ -> getUserQueueStatus(queue, userId) }
}
}
.awaitSingle()
}
data class QueueStatus(
val queueFront: Long,
val queueBack: Long,
val progress: Double
)
package com.docqueue.global.config
import com.docqueue.domain.flow.service.TokenGenerator
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.util.UUID
@Configuration
class AppConfig {
@Bean
fun tokenGenerator(): TokenGenerator = TokenGenerator { UUID.randomUUID().toString() }
}
package com.docqueue.domain.flow.controller
import com.docqueue.domain.flow.dto.QueueUpdateEvent
import com.docqueue.domain.flow.service.UserQueueService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Flux
import java.time.Duration
import com.docqueue.global.util.asFlux
@RestController
@RequestMapping("/api/v1/queue")
class QueueEventController(
private val userQueueService: UserQueueService
) {
@GetMapping(path = ["/events"], produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun streamQueueEvents(
@RequestParam(name = "queue", defaultValue = "default") queue: String,
@RequestParam(name = "user-id") userId: Long
): Flux<QueueUpdateEvent> {
return Flux.interval(Duration.ofSeconds(1))
.flatMap { userQueueService.getQueueStatus(queue, userId) }
.map { status -> QueueUpdateEvent(status.first, status.second, status.third) }
.distinctUntilChanged()
}
@GetMapping(path = ["/flow-events"], produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun streamQueueEventsFlow(
@RequestParam(name = "queue", defaultValue = "default") queue: String,
@RequestParam(name = "user-id") userId: Long
): Flux<QueueUpdateEvent> {
val statusFlow: Flow<QueueUpdateEvent> = userQueueService
.getQueueStatusAsFlow(queue, userId)
.map { status -> QueueUpdateEvent(status.first, status.second, status.third) }
return statusFlow.asFlux()
}
}
plugins {
kotlin("jvm") version "1.9.25"
kotlin("plugin.spring") version "1.9.25"
id("org.springframework.boot") version "3.4.4"
id("io.spring.dependency-management") version "1.1.7"
id("org.sonarqube") version "5.1.0.4882"
id("com.github.davidmc24.gradle.plugin.avro") version "1.9.1"
id("com.google.protobuf") version "0.9.4"
application
jacoco
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.ai:spring-ai-openai-spring-boot-starter:${property("springAiVersion")}")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactive:1.9.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.projectreactor:reactor-test")
testImplementation("org.junit.jupiter:junit-jupiter:5.11.3")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("io.mockk:mockk:1.13.12")
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
implementation("org.postgresql:postgresql:42.7.4")
implementation("com.fasterxml:classmate:1.7.0")
implementation("org.projectlombok:lombok:1.18.34")
annotationProcessor("org.projectlombok:lombok:1.18.34")
implementation("com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.5")
implementation("org.jasypt:jasypt:1.9.3")
developmentOnly("org.springframework.boot:spring-boot-devtools")
implementation("com.google.protobuf:protobuf-java:4.28.3")
implementation("org.springframework.kafka:spring-kafka")
implementation("org.apache.kafka:kafka-clients:3.9.1")
implementation("io.confluent:kafka-avro-serializer:7.7.1")
testImplementation("com.github.codemonstur:embedded-redis:1.4.3")
implementation("org.springframework.boot:spring-boot-starter-data-redis-reactive")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
}
코드 안정성
가독성 및 유지보수성
의존성 최적화
빌드 안정성
Spring의 의존성 주입은 Kotlin의 디폴트 값과 별개로 동작한다.
@Bean 등록 또는 생성자 주입을 명시적으로 관리해야 함.
Kotlin의 제네릭 타입(T vs T : Any)은 라이브러리와의 호환성에 큰 영향을 미친다.
kotlinx-coroutines-reactive를 사용할 때는 asFlow(), asPublisher()의 제네릭 제한을 이해해야 한다.
Flow와 Flux 간 변환은 명시적 임포트와 타입 안전성이 중요하다.
Spring Boot와 Kotlin 프로젝트에서는 의존성 버전 호환성을 항상 점검해야 한다.
./gradlew dependencies로 충돌을 확인하고, 불필요한 의존성을 제거하는 습관이 필요하다.
IntelliJ IDEA의 캐시 문제는 Invalidate Caches / Restart로 해결 가능.
Gradle 빌드 캐시와 동기화 문제는 ./gradlew clean build --refresh-dependencies로 처리.
Spring Boot와 Kotlin으로 비동기 대기열 시스템을 구현하면서 여러 오류를 해결하며 많은 것을 배웠습니다.
Redis 설정, Coroutines와 Reactor 통합, Spring 빈 주입, 그리고 WebFlux 컨트롤러의 문제를 해결하며, Spring Boot의 의존성 주입, Kotlin의 타입 시스템, 그리고 비동기 프로그래밍의 복잡성을 깊이 이해하게 되었습니다.
특히, 제네릭 타입 관리와 의존성 버전 호환성의 중요성을 깨달았고, IDE와 빌드 도구를 효과적으로 활용하는 방법을 익혔습니다.
이 경험은 앞으로 Spring Boot와 Kotlin을 사용하는 프로젝트에서 더 안정적이고 유지보수 가능한 코드를 작성하는 데 큰 도움이 될 것입니다.
혹시 비슷한 문제를 겪고 있다면, 위 해결 방법을 참고하고, ./gradlew dependencies와 IDE 캐시 초기화를 먼저 시도해보세요!
추가 질문이 있다면 언제든 댓글로 남겨주세요