메세지 브로커/Kafka

[Kafka] 핵심 원리 정리 — 처리량이 높은 이유, acks, 오토커밋 유실·중복, 리밸런싱

Joonfluence 2026. 7. 12.

1. 문서 제목

  • Kafka 핵심 원리 정리 — 처리량의 진짜 이유, acks, 오토커밋 유실·중복, 리밸런싱까지

2. 기술 개요 요약

Kafka를 학습하다 보면 "파티션으로 병렬 처리한다"는 표면 지식까지는 금방 도달하지만, 한 겹 아래의 질문들 — 브로커 한 대는 왜 빠른가, 프로듀서 단에서도 순서가 꼬일 수 있는가, 오토커밋은 유실을 내는가 중복을 내는가 — 에서 막히기 쉽다. 이 문서는 그 한 겹 아래에 해당하는 6개 질문을 정리한다. 관통하는 원칙은 하나다: Kafka 파티션의 존재 이유는 "병렬 처리"와 "순서 보장" 두 가지이고, 모든 설정(acks, max.in.flight, 오토커밋, 리밸런싱, 파티션 증설)은 이 두 축 + "유실 vs 중복" 트레이드오프 위에서 해석하면 답이 나온다.

다루는 질문:

# 질문 핵심 키워드
1 Kafka가 다른 MQ보다 처리량이 높은 이유는? ("RabbitMQ 여러 대면 똑같지 않나?") 순차 디스크 I/O, OS 페이지 캐시, 제로카피
2 acks 설정은 무엇을 결정하나? acks=0/1/all, min.insync.replicas
3 프로듀서 한 대가 병렬 전송하면 순서가 꼬일 수 있나? max.in.flight, 멱등 프로듀서
4 오토커밋을 쓰면 유실과 중복 중 무엇이 발생하나? 커밋 시점 vs 처리 시점의 분리
5 리밸런싱은 언제 일어나고 왜 문제인가? eager vs cooperative, static membership
6 파티션 증설은 왜 신중해야 하나? 키 라우팅 변경, 순서 붕괴, 컨슈머 상한

3. 핵심 개념 정리

3-1. Kafka 처리량이 높은 이유 — 파티션 "말고" 세 가지

파티션 병렬 처리는 절반의 답이다. "RabbitMQ를 여러 대 두면 똑같다"는 반문에 대한 답은 브로커 한 대의 단일 머신 성능 자체가 다른 이유에서 나온다.

원리 설명 실무 포인트
순차 디스크 I/O (append-only log) 파티션은 수정·삭제 없이 끝에만 붙이는(append-only) 로그 파일. 디스크는 랜덤 I/O에 약하지만 순차 쓰기는 매우 빠르며, HDD에서도 순차 접근은 랜덤 접근보다 수천 배 빠를 수 있다 B+Tree 기반 저장소(전통적 MQ의 메시지 상태 갱신)와 달리 쓰기 경로에 랜덤 I/O가 없음. "디스크에 쓰는데 왜 빠르냐"는 꼬리질문의 답
OS 페이지 캐시 Kafka는 자체 캐시를 거의 두지 않고 파일 쓰기를 OS 페이지 캐시에 맡긴다. 쓰기는 페이지 캐시에 반영된 뒤 OS가 배치로 flush하고, 최근 데이터를 읽는 컨슈머는 디스크가 아니라 메모리(페이지 캐시)에서 읽는다 JVM 힙에 캐시를 두지 않으므로 GC 부담이 없고, 프로세스를 재시작해도 캐시가 OS에 남아 있음. 컨슈머가 뒤처지지 않는 한 "디스크 기반인데 사실상 메모리 속도"
제로카피 (zero-copy) 컨슈머에게 데이터를 보낼 때 sendfile 시스템 콜(Java의 FileChannel.transferTo)로 페이지 캐시 → NIC 버퍼로 커널 안에서 직접 전달. 유저 공간으로의 복사와 컨텍스트 스위칭을 생략 일반 경로는 "디스크→커널버퍼→유저버퍼→소켓버퍼→NIC"로 복사 4회. 제로카피는 이를 크게 줄임. 단, TLS(SSL) 사용 시에는 암호화를 위해 유저 공간을 거쳐야 하므로 제로카피 이점이 사라진다는 것까지 알면 방어 완성
(보조) 배치 + 압축 프로듀서는 레코드를 batch로 모아 압축해서 전송하고, 브로커는 압축된 배치를 그대로 저장·전달 linger.ms, batch.size로 조절. 처리량 질문에 곁들이면 좋은 보너스 답변

