TL;DR
Kafka는 '전달'을 해결하지, 'exactly once'는 개발자 몫이다.
옛날 개발자들은 완전히 멘탈이 나가버렸습니다.
시스템끼리 직접 연결해야 했거든요. A -> B, B -> C ,B -> D, A->E , ....시스템이 하나 늘 때마다 연동 파이프라인이 폭발적으로 늘어났습니다.
5개 시스템이면 연동이 최대 20개, 10개면 90개.... 아키텍처 다이어그램은 거미줄이 되어버렸죠.
그래서 똑똑한 개발자들이 기발한 생각을 했는데요, 메시지 큐, 그러니까 시스템 사이에 우체통을 하나 놓자는 겁니다. 보내는 쪽은 큐에 넣고, 받는 쪽은 큐에서 꺼내면 되니까요. Rabbit MQ같은 전통적인 MQ가 이렇게 탄생했습니다.
그런데 진짜 문제는 여기서부터였습니다. 전통 MQ는 컨슈머가 메시지를 꺼내가면 그 메시지가 사라져버렸거든요. 장애가 터져서 "아까 그 메시지 다시 처리해야 하는데?" 하면, 이미 큐에서 사라진 뒤였습니다. 재처리를 하려면 로그를 다시 만들거나 DB를 뒤져야 했고, 시스템마다 재처리 방식도 제각각이였죠.
바로 그 순간, LinkedIn의 천재 엔지니어들이 미친 아이디어를 냈습니다. "메지시를 전달하는게 아니라, 로그에 쭉 쌓아놓고 읽게 하면 되잖아?" 이게 바로 Kafka의 탄생이었습니다.
Kafka의 본질은 Distributed Commit Log입니다. 프로듀서가 로그에 쓰면 브로커가 보관하고, 컨슈머는 메시지를 전달받는 게 아니라 로그를 직접 읽습니다. append-only라 뒤에 계속 쌓이기만 하고, 오프셋 기반으로 읽으니까 장애가 터져도 오프셋만 되돌리면 재처리가 가능해진 겁니다.
그런데 심각한 문제가 또 터졌습니다. 파티션 설계, 컨슈머 그룹 리밸런싱, 오프셋 커밋 전략... 개발자들의 멘탈은 또다시 완전히 나가버렸습니다.
Producer가 카프카에 메시지를 전달하고, Consumer가 읽기까지의 과정을 따라가며 Kafka가 해결해주는 것과 여전히 남아있는 것에 대해 살펴볼 예정입니다.
이번 글에서는 카프카를 실무에서 잘 사용하는 방법이나 설정값과 같은 실용적인 내용을 다루지 않습니다..
순수 호기심에서 출발했기 때문에 지엽적인 내용, 몰라도 Kafka 사용에 지장 없는 내용일수 있습니다.
목차는 아래와 같습니다.
1. 프로듀서가 send() 호출
2. 브로커에 도착해서 디스크에 기록
3. 컨슈머가 "offset 42를 읽고 싶다"
4. 컨슈머가 메지지를 받아서 처리
[1] 프로듀서가 send() 호출
Kafka가 해결하는 것
kafkaTemplate.send()를 호출하면 메시지가 즉시 네트워크로 나가는게 아닙니다. 내부적으로 여러 단계를 거치게 됩니다.
- Serialization - 객체를 byte[]로 변환합니다. key-value각각에 대해 Serializer가 동작합니다. Json이면 JsonSerializer, Avro면 KafkaAvroSrializer등이 여기서 작동합니다.
- Partitioning - 어떤 파티션에 보낼지 결정합니다. key가 있으면 hash(key) % 파티션 수, key가 null이면 Sticky Partitioner가 동작합니다. Sticky Partitioner는 Kafka 2.4부터 도입된 건데, 과거의 라운드로빈과 달리 하나의 배치가 채워질 때까지 같은 파티션에 계속 보내서 배치 효율을 높입니다.
- RecordAccumulator에 적재 — 메시지가 바로 전송되지 않고, 파티션별 버퍼(Deque)에 쌓입니다. 여기서 batch.size(기본 16KB)만큼 모이거나 linger.ms(기본 0ms) 시간이 지나면 하나의 배치로 묶이게 됩니다.
send() 호출
│
├─ Serializer: OrderEvent → byte[]
├─ Partitioner: hash("order-1001") % 3 = 1
├─ RecordAccumulator: Partition 1의 버퍼에 적재
│ ├─ batch.size(16KB) 채워지면 전송 트리거
│ └─ linger.ms(5ms) 지나면 미완성 배치도 전송
│
└─ Sender 스레드 (별도 스레드):
├─ 배치를 파티션의 leader 브로커로 전송
├─ max.in.flight.requests.per.connection(5)만큼
│ 응답 안 받고도 연속 전송 가능
└─ 실패 시 retries 설정만큼 재시도
linger.ms=0이면 메시지가 들어오자마자 바로 전송하니까 지연은 최소지만 배치 효율이 낮습니다.
linger.ms=5로 설정하면 5ms 기다리면서 메시지를 모아 배치로 보내서 네트워크 왕복이 줄어듭니다.
5ms의 지연을 감수하고 처리량을 올리는 트레이드오프라고 볼 수 있겠습니다.
- compression — 배치 단위로 압축이 일어납니다. compression.type=lz4로 설정하면 배치가 완성된 후 압축해서 네트워크 대역폭과 디스크 사용량을 줄여줍니다. 압축은 개별 메시지가 아니라 배치 단위라서 압축률이 높습니다. 비슷한 구조의 메시지가 배치 안에 여러 개 있으니까요.
- idempotent producer — enable.idempotence=true이면 프로듀서가 각 메시지에 PID(Producer ID) + sequence number를 붙입니다. 브로커가 이걸 보고 "이 메시지는 이미 받았다"를 판단해서, 네트워크 타임아웃으로 재전송이 일어나도 중복 저장을 방지해줍니다.
Kafka가 해결하지 않는 것
프로듀서 애플리케이션 자체가 죽는 경우. send()를 호출했는데 RecordAccumulator에 적재된 상태에서, 아직 Sender 스레드가 전송하기 전에 JVM이 죽으면 그 메시지는 사라집니다. Kafka에 도달하지도 않았으니까요.
그리고, 비즈니스 로직(DB 저장)은 성공했는데 send() 호출 직전에 죽는 경우입니다. DB에는 주문이 있는데 Kafka에는 이벤트가 없는 상태가 됩니다.
@Transactional
public void createOrder(OrderRequest req) {
orderRepository.save(order); // 성공 → DB에 주문 있음
// ← 여기서 JVM 죽으면?
kafkaTemplate.send("order-events", ...); // 실행 안 됨 → Kafka에 이벤트 없음
}
이걸 해결하는 게 Transactional Outbox 패턴입니다. 비즈니스 데이터와 발행할 이벤트를 같은 DB 트랜잭션으로 묶으면, DB 커밋이 됐으면 이벤트도 반드시 존재하고, 롤백됐으면 둘 다 없습니다. 이벤트 발사대같은 역할을 하게 되는겁니다.
@Transactional
public void createOrder(OrderRequest req) {
orderRepository.save(order);
outboxRepository.save(new OutboxEvent("order-events", ...));
// → 같은 트랜잭션. 둘 다 성공하거나 둘 다 실패
}
Kafka의 Transactional Producer(transaction API)로도 해결 가능하지 않나? 라는 의문이 들 수도 있습니다.
Kafka의 Transaction은 "Kafka 내부에서의 원자성" (여러 토픽/파티션에 대한 all or nothing)을 보장하지, "외부 DB와 Kafka사이의 원자성" 은 보장하지 않습니다. DB 커밋과 Kafka커밋은 서로 다른 시스템이니까, outbox같은 발사대가 필요한겁니다.
💡고민해볼 것
linger.ms와 batch.size의 최적값은 트래픽 패턴에 따라 다르다. 초당 수만 건이면 linger.ms=0 으로도 배치가 자연스럽게 차는데, 초당 수십 건이면 linger.ms를 올려야 배치 효율이 나온다. 자신의 시스템 트래픽 패턴에서 최적값을 어떻게 찾을 것인가?
[2] 브로커에 도착해서 디스크에 기록
Kafka가 해결하는 것
메시지가 브로커에 도착하면 해당 파티션의 leader가 처리합니다.
Append-only 쓰기 - 절대 중간에 끼워넣거나 수정하지 않고, 항상 active segment 끝에 추가합니다.
디스크의 sequential write는 랜덤 write보다 수백 배 빠르기 때문입니다.
실제 코드에서 벌어지는 일
브로커가 메시지를 받으면:
│
├─ 1) LogSegments.activeSegment()
│ → ConcurrentSkipListMap의 lastEntry() = 현재 쓰기 중인 세그먼트
│
├─ 2) LogSegment.shouldRoll() 체크
│ → 크기 > log.segment.bytes(1GB)?
│ → 시간 > log.roll.ms(7일)?
│ → index 꽉 찼는가?
│ → 하나라도 true면 새 세그먼트 생성 후 전환
│
├─ 3) LogSegment.append()
│ ├─ FileRecords.append(records)
│ │ → .log 파일 끝에 바이너리 write
│ │ → java.nio.channels.FileChannel.write()
│ │ → OS가 page cache에 먼저 기록, 비동기로 디스크 flush
│ │
│ └─ bytesSinceLastIndexEntry > index.interval.bytes(4KB)?
│ ├─ Yes: OffsetIndex.append(offset, position)
│ │ TimeIndex.maybeAppend(timestamp, offset)
│ │ bytesSinceLastIndexEntry = 0
│ └─ No: bytesSinceLastIndexEntry += appendedBytes
│
├─ 4) ISR follower들이 leader에게 Fetch 요청
│ → leader의 .log 파일에서 읽어서 follower에게 전달
│ → follower도 자기 로컬에 같은 과정으로 append
│
└─ 5) acks 설정에 따라 프로듀서에게 응답
├─ acks=0: 기다리지 않고 즉시 다음
├─ acks=1: leader append 완료 시 OK
└─ acks=all: ISR 전원 복제 완료 시 OK
Kafka가 해결하지 않는 것
- leader가 메시지를 저장하기 전에 브로커가 죽으면 - acks설정과 무관하게 유실입니다. 프로듀서는 timeout이나 connection error를 받고, retries 설정에 따라 재전송을 시도합니다. 하지만 프로듀서 자체도 죽을 수 있으니 다시 Outbox패턴으로 돌아와야 합니다.
- 디스크 수준의 데이터 손상 - Kafka는 레코드에 CRC 체크섬을 기록해서 무결성을 검증하지만, 디스크 자체가 고장나면 해당 브로커의 데이터는 손실됩니다. 이걸 ISR복제로 방어하는겁니다. replication-factor = 3이면 3대의 서로 다른 물리 디스크에 복사본이 있으니, 1대가 죽어도 데이터는 살아있게 됩니다.
- Page cache flush 지연 - vm.dirty_ratio에 도달하면 커널이 dirty page를 강제로 flush하면서 모든 쓰기가 블로킹 될 수 있습니다. 근데 이건 Kafka문제가 아니라 OS 수준의 문제입니다.
[3] 컨슈머가 "offset 42를 읽고 싶다"
Kafka가 해결하는 것
컨슈머가 poll() 을 호출하면 내부적으로 Fetch 요청이 브로커로 가고, 브로커에서 2단계 탐색이 일어납니다.
1단계 : 어떤 세그먼트 파일인가?
LogSegments가 ConcurrentSkipListMap<Long, LogSegment> 로 세그먼트 목록을 관리합니다. floorEntry(42) 한 번에 offset 42가 속한 세그먼트를 O(logN)에 찾을 수 있습니다.
O(logN) 에서 N은 세그먼트 수입니다. 1GB 세그먼트에 retention이 7일이면 일반적으로 수십 개 수준이라 사실상 즉시 찾습니다.
ConcurrentSkipListMap을 사용하는 이유는 baseOffset으로 정렬된 상태에서 floorEntry()가 가능하고, 읽기/쓰기 동시 처리가 lock-free 수준이며, TreeMap보다 concurrent 환경에서 성능이 좋기 때문입니다.
2단계 : .log 파일의 몇 번째 바이트인가?
이걸 혼자 생각해봤을때 "당연히 이분탐색으로 찾지 않으려나" 라고 생각했습니다. 하지만 Kafka 개발진들은 좀 더 똑똑한 방식으로 이분탐색을 수행하도록 설계했습니다.
찾은 세그먼트의 OffsetIndex(.index파일)에서 이분탐색을 수행합니다.
.index 내용 (sparse):
offset 0 → position 0
offset 32 → position 4096
offset 65 → position 8192
offset 97 → position 12288
lookup(42)를 실행하면
-> binarySearch 실행
-> offset 32가 42보다 작거나 같은 수 중 가장 큰 수
-> offset 32부터 33, 34, 35, 36, ... 42까지 가면서 찾기 성공
이 이분탐색이 AbstractIndex.indexSlotRangeFor()에 구현되어 있어요. 핵심은 mmap 위에서 동작한다는 것과, warm/cold 영역 분리가 적용된다는 것입니다.
mmap의 역할 : .index 파일을 MappedByteBuffer로 메모리에 매핑하면, parseEntry(idx, mid)가 호출 될 때 파일 I/O 시스템콜 없이 메모리 접근으로 엔트리를 읽습니다. mmap자체는 "파일을 배열처럼 접근 가능하게 해주는것"일뿐, 탐색 알고리즘과는 무관합니다.
warm/cold 영역 분리 이유와 원리
kafka/storage/src/main/java/org/apache/kafka/storage/internals/log/AbstractIndex.java at trunk · apache/kafka
Apache Kafka - A distributed event streaming platform - apache/kafka
github.com
실제 코드를 확인해보자.

