최근 Spring Application Event를 공부하고 사용해 보면서, 모듈 간 의존성을 줄이는 것만으로도 코드가 단순해지고 유지보수가 쉬워지는 경험을 했습니다.
하지만 이 방식은 JVM 내부에서만 동작합니다.
분산 환경에서는 JVM 내부 로컬에서만 동작하는 Spring Application Event 를 사용할 수 없습니다.
그렇다면 여러 애플리케이션이 동시에 통신하는 분산 환경에서는 어떻게 이벤트를 주고받을 수 있을까요?
RabbitMQ, ActiveMQ 같은 전통적인 메시지 브로커도 있지만, 왜 Kafka는 현대 데이터 아키텍처의 중심으로 자리 잡았을까요?
Kafka는 단순히 "이벤트를 전달"하는 것을 넘어서, 대규모 데이터 처리와 재처리, 확장성, 로그 저장소라는 특별한 강점을 가지고 있습니다.
Kafka의 기본 구조는 익숙합니다. 메시지를 발행하는 Producer 메시지를 소비하는 Consumer 그 사이를 중개하는 Broker 이 발행/구독 모델 덕분에 Producer와 Consumer는 서로를 전혀 몰라도 되며, 이는 시스템의 유연성과 확장성을 높여주는 첫 번째 비결입니다.
여기에 Kafka만의 차별점이 숨어 있습니다. Broker는 메시지를 종류별로 보관하는 토픽(Topic) 을 가지고 있습니다. '주문', '좋아요', '로그' 등 각 토픽은 자신만의 메시지를 보관하죠.
그런데 만약 '좋아요' 토픽에 초당 수천 개의 메시지가 쏟아진다면 어떻게 될까요?
하나의 토픽만으로는 이 모든 메시지를 처리하기 벅찰 겁니다. 그래서 카프카는 각 토픽을 여러 개의 작은 물리적 파일, 즉 파티션(Partition) 으로 나눕니다.
토픽이 하나의 거대한 고속도로라면, 파티션은 그 고속도로를 구성하는 각각의 차선입니다. 차선이 많을수록 더 많은 차가 동시에 달릴 수 있듯, 파티션이 많을수록 더 많은 메시지를 동시에 처리할 수 있습니다. 이것이 카프카가 엄청난 처리량을 감당할 수 있는 병렬 처리의 비밀입니다.
토픽이 여러 개의 파티션으로 나뉘어 병렬 처리가 가능하다는 건 알겠습니다. 그럼 프로듀서가 보낸 메시지는 대체 어떤 차선(파티션)으로 들어가게 될까요?
프로듀서가 파티션 키 없이 메시지를 전송하면, Kafka는 메시지를 모든 파티션에 골고루 분산하려고 합니다.
하지만 최신 Kafka 클라이언트(2.4+)에서는 단순한 Round-robin이 아니라 Sticky Partitioner가 기본 동작입니다.
Sticky Partitioner는 한동안 특정 파티션에 메시지를 몰아서 전송하다가, 배치가 가득 차거나 전송이 끝나면 다음 파티션으로 전환합니다.
즉, 단기적으로는 특정 파티션에 몰리지만, 장기적으로는 모든 파티션에 균등하게 분산됩니다.
이 방식은 메시지를 파티션별로 묶음 단위로 보낼 수 있어 배치/압축 효율이 크게 올라가고 처리량이 향상됩니다.
따라서 로그 수집, 클릭 이벤트 같은 대규모 스트림 처리에서 매우 효과적입니다.
참고로, Kafka 2.4 이전 버전에서는 Round-robin 분배가 기본이었습니다. 이 경우 메시지를 보낼 때마다 파티션을 순차적으로 바꿔가며 전송했기 때문에 즉시 균등 분산은 가능했지만, 배치 효율은 떨어졌습니다.
프로듀서가 메시지를 보낼 때 파티션 키를 지정하면, 메시지의 흐름은 더 이상 무작위로 결정되지 않습니다.
프로듀서는 주어진 키의 해시(Hash) 값을 계산하고, 그 값을 파티션의 개수로 나눈 나머지를 통해 메시지가 들어갈 파티션을 결정합니다.
partition = hash(key) % num_partitions
해시 함수의 특성상, 동일한 키는 항상 동일한 해시 값을 가지므로, 반드시 동일한 파티션으로 들어가게 됩니다.
그렇다면 왜 굳이 키를 써서 메시지를 특정 파티션에 "고정"해야 할까요? 바로 메시지 처리 순서를 보장하기 위해서입니다.
예를 들어, 한 사용자가 특정 상품에 대해 1초 만에 "좋아요" 를 눌렀다가 바로 "좋아요 취소" 를 눌렀다고 상상해 봅시다. 만약 파티션 키가 없다면, '좋아요 ' 이벤트는 0번 파티션에, '좋아요 취소 ' 이벤트는 1번 파티션에 들어갈 수 있습니다. 만약 1번 파티션을 처리하는 컨슈머가 더 빨랐다면, 시스템은 '취소'를 먼저 처리하고 나중에 '좋아요'를 처리하여 최종 결과가 '좋아요' 상태로 남는, 데이터가 틀어지는 문제가 발생합니다.
하지만 이때 productId를 파티션 키로 사용했다면 어떨까요? '좋아요'와 '좋아요 취소' 이벤트는 모두 동일한 productId 키를 가지므로, 반드시 같은 파티션에, 발생한 순서대로 저장됩니다. 카프카는 파티션 내에서의 순서는 보장하므로, 컨슈머는 반드시 '좋아요'를 먼저 처리하고 '좋아요 취소'를 처리하게 되어 데이터의 정합성을 지킬 수 있습니다.
실제 코드에서는 kafkaTemplate.send 메소드의 두 번째 인자로 이 파티션 키를 전달할 수 있습니다.
// '좋아요' 이벤트를 발행하는 코드 예시
@RequiredArgsConstructor
@Component
public class LikeEventPublisherImpl implements LikeEventPublisher {
private final KafkaTemplate<String, Object> kafkaTemplate;
private static final String TOPIC_NAME = "like-update-topic-v1";
@Override
public void publishEvent(ProductLikeEvent.Update event) {
// ...
// 토픽 이름, 파티션 키(productId), 실제 메시지 순으로 전달
kafkaTemplate.send(TOPIC_NAME, event.productId().toString(), update);
}
}참고로 파티션을 설계할 때 한 가지 중요한 제약사항이 있습니다. 파티션은 한번 늘리는 것은 가능하지만, 절대로 줄일 수는 없습니다. 파티션 수를 변경하면 키와 파티션의 매핑 규칙이 바뀌어 순서 보장이 깨질 수 있으므로, 신중한 설계가 필요합니다.
파티션을 통해 데이터를 병렬로 저장하는 구조를 갖췄다면, 이제는 그 데이터를 효율적으로 소비할 차례입니다.
Kafka 컨슈머는 메시지를 하나씩 가져오는 대신, 여러 개를 한 묶음(Batch)으로 가져올 수 있습니다.
이는 Kafka의 설계 의도 중 하나로, 네트워크 왕복 비용과 DB I/O 횟수를 줄이는 데 큰 효과가 있습니다.
예를 들어, 메시지를 100건 처리한다고 가정해 보겠습니다.
실제로 저는 '좋아요 수'를 처리하는 로직에서 이 방식을 적용했습니다.
Spring Application Event만 사용했을 때는 '좋아요' 이벤트마다 곧바로 DB에 단건 UPDATE가 실행되었습니다. 코드는 단순했지만, 트래픽이 몰리면 DB 부하가 급격히 늘어날 수 있는 구조였죠.
Kafka를 도입한 뒤에 이벤트를 토픽에 쌓고, 컨슈머에서 여러 이벤트를 배치 단위로 집계 한 후 DB에 반영했습니다. 덕분에 수십·수백 번의 단건 UPDATE가 단 몇 번의 배치 UPDATE로 바뀌면서, DB에 가해지는 부하를 크게 줄일 수 있는 구조를 만들 수 있었습니다.
즉, Kafka는 배치 단위로 소비(poll)할 수 있는 판을 깔아주고, 개발자는 이 데이터를 활용해 효율적인 집계·처리를 구현할 수 있습니다. 이 특성은 로그 수집, 클릭스트림 처리, 집계성 이벤트 처리 같은 시나리오에서 특히 큰 장점을 발휘합니다.
전통적인 메시지 큐는 보통 컨슈머가 메시지를 성공적으로 처리하고 나면 큐에서 해당 메시지를 삭제합니다. 한 번 배달된 택배는 사라지는 것과 같죠. 하지만 카프카는 정반대로 행동합니다.
카프카는 메시지를 받는 즉시, 토픽 내의 파티션(Partition) 이라는 파일 시스템에 순서대로 차곡차곡 기록합니다.
마치 데이터베이스의 트랜잭션 로그처럼, 한번 기록된 데이터는 정해진 보존 기간(기본값 7일) 동안 절대로 지워지지 않습니다. 컨슈머는 메시지를 '가져가는' 것이 아니라, 특정 위치(오프셋, Offset)의 메시지를 '복사해서 읽어가는' 것에 가깝습니다.
이 "지우지 않는다"는 단순한 특징 하나가 카프카를 완전히 다른 차원의 시스템으로 만듭니다.
컨슈머 애플리케이션에 버그가 있어서 잘못된 데이터를 처리했다면 어떻게 할까요?
기존 메시지 큐라면 이미 사라진 데이터를 복구할 방법이 막막합니다. 하지만 카프카에서는 간단합니다. 컨슈머의 오프셋을 과거의 특정 시점으로 되돌리기만 하면 됩니다. 그러면 컨슈머는 마치 타임머신을 탄 것처럼 그 시점부터 모든 메시지를 다시 읽어와 재처리할 수 있습니다. 장애가 발생해도, 로그가 남아있는 한 데이터 처리는 언제든 복구될 수 있다는 엄청난 안정감을 줍니다.
데이터가 삭제되지 않기 때문에, 여러 목적을 가진 다양한 컨슈머들이 각자의 필요에 따라 동일한 데이터를 소비할 수 있습니다. 예를 들어, 동일한 '주문 발생' 이벤트를 가지고,
이처럼 카프카는 단순한 메시지 '통로'가 아니라, 모든 이벤트의 역사가 기록되는 신뢰할 수 있는 '분산 로그 저장소(Distributed Log Store)' 에 더 가깝습니다.
이것이 바로 카프카의 진짜 정체성입니다.
이 '로그'라는 특성을 어떻게 활용할지는 컨슈머가 길을 잃었을 때를 보면 더 명확해집니다. 완전히 새로운 컨슈머가 토픽을 구독하거나, 너무 오랫동안 꺼져 있어서 마지막 위치를 잃어버렸을 때, "어디서부터 다시 읽을까?"를 결정해야 합니다. 이 때 auto.offset.reset 설정이 사용됩니다. earliest로 설정하면 토픽의 맨 처음부터 모든 역사를 훑으며 데이터를 처리하고, latest(기본값)로 설정하면 과거는 무시하고 지금부터 들어오는 새로운 이벤트만 처리합니다. 데이터의 유실을 허용하지 않는 대부분의 서비스에서는 earliest를 선택하여 모든 이벤트의 역사를 존중하는 방식을 택합니다.
이렇게 강력한 카프카도 분산 시스템의 숙명인 '네트워크 문제'로부터 자유로울 수는 없습니다. 여기서부터 개발자의 진짜 고민이 시작됩니다. 바로 "데이터 유실과 중복" 의 문제입니다.
카프카는 기본적으로 At-least-once(최소 한 번 전송) 시맨틱을 지향합니다. "메시지가 유실되는 것보다는 차라리 중복되는 게 낫다"는 철학이죠. 이 철학은 프로듀서와 컨슈머 양쪽 모두에게서 나타납니다.
프로듀서가 메시지를 브로커에 보냈는데, 네트워크가 잠시 불안정해서 브로커로부터 "잘 받았어"라는 응답(Ack)을 받지 못했다고 가정해 봅시다. 프로듀서 입장에서는 메시지가 유실됐다고 의심할 수밖에 없습니다. 그래서 카프카 클라이언트는 retries 옵션에 따라 자동으로 메시지를 재전송합니다.
사실 브로커는 첫 번째 메시지를 잘 받고 저장했는데, 응답만 유실된 상황이었다면 어떻게 될까요? 브로커는 재전송된 똑같은 메시지를 또 받게 되고, 결국 토픽에는 동일한 메시지가 중복으로 저장됩니다.
컨슈머 측에서는 상황이 더 복잡해집니다. 프로듀서가 메시지를 딱 한 번만 보냈더라도, 컨슈머는 동일한 메시지를 여러 번 처리할 수 있습니다.
컨슈머는 메시지를 처리한 뒤, "나 여기까지 처리했어"라는 의미로 오프셋을 커밋합니다. 만약 메시지 처리는 성공했는데(예: DB에 count = count + 1 실행 완료), 오프셋을 커밋하기 직전에 컨슈머 애플리케이션이 다운된다면 어떻게 될까요?
컨슈머가 재시작되면, 아직 커밋되지 않은 이전 오프셋부터 메시지를 다시 가져옵니다. 결국 방금 성공했던 count = count + 1 로직이 다시 실행되어 중복 처리가 발생합니다.
이 두 가지 시나리오는 "좋아요" 카운트가 1이 아닌 2가 올라가는 것처럼 데이터의 정합성을 깨뜨리는 심각한 문제를 일으킬 수 있습니다. 그렇다면 이 문제를 어떻게 해결해야 할까요?
바로 여기서 멱등성(Idempotence) 이라는 개념이 등장합니다. "여러 번 수행해도 결과는 한 번만 적용되는 성질"을 의미하는 멱등성은, At-least-once 환경에서 데이터 신뢰성을 지키기 위해 반드시 고려해야 하는 개념입니다.
다행히도, 프로듀서가 메시지를 중복 발행하는 문제는 카프카가 자체적으로 제공하는 매우 강력한 기능으로 간단하게 해결할 수 있습니다. 바로 **멱등성 프로듀서(Idempotent Producer)**입니다.
앞서 설명한 '응답(Ack) 유실로 인한 재전송' 문제를 해결하기 위해, 멱등성 프로듀서는 각 메시지에 고유한 시퀀스 번호를 붙여서 보냅니다. 브로커는 이 시퀀스 번호를 기억하고 있다가, 만약 이미 처리한 시퀀스 번호의 메시지가 또 들어오면 "아, 이건 중복이구나"라고 판단하고 조용히 무시합니다.
이 놀라운 기능을 활성화하는 방법은 허무할 정도로 간단합니다. 프로듀서 설정에 단 한 줄만 추가하면 됩니다.
# application.yml or kafka.yml
spring:
kafka:
producer:
properties:
enable.idempotence: true이 설정 하나만으로 프로듀서가 브로커에게 메시지를 보내는 과정의 중복 문제는 완벽하게 해결됩니다. 이제 진짜 문제는 컨슈머에게 넘어왔습니다.
컨슈머가 동일한 메시지를 중복 처리하는 문제는 카프카가 해결해 줄 수 없습니다. 오롯이 메시지를 소비하는 우리의 애플리케이션 코드로 막아내야 합니다. 다행히 몇 가지 효과적인 전략이 있습니다.
가장 확실하고 안정적인 방법은, 처리한 메시지의 이력을 별도의 DB 테이블에 기록하는 것입니다. "이 메시지는 내가 이미 처리했다"는 사실을 영구적으로 남기는 것이죠.
만약 중복이 주로 짧은 시간 내에 발생하고, DB에 의존하고 싶지 않다면 배치(Batch) 단위로 메시지를 처리하는 컨슈머의 특성을 활용할 수 있습니다.
카프카 컨슈머는 보통 메시지를 한 개씩이 아닌, 여러 개를 한 묶음(List)으로 받아옵니다. 이 묶음 안에서 중복된 event_id가 있는지 확인하고, 중복을 제거한 뒤 처리하는 방식입니다.
이 방법은 DB I/O 없이 메모리 내에서 간단하게 중복을 걸러낼 수 있어 매우 빠르고 가볍습니다. 하지만 이 방법에는 명확한 한계가 있습니다. 오직 '같은 배치' 안에서 발생한 중복만 제거할 수 있습니다.
만약 첫 번째 배치에서 메시지를 성공적으로 처리하고 커밋하기 직전에 장애가 나서, 다음 poll()로 받아온 두 번째 배치에 동일한 메시지가 또 포함되어 있다면 이 방법으로는 막을 수 없습니다.
결국 어떤 멱등성 처리 방식을 선택할지는 "데이터 정합성을 어느 수준까지 보장해야 하는가?" 라는 비즈니스 요구사항에 따라 달라집니다. 약간의 부정합을 감수하고 성능과 단순함을 택할 것인지, 아니면 복잡도를 감수하고 완벽한 정합성을 추구할 것인지에 대한 현명한 트레이드오프가 필요합니다.
Kafka는 단순한 메시지 브로커가 아니라, 시스템을 설계하는 방식을 바꾸어 주는 도구였습니다. 느슨한 결합, 비동기 처리, 최종적 일관성이라는 키워드를 실제로 적용해 본 경험을 통해, 왜 많은 기업들이 Kafka를 데이터 파이프라인의 중심으로 삼는지 조금은 이해할 수 있었습니다.