Spring Boot 4 텔레그램 봇 마이그레이션 트러블슈팅

궁금하면 500원·2026년 3월 17일

미생의 개발 이야기

목록 보기
72/73

운영 중이던 Java 기반 Telegram 봇 백엔드를 Kotlin + Spring Boot 4로 옮기는 작업을 진행했습니다.
"단순히 언어만 바꾸면 되겠지"라는 생각은 첫 번째 컴파일 에러 앞에서 바로 무너졌습니다. Kotlin의 null-safety, Reactor의 제네릭 제약, Spring Boot 4의 달라진 자동 구성까지 — 세 가지가 동시에 맞물리면서 예상치 못한 문제들이 쏟아졌습니다.

이 글은 그 과정에서 마주한 에러들과, 각각을 어떻게 판단하고 해결했는지를 정리한 기록입니다. 단순히 "이렇게 고쳤다"가 아니라, 왜 그런 에러가 발생했고, 왜 그 방향으로 해결했는지에 초점을 맞추었습니다.


프로젝트 배경

프로젝트는 Telegram 봇 백엔드입니다.
Webhook과 폴링 방식의 메시지 수신, 커맨드 처리, 채널 발송, 파일 업로드 등의 기능을 포함하고 있습니다.

기존 구조는 Java + Spring Boot 기반이었고, 포트/어댑터 패턴을 적용해 Use Case와 커맨드 핸들러를 분리한 상태였습니다.
이번 마이그레이션의 목표는 이 구조를 그대로 유지하면서 Kotlin으로 전환하고, Spring Boot 4 환경에서 Reactor기반의 비동기 처리가 안정적으로 동작하도록 만드는 것이었습니다.

결론부터 말씀드리면, 가장 큰 과제는 Kotlin의 nullable 타입 시스템과 Reactor/Spring의 non-null 제네릭 제약을 동시에 만족시키는 것이었습니다.


1. 애플리케이션이 기동되지 않는다 — TelegramApiPort 빈을 찾을 수 없음

증상

애플리케이션 기동 시 UnsatisfiedDependencyException이 발생했습니다. HandleUpdateService의 생성자에서 요구하는 TelegramApiPort 타입의 빈이 컨텍스트에 없다는 내용이었습니다.

원인 분석

실제 Telegram API를 호출하는 구현체인 TelegramWebClientAdapter는 WebClient와 토큰 설정이 갖춰져야 생성됩니다.
로컬이나 테스트 환경에서는 이 조건이 충족되지 않을 수 있기 때문에, "실제 구현이 없을 때 대신 쓸" 스텁을 만들어 두었습니다.

문제는 이 스텁이 빈으로 등록되지 않고 있었다는 점입니다.
@Component는 붙어 있었지만, 컴포넌트 스캔 경로에 포함되지 않았거나 빌드 결과물에 누락된 상태였습니다.

해결 방법

스캔 경로에 의존하는 방식 대신, 확실한 등록 방법을 선택했습니다.

StubBeansConfig라는 @Configuration 클래스를 만들어 TelegramApiPortExternalContentPort의 스텁을 @Bean으로 명시 등록했습니다.
그리고 메인 클래스에서 @Import(StubBeansConfig::class)로 이 설정을 반드시 로드하도록 지정했습니다.

추후 실제 구현체가 등록되면 스텁은 필요 없으므로, 스텁 빈에 @ConditionalOnMissingBean(TelegramApiPort::class)을 붙여 "해당 타입의 빈이 없을 때만" 생성되도록 했습니다.

컴포넌트 스캔 순서나 패키지 구조에 의존하지 않고, 설정 클래스와 @Import로 로딩을 보장한 것이 핵심입니다.
환경에 따라 구현체가 달라질 수 있는 포트 패턴에서, 이런 명시적 등록 방식이 더 안전하다고 판단했습니다.


2. 같은 타입의 빈이 둘 다 Primary — NoUniqueBeanDefinitionException

증상

TelegramApiPort 타입의 후보 빈이 telegramWebClientAdaptertelegramApiPort두 개 발견되었는데, 둘 다 @Primary가 붙어 있어 NoUniqueBeanDefinitionException이 발생했습니다.

