자바 아키텍처·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.)

처리량을 늘리는 핵심 — 파티션이 병렬성의 상한

그래서 Kafka에서 처리량을 어떻게 늘리느냐의 답이 여기서 나온다.

다이어그램 로딩 중…

한 파티션을 그룹 내 한 컨슈머만 읽으니, 그룹의 최대 병렬성은 곧 파티션 수다. 컨슈머를 파티션보다 많이 띄워도 남는 컨슈머는 그냥 논다. 따라서 병렬성을 키우려면 컨슈머가 아니라 파티션을 늘려야 한다(컨슈머도 함께). Spring Kafka에서는 @KafkaListener(concurrency = N)이 N개의 컨슈머 스레드를 띄우고, 인스턴스를 늘려도 같은 그룹이면 파티션을 나눠 갖는다. 이때 컨슈머가 들고 나면 파티션이 *재분배(리밸런싱)*되는데, 그룹 코디네이터가 이를 조율한다. 예전 eager 방식은 리밸런스 동안 모두 멈췄지만(stop-the-world), Kafka 2.4+의 cooperative 방식은 옮겨지는 파티션만 멈춰 나머지는 계속 처리한다 — 그래도 잦은 join/leave는 비용이다. (출처: Confluent — Rebalancing.)

여기서 순서와 병렬성의 절충이 드러난다. 순서는 파티션 안에서만 보장되니, 키로 파티셔닝하면 "같은 키는 순서 보존 + 다른 키는 병렬"을 동시에 얻는다 — 주문 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냐 RabbitMQ냐

마지막으로 둘 중 무엇을 고를지다. 차이의 뿌리는 모델에 있다 — Kafka는 로그라 소비해도 메시지가 남아 재처리와 다중 소비자에 강하고, RabbitMQ는 라 소비하면 사라지며 유연한 라우팅(exchange: direct·topic·fanout)과 작업 분배에 강하다. 그래서 대용량 이벤트 스트림(사용자 활동·거래 로그)은 Kafka로, 내부 작업 분배(이메일 발송·이미지 변환 같은 백그라운드 작업)는 RabbitMQ로 — 둘을 함께 쓰기도 한다. 경계를 한 줄로 줄이면 — 재처리·다중 소비자가 필요하면 Kafka, 유연한 라우팅·작업 큐가 필요하면 RabbitMQ.

정리하면, 메시징은 직접 호출을 *"사건만 알린다"*로 바꿔 결합을 끊고 비동기·버퍼링을 얻는다. 한 프로세스 안이면 Spring 이벤트로(커밋 후 처리는 AFTER_COMMIT), 대용량 분산이면 Kafka로 — Kafka는 순차 디스크·페이지 캐시·zero-copy로 빠르고, 복제 ISR + acks=all로 안 잃으며, 파티션 수만큼 병렬(키로 순서 보존)이고, 오프셋으로 재개한다. 기본이 at-least-once멱등 컨슈머가 필수이며(Outbox와 짝지으면 골드 스탠더드), 라우팅·작업 큐 성격이면 RabbitMQ가 더 맞다.

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