// check if the target offset is in the warm section of the index
if (compareIndexEntry(parseEntry(idx, firstHotEntry), target, searchEntity) < 0) {
return binarySearch(idx, target, searchEntity,
searchResultType, firstHotEntry, entries - 1);
}
502 ~ 506라인을 보면 warm영역에 찾고자 하는 부분이 있는지 판단후 warm영역에서 탐색을 먼저 수행하는 것을 볼 수 있다.
그리고 8192라는(8KB)숫자를 사용하고 이것을 엔트리 크기로 나눠서 이용을 하는데, 이와 관련된 내용은

주석에서 너무너무너무너무너무 잘 설명해주고 있기 때문에 확인해보시길 바랍니다.
번역
Kafka 인덱스의 캐시 친화적 탐색 알고리즘
Kafka는 인덱스 파일을 메모리에 mmap하여, 인덱스의 모든 읽기/쓰기 연산이 OS 페이지 캐시를 통해 이루어집니다. 덕분에 대부분의 경우 디스크 I/O 블로킹을 피할 수 있습니다.
배경: LRU와 Kafka의 궁합
현대 운영체제는 페이지 캐시 관리에 LRU(Least Recently Used) 정책 또는 그 변형을 사용합니다. Kafka는 항상 인덱스 파일의 끝에 추가(append)하고, 대부분의 인덱스 조회(동기화 팔로워나 컨슈머)도 인덱스 끝부분에서 일어나기 때문에, LRU 정책과 Kafka의 접근 패턴은 아주 잘 맞습니다.
문제: 이진 탐색의 페이지 캐시 비친화성
그런데 표준 이진 탐색(binary search) 은 캐시 친화적이지 않아서, 불필요한 페이지 폴트(해당 인덱스 항목이 페이지 캐시에 없어 디스크에서 읽느라 스레드가 블로킹되는 현상)를 유발할 수 있습니다.
예를 들어, 13페이지짜리 인덱스에서 마지막 페이지(#12)의 항목을 찾을 때, 표준 이진 탐색은 다음 페이지들을 읽습니다:
페이지 번호: |0|1|2|3|4|5|6|7|8|9|10|11|12 |
탐색 순서: |1| | | | | |3| | |4| |5 |2/6|
각 페이지에는 수백 개의 로그 항목이 있고, 이는 수백~수천 개의 Kafka 메시지에 대응합니다. 인덱스가 12페이지 내에서 성장하는 동안에는 페이지 #0, 6, 9, 11, 12가 매 조회마다 사용되므로 캐시에 잘 남아 있습니다.
그런데 인덱스가 13페이지로 넘어가는 순간, 조회에 필요한 페이지가 #0, 7, 10, 12, 13으로 바뀝니다:
페이지 번호: |0|1|2|3|4|5|6|7|8|9|10|11|12|13 |
탐색 순서: |1| | | | | | |3| | |4 |5 |6 |2/7|
페이지 #7과 #10은 오랫동안 사용되지 않았기 때문에 페이지 캐시에서 이미 밀려났을 가능성이 높습니다. 13페이지 첫 항목이 추가된 직후의 첫 조회에서 이 페이지들을 디스크에서 읽어야 하며, 이는 1초 이상 걸릴 수 있습니다. 실제 테스트에서 at-least-once 프로듀스 지연이 수 ms에서 약 1초로 급등하는 현상이 관찰되었습니다.
해결책: "웜 섹션" 탐색
이 문제를 해결하기 위해, 더 캐시 친화적인 탐색 알고리즘을 사용합니다:
if (target > indexEntry[end - N]) // 대상이 인덱스 마지막 N개 항목 안에 있으면
binarySearch(end - N, end) // → 마지막 N개 안에서만 이진 탐색
else
binarySearch(begin, end - N) // → 그 앞쪽에서 이진 탐색
가능하면 인덱스의 마지막 N개 항목 안에서만 탐색합니다. 적절한 N값을 선택하면, 동기화 조회는 거의 항상 첫 번째 분기로 들어갑니다. 이 마지막 N개 항목을 "웜(warm) 섹션" 이라고 부르며, 이 작은 영역을 자주 조회하므로 해당 페이지들이 페이지 캐시에 남아 있을 확률이 높아집니다.
N = 8192인 이유
N(_warmEntries)을 8192로 설정한 이유는 두 가지입니다:
- 충분히 작아서, 매 웜 섹션 조회 시 해당 섹션의 모든 페이지가 반드시 접촉(touch)됩니다. 웜 섹션 조회 시 항상 접촉되는 3개 항목은 indexEntry(end), indexEntry(end-N), indexEntry((end*2-N)/2)입니다. 페이지 크기가 4096바이트 이상이면 웜 섹션의 페이지(3개 이하)가 모두 접촉됩니다. 2018년 기준으로 모든 프로세서(x86-32, x86-64, MIPS, SPARC, Power, ARM 등)의 최소 페이지 크기는 4096바이트입니다.
- 충분히 커서, 대부분의 동기화 조회가 웜 섹션 안에서 이루어집니다. 기본 Kafka 설정에서 8KB 인덱스는 약 4MB(오프셋 인덱스) 또는 2.7MB(타임 인덱스)의 로그 메시지에 해당합니다.
N을 8192보다 크게 설정할 수는 없는데, 일반적인 4KB 페이지 호스트에서 웜 섹션의 모든 페이지가 실제로 "따뜻하게"(매 조회마다 접촉) 유지된다고 보장할 간단한 방법이 없기 때문입니다.
향후 개선 방향
백그라운드 스레드를 사용하여 웜 섹션 전체를 주기적으로 접촉(touch)하는 방식을 고려하고 있으며, 이를 통해 더 큰 웜 섹션을 지원하고, QPS가 낮은 토픽-파티션의 웜 섹션도 실제로 "따뜻하게" 유지할 수 있을 것입니다.
좀 더 캐시 친화적으로 빠르게 돌아가도록 했다는 내용입니다.
3단계: 메시지를 네트워크로 전송
위치를 찾으면 FileRecords가 FileChannel.transferTo() 를 호출합니다. 이게 리눅스의 sendfile() 시스템콜로 변환됩니다.
일반 전송은 디스크 -> 커널 버퍼 -> JVM 힙 -> 소켓 버퍼 -> NIC 까지 4번 복사하고, 2번 Context Switch가 일어나지만
카프카는 디스크 -> 커널 버퍼(page cahce) -> NIC로 복사 자체를 하지 않습니다. JVM을 타지도 않습니다.
이게 가능한 이유는, Kafka 브로커는 메시지 내용을 해석하지 않습니다. 프로듀서가 보낸 byte[]를 그대로 컨슈머에게 넘깁니다. 중간에 가공이 없으니까 JVM 힙으로 올릴 이유가 없는겁니다.
이 과정이 "kafka가 디스크 IO임에도 빠른 이유" 라고 검색했을때 나오는 Page cache, zero copy에 해당하는 내용입니다.
Kafka가 해결하지 않는 것
- Page Cache miss시 성능 저하 - 컨슈머 lag이 크게 벌어져 한참 전 데이터를 요청한다면, 그 데이터는 page cache에 없어서 디스크에서 직접 읽어야 합니다. 이때 실시간 컨슈머까지 느려질 수 있습니다. 하나의 느린 컨슈머가 page cache를 오염시키기 때문입니다. 이 문제를 완화하려면 lag가 큰 컨슈머(예: 분석용 배치 컨슈머)는 별도 브로커 그룹이나 follower replica에서 읽게 하는 방법이 있습니다. Kafka 2.4부터 replica.selector.class를 설정하면 follower에서 읽기가 가능
- 파티션 간 순서는 보장하지 않는다 - offset 기반 순서 보장은 파티션 내에서만 동작합니다. 서로 다른 파티션에 들어간 메시지의 순서는 Kafka가 보장하지 않아요. 파티션 키 설계로 "비즈니스적으로 의미 있는 단위"의 순서만 지키는 것입니다.
[4] 컨슈머가 메시지를 받아서 처리
Kafka가 해결하는 것
- 오프셋 관리 - 각 컨슈머 그룹이 각 파티션에 대해 "여기까지 읽었다"를 독립적으로 기록합니다. 이 offset은 Kafka 내부 토픽(`__consumer_offsets`)에 저장돼요. 컨슈머가 재시작되면 마지막 커밋된 offset부터 다시 읽습니다.
- 되감기 - offset을 특정 시점으로 되돌리면 과거 메시지를 재처리할 수 있습니다. 이게 가능한 이유는 메시지가 소비 후에도 삭제되지 않고 retention 기간까지 남아있기 때문입니다.
- 리밸런싱 - 컨슈머가 추가되거나 죽으면 Kafka가 파티션을 자동 분배합니다. 컨슈머 3개 중 1대가 죽으면, 그 컨슈머가 담당하던 파티션을 나머지 2대가 나눠 가집니다. 리밸런싱이 일어나는 조건은 3가지가 있습니다.
- 컨슈머가 그룹에 참가하거나 떠날 때
- session.timeout.ms 안에 하트비트가 안 올 때
- max.poll.interval.ms 안에 poll() 호출이 되지 않을때
- max.poll.intervals.ms가 중요한 이유는 메시지 하나를 처리하는 데 시간이 오래걸려서 poll()이 늦어지면, Kafka는 이 컨슈머가 죽은 거라고 판단하고 리밸런싱을 트리거합니다. 그러면 해당 파티션이 다른 컨슈머에게 넘어가고, 원래 컨슈머가 처리를 마치고 커밋하려고 하면 CommitFailedException이 터지게 됩니다.
Kafka가 해결하지 않는 것
- 중복 처리 방지(멱등성) - 되감기를 하든, 리밸런싱이 발생하든, 커밋 전에 컨슈머가 죽든, "같은 메시지를 두 번 받는 상황"은 구조적으로 발생합니다. Kafka는 "at-least-once delivery"를 보장하지, "exactly-once processing"은 보장하지 않습니다. 컨슈머가 구현해야 합니다.
멱등성 구현 방식에도 수준이 있습니다.
가장 단순한 방식 - eventId 기반 중복 체크:
@KafkaListener(topics = "order-events")
public void onMessage(OrderEvent event, Acknowledgment ack) {
if (processedEventRepository.existsById(event.getEventId())) {
ack.acknowledge();
return;
}
processOrder(event);
processedEventRepository.save(event.getEventId());
ack.acknowledge();
}
하지만 이것도 완벽하지는 않습니다. processOrder() 와 processEventRepository.save() 사이에 죽으면 처리는 됐는데 기록이 안돼서 다음에 또 처리합니다. 이걸 완벽하게 하려면 비즈니스 로직과 processed_event 저장을 같은 DB 트랜잭션으로 묶어야 합니다.
@Transactional
@KafkaListener(topics = "order-events")
public void onMessage(OrderEvent event, Acknowledgment ack) {
if (processedEventRepository.existsById(event.getEventId())) {
ack.acknowledge();
return;
}
processOrder(event); // 비즈니스 로직
processedEventRepository.save(event.getEventId()); // 같은 트랜잭션
ack.acknowledge(); // 트랜잭션 커밋 후 offset 커밋
}
더 자연스러운 방식 - 비즈니스 로직 자체를 멱등하게
// INSERT 대신 UPSERT
// "주문 상태를 PAID로 변경"은 두 번 실행해도 결과가 같다
UPDATE orders SET status = 'PAID' WHERE id = :orderId AND status = 'PENDING';
// → affected rows = 0이면 이미 처리된 것, 스킵
이렇게 하면 별도의 processed_event 테이블이 필요가 없습니다. 비즈니스 로직이 자연스럽게 멱등한 경우에 적합합니다. 하지만 "포인트 100 차감"처럼 본질적으로 멱등하지 않은 연산은 eventId 체크가 필수입니다.
- 커밋 전략 선택 - auto commit을 쓸지, 수동 커밋을 쓸지, 배치 커밋을 쓸지는 Kafka가 정해주지 않습니다. 데이터의 중요도에 따라 개발자가 판단해야 합니다.
- DLQ 처리 - 파싱 불가능한 메시지가 들어왔을 때 컨슈머가 무한 실패하지 않도록 하는 것도 개발자 몫입니다. Spring Kafka의 @RetryableTopic을 쓰면 N번 실패 후 DLQ 토픽으로 보낼 수 있지만, DLQ에 빠진 메시지를 나중에 어떻게 재처리할지는 직접 설계해야 합니다.
정상 흐름: order-events → consumer → 처리 성공
실패 흐름: order-events → consumer → 3번 실패
→ order-events-retry-0 (1초 후 재시도)
→ order-events-retry-1 (2초 후 재시도)
→ order-events-retry-2 (4초 후 재시도)
→ order-events.DLQ (최종 실패, 수동 확인 필요)
4개 구간을 따라오면서 하나의 패턴이 보였을 겁니다.
[1] send() → Kafka가 배치, 압축, 파티셔닝을 해결 → 프로듀서가 죽으면? 개발자 몫
[2] 디스크 기록 → Kafka가 append-only, page cache, ISR을 해결 → 브로커가 죽으면? 개발자 몫 (acks + replication 설계)
[3] offset 42 읽기 → Kafka가 2단계 탐색, zero-copy를 해결 → lag으로 page cache miss? 개발자 몫 (모니터링)
[4] 메시지 처리 → Kafka가 offset 관리, 되감기를 해결 → 중복 처리? 개발자 몫 (멱등성) Kafka는 "A에서 B로 메시지가 전달되는 것"을 놀라울 정도로 잘 해결합니다. 하지만 "정확히 한 번 처리"는 Kafka의 영역 밖입니다. Outbox, 멱등성, 커밋 전략, DLQ...
결국 Kafka를 "잘" 쓴다는 건, Kafka가 해결하지 않는 것의 목록을 알고 있다는 뜻일지도 모릅니다.
'TW' 카테고리의 다른 글
| Redis가 해결하는 것과 여전히 남아있는 것 (0) | 2026.04.10 |
|---|---|
| 대기열? ZADD하면 끝 아니야? (0) | 2026.04.03 |
| 인덱스가 5배 느려지게 만들었다 (0) | 2026.03.11 |
| 원자적 업데이트가 그렇게 좋아? (3) | 2026.03.06 |
| JPA 엔티티와 도메인 엔티티, 분리해야 하는가? (0) | 2026.02.25 |