원인 분석

스텁을 등록할 때 "스텁이 기본값"이라는 의미로 @Primary를 붙여 두었고, 실제 어댑터에도 @Primary를 붙인 상태였습니다.
Spring은 하나의 타입에 primary 빈이 하나만 있어야 한다고 판단하기 때문에, 두 개가 공존하면 예외가 납니다.

해결 방법

스텁에서 @Primary를 제거했습니다.

애초에 @ConditionalOnMissingBean을 쓰고 있으므로, 실제 어댑터가 있으면 스텁은 생성되지 않습니다.
실제 어댑터만 남으면 primary 지정 자체가 불필요하고, 어댑터가 없으면 스텁 하나만 등록되니 역시 충돌할 일이 없습니다.

@ConditionalOnMissingBean@Primary를 동시에 쓰면 의도치 않은 충돌이 생길 수 있습니다. 조건부 등록을 사용할 때는, primary 대신 조건 자체로 유일성을 보장하는 편이 더 깔끔합니다.


3. 설정 클래스 이름 충돌 — ConflictingBeanDefinitionException

증상

StubBeansConfig라는 이름의 빈 정의가 두 곳에서 발견되어 충돌한다는 ConflictingBeanDefinitionException이 발생했습니다.

  • com.sleekydz86.tellme.StubBeansConfig
  • com.sleekydz86.tellme.global.config.StubBeansConfig

원인 분석

스텁 설정을 루트 패키지에 두었다가, 이후 global.config 패키지로 옮기는 과정에서 원본을 삭제하지 않은 것이 원인이었습니다.
두 클래스 모두 @Configuration이 붙어 있어 기본 빈 이름이 stubBeansConfig로 동일하게 생성됩니다.

해결 방법

루트 패키지의 StubBeansConfig를 삭제하고, global.config.StubBeansConfig만 남겼습니다.

단순한 실수이지만, 코드를 옮길 때 "원본 삭제"를 빠뜨리면 이런 식으로 빈 이름 충돌이 발생합니다.
IDE의 Move Refactoring 기능을 사용하면 이런 문제를 예방할 수 있습니다.
수동으로 복사-붙여넣기를 하면 원본이 남기 쉽습니다.


4. Kotlin에서 Java 스타일 getter가 보이지 않는 문제

증상

TelegramBotConfig에서 props.getApi().getBaseUrl()을 호출했을 때 Unresolved reference 'getApi' 에러가 발생했습니다.

원인 분석

TelegramBotProperties는 이미 Kotlin 클래스로 전환된 상태였습니다.
Kotlin 클래스의 프로퍼티는 Java의 getXxx() 형태가 아니라 .xxx 형태로만 접근할 수 있습니다.
Java 코드에서 쓰던 getApi()라는 메서드명을 그대로 가져온 것이 원인이었습니다.

해결 방법

props.getApi().getBaseUrl()props.api.baseUrl로 수정했습니다.

추가로, TelegramBotProperties의 프로퍼티가 private val로 선언되어 있어 다른 클래스에서 접근이 불가능한 상태였습니다.
Spring의 @ConfigurationProperties 바인딩도 setter가 필요하므로, public var로 변경했습니다.
Kotlin에서는 var로 선언하는 것만으로 getter와 setter가 자동 생성되기 때문에, Lombok의 @Getter/@Setter는 제거했습니다.

Java에서 Kotlin으로 전환할 때 가장 흔하게 마주치는 문제 중 하나입니다.
Kotlin 클래스에는 Java 스타일 getter 호출이 아니라 프로퍼티 접근을 사용해야 합니다.


5. List 타입 불일치 — MutableList<String?> vs List<String>?

증상

CORS 설정에서 setAllowedOrigins에 리스트를 전달할 때, 타입 불일치 에러가 발생했습니다. 실제로는 MutableList<String?>을 전달했는데, Spring API는 List<String>? 요소가 non-null인 리스트을 기대하고 있었습니다.

원인 분석

