LabHub
배우기 러닝패스 코스

주문이 두 번 도착했고 한 번은 사라졌다 · 한 번 사라짐 · 이론

한 번 사라짐 — 커밋 시점이 만드는 at-most-once 와 at-least-once

LabHub 에서 이어서 보기

한 줄 요약

컨슈머가 위치를 저장하는 시점이 의미론을 정한다. 처리 전에 저장하면 처리 중
죽은 메시지는 다시 오지 않고(at-most-once, "사라짐"), 처리 후에 저장하면
저장 전에 죽은 메시지가 다시 온다(at-least-once, "두 번 도착"). 후자를 택하고
처리기를 멱등하게 만드는 것이 답이며, 되감기(--reset-offsets)는 사라진 것을
되찾는 도구다.

왜 이게 필요했나

[설계 문서](https://kafka.apache.org/43/design/design/)의 '메시지 전달 의미론'
절은 컨슈머 쪽을 두 문단으로 끝낸다. 컨슈머가 메시지를 읽고 위치를 저장한 뒤
처리하면, 저장 뒤 처리 전에 죽었을 때 넘겨받은 프로세스는 저장된 위치에서
시작하므로 그 앞의 메시지는 처리되지 않는다 — at-most-once. 읽고 처리한 뒤
저장하면, 처리 뒤 저장 전에 죽었을 때 넘겨받은 프로세스가 이미 처리한 메시지를
다시 받는다 — at-least-once. 그리고 문서는 덧붙인다: 많은 경우 메시지에 기본
키가 있어 갱신이 멱등하다(같은 메시지를 두 번 받아도 같은 레코드를 덮어쓸
뿐이다).

이 두 문단이 코스 제목의 두 사고다. "사라진 한 번" 은 처리 전 저장의 결과이고,
그것을 고치면 "두 번 도착" 이 된다. 둘 중 하나를 고르는 것이 아니라, 두 번
도착을 택하고 두 번 처리해도 괜찮게 만드는 것이 설계다.

어떻게 동작하나

자동 커밋은 처리와 무관하게 돈다. [컨슈머 설정 문서](https://kafka.apache.org/43/configuration/consumer-configs/)
enable.auto.commit 은 기본 true 이고, auto.commit.interval.ms(기본 5000)
마다 오프셋이 백그라운드에서 커밋된다. 콘솔 컨슈머처럼 자동 커밋에 기대는
처리기는 "읽은 것" 을 커밋하지 "처리한 것" 을 커밋하지 않는다. 컨슈머가 세
건을 넘기고 오프셋 3 을 커밋했는데 처리기가 두 번째에서 죽었다면, 다음 컨슈머는
3 부터 읽는다. 두 번째와 세 번째는 Kafka 에 그대로 있지만 이 그룹은 영영
지나친다.

shipments  P0: order-1 order-2 order-3 order-4 order-5ship-svc   읽음 3건 → 커밋 3 → 처리기 order-2 에서 크래시           처리됨: order-1            사라짐: order-2, order-3

사라진 것은 메시지가 아니라 위치다. 소비는 삭제가 아니므로 다른 그룹으로
처음부터 읽으면 다섯 건이 다 있다. 그리고 [운영 문서](https://kafka.apache.org/43/operations/basic-kafka-operations/)
--reset-offsets 로 그룹의 위치를 되감을 수 있다 — --to-earliest,
--to-latest, --to-offset, --shift-by, --to-datetime 등의 시나리오가 있고,
--execute 를 붙여야 실제로 바뀌며(없으면 계획만 보여 준다), **컨슈머
인스턴스가 멈춰 있어야** 한다. 되감으면 사라졌던 두 건이 돌아오지만 이미
처리한 order-1 도 다시 온다. 되감기의 대가가 중복이다.

멱등 컨슈머. 문서가 말한 "기본 키가 있어 갱신이 멱등한 경우" 를 처리기
쪽에서 만드는 것이다. 처리한 주문 번호를 결과와 같은 곳에 남기고, 들어온
주문이 이미 있으면 건너뛴다. 상태를 메모리에만 두면 프로세스가 죽을 때 함께
죽어 다음 실행이 다시 중복을 낸다. 파일이든 DB 든 처리 결과와 함께 살아남아야
한다.

새 그룹은 어디서 시작하나. auto.offset.reset 항목 — 그룹에 커밋된
오프셋이 없거나 그 오프셋이 더 이상 존재하지 않을 때(데이터가 지워진 경우)
무엇을 할지다. earliest 는 가장 앞으로, latest(기본) 는 가장 뒤로,
by_duration:<ISO8601> 은 지금부터 그 기간 전으로, none 은 예외를 던진다.
기본이 latest 라서, 새 그룹을 만들고 아무 설정 없이 붙이면 **이전 메시지는
전부 건너뛴다** — "새 서비스가 옛 주문을 못 봤다" 는 신고의 원인이다. 콘솔
도구의 --from-beginning 은 이것을 earliest 로 바꿔 주는 단축키이고, 그룹에
이미 오프셋이 있으면 무시된다.

커밋은 사라진다. [브로커 설정 문서](https://kafka.apache.org/43/configuration/broker-configs/)
offsets.retention.minutes(기본 10080, 7일) — 그룹이 비어 있거나 토픽
구독을 멈춘 채 이 시간이 지나면 커밋된 오프셋이 버려진다. 그 뒤 컨슈머가
돌아오면 오프셋이 없는 것이라 auto.offset.reset 이 적용된다. 한 주 멈췄던
배치 컨슈머가 돌아와 latest 로 시작하면 그 사이 데이터를 전부 건너뛴다.

현장에서 만나는 모습

"주문이 사라졌다" 는 신고를 받으면 순서는 이렇다. 그룹을 describe 해 커밋된
오프셋을 보고, 다른 그룹(또는 그룹 없이)으로 그 구간을 읽어 메시지가 있는지
확인하고, 있으면 위치의 문제다 — 컨슈머가 멈춘 상태에서 `--reset-offsets
--to-offset` 으로 되감고 재처리한다. 재처리가 중복을 만들지 않는지는 처리기가
멱등한지에 달렸고, 아니라면 되감기 전에 그것부터 고쳐야 한다.

반대로 "같은 주문이 두 번 처리됐다" 는 신고는 대개 리밸런스나 재시작 뒤
at-least-once 의 정상 동작이다. 그것을 버그로 보고 커밋을 앞당기면 다음 신고는
"사라졌다" 가 된다. 두 신고는 같은 손잡이의 양쪽 끝이다.

다음 실습에서 할 것

배송 처리기가 두 번째 주문에서 죽어 두 건이 사라지는 것을 재현하고, 다른
그룹으로 그것이 Kafka 에 남아 있음을 확인한 뒤, 그룹을 되감아 재처리해
중복이 생기는 것을 보고, 멱등 처리기로 막는다. 마지막에 auto.offset.reset
의 두 값을 새 그룹으로 비교한다.