FireDrago
Spring Boot 4.0 & Kafka 4.x NoClassDefFoundError 해결하기 본문
개인 프로젝트에서 Java 25 + Spring Boot 4.0.1 + Kafka 4.1.1이라는 가장 최신버전을 사용했다. 왜 최신버전을 신중히 도입해야 하는지 제대로 느꼈다. 이 조합에서, 예상치 못한 `NoClassDefFoundError`를 만나 몇일동안 디버깅을 해야했다. Kafka 4.x의 구조적 특징과 가상 스레드(Virtual Thread) 환경이 맞물려 발생한 문제를 해결해보자
문제 상황
우선 치지직의 100개 이상의 방송 웹소켓 채팅을 동시에 연결하여 카프카로 전송하는 과정에서 발생한 문제이다. 가상 스레드를 활용하기 위해서 가장 최신 환경의 버전을 사용했다.
- 언어 : java 25
- 프레임워크 : Spring Boot 4.0.1
- 메시지 큐 : Kafka Client 4.1.1 (Spring Kafka 4.0.1 내장)
Kafka를 로컬 개발단계에서 사용중이었기 때문에, 보안 프로토콜이 필요하지 않았다. 따라서 application.yml 설정에서 `PLAINTEXT` 설정을 명시했다. `security.protocol`은 Kafka 클라이언트와 브로커 간 통신에서 보안 계층(SSL/SASL)을 쓸지, 그냥 평문으로 주고받을지를 정하는 설정이다.
spring:
kafka:
producer:
security.protocol: PLAINTEXT
// build.gradle 설정
dependencies {
// Spring Boot 4.0.1의 기본 Kafka Client 4.1.1 사용
implementation 'org.springframework.boot:spring-boot-starter-kafka'
// (보안을 안 쓰니까 SASL 관련 라이브러리는 추가 안 함)
}
에러 발생
앱 실행 후 첫 메시지를 전송하는 순간 (KafkaTemplate.send()), `NoClassDefFoundError`가 발생하고 애플리케이션이 종료되었다.
2026-01-29T06:39:40.340Z ERROR 1
--- [slice-stream-engine] [virtual-1720] i.s.s.e.c.a.ChatConnectionManager
: [N2GBOU] 재연결을 시도합니다.
java.lang.NoClassDefFoundError:
Could not initialize class org.apache.kafka.clients.producer.ProducerConfig
at org.apache.kafka.clients.producer.KafkaProducer.<init>(KafkaProducer.java:301)
...
Caused by: java.lang.ExceptionInInitializerError
at org.apache.kafka.clients.producer.ProducerConfig.<clinit>(ProducerConfig.java:518)
Caused by: org.apache.kafka.common.config.ConfigException:
Invalid value org.apache.kafka.common.security.oauthbearer.DefaultJwtRetriever
for configuration sasl.oauthbearer.jwt.retriever.class:
Class org.apache.kafka.common.security.oauthbearer.DefaultJwtRetriever
could not be found.
- [virtual-1720] : 에러가 가상 스레드에서 발생했음을 알 수 있다.
- ProducerConfig.<clinit> : 클래스의 static 초기화 메서드 clinit으로 보아 인스턴스 생성이 아닌,
정적 초기화 단계에서 에러가 발생한 것을 알 수 있다.
- DefaultJwtRetriever : 보안 설정에 관여하는 클래스이다. 분명 PLAINTEXT 설정을 했는데 OAuth 관련 설정을 찾다가
찾을 수 없다는 에러메시지가 발생했다.
문제 원인
Gemini, Perplexity를 통해서 디버깅을 열심히 해서, 원인을 추정할 수 있었다. 여담이지만 Perplexity는 디버깅 학습의 신이다. 이 문제는 세가지 원인이 콤비네이션을 이뤄서 발생된 아주 복잡한 문제였다.
1. Kafka 4.x 버전의 설정동작 방식
Kafka 4.1.1 `ProducerConfig` 클래스는 메모리에 로딩되는 순간 사용가능한 모든 설정 기본값을 정의한다. 즉 내가 application.yml파일에 PLAINTEXT 설정을 명시했더라도, 런타임 설정이 적용되기 전에 OAuth 설정 기본 클래스 DefaultJwtRetriever를 강제로 참조한다. 이 설정 자체가 문제는 아니다.
2. 유령 의존성
문제는 `DefaultJwtRetriever`가 내부적으로 `org.jose4j` 라이브러리를 사용한다는 점이다. Kafka 4.x에서 jose4j는 Optional(선택적) 의존성이다. 즉, 개발자가 build.gradle에 명시적으로 추가하지 않으면 프로젝트에 포함되지 않는다. JVM은 ProducerConfig를 로딩하려고 시도하다가, 검증 단계에서 필수 부품인 jose4j가 없다는 것을 발견하고 `NoClassDefFoundError`를 발생시킨다.
3. 결정적 차이: 플랫폼 스레드 vs 가상 스레드
3.1 플랫폼 스레드 환경이었다면?
전통적인 스레드 풀 환경이라면, 에러가 발생했을 때 스택트레이스를 남기고 해당 스레드만 종료되었을 것이다. 물론 Kafka 전송 기능은 동작하지 않았겠지만, 서버 프로세스 자체는 살아있고 에러가 명확히 찍혀 원인을 금방 파악할 수 있다.
3.2 가상스레드 환경에서는?
웹소켓 요청을 처리하단 가상 스레드 내부에서 NoClassDefFoundError가 발생하면, 가상스레드는 가볍고 수가 많기 때문에 메인스레드 만큼 경고를 주지 않거나, 예외가 모호하게 처리되는 경우가 있다. 실제로 로그 상에서 짧은 로그메시지만 남기고 서버 프로세스가 종료되어 문제를 파악하기 힘들었다.
해결 방법
1. 의존성 주입
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-kafka'
// [핵심 해결책] Kafka 4.x ProducerConfig 초기화 에러 방지용 필수 의존성
// 실제 OAuth를 안 쓰더라도, 클래스 로딩 패스를 위해 반드시 필요함
implementation 'org.bitbucket.b_c:jose4j:0.9.6'
}
kafka 초기화 과정에서 에러를 뱉지 않도록, 사용하지 않더라도 `jose4j`를 끼워넣는다. 의문인것은 사용자가 설정으로 보안을 사용하지 않는 선택지를 주면서도, 필수적으로 보안 담당객체를 주입받도록 설계한 이유는 뭘까? 이해하지는 못했다.
2. 메인 스레드 웜업
@Slf4j
@Component
@RequiredArgsConstructor
public class KafkaWarmup {
private final KafkaTemplate<String, String> kafkaTemplate;
/**
* Virtual Thread 환경에서 Kafka Producer가 처음 초기화될 때
* ClassLoader 문제로 인한 NoClassDefFoundError를 방지하기 위해
* Main Thread에서 미리 초기화(Warm-up)를 수행
*/
@PostConstruct
public void warmup() {
try {
log.info(" Kafka Producer Warm-up Starting...");
// Producer 내부 Metrics를 호출하여 강제로 클래스 로딩을 유발함
kafkaTemplate.execute(producer -> {
log.info(" Kafka Producer Initialized! Metrics count: {}", producer.metrics().size());
return null;
});
} catch (Exception e) {
log.warn("⚠ Kafka Warm-up failed (non-critical): {}", e.getMessage());
}
}
}
가상 스레드 환경에서 발생할 수 있는 NoClassDefFoundError와 같은 Kafka Producer 초기화 문제를 예방하기 위해서는 애플리케이션 시작 시점에 메인 스레드를 통해 KafkaTemplate을 미리 한번 실행하여 웜업(Warm-up)하는 과정이 권장된다. 이는 가상 스레드가 메인 스레드와 비교해 클래스 로더의 동작이나 초기화 타이밍에 미묘한 차이가 있어 첫 사용 시 예외가 발생하면 스레드가 종료되거나 로그가 누락될 위험이 있기 때문이며, @PostConstruct나 CommandLineRunner를 활용해 메인 스레드에서 미리 초기화를 수행하면 클래스 로딩 및 정적 초기화(<clinit>)가 확실하게 완료되어 메모리에 안전하게 적재된다.
3. 결과
조치 후 로그에서 `NoClassDefFoundError`가 완전히 사라지고 가상스레드 환경에서도 안정적으로 kafka 메시지 전송이 성공한다.
INFO [slice-stream-engine] [main] KafkaWarmup : ✅ Kafka Producer Initialized!
...
INFO [slice-stream-engine] [virtual-135] ChzzkWebSocketListener : [N2GDc2] 채팅 메시지 수신
INFO [kafka-server] Sent auto-creation request for Set(__consumer_offsets) ...
- 최신 버전은 언제나 베타 테스터가 될 각오가 필요하다. Kafka 4.x의 Eager Loading 방식은 다소 아쉬운 설계라고 생각한다.
- 설정값(YAML)보다 클래스 로딩이 먼저다. static 블록에서 터지는 문제는 설정 파일 수정으로 해결할 수 없다.
- 가상 스레드 환경의 디버깅. 가상 스레드는 클래스 로딩이나 컨텍스트 전파 방식이 미묘하게 다를 수 있어, 무거운 초기화 로직은 가능한 메인 스레드에서 Warm-up 하는 패턴이 베스트 프랙티스 이다.
'프로젝트' 카테고리의 다른 글
| [Kafka] Kafka를 사용할때 살펴봐야할 6가지 (1) (0) | 2026.02.28 |
|---|---|
| [트러블 슈팅] 비동기 코드가 동기적으로 처리되는 이유 (Java Stream, CompletableFuture) (0) | 2026.02.14 |
| [채팅 분석] 7. 안정적인 대규모 실시간 채팅 수집 완성 (2) (0) | 2026.02.01 |
| [채팅 분석] 6. 가상 스레드로 구축하는 대규모 실시간 채팅 수집 (1) (0) | 2026.01.23 |
| [채팅 감정 분석] 5. MVP 완성 및 트러블 슈팅 (설계 변경) (0) | 2025.12.25 |