mutableListOf<String?>("http://localhost:3000", ...)처럼 타입 인자에 String?을 명시하면, "nullable String을 담는 리스트"가 됩니다.
하지만 Spring의 CORS API는 "요소가 nullable이 아닌 String을 담는 리스트"를 받습니다. Kotlin의 제네릭에서 List<String?>List<String>은 서로 다른 타입입니다.

해결 방법

타입 인자를 명시하지 않고 mutableListOf("http://localhost:3000", ...)처럼 작성하여 MutableList<String>이 추론되도록 했습니다.

같은 문제가 PollUpdatesUseCase에서도 발생했습니다.
텍스트를 모아두는 리스트를 mutableListOf<String?>()로 선언해 두었는데, 이를 mutableListOf<String>()로 바꾸고 message.text가 null이 아닌 경우에만 add하도록 수정했습니다.
이렇게 하면 filterNotNull() 호출도 불필요해집니다.

Java API와 연동할 때는 Kotlin 쪽에서 제네릭의 nullable 여부를 신경 써야 합니다. 특히 리스트 요소의 nullability는 놓치기 쉽습니다.


6. Reactor Mono와 Kotlin nullable — "Type argument is not within its bounds"

이번 마이그레이션에서 가장 빈번하게 마주친 에러입니다.

증상

Mono<TelegramSendResponse?>, Mono<Path?>, ResponseEntity<SendMessageResponse?> 등에서 "Type argument is not within its bounds: must be subtype of 'Any'" 에러가 발생했습니다.

원인 분석

Reactor의 Mono와 Spring의 ResponseEntity는 타입 파라미터에 T : Any라는 상한 제약(upper bound)이 있습니다.
즉, non-null 타입만 넣을 수 있습니다.

Kotlin에서 String?Any?의 하위 타입이지, Any의 하위 타입이 아닙니다.
따라서 Mono<String?>이라고 쓰면 제네릭 제약에 위배됩니다.

이 규칙을 모르면 에러 메시지만 보고 혼란에 빠지기 쉽습니다.
핵심은 "Mono 안에는 non-null만, nullable은 Mono 바깥에" 라는 원칙입니다.

해결 방법

모든 포트, 어댑터, 컨트롤러에 걸쳐 다음 원칙을 일관되게 적용했습니다.

포트 시그니처

// Before — 컴파일 에러
fun sendMessage(chatId: Long?, text: String?): Mono<TelegramSendResponse?>?

// After
fun sendMessage(chatId: Long?, text: String?): Mono<TelegramSendResponse>?

Mono<TelegramSendResponse>?는 "Mono 자체는 null일 수 있지만, Mono가 방출하는 값은 반드시 non-null"이라는 뜻입니다.

어댑터 및 컨트롤러에서는

  • bodyToMono(TelegramSendResponse::class.java) → 반환 타입이 자동으로 Mono<TelegramSendResponse>
  • Mono.just(value), Mono.error(e)에서도 타입 인자를 non-null로 유지
  • ResponseEntity.ok(body)에서 body가 non-null로 흐르도록 보장
  • nullable 값이 필요한 경우 json ?: "{}"처럼 Elvis 연산자로 non-null 변환

HandleUpdateService에서는:

// Before — 컴파일 에러, 강제 캐스팅 필요
fun handle(message: TelegramUpdate.Message): Mono<Void?> {
    ...
    @Suppress("UNCHECKED_CAST")
    return Mono.empty<Void>() as Mono<Void?>
}

// After — 깔끔하게 정리
fun handle(message: TelegramUpdate.Message): Mono<Void> {
    ...
    return Mono.empty()
}

Mono<Void>로 통일하면 @Suppress("UNCHECKED_CAST")와 강제 캐스팅도 전부 제거할 수 있습니다.
Mono.empty().then() 모두 Mono<Void>를 반환하기 때문입니다.

이 원칙을 정리하면 아래와 같습니다:

위치nullable 허용 여부예시
Mono/ResponseEntity 타입 인자non-null만 가능Mono<String>, ResponseEntity<Body>
Mono/ResponseEntity 자체nullable 가능Mono<String>? (Mono가 없을 수 있음)
메서드 파라미터상황에 따라chatId: Long?

