Blog
kafkadurabilitydata-lossreliabilitytroubleshooting

[Kafka 운영 ③] 메시지 유실은 어디서 생기는가 — 시나리오 총정리와 무손실 설정

Kafka에서 메시지가 사라지는 모든 경로를 프로듀서·브로커·컨슈머 세 구간으로 나눠 정리하고, acks=all·RF=3·min.insync.replicas=2·수동 커밋으로 구성하는 무손실 레시피를 제시합니다.

Data Dynamics2026년 6월 2일22 min read

"Kafka에 분명히 보냈는데 메시지가 없어요." 운영 현장에서 가장 등골이 서늘해지는 한마디입니다. 결제 이벤트가, 주문 로그가, 감사 기록이 어디론가 증발했는데 아무도 예외를 보지 못했다면 — 문제는 코드가 아니라 설정일 가능성이 큽니다. Kafka는 기본적으로 처리량과 가용성을 우선하도록 튜닝돼 있어서, 무손실을 원한다면 명시적으로 그렇게 설정해야 합니다. 이 글에서는 메시지가 사라질 수 있는 모든 길목을 짚고, 한 건도 잃지 않는 설정을 처음부터 끝까지 조립합니다.

이 글에서 배우는 것

  • 메시지 유실이 프로듀서·브로커·컨슈머 어느 구간에서 생기는지
  • acks, retries, flush()가 프로듀서 측 유실과 어떻게 연결되는지
  • unclean.leader.electionmin.insync.replicas가 브로커 측 유실을 어떻게 가르는지
  • 자동 커밋이 만드는 "논리적 유실"의 함정
  • 한 건도 잃지 않는 무손실 레시피(프로듀서·브로커·컨슈머 통합 설정)
  • 내구성 vs 가용성·처리량의 트레이드오프

이 글은 Kafka 운영 트러블슈팅 시리즈의 3편입니다. 1편(컨슈머 랙)과 2편(리밸런싱)에 이어, 이번에는 "데이터를 잃지 않는 법"을 다룹니다. acksmin.insync.replicas의 내부 동작은 4편에서, unclean.leader.election의 위험성은 5편에서 더 깊게 파고듭니다.


1. 메시지의 일생 — 유실은 어디서든 생긴다

메시지 한 건이 살아남으려면 여러 관문을 통과해야 합니다. 프로듀서가 보내고, 리더 브로커가 받고, 팔로워가 복제하고, 커밋되고, 컨슈머가 읽어 처리합니다. 이 사슬의 어느 고리든 끊기면 메시지는 사라집니다.

Loading diagram…

유실 지점을 세 구간으로 나눠 보면 이렇습니다.

구간대표 원인결과
프로듀서acks 약함, retries=0, flush() 누락브로커에 도달조차 못 함
브로커unclean 리더 선출, min.insync.replicas 낮음, RF=1커밋된 레코드가 사라짐
컨슈머처리 전 오프셋 커밋, 자동 커밋레코드를 건너뜀(논리적 유실)

이제 구간별로 하나씩 해부합니다.


2. 프로듀서 측 유실 — 보냈다고 도착한 게 아니다

프로듀서가 send()를 호출했다고 해서 메시지가 브로커에 안전하게 저장된 건 아닙니다. 어떤 설정이냐에 따라 결과가 완전히 달라집니다.

2.1 acks=0 — 던지고 잊기

acks=0은 프로듀서가 브로커의 응답을 전혀 기다리지 않습니다. 소켓 버퍼에 쓰는 순간 "성공"으로 간주합니다. 네트워크가 끊겨도, 리더가 죽어 있어도 프로듀서는 알 수 없습니다. 최고 처리량을 얻지만, 무손실과는 정반대입니다.

# 절대 무손실이 아니다 — 가장 빠르지만 가장 위험
acks=0

2.2 acks=1 — 리더만 받고 끝, 그 사이 리더가 죽으면?

acks=1리더 브로커가 자기 로그에 기록하면 곧바로 ack를 돌려줍니다. 팔로워 복제를 기다리지 않습니다. 문제는 여기서 생깁니다.

1. 프로듀서 → 리더: 메시지 기록, ack 반환 (acks=1)
2. 팔로워들이 아직 복제하기 전에 리더 브로커가 크래시
3. 컨트롤러가 팔로워 중 하나를 새 리더로 선출
4. 새 리더에는 그 메시지가 없다 → 유실

프로듀서는 ack를 받았으니 "성공"으로 처리하고 넘어갑니다. 이미 성공으로 기록된 메시지가 조용히 사라지는 가장 흔한 시나리오입니다.

