Spring Boot와 Kotlin으로 구현한 프로젝트에서 만난 오류 해결 여정

Spring Boot와 Kotlin을 사용해 대기열 관리 시스템을 구현하던 중, 여러 컴파일 오류와 의존성 문제를 마주쳤습니다.

이 포스팅에서는 Redis 설정, Coroutines 확장 함수, Spring 빈 주입, 그리고 WebFlux 컨트롤러에서 발생한 오류들을 해결한 과정을 정리합니다.

각 문제의 배경, 변경 전/후 코드, 해결 방법, 개선 사항, 그리고 배운 점을 공유하여 비슷한 문제를 겪는 개발자들에게 도움을 주고자 합니다.

1. 문제 배경

프로젝트는 Spring Boot 3.4.4와 Kotlin 1.9.25를 기반으로, WebFlux와 Redis를 활용한 비동기 대기열 관리 시스템을 구현하는 것이 목표였습니다.
주요 기능은 다음과 같습니다.

  • Redis를 사용한 대기열 상태 관리 (ReactiveRedisTemplate 설정).
  • Kotlin Coroutines와 Reactor를 결합한 비동기 스트림 처리 (Flow와 Flux 변환).
  • Spring 빈 주입을 통한 의존성 관리 (TokenGenerator).
  • Server-Sent Events(SSE)를 통한 실시간 대기열 상태 업데이트.

그러나 개발 과정에서 다음과 같은 오류들이 발생했습니다.

  1. RedisConfig.kt: ReactiveRedisTemplate 설정에서 메서드 체이닝 오류 (Unresolved reference: key).
  2. FlowExtensions.kt: Mono와 Flow 간 변환에서 제네릭 타입 불일치 (Unresolved reference: asFlow).
  3. FunctionalUserQueueService.kt: TokenGenerator 빈 주입 실패 (Could not autowire).
  4. FlowExtensions.kt (재발): asFlow와 asPublisher의 타입 불일치 (T : Any 제한 누락).
  5. QueueEventController.kt: asFlux 확장 함수 참조 오류 (Unresolved reference: asFlux).

이 오류들은 Spring Boot, Kotlin Coroutines, Reactor, 그리고 의존성 관리의 복잡한 상호작용에서 비롯된 문제들이었습니다.

2. 변경 전 코드

아래는 각 파일에서 오류가 발생했던 원본 코드의 주요 부분입니다.

2.1. RedisConfig.kt

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에서 존재하지 않아 컴파일 오류 발생.

2.2. FlowExtensions.kt (제네릭 타입 오류)

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를 요구하여 타입 불일치 오류 발생.

2.3. FunctionalUserQueueService.kt (빈 주입 오류)

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 컨텍스트에 없어 자동 주입 실패.

2.4. QueueEventController.kt (확장 함수 참조 오류)

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() 확장 함수가 참조되지 않음 (임포트 누락).

3. 해결 방법

각 문제에 대해 다음과 같은 해결 방법을 적용했습니다.

3.1. RedisConfig.kt

  • 문제 원인: RedisSerializationContext의 빌더 API를 잘못 사용 (key, value 메서드 대신 SerializationPair 사용 필요).

  • 해결

    • SerializationPair.fromSerializer()를 사용하여 직렬화 설정 구성.
    • 중복된 ReactiveRedisTemplate 빈 제거.
    • RedisSerializationContext.string()으로 간소화된 설정 추가.

3.2. FlowExtensions.kt

  • 문제 원인: kotlinx-coroutines-reactive의 asFlow()와 asPublisher()가 T : Any를 요구하여 제네릭 타입 불일치.

  • 해결

    • fun 제약 추가로 타입 안전성 확보.
    • 패키지를 com.docqueue.global.exception에서 com.docqueue.global.util로 이동.
    • build.gradle에서 의존성 버전 업데이트 및 충돌 점검.

3.3. FunctionalUserQueueService.kt

  • 문제 원인: Spring이 TokenGenerator 빈을 찾지 못함 (디폴트 값 무시).

  • 해결

    • AppConfig.kt에 @Bean으로 TokenGenerator 등록.
    • 생성자에서 디폴트 값 제거, Spring 의존성 주입 활용.

3.4. QueueEventController.kt

  • 문제 원인: asFlux() 확장 함수 임포트 누락.

  • 해결

    • import com.docqueue.global.util.asFlux 추가.
    • FlowExtensions.kt 파일이 올바른 패키지에 위치하는지 확인.
    • Gradle 빌드 및 IDE 캐시 초기화.

