자바 아키텍처·Spring 학습 노트 목차

메시징 — 직접 부르지 말고, 메시지로

주문 하나가 끝나면 그 뒤로 결제·배송·알림·정산이 줄줄이 따라온다. 이걸 주문 메서드 안에서 하나하나 직접 호출하면, 주문이 이 모든 걸 알아야 하고 그중 하나가 느려지면 주문 전체가 느려진다. 메시징은 발상을 바꾼다 — "나는 '주문됐다'는 사건만 알릴 테니, 듣고 싶은 쪽이 알아서 처리해라". 이렇게 하면 결합이 끊기고 비동기가 된다. 앞 편 끝에서 "외부 호출은 트랜잭션 밖으로, 커밋 이후로"라던 그 이야기의 본격적인 전개다. 한 프로세스 안의 이벤트부터 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부터 쿼럼 큐뿐이다.

테스트 — 슬라이스·MockMvc·Testcontainers캐시·Redis — 어사이드·쓰기 전략