왜 메시징 브로커 기초 Kafka 핵심 전달 보장 데이터 일관성 스프링에서 📝 문제풀이
◀ 심화 CH 09 📋 목차 심화 CH 11 ▶
📨 심화 CHAPTER 10 · 5년차 트랙

서비스끼리 직접 부르지 않고, 메시지로 대화하게 하려면?

모놀리식을 넘어 여러 서비스가 협업하기 시작하면, "누가 누구를 언제 호출하는가"가 시스템의 병목이자 장애 전파 경로가 돼요. 이번 장은 비동기 메시징으로 결합을 끊고, 이벤트 드리븐으로 확장하는 법 — Kafka의 파티션·컨슈머 그룹, 전달 보장과 멱등성, 아웃박스·사가까지 파고듭니다.

🎯 이 장을 끝내면
🔀
왜 메시징
동기 호출은 왜 위험해지는가
결합도·장애 전파·트래픽 스파이크 — 세 가지 통증.

주문 서비스가 결제·재고·알림 서비스를 동기 REST로 직접 부른다고 해봐요. 겉보기엔 단순하지만, 세 가지가 발목을 잡습니다.

⚠️ 동기 호출의 세 가지 통증 ① 강한 결합(coupling) — 주문 서비스는 호출 대상의 주소·응답 스펙·가용성을 모두 알아야 해요. 새 구독자(예: 정산 서비스)를 추가하려면 주문 코드를 고쳐야 합니다.
② 장애 전파(cascading failure) — 알림 서비스가 느려지면 그 응답을 기다리는 주문 API도 같이 느려지고, 스레드 풀이 마르면 전체가 멈춰요.
③ 트래픽 스파이크 — 순간 요청이 몰리면 하위 서비스가 그 부하를 그대로 받아 터집니다. 공통 원인은 "호출자와 피호출자가 같은 시간에, 같은 속도로 살아있어야 한다"는 시간적 결합(temporal coupling)이에요.

비동기 메시징은 이 사이에 브로커(broker)를 끼워 넣어요. 생산자는 브로커에 메시지를 던지고 바로 리턴하고, 소비자는 자기 속도로 꺼내 처리합니다.