7. Lombok과 Kotlin의 불일치 — @Slf4j, @Data, getter 접근

증상

여러 클래스에서 동시에 문제가 발생했습니다.

  • ExternalContentAdapter, TelegramWebClientAdapter: Unresolved reference 'log'
  • TelegramFileResponse: Unresolved reference 'getOk', 'getResult'
  • TelegramBotProperties: Cannot access 'api': it is private

원인 분석

세 문제 모두 Kotlin과 Lombok의 궁합이 맞지 않는다는 하나의 원인에서 비롯됩니다.

Lombok은 Java의 어노테이션 프로세서를 통해 컴파일 시점에 코드를 생성합니다.
그런데 Kotlin 컴파일러는 Lombok이 생성한 코드를 인식하지 못하는 경우가 있습니다.
@Slf4j가 만드는 log 필드, @Data가 만드는 getOk() 메서드 등이 Kotlin 쪽에서 "없는 것"으로 취급됩니다.

해결 방법

Kotlin 코드에서 Lombok 의존을 전면 제거하는 방향으로 정리했습니다.

로거: Lombok @Slf4j 대신 SLF4J를 직접 선언합니다.

private val log = LoggerFactory.getLogger(TelegramWebClientAdapter::class.java)

DTO: Lombok @Data 대신 Kotlin data class를 사용합니다.

// Before — Lombok 기반
@Data
class TelegramFileResponse {
    private val ok: Boolean = false
    private val result: FileResult? = null
}

// After — Kotlin data class
data class TelegramFileResponse(
    val ok: Boolean = false,
    val result: FileResult? = null
)

이렇게 하면 .ok, .result 같은 프로퍼티 접근이 자연스럽게 동작하고, getOk() 같은 Java 스타일 호출도 필요 없어집니다.

설정 클래스: private valpublic var로 변경하여 Spring 바인딩과 외부 접근을 모두 가능하게 했습니다.

Kotlin 프로젝트에서 Lombok을 함께 쓰는 것은 권장하지 않습니다.
Kotlin 자체가 data class, 프로퍼티 접근, 기본 파라미터 등 Lombok의 기능 대부분을 언어 수준에서 지원하기 때문입니다.


8. 포트와 어댑터의 시그니처 불일치

증상

TelegramWebClientAdapter에서 여러 에러가 동시에 발생했습니다.

  • "is not abstract and does not implement abstract members"
  • "Return type is not a subtype of the overridden member"
  • "'sendMessage' overrides nothing"

원인 분석

포트 인터페이스는 이미 Mono<T>? T는 non-null 형태로 정리된 상태인데, 어댑터는 여전히 Mono<T?>? 형태를 쓰고 있었습니다.
Kotlin에서 Mono<TelegramSendResponse>?Mono<TelegramSendResponse?>?는 서로 다른 타입이므로, override가 성립하지 않습니다.

여기에 더해, DTO가 Lombok @Data + private val인 상태여서 getOk(), getResult() 호출이 안 되는 문제, doOnNext에서 Java Consumer 대신 Kotlin 람다를 써야 하는 문제 등이 겹쳐 있었습니다.

해결 방법

포트 인터페이스의 시그니처를 기준으로, 어댑터의 모든 메서드를 맞춰 나갔습니다.

// 포트 시그니처 (기준)
fun sendMessage(chatId: Long?, text: String?): Mono<TelegramSendResponse>?
fun downloadFileToLocal(fileId: String?, localFileName: String?): Mono<Path>?

// 어댑터 (포트에 맞춤)
override fun sendMessage(chatId: Long?, text: String?): Mono<TelegramSendResponse>? {
    return webClient.post()
        .uri("/sendMessage")
        .bodyValue(mapOf("chat_id" to chatId, "text" to text))
        .retrieve()
        .bodyToMono(TelegramSendResponse::class.java)
        .doOnNext { resp ->
            if (!resp.ok) log.warn("sendMessage failed: {}", resp)
        }
}

