메시징 — 직접 부르지 말고, 메시지로
주문 하나가 끝나면 그 뒤로 결제·배송·알림·정산이 줄줄이 따라온다. 이걸 주문 메서드 안에서 하나하나 직접 호출하면, 주문이 이 모든 걸 알아야 하고 그중 하나가 느려지면 주문 전체가 느려진다. 메시징은 발상을 바꾼다 — "나는 '주문됐다'는 사건만 알릴 테니, 듣고 싶은 쪽이 알아서 처리해라". 이렇게 하면 결합이 끊기고 비동기가 된다. 앞 편 끝에서 "외부 호출은 트랜잭션 밖으로, 커밋 이후로"라던 그 이야기의 본격적인 전개다. 한 프로세스 안의 이벤트부터 Kafka·RabbitMQ까지, 왜 빠른지와 어떻게 안 잃는지를 내부까지 따라가 보자.
왜 메시지인가
위쪽 직접 호출은 주문이 결제·배송·알림을 전부 알아야 하고, 그중 하나가 느리면 주문도 함께 느려진다. 아래쪽 이벤트 발행은 주문이 듣는 쪽이 누군지도 모른 채 "주문됨"만 알린다. 그 결과 결합이 분리되고(주문은 소비자를 모른다), 비동기가 되며(주문은 기다리지 않는다), 버퍼링이 생긴다(소비자가 느려도 메시지가 쌓여 기다린다). 이 세 가지가 메시징의 핵심 이득이다.
한 프로세스 안이라면 — Spring 이벤트
결합 분리가 한 애플리케이션 안에서만 필요하다면, 굳이 Kafka까지 갈 것 없이 Spring 이벤트로 충분하다.
publisher.publishEvent(new OrderPlaced(orderId)); // 발행
@EventListener
void onOrderPlaced(OrderPlaced e) { ... } // 구독
그런데 06편에서 봤듯, 그냥 @EventListener는 같은 트랜잭션 안에서 동기로 실행되어 리스너가 실패하면 주 트랜잭션까지 롤백된다. 그래서 "주문이 확정된(커밋된) 뒤에만 알림을 보낸다" 같은 흐름에는 **@TransactionalEventListener(phase = AFTER_COMMIT)**를 써서 커밋 이후에만 돌게 한다.
여러 서비스·대용량으로 — Kafka
서비스가 여럿이고 대용량으로 가면 Kafka다. Kafka의 토픽은 흔히 생각하는 큐가 아니라 로그라는 점이 출발점이다 — 메시지가 소비돼도 사라지지 않고 보존 기간 동안 남아, 나중에 *다시 처리(replay)*할 수 있다.
토픽은 여러 파티션으로 나뉘고, 각 파티션은 순서가 보장되는 append-only 로그(오프셋 0,1,2…)다. 메시지에 키를 주면 같은 키는 같은 파티션으로 가니(해시), 그 키의 메시지 순서가 보존된다 — 같은 사용자의 이벤트는 순서대로다. 컨슈머 그룹 안에서는 한 파티션을 한 컨슈머만 읽고, 진행 위치는 오프셋으로 추적하는데 이 오프셋은 __consumer_offsets라는 내부 토픽에 저장돼 컨슈머가 죽었다 살아나도 이어서 읽는다.
왜 디스크에 쓰는데도 빠른가
Kafka가 디스크 기반인데도 초당 수백만 메시지를 처리하는 비결은 OS와 디스크의 특성을 정면으로 이용하는 데 있다. 첫째, 로그 끝에만 덧붙이는(append-only) 순차 쓰기라 디스크 헤드가 탐색(seek)할 일이 없다 — 순차 I/O는 랜덤 I/O보다 처리량이 몇 자릿수 높다. 둘째, 쓴 데이터를 OS 페이지 캐시에 두어, 소비자가 따라잡고 있으면 디스크를 아예 안 읽고 메모리에서 내보낸다. 셋째, 그 내보내기를 **zero-copy(sendfile 시스템 콜)**로 한다 — 데이터를 페이지 캐시에서 곧장 네트워크 카드 버퍼로 보내, 애플리케이션(유저 공간)을 거치는 복사를 통째로 건너뛴다. (출처: Confluent — Kafka Design.) 참고로 SSL을 켜면 브로커가 암복호화를 해야 해서 이 zero-copy 이점이 사라진다 — 성능에 민감하면 알아 둘 함정이다.
어떻게 안 잃는가 — 복제와 acks
데이터를 잃지 않으려면 파티션을 여러 브로커에 복제한다. 리더를 따라잡은 복제본들의 집합이 **ISR(In-Sync Replicas)**인데, 프로듀서의 acks 설정이 언제 "썼다"고 인정할지를 정한다 — acks=0(인정 안 기다림, 유실 위험), acks=1(리더만 쓰면 인정), acks=all(ISR 전부가 받아야 인정, 가장 안전). 진짜 내구성을 원하면 **acks=all + min.insync.replicas=2**를 묶어, 최소 2개 복제본이 받기 전엔 쓰기를 거절하게 한다. (출처: Kafka acks & min.insync.replicas.)
클러스터는 누가 관리하나 — ZooKeeper에서 KRaft로
브로커가 여럿이면 누군가는 어느 브로커가 어느 파티션의 리더인지, 토픽 설정이 무엇인지를 알고 있어야 한다. 이 메타데이터 관리를 Kafka는 14년 동안 ZooKeeper라는 별도 시스템에 맡겼다. 그래서 Kafka를 운영한다는 건 사실상 두 개의 분산 시스템을 운영하는 일이었다.
KRaft(Kafka Raft, KIP-500)가 그 일을 Kafka 안으로 가져왔다. 핵심은 메타데이터도 결국 로그로 관리하면 된다는 발상이다 — Kafka가 제일 잘하는 게 로그니까.
몇 대의 컨트롤러가 쿼럼(과반 합의 집단)을 이뤄, 메타데이터 변경을 __cluster_metadata라는 내부 토픽에 이벤트로 append한다. 브로커는 그 로그를 컨슈머처럼 당겨 읽어 자기 메모리에 반영한다. 로그가 무한정 커지지 않도록 주기적으로 스냅샷을 남긴다.
이 구조가 주는 실질적 차이는 장애 복구 속도다. ZooKeeper 시절엔 컨트롤러가 죽으면 새 컨트롤러가 ZooKeeper에서 전체 상태를 다시 읽어 들여야 했고, 파티션이 많을수록 그 시간이 길어졌다. KRaft에서는 팔로워 컨트롤러가 이미 같은 로그를 따라오며 메모리에 상태를 갖고 있어서 거의 즉시 이어받는다. 그래서 파티션 수 한계도 크게 늘었다.
버전 주의 — Kafka 4.0은 ZooKeeper를 아예 지웠다. 2025년 3월에 나온 Kafka 4.0은 KRaft 전용이고 ZooKeeper 모드가 제거됐다. 중요한 건 ZooKeeper 모드에서 4.0으로 바로 못 올라간다는 점이다 — 3.x(권장 3.9)에서 먼저 KRaft로 전환한 뒤 4.0으로 올려야 한다. 자바 요구 버전도 갈렸다: 클라이언트·Streams는 Java 11, 브로커·Connect·도구는 Java 17 이상이다. (출처: Apache Kafka 4.0 릴리스 공지 · Confluent — KRaft.)
처리량을 늘리는 핵심 — 파티션이 병렬성의 상한
그래서 Kafka에서 처리량을 어떻게 늘리느냐의 답이 여기서 나온다.
한 파티션을 그룹 내 한 컨슈머만 읽으니, 그룹의 최대 병렬성은 곧 파티션 수다. 컨슈머를 파티션보다 많이 띄워도 남는 컨슈머는 그냥 논다. 따라서 병렬성을 키우려면 컨슈머가 아니라 파티션을 늘려야 한다(컨슈머도 함께). Spring Kafka에서는 @KafkaListener(concurrency = N)이 N개의 컨슈머 스레드를 띄우고, 인스턴스를 늘려도 같은 그룹이면 파티션을 나눠 갖는다. 이때 컨슈머가 들고 나면 파티션이 *재분배(리밸런싱)*되는데, 그룹 코디네이터가 이를 조율한다. 예전 eager 방식은 리밸런스 동안 모두 멈췄지만(stop-the-world), Kafka 2.4+의 cooperative 방식은 옮겨지는 파티션만 멈춰 나머지는 계속 처리한다 — 그래도 잦은 join/leave는 비용이다. (출처: Confluent — Rebalancing.)
그리고 Kafka 4.0에서 **차세대 리밸런스 프로토콜(KIP-848)이 정식(GA)**이 됐다. 바뀐 핵심은 계산 주체다 — 예전에는 그룹 멤버 중 하나(리더 컨슈머)가 배정을 계산하고 나머지가 그 결과를 받는 구조라, 멤버가 하나 들고 날 때마다 그룹 전체가 동기화 지점을 거쳐야 했다. 새 프로토콜은 브로커 쪽 그룹 코디네이터가 배정을 계산해 점진적으로 내려보내서, 멤버 변경이 나머지 컨슈머의 소비를 멈추지 않는다. 컨슈머가 많은 대규모 그룹일수록 차이가 크다. 쓰려면 컨슈머에 group.protocol=consumer를 명시해 참여시킨다.
여기서 순서와 병렬성의 절충이 드러난다. 순서는 파티션 안에서만 보장되니, 키로 파티셔닝하면 "같은 키는 순서 보존 + 다른 키는 병렬"을 동시에 얻는다 — 주문 id를 키로 주면 한 주문의 이벤트는 순서대로면서 서로 다른 주문은 병렬로 흐른다. 키 없이 전체 순서를 지키려면 파티션을 하나만 둬야 해 병렬성을 포기한다. 그렇다고 무작정 늘릴 수도 없는 게, 파티션이 많아질수록 파일 핸들·메모리·리밸런스 시간·종단 지연이 늘고 한 번 늘린 파티션은 줄이기 어렵기(키→파티션 매핑이 바뀐다) 때문이다.
한 번은 보장하되 중복은 각오 — at-least-once
Kafka의 기본 전달 보장은 at-least-once(적어도 한 번)다. 컨슈머가 메시지를 처리하고 오프셋을 커밋하기 직전에 죽으면, 재시작했을 때 그 메시지를 다시 처리해 중복이 생긴다. 그래서 실무의 답은 컨슈머를 멱등(idempotent)하게 만드는 것이다 — 메시지 고유 id로 중복을 검사하거나, UPSERT하거나, 조건부로만 갱신한다. Kafka는 *정확히 한 번(exactly-once)*도 제공하는데, idempotent 프로듀서(시퀀스 번호로 재전송 중복 제거)와 트랜잭션(transactional.id로 여러 파티션에 원자적 쓰기)을 쓴다. 다만 비용과 복잡성이 커서, 대개는 멱등 컨슈머 + at-least-once가 더 단순하고 견고한 정답이다. (출처: Strimzi — Kafka transactions.)
특히 "DB 갱신 + 이벤트 발행"을 원자적으로 하는 Outbox 패턴(이벤트를 비즈니스 데이터와 같은 트랜잭션으로 Outbox 테이블에 기록 → Debezium 같은 CDC가 그 테이블 변경을 읽어 발행)도 — 발행 단계가 at-least-once라 중복이 생길 수 있다. 그래서 Outbox + 멱등 컨슈머가 분산 정합의 "골드 스탠더드"로 불린다(MSA 편에서 다시 만난다). (출처: Outbox + Idempotent Consumer.)
메시지는 언제까지 남나 — 리텐션과 로그 컴팩션
"Kafka는 로그라서 다시 읽을 수 있다"는 말에는 언제까지라는 조건이 빠져 있다. 그 조건을 정하는 게 **cleanup.policy**다.
delete(기본) — 오래된 세그먼트를 통째로 버린다. Kafka는 로그를 세그먼트 파일 단위로 나눠 쓰는데, 그 세그먼트가 retention.ms(시간)나 retention.bytes(크기)를 넘기면 파일째 지운다. 기본은 7일이다. 재처리가 가능한 기간이 곧 이 값이라, "3개월 전 데이터를 다시 흘려 달라"는 요구는 리텐션이 7일이면 애초에 불가능하다.
compact — 지우는 기준이 나이가 아니라 키다. 같은 키의 가장 최신 값만 남기고 과거 값을 정리한다.
컴팩션 전: (u1,"서울") (u2,"부산") (u1,"대전") (u3,"광주") (u1,"제주")
↓ 같은 키의 옛 값 제거 (오프셋 번호는 그대로 유지)
컴팩션 후: (u2,"부산") (u3,"광주") (u1,"제주")
그래서 컴팩션 토픽은 "이벤트 기록"이 아니라 "현재 상태 스냅샷" 이 된다. 처음부터 다 읽으면 모든 키의 최신 상태가 복원되므로, 캐시 워밍·설정 배포·CDC 스냅샷에 쓴다. 앞서 본 __consumer_offsets가 바로 컴팩션 토픽이다 — 필요한 건 "이 그룹의 현재 오프셋"이지 그 이력이 아니니까.
두 가지 전제가 붙는다. 키가 없는 메시지는 컴팩션할 수 없고(무엇의 최신인지 알 수 없으니), 삭제를 표현하려면 툼스톤(tombstone) — 키는 있고 값이 null 인 메시지 — 를 보내야 한다. 툼스톤 자체도 컨슈머들이 볼 시간을 준 뒤 delete.retention.ms가 지나면 정리된다.
cleanup.policy=compact,delete처럼 둘을 함께 걸면 먼저 컴팩션하고 그다음 리텐션으로 오래된 것을 버린다. (출처: Confluent — Log Compaction.)
잘 돌고 있는지 보는 법 — 컨슈머 랙
운영에서 가장 먼저 보는 지표는 처리량도 지연도 아니고 **컨슈머 랙(consumer lag)**이다. 정의는 뺄셈 하나다.
랙 = LEO(Log End Offset, 파티션에 쌓인 마지막 오프셋)
− 그룹이 커밋한 오프셋
예: LEO 10,000 / 커밋 9,500 → 랙 500 (읽어야 할 게 500건 남았다)
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--describe --group order-consumer
# TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER-ID
# orders 0 9500 10000 500 consumer-1-...
랙이 의미하는 것은 "느리다"가 아니라 **"생산 속도를 소비가 못 따라간다"**는 관계다. 그래서 진단이 갈린다 — 랙이 특정 파티션에만 쏠리면 키 분포가 치우친 것이고(핫 파티션), 모든 파티션에서 고르게 늘면 컨슈머가 부족하거나 처리 로직이 느린 것이며, 리밸런스 직후 잠깐 튀는 건 정상이다.
마지막 항목 때문에 순간값으로 알림을 걸면 오탐이 쏟아진다. 배포·재시작 때마다 랙이 튀었다가 따라잡히기 때문이다. 그래서 LinkedIn의 Burrow 같은 도구는 임계값 대신 시간에 따른 추세로 판단한다 — 랙이 줄고 있으면 정상, 계속 늘고 있으면 경보다.
스프링에서는 — 발행·소비·실패 처리
// 발행 — 키를 주면 같은 키가 같은 파티션으로 간다(순서 보존)
@Service
class OrderEventPublisher {
private final KafkaTemplate<String, OrderPlaced> template;
void publish(OrderPlaced event) {
template.send("orders", event.orderId(), event); // (토픽, 키, 값)
}
}
// 소비 — concurrency는 이 인스턴스가 띄울 컨슈머 스레드 수(파티션 수를 넘기면 남는 건 논다)
@KafkaListener(topics = "orders", groupId = "order-consumer", concurrency = "3")
void onOrderPlaced(OrderPlaced event) {
orderService.handle(event); // 여기서 예외가 나면 아래 에러 핸들러로
}
실패 처리가 핵심이다. 그냥 두면 같은 메시지에서 무한히 재시도하며 파티션 전체가 막힌다(poison message). 그래서 몇 번 재시도하고, 그래도 안 되면 별도 토픽으로 치운다.
@Bean
DefaultErrorHandler errorHandler(KafkaTemplate<String, Object> template) {
// 실패한 레코드를 "<원본토픽>.DLT" 로 보낸다
var recoverer = new DeadLetterPublishingRecoverer(template);
// 1초 간격으로 3회 재시도 후 DLT로
return new DefaultErrorHandler(recoverer, new FixedBackOff(1000L, 3L));
}
DLT(Dead Letter Topic)로 간 메시지는 @DltHandler 메서드로 받아 로깅·알림·수동 재처리에 쓴다. 여기서 자주 놓치는 게 하나 있다 — 역직렬화 실패는 재시도해도 절대 성공하지 않는다. 메시지 본문이 애초에 깨진 것이니 재시도는 시간 낭비이고, 이런 예외는 재시도 없이 바로 DLT로 보내도록 분류해야 한다. (출처: Spring for Apache Kafka — Handling Exceptions.)
다른 모델 — RabbitMQ와 AMQP
RabbitMQ는 Kafka와 모델 자체가 다르다. Kafka에서 프로듀서는 *토픽과 파티션*을 직접 겨냥하지만, AMQP에서 발행자는 큐를 모른다 — 익스체인지(exchange) 에 보낼 뿐이고, 어느 큐로 갈지는 바인딩(binding) 규칙이 정한다.
익스체인지 종류가 라우팅 방식을 결정한다.
| 종류 | 라우팅 방식 |
|---|---|
| direct | 라우팅 키가 바인딩 키와 정확히 일치할 때만 |
| fanout | 라우팅 키를 무시하고 바인딩된 모든 큐에 복사 (브로드캐스트) |
| topic | 점(.)으로 나뉜 패턴 매칭 — *는 단어 하나, #는 0개 이상의 단어 |
| headers | 라우팅 키 대신 헤더 속성으로 매칭 (`x-match=any |
이 간접 계층이 RabbitMQ의 강점이다 — 발행자 코드를 안 고치고 바인딩만 추가해 새 구독자를 붙일 수 있다.
소비 쪽의 두 축은 ack와 prefetch다. 소비자가 basic.ack를 보내야 브로커가 메시지를 지우고, 확인 없이 죽으면 다른 소비자에게 재전달된다(basic.nack으로 명시적 거부·재큐도 가능). prefetch는 확인 안 된 메시지를 소비자당 몇 개까지 미리 밀어 줄지의 상한이다 — 이 값이 너무 크면 빠른 소비자가 메시지를 잔뜩 쥔 채 느리게 처리해 부하 분산이 무너지고, 너무 작으면 왕복 대기로 처리량이 준다. DLX(Dead Letter Exchange) 는 거부되거나 TTL이 지난 메시지를 자동으로 다른 익스체인지로 보내 주는 장치로, Kafka의 DLT와 같은 역할이다.
버전 주의 — RabbitMQ 4.0에서 classic mirrored queue가 제거됐다. 고가용성 큐는 이제 쿼럼 큐(quorum queue) 가 유일한 선택지다. 쿼럼 큐는 Raft 합의 알고리즘으로 복제해 데이터 안전성과 처리량이 모두 낫고(같은 조건에서 미러링 큐의 약 3배 처리량), 항상 durable이다. 대신 exclusive 큐나 global QoS를 지원하지 않는다. 실무에 바로 와닿는 기능은
delivery-limit(기본 20) — 배달 횟수를 헤더로 세다가 한계를 넘기면 버리거나 DLX로 보내, 무한 재큐 루프를 구조적으로 막는다. (출처: RabbitMQ — Quorum Queues · AMQP 0-9-1 Concepts.)
Kafka냐 RabbitMQ냐
마지막으로 둘 중 무엇을 고를지다. 차이의 뿌리는 위에서 본 모델에 있다 — Kafka는 로그라 소비해도 메시지가 남아 재처리와 다중 소비자에 강하고, RabbitMQ는 큐라 소비하면 사라지며 유연한 라우팅과 작업 분배에 강하다. 다르게 말하면 Kafka는 덤(dumb) 브로커 + 스마트 컨슈머(브로커는 로그만 쌓고 진행 위치는 컨슈머가 관리)이고, RabbitMQ는 스마트 브로커 + 덤 컨슈머(브로커가 라우팅·전달 상태·재전송을 다 챙긴다)다. 그래서 대용량 이벤트 스트림(사용자 활동·거래 로그)은 Kafka로, 내부 작업 분배(이메일 발송·이미지 변환 같은 백그라운드 작업)는 RabbitMQ로 — 둘을 함께 쓰기도 한다. 경계를 한 줄로 줄이면 — 재처리·다중 소비자가 필요하면 Kafka, 유연한 라우팅·작업 큐가 필요하면 RabbitMQ.
정리하면, 메시징은 직접 호출을 *"사건만 알린다"*로 바꿔 결합을 끊고 비동기·버퍼링을 얻는다. 한 프로세스 안이면 Spring 이벤트로(커밋 후 처리는 AFTER_COMMIT), 대용량 분산이면 Kafka로 — Kafka는 순차 디스크·페이지 캐시·zero-copy로 빠르고, 복제 ISR + acks=all로 안 잃으며, 파티션 수만큼 병렬(키로 순서 보존)이고, 오프셋으로 재개한다. 클러스터 메타데이터는 이제 ZooKeeper가 아니라 KRaft가 관리하고(4.0부터는 그것만 가능), 리밸런스는 KIP-848로 브로커가 계산해 점진적으로 내려보낸다. 메시지의 수명은 cleanup.policy 가 정해 — delete면 기간만큼 보관해 재처리 창이 되고, compact면 키별 최신 값만 남아 상태 스냅샷이 된다. 기본 전달 보장이 at-least-once라 멱등 컨슈머가 필수이며(Outbox와 짝지으면 골드 스탠더드), 실패 메시지는 재시도 후 DLT로 치워 파티션이 막히지 않게 한다. 운영 지표의 1순위는 컨슈머 랙(LEO − 커밋 오프셋)이고 순간값이 아니라 추세로 본다. 라우팅·작업 큐 성격이면 RabbitMQ가 맞는데, 그쪽은 발행자가 큐가 아니라 익스체인지에 보내고 바인딩이 경로를 정하며, HA 큐는 4.0부터 쿼럼 큐뿐이다.