📤
Producer
메시지 발행 후 즉시 리턴
🗄️
Broker
저장·버퍼링·라우팅
📥
Consumer
자기 속도로 소비·재처리
💡
메시징이 사주는 것. 결합도↓(생산자는 누가 소비하는지 몰라도 됨) · 버퍼링(스파이크를 큐가 흡수해 하위 서비스를 보호) · 탄력적 확장(소비자를 늘려 처리량 증가) · 장애 격리(소비자가 죽어도 메시지는 브로커에 남아 복구 후 재처리). 대신 최종 일관성(eventual consistency)순서·중복이라는 새 숙제가 생겨요 — 이 장의 나머지가 그 숙제입니다.
🗄️
브로커 기초
큐(점대점) vs 발행/구독(pub-sub)
"한 명만 받나, 모두가 받나"가 핵심 갈림길이에요.
모델동작대표 예
큐(Queue)
점대점(P2P)
한 메시지를 여러 소비자 중 하나만 받아 처리. 작업 분배(work queue)에 적합작업 분산, RabbitMQ 큐
발행/구독
(Pub-Sub)
한 메시지를 구독한 모두가 각자 받아 처리. 이벤트 브로드캐스트에 적합이벤트 알림, Kafka 컨슈머 그룹, RabbitMQ 팬아웃
🖼️ 그림으로 보기 — 큐(점대점) vs 발행/구독(pub-sub)
큐 (점대점) — 한 명만 받음 발행/구독 — 모두가 받음 📤 Producer 🗄️ Queue C1 C2 ✓ C3 여러 소비자 중 하나만 처리 (작업 분배) 📤 Producer 📣 Topic C1 ✓ C2 ✓ C3 ✓ 구독한 모두가 각자 사본을 받음 (브로드캐스트)
🧭
Kafka는 이 둘을 컨슈머 그룹으로 통합해요. 같은 컨슈머 그룹 안에서는 파티션을 나눠 가지니 큐(작업 분배)처럼 동작하고, 서로 다른 그룹은 같은 메시지를 각자 전부 받으니 pub-sub처럼 동작합니다. "그룹 하나 = 논리적 소비자 하나"로 생각하면 깔끔해요.
🔧 대표 브로커의 성격 차이 (개념만) RabbitMQ / ActiveMQ스마트 브로커. 라우팅·필터링·우선순위·즉시 삭제형 큐에 강해요. 전통적 메시지 큐 감성.
Kafka분산 커밋 로그(append-only log). 메시지를 지우지 않고 로그로 쌓아 두고, 소비자는 오프셋으로 어디까지 읽었는지만 관리해요. 고처리량·재처리·스트리밍에 강합니다. 그래서 Kafka에서는 "소비 = 삭제"가 아니에요. 이 로그 기반 모델이 뒤에 나올 재처리·리텐션의 핵심입니다.
📊
Kafka 핵심
토픽 · 파티션 · 오프셋 · 컨슈머 그룹
이 네 단어로 Kafka의 90%가 설명돼요.
개념정체왜 중요한가
토픽(Topic)메시지의 논리적 분류 이름(예: orders)생산·구독의 단위
파티션(Partition)토픽을 나눈 물리적 로그 조각. 각각 append-only병렬성순서 보장의 단위
오프셋(Offset)파티션 내 메시지의 순번(위치)"어디까지 읽었나"를 기록·커밋
컨슈머 그룹같은 토픽을 함께 소비하는 소비자 묶음파티션을 멤버에게 분배
리텐션(Retention)메시지를 로그에 남겨두는 기간/크기소비해도 즉시 안 지움 → 재처리 가능
🖼️ 그림으로 보기 — 토픽 · 파티션 · 오프셋 · 컨슈머 그룹
Topic: orders P0 0 1 2 3 4 P1 0 1 2 P2 0 1 2 3 ← append-only 로그 · 숫자 = 오프셋(위치) Consumer Group: order-svc 📥 Consumer 1 P0 · P1 담당 📥 Consumer 2 P2 담당 한 파티션 → 그룹 내 정확히 한 컨슈머
🧩 파티션 = 순서 보장 + 병렬성의 단위 Kafka는 파티션 내부에서만 메시지 순서를 보장해요. 토픽 전체 순서는 보장하지 않습니다. 그래서 "같은 주문 ID의 이벤트는 순서대로 처리하고 싶다"면 주문 ID를 메시지 키로 줘서 항상 같은 파티션으로 보내야 해요(키 해시 → 파티션 지정). 병렬성도 파티션 수가 상한이에요. 파티션이 4개면 한 컨슈머 그룹에서 최대 4개 컨슈머만 동시에 일할 수 있어요(5번째는 놀게 됨). 순서(파티션 적게)와 병렬성(파티션 많게)은 트레이드오프입니다.
💡
컨슈머 그룹의 파티션 분배. 한 파티션은 그룹 내 정확히 한 컨슈머에게만 배정돼요(1:N 아님). 컨슈머가 늘거나 줄면 리밸런싱(rebalancing)이 일어나 파티션이 재배정됩니다. 리밸런싱 동안은 잠깐 소비가 멈추므로, 잦은 리밸런싱(예: 처리 지연으로 인한 max.poll.interval.ms 초과 → 그룹 이탈)은 성능 문제의 단골 원인이에요.
Topic Partition Offset Consumer Group Retention Rebalancing
🧭
오프셋 커밋과 재처리. 소비자는 처리한 위치를 오프셋 커밋으로 기록해요. 다시 붙으면 마지막 커밋 다음부터 읽습니다. 리텐션 기간 안이라면 오프셋을 되감아(seek) 과거 이벤트를 재생(replay)할 수도 있어요 — 로그 기반 브로커라 가능한 강력한 기능입니다.
🎯
전달 보장
at-most / at-least / exactly-once
현실 대부분은 at-least-once + 멱등 소비자예요.
보장 수준의미대가
at-most-once
(최대 1회)
중복은 없지만 유실 가능. 처리 전에 오프셋을 먼저 커밋하는 식메시지 손실 감수(로그·메트릭 등)
at-least-once
(최소 1회) ⭐
유실은 없지만 중복 가능. 처리 후 커밋하다 재시도로 두 번 처리될 수 있음멱등 처리 필수
exactly-once
(정확히 1회)
유실·중복 모두 없음. Kafka는 트랜잭션·멱등 프로듀서로 지원제약·오버헤드 큼. 외부 시스템까지는 어려움
⚠️
"exactly-once"를 만능으로 믿지 마세요. Kafka의 EOS(exactly-once semantics)는 Kafka 내부(읽기→처리→쓰기가 모두 Kafka인 경우)에서 트랜잭션으로 성립해요. 하지만 소비자가 외부 DB에 쓰거나 이메일을 보내는 순간, 그 부작용까지 정확히 한 번을 보장하긴 어렵습니다. 그래서 현실의 정석은 at-least-once로 받고, 소비자를 멱등(idempotent)하게 만드는 거예요.
🔁 멱등성(idempotency)이란 같은 요청을 여러 번 처리해도 결과가 한 번 처리한 것과 같은 성질이에요. at-least-once에서는 중복이 정상이므로, 소비자가 중복을 견뎌야 시스템이 망가지지 않아요. 전형적 기법: 메시지에 고유 ID를 싣고, 처리 시 "이미 처리한 ID인가"를 DB 유니크 제약이나 processed_ids 테이블로 검사. 혹은 INSERT ... ON CONFLICT DO NOTHING, UPSERT처럼 연산 자체를 멱등하게 설계.
@KafkaListener(topics = "payments")
public void handle(PaymentEvent e) {
    // at-least-once: 같은 이벤트가 두 번 올 수 있다
    if (processedRepo.existsById(e.getEventId())) {
        return;                 // 이미 처리 → 조용히 무시 (멱등)
    }
    applyPayment(e);            // 실제 처리
    processedRepo.save(new Processed(e.getEventId()));
}
🧬
데이터 일관성
이중 쓰기 · 아웃박스 · 사가
"DB에도 쓰고 메시지도 보내기"가 왜 위험한가.

