FireDrago
[Kafka] Kafka를 사용할때 살펴봐야할 6가지 (2) 본문
4. EOS 설정
데이터의 유실과 중복을 방지하는 '정확히 한 번(Exactly-Once)' 처리를 위해서는 프로듀서(데이터를 보내는 앱)와 브로커(카프카 서버) 사이의 약속이 필요하다. 하지만 이 설정들을 활성화하면 단일 노드로 구성된 로컬 환경(Docker)에서는 에러가 발생한다.
EOS를 구현하기 위해 프로듀서에서 설정하는 두 가지 핵심 옵션
- `acks=all` (유실 방지): 프로듀서가 메시지를 보낸 후, 메인 서버(Leader)뿐만 아니라 예비 서버(Follower)들까지 복제를 마쳤는지 확인하고 응답(ACK)을 받는 설정이다. 서버 한 대가 고장 나도 데이터가 사라지지 않게 보장한다.
- `enable.idempotence=true` (중복 방지): 프로듀서가 메시지마다 고유 번호(PID, Sequence Number)를 붙여서 보낸다. 브로커는 이 번호를 장부에 기록해두었다가, 이미 받은 번호가 중복으로 들어오면 저장하지 않고 버린다. 네트워크 장애로 인해 같은 메시지를 여러 번 재전송해도 로그에는 딱 한 번만 기록된다.
왜 단일 노드에서 에러가 날까?
카프카는 본래 여러 대의 서버에 데이터를 나눠 담아 안정성을 확보하도록 설계된 분산 시스템이다. 특히 오프셋이나 트랜잭션 상태를 기록하는 핵심 내부 토픽들은 장애 발생을 대비해 최소 3대의 브로커에 복제본을 만들도록(Replication Factor=3) 기본값이 설정되어 있다.
하지만 로컬 개발을 위해 단일 브로커(1노드)만 띄운 환경에서는 이 '복제본 3개'라는 조건을 물리적으로 충족할 방법이 없다. 카프카 입장에서는 시스템의 안전을 보장하는 최소한의 복제 요구사항을 지킬 수 없다고 판단하고, 작업을 거부하며 `INVALID_REPLICATION_FACTOR` 에러를 띄우는 것이다. 이 과정이 무한히 반복되면서 브로커가 정상적으로 기동되지 않는 루프에 빠지게 된다.
환경에 맞는 명시적 설정과 초기화
# docker-compose.yml 환경 변수 설정
environment:
# 내부 장부(오프셋, 트랜잭션)의 복사본 수를 1개로 제한
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
로컬 환경에서는 카프카에게 "서버가 1대뿐이니 복사본을 1개만 만들어도 된다"고 허락해줘야 한다. 설정을 고쳐도 에러가 반복된다면, 이미 잘못된 설정(복사본 3개 필요)으로 생성된 데이터가 로컬 볼륨에 남아있기 때문이다. 카프카는 첫 기동 시의 메타데이터를 디스크에 기록하므로, docker compose down -v 명령어로 기존 데이터를 완전히 삭제한 뒤 재기동해야 새로운 설정이 적용된다.
5. Consumer Lag 과 모니터링
실시간 분석 엔진에서 '지연(Lag)'은 시스템의 신뢰도를 결정하는 핵심 지표다. 카프카에서 지연이란 프로듀서가 보낸 메시지의 위치(Log End Offset)와 컨슈머가 읽은 메시지의 위치(Current Offset) 사이의 차이를 의미한다. 컨슈머가 메시지를 읽어가는 속도가 생산 속도를 못 따라가면, 엔진은 현재의 하이라이트가 아닌 '이미 지나간 과거의 채팅'을 분석하게 된다. 실시간성이 깨진 분석 결과는 대시보드에서 아무런 가치를 갖지 못한다.
모니터링 체계 구축 : Prometheus & Grafana