2.3 retries=0 — 일시적 실패를 그냥 버린다

retries=0이면 일시적 오류(리더 변경 중, 네트워크 순단, NotEnoughReplicas)가 발생해도 재시도하지 않고 즉시 예외를 던지거나 콜백에 에러를 넘깁니다. 호출 측이 그 에러를 제대로 처리하지 않으면 메시지는 그대로 증발합니다. 사실 대부분의 일시적 실패는 몇백 밀리초만 기다리면 해소되는데, 재시도를 끄면 그 기회를 스스로 차버리는 셈입니다.

// 위험: 재시도 없음 + 에러 무시
producer.send(record); // 반환된 Future를 확인하지 않음 → 실패가 묻힌다

2.4 async send() — flush() 없이 프로세스가 죽으면

Kafka 프로듀서는 send()를 호출하면 메시지를 메모리 내 배치 버퍼(buffer.memory)에 쌓아두고 백그라운드 I/O 스레드가 모아서 전송합니다. 비동기라 빠르지만, 아직 전송되지 않은 버퍼는 프로세스 메모리에만 존재합니다.

for (Order order : orders) {
    producer.send(new ProducerRecord<>("orders", order.id(), order.json()));
}
// 여기서 System.exit()나 SIGKILL이 들어오면?
// 버퍼에 남아 있던 메시지는 전송되지 못하고 통째로 사라진다

배치 작업이 끝났는데 flush()close()를 호출하지 않고 JVM이 종료되면, 버퍼에 남은 미전송 메시지는 전부 유실됩니다. 종료 전 반드시 flush() 또는 close()로 버퍼를 비워야 합니다.

try {
    for (Order order : orders) {
        producer.send(record, (meta, ex) -> {
            if (ex != null) log.error("send failed", ex); // 콜백에서 실패 감지
        });
    }
    producer.flush(); // 모든 in-flight 전송 완료를 보장
} finally {
    producer.close(); // close()도 내부적으로 flush 수행
}

3. 브로커 측 유실 — 커밋된 것마저 사라진다

프로듀서가 ack를 받고 메시지가 분명히 커밋됐는데도 사라지는 경우가 있습니다. 브로커의 복제 설정이 핵심입니다.

3.1 unclean.leader.election.enable=true — 뒤처진 복제본을 리더로

Kafka는 리더와 동기화된 복제본 집합을 **ISR(In-Sync Replicas)**로 관리합니다. 정상이라면 ISR에 있는 복제본 중에서만 새 리더를 뽑습니다. 그런데 unclean.leader.election.enable=true로 두면, ISR이 비었을 때 동기화가 뒤처진(out-of-sync) 복제본까지 리더로 선출합니다.

1. 리더 + ISR 팔로워들이 동시에 다운 (예: 랙 전원 장애)
2. 살아남은 건 한참 뒤처진 복제본 하나뿐 (ISR 밖)
3. unclean election 허용 → 그 뒤처진 복제본이 새 리더로
4. 리더가 가진 오프셋 이후의 "커밋된" 레코드는 전부 소실

가용성(어쨌든 쓰기 가능한 리더를 빨리 복구)을 위해 내구성을 희생하는 옵션입니다. 무손실이 목표라면 반드시 false로 둬야 합니다. 자세한 메커니즘은 5편에서 다룹니다.

3.2 min.insync.replicas가 낮으면 acks=all도 무력하다

acks=all은 "ISR에 있는 모든 복제본이 받을 때까지 기다린다"는 뜻입니다. 그런데 ISR이 단 1개로 쪼그라들었다면, acks=all이라도 복제본 1개만 받으면 ack가 반환됩니다. 그 1개가 죽으면 끝입니다.

min.insync.replicas쓰기가 성공하기 위해 ISR에 최소 몇 개가 있어야 하는지의 하한선입니다. 이 값이 충족되지 않으면 브로커는 쓰기를 거부하고 NotEnoughReplicasException을 던집니다(프로듀서는 재시도).

설정acks=all의 실제 보장
min.insync.replicas=1ISR이 1개여도 ack → 그 1개 죽으면 유실
min.insync.replicas=2최소 2개가 받아야 ack → 1개 죽어도 안전

acks=allmin.insync.replicas반드시 짝으로 설정해야 의미가 있습니다.

3.3 replication.factor=1 — 복제본이 없으면 복구도 없다