3.5. 의존성 관리

  • 문제 원인: spring-boot-starter-web과 spring-boot-starter-webflux 충돌 가능성, 의존성 버전 최신화 필요.

  • 해결

    • build.gradle에서 의존성 버전 업데이트 (junit-jupiter, mockk, kotest 등).
    • 불필요한 spring-boot-starter-web 제거 권장.
    • ./gradlew dependencies로 충돌 점검.

4. 변경 후 코드

아래는 각 파일의 최종 수정된 코드입니다.

4.1. RedisConfig.kt

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)
    }
}

4.2. FlowExtensions.kt

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()

4.3. FunctionalUserQueueService.kt

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
)

4.4. AppConfig.kt (TokenGenerator 빈 등록)

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() }
}

4.5. QueueEventController.kt

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()
    }
}

4.6. build.gradle (의존성 최적화)

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")
}

5. 주요 개선 사항

  • 코드 안정성

    • Redis 설정에서 올바른 API 사용으로 컴파일 오류 해결.
    • 제네릭 타입 제한(T : Any) 추가로 kotlinx-coroutines-reactive와의 호환성 확보.
    • Spring 빈 주입을 표준화하여 의존성 관리 개선.
  • 가독성 및 유지보수성

    • FlowExtensions.kt를 util 패키지로 이동하여 코드 의도 명확화.
    • QueueStatus 데이터 클래스 추가로 타입 안전성 강화.
    • 불필요한 디폴트 값 제거 및 Spring 의존성 주입 활용.
  • 의존성 최적화

    • 최신 의존성 버전 적용으로 호환성 문제 최소화.
    • spring-boot-starter-web 제거 권장으로 WebFlux 중심 아키텍처 명확화.
  • 빌드 안정성

    • Gradle 캐시 초기화 및 ./gradlew clean build로 빌드 문제 해결.
    • IDE 캐시 초기화로 "Unresolved reference" 오류 제거.

6. 배우게 된 점

1. Spring Boot와 Kotlin의 상호작용

  • Spring의 의존성 주입은 Kotlin의 디폴트 값과 별개로 동작한다.
    @Bean 등록 또는 생성자 주입을 명시적으로 관리해야 함.

  • Kotlin의 제네릭 타입(T vs T : Any)은 라이브러리와의 호환성에 큰 영향을 미친다.

2. Kotlin Coroutines와 Reactor 통합

  • kotlinx-coroutines-reactive를 사용할 때는 asFlow(), asPublisher()의 제네릭 제한을 이해해야 한다.

  • Flow와 Flux 간 변환은 명시적 임포트와 타입 안전성이 중요하다.

3. 의존성 관리의 중요성

  • Spring Boot와 Kotlin 프로젝트에서는 의존성 버전 호환성을 항상 점검해야 한다.

  • ./gradlew dependencies로 충돌을 확인하고, 불필요한 의존성을 제거하는 습관이 필요하다.

4. IDE와 빌드 도구 활용

  • IntelliJ IDEA의 캐시 문제는 Invalidate Caches / Restart로 해결 가능.

  • Gradle 빌드 캐시와 동기화 문제는 ./gradlew clean build --refresh-dependencies로 처리.

5. 패키지 구조와 코드 조직

  • 확장 함수와 유틸리티 코드는 util 패키지에, 예외 처리는 exception 패키지에 분리하여 가독성을 높여야 한다.

7. 결론

Spring Boot와 Kotlin으로 비동기 대기열 시스템을 구현하면서 여러 오류를 해결하며 많은 것을 배웠습니다.
Redis 설정, Coroutines와 Reactor 통합, Spring 빈 주입, 그리고 WebFlux 컨트롤러의 문제를 해결하며, Spring Boot의 의존성 주입, Kotlin의 타입 시스템, 그리고 비동기 프로그래밍의 복잡성을 깊이 이해하게 되었습니다.

특히, 제네릭 타입 관리와 의존성 버전 호환성의 중요성을 깨달았고, IDE와 빌드 도구를 효과적으로 활용하는 방법을 익혔습니다.
이 경험은 앞으로 Spring Boot와 Kotlin을 사용하는 프로젝트에서 더 안정적이고 유지보수 가능한 코드를 작성하는 데 큰 도움이 될 것입니다.

혹시 비슷한 문제를 겪고 있다면, 위 해결 방법을 참고하고, ./gradlew dependencies와 IDE 캐시 초기화를 먼저 시도해보세요!
추가 질문이 있다면 언제든 댓글로 남겨주세요

profile
꾸준히, 의미있는 사이드 프로젝트 경험과 문제해결 과정을 기록하기 위한 공간입니다.

0개의 댓글