서비스가 자기 DB를 갱신하면서 메시지도 발행해야 하는 일은 매우 흔해요. 문제는 이 둘이 서로 다른 시스템이라 하나의 트랜잭션으로 묶기 어렵다는 겁니다.

⚠️
이중 쓰기 문제(dual write). "DB 커밋"과 "Kafka 발행"을 따로 하면, DB는 성공했는데 발행 직전 서버가 죽거나(→ 이벤트 유실), 발행은 됐는데 DB 커밋이 롤백되면(→ 유령 이벤트) 둘의 상태가 어긋나요. 순서를 바꿔도 근본 문제는 남습니다. 이건 "재시도"로 못 고치는 원자성(atomicity) 문제예요.
📦 트랜잭셔널 아웃박스(Transactional Outbox) 패턴 핵심 아이디어: 메시지를 브로커에 직접 보내지 말고, 같은 DB의 outbox 테이블에 비즈니스 데이터와 하나의 트랜잭션으로 함께 저장해요. 그러면 "DB 갱신"과 "이벤트 기록"이 원자적이 됩니다(둘 다 커밋되거나 둘 다 롤백). 그 뒤 별도 릴레이(메시지 릴레이 / CDC 예: Debezium)가 outbox를 읽어 Kafka로 발행해요. 발행 후 재시도로 중복은 날 수 있지만(at-least-once) 유실은 없습니다 — 그래서 소비자 멱등이 짝을 이뤄야 해요.
💾
① 한 트랜잭션
주문 저장 + outbox 저장
🚚
② 릴레이/CDC
outbox 읽어 브로커로 발행
📨
③ Kafka
소비자가 멱등 처리
🖼️ 그림으로 보기 — 트랜잭셔널 아웃박스 — 이중 쓰기 해결
비즈니스 데이터와 이벤트를 하나의 트랜잭션으로 함께 저장 ① 하나의 DB 트랜잭션 💾 비즈니스 데이터 저장 예: orders 테이블 📥 outbox 이벤트 저장 같은 트랜잭션 · 같은 커밋 둘 다 커밋 or 둘 다 롤백 (원자적) ② 릴레이 / CDC outbox 읽어 발행(Debezium) ③ 브로커 Kafka 토픽 ④ 소비자 멱등 처리 발행 재시도로 중복 가능(at-least-once) · 유실은 없음 → 소비자 멱등과 짝
🪢 사가(Saga) — 분산 트랜잭션 대안 여러 서비스에 걸친 업무는 2PC(2단계 커밋)가 확장성·가용성이 나빠 잘 안 써요. 대신 사가는 업무를 로컬 트랜잭션의 연쇄로 쪼개고, 각 단계가 이벤트로 다음을 트리거해요. 중간에 실패하면 앞 단계를 되돌리는 보상 트랜잭션(compensating transaction)을 실행합니다(예: 결제 실패 → 재고 예약 취소). 방식은 둘 — 코레오그래피(각 서비스가 이벤트로 알아서 반응, 중앙 조정자 없음)와 오케스트레이션(중앙 오케스트레이터가 단계를 지휘). 사가는 ACID가 아니라 최종 일관성을 받아들이는 패턴이에요.
🧭
이벤트 순서·중복은 여전히 소비자 몫. 아웃박스로 유실을 막고 사가로 흐름을 조율해도, 소비자는 중복(멱등)순서 뒤바뀜에 대비해야 해요. 순서가 중요하면 키로 같은 파티션에 몰고, 오래된 이벤트를 무시하려면 버전/타임스탬프를 실어 최신만 반영합니다.
🍃
스프링에서
@KafkaListener · 재시도 · DLQ
"실패한 메시지를 어디로 보낼까"가 운영의 관건.

