파티션 안에서 각 메시지에 매겨지는 순번. 0부터 시작해 하나씩 증가하며 절대 재사용되지 않는다.
파티션 안에서만 의미가 있다
- 파티션 0: — [0] [1] [2] [3] [4] ...
- 파티션 1: — [0] [1] [2] ... ← 파티션 0의 0번과 아무 관계 없다
오프셋이 파티션 지역(local)이라는 사실이 "Kafka는 파티션 단위로만 순서를 보장한다"의 근거다. 전역 순번이 존재하지 않으니 전역 순서도 존재할 수 없다.
세 가지 오프셋을 구분해야 한다
| 이름 | 뜻 |
|---|---|
| LEO(Log End Offset) | 파티션에 쌓인 마지막 오프셋 + 1. 생산 쪽 최신 지점 |
| current offset | 컨슈머가 지금까지 읽어 간 위치(메모리상) |
| committed offset | "여기까지 처리 완료"라고 브로커에 기록한 위치 |
컨슈머 랙은 LEO에서 committed offset을 뺀 값이다. 그리고 컨슈머가 재시작하면 current가 아니라 committed부터 다시 읽는다.
언제 커밋하느냐가 전달 보장을 결정한다
-
[처리 후 커밋] — 읽기 → 처리 → 커밋
- 커밋 직전에 죽으면? → 재시작 후 그 메시지를 다시 처리 → 중복
- = at-least-once (Kafka 기본)
-
[커밋 후 처리] — 읽기 → 커밋 → 처리
- 처리 중에 죽으면? → 이미 커밋됐으니 다시 안 읽는다 → 유실
- = at-most-once
둘 중 유실보다 중복이 낫다는 판단이 표준이고, 그래서 실무의 답이 "at-least-once + 멱등 컨슈머"가 된다.
enable.auto.commit=true(기본)는 주기적으로 자동 커밋하는데, 처리 완료와 무관하게 시간이 되면 커밋하므로 유실 위험이 있다. 정확성이 중요하면 꺼 두고 처리 후 수동 커밋한다.
저장 위치
커밋된 오프셋은 __consumer_offsets 내부 토픽에 (그룹, 토픽, 파티션)을 키로 저장된다. 로그 컴팩션이 걸려 있어 최신 값만 남는다.
오프셋이 사라지는 경우
커밋해 둔 오프셋의 데이터가 리텐션으로 이미 삭제됐다면 그 위치는 더 이상 존재하지 않는다. 이때 동작은 auto.offset.reset이 정한다 — earliest(남아 있는 가장 오래된 것부터)나 latest(지금부터). latest면 그사이 데이터를 조용히 건너뛴다.
면접 함정
- ❌ "오프셋은 전역 순번" → 파티션 지역이다.
- ❌ "컴팩션하면 오프셋이 다시 매겨진다" → 오프셋은 유지되고 중간이 비게 된다.
커밋 시점을 직접 잡기
// 자동 커밋은 처리 완료와 무관하게 시간이 되면 커밋한다 → 유실 위험
props.put(ENABLE_AUTO_COMMIT_CONFIG, false);
while (true) {
var records = consumer.poll(Duration.ofMillis(500));
for (var r : records) {
process(r); // ① 먼저 처리하고
}
consumer.commitSync(); // ② 그다음 커밋한다 → at-least-once
}
①과 ②의 순서를 바꾸면 at-most-once가 된다. 이 두 줄의 순서가 전달 보장을 결정한다.
시간으로 오프셋 찾기
// "3일 전부터 다시 처리해 주세요" 를 구현하는 방법
var target = Map.of(partition, Instant.now().minus(3, DAYS).toEpochMilli());
var found = consumer.offsetsForTimes(target);
consumer.seek(partition, found.get(partition).offset());
단 그 시점의 데이터가 리텐션 안에 남아 있어야 한다. 없으면 offsetsForTimes가 null을 준다.