면접 답변으로 압축하면: "파티션 병렬 처리에 더해, 브로커 한 대 자체가 빠릅니다. append-only 로그라 디스크 쓰기가 전부 순차 I/O이고, OS 페이지 캐시를 그대로 활용해 최근 데이터는 메모리에서 읽으며, 컨슈머 전송 시 sendfile 기반 제로카피로 유저 공간 복사를 생략하기 때문입니다."

3-2. acks — 프로듀서의 쓰기 내구성 설정 (TCP의 ACK와는 무관)

이름 때문에 TCP 핸드셰이크의 ACK를 떠올리기 쉽지만 전혀 다른 개념이다. acks는 프로듀서가 "이 전송을 성공으로 칠 것인가"를 판단하는 기준, 즉 브로커 복제 수준에 대한 확인 강도다.

의미 유실 가능성 용도
acks=0 응답을 기다리지 않음 브로커가 못 받아도 모름 → 유실 허용 메트릭, 로그 등 유실 허용 데이터
acks=1 리더 파티션에 쓰이면 성공 리더 장애 + 팔로워 미복제 시 유실 중간 수준
acks=all (-1) ISR(동기화된 레플리카)들이 복제를 마쳐야 성공 min.insync.replicas와 조합 시 유실 최소화 결제·주문 등 유실 불가 데이터
  • Kafka 3.0부터 프로듀서 기본값이 acks=all + enable.idempotence=true 다(KIP-679). "디폴트가 뭐냐"는 질문에는 "3.0 이후 all"이 답.
  • acks=allmin.insync.replicas(브로커/토픽 설정, 예: 2)와 함께 써야 의미가 있다. ISR이 1개로 쪼그라든 상태의 acks=all은 사실상 acks=1과 같아지기 때문.

3-3. 프로듀서 단에서 순서가 꼬이는 경우 — max.in.flight와 멱등 프로듀서

"순서 보장은 파티션 내에서만"이라는 명제조차 프로듀서가 그 파티션에 순서대로 적재해 준다는 전제 위에 있다. 컨슈머 쪽 순서만 신경 쓰다 보면 놓치기 쉬운 지점인데, 이 전제가 깨지는 시나리오가 있다.

  1. 프로듀서는 한 커넥션에서 여러 요청을 동시에 날릴 수 있다 — max.in.flight.requests.per.connection (기본 5).
  2. 배치 1 전송 실패 → 재시도(retries) 하는 사이에 배치 2가 먼저 성공하면, 같은 파티션 안에서도 2가 1보다 앞에 적재된다.
  3. 대응:
    • enable.idempotence=true (3.0+ 기본): 브로커가 프로듀서별 시퀀스 번호로 중복을 제거하고, max.in.flight가 5 이하면 재시도 상황에서도 순서를 보장한다.
    • 멱등성 없이 순서를 지키려면 max.in.flight=1로 줄여야 하는데, 이는 처리량을 희생한다.

면접 답변으로 압축하면: "프로듀서는 in-flight 요청을 여러 개 두고 병렬 전송하므로, 앞 배치의 재시도 중 뒤 배치가 먼저 성공하면 파티션 내에서도 순서가 꼬일 수 있습니다. 멱등 프로듀서를 켜면(3.0+ 기본) 시퀀스 번호 기반으로 max.in.flight 5 이하에서 순서가 보장됩니다."

3-4. 오토커밋의 유실과 중복 — 답은 "둘 다"

먼저 구조부터. 커밋 = 컨슈머가 "여기까지 읽었다"는 오프셋을 브로커에 기록하는 것이고, 이 오프셋은 브로커의 내부 토픽 __consumer_offsets에 저장된다(SSOT는 브로커. 0.9 이전에는 ZooKeeper였음).

오토커밋(enable.auto.commit=true, auto.commit.interval.ms 기본 5초)은 "처리 완료 여부"를 보지 않고, poll 호출 시점에 주기가 도래했으면 직전 poll까지 읽어온 오프셋을 커밋한다. 그래서:

시나리오 결과
읽어옴 → 커밋됨 → 처리 전/중에 컨슈머 사망 재시작 후 커밋된 오프셋 다음부터 읽음 → 처리 안 된 메시지를 건너뜀 → 유실
읽어옴 → 처리 완료 → 커밋 주기 전에 컨슈머 사망 재시작(또는 리밸런싱으로 다른 컨슈머가 인계) 후 같은 메시지를 다시 읽음 → 중복

"유실만 발생한다"고 생각하기 쉽지만 틀린 직관이다. 커밋 시점과 처리 시점이 분리되어 있으니, 커밋이 처리보다 빠르면 유실, 느리면 중복 — 이 한 문장으로 기억한다.