복제 계수(RF)가 1이면 파티션 데이터가 브로커 한 대에만 존재합니다. 그 브로커의 디스크가 깨지거나 영구 장애가 나면 복구할 곳이 없습니다. acks를 아무리 강하게 줘도, min.insync.replicas를 올려도 복제본 자체가 없으니 소용없습니다. 운영 토픽에 RF=1은 금물입니다.


4. 컨슈머 측 유실 — 읽었는데 처리는 안 됐다

컨슈머 측 유실은 데이터가 디스크에서 사라지는 게 아니라, 컨슈머가 처리하지 않은 메시지를 "처리했다"고 기록해서 다시는 읽지 않는 논리적 유실입니다.

4.1 자동 커밋 + 처리 전 커밋

enable.auto.commit=true(기본값)이면 컨슈머는 auto.commit.interval.ms(기본 5초)마다 현재까지 poll()한 오프셋을 백그라운드로 커밋합니다. 문제는 커밋이 실제 처리 완료와 무관하게 일어난다는 점입니다.

1. poll() → 레코드 100건 수신
2. auto-commit 타이머 발동 → 오프셋 100까지 커밋됨
3. 60번째 레코드 처리 중 컨슈머 크래시
4. 재시작 → 오프셋 100부터 읽음
5. 61~100번 레코드는 처리되지 않았는데 영영 건너뜀 → 논리적 유실

비슷하게, 코드에서 처리 전에 명시적으로 커밋하는 것도 같은 결과를 냅니다.

// 위험: 커밋이 먼저, 처리가 나중
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
consumer.commitSync();        // 먼저 커밋해버림
for (var r : records) process(r); // 여기서 죽으면 → 미처리 레코드 유실

4.2 안전한 패턴 — 처리 끝난 뒤에 커밋

해법은 단순합니다. 자동 커밋을 끄고, 처리가 완전히 끝난 뒤에 수동으로 커밋하면 됩니다.

props.put("enable.auto.commit", "false"); // 자동 커밋 비활성화
 
while (running) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    for (var r : records) {
        process(r);        // 1. 먼저 처리 (DB 저장 등)
    }
    consumer.commitSync(); // 2. 처리가 끝난 뒤에 커밋
}

이렇게 하면 처리 도중 크래시가 나도 오프셋이 커밋되지 않았으므로, 재시작 시 그 배치를 다시 읽습니다. 단, 이 방식은 최소 한 번(at-least-once) 보장이라 중복 처리가 발생할 수 있습니다. 따라서 처리 로직은 멱등(idempotent) 하게 설계하거나(동일 키 upsert, 처리 ID 중복 체크 등), 트랜잭션으로 묶어야 합니다. "유실 없음"과 "중복 없음"은 별개의 문제이며, 무손실을 택하면 보통 중복은 감수합니다.


5. 무손실 레시피 — 한 건도 잃지 않는 설정

지금까지의 유실 경로를 모두 막는 설정을 한자리에 모읍니다. 핵심은 프로듀서·브로커/토픽·컨슈머 세 구간을 동시에 잠그는 것입니다. 한 곳이라도 빠지면 사슬은 그 지점에서 끊깁니다.

5.1 프로듀서 설정

# 모든 ISR이 받을 때까지 대기
acks=all
 
# 멱등 프로듀서: 재시도로 인한 중복/순서 꼬임 방지
enable.idempotence=true
 
# 멱등 활성화 시 기본값. 일시적 실패를 끝까지 재시도
retries=2147483647
 
# 재시도의 총 상한 시간 (이 시간 안에서만 retries가 동작)
delivery.timeout.ms=120000
 
# 멱등 보장을 위해 5 이하 (순서 보존)
max.in.flight.requests.per.connection=5

enable.idempotence=true를 켜면 acks=all, retries=Integer.MAX_VALUE, max.in.flight<=5가 자동으로 강제·정합화됩니다. 그래도 명시적으로 적어두면 의도가 드러나고, 누군가 실수로 acks=1로 바꿨을 때 충돌로 바로 드러납니다.

5.2 브로커/토픽 설정

# 파티션을 3대에 복제 (브로커 1대 손실에도 데이터 보존)
replication.factor=3
 
# 쓰기 성공에 최소 2개 ISR 필요 (acks=all과 짝)
min.insync.replicas=2
 
# 뒤처진 복제본을 리더로 뽑지 않음 (내구성 우선)
unclean.leader.election.enable=false

min.insync.replicas는 토픽 레벨에서도 설정할 수 있으며, 토픽 설정이 브로커 기본값을 덮어씁니다. 무손실이 필요한 토픽에는 토픽 레벨로 명시하는 것을 권장합니다.

5.3 컨슈머 설정

