FireDrago

[Kafka] Kafka를 사용할때 살펴봐야할 6가지 (1) 본문

프로젝트

[Kafka] Kafka를 사용할때 살펴봐야할 6가지 (1)

화이용 2026. 2. 28. 16:17

왜 Kafka가 필요할까?

치지직 라이브 채팅 데이터를 실시간 분석해 하이라이트를 포착하는 엔진을 개발하며 카프카를 도입했다. 초당 수천 ~ 수만 건의 채팅 데이터를 유실 없이 처리하고, 수집 서버와 분석 엔진 간의 의존성을 끊기 위해 카프카를 사용하는 분산 아키텍쳐가 필요했다.

 

하지만 파이프라인만 구축하면 모든 게 해결될 줄 알았던 생각은 오산이었다. 카프카는 메시지를 배달해줄 뿐, 중복 처리 방어(Idempotency)나 데이터 순서 보장, 이벤트 시간(Event Time) 정렬 같은 데이터 무결성은 온전히 개발자의 설계 영역이었다. 분산 시스템의 문제들을 어떻게 해결했는지, 삽질을 통해 배운것을 6가지 원칙으로 정리한다.

 

멱등성 : 카프카가 메시지를 여러개 보낼때

카프카는 기본적으로 'At-least-once(최소 한 번)' 전달을 보장한다. 네트워크 장애로 프로듀서(카프카)가 잘 받았다는 응답을 못 받으면 메시지를 재전송하고, 컨슈머는 같은 메시지를 두 번 읽게 된다. 카프카가 메시지를 잘 배달해주는 것과, 그 메시지를 받은 애플리케이션이 정확하게 계산하는 것은 별개의 문제다. 100개의 채팅이 발생했을 때 중복 처리로 인해 200개가 기록되는 참사를 막으려면, 데이터가 최종적으로 담기는 저장소에서 멱등성 설계가 필수적이다. 이번 프로젝트에서는 분석 엔진의 상태를 저장하는 `Redis TimeSeries`와 애플리케이션 양단에서 이 문제를 해결했다.

 

저장소(Redis) 레벨의 방어

-- Redis TS.ADD 명령: 동일 타임스탬프 유입 시 마지막 값으로 덮어쓰기
redis.call('TS.ADD', key, timestamp, value, 'ON_DUPLICATE', 'LAST')

컨슈머가 동일한 이벤트 시간의 데이터를 중복해서 밀어 넣더라도 데이터가 뻥튀기되지 않도록 `ON_DUPLICATE LAST` 옵션을 사용했다. 동일한 타임스탬프의 데이터가 유입되면 새로운 값으로 덮어쓰기를 수행하여, 결과적으로 데이터의 합계가 오염되는 것을 원천 차단했다.

 

애플리케이션(Java)레벨의 방어

// Java: AtomicReference를 이용한 원자적 시간 갱신
lastChatTime.updateAndGet(current -> 
    (current == null || eventTime.isAfter(current)) ? eventTime : current
);

애플리케이션 내부 메모리 상태도 보호해야 한다. 여러 스레드가 동시에 같은 채팅방의 데이터를 갱신할 때 발생하는 경쟁상태를 막기위해 `AtomicReference`를 도입했다. `updateAndGet` 연산을 통해 어떤 상황에서도 최신 시간의 데이터만 반영되도록 보장했다.

 

이벤트 시간과 처리시간 : 서버에서 데이터 발생 시간을 찍지 말것

서버 시계를 사용했더니

처음에는 단순하게 `Instant.now()`를 기준으로 Redis에 데이터를 쌓았다. 하지만 서버가 재시작되어 밀린 메시지를 한꺼번에 읽어올 때 재앙이 시작되었다. 10분 전에 발생한 채팅들이 '지금' 발생한 것처럼 한 슬롯에 몰려 들어가면서 분석 차트가 비정상적으로 튀는 현상이 발생한 것이다.

 

데이터 안의 시각을 끝까지 추적하기

// ❌ Processing Time: 서버의 현재 시각 기준 (지연 시 데이터 왜곡 발생)
long toTs = Instant.now().toEpochMilli();

// ✅ Event Time: 메시지 발생 시각을 기준으로 하되, 역전 방지 로직 추가
lastChatTime.updateAndGet(current -> 
    (current == null || eventTime.isAfter(current)) ? eventTime : current
);

결국 모든 분석 좌표를 치지직이 내려주는 채팅시간(Event Time)으로 고정했다. 메시지 페이로드에 포함된 타임스탬프를 끝까지 들고 가서 Redis의 타임스탬프로 사용했다. 하지만 여기서 또 다른 문제에 직면했다. 분산 환경에서는 메시지의 도착 순서가 발생 순서와 미세하게 다를 수 있다는 점이었다. 이를 방어하기 위해 Java의 `AtomicReference`를 활용했다. 새로운 데이터가 들어올 때 현재 메모리에 기록된 '최종 처리 시간'보다 과거의 데이터라면 무시하거나 별도 처리하도록 로직을 보강했다.


스트림 데이터 처리에서 서버의 시계는 절대 믿어서는 안 되는 가변값이며, 오직 데이터가 품고 있는 시간만을 사용해야 한다.

 

파티셔닝과 순서보장 : 키 설계의 중요성

키를 생략한 실수

카프카를 처음 사용하면서, 카프카가 알아서 순서를 맞춰줄것이라고 생각했다. 하지만 카프카의 순서보장은 오직 단일 파티션 내부에서만 유효하다. 설계를 정교하게 하지 않으면 메시지는 라운드 로빈 방식으로 흩어지고, 병렬로 동작하는 컨슈머들은 이를 제멋대로 낚아채 처리하기 시작한다. 

 

화력 분석 엔진에서 메시지 순서가 꼬이는 것은 치명적이다. 누적 채팅수를 기반으로 델타(Delta) 값을 계산하는데, 최신 데이터가 이전 데이터보다 먼저 도착하면 계산 로직은 '음수 변화량'을 내뱉거나 분석 불능 상태(WAITING)에 빠지게 된다

 

같은 채팅방은 같은 파티션에 할당하기

// ❌ 기존: 키 없이 전송 (라운드 로빈으로 파티션 분산)
kafkaTemplate.send("chat-topic", chatMessage);

// ✅ 개선: roomId를 Key로 지정 (동일 방 데이터는 동일 파티션 고정)
kafkaTemplate.send("chat-topic", chatMessage.getRoomId(), chatMessage);

데이터 전송 시 roomId를 메시지 키로 명시하여 전송하도록 수정했다. 동일한 키를 가진 데이터는 카프카 내부 해시 함수에 의해 반드시 동일한 파티션에 할당된다. 이를 통해 특정 채팅방의 모든 이벤트는 단일 컨슈머 스레드가 발생 순서 그대로 읽어 순서가 완전히 보장된다.

 

 

1편 요약

 

  • 멱등성: 중복 배달을 대비해 Redis와 Java 레벨에서 2중 방어막 구축.
  • Event Time: 서버 시계를 버리고 데이터 안의 시간을 사용.
  • 파티셔닝: Key 설정을 통해 동시성과 순서 보장하기.