
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)
}
}
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)
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 }
}
}
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()
}
}
각 문제에 대해 다음과 같은 해결 방법을 적용했습니다.
문제 원인: RedisSerializationContext의 빌더 API를 잘못 사용 (key, value 메서드 대신 SerializationPair 사용 필요)
해결
문제 원인: kotlinx-coroutines-reactive의 asFlow()와 asPublisher()가 T : Any를 요구하여 제네릭 타입 불일치.
해결
문제 원인: 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 WebFlux와 Kotlin Coroutines를 사용한 대기열 시스템 구현은 비동기 처리와 반응형 프로그래밍의 강점을 활용할 수 있는 흥미로운 작업이었습니다.
하지만, 의존성 주입, 타입 관리, 코루틴과 Reactor의 통합에서 발생하는 복잡한 오류들은 신중한 디버깅과 설계가 필요함을 보여주었습니다.
이번 경험을 통해 Spring의 빈 관리, Kotlin Coroutines의 올바른 사용법, 그리고 비동기 시스템의 디버깅 기법을 깊이 이해하게 되었습니다.
특히, 타입 충돌과 비동기 컨텍스트 문제를 해결하면서 코드의 안정성과 가독성을 높이는 데 집중했습니다.
이 포스팅이 비슷한 문제를 겪는 개발자들에게 도움이 되기를 바라며, 앞으로도 비동기 시스템 설계에 대한 탐구를 계속할 계획입니다.