doOnNext는 Java Consumer 대신 Kotlin 람다로, DTO 접근은 data class의 프로퍼티로, Mono.just()Mono.error()의 타입 인자도 non-null로 통일했습니다.

WebClient 주입 시에는 @Qualifier("telegramWebClient")를 명시하여, Servlet 환경에서 빈을 정확히 찾을 수 있도록 했습니다.

포트-어댑터 패턴에서 시그니처 불일치는 컴파일 에러로 바로 잡히기 때문에, 오히려 런타임에 문제가 터지는 것보다 낫습니다.
다만 Kotlin의 nullable 차이가 시그니처 불일치의 원인이 될 수 있다는 점은 처음에 놓치기 쉬운 부분입니다.


9. WebClient.Builder가 자동 등록되지 않음 — Spring Boot 4 + Servlet 환경

증상

TelegramBotConfig에서 WebClient.Builder를 생성자로 주입받으려 했을 때, No qualifying bean of type 'WebClient.Builder' 에러가 발생했습니다.

원인 분석

Spring Boot에서 WebClient.Builder 빈의 자동 구성은 WebFlux 자동 구성에 포함되어 있습니다. 그런데 이 프로젝트는 Servlet Tomcat 기반으로 기동됩니다.
spring-boot-starter-webflux가 의존성에 있더라도, 메인 런타임이 Servlet이면 WebFlux 자동 구성이 활성화되지 않습니다.

Spring Boot 3까지는 spring-boot-starter-webflux를 추가하면 Servlet 환경에서도 WebClient.Builder가 자동 등록되는 경우가 있었지만, Spring Boot 4에서는 이 동작이 달라진 것으로 보입니다.

해결 방법

WebClient.Builder를 파라미터로 받는 대신, 설정 클래스 내부에서 직접 생성하도록 변경했습니다.

@Bean("telegramWebClient")
fun telegramWebClient(props: TelegramBotProperties): WebClient {
    return WebClient.builder()
        .baseUrl(props.api.baseUrl)
        .build()
}

별도의 WebClient.Builder 빈을 노출하지 않고, "Telegram 전용 WebClient" 하나만 빈으로 등록하는 형태입니다.
여러 외부 API를 호출해야 한다면 각각의 WebClient 빈을 만들고 @Qualifier로 구분하는 방식이 관리하기 편합니다.

Servlet 기반 앱에서 WebClient를 "클라이언트 전용"으로 쓸 때는, Builder 자동 구성에 의존하지 말고 직접 생성하는 것이 확실합니다.


10. 컨트롤러 전환 — 생성자 주입, nullable, ResponseEntity

증상

WebHookController에서 여러 문제가 동시에 터졌습니다.

  • Mono<ResponseEntity<TelegramSendResponse?>?> — Type argument is not within its bounds
  • Unresolved reference 'log'
  • Unresolved reference 'getMessage'
  • 의존성이 private val xxx: Type? = null로 선언되어 실제 주입이 안 되는 상태

원인 분석

Java 코드를 Kotlin으로 변환하면서, Lombok의 @RequiredArgsConstructor가 처리하던 생성자 주입이 사라진 것이 근본 원인이었습니다.
Kotlin에서는 필드만 = null로 선언해 두면 Spring이 주입할 방법이 없습니다.

거기에 앞서 다뤘던 문제들 — Lombok @Slf4j 미인식, Java getter 호출 불가, Mono/ResponseEntity nullable 타입 인자 위반 — 이 모두 겹쳐 있었습니다.

해결 방법

컨트롤러를 Kotlin 관례에 맞게 전면 재작성했습니다.

@RestController
@RequestMapping("/api/telegram")
class WebHookController(
    private val setWebhookUseCase: SetWebhookUseCase,
    private val handleUpdateService: HandleUpdateService,
    private val pollUpdatesUseCase: PollUpdatesUseCase,
    private val channelBroadcastUseCase: ChannelBroadcastUseCase,
    private val props: TelegramBotProperties
) {
    private val log = LoggerFactory.getLogger(WebHookController::class.java)

    // 반환 타입: Mono<ResponseEntity<TelegramSendResponse>> — 타입 인자는 모두 non-null
    // DTO 접근: update.getMessage() → update.message
    // nullable 처리: json ?: "{}" 로 non-null 보장
}