추가로 짚어야 할 두 가지:

  • 명시(수동) 커밋으로 바꿔도 중복은 남는다. 처리 완료 → 커밋 직전 사망 시나리오는 그대로이기 때문. 이것이 at-least-once이며, Kafka 컨슈머의 기본 설계 목표다.
  • 유실은 사후 감지가 사실상 불가능하다. 브로커는 파티션별 커밋 오프셋만 알지 "몇 번 메시지가 처리 안 됐는지"는 모른다. 그래서 설계 원칙은 "유실은 인프라 레벨에서 원천 차단(at-least-once 구성)하고, 대신 발생하는 중복은 컨슈머의 멱등 처리(멱등 키 + DB unique 제약 등)로 막는다". 오토커밋은 메트릭·로그처럼 유실이 허용되는 데이터에만 쓴다.
  • 커밋과 오토커밋 설정의 주체는 컨슈머이고, 동작은 컨슈머 그룹 단위로 이뤄진다.

3-5. 리밸런싱 — 파티션-컨슈머 재배분

정의: 컨슈머 그룹 내에서 "어떤 컨슈머가 어떤 파티션을 담당할지"의 배정을 다시 하는 과정. 페일오버 복구와 혼동하기 쉽지만, 본질은 소유권 재배분이다(장애는 트리거 중 하나일 뿐).

발생 조건:

트리거 예시
컨슈머 추가 랙이 쌓여서 컨슈머를 늘림(스케일 아웃)
컨슈머 이탈 배포·재시작, 크래시, session.timeout.ms(3.0+ 기본 45초) 내 하트비트 누락
처리 지연 max.poll.interval.ms(기본 5분) 안에 다음 poll을 못 부름 → 그룹에서 추방
파티션 수 변경 토픽 파티션 증설

왜 문제인가:

  • eager 방식(전통적 Range/RoundRobin/Sticky assignor)은 리밸런싱 시 그룹의 모든 컨슈머가 모든 파티션을 내려놓고(stop-the-world) 재배정받는다. 그동안 그룹 전체 소비가 멈춘다.
  • 이때 "처리했지만 커밋 못 한" 메시지가 다른 컨슈머로 넘어가며 중복 소비가 발생할 수 있다. 평상시에는 파티션당 컨슈머가 1:1이라 컨슈머 간 중복이 없지만, 리밸런싱 순간만은 예외다.

대응:

  • Cooperative(incremental) 리밸런싱CooperativeStickyAssignor: 전체를 내려놓지 않고 이동이 필요한 파티션만 점진적으로 넘긴다. "새 컨슈머가 들어오면 각자 전부 반납 후 재배정"이 아니라 "각자 1개씩 떼어주는" 동작. Kafka 3.0+에서 기본 전략 목록은 [RangeAssignor, CooperativeStickyAssignor]이지만, 이 기본값에서 실제 사용되는 프로토콜은 여전히 eager(Range) 다 — 목록의 CooperativeSticky는 무중단 전환용이며, cooperative를 실제로 쓰려면 명시 지정이 필요하다.
  • Static membershipgroup.instance.id 지정: session.timeout.ms 이내에 같은 ID로 재조인하는 한, 재시작·배포 정도로는 리밸런싱을 유발하지 않게 함.
  • 참고: Kafka 4.0부터는 브로커 주도의 새 컨슈머 그룹 프로토콜(KIP-848)이 도입되어 리밸런싱이 점진적·서버 주도 방식으로 재설계됐다.

3-6. 파티션 수 상한과 증설의 리스크

  • 컨슈머 수의 상한 = 파티션 수. 한 파티션에 (같은 그룹의) 컨슈머 두 개가 붙으면 순서 보장이 깨지므로, 파티션당 컨슈머는 최대 1개. 파티션 5개면 컨슈머는 5대까지만 의미가 있고, 초과분은 유휴 상태가 된다.
  • 랙이 쌓여 컨슈머를 더 못 늘리면 파티션 증설이 떠오르지만, 증설은 순서 보장을 깨뜨린다:
    • 키 기반 라우팅은 hash(key) % 파티션수인데 파티션 수가 바뀌면 같은 키의 새 메시지가 다른 파티션으로 가기 시작한다. 기존 메시지는 원래 파티션에 남아 있으므로(재배치되지 않음), 같은 키(예: 같은 주문 ID)의 신·구 메시지 간 순서가 깨진다.
    • 파티션 축소는 아예 지원되지 않는다.
  • 실무 원칙:
    1. 처음부터 파티션을 넉넉하게 만든다 — "부족하면 늘리자"가 아니라 "일단 여유 있게"가 일반 원칙. 증설은 순서가 무의미한 토픽이거나 불가피할 때만. (단, 파티션도 공짜는 아니다 — 파티션당 파일 핸들·메모리 오버헤드와 장애 시 리더 재선출 작업량이 함께 늘어나므로 "무조건 많이"가 아니라 "성장분을 감안해 여유 있게".)
    2. 파티션을 못 건드리면 컨슈머 내부 병렬 처리(워커 스레드 풀 등)로 소비량을 늘린다. 단, 이 경우 컨슈머 안에서 순서가 꼬일 수 있으므로 키 단위 직렬화 등 별도 제어가 필요하다.