Prometheus와 Grafana를 연동하여 컨슈머 그룹별 랙(Lag)을 실시간 대시보드로 시각화했다. `Message in per second` 지표를 통해 초당 유입량을 확인하고, `Lag by Consumer Group`을 통해 컨슈머가 얼마나 뒤처지고 있는지 실시간으로 감시한다.
이미지에서 보듯 Message in 수치가 갑자기 튀는 구간(Spike)이 발생하면 컨슈머 랙도 함께 증가할 가능성이 높다. 이때 무작정 서버를 늘리는 게 아니라 대시보드를 보고 원인을 파악해야 한다.
- 파티션 병목: 유입량은 많은데 특정 파티션만 랙이 심하다면 키(Key) 분산 전략을 다시 점검해야 한다.
- 컨슈머 처리 속도: 유입량 대비 Message consume 속도가 낮다면 분석 로직 내 외부 I/O(예: Redis 쓰기) 병목을 의심해 봐야 한다.
Dead Letter Queue (DLQ)
시스템이 처리할 수 없는 잘못된 형식의 데이터나 비즈니스 로직 오류를 유발하는 메시지를 독약 메시지(Poison Pill)라고 한다. 이러한 데이터가 유입되면 컨슈머는 에러를 내뱉으며 처리를 중단하거나, 설정에 따라 무한 재시도(Retry)를 반복하게 된다.
재시도와 장애 전이
카프카 컨슈머는 메시지 처리에 실패할 경우, 성공할 때까지 동일한 오프셋을 계속 읽으려 시도한다. 하지만 데이터 자체가 손상되었거나 로직상 처리 불가능한 데이터라면 아무리 재시도해도 성공할 수 없다. 결과적으로 한 개의 메시지 때문에 해당 파티션에 쌓인 수만 개의 후속 메시지들이 줄지어 대기하게 되는 블로킹 현상이 발생하고, 전체 시스템의 지연(Lag)으로 번진다.
실패한 메시지를 격리 수거하기
@Service
public class ChatConsumer {
// 1. 재시도 전략 설정
@RetryableTopic(
attempts = "3", // 최대 3번 시도 (본 시도 1회 + 재시도 2회)
backoff = @Backoff(delay = 1000, multiplier = 2.0), // 1초부터 시작해 재시도 간격을 2배씩 늘림
topicSuffixingStrategy = TopicSuffixingStrategy.SUFFIX_WITH_INDEX_VALUE,
dltStrategy = DltStrategy.FAIL_ON_ERROR, // 최종 실패 시 DLT로 전송
include = {RuntimeException.class} // 재시도를 유발할 예외 종류
)
@KafkaListener(topics = "chat-topic", groupId = "analysis-group")
public void consume(ChatMessage message) {
// 비즈니스 로직 수행
if (message.getContent() == null) {
throw new RuntimeException("내용이 없는 독약 메시지 발생");
}
System.out.println("채팅 분석 중: " + message.getContent());
}
// 2. 최종 실패 처리 (DLT 컨슈머)
@DltHandler
public void handleDlt(ChatMessage message, @Header(KafkaHeaders.RECEIVED_TOPIC) String topic) {
// 최종 실패한 메시지를 로그로 남기거나 별도 DB에 저장하여 사후 분석
System.err.printf("최종 실패 메시지 격리 - Topic: %s, Message: %s%n", topic, message);
}
}
이 문제를 해결하기 위해 일정 횟수 이상 처리에 실패한 메시지는 별도의 DLQ(Dead Letter Queue) 토픽으로 전송하도록 설계했다. 문제가 된 메시지만 별도 토픽으로 격리하기 때문에, 정상적인 다음 메시지들은 중단 없이 계속 처리될 수 있다. DLQ에 쌓인 메시지는 실시간 흐름을 방해하지 않는다. 나중에 개발자가 여유 있게 로그를 확인하여 왜 실패했는지 분석하고, 버그를 수정하거나 데이터를 보정하여 해당 메시지만 수동으로 재처리(Reprocess)할 수 있다.
'프로젝트' 카테고리의 다른 글
| [치즈픽] 하이브리드 아키텍쳐 도입기 (OCI + Home Server) (0) | 2026.03.20 |
|---|---|
| [치즈픽] 치지직 채팅 화력분석 알고리즘 개선기 (0) | 2026.03.19 |
| [Kafka] Kafka를 사용할때 살펴봐야할 6가지 (1) (0) | 2026.02.28 |
| [트러블 슈팅] 비동기 코드가 동기적으로 처리되는 이유 (Java Stream, CompletableFuture) (0) | 2026.02.14 |
| Spring Boot 4.0 & Kafka 4.x NoClassDefFoundError 해결하기 (0) | 2026.02.07 |