핵심 변경 사항을 정리하면 다음과 같습니다:

  • 생성자 주입: Kotlin의 주 생성자 primary constructor 에서 private val로 선언하면, Lombok 없이도 생성자 주입이 동작합니다
  • 로거: LoggerFactory.getLogger를 직접 사용
  • 반환 타입: Mono<ResponseEntity<T>>에서 T는 항상 non-null
  • DTO 접근: getMessage().message data class 프로퍼티
  • nullable 처리: Elvis 연산자(?:)로 non-null 변환

11. 설정 클래스 중복 제거와 패키지 정리

이것은 에러 자체보다는 마이그레이션 과정에서 발생하는 구조적 실수에 대한 이야기입니다.

파일을 옮기면서 원본을 삭제하지 않아 같은 이름의 @Configuration이 두 패키지에 남거나, 테스트용 코드가 프로덕션 패키지에 섞여 들어가는 일이 있었습니다.

이런 문제를 줄이기 위해 다음을 실천했습니다.

  • 파일 이동 시 IDE의 Refactor → Move를 사용하여 원본이 자동 삭제되도록 처리
  • 마이그레이션 완료 후 grep -r "class StubBeansConfig" 같은 검색으로 중복 확인
  • 패키지 구조를 정리한 뒤 전체 빌드를 돌려 빈 이름 충돌 여부 확인

마이그레이션 결과 정리

최종적으로 적용한 설계 판단을 정리하겠습니다.

영역선택판단 근거
스텁 빈 등록@Configuration + @Bean + @Import컴포넌트 스캔에 의존하지 않고 확실한 로딩 보장
빈 우선순위스텁에는 @Primary 미사용@ConditionalOnMissingBean@Primary 동시 사용 시 충돌 위험
포트 반환 타입Mono<T>? (T는 non-null)Reactor/Spring의 T : Any 제약 준수
DTOKotlin data class + public valLombok 의존 제거, 프로퍼티 접근 일관성
로거LoggerFactory.getLogger 직접 선언Kotlin + Lombok @Slf4j 호환 문제 회피
WebClient 생성설정 클래스에서 WebClient.builder().build() 직접 호출Servlet 환경에서 Builder 자동 구성 미적용 대응
컨트롤러 주입Kotlin 주 생성자 + non-null 타입 인자null-safety와 Spring 주입의 정합성 유지

돌아보며

이번 마이그레이션에서 배운 것을 세 가지로 요약하겠습니다.

첫째, Kotlin의 nullable과 Java/Spring의 제네릭 제약은 생각보다 자주 충돌합니다. "Mono 안에는 non-null만, nullable은 바깥에"라는 원칙을 초반에 세워두지 않으면, 포트-어댑터-컨트롤러 전체에서 같은 류의 에러가 반복됩니다.

둘째, Kotlin 프로젝트에서 Lombok은 거의 필요 없습니다.
data class, 프로퍼티 접근, 기본 파라미터 등 Kotlin이 이미 제공하는 기능으로 대체할 수 있고, 오히려 함께 쓰면 컴파일 시점 코드 생성 순서 문제로 에러가 납니다.

셋째, Spring Boot 메이저 버전 업그레이드에서는 "자동 구성이 달라졌을 수 있다"를 항상 염두에 두어야 합니다. WebClient.Builder 자동 등록처럼, 이전 버전에서 암묵적으로 동작하던 것이 새 버전에서 사라질 수 있습니다.
에러 메시지가 "빈을 찾을 수 없다"일 때, 코드를 의심하기 전에 자동 구성 조건을 먼저 확인하는 습관이 필요합니다.

이 글에서 다룬 에러들은 대부분 "Java → Kotlin 전환" 또는 "Spring Boot 메이저 업그레이드"를 할 때 누구나 마주칠 수 있는 것들입니다.
비슷한 작업을 계획하고 계신 분들께 조금이나마 참고가 되었으면 합니다.

profile
그냥 코딩할래요 재미있어요

0개의 댓글