Spring for Apache Kafka는 @KafkaListener로 소비자를 선언적으로 만들어요. 메서드가 정상 리턴하면 오프셋이 커밋되고, 예외가 나면 재처리 흐름을 탑니다.

@KafkaListener(topics = "orders", groupId = "order-svc")
public void onOrder(OrderEvent e) {
    orderService.process(e);   // 예외 던지면 에러 핸들러가 재시도/DLQ 결정
}
🔁 재시도와 DLQ(Dead Letter Queue) 재시도 — 일시적 오류(네트워크·순간 장애)는 몇 번 다시 시도하면 성공할 수 있어요. 스프링은 DefaultErrorHandler + BackOff재시도 횟수·간격(백오프)을 지정합니다.
DLQ(데드레터 큐) — 재시도를 다 써도 실패하는 독약 메시지(poison message)는 계속 붙들면 파티션 전체가 막혀요. 그래서 별도 토픽(예: orders.DLT)으로 치워두고 다음 메시지로 넘어갑니다. DLQ는 "버리는 곳"이 아니라 격리·분석·수동 재처리를 위한 곳이에요. 스프링은 DeadLetterPublishingRecoverer로 실패 메시지를 <topic>.DLT로 자동 전송해줍니다.
⚠️
블로킹 재시도는 파티션을 멈춰요. 같은 스레드에서 sleep하며 재시도하면 그 파티션의 뒤 메시지가 다 밀려요. 지연이 큰 재시도는 재시도 전용 토픽(non-blocking retry, @RetryableTopic)으로 빼서 원본 파티션 흐름을 막지 않게 하는 게 정석입니다.
💡
운영 체크리스트. ① 소비자는 멱등하게(중복 정상) ② 실패는 재시도 → DLQ로 격리 ③ DLQ에 알람을 걸어 쌓이면 감지 ④ 컨슈머 랙(lag) 모니터링으로 처리 지연 조기 발견 ⑤ 순서가 중요한 이벤트는 키 지정. 이 다섯이 이벤트 드리븐 운영의 기본기예요.
🧠 이 장 핵심 요약
📝
문제풀이 · 점검
시니어 관점으로 점검
면접·설계 리뷰에서 실제로 묻는 결의 문제예요. 틀려도 바로 해설이 나옵니다.
🧪
개념을 "안다"와 "설계에 쓸 수 있다"는 달라요. 네 유형(객관식 · O/X · 빈칸 · 코드결과)으로 점검해보세요.
심화 CH 11 🔐 보안 — 인증·인가, 암호화, 토큰, 흔한 취약점과 방어 (제작 예정)