면접 답변으로 압축하면: "랙이 쌓이면 파티션 수까지는 컨슈머를 늘리면 됩니다. 그 상한을 넘으면 선택의 문제인데, 파티션을 늘리면 키 라우팅이 바뀌어 순서 보장이 깨지고 리밸런싱 비용이 생기는 걸 감당해야 하고, 아니면 컨슈머 내부를 병렬화하되 그 안의 순서 제어를 별도로 해야 합니다."

4. 사용 예시 및 코드 스니펫

유실 불가 도메인(결제·주문)의 프로듀서/컨슈머 설정 예시:

# --- Producer: 유실 방지 + 순서 보장 ---
acks=all                                   # ISR 복제 완료까지 대기 (3.0+ 기본)
enable.idempotence=true                    # 재시도 중복 방지 + 순서 보장 (3.0+ 기본)
max.in.flight.requests.per.connection=5    # 멱등성 켠 상태에서 5 이하면 순서 보장

# --- Broker/Topic ---
min.insync.replicas=2                      # acks=all이 실효성을 갖는 최소 ISR

# --- Consumer: 중복은 허용하되 유실은 차단 ---
enable.auto.commit=false                   # 처리 완료 후 명시 커밋 (at-least-once)
partition.assignment.strategy=org.apache.kafka.clients.consumer.CooperativeStickyAssignor
group.instance.id=order-consumer-1         # static membership: 재시작 시 리밸런싱 회피

컨슈머 랙 확인(소비가 발행을 못 따라가는지 감지):

kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --describe --group order-consumer-group
# LAG 컬럼 = (파티션 최신 오프셋) - (커밋된 오프셋)

오토커밋 유실/중복 시나리오 타임라인 (커밋은 다음 poll() 호출 시점에 나간다):

poll()#1 ──▶ [msg 100~109 수신, 워커에 처리 위임] ──▶ poll()#2 (5초 주기 도래 → 오프셋 110 자동 커밋)
                                                          │
   커밋 후 워커가 처리 완료 전에 crash ────────────────────┘──▶ 재시작 시 110부터 재개 = 유실
   처리 완료 후, 커밋 주기 도래 전에 crash ─────────────────▶ 100~109 다시 수신 = 중복
  • 유실 시나리오는 처리가 poll 루프와 분리된(비동기·워커 스레드 위임) 구조에서 성립한다. 단일 스레드 동기 루프라면 다음 poll 전에 처리가 끝나므로, 남는 문제는 중복뿐이다 — "동기 루프에서도 유실되나요?"라는 꼬리질문의 답.

5. 실제 사용 시 주의점 / Best Practice

  • "파티션의 존재 이유 = 병렬 처리 + 순서 보장" 프레임으로 모든 질문을 환원할 것. 파티션 증설이 왜 위험한지, 컨슈머 상한이 왜 파티션 수인지, 리밸런싱이 왜 아픈지가 전부 이 두 축에서 유도된다.
  • 유실 vs 중복은 대칭이 아니다: 중복은 멱등 키로 막을 수 있지만, 유실은 감지조차 어렵다. 따라서 인프라는 at-least-once로 고정하고 중복 방어를 애플리케이션에서 한다.
  • 오토커밋은 "유실·중복을 감당할 수 있는 데이터"에만. 대신 커밋을 배치로 모아 네트워크 비용을 아끼는 이점은 있다.
  • acks=all을 믿으려면 min.insync.replicas를 반드시 함께 볼 것.
  • 버전 표기 주의: acks 기본값(all)과 멱등성 기본 활성화는 Kafka 3.0 이후 기준이다. 그 이전 버전 기준으로 답하면 acks=1이 기본이었으므로, 면접에서는 "3.0 이후 기준"을 명시하는 게 안전하다.
  • 제로카피는 TLS 구간에서는 효과가 없다 — "그럼 SSL 쓰면요?"라는 꼬리질문 대비.

6. 참고자료 / 공식 문서 출처

반응형

댓글