# 자동 커밋 끄기 — 처리 후 수동 커밋
enable.auto.commit=false

그리고 4.2의 패턴대로 처리 완료 후 commitSync().

5.4 왜 RF=3 + min.insync.replicas=2 인가

가장 자주 권장되는 조합이 RF=3, min.insync.replicas=2입니다. 이유는 두 가지 요구를 동시에 만족시키기 때문입니다.

항목동작
내구성3개 복제 중 acks=all + min.insync=2면 항상 2개 이상이 데이터를 가짐. 브로커 1대가 죽어도 데이터 보존.
가용성(쓰기)브로커 1대가 죽어도 남은 2개가 min.insync=2를 충족 → 쓰기 계속 가능.

수식으로 보면, 한 번에 견딜 수 있는 브로커 손실 수는 RF - min.insync.replicas입니다.

  • RF=3, min.insync=2 → 1대 손실까지 쓰기 유지(균형점)
  • RF=3, min.insync=3 → 1대만 죽어도 쓰기 중단(내구성 최대, 가용성 희생)
  • RF=3, min.insync=1 → 가용성 최대지만 사실상 acks=1 수준의 위험

즉 RF=3 + min.insync=2는 **"브로커 1대 손실까지는 데이터도 지키고 쓰기도 유지"**하는, 내구성과 가용성의 합리적 균형점입니다.

5.5 무손실 설정 한눈에 보기

구간설정목적
프로듀서acksall모든 ISR이 받을 때까지 대기
프로듀서enable.idempotencetrue재시도 중복·순서 꼬임 방지
프로듀서retries2147483647일시적 실패 끝까지 재시도
프로듀서delivery.timeout.ms120000재시도 총 상한 시간
브로커/토픽replication.factor31대 손실에도 데이터 보존
브로커/토픽min.insync.replicas2acks=all의 실질 보장 하한
브로커/토픽unclean.leader.election.enablefalse뒤처진 복제본 리더 금지
컨슈머enable.auto.commitfalse처리 후 수동 커밋

6. 트레이드오프 — 공짜 내구성은 없다

무손실 설정은 안전한 만큼 비용을 치릅니다. 무엇을 얻고 무엇을 내주는지 정확히 알고 선택해야 합니다.

선택얻는 것내주는 것
acks=all강한 내구성지연 증가(복제 대기), 처리량 감소
min.insync.replicas=21대 손실에도 안전ISR이 2 미만이면 쓰기 거부
unclean.election=false커밋 레코드 보존ISR 전멸 시 파티션 쓰기/읽기 중단
수동 커밋(처리 후)논리적 유실 차단중복 처리 가능 → 멱등 설계 필요
RF=3복구 여력디스크·네트워크 비용 3배

핵심 원칙은 모든 토픽에 무손실을 적용하지 않는 것입니다. 결제·주문·감사 로그처럼 한 건도 잃으면 안 되는 토픽은 무손실로, 클릭스트림·메트릭처럼 약간의 유실이 허용되는 토픽은 처리량 우선(acks=1, RF=2 등)으로 — 토픽의 가치에 맞춰 차등 적용하는 것이 현실적입니다.

내구성을 더 강하게 끌어올리고 싶다면 acks/min.insync.replicas의 내부 동작을 다루는 4편, unclean 리더 선출이 실제로 데이터를 어떻게 날리는지 재현하는 5편을 이어서 보세요.


마치며

  • 메시지 유실은 프로듀서·브로커·컨슈머 세 구간 어디서든 생깁니다. 한 구간만 막아서는 안전하지 않습니다.
  • 프로듀서: acks=0/1, retries=0, flush() 누락이 도달 자체를 막습니다. acks=all + 멱등 + 종료 전 flush()/close()로 잠그세요.
  • 브로커: unclean.leader.election=true, 낮은 min.insync.replicas, RF=1이 커밋된 레코드마저 날립니다. RF=3 + min.insync=2 + unclean=false가 균형점입니다.
  • 컨슈머: 자동 커밋과 처리 전 커밋이 논리적 유실을 만듭니다. 자동 커밋을 끄고 처리 후 수동 커밋하되, 중복에 대비해 멱등하게 설계하세요.
  • 무손실은 지연·처리량·가용성을 대가로 합니다. 모든 토픽에 일괄 적용하지 말고, 토픽의 가치에 맞춰 차등 적용하세요.
  • "보냈다"와 "저장됐다", "읽었다"와 "처리됐다"는 다릅니다. 그 틈을 메우는 것이 무손실 설정의 본질입니다.

참고 자료


— Data Dynamics 엔지니어링 팀