- 읽는 순서
- 1장. 전체 구조와 데이터 경로
- 2장. 이벤트를 기록하고 다시 읽기 | 토픽·파티션·키·오프셋
- 3장. 순서는 어디까지 보장되는가 | 파티션과 소비
- 4장. 브로커가 죽으면 무엇이 남는가 | 로그 · 복제 · ISR · acks
- 5장. 컨슈머가 재시작하면 어디서부터 읽는가 | 그룹 · 오프셋 커밋 · 리밸런스
- 6장. 중복과 누락은 어디서 생기는가 | 처리와 커밋 사이의 실패 창
- 7장. 멱등성과 트랜잭션은 어디까지 지켜 주는가 | Kafka 내부와 외부 DB의 경계
- 8장. 데이터의 수명 | 삭제 · 보존 · 컴팩션
- 9장. 설정은 조합으로 읽어야 한다 | acks · ISR · min.insync.replicas 외
- 10장. 증상에서 관측으로 | 지연 · 복제 · 소비 상태
- 11장. 가용성, 내구성, 재처리 비용 사이에서 고르기 | 설계 판단의 실제
- 12장. 데이터와 메타데이터는 어떻게 다르게 복제되는가 | KRaft
- 13장. 누가 접근하고 어디에서 비용이 발생하는가 | 보안·성능
- 14장. 외부 시스템을 연결할 때 무엇을 맡길 것인가 | Kafka Connect
- Source·Sink, Connector·Task·Worker는 각각 무엇인가
- Connect는 브로커 안의 기능인가, 별도 프로그램인가
- 플러그인 설치와 커넥터 등록은 서로 다른 작업이다
- 프로듀서·컨슈머를 직접 쓸 것인가, Connect를 쓸 것인가
- 먼저 레코드의 형태와 변환 단계를 구분한다
- 작은 설정 예제: Worker를 시작하는 설정과 작업을 등록하는 설정
- 설정에서 실행으로: Connector가 Task를 만드는 정상 흐름
- 정상 동작을 한 행에서 검색 문서까지 추적한다
- 등록 상태, 실행 상태, 업무 완료를 각각 확인한다
- Standalone·Distributed와 세 내부 토픽
- 누가 분산 실행을 조정하고 브로커는 무엇을 하는가
- Worker 재배분은 왜 중복을 만들 수 있는가
- Connect의 Exactly-once는 어디까지인가
- Converter·Schema·SMT는 어디에서 책임지는가
- Retry·errors.tolerance·DLQ가 해결하지 않는 것
- REST 관리·모니터링·보안은 어떻게 연결되는가
- 확인 질문: 장애 인계와 업무 중복은 같은 문제인가
- 15장. 데이터베이스 변경을 이벤트로 읽는 법 | JDBC Polling·CDC·Snapshot
- 16장. DB 변경과 발행의 빈틈을 없애는 법 | Transactional Outbox
- 부록 A. 업그레이드와 호환성 | 되돌릴 수 있는가를 먼저 묻기
- 부록 B. 선택 실험 | 읽은 것을 손으로 확인하고 싶을 때
- 부록 C. 용어 찾아보기 | 처음 자세히 다룬 곳
- 부록 D. 공식 문서 찾아보기 | 장별로 먼저 열 페이지
- 공식 근거 찾아보기
읽는 순서
이 문서는 Kafka 4.3 공식 문서를 바탕으로 쓴 비공식 웹 교과서다. DB 트랜잭션과 서비스 재시도는 알지만 Kafka는 처음인 독자를 대상으로 한다. 처음에는 순서대로 읽고, 이후에는 목차와 절 링크로 필요한 내용을 찾는다.
한결마켓과 주문·정산 사례는 모두 설명을 위해 만들었다. 본문은 정상 동작을 설명한 뒤 장애가 났을 때의 결과와 보장 조건을 살펴본다. 확인 질문의 해설은 펼쳐 볼 수 있으며, 실험이나 공부 일정은 요구하지 않는다. 설정과 프로토콜은 Kafka 4.3 기준이므로 사용하는 클라이언트·브로커·커넥터 버전이 다르면 원문을 대조한다.
이벤트 로그의 보존·재처리와 현재 요청의 응답이 필요한 원격 호출을 비교하려면 gRPC의 RPC·deadline·재시도 설명을 함께 읽는다.
| 순서 | 배우는 문제 |
|---|---|
| 1장 | 데이터 경로·배치·로그 파일·메모리와 I/O에서 설계 이유를 이해한다 |
| 2장 | Topic·Partition·Key·Offset으로 로그를 이해한다 |
| 3장 | 순서·그룹·처리·commit을 구분한다 |
| 4장 | 복제·ISR·acks와 성공 응답의 의미를 연결한다 |
| 5장 | 재시작·rebalance 뒤의 읽기 위치를 예측한다 |
| 6장 | 처리와 commit 사이의 중복·누락을 설명한다 |
| 7장 | 멱등 producer·transaction과 외부 DB 경계를 다룬다 |
| 8장 | 보존·offset 만료·compaction의 수명을 비교한다 |
| 9장 | 설정을 조합하고 장애 때의 비용을 판단한다 |
| 10장 | 지표와 실제 업무 진척을 구분한다 |
| 11장 | 가용성·내구성·재처리 비용에서 설계를 선택한다 |
| 12장 | KRaft metadata quorum과 data replication을 구분한다 |
| 13장 | 인증·인가·암호화와 client 성능을 다룬다 |
| 14장 | Connect worker·task·복구·변환·오류 처리를 배운다 |
| 15장 | JDBC polling·CDC·snapshot·delete의 차이를 설명한다 |
| 16장 | Outbox의 DB 원자성과 재전달 경계를 구분한다 |
1장. 전체 구조와 데이터 경로
Kafka는 이벤트를 기록하고 여러 애플리케이션이 각자 읽게 하는 시스템이다. 이벤트를 보내는 애플리케이션을 프로듀서(producer), 읽어 처리하는 애플리케이션을 컨슈머(consumer), 기록을 저장하고 읽기 요청에 응답하는 Kafka 서버를 브로커(broker)라고 한다.
여기서 이벤트는 “주문 104가 결제되었다”처럼 이미 일어난 사실이고, 로그는 그 사실들을 순서대로 덧붙여 둔 기록이다. Kafka는 주문을 대신 결제하거나 정산하는 업무 엔진이 아니다. 기록을 남겨 두고 각 업무 애플리케이션이 자기 속도로 읽게 한다. 프로듀서·컨슈머는 별도의 Kafka 서버 종류가 아니라 애플리케이션이 사용하는 클라이언트 역할이다. 하나의 애플리케이션이 읽은 결과를 다시 발행하면 두 역할을 함께 할 수도 있다.[2.1]
먼저 주문 하나의 길을 단순하게 잡아 보자. 아래는 정상 흐름의 역할도이며 복제와 장애는 아직 생략했다.
주문 서비스의 producer → Kafka broker의 주문 기록 → 정산 서비스의 consumer → 정산 DB
→ 알림 서비스의 consumer → 알림 API
브로커는 정산 코드를 호출하지 않는다. 정산 컨슈머가 기록을 요청해 받아서 자기 코드를 실행한다. 알림 서비스가 잠시 멈춰도 정산은 같은 기록을 독립적으로 읽을 수 있다. 대신 주문 서비스가 Kafka에 기록했다고 두 업무가 동시에 끝나지는 않는다. 이 비동기 관계를 이해한 뒤 저장과 전송의 세부 구조를 추가한다.
한결마켓의 주문 서비스가 주문 이벤트를 보내고 정산 서비스가 읽는다고 하자. 주문 객체는 바이트로 바뀌어 전송되고, 브로커에 기록·복제된 뒤 정산 코드에 도달한다. send() 반환, 브로커의 기록, 디스크 동기화, 정산 완료는 서로 다른 시점이다. 성공을 판단하려면 어느 단계까지 끝났는지 구분해야 한다.
이 장에서는 데이터가 이동하고 저장되는 경로를 살펴본다. 토픽·파티션·오프셋의 사용법은 2~3장, 복제와 처리의 성공 조건은 4~7장에서 이어서 다룬다. 그림은 역할과 데이터 이동을 나타낸 개념도이며 모든 구현 클래스나 요청을 표시하지는 않는다.
1.1 데이터가 흐르는 곳과 경로를 관리하는 곳
클러스터(cluster)는 여러 Kafka 서버가 협력하는 배치다. 브로커 세 대를 둔다고 모든 레코드를 세 대에 무조건 저장하는 것은 아니다. 어떤 로그를 어디에 몇 개의 복제본으로 둘지는 토픽·파티션의 구성으로 정한다.
관련 이벤트를 묶는 이름이 토픽(topic)이고, 토픽을 나눈 독립적인 로그가 파티션(partition)이다. 파티션마다 쓰기를 받는 복제본인 리더(leader)가 있다. 다른 복제본인 팔로워(follower)는 리더의 데이터를 가져와 자신의 로그에 기록한다.
프로듀서는 클러스터의 배치 정보인 메타데이터를 받아 대상 파티션의 리더 브로커로 직접 보낸다. 컨슈머도 메타데이터와 할당 정보를 이용해 담당 파티션을 읽는다. 여기서는 일반적인 리더 읽기를 기준으로 설명한다. 조건에 따라 팔로워에서 읽는 구성도 있다.
부트스트랩 주소는 클러스터를 처음 찾는 입구다. 모든 레코드를 중계하는 서버 주소가 아니다.[1.1]
레코드를 쓰고 복제하고 읽는 경로를 데이터 경로(data plane), 토픽·파티션의 배치와 리더·브로커 상태를 관리하는 경로를 제어 경로(control plane)로 구분할 수 있다. 구조를 설명하기 위한 분류다.
컨트롤러(controller)는 이런 배치를 관리하는 서버 역할이다. KRaft는 Kafka 안에서 메타데이터를 복제하고 합의하는 방식이다. 정족수(quorum)는 그 합의에 참여하는 컨트롤러 집합이며, 메타데이터 변경을 확정하려면 과반의 참여가 필요하다. 지금은 “주문 기록의 경로를 정하지만 주문 본문은 중계하지 않는다”는 관계를 먼저 기억하면 된다. 합의와 장애 조건은 12장에서 이어 설명한다.
KRaft 컨트롤러들은 메타데이터 정족수(quorum)를 이루고, 활성 컨트롤러가 클러스터 변경을 관리한다. 주문 데이터를 받아 브로커로 전달하지는 않는다. 컨트롤러와 브로커를 같은 프로세스로 실행해도 두 역할의 책임은 다르다.[1.2]
그룹 코디네이터(group coordinator)는 컨슈머 그룹의 구성원과 복구 위치를 관리하는 브로커 역할이다. 컨슈머는 FindCoordinator로 담당 브로커를 찾고, 다시 읽을 위치를 저장하는 오프셋 커밋 요청을 보낸다. 위치는 복제된 내부 토픽 __consumer_offsets에 남는다.
컨트롤러는 클러스터 배치를, 코디네이터는 그룹과 복구 위치를 관리한다. 실제 주문은 파티션 로그에서 읽는다. 모든 주문이 코디네이터를 거치는 것도, 복구 위치를 컨트롤러의 메타데이터 정족수에 직접 저장하는 것도 아니다.[1.3]
1.2 객체 하나를 보내도 저장과 전송의 단위는 배치다
프로듀서에서 브로커까지의 전송은 다음 순서로 진행된다.
직렬화는 값의 의미를 전송 가능한 바이트 표현으로 바꾸는 일이고, 역직렬화는 그 바이트를 읽는 쪽의 값으로 되돌리는 일이다. 예를 들어 주문 값을 JSON 바이트로 보냈다면 정산 컨슈머도 그 JSON과 필드 의미를 해석할 수 있어야 한다. 브로커가 Java 객체나 주문의 업무 의미를 다른 서비스에 자동 공유해 주는 것은 아니다.
- 직렬화: 직렬화기(serializer)가 애플리케이션의 키·값 객체를 바이트로 바꾼다.
- 배치 구성: 프로듀서가 레코드를 파티션별 버퍼에 모은다. 같은 파티션의 레코드들이 하나의 레코드 배치(record batch)를 이룬다. 레코드 하나만 담긴 배치도 가능하다.
- 요청 전송: 백그라운드 I/O 스레드가 요청을 만들어 보낸다. 한 브로커가 담당하는 여러 파티션의 배치를 하나의 요청에 실을 수 있다.
이 모으는 단계 때문에 send() 호출 횟수와 네트워크 요청 수는 같지 않다.[1.4]
batch.size는 파티션별 배치 크기에, linger.ms는 더 모으기 위한 대기에 영향을 준다. 배치가 크면 요청·전송·쓰기의 고정 비용을 여러 레코드가 나누지만, 유입이 적으면 레코드를 모으는 동안 기다리게 된다.
압축도 배치 단위로 적용하면 반복되는 필드와 값의 패턴을 함께 이용할 수 있다. 프로듀서에서 압축한 배치는 브로커에 저장되고 컨슈머까지 압축된 채 이동할 수 있다. 컨슈머가 이를 풀어 읽는다. 다만 토픽이 다른 압축 코덱을 강제하면 브로커에 재압축 비용이 생긴다. “Kafka는 바이트를 저장한다”는 말이 브로커가 아무 검사나 변환도 하지 않는다는 뜻은 아니다.[1.5]
브로커에서 읽기까지는 다음 단계가 이어진다.
- 요청 수신: 연결을 받는 acceptor와 연결 I/O를 담당하는 processor가 요청을 받아들인다.
- 로그 추가: 요청 처리 단계가 쓰기 데이터의 형식과 조건을 검사하고, 해당 파티션의 활성 로그 끝에 붙인다.
- 쓰기 확인:
acks=1이면 리더 기록을,acks=all이면 필요한 복제 확인을 기다려 성공을 응답한다.acks=0에서는 프로듀서가 브로커의 응답을 기다리지 않는다. - 읽기 요청: 컨슈머와 팔로워가 fetch 요청으로 필요한 로그 범위를 가져온다. 컨슈머는 받은 바이트를 역직렬화해 업무 코드에서 사용한다.
컨슈머의 읽기는 서버가 업무 처리를 호출하는 동작이 아니다. 클라이언트가 자신의 위치에서 데이터를 요청한다. 데이터가 더 모이기를 기다리는 fetch와 복제 확인을 기다리는 쓰기는 지연 원인도 다르다.[1.6][1.1]
1.3 긴 로그를 작은 파일과 성긴 색인으로 읽는다
파티션 로그는 세그먼트(segment)라는 파일들로 나뉜다. 새 레코드는 마지막 활성 세그먼트의 끝에 붙는다. 파일 크기나 시간 조건을 충족하면 새 세그먼트로 넘어간다.
컨슈머가 요청하는 오프셋(offset)은 파티션 안의 논리적인 레코드 위치다. 파일의 바이트 주소와는 다르다. 브로커는 요청한 오프셋이 있는 세그먼트를 찾고, 파일 안에서 읽을 위치를 찾는다.
Kafka 4.3의 Log 설명에는 오래된 메시지 형식도 남아 있다. 현재 배치의 이진 형식은 Message Format과 함께 읽어야 한다.[1.7]
오프셋 색인은 모든 레코드의 주소를 적는 대신 일정 바이트 간격으로 위치를 기록한다. 이를 성긴 색인(sparse index)이라고 한다. 가까운 색인 위치에서 원하는 오프셋까지 로그를 훑으므로, 색인 크기를 줄이는 대신 그 사이를 읽는 비용이 남는다. 시간 색인은 타임스탬프에서 읽을 위치를 찾는 데 쓰인다.
index.interval.bytes를 작게 하면 일반적으로 색인 항목이 늘고 탐색 뒤 스캔 구간은 짧아진다. 크게 하면 반대다. 실제 읽기 비용은 레코드·배치 크기와 캐시 상태에도 달려 있다.[1.8]
이 색인은 DB에서 특정 키의 현재 값을 조회하는 색인과 목적이 다르다. Kafka는 파티션 끝에 추가하고, 오프셋부터 연속된 구간을 읽는다. 키는 파티션 선택과 컴팩션의 기준이지만 일반 컨슈머에게 key=주문7만 찾아 주는 조회 색인은 없다. 개별 주문 조회가 필요하면 컨슈머가 별도 DB나 상태 저장소를 만든다.
세그먼트 분할은 정리에도 쓰인다. 오래된 파일을 통째로 삭제하거나, 쓰기가 끝난 파일을 정리할 수 있다.
1.4 JVM 힙·페이지 캐시·디스크를 구분하기
Kafka가 로그 파일에 썼다고 데이터가 곧바로 저장장치에 내려간 것은 아니다. 일반적인 버퍼 I/O에서 새 데이터는 OS의 페이지 캐시(page cache)에 들어간다. 디스크에 아직 반영되지 않은 페이지를 더티 페이지(dirty page)라고 한다. OS의 백그라운드 내려쓰기(writeback)가 이를 디스크에 반영한다. Kafka는 매 레코드마다 fsync로 강제 동기화하지 않는다.
디스크가 쓰기 유입을 계속 따라가지 못하면 더티 데이터가 쌓이고, 결국 쓰는 프로세스도 기다릴 수 있다. 페이지 캐시는 쓰기를 모으고 읽은 파일을 재사용하는 층이지, 부족한 디스크 성능을 없애는 장치는 아니다.[1.9]
JVM 힙(heap)은 Java 프로세스의 객체들이 사용하는 메모리 영역이다. 페이지 캐시는 운영체제가 파일 내용을 기억하는 메모리이므로 힙과 관리 주체가 다르다. 디스크는 전원이 끊겨도 데이터를 남기기 위한 저장장치다. 같은 레코드가 지금 어느 층에 있는지를 구분해야 “메모리에 있다”와 “디스크에 안전하게 남았다”를 혼동하지 않는다.
JVM에 레코드를 Java 객체로 대량 캐시하면 객체 헤더와 참조가 공간을 차지한다. 살아 있는 객체가 많아져 가비지 컬렉션(GC)의 부담도 커질 수 있다. 같은 파일이 페이지 캐시에도 있으면 두 캐시가 메모리를 중복 점유한다.
객체 표현 비용과 캐시의 중복 점유는 별개의 문제다. 공식 Design의 배수 예시는 이 선택을 설명하기 위한 사례이지, 모든 객체가 정확히 두 배의 공간을 쓴다는 법칙은 아니다.[1.10]
Kafka는 로그 데이터의 큰 캐시를 JVM 객체 모음으로 따로 두기보다 파일과 페이지 캐시를 활용한다. 그래도 요청 처리, 메타데이터, 코디네이터의 오프셋 캐시 같은 상태는 메모리를 쓴다. Kafka가 힙을 쓰지 않거나 GC를 하지 않는다는 뜻은 아니다.
OS는 여러 독자가 같은 파일 페이지를 재사용하도록 관리한다. 브로커 프로세스만 재시작하면 캐시가 남을 수 있지만 머신 재부팅 뒤에도 보존된다는 보장은 없다. 힙을 늘리면 같은 머신에서 페이지 캐시가 쓸 여유 메모리가 줄 수 있으므로, 힙 확대가 항상 읽기 성능 향상으로 이어지는 것은 아니다.[1.10][1.3]
1.5 순차 접근과 제로 카피가 줄이는 비용
연속된 파일 구간을 읽고 쓰면 요청의 고정 비용을 여러 레코드가 나누기 쉽다. 미리 읽기(read-ahead)도 이후에 필요한 데이터를 캐시에 올릴 수 있다. HDD에서는 탐색·회전 비용을 줄이는 효과가 특히 크다. SSD에도 배치와 연속 전송의 이점은 남지만 같은 비율의 개선을 약속할 수는 없다.
여러 파티션의 쓰기, 과거 로그 재처리, 컴팩션의 재읽기·재쓰기가 섞이면 저장장치가 보는 접근은 순차 쓰기만이 아니다. 공식 문서의 과거 HDD 처리량 예시는 설계 배경이지, HDD가 필수라거나 SSD의 효과가 없다는 근거는 아니다.[1.10]
일반적인 파일 전송은 커널의 파일 페이지를 사용자 공간 버퍼로 읽고, 다시 소켓으로 보내는 복사를 포함한다. Kafka는 지원되는 경로에서 Java의 transferTo와 OS의 sendfile 계열 기능을 이용해 이 왕복 복사를 줄인다.
이것이 제로 카피(zero-copy)다. 저장장치부터 네트워크 장치까지 데이터 이동이 전혀 없다는 뜻은 아니다. 애플리케이션 버퍼를 거치는 불필요한 복사를 피한다는 뜻이며, 실제 하위 전송은 OS와 네트워크 장치에 달려 있다.[1.6][1.11]
파일의 배치를 변환 없이 보낼 수 있어야 이 경로를 활용할 수 있다. 형식 변환이 필요하면 파일 구간을 그대로 전달한다는 전제가 달라진다. Kafka 4.3 Design은 TLS/SSL 처리가 사용자 공간에서 일어나고 in-kernel SSL_sendfile을 지원하지 않으므로, SSL 연결에서 sendfile을 쓰지 않는다고 명시한다.
압축은 데이터 크기를 줄이고 TLS는 연결을 암호화한다. 하나를 켜도 다른 작업의 비용이 없어지지는 않는다. 캐시에 없는 과거 세그먼트는 디스크에서 읽어야 하므로, 최신 로그를 따라가는 컨슈머와 대규모 재처리에 같은 성능을 약속해서는 안 된다.[1.11]
1.6 물리 경로를 알면 보장의 조건이 보인다
페이지 캐시의 레코드도 팔로워로 복제될 수 있다. 팔로워는 자신의 로그에 이를 기록한다. acks=all은 필요한 복제 확인을 기다리는 계약이지, 모든 복제본의 저장장치에 매번 fsync가 끝났다는 계약은 아니다.
브로커 하나가 멈췄지만 동기화된 다른 복제본이 살아 있는 경우와, 같은 전원·장애 영역의 복제본들을 한꺼번에 잃는 경우는 다르다. 복제 설정을 읽을 때는 몇 군데에 기록됐는지와 각 머신의 저장장치에 어디까지 반영됐는지를 따로 확인한다(4장, 9장).[4.2][4.5]
로그를 읽어도 파일은 없어지지 않아 다른 그룹이 독립적으로 읽거나 재처리할 수 있다. 대신 시스템이 보존과 정리 비용을 부담한다.
| 작업 | 남기는 것과 비용 |
|---|---|
| 보존 정책(retention)에 따른 삭제 | 오래된 세그먼트를 버려 공간을 확보하지만 재처리 범위가 줄어든다 |
| 컴팩션(compaction) | 키별 최신 상태를 남기기 위해 세그먼트를 다시 읽고 쓴다. 일부 이력과 클리너 I/O가 비용이다 |
| 배치 압축(compression) | 전송·저장할 바이트를 줄인다. 어떤 이력을 남길지 정하는 정책은 아니다 |
삭제 표시의 기한과 재처리 예산은 8장과 11장에서 다룬다.[1.7][8.3]
확인 질문: 디스크 읽기는 없는데 왜 정산은 늦는가
가상 운영 지표에서 브로커의 디스크 읽기가 거의 없고 프로듀서의 send()도 빠르게 반환되는데 정산 반영은 늦다. 이것만으로 Kafka 기록과 정산 DB 쓰기가 끝났다고 판단할 수 있는가? TLS를 켠 뒤 CPU 사용량이 늘었다면 “page cache를 못 쓰게 됐다”는 설명은 맞는가?
해설
send()가 반환되어도 레코드는 아직 전송 버퍼에 있을 수 있다. 전송 성공 콜백과 복제 확인, 컨슈머의 읽기·처리·커밋, 정산 DB 완료를 각각 확인해야 한다. 디스크 읽기가 적은 것은 캐시가 데이터를 공급하는 상황과 양립하지만 업무 완료 증거는 아니다.
TLS가 페이지 캐시를 없애지는 않는다. Kafka 4.3에서는 SSL 연결에 sendfile을 쓰지 않으며 암호화도 필요하므로, CPU와 복사 비용을 함께 조사한다.
2장. 이벤트를 기록하고 다시 읽기 | 토픽·파티션·키·오프셋
한결마켓에서 주문이 결제되면 알림 서비스는 고객에게 알려야 하고, 정산 배치는 금액을 집계해야 한다. 주문 서비스가 둘을 직접 호출하면 상대 서비스의 상태가 주문 처리에 영향을 준다.
Kafka를 사이에 두면 주문 서비스는 결제 사실을 먼저 기록한다. 알림과 정산은 각자 준비된 때 같은 기록을 읽는다. Kafka가 여러 독자의 로그라는 말은 이 동작을 가리킨다.
이벤트에는 무엇을 담는가
이벤트(event)는 이미 일어난 사실이다. Kafka는 이를 레코드(record) 또는 메시지(message)로 저장한다. 주문 이벤트는 다음과 같이 구성할 수 있다.
| 요소 | 주문 이벤트의 예 |
|---|---|
| 키(key) | 주문 번호. 관련 이벤트를 같은 파티션에 모으는 기준 |
| 값(value) | 결제 완료 등 상태 변경 내용 |
| 타임스탬프(timestamp) | 이벤트의 시각 |
| 헤더(header) | 필요한 경우 붙이는 부가 정보 |
주문 서비스의 프로듀서가 이를 보내고 알림·정산 서비스의 컨슈머가 읽는다. 브로커는 직렬화된 바이트를 파티션 로그에 저장한다.[2.1]
주문이 Kafka에 기록돼도 정산 DB 반영이나 알림 발송이 끝난 것은 아니다. 프로듀서의 저장 성공과 컨슈머의 업무 성공은 별개의 결과다.
주문 이벤트 한 건을 실제 값으로 따라가기
다음은 특정 클라이언트 언어의 전체 코드가 아니라 키와 값의 관계를 보여 주는 설명용 레코드다.
topic: orders
key: "104"
value: {"eventId":"E-104-paid","orderId":104,"status":"PAID"}
orders는 기록을 묶는 이름, 104는 관련 주문을 모으는 키, E-104-paid는 이 업무 이벤트 자체의 식별자다. 키가 같아도 주문 생성과 결제 완료는 서로 다른 사건이다. 같은 이벤트가 재전달되었는지 구분하는 ID와 같은 주문의 이벤트를 모으는 키를 혼동하면 정상 후속 이벤트까지 중복으로 버릴 수 있다.
주문 서비스는 키와 값을 직렬화하고, 파티셔너가 고른 파티션의 리더에 보낸다. 브로커가 그 파티션 안의 위치를 부여하면 알림·정산은 그 위치의 레코드를 각자 읽는다. 값을 DB에 반영하는 일은 각각의 컨슈머가 한다. 이제 이 흐름의 이름·배치·위치를 토픽·파티션·오프셋으로 나누어 살펴본다.
토픽과 파티션: 이름 하나에 여러 로그
토픽은 관련 이벤트를 묶는 이름이다. orders를 여러 파티션으로 나누면 각 파티션은 독립적인 추가 전용 로그가 된다. 새 레코드는 선택된 파티션의 끝에 붙는다.
파티션을 여러 브로커에 분산하면 병렬로 읽고 쓸 수 있다. 따라서 토픽 이름만 정할 것이 아니라, 관련 레코드를 어느 파티션에 모을지도 정해야 한다.[2.1]
P0 안에서는 기록 순서가 정해지지만 P0와 P1 사이에 하나의 전역 순서는 없다. 전체 주문을 시간순으로 처리하는 요구와 주문 하나의 상태 변경 순서를 지키는 요구는 다르다. 후자가 필요하면 한 주문의 이벤트를 같은 파티션에 모은다.
키와 파티셔너: 같은 주문을 같은 로그에 모으기
프로듀서의 파티셔너(partitioner)가 파티션을 선택한다. 파티션을 명시하지 않고 키가 있는 기본 경로에서는 직렬화한 키의 해시를 사용한다. 프로듀서는 메타데이터에서 그 파티션의 리더를 찾아 직접 전송한다.[2.2][2.3]
같은 키가 같은 파티션으로 가려면 다음 조건이 유지되어야 한다.
- 토픽과 직렬화된 키 바이트가 같다.
- 파티셔너가 호환되고 파티션 수가 같다.
- 파티션을 별도로 지정하거나 키를 무시하도록 설정하지 않는다.
파티션 수를 늘리면 새 레코드의 매핑이 바뀔 수 있지만 기존 레코드는 옮겨지지 않는다. 사용자 정의 파티셔너도 매핑 계약을 바꿀 수 있다.[2.4]
오프셋: 위치 번호와 처리 완료를 구분하기
오프셋은 파티션 안의 위치다. (topic, partition, offset)을 함께 써야 레코드 위치를 지정할 수 있다. 다른 파티션의 숫자를 비교해 발생 시각이나 업무 순서를 결정해서는 안 된다.
가상의 orders P0에 주문 생성이 오프셋 40, 결제 완료가 41로 기록됐다고 하자. 알림 서비스가 41까지 읽었지만 정산은 아직 40을 읽는 중일 수 있다. 읽는 위치는 달라도 둘이 보는 P0의 기록 순서는 같다. P1에도 41이라는 번호가 있을 수 있지만 그것은 별도의 로그 위치다.
오프셋은 증가하지만 컴팩션으로 레코드가 빠지거나 트랜잭션의 제어 레코드가 숨겨져, 애플리케이션이 받는 번호는 연속하지 않을 수 있다.[2.5][2.7]
컨슈머의 동작에는 세 가지 경계가 있다.
- 읽기: 컨슈머가 레코드를 받아 다음 읽을 위치를 앞으로 옮긴다.
- 업무 처리: 정산 DB 반영이나 알림 발송을 수행한다.
- 오프셋 커밋: 장애 후 다시 읽을 위치를 따로 저장한다.
읽었다고 처리나 커밋까지 끝난 것은 아니다. 처리와 커밋 순서에 따른 실패 결과는 5·6장에서 다룬다.
읽은 데이터가 남는 이유: 여러 독자의 독립성
알림 서비스가 주문 이벤트를 읽어도 그 레코드는 삭제되지 않는다. 정산 서비스는 같은 로그를 자신의 읽기 위치에서 읽는다. 뒤늦게 시작하더라도 데이터가 남아 있으면 과거 위치부터 읽을 수 있다.
다만 보존 정책은 컨슈머의 처리 완료 여부와 관계없이 적용된다. 정산이 끝나지 않았다고 데이터를 계속 남겨 두는 것은 아니다. 보존·세그먼트·오프셋 만료는 8장에서 다룬다.[2.1][2.6]
확인 질문: 병렬성보다 먼저 결정할 순서는 무엇인가
주문 하나의 상태 변경 순서는 지켜야 하지만 서로 다른 주문끼리의 순서는 필요 없다. 키를 주문 ID 대신 항상 같은 상수로 정하면 무엇을 얻고 잃는가? 이후 파티션을 늘리면 이전·이후 이벤트가 자동으로 합쳐지는가?
해설
상수 키는 호환되는 partitioner 아래에서 모든 주문을 한 파티션으로 모아 그 로그 안의 순서를 얻지만 분산 병렬성의 이점을 잃는다. 주문 ID 키는 주문별 순서를 같은 파티션으로 모으면서 주문 사이의 병렬성을 허용한다. 어느 쪽도 여러 프로듀서의 업무상 인과관계까지 자동으로 정하지는 않는다. 파티션을 늘려도 기존 데이터가 옮겨지지 않으므로 migration 때 키 매핑과 소비 전환 절차가 필요하다.
3장. 순서는 어디까지 보장되는가 | 파티션과 소비
가상 시나리오 · 거의 동시에 들어온 세 이벤트
주문 서비스가 orders 토픽에 이벤트 세 개를 거의 동시에 기록했다. 주문 7번의 생성과 결제완료, 그리고 주문 9번의 생성이다. 키는 주문 번호다. orders에는 파티션이 여섯 개 있다.
이 토픽을 알림 서비스(그룹 notify)와 정산 배치(그룹 settlement)가 각자 읽는다. 알림 서비스는 “결제완료 알림이 주문 접수 알림보다 먼저 가면 안 된다”는 요구 사항을 갖고 있다.
예측 질문
두 소비자가 각각 확실히 기대할 수 있는 순서는 어디까지이고, 기대할 수 없는 순서는 무엇인가? 그리고 한 소비자가 이벤트를 읽었다는 사실이 다른 소비자가 읽을 데이터의 존재나 순서에 영향을 주는가? 조건과 보장하지 않는 범위를 나눠서 답해 보자.
2장에서 설명한 파티션이 Kafka가 약속하는 순서의 단위다. 토픽 전체에는 같은 약속이 적용되지 않는다. 이 순서가 업무 처리까지 유지되려면 프로듀서와 컨슈머도 순서를 깨뜨리지 않아야 한다.
먼저 정상 소비에서는 프로세스가 Kafka 클라이언트를 만들고 subscribe()로 orders를 구독한다. 그룹 참여와 파티션 할당이 진행된 뒤 poll()로 레코드를 받는다. 이 호출은 “토픽의 새 알림 하나”가 아니라 여러 파티션의 여러 레코드를 반환할 수 있다. 받은 레코드를 업무 코드로 처리하는 단계는 그 다음이다.
구독과 그룹 참여 → 파티션 할당 → 각 파티션의 위치에서 poll → 레코드 반환 → 업무 처리
그림은 컨슈머 애플리케이션의 논리적 단계다. subscribe()가 반환됐다는 사실만으로 모든 할당과 읽기가 끝났다는 뜻은 아니다. 순서 보장은 Kafka가 돌려주는 파티션별 기록 순서에서 시작하며, 업무 처리의 순서까지 이어지는 조건을 이 장에서 추가한다.[3.6]
파티션 안에서는 쓴 순서가 곧 읽는 순서다
같은 네트워크 요청에 실린 배치라도 여러 파티션으로 나뉘면 각 로그는 독립적으로 전진한다. 요청 하나로 전송됐다는 사실은 토픽 전체의 순서를 만들지 않는다.
공식 문서는 어떤 토픽 파티션을 읽는 컨슈머든 그 파티션의 이벤트를 쓰인 순서와 정확히 같은 순서로 읽는다고 설명한다.[3.1] 설계 문서는 토픽을 “완전히 순서가 정해진 파티션들의 집합”이라고 표현한다.[3.2] 컨슈머 설정 문서도 트랜잭션 설정과 관계없이 메시지는 항상 오프셋 순서로 반환된다고 적는다.[3.3]
이 순서는 2장에서 설명한 파티션의 구조에 따른다. 파티션은 추가 전용이고, 오프셋은 쓰인 순서대로 증가하며, 컨슈머는 오프셋을 따라 앞으로 읽는다. 따라서 주문 7번의 두 이벤트가 같은 키로 같은 파티션에 생성, 결제완료 순서로 쓰였다면, 그 파티션을 읽는 소비자는 누구든 이 순서로 받는다.
알림 서비스와 정산 배치는 서로 다른 컨슈머 그룹이지만 이 순서는 양쪽에 적용된다. 각자 자기 위치(오프셋)를 따로 가지고 같은 로그를 읽을 뿐이라, 한쪽이 읽었다고 다른 쪽이 받을 데이터가 줄거나 순서가 바뀌지 않는다. 2장에서 본 대로 소비는 삭제를 일으키지 않는다.
파티션 사이에는 순서가 없다
주문 9번은 키가 다르다. 키 해시가 우연히 같은 파티션을 가리키지 않는 한 다른 파티션에 들어간다. 그러면 “주문 7번의 결제완료와 주문 9번의 생성 중 무엇이 먼저 읽히는가”에는 답이 없다. 문서가 보장하는 순서는 파티션 안의 순서뿐이고, 토픽 전체의 순서는 약속하지 않는다.[3.1] [3.2]
한 소비자가 여러 파티션을 맡고 있으면 그 소비자가 받는 레코드는 여러 파티션의 로그가 섞인 흐름이다. 어느 파티션의 데이터를 먼저 가져오는지는 요청 시점과 각 파티션 리더의 응답에 따라 달라진다. 설령 P0에 A1이 먼저 쓰이고 P1에 B1이 나중에 쓰였더라도, 소비자가 B1을 먼저 받을 수 있다.
순서가 중요한 이벤트는 같은 키를 써서 같은 파티션에 모은다. 알림 서비스의 요구 사항(“결제완료 알림이 접수 알림보다 먼저 가면 안 된다”)은 같은 주문 안의 순서이므로 키를 주문 번호로 잡은 이상 파티션 순서로 지켜진다. 반대로 “전체 주문을 들어온 순서대로 처리해야 한다”는 요구는 파티션이 하나뿐인 토픽이 아니면 Kafka 혼자서는 지킬 수 없다.
컨슈머 그룹: 파티션을 나눠 갖는 단위
컨슈머 그룹(consumer group)은 같은 group.id를 가진 컨슈머들의 모임이다. 설계 문서에 따르면 각 파티션은 구독하는 컨슈머 그룹마다 어느 순간이든 정확히 한 컨슈머가 읽는다.[3.2] 그룹 안에서 파티션을 나눠 가지니 일을 나눠 처리할 수 있고, 한 파티션을 두 컨슈머가 동시에 읽지 않으니 파티션 안의 순서가 그룹 안에서도 유지된다.
여기서 인스턴스는 실행 중인 애플리케이션 프로세스다. 알림 코드 세 벌을 실행하고 모두 group.id=notify를 쓰면 같은 논리적 알림 작업을 나누어 맡는다. 정산 프로세스가 group.id=settlement를 쓰면 알림과 경쟁해 레코드를 빼앗는 것이 아니라 같은 로그를 별도로 읽는다. 서비스 이름을 붙이는 설정이 아니라 어떤 작업의 읽기 위치와 할당을 공유할지 정하는 설정이다.
orders의 파티션 여섯 개를 두 그룹이 나눠 갖는 예는 다음과 같다.
| 그룹 | 컨슈머 | 맡은 파티션(예) |
|---|---|---|
notify |
인스턴스 3개 | 각자 2개씩 |
settlement |
인스턴스 1개 | 6개 전부 |
notify에 인스턴스를 8개로 늘리면 |
8개 | 6개는 1개씩, 2개는 할당 없음 |
한 파티션을 한 그룹 안에서 한 컨슈머만 읽으므로, 파티션보다 많은 컨슈머는 할당받을 파티션이 없다. 2장에서 파티션 수가 컨슈머 병렬성의 상한이라고 한 이유다.[3.5] 파티션을 어느 컨슈머에게 줄지는 그룹 코디네이터와 할당 전략이 정하는데, 그 과정(리밸런스)은 5장에서 다룬다.
오프셋 커밋은 업무 완료와 어떻게 연결되는가
컨슈머는 poll로 가져온 위치(position)와 그룹의 복구 위치(committed offset)를 구분한다. 처리한 마지막 오프셋이 41이면 재시작 때 42부터 읽도록 다음 위치를 commit한다. 여러 파티션이면 각 파티션의 위치를 따로 저장한다. 컨슈머 group은 같은 논리적 작업의 파티션을 나누는 단위이고, 다른 group은 같은 로그를 독립적으로 읽는다.
DB 업무 반영 이후 commit하면 장애 때 재처리할 수 있다. Commit을 업무 반영보다 먼저 하면 장애 때 처리하지 않은 구간을 건너뛸 수 있다. 자동 commit 설정 자체가 업무 완료를 판정하지 않으므로 poll 이후 비동기 작업을 남겨 둔 구조에서는 특히 주의한다. 5장은 위치 저장과 리밸런스, 6장은 외부 부수 효과의 중복·누락을 더 자세히 연결한다.
“쓴 순서”는 누가 정하는가
파티션 안의 순서는 “쓰인 순서”다. 그런데 쓰인 순서가 애플리케이션이 send()를 호출한 순서와 항상 같을까? 두 가지 경우에 다를 수 있다.
첫째, 한 프로듀서 안에서의 재시도. 프로듀서는 한 연결에 응답을 기다리지 않은 요청을 여러 개 보낼 수 있다(max.in.flight.requests.per.connection, 기본 5). 문서에 따르면 재시도가 켜진 상태에서 멱등성(enable.idempotence)을 끄고 이 값을 1보다 크게 두면, 같은 파티션으로 간 두 배치 중 첫 번째가 실패해 재시도되는 동안 두 번째가 성공해서 두 번째 배치의 레코드가 먼저 기록될 수 있다.[3.4] 멱등성이 켜져 있으면 이 값이 5 이하인 한 순서가 보존된다.
멱등성은 기본으로 켜져 있지만, 문서에 따르면 충돌하는 설정(예: acks를 all이 아닌 값으로 설정)을 주고 멱등성을 명시적으로 켜지 않으면 조용히 꺼진다.[3.4] 성능 때문에 acks=1로 바꾸면 순서 보장에도 영향을 줄 수 있다는 뜻이다. 멱등성의 원리는 7장에서 본다.
둘째, 서로 다른 프로듀서. 주문 서비스 인스턴스가 여러 대이고, 주문 7번의 생성은 인스턴스 1이, 결제완료는 인스턴스 2가 썼다고 해 보자. 두 이벤트는 같은 키라 같은 파티션에 들어가지만, 파티션에 기록되는 순서는 각 요청이 리더 브로커에 도착해 추가된 순서다. 소개 문서의 그림 설명도 두 프로듀서가 같은 파티션에 쓸 수 있다고 말한다.[3.8] 인스턴스 2의 요청이 네트워크 지연 없이 먼저 도착하면 결제완료가 생성보다 앞 오프셋을 받는다. Kafka는 비즈니스 시각을 보고 순서를 고쳐 주지 않는다. 한 엔터티의 상태 변경은 한 곳에서 순서를 정해 내보내거나, 이벤트에 버전 번호를 넣어 소비자가 뒤바뀐 이벤트를 알아볼 수 있게 해야 한다.
컨슈머가 스스로 순서를 잃는 경우
컨슈머는 오프셋 순서로 레코드를 받는다. 하지만 받은 순서대로 처리하는 것은 애플리케이션의 몫이다. KafkaConsumer Javadoc은 소비 스레드가 받은 레코드를 큐에 넣고 여러 처리 스레드가 가져가는 구조를 소개하면서, 스레드들이 독립적으로 실행되므로 앞 덩어리가 뒤 덩어리보다 늦게 처리될 수 있고 순서를 지키려면 특별한 주의가 필요하다고 적는다. 커밋도 모든 스레드가 해당 파티션의 처리를 마쳤는지 맞춰야 해서 어려워진다.[3.6]
같은 문서가 제시하는 완화책은 처리 스레드마다 큐를 두고 TopicPartition으로 해시해 넣는 방식이다.[3.6] 같은 방식을 키에 적용해, 파티션이 아니라 키로 해시해 넣을 수도 있다. 같은 키는 같은 스레드가 처리하므로 키 단위 순서는 지켜지고, 한 파티션 안의 서로 다른 키는 병렬로 처리된다. 다만 6장에서 볼 커밋 문제가 더 까다로워진다. 한 파티션의 오프셋 100과 101이 서로 다른 스레드에서 처리 중이라면, 101이 먼저 끝났다고 102를 커밋하면 안 된다. 100이 아직 끝나지 않았기 때문이다.
파티션 수를 바꿀 때
2장에서 설명했듯이 파티션을 늘리면 기본 파티셔너의 키 매핑이 달라져 같은 키의 새 이벤트가 다른 파티션으로 갈 수 있고, 기존 데이터는 옮겨지지 않는다.[3.5] 주문 7번의 생성이 옛 파티션 P2에, 결제완료가 새 매핑에 따라 P8에 들어갔다면, 두 이벤트는 이제 서로 다른 파티션에 있다. 둘 사이의 순서는 보장되지 않는다. 소비자가 P8을 먼저 읽으면 결제완료를 접수보다 먼저 본다.
운영 문서는 여기에 두 가지를 더한다. 새 파티션은 메타데이터 갱신 주기(metadata.max.age.ms) 때문에 클라이언트에게 곧바로 보이지 않고, auto.offset.reset=latest인 컨슈머는 새 파티션을 발견하기 전에 그곳에 쓰인 메시지를 건너뛸 수 있다.[3.5] 순서가 뒤집히거나 레코드를 건너뛸 수 있으므로, 키 순서가 중요한 토픽은 처음부터 파티션을 넉넉히 잡거나 새 토픽으로 옮기는 절차를 따로 설계하는 편이 낫다.
순서를 포기하는 선택: share group
모든 작업이 순서를 원하지는 않는다. 이메일 발송처럼 레코드 하나하나를 독립적으로 처리하는 작업은 파티션 수에 묶이지 않고 소비자를 늘리고 싶을 수 있다. Kafka 4.2부터 운영 환경에서 쓸 수 있게 된 share group이 그 용도다.[3.7]
설계 문서가 정리한 차이는 이렇다. share group의 소비자들은 레코드를 협력해서 소비하고 한 파티션이 여러 소비자에게 할당될 수 있으며, 소비자 수가 파티션 수를 넘을 수 있다. 레코드는 하나씩 확인(acknowledge)되며, 전달 시도 횟수를 세어 처리할 수 없는 레코드를 자동으로 다룰 수 있다. 소비자가 가져간 레코드는 시간 제한이 있는 잠금(기본 30초) 동안 같은 그룹의 다른 소비자에게 가지 않으며, 잠금이 풀리면 다시 전달될 수 있다.[3.7] 업그레이드 문서는 share group을 “레코드를 순서 있는 흐름의 일부가 아니라 하나씩 처리하는 경우”에 쓰라고 권한다.[3.7]
같은 파티션의 레코드가 여러 소비자에게 동시에 나가고, 잠금이 풀린 레코드는 나중에 다시 전달되므로, share group에서는 파티션 안의 처리 순서를 기대할 수 없다. 파티션 수보다 넓은 병렬성과 레코드 단위 재시도를 얻는 대신 순서를 포기하는 선택이다.
Kafka가 약속하는 것은 한 파티션 안에서 “오프셋 순서 = 기록 순서 = 모든 소비자가 받는 순서”라는 한 가지다. 파티션 사이의 순서, 즉 토픽 전체의 순서는 약속하지 않는다. 이 순서가 업무 처리까지 이어지려면 네 가지 조건이 필요하다. 프로듀서가 재시도로 순서를 뒤집지 않을 것(멱등성 유지), 한 엔터티를 여러 프로듀서가 경쟁하듯 쓰지 않을 것, 파티션 수가 바뀌지 않을 것, 컨슈머가 병렬 처리로 순서를 섞지 않을 것. 그룹끼리는 서로 영향을 주지 않으므로, 한 그룹이 읽었다고 다른 그룹의 데이터나 순서가 바뀌는 일은 없다.
확인 질문
- 트래픽이 늘어
orders의 파티션을 6개에서 12개로 늘렸다. 늘린 직후 주문 7번의생성이벤트는 옛 매핑의 파티션에,결제완료이벤트는 새 매핑의 다른 파티션에 들어갔다. 알림 서비스 인스턴스들은 무엇을 볼 수 있을까? 알림 서비스가auto.offset.reset=latest라면 추가로 어떤 일이 생길 수 있을까? - 알림 서비스를 share group으로 바꾸고 소비자를 20개로 늘렸다. 예측 질문의 요구 사항(“결제완료 알림이 접수 알림보다 먼저 가면 안 된다”)은 지켜질까?
확인 질문 1 해설
같은 주문의 두 이벤트가 이제 다른 파티션에 있으므로 둘 사이의 순서는 더 이상 보장되지 않는다. 두 파티션이 서로 다른 인스턴스에 할당되어 있으면 결제완료 알림이 먼저 나갈 수 있다. 게다가 새 파티션은 메타데이터가 갱신되고 리밸런스로 할당될 때까지 아무도 읽지 않는다. 그 사이 새 파티션에 쓰인 이벤트는, latest인 컨슈머가 처음 붙는 순간의 끝에서 읽기 시작하면 건너뛰어진다. 순서 역전과 누락이 동시에 생길 수 있는 작업이다.
확인 질문 2 해설
지켜지지 않는다. share group은 같은 파티션의 레코드를 여러 소비자에게 나눠 주고, 레코드를 하나씩 확인하며, 잠금 시간이 지나면 다시 전달한다. 주문 7번의 두 이벤트가 서로 다른 소비자에게 가면 어느 쪽이 먼저 처리를 끝낼지 알 수 없다. 이 요구 사항이 필요하다면 컨슈머 그룹을 유지하고 키 단위 순서 처리를 하거나, 알림 서비스가 주문별 상태 버전을 보고 늦게 온 이벤트를 걸러야 한다.
4장. 브로커가 죽으면 무엇이 남는가 | 로그 · 복제 · ISR · acks
가상 시나리오 · 전원이 나간 리더
결제 서비스는 결제 결과를 payments 토픽에 기록한다. 토픽의 복제 계수(replication factor)는 3이고, min.insync.replicas는 따로 설정하지 않았다. 결제 서비스의 프로듀서 설정에는 몇 년 전 누군가 “지연을 줄이려고” 넣은 acks=1이 남아 있다.
14시 02분 10초, payments 파티션 4의 리더인 브로커 1의 랙에 전원이 나갔다. 브로커 1은 아무 정리도 하지 못하고 즉시 멈췄다. 평소 두 팔로워(브로커 2, 3)는 리더를 몇 밀리초 차이로 따라가고 있었다.
예측 질문
브로커 1이 멈추기 직전 수백 밀리초 동안 결제 서비스가 acks=1로 “성공” 응답을 받은 결제 이벤트는 어떻게 될까? 같은 순간 acks=all이었다면 결과가 달라질까? 달라진다면, 그 차이가 사라지는 조건은 무엇일까?
브로커의 디스크에 남은 데이터, 복제본 사이에서 “커밋”된 데이터, 프로듀서가 성공 응답을 받은 데이터는 구분해야 한다.
장애가 없을 때 복제와 성공은 어떻게 이어지는가
복제 계수 3인 한 파티션이 브로커 1·2·3에 놓이고 모두 정상이라고 하자. “3”은 주문 로그의 복제본을 세 군데 배치한다는 뜻이지 요청 하나가 항상 세 저장장치에 즉시 동기화된다는 뜻은 아니다.
- 프로듀서는 파티션 리더인 브로커 1에 레코드를 보낸다.
- 브로커 1은 자신의 로그 끝에 추가한다. 팔로워인 2·3은 리더에서 새 구간을 가져와 각자의 로그에 추가한다.
- 복제 진척과 최소 ISR 조건을 충족하면 읽을 수 있는 경계가 전진한다. 복제되지 않은 리더의 최신 꼬리는 아직 컨슈머에게 보이지 않을 수 있다.
- 프로듀서가 성공을 듣는 시점은
acks가 정한다. 리더 기록만 기다리는 것과 복제 확인을 기다리는 것은 다르다.
다음 절들은 같은 경로를 세 관점으로 나눈다. 한 머신의 저장층은 페이지 캐시·디스크, 복제본의 참여 상태는 ISR, 읽을 수 있는 범위는 하이 워터마크다. 새 용어를 독립적인 기능 목록이 아니라 같은 주문의 기록 상태로 연결한다.[4.5]
한 브로커 안: 페이지 캐시와 복구
복제는 다른 머신에 로그를 남기고, fsync는 한 머신의 저장장치에 강제로 내려쓴다. 두 작업은 전원 차단 때 보호하는 범위가 다르다.
Kafka 브로커는 받은 데이터를 곧바로 파일 시스템의 로그에 쓰지만, 그때마다 디스크로 강제로 내려쓰지(fsync) 않는다. 설계 문서의 표현으로는 데이터가 “커널의 페이지 캐시로 옮겨질 뿐”이다.[4.1] 디스크로 내려쓰는 시점은 운영 체제의 백그라운드 플러시에 맡긴다.
운영 문서는 Kafka의 내구성이 매 쓰기의 디스크 동기화를 요구하지 않는다고 말한다. 살아 있는 복제본이 남아 있으면 실패한 노드를 그 복제본에서 복구할 수 있기 때문이다. 그래서 애플리케이션 수준의 fsync를 끄는 기본 설정을 권하고, 복제가 주는 보장이 로컬 디스크 동기화보다 강하다고 본다.[4.2] 토픽 설정 flush.messages와 flush.ms의 기본값도 사실상 무한대이며, 문서는 이 값을 건드리지 말고 복제로 내구성을 얻으라고 권한다.[4.3]
그러면 전원이 나간 브로커 1의 디스크에는 무엇이 남을까? 페이지 캐시에만 있고 디스크에 내려가지 못한 꼬리 부분은 사라질 수 있다. 재시작한 브로커는 먼저 로그 복구를 한다. 가장 최근 세그먼트의 메시지를 처음부터 훑으며 크기와 체크섬(CRC)이 맞는지 검사하고, 손상이 발견되면 마지막으로 유효한 오프셋까지 로그를 잘라낸다.[4.4] 구현 문서는 두 종류의 손상을 모두 다뤄야 한다고 설명한다. 쓰다 만 블록이 사라지는 경우와, 파일 크기 정보만 갱신되고 내용은 유효하지 않은 블록이 덧붙는 경우다. 체크섬이 두 번째 경우를 잡아낸다.[4.4]
한 브로커의 전원이 나가면 로그 끝의 일부를 잃을 수 있고, 복구는 “살아남은 앞부분”을 확정할 뿐 잃은 것을 되살리지 않는다. 잃은 데이터는 다른 복제본에서 복구해야 한다.
복제: 리더와 팔로워
Kafka는 토픽의 파티션마다 로그를 여러 서버에 복제한다. 복제의 단위는 파티션이다. 장애가 없을 때 파티션마다 리더(leader) 하나와 0개 이상의 팔로워(follower)가 있고, 리더를 포함한 전체 복제본 수가 복제 계수다. 모든 쓰기는 리더로 간다. 팔로워는 일반 컨슈머처럼 리더에게서 메시지를 가져와(pull) 자기 로그에 적용하며, 팔로워의 로그는 리더의 로그와 오프셋과 순서까지 같다. 물론 어느 순간이든 리더 끝에는 아직 복제되지 않은 메시지가 조금 있을 수 있다.[4.5]
브로커 1이 멈춘 순간에도 리더 로그의 꼬리 몇 밀리초 분량은 팔로워에 아직 없었을 수 있다.
ISR: “살아 있다”를 대신하는 말
장애를 자동으로 처리하려면 “살아 있는 복제본”을 정확히 정의해야 한다. Kafka는 브로커가 두 조건을 모두 만족할 때 “동기화되었다(in sync)”고 부른다.[4.5]
- 컨트롤러(controller, 클러스터 메타데이터를 관리하는 특별한 역할)와 활성 세션을 유지한다. KRaft 클러스터에서는 주기적으로 하트비트를 보내며,
broker.session.timeout.ms(기본 9초) 안에 하트비트가 없으면 오프라인으로 본다.[4.5] [4.6] - 팔로워라면 리더의 쓰기를 복제하며 “너무 멀리” 뒤처지지 않는다. 기준은
replica.lag.time.max.ms(기본 30초)다. 이 시간 동안 가져오기 요청이 없거나 리더의 로그 끝까지 따라잡지 못한 팔로워는 제외된다.[4.5] [4.6]
리더는 이 조건을 만족하는 복제본 집합을 관리하는데, 이것이 ISR(in-sync replicas)이다. 팔로워가 죽으면 컨트롤러가 세션이 끊긴 것을 보고 ISR에서 뺀다. 세션은 살아 있지만 너무 뒤처진 팔로워는 리더가 뺀다.[4.5] ISR이 바뀔 때마다 그 내용은 클러스터 메타데이터에 저장되고, ISR에 있는 복제본만 새 리더가 될 자격이 있다.[4.7]
Kafka가 다루는 것은 노드가 갑자기 멈췄다가 나중에 (자기가 죽었었는지 모른 채) 돌아오는 “실패/복구” 모델이다. 노드가 임의의 값이나 악의적인 응답을 내는 비잔틴 실패는 다루지 않는다.[4.5] 또 Kafka는 죽었던 노드가 데이터를 온전히 가진 채 돌아온다고 가정하지 않는다. ISR에 다시 들어오려는 복제본은 크래시로 플러시되지 않은 데이터를 잃었더라도 먼저 완전히 다시 동기화해야 한다.[4.7] 앞 절에서 본 “디스크 동기화 없이도 안전하다”는 주장은 바로 이 규칙에 기대고 있다.
커밋과 하이 워터마크
“커밋”의 대상부터 구분한다. 3장의 오프셋 커밋은 컨슈머가 다시 읽을 위치를 저장하는 일이다. 여기서 말하는 복제 커밋은 레코드가 복제 조건을 충족해 Kafka 로그에서 읽을 수 있는 상태가 되는 일이다. 정산 DB의 트랜잭션 커밋과도 다르다. 컨슈머가 한 번도 읽지 않았어도 복제 커밋은 가능하다.
메시지는 파티션의 ISR에 있는 모든 복제본이 자기 로그에 적용했을 때 커밋된 것으로 본다.[4.5] 그리고 커밋된 메시지만 컨슈머에게 전달된다. 그래서 컨슈머는 리더가 죽으면 사라질 수도 있는 메시지를 볼 걱정을 하지 않아도 된다.[4.5]
리더 입장에서 커밋된 구간의 끝을 하이 워터마크(high watermark)라고 부른다. 컨슈머는 하이 워터마크 앞까지만 읽을 수 있다. 공식 문서는 이 용어를 트랜잭션 격리 수준과 ELR을 설명하는 자리에서 쓴다.[4.14] 앞의 정의를 따르면 하이 워터마크는 ISR 구성원들이 모두 가진 지점, 즉 복제 확인과 커밋 조건을 충족한 배타적 읽기 경계다. ISR의 복제 진척이 그 전진을 제한하며, 최소 ISR 조건이나 복제 확인 지연 때문에 현재 최소 로그 끝보다 뒤에 머물 수 있다.
프로듀서의 acks 설정과 관계없이, 메시지는 두 조건이 모두 충족되기 전에는 컨슈머에게 보이지 않는다. 모든 ISR 복제본에 복제되었을 것, 그리고 ISR 크기가 min.insync.replicas 이상일 것.[4.5] [4.10]
acks: 프로듀서는 어느 시점에 성공을 듣는가
프로듀서의 acks는 리더가 몇 개의 확인을 받아야 요청을 완료로 볼지 정한다. 기본값은 all이다.[4.8]
acks |
성공 응답 시점 | 문서가 말하는 위험 |
|---|---|---|
0 |
기다리지 않음. 소켓 버퍼에 넣으면 보낸 것으로 간주 | 서버가 받았다는 보장이 없고 재시도도 동작하지 않음. 돌려받는 오프셋은 항상 -1 |
1 |
리더가 자기 로그에 쓰면 | 확인 직후, 팔로워가 복제하기 전에 리더가 죽으면 그 레코드는 사라짐 |
all (-1) |
ISR 전체가 확인하면 | ISR 가운데 하나라도 살아 있는 한 잃지 않음. 가장 강한 보장 |
표의 세 행은 공식 설정 문서를 풀어 쓴 것이다.[4.8] 두 번째 행이 예측 질문의 답이다. acks=1에서 결제 서비스가 받은 “성공”은 “리더의 로그(정확히는 페이지 캐시)에 들어갔다”는 뜻일 뿐이다. 그 레코드가 팔로워에 복제되기 전에 브로커 1이 멈췄다면, 컨트롤러는 ISR에 남은 브로커 2나 3 가운데 하나를 새 리더로 뽑는다.[4.11] 새 리더의 로그에는 그 레코드가 없다.
브로커 1이 나중에 돌아오면 팔로워로 합류한다. 앞에서 본 대로 ISR에 다시 들어가려면 새 리더의 로그에 맞춰 완전히 다시 동기화해야 하므로, 브로커 1에만 있던 꼬리 레코드는 결국 남지 않는다.[4.7] 이 레코드들은 커밋된 적이 없으므로 어떤 컨슈머도 본 적이 없다. 하류 시스템은 일관되게 “그런 결제는 없었다”고 본다. 문제는 결제 서비스만 그 결제가 기록되었다고 믿고 있다는 점이다.
acks=all이었다면 달라진다. 리더는 ISR 전체가 레코드를 받은 뒤에야 성공을 응답하므로, 성공을 들은 레코드는 모두 새 리더 후보(ISR 구성원)에게 이미 있다. 브로커 1이 멈춘 순간 복제되지 않았던 레코드는 아직 성공 응답을 받지 못한 것들이다. 프로듀서는 타임아웃이나 오류를 보고 재시도하며, 멱등성이 켜져 있으면 그 재시도가 중복을 만들지 않는다(7장).
차이가 사라지는 조건: ISR이 줄어든 경우
다만 설계 문서에서 “모든 복제본의 확인”은 할당된 모든 복제본이 아니라 현재 ISR의 모든 복제본의 확인을 뜻한다. 예를 들어 복제본이 두 개인 토픽에서 하나가 실패해 ISR에 하나만 남으면, acks=all 쓰기도 성공한다. 그리고 그 남은 하나마저 실패하면 이 쓰기는 사라질 수 있다.[4.9]
시나리오에서 사고 몇 분 전부터 브로커 2와 3의 디스크가 느려져 30초 넘게 뒤처졌고, 그래서 둘 다 ISR에서 빠져 ISR이 {1}만 남아 있었다고 하자. 이때 acks=all의 “ISR 전체”는 브로커 1 하나다. 성공 응답의 의미가 acks=1과 같아지고, 브로커 1이 멈추면 똑같이 잃는다.
min.insync.replicas: 적게 복제된 쓰기를 거절하기
이때 필요한 최소 복제본 수를 정하는 설정이 min.insync.replicas다. acks=all인 쓰기가 성공하려면 리더를 포함해 최소 몇 개의 동기화 복제본이 필요한지를 정한다. acks=all인데 현재 ISR이 이 값보다 작으면 프로듀서는 NotEnoughReplicas 또는 NotEnoughReplicasAfterAppend 예외를 받는다.[4.10] 설계 문서는 이 설정이 프로듀서가 acks=all을 쓸 때만 효과가 있으며, 값을 높이면 일관성은 좋아지지만 ISR이 기준 아래로 떨어진 동안 그 파티션에 쓸 수 없게 되어 가용성이 떨어진다고 정리한다.[4.9]
문서가 드는 대표적인 조합은 복제 계수 3, min.insync.replicas=2, acks=all이다. 이러면 과반의 복제본이 기록해야 성공으로 보고 컨슈머에게도 보인다.[4.10] 기본값은 1이다.[4.10] 시나리오의 payments 토픽처럼 아무 설정도 하지 않았다면, acks=all로 바꾸더라도 ISR이 리더 하나로 줄어든 순간의 쓰기는 보호받지 못한다.
ISR이 min.insync.replicas보다 작아지면 acks=all 쓰기는 거절된다. 그렇다면 같은 파티션에 acks=1로 쓰는 다른 프로듀서가 있으면 어떻게 될까? acks=1은 이 확인을 하지 않으므로 리더는 쓰기를 받아들인다. 하지만 ISR 크기가 기준을 채우지 못하는 동안에는 그 메시지가 컨슈머에게 보이지 않는다.[4.5] ELR 문서는 이 “엄격한 최소 ISR” 규칙을 ISR이 기준보다 작으면 하이 워터마크가 전진할 수 없다고 표현한다.[4.14] 이때 프로듀서는 쓰기 성공 응답을 받지만 컨슈머는 새 메시지를 받지 못한다. 9장에서 이 조합을 표로 정리한다.
새 리더는 누가, 어떻게 뽑는가
리더 브로커가 실패하면 컨트롤러가 그것을 감지하고 남은 ISR 구성원 가운데 하나를 새 리더로 뽑는다. 파티션이 수천 개라도 컨트롤러가 리더 변경 알림을 묶어서 처리하므로 선출이 빠르다. 컨트롤러 자신이 실패하면 다른 컨트롤러가 선출된다.[4.11] 선출이 끝날 때까지 그 파티션은 쓰기를 받지 못한다. 설계 문서는 Kafka가 노드 장애 시 짧은 장애 조치 시간 뒤 가용성을 유지하지만, 네트워크 분할 상황에서는 그렇지 않을 수 있다고 적는다.[4.5]
Kafka 4.x는 선출 규칙에 ELR(Eligible Leader Replicas)을 더했다. 엄격한 최소 ISR 규칙 아래에서는 ISR에 없더라도 새 리더가 되어도 안전한 복제본이 생긴다. KRaft 컨트롤러는 이런 복제본을 ELR로 기록하고, 선출할 때 ISR → 펜싱되지 않은 ELR 구성원 → 펜싱되지 않은 마지막 리더 순서로 고른다.[4.14] ELR은 4.1부터 새 클러스터에서 기본으로 켜진다. 업그레이드한 클러스터에서는 실제 기능 활성 상태를 확인해야 한다. ELR은 별도의 주문 데이터 사본이 아니라, 이미 배치된 복제본 중 안전한 리더 후보를 추적하는 메타데이터다. 예를 들어 ISR에서 빠졌다는 이유만으로 모든 복제본이 같은 위험을 갖는 것은 아니며, 확정된 구간을 보존한 후보인지가 중요하다.
4.0에서는 기본으로 꺼져 있고 eligible.leader.replicas.version=1로 켤 수 있다.[4.14] ELR이 켜지면 min.insync.replicas는 클러스터 수준에서 관리해야 하는 등 설정 규칙도 바뀌는데, 9장에서 다룬다.
모두 죽었을 때: unclean leader election
ISR 구성원이 모두 죽으면 어떻게 될까? 설계 문서는 Kafka의 손실 방지 보장이 “적어도 하나의 복제본이 동기화된 채 남아 있다”는 전제 위에 있고, 모두 죽으면 그 보장은 더 이상 성립하지 않는다고 분명히 말한다.[4.12] 이때 가능한 선택은 두 가지다.[4.12]
- ISR에 있던 복제본이 돌아오기를 기다려 그것을 리더로 삼는다. 그 복제본들이 돌아오지 못하거나 데이터를 잃었다면 파티션은 계속 사용할 수 없다.
- ISR이 아니더라도 가장 먼저 돌아온 복제본을 리더로 삼는다. 파티션은 바로 쓸 수 있지만, 그 복제본이 모든 커밋된 메시지를 가졌다는 보장이 없는데도 그 로그가 새로운 기준이 된다.
첫째는 일관성을, 둘째는 가용성을 택한다. Kafka는 0.11.0.0부터 기본으로 첫째를 택하며, unclean.leader.election.enable(기본 false)로 바꿀 수 있다.[4.12] [4.13]
KRaft 모드에서 이 설정을 동적으로 켜면 주기적으로 도는 선출 스레드(기본 5분 주기)를 기다려야 하므로, 즉시 필요하면 kafka-leader-election.sh의 unclean 옵션으로 선출을 일으키라고 문서는 안내한다.[4.13] 설계 문서는 이 딜레마가 Kafka만의 것이 아니라 과반수 투표 방식에서도 과반이 영구히 사라지면 똑같이 생긴다고 덧붙인다.[4.12]
계획된 재시작은 다르다
전원 차단과 달리 운영자가 브로커를 정상 종료하면 두 가지 최적화가 일어난다. 모든 로그를 디스크에 동기화해 재시작 시 로그 복구를 건너뛰고, 자신이 리더인 파티션의 리더십을 다른 복제본으로 먼저 넘겨 각 파티션이 쓰기를 받지 못하는 시간을 몇 밀리초로 줄인다.[4.15] 다만 리더십 이전은 그 브로커가 가진 모든 파티션에 살아 있는 다른 복제본이 있을 때만 성공한다.[4.15] 복제 계수 1인 토픽이 섞여 있으면 롤링 재시작마다 그 파티션은 멈춘다.
보장이 성립하는 범위
- 문서의 보장: 커밋된 메시지는 동기화된 복제본이 하나라도 살아 있는 한 사라지지 않는다.
- 커밋의 정의는 “현재 ISR 전체가 받음”이다. ISR이 줄면 커밋의 문턱도 함께 낮아진다.
acks=1의 성공은 커밋이 아니다.acks=all의 성공은 커밋이지만, ISR 크기를min.insync.replicas로 받치지 않으면 한 복제본짜리 커밋일 수 있다.- 모든 ISR이 사라지면 보장은 끝나고, 기다릴지(가용성 포기) 비동기화 복제본을 쓸지(일관성 포기)를 골라야 한다.
- 비잔틴 실패(잘못된 값을 내는 노드)는 다루지 않는다.
확인 질문
payments를 복제 계수 3,min.insync.replicas=2로 바꾸고 결제 서비스도acks=all로 고쳤다. 어느 날 브로커 2와 3이 동시에 네트워크에서 떨어져 나갔다. 결제 서비스는 무엇을 보게 될까? 같은 파티션을 읽는 정산 배치는? 그리고 같은 토픽에acks=1로 쓰는 로그 수집기가 있었다면 그 로그는 어떻게 될까?- 복제 계수 2,
min.insync.replicas=1,acks=all인 토픽이 있다. 팔로워가 고장으로 내려간 사이 리더가 계속 쓰기를 받았고, 이어서 리더의 디스크가 완전히 망가졌다.unclean.leader.election.enable이false일 때와true일 때 각각 무슨 일이 생길까?
확인 질문 1 해설
브로커 2, 3이 떨어져 나가면 ISR에는 리더 하나만 남는다. ISR 크기 1은 min.insync.replicas=2보다 작으므로, 결제 서비스의 acks=all 쓰기는 NotEnoughReplicas 계열 오류로 거절된다. 프로듀서는 재시도를 계속하다가 delivery.timeout.ms(기본 2분)를 넘기면 애플리케이션에 실패를 알린다(9장). 결제 서비스 입장에서는 쓰기 불가 장애다. 대신 이미 성공 응답을 받은 결제는 하나도 잃지 않는다.
정산 배치는 그 전에 커밋된 데이터를 계속 읽을 수 있다. 다만 새 데이터가 커밋되지 않으니 곧 읽을 것이 없어진다.
acks=1 로그 수집기의 쓰기는 리더가 받아들인다. 하지만 ISR 크기가 기준을 채우지 못하는 동안 하이 워터마크가 전진하지 못하므로 그 로그는 컨슈머에게 보이지 않는다. 브로커들이 돌아와 ISR이 복구되기 전에 리더마저 잃으면 이 로그는 사라진다.
확인 질문 2 해설
팔로워가 내려가면 ISR은 리더 하나가 되고, min.insync.replicas=1이므로 acks=all 쓰기가 계속 성공한다. 이 쓰기들은 한 복제본에만 있다. 이제 리더의 디스크가 망가지면 ISR 구성원이 모두 사라진다.
false(기본값)이면 Kafka는 ISR이던 리더가 돌아오기를 기다린다. 디스크가 완전히 망가져 돌아올 수 없으니 파티션은 계속 쓸 수 없다. 데이터 손실을 인정하지 않는 대신 가용성을 포기한 결과다.
true이면 먼저 돌아온 팔로워가 리더가 된다. 파티션은 다시 쓸 수 있지만, 팔로워가 내려가 있던 동안 성공 응답을 받은 쓰기는 그 로그에 없으므로 사라진다. 두 경우 모두 “커밋된 메시지를 잃지 않는다”는 보장은 이미 깨져 있다. 보장의 전제(동기화 복제본 하나 이상 생존)가 무너졌기 때문이다.
5장. 컨슈머가 재시작하면 어디서부터 읽는가 | 그룹 · 오프셋 커밋 · 리밸런스
가상 시나리오 · 저녁 피크의 OOM
알림 서비스는 인스턴스 세 대가 그룹 notify로 orders의 파티션 여섯 개를 두 개씩 나눠 읽는다. 컨슈머 설정은 기본값이다. 자동 커밋이 켜져 있고, 그룹 프로토콜도 기본값인 classic이다.
저녁 피크에 인스턴스 2가 메모리 부족으로 운영 체제에 의해 즉시 종료되었다(OOM kill). 종료 신호를 처리할 틈도 없었다. 몇 분 뒤 모니터링에는 두 가지가 보였다. 인스턴스 2가 맡던 두 파티션의 소비 지연이 한동안 치솟았다가 내려갔고, 일부 사용자는 같은 “결제 완료” 푸시를 두 번 받았다.
예측 질문
인스턴스 2가 맡던 파티션은 언제, 누가, 어느 오프셋부터 다시 읽기 시작할까? 그리고 그 결과로 어떤 알림이 두 번 나가고, 어떤 알림은 빠질 수 있을까?
두 가지 위치: position과 커밋된 오프셋
KafkaConsumer Javadoc은 사용자가 알아야 할 위치가 사실 두 가지라고 설명한다.[5.1]
- 위치(position)는 다음에 내줄 레코드의 오프셋이다. 컨슈머가 그 파티션에서 본 가장 큰 오프셋보다 1 크고,
poll()로 레코드를 받을 때마다 자동으로 앞으로 간다. - 커밋된 위치(committed position)는 안전하게 저장해 둔 마지막 오프셋이다. 프로세스가 죽었다가 다시 시작하면 이 위치로 돌아간다.
위치는 컨슈머 프로세스의 메모리에만 있다. 프로세스가 죽으면 함께 사라진다. 살아남는 것은 커밋된 위치뿐이다. 새로 파티션을 맡은 컨슈머는 인스턴스 2가 실제로 어디까지 처리했는지 알 방법이 없고, 인스턴스 2가 마지막으로 커밋한 지점에서 시작한다.
커밋할 값에도 규칙이 있다. 커밋하는 오프셋은 “마지막으로 처리한 레코드의 오프셋”이 아니라 애플리케이션이 다음에 소비할 레코드의 오프셋이어야 한다.[5.1] 오프셋 43까지 처리했다면 44를 커밋한다. 2장에서 본 “위치는 다음 것을 가리킨다”는 규칙과 같다. 43을 커밋하면 재시작할 때마다 레코드 하나씩 중복된다.
커밋은 어디에 저장되는가
업무 레코드를 읽는 브로커와 커밋을 받는 코디네이터는 같을 필요가 없다. 따라서 읽기가 계속돼도 커밋은 실패할 수 있다. 이때 메모리의 읽기 위치는 전진하지만 재시작 지점은 마지막 성공한 커밋에 남는다.
컨슈머 그룹마다 오프셋을 관리하는 브로커가 하나 정해지는데, 이를 그룹 코디네이터(group coordinator)라고 한다. 그룹은 이름을 기준으로 코디네이터에 배정되고, 컨슈머는 아무 브로커에게나 FindCoordinator 요청을 보내 자기 코디네이터를 찾는다. 코디네이터가 옮겨 가면 다시 찾아야 한다.[5.2]
코디네이터는 커밋 요청을 받으면 그것을 __consumer_offsets라는 특별한 컴팩션된 내부 토픽에 덧붙인다. 성공 응답은 이 오프셋 토픽의 모든 복제본이 커밋을 받은 뒤에 보낸다.
제한 시간 안에 복제되지 않으면 커밋은 실패하고, 컨슈머는 잠시 뒤 다시 시도할 수 있다.[5.2] 이 제한 시간이 브로커 설정 offsets.commit.timeout.ms(기본 5초)다.[5.4] 오프셋 토픽의 기본 복제 계수는 3이다.[5.4] 커밋 역시 4장에서 본 복제 규칙을 따르는 쓰기라는 뜻이다.
__consumer_offsets는 파티션별로 가장 최근 커밋만 있으면 되므로 주기적으로 컴팩션되고(8장), 코디네이터는 빠른 조회를 위해 오프셋을 메모리에도 캐시한다. 코디네이터가 막 시작했거나 새로 코디네이터 역할을 맡은 직후라면 오프셋 토픽을 메모리로 읽어 들이는 동안 오프셋 조회가 CoordinatorLoadInProgressException으로 실패할 수 있고, 컨슈머는 잠시 뒤 다시 시도한다.[5.2] 코디네이터 브로커가 재시작하는 순간 컨슈머 로그에 이런 예외가 잠깐 찍히는 것은 정상이다.
먼저 정상 처리와 재시작을 숫자로 따라가기
다음은 오프셋이 연속인 P0 하나의 가상 예다. 수동 커밋을 사용하고, 알림 API의 성공 여부를 확인한 뒤 커밋한다고 가정한다.
| 단계 | 메모리 position | 저장된 복구 위치 | 알림 업무 |
|---|---|---|---|
| 시작 | 40 | 40 | 40부터 처리해야 한다 |
poll()로 40·41을 받음 |
42 | 40 | 레코드를 받았을 뿐 아직 발송 완료가 아니다 |
| 두 알림 발송을 완료 | 42 | 40 | 발송은 끝났지만 지금 죽으면 40부터 재전달될 수 있다 |
| 다음 위치 42의 커밋 성공 | 42 | 42 | 재시작하면 42부터 이어 읽는다 |
Position과 커밋 위치가 숫자로 같아지는 것은 읽기와 업무 완료를 자동 연결해서가 아니라 애플리케이션이 완료 뒤 위치를 저장했기 때문이다. 자동 커밋은 이 순서를 코드가 직접 호출하는 방식과 다르게 관리한다. 그 차이를 다음 절에서 본다.
자동 커밋과 수동 커밋
커밋을 언제 할지는 두 방식 중에서 고른다. 기본값은 자동 커밋이다. enable.auto.commit=true이면 컨슈머가 주기적으로 오프셋을 커밋하도록 관리하며, 주기는 auto.commit.interval.ms(기본 5초)다.[5.3] 수동 방식에서는 애플리케이션이 commitSync()나 commitAsync()를 직접 호출한다.[5.1]
자동 커밋이 커밋하는 것은 “처리한 것”이 아니라 “받은 것”에 가깝다. Javadoc은 자동 커밋으로도 at-least-once(적어도 한 번) 전달을 얻을 수 있지만, 조건이 있다고 적는다.
한 번의 poll()이 돌려준 데이터를 다음 poll()을 호출하거나 컨슈머를 닫기 전에 모두 처리해야 한다. 그러지 않으면 커밋된 오프셋이 실제 소비한 위치를 앞질러 레코드가 빠질 수 있다.[5.1] 알림 서비스처럼 poll() 루프 안에서 받은 레코드를 순서대로 모두 처리한 뒤 다음 호출로 넘어간다면, 대략 마지막 커밋 주기 동안 처리한 레코드는 다시 처리될 수 있다. 조건에 따른 실패 창은 6장에서 다룬다.
멤버가 사라졌다는 것을 어떻게 아는가
그룹은 멤버가 살아 있는지를 두 가지 방법으로 확인한다.[5.1]
첫째는 하트비트와 세션 타임아웃이다. 컨슈머는 백그라운드에서 주기적으로 하트비트를 보낸다. 컨슈머가 죽었거나 세션 타임아웃 동안 하트비트를 보내지 못하면 죽은 것으로 보고 그 파티션을 다른 멤버에게 넘긴다. classic 프로토콜에서 세션 타임아웃은 컨슈머 설정 session.timeout.ms(기본 45초), 하트비트 주기는 heartbeat.interval.ms(기본 3초)다.[5.3] 새 consumer 프로토콜에서는 이 두 값을 브로커가 정한다.
group.consumer.session.timeout.ms(기본 45초)와 group.consumer.heartbeat.interval.ms(기본 5초)다.[5.4] [5.5]
둘째는 poll 간격이다. 하트비트는 계속 가는데 처리가 멈춰 있는 “라이브락”을 막기 위해, poll()을 max.poll.interval.ms(기본 5분)보다 오래 호출하지 않으면 컨슈머가 스스로 그룹을 떠나 다른 멤버가 파티션을 가져가게 한다. 이때 그 컨슈머가 뒤늦게 commitSync()를 부르면 CommitFailedException이 난다.
문서는 이것을 그룹의 활성 멤버만 오프셋을 커밋할 수 있게 하는 안전장치라고 설명한다.[5.1] [5.3] 한 번의 poll()이 돌려주는 최대 레코드 수 max.poll.records(기본 500)는 이 간격과 함께 조정하는 값이다.[5.3]
시나리오의 인스턴스 2는 첫째 경우다. OOM으로 프로세스가 즉시 사라졌으니 하트비트가 끊긴다. 코디네이터는 곧바로 알 수 없다. 세션 타임아웃 45초가 지나야 인스턴스 2를 그룹에서 제거한다. 그동안 인스턴스 2의 두 파티션은 아무도 읽지 않는다. 모니터링에 보인 소비 지연의 급등이 이 45초다.
인계할 때 위치와 업무는 어떻게 이어지는가
동적 멤버 C2가 P0을 맡다가 사라지고, 새 멤버 C1에게 P0이 할당된다고 하자. 그룹은 멤버의 부재를 판정하고 할당을 바꾼다. C1은 코디네이터에서 그 그룹·그 파티션의 커밋 위치를 조회하고 그 위치부터 읽는다. C2의 메모리 position이나 푸시 API의 실제 완료 내역을 복사받는 것은 아니다.
C2가 앞의 예에서 알림 발송까지 끝내고 42를 커밋하기 전에 죽었다면, 저장된 위치는 40이다. C1은 40·41을 다시 받아 알림을 중복 발송할 수 있다. 이것은 Kafka가 로그의 순서를 뒤집어서가 아니라 업무 완료와 복구 위치 저장의 시점이 달라서 생긴다. 6장에서는 이 두 단계 사이의 실패를 본격적으로 다룬다.
유효한 커밋 위치가 처음부터 없거나 보존된 로그 범위 밖이라면 정상적인 이어 읽기를 할 수 없다. 이때의 auto.offset.reset 선택은 8장에서 설명한다. 유효한 커밋이 있는데도 재시작할 때마다 이 설정으로 시작점을 고르는 것은 아니다.[5.1]
리밸런스: 파티션을 다시 나누는 과정
멤버가 들어오거나 나가면 그룹은 파티션 할당을 다시 계산한다. 이것을 리밸런스(rebalance)라고 한다. Kafka 4.3에는 리밸런스 프로토콜이 두 가지 있다.
classic 프로토콜은 오래 써 온 방식이다. 할당 전략이 클라이언트 쪽에 있고, 기본 전략 목록은 [RangeAssignor, CooperativeStickyAssignor]다. 기본으로 RangeAssignor를 쓰되, 롤링 재시작 한 번으로 CooperativeStickyAssignor로 옮겨 갈 수 있게 해 둔 구성이다.[5.3] 컨슈머의 group.protocol 기본값은 여전히 classic이다.[5.3]
consumer 프로토콜(KIP-848)은 4.0부터 정식(GA)이 된 차세대 프로토콜이다. 문서는 이 프로토콜이 그룹의 확장성을 높이고 컨슈머를 단순하게 만들며, 전역 동기화 장벽에 더 이상 의존하지 않는 완전 점진적 설계 덕분에 리밸런스 시간을 줄인다고 소개한다.[5.5] 주요 차이는 이렇다.[5.5]
- 서버 쪽은 4.0부터 자동으로 켜져 있지만(피처 플래그
group.version), 클라이언트는group.protocol=consumer를 설정해야 쓴다. - 하트비트 주기와 세션 타임아웃, 할당 전략을 브로커가 정한다. 기본 할당기는
uniform이고range도 제공된다. 클라이언트는group.remote.assignor로 다른 것을 고를 수 있다. - 이 프로토콜을 쓰면
heartbeat.interval.ms,session.timeout.ms,partition.assignment.strategy,enforceRebalance()를 더 이상 쓸 수 없다. - 클라이언트 쪽 할당기는 지원하지 않고, 랙 인식 할당은 아직 완전히 지원되지 않는다.
- 운영 중 전환(온라인)은 롤링 배포로 가능하지만, 기존
classic그룹이 사용자 정의 메타데이터를 담는 할당기를 쓰고 있지 않을 때만 된다. 그렇지 않으면 그룹을 비운 뒤 새 설정으로 다시 띄우는 오프라인 전환을 해야 한다.
문서에 적힌 향후 계획은 5.0에서 KafkaConsumer의 기본값을 consumer 프로토콜로 바꾸고, 6.0에서는 클라이언트가 consumer 프로토콜만 지원하는 것이다.[5.5] 아직 “예정”이므로, 새로 만드는 서비스는 프로토콜 전환 때 어떤 설정을 더 이상 쓸 수 없는지 확인해야 한다.
어느 프로토콜이든 리밸런스가 끝나면 파티션을 새로 맡은 컨슈머는 그 파티션의 커밋된 오프셋을 코디네이터에게서 가져와 그 지점부터 읽는다.[5.1] [5.2]
정상 종료, 비정상 종료, 정적 멤버십
같은 “재시작”이라도 어떻게 멈췄는지에 따라 결과가 크게 달라진다.
정상 종료. 애플리케이션이 종료 신호를 받아 close()를 호출하면, 컨슈머는 기본 “그룹 떠나기” 동작을 하면서 자동 커밋이 켜져 있으면 현재 오프셋을 커밋하려고 시도한다.[5.1] 그룹을 명시적으로 떠나므로 코디네이터는 세션 타임아웃을 기다리지 않고 바로 리밸런스를 시작한다. 파티션이 멈춰 있는 시간도, 다시 처리되는 레코드도 줄어든다. 리밸런스로 파티션을 잃기 직전에 오프셋을 커밋하고 싶다면 ConsumerRebalanceListener.onPartitionsRevoked()를 구현하는 방법도 있다.[5.1]
비정상 종료. 시나리오처럼 프로세스가 즉시 사라지면 커밋할 기회도, 그룹을 떠날 기회도 없다. 세션 타임아웃만큼 파티션이 멈추고, 마지막 자동 커밋 이후 처리한 레코드는 다시 처리된다.
정적 멤버십. 배포처럼 “곧 돌아올” 재시작마다 리밸런스가 일어나는 것이 부담스럽다면 정적 멤버십(static membership)을 쓸 수 있다. 설계 문서는 코디네이터가 멤버에게 주는 ID가 재시작할 때마다 바뀌는 “동적 멤버십” 때문에 코드 배포나 설정 변경, 주기적 재시작 때마다 많은 작업이 다른 인스턴스로 옮겨 가고, 큰 상태를 가진 애플리케이션은 상태를 복구하느라 오래 쓸 수 없게 된다고 설명한다. 멤버가 고정 ID를 제공하면 그 ID를 기준으로 멤버십이 유지되어 리밸런스가 일어나지 않는다.[5.6]
컨슈머 설정으로는 group.instance.id에 인스턴스마다 고유한 값을 준다. 이 값을 가진 인스턴스는 그룹 안에 동시에 하나만 있을 수 있고, 더 큰 세션 타임아웃과 함께 쓰면 프로세스 재시작 같은 일시적 부재로 인한 리밸런스를 피할 수 있다.[5.3]
정적 멤버십에도 대가가 있다. 실수로 두 인스턴스에 같은 ID를 주면 브로커가 중복 클라이언트에게 FencedInstanceIdException을 보내 즉시 종료하게 한다.[5.6] 또 정적 멤버가 max.poll.interval.ms를 넘기면 파티션이 즉시 재할당되지 않고, 하트비트를 멈춘 뒤 세션 타임아웃이 지나야 재할당된다.[5.3] 리밸런스를 줄인 대신, 정말로 죽은 멤버의 파티션도 세션 타임아웃만큼 오래 멈춘다. 세션 타임아웃을 크게 잡을수록 이 멈춤도 길어진다.
정리: 재시작 뒤 어디서부터 읽는가
| 상황 | 파티션이 멈추는 시간 | 다시 읽는 지점 | 결과 |
|---|---|---|---|
정상 종료(close()) |
짧음(즉시 리밸런스) | 종료 직전 커밋 | 중복 적음 |
| 비정상 종료, 동적 멤버 | 세션 타임아웃(기본 45초) + 리밸런스 | 마지막 자동/수동 커밋 | 마지막 커밋 이후 처리분 중복 |
| 비정상 종료, 정적 멤버, 타임아웃 안에 복귀 | 복귀할 때까지 | 마지막 커밋 | 리밸런스 없음, 같은 멤버가 이어 읽음 |
max.poll.interval.ms 초과(동적) |
즉시 그룹 이탈 + 리밸런스 | 마지막 커밋 | 늦게 끝난 처리의 커밋 실패, 다른 멤버가 재처리 |
확인 질문
- 알림 서비스에 정적 멤버십을 적용해
group.instance.id를 인스턴스마다 주고, 브로커의 세션 타임아웃은 기본값 45초로 두었다. 이번에는 인스턴스 2가 OOM으로 죽은 뒤 쿠버네티스가 20초 만에 같은 ID로 다시 띄웠다. 두 파티션은 어떻게 될까? 인스턴스 2가 90초 뒤에 돌아왔다면? - 알림 서비스가 외부 푸시 게이트웨이 장애로 한 번의
poll()에서 받은 500개 레코드를 처리하는 데 6분이 걸렸다. 설정은 기본값이다. 이 인스턴스와 그룹에는 어떤 일이 연달아 일어날까?
확인 질문 1 해설
20초 만에 같은 ID로 돌아오면, 세션 타임아웃 45초가 지나기 전이므로 코디네이터는 인스턴스 2를 그룹에서 제거하지 않았다. 리밸런스가 일어나지 않고 인스턴스 2가 그대로 두 파티션을 다시 맡는다. 두 파티션은 약 20초 동안 멈췄다가, 인스턴스 2의 마지막 커밋 지점부터 이어서 읽힌다. 중복의 범위는 동적 멤버일 때와 같다(마지막 커밋 이후 처리분). 달라지는 것은 다른 인스턴스의 할당이 흔들리지 않는다는 점이다.
90초 뒤에 돌아오면 45초 시점에 세션이 만료되어 인스턴스 2가 제거되고 리밸런스가 일어난다. 이후는 예측 질문의 흐름과 같고, 인스턴스 2가 돌아오면 다시 한 번 리밸런스가 일어나 파티션이 재분배된다.
확인 질문 2 해설
6분은 max.poll.interval.ms 기본값 5분을 넘는다. 5분이 지나는 순간 이 컨슈머는 스스로 그룹을 떠나고 리밸런스가 일어나 다른 인스턴스가 파티션을 가져간다. 새 담당자는 마지막 커밋 지점부터, 즉 문제의 500개 레코드를 처음부터 다시 처리한다. 원래 인스턴스는 6분 뒤 처리를 마치지만 이미 그룹의 활성 멤버가 아니므로 그 처리분의 커밋은 받아들여지지 않는다(수동 commitSync()였다면 CommitFailedException이 보인다). 이 인스턴스는 다음 poll()에서 그룹에 다시 들어오며 또 한 번 리밸런스를 일으킨다.
게이트웨이가 계속 느리면 새 담당자도 5분을 넘겨 같은 일이 반복된다. 리밸런스가 끝없이 이어지고 같은 레코드가 여러 번 처리되는 “리밸런스 폭풍”이다. max.poll.records를 줄이거나, 외부 호출에 타임아웃을 두거나, max.poll.interval.ms를 처리 시간에 맞게 늘리는 것이 대응책이다. 마지막 방법은 정말로 멈춘 컨슈머를 알아차리는 시간도 함께 늘린다.
6장. 중복과 누락은 어디서 생기는가 | 처리와 커밋 사이의 실패 창
5장에서 오프셋 커밋을 재시작 지점의 저장으로 배웠다. 먼저 장애가 없는 소비를 따라가 보자. 포인트 컨슈머는 주문 완료 레코드를 받고, DB에 적립을 확정한 뒤, 그 레코드 다음 위치를 Kafka에 커밋한다. 다음 poll()은 그 뒤를 읽고, 재시작한 컨슈머도 마지막으로 저장한 위치에서 이어간다. Kafka는 포인트 잔액을 조회해서 커밋해 주지 않는다. 어느 위치가 처리 완료를 뜻하는지는 애플리케이션이 정한다.[6.2]
이 장에서 처리 완료는 메모리 계산이나 DB 요청 전송이 아니라, 예시의 DB 트랜잭션이 성공한 시점으로 둔다. 중복은 같은 업무 효과를 다시 반영하는 것, 누락은 처리해야 할 효과가 반영되지 않았는데 복구 경로에서도 건너뛰는 것이다. 레코드가 로그에 한 번 저장됐어도 컨슈머는 여러 번 실행할 수 있다. 따라서 “몇 번 전달되는가”와 “잔액이 몇 번 바뀌는가”를 나누어 보아야 한다.
가상 시나리오 · 두 가지 포인트 적립 코드
포인트 서비스는 orders에서 “주문 완료” 이벤트를 읽어 회원 포인트를 500점씩 적립한다. 적립은 데이터베이스에서 balance = balance + 500을 실행하는 방식이다. 자동 커밋은 끄고 수동으로 커밋한다. 한 번에 최대 500개 레코드를 받는다.
지난달 중복 적립 민원이 몇 건 들어오자, 한 개발자가 “커밋을 먼저 하면 중복이 안 생긴다”며 코드를 바꿨다. 그래서 지금 저장소에는 두 가지 버전이 있다.
- 버전 A:
poll()→commitSync()→ 500개 적립 - 버전 B:
poll()→ 500개 적립 →commitSync()
예측 질문
두 버전 모두 배치의 300번째 레코드를 적립하던 도중 프로세스가 죽고, 다른 인스턴스가 파티션을 넘겨받았다. 버전 A와 B 각각에서 어떤 레코드가 한 번도 적립되지 않고, 어떤 레코드가 두 번 적립될까? 회원의 포인트 잔액에는 어떻게 드러날까?
5장에서 설명했듯이 새 담당 컨슈머는 마지막으로 커밋된 오프셋부터 읽는다. “처리한 범위”와 “커밋한 범위”가 어긋난 동안 장애가 나면 중복이나 누락이 생긴다. 이 시간 구간을 실패 창(failure window)이라고 한다.
두 번의 쓰기, 두 개의 저장소
레코드 하나를 처리하는 컨슈머는 사실 서로 다른 두 곳에 쓴다. 처리 결과는 데이터베이스(또는 다른 토픽, 외부 API)에 쓰고, 위치는 Kafka의 __consumer_offsets에 커밋한다. 두 쓰기를 하나로 묶어 주는 장치가 없는 한, 둘 사이에는 반드시 순서가 있고 그 사이에 프로세스가 죽을 수 있다.
설계 문서는 이 상황을 두 가지 선택으로 정리한다.[6.1]
- 읽고, 위치를 저장하고, 처리한다. 위치를 저장한 뒤 처리 결과를 저장하기 전에 죽으면, 넘겨받은 프로세스는 저장된 위치부터 시작하므로 그 앞의 몇 개는 처리되지 않는다. 이것이 at-most-once(많아야 한 번)다.
- 읽고, 처리하고, 위치를 저장한다. 처리한 뒤 위치를 저장하기 전에 죽으면, 넘겨받은 프로세스가 받는 처음 몇 개는 이미 처리된 것이다. 이것이 at-least-once(적어도 한 번)다.
버전 A는 첫째, 버전 B는 둘째다. 순서를 바꿨다고 실패 창이 사라지지 않는다. 장애 때 생기는 결과가 중복에서 누락으로 바뀔 뿐이다. 설계 문서는 기본 조합(재시도하는 프로듀서와 처리 뒤 커밋하는 컨슈머)이 at-least-once를 주고, at-most-once는 프로듀서 재시도를 끄고 처리 전에 커밋해서 “구현할 수 있다”고 표현한다.[6.1] 둘 다 선택지일 뿐, 어느 쪽도 “정확히 한 번”이 아니다.
커밋의 단위가 중복의 크기를 정한다
예측 질문에서는 배치 500개를 받아 300번째를 처리하다 죽었다.
- 버전 A는
poll()직후 배치 전체의 끝(501번째에 해당하는 오프셋)을 커밋했다. 새 담당자는 그 지점부터 읽는다. 301번째부터 500번째까지 200개는 한 번도 적립되지 않는다. 처리 도중이던 300번째는 데이터베이스 쓰기가 끝났는지에 따라 적립되었거나 빠진다. 해당 회원들의 잔액이 500점씩 모자란다. - 버전 B는 배치를 다 처리한 뒤에만 커밋하므로, 직전 배치의 끝이 마지막 커밋이다. 새 담당자는 이번 배치의 첫 레코드부터 다시 읽는다. 이미 적립한 1~299번째가 한 번 더 적립된다. 300번째는 죽은 순간 데이터베이스 쓰기가 끝났는지에 따라 한 번 또는 두 번 적립된다.
KafkaConsumer Javadoc의 수동 커밋 예제가 정확히 버전 B다. 레코드를 모아 데이터베이스에 넣은 뒤에만 커밋하면, 넣은 직후와 커밋 직전 사이(몇 밀리초라도)에 죽는 경우 넘겨받은 프로세스가 마지막 배치를 다시 넣게 되며, 이것이 흔히 at-least-once라고 부르는 보장이라고 문서는 설명한다.[6.2]
첫째, 재시작 때 중복될 수 있는 범위는 마지막 성공한 커밋 이후의 처리량이다. 각 배치의 커밋 성공을 확인하고 다음 배치로 넘어가는 단순한 처리에서는 최대 한 배치, 레코드별로 같은 순서를 지키면 최대 한 개가 재처리될 수 있다. 커밋 오류 뒤에도 처리를 계속하면 그 범위는 더 커진다. 대신 커밋을 자주 할수록 5장에서 본 복제된 쓰기(오프셋 토픽)가 늘어나 처리량이 떨어진다. 둘째, 커밋 빈도를 아무리 높여도 창의 폭이 0이 되지는 않는다. 처리 결과를 쓰는 일과 커밋하는 일이 서로 다른 시스템에 대한 두 번의 쓰기인 한 그렇다.
가상 예시 · 숫자로 잡는 커밋 경계
P0의 커밋된 오프셋이 100이고, 한 poll()에서 오프셋 100·101·102를 받았다고 하자. 읽기 위치는 103으로 갔지만, DB에 100과 101만 성공했다면 처리 완료 경계는 102다. 세 숫자는 같은 의미가 아니다.
| 경계 | 값 | 재시작 또는 처리에서 뜻하는 것 |
|---|---|---|
| 마지막 성공한 Kafka 커밋 | 100 | 지금 죽으면 100부터 다시 읽는다 |
| 빈틈없이 DB 반영을 끝낸 다음 위치 | 102 | 100·101은 완료했고 102는 아직 남았다 |
poll() 뒤 메모리 위치 |
103 | 100·101·102를 받았다는 뜻이지 모두 처리했다는 뜻은 아니다 |
이때 인자 없는 commitSync()로 받은 배치의 현재 위치를 커밋하면, 미완료인 102까지 건너뛸 수 있다. 일부만 처리했다면 파티션별로 완료 경계를 지정한 커밋이 필요하다. 병렬 처리에서 102가 먼저 끝났어도 101이 남아 있다면 그 틈을 넘겨 커밋해서는 안 된다. 이 예시는 오프셋이 연속인 단순 로그를 가정했다. 실제 로그에는 빈 번호가 있을 수 있으므로, 배치를 모두 처리했을 때의 다음 위치는 Kafka 4.3의 ConsumerRecords.nextOffsets() 등 반환된 위치 정보를 이용해 확인한다.[6.2]
반대로 처리 뒤 커밋하는 구조에서 커밋 오류가 지속되면, “직전 한 배치”가 아니라 마지막 성공한 커밋 이후의 여러 배치가 재처리 대상이 된다. 아래의 커밋 빈도와 자동 커밋 설명도 요청 횟수가 아닌 성공한 저장 경계를 기준으로 읽는다.
자동 커밋은 어느 쪽인가
5장에서 자동 커밋이 at-least-once를 주려면 “한 번의 poll()이 돌려준 데이터를 다음 poll()이나 close() 전에 모두 처리해야 한다”는 조건이 있다고 했다.[6.2]
Javadoc은 자동 커밋을 켜면 레코드가 poll()에서 사용자에게 반환된 뒤 소비된 것으로 간주된다고 적는다.[6.2] 그래서 두 가지 코드 구조에서 결과가 갈린다.
poll()로 받은 레코드를 루프 안에서 순서대로 다 처리하고 다음poll()을 부르는 구조라면, 커밋되는 범위는 이미 처리한 범위다. 죽으면 마지막 커밋 이후 처리분이 중복된다(at-least-once).- 받은 레코드를 메모리 버퍼에 모아 두었다가 나중에 한꺼번에 처리하거나, 다른 스레드에 넘기고 곧장 다음
poll()을 부르는 구조라면, 커밋이 처리를 앞지른다. 버퍼에 있던 레코드를 처리하기 전에 죽으면 그 레코드는 이미 커밋되어 다시 오지 않는다. Javadoc도 레코드를 모았다가 데이터베이스에 넣는 예에서 자동 커밋을 쓰면 넣기 전에 실패할 경우 문제가 된다고 지적한다.[6.2]
즉 자동 커밋은 코드 구조에 따라 at-least-once도 되고, 3장에서 본 “처리 스레드 풀” 구조에서는 누락을 만드는 장치도 된다. 자동 커밋을 쓸지 말지보다 “커밋이 가리키는 범위가 처리된 범위 안에 있는가”를 먼저 확인해야 한다.
프로듀서 쪽의 실패 창: 보냈는지 모르는 순간
실패 창은 소비 쪽에만 있지 않다. 설계 문서는 프로듀서가 메시지를 보내다 네트워크 오류를 겪으면 그 오류가 커밋 전에 난 것인지 후에 난 것인지 알 수 없다고 설명한다.
자동 증가 키가 있는 데이터베이스 테이블에 삽입하다 연결이 끊긴 상황과 같다.[6.1] 이때 할 수 있는 일은 다시 보내는 것뿐이고, 원래 요청이 성공했었다면 같은 메시지가 두 번 기록된다. 0.11.0.0 이전의 프로듀서가 at-least-once였던 이유다.[6.1]
지금의 프로듀서는 이 창을 대부분 스스로 다룬다. retries의 기본값은 사실상 무한대(2147483647)이고, 일시적 오류를 받으면 같은 레코드를 다시 보낸다.
문서는 재시도 횟수보다 delivery.timeout.ms로 재시도에 쓸 시간을 조절하라고 권한다.[6.3] delivery.timeout.ms(기본 2분)는 send()가 돌아온 뒤 성공이나 실패를 알리기까지의 상한으로, 전송 전 대기, 브로커 확인 대기, 재시도에 쓰는 시간을 모두 포함한다.[6.3] 그리고 7장에서 볼 멱등성 덕분에 이 내부 재시도는 중복을 만들지 않는다.
남는 창은 두 곳이다.
- 애플리케이션이 직접 다시 보내는 경우.
delivery.timeout.ms가 지나 콜백에 실패가 오면, 애플리케이션은 “다시 보낼까?”를 결정해야 한다. KafkaProducer Javadoc은 애플리케이션 수준의 재전송은 중복 제거가 불가능하므로 피하라고 강조한다. 무한 재시도에도 오류가 났다면 프로듀서를 멈추고 마지막으로 보낸 메시지가 중복되지 않았는지 확인하라고 권한다.[6.4] 실패를 받았다고 해서 기록되지 않았다는 뜻은 아니다. - 프로세스가 바뀌는 경우. 같은 Javadoc은 프로듀서가 멱등성을 보장하는 범위가 한 세션 안에서 보낸 메시지뿐이라고 적는다.[6.4] 프로듀서가 재시작되어 새 세션이 되면, 이전 세션이 보냈는지 불확실한 메시지를 다시 보내는 일은 중복을 만들 수 있다.
acks=0은 확인을 기다리지 않으므로 실패를 알 수 없고 재시도도 동작하지 않는다(4장).
중복을 흡수하는 소비자
at-least-once를 기본으로 받아들인다면, 남은 과제는 중복이 와도 결과가 달라지지 않게 처리하는 것이다. 이런 처리를 멱등(idempotent)하다고 한다. 설계 문서도 많은 경우 메시지에 기본 키가 있어 갱신이 멱등하므로, 같은 메시지를 두 번 받아도 같은 레코드를 한 번 더 덮어쓸 뿐이라고 말한다.[6.1]
포인트 서비스의 balance = balance + 500은 멱등하지 않다. 두 번 실행하면 두 번 더해진다. 바꾸는 방법은 여러 가지다.
- 결과를 키로 기록한다. 적립 내역 테이블에
(주문 번호)를 기본 키로 한 행을 넣고, 잔액은 내역의 합으로 계산한다. 같은 주문이 두 번 오면 기본 키 충돌로 두 번째 삽입이 무시된다. - 처리한 이벤트를 기억한다. 이벤트 ID(또는 토픽·파티션·오프셋 조합)를 처리 결과와 같은 데이터베이스 트랜잭션 안에 기록하고, 이미 있으면 건너뛴다.
- 오프셋을 결과와 같은 곳에 저장한다. 처리 결과와 “다음에 읽을 오프셋”을 같은 트랜잭션으로 데이터베이스에 쓰고, 재시작할 때 Kafka의 커밋 대신 데이터베이스의 오프셋에서 시작한다. 설계 문서가 외부 시스템에 쓸 때 권하는 방식이며, 7장에서 트랜잭션과 함께 자세히 본다.[6.5]
세 방법의 공통점은 “두 번의 쓰기”를 “한 번의 쓰기”로 줄이거나, 두 번째 쓰기가 반복되어도 괜찮게 만든다는 것이다.
처리 ID와 효과를 함께 기록하는 방법을 포인트 적립에 적용하면, DB 트랜잭션 안에서 processed_events에 이벤트 ID를 넣고 그 ID가 새로 들어간 경우에만 잔액을 더한다. 둘 중 하나라도 실패하면 둘 다 롤백한다. 성공 뒤 Kafka 커밋 전에 죽어도, 재전달된 ID가 이미 있으므로 잔액을 또 더하지 않고 오프셋 커밋을 진행할 수 있다. 이 절차는 앞의 실패 모델과 DB 원자성에서 도출한 애플리케이션 설계 예시다.
중복 여부 기록을 먼저 별도 트랜잭션으로 저장하면, 잔액 변경 직전 장애에서 “처리 ID만 있고 포인트는 없는” 누락이 생긴다. 잔액을 먼저 별도 저장하면 반대 창에서 중복이 생긴다. 같은 트랜잭션이라는 조건이 순서만 맞추는 조건보다 강한 이유다. 유일 제약은 여러 처리자가 동시에 같은 ID를 시도할 때도 중복을 구별하게 해야 하며, DB 연결 오류까지 중복으로 간주해서는 안 된다.
주문 번호를 적립 내역의 키로 쓰는 첫 방법은 한 주문에 적립이 정확히 하나라는 업무 규칙을 전제로 한다. 같은 주문에 추가 적립·취소·환급 이벤트가 따로 있다면 주문 번호만으로는 정상 후속 이벤트까지 버린다. 이벤트 ID는 재전달에도 유지하고, 다른 정상 이벤트에는 다른 ID를 부여한다. (topic, partition, offset)은 같은 Kafka 위치의 재소비를 식별하지만, 같은 업무 이벤트를 다른 위치에 다시 발행한 경우까지 식별하지 못한다.
누락이 생기는 다른 경로들
중복은 같은 알림이 두 번 오거나 잔액이 예상보다 늘어나는 식으로 드러난다. 반면 누락은 알아차리기 어렵다. 장애를 조사할 때는 앞 장들에서 본 다음 경로를 확인한다.
- 커밋이 처리를 앞지른다: 처리 전 커밋(버전 A), 버퍼링이나 다른 스레드로 넘기는 구조에서의 자동 커밋.
- 돌아갈 위치가 없다: 새 그룹 이름이나 만료된 커밋 오프셋에
auto.offset.reset=latest가 적용된다(8장). 파티션을 늘린 직후 새 파티션의 초기 구간을 건너뛴다(3장). - 데이터가 먼저 사라진다: 소비자가 보존 기간보다 오래 멈춰 있었다(2장).
- 쓰기가 애초에 커밋되지 않았다:
acks=0이나acks=1에서 리더가 죽었다(4장). 프로듀서가 실패를 받고도 그냥 넘어갔다. - 애플리케이션이 스스로 건너뛴다: 처리할 수 없는 레코드(포이즌 필)를 만나 예외를 삼키고 다음으로 넘어가면서 커밋했다. 이 경우는 로그를 남기거나 별도 토픽으로 보내지 않으면 흔적도 없다.
일부러 at-most-once를 택할 때
모든 데이터가 at-least-once를 필요로 하지는 않는다. 배달 기사의 위치 핑처럼 몇 초마다 새 값이 오고 오래된 값은 곧 쓸모없어지는 데이터라면, 중복 처리 비용보다 약간의 누락으로 인한 비용이 작다. 설계 문서가 말하는 at-most-once 구현(프로듀서 재시도를 끄고, 처리 전에 커밋)이 이런 데이터에 맞는다.[6.1] 11장에서 이런 선택을 비용과 함께 비교한다.
확인 질문
- 버전 B를 쓰되
commitSync()대신commitAsync()를 쓰고, 커밋 실패는 로그만 남긴다. 오프셋 토픽을 가진 브로커가 잠시 느려져 몇 번의 비동기 커밋이 연달아 실패한 뒤 프로세스가 죽었다. 재시작 뒤 중복되는 범위는 어떻게 달라질까? - 다른 팀이 포인트 서비스를 “빠르게” 만들려고 구조를 바꿨다. 자동 커밋을 켜고,
poll()로 받은 레코드를 스레드 풀에 넘긴 뒤 곧바로 다음poll()을 부른다. 이 구조에서 프로세스가 죽으면 어떤 레코드가 어떻게 될까?
확인 질문 1 해설
비동기 커밋이 실패하면 커밋된 오프셋이 앞으로 가지 않는다. 연달아 실패했다면 마지막으로 성공한 커밋은 여러 배치 전의 것이다. 재시작하면 그 지점부터 다시 읽으므로, 한 배치가 아니라 실패한 커밋들이 덮었어야 할 여러 배치가 모두 중복된다. 실패 창의 폭이 “커밋 사이의 처리량”이라는 규칙에서 “커밋”은 성공한 커밋이라는 점을 보여 주는 예다. 비동기 커밋의 실패를 무시하면 중복 범위가 예상보다 훨씬 커질 수 있다.
확인 질문 2 해설
자동 커밋은 poll()이 레코드를 반환한 뒤 그 레코드를 소비한 것으로 본다. 레코드를 스레드 풀에 넘기고 곧바로 다음 poll()을 부르면, 처리가 끝나지 않은 레코드의 오프셋까지 커밋될 수 있다.
이 상태에서 죽으면 스레드 풀 큐에 남아 있던 레코드와 처리 중이던 레코드는 이미 커밋된 범위에 있으므로 다시 오지 않는다. 누락이다. 동시에, 스레드들이 서로 다른 속도로 처리하므로 같은 회원의 이벤트 순서도 보장되지 않는다(3장). 이 구조를 쓰려면 자동 커밋을 끄고, 파티션별로 “여기까지는 빈틈없이 처리됨”을 추적해 그 지점만 커밋해야 한다.
7장. 멱등성과 트랜잭션은 어디까지 지켜 주는가 | Kafka 내부와 외부 DB의 경계
6장의 실패 창은 결과 저장과 복구 위치 저장을 따로 확정할 때 생겼다. 결과도 Kafka 토픽이라면 두 저장을 같은 Kafka 트랜잭션에 넣을 수 있다. 여기서 원자적(atomic)이라는 말은 결과 레코드와 입력의 복구 위치가 모두 유효해지거나 모두 유효해지지 않는다는 뜻이다. EOS(exactly-once semantics, 정확히 한 번의 의미)는 이 경계 안에서 성공한 처리 결과가 한 번 반영되도록 하는 계약이다. 장애 뒤 업무 코드가 한 번만 실행된다는 뜻은 아니다.
먼저 한 주문을 orders에서 읽어 payment-requests에 결제 요청으로 바꾸는 정상 경로를 생각하자. 변환기는 입력을 읽고 출력 요청을 쓴 뒤, 입력의 다음 위치를 그 출력과 같은 트랜잭션에 등록해 커밋한다. 하류의 read_committed 컨슈머는 확정된 요청을 읽는다. 변환기가 다시 시작하면 확정된 입력 위치 뒤에서 이어가므로, 그 입력을 다시 성공 출력으로 만들 필요가 없다.[7.1] [7.3]
가상 시나리오 · “EOS를 켰으니 중복은 없다”
결제 요청 변환기는 orders에서 “주문 완료” 이벤트를 읽어 payment-requests 토픽에 결제 요청을 쓴다. 팀은 Kafka 트랜잭션을 도입했다. 인스턴스마다 고유한 transactional.id를 주고, 결제 요청 쓰기와 입력 오프셋 커밋을 한 트랜잭션으로 묶는다. 결제 서비스는 payment-requests를 isolation.level=read_committed로 읽는다.
변환기는 루프 안에서 한 가지 일을 더 한다. 결제 시도 기록을 결제 데이터베이스의 payment_attempts 테이블에 INSERT한다. 감사 팀의 로그 수집기도 payment-requests를 읽는데, 컨슈머 설정은 기본값이다.
어느 날 브로커 재시작 도중 변환기가 트랜잭션 중간에 중단 가능한(abortable) 오류를 받아 트랜잭션을 abort했고, 마지막 커밋 지점으로 되돌아가 같은 주문들을 다시 처리했다.
예측 질문
abort와 재처리가 끝난 뒤, 결제 서비스(read_committed), 감사 로그 수집기(기본 설정), 그리고 payment_attempts 테이블에는 같은 주문에 대해 각각 몇 건이 남아 있을까?
6장은 “처리 결과를 쓰는 일”과 “커밋하는 일”이 두 번의 쓰기라서 실패 창이 생긴다고 했다. Kafka의 멱등성과 트랜잭션은 이 창을 줄이거나 없애는 도구다. 다만 그 효과는 Kafka가 관리하는 저장소 안에서만 온전하다.
멱등 프로듀서: 재시도가 중복을 만들지 않는 원리
6장에서 프로듀서가 네트워크 오류를 받으면 기록 여부를 알 수 없어 다시 보내야 하고, 그러면 중복이 생길 수 있다고 했다. 0.11.0.0부터 Kafka 프로듀서는 재전송이 로그에 중복 항목을 만들지 않도록 하는 멱등 전달을 지원한다. 브로커가 프로듀서마다 ID를 부여하고, 프로듀서가 메시지마다 함께 보내는 시퀀스 번호로 중복을 걸러 낸다.[7.1] 메시지 형식에도 이 흔적이 있다. 레코드 배치 헤더에는 producerId, producerEpoch, baseSequence 필드가 있다.[7.5]
멱등성은 기본으로 켜져 있다(enable.idempotence=true). 켜지려면 max.in.flight.requests.per.connection이 5 이하, retries가 0보다 크고, acks가 all이어야 한다. 충돌하는 설정이 있으면, 멱등성을 명시적으로 켜지 않은 경우 조용히 꺼지고, 명시적으로 켠 경우 설정 예외가 난다.[7.2] 5라는 숫자에도 이유가 있다.
브로커가 프로듀서마다 최대 5개 배치만 기억하기 때문에, 그보다 많이 동시에 보내면 앞선 배치 정보가 브로커에서 지워질 수 있다.[7.2]
KafkaProducer Javadoc은 프로듀서가 멱등성을 보장하는 것은 한 세션 안에서 보낸 메시지뿐이라고 적는다.[7.6] 6장에서 본 것처럼 애플리케이션이 직접 다시 보내거나, 프로세스가 재시작되어 새 세션에서 같은 메시지를 보내면 걸러지지 않는다. 멱등 프로듀서가 없애는 것은 “한 프로듀서의 내부 재시도로 생기는 로그 중복”이다. 소비자 쪽의 중복(5장, 6장)과는 관계가 없다.
트랜잭션이 묶는 것
0.11.0.0부터 프로듀서는 여러 토픽 파티션에 메시지를 원자적으로 보낼 수 있다. 모두 성공하거나 모두 실패한다.[7.1] 컨슈머의 위치는 내부 토픽에 메시지로 저장되므로, 출력 토픽에 쓰는 결과와 같은 트랜잭션 안에서 오프셋도 쓸 수 있다.[7.1]
Kafka 트랜잭션은 다른 메시지 시스템의 트랜잭션과 조금 다르다. 컨슈머와 프로듀서는 별개이고 트랜잭션을 가진 쪽은 프로듀서뿐이다. 다만 프로듀서가 컨슈머의 위치(헷갈리게도 “committed offset”이라고 부른다)를 트랜잭션으로 갱신할 수 있고, 이것이 전체적인 exactly-once 동작을 만든다.[7.3] 설계 문서는 프로듀서와 컨슈머로 exactly-once 처리를 할 때의 세 가지 요소를 이렇게 정리한다.[7.3]
- 컨슈머는 파티션 할당으로 그룹 안에서 각 파티션을 자기만 처리하도록 보장받는다.
- 프로듀서는 트랜잭션으로, 자신이 쓰는 모든 레코드와 컨슈머를 대신해 갱신하는 오프셋을 원자적으로 처리한다.
- 리밸런스와 함께 트랜잭션을 제대로 다루려면 컨슈머 인스턴스마다 프로듀서 인스턴스를 하나씩 두는 편이 좋다.
트랜잭션의 결과는 로그에 어떻게 남을까? 트랜잭션 프로듀서가 쓴 레코드는 트랜잭션의 일부로 표시되고, 트랜잭션이 커밋되거나 abort되면 결과를 알리는 트랜잭션 마커 레코드가 쓰인다.[7.3] 메시지 형식 문서에서 이 마커는 제어 배치(control batch)이며, 커밋 또는 abort를 나타내는 제어 레코드 하나를 담는다. 제어 레코드는 애플리케이션에 전달되지 않고, 컨슈머가 abort된 메시지를 걸러 내는 데 쓰인다.[7.5] 2장에서 트랜잭션을 쓰면 오프셋이 연속되지 않을 수 있다고 했던 이유가 이것이다. 마커도 오프셋을 차지하지만 애플리케이션에는 보이지 않는다.
abort된 트랜잭션의 레코드는 로그에서 지워지지 않는다. 이미 파티션에 쓰였고, 뒤에 abort 마커가 붙을 뿐이다. 누가 그것을 보고 누가 못 보는지는 소비자의 설정에 달려 있다.
정상 경로를 API에 연결하기
트랜잭션 프로듀서 초기화와 매 처리 배치의 동작은 구분한다. transactional.id를 설정하고 initTransactions()로 초기화한 뒤, 배치마다 다음 순서로 진행한다. 아래는 API 책임을 보여 주는 개념 절차이며, 리밸런스와 예외 처리를 생략한 실행 가능한 전체 코드가 아니다.
- 컨슈머가
poll()로 입력을 받는다. 자동 오프셋 커밋은 꺼 둔다. - 프로듀서가
beginTransaction()으로 이번 처리의 트랜잭션을 연다. - 입력으로부터 만든 출력들을
send()한다. 아직 트랜잭션의 성공 결과로 확정된 것은 아니다. - 처리한 입력의 다음 위치와
consumer.groupMetadata()를sendOffsetsToTransaction()에 넘긴다. 배치 전체를 처리했다면records.nextOffsets()가 다음 위치를 제공한다. commitTransaction()으로 출력과 입력 위치를 함께 확정한다.
sendOffsetsToTransaction()이 반환됐다는 사실만으로 입력 오프셋이 커밋된 것은 아니다. 프로듀서 Javadoc은 그 오프셋이 트랜잭션 커밋 성공 때만 커밋된 것으로 간주된다고 설명한다. 여기에서 consumer.commitSync()·commitAsync()를 별도로 호출하면, 함께 묶으려던 입력 위치를 트랜잭션 밖에서 먼저 전진시킬 수 있다. 따라서 이 처리 경로에서는 별도 커밋을 하지 않는다. 실제 그룹 메타데이터를 넘기는 것은 현재 그룹의 소유권을 반영하는 펜싱을 위해서도 필요하다.(KafkaProducer 4.3 Javadoc · sendOffsetsToTransaction)
여러 파티션의 결과가 같은 트랜잭션에 들어가도 하류의 여러 컨슈머가 같은 순간에 모든 결과를 처리하는 것은 아니다. 원자적 성공·실패는 Kafka 기록의 유효성 계약이고, poll()의 반환 묶음과 하류 업무의 완료 시각까지 하나로 만드는 계약은 아니다. 하류가 여러 결과를 모아 외부 작업 하나로 처리해야 한다면 그 모으기와 업무 완료는 별도로 설계한다.
소비자에게 보이는 것: isolation.level과 LSO
컨슈머 설정 isolation.level에 따라 반환하는 메시지가 달라진다.[7.4]
read_committed: 커밋된 트랜잭션의 메시지만 반환한다. 트랜잭션이 아닌 메시지는 그대로 반환한다.read_uncommitted(기본값): abort된 트랜잭션의 메시지까지 모두 반환한다.
read_committed 컨슈머는 마지막 안정 오프셋(LSO, last stable offset)까지만 읽는다. 이 책에서는 LSO를 배타적 읽기 경계로 표기한다. 해당 파티션에 미완료 트랜잭션이 있다면 그 첫 레코드의 오프셋과 하이 워터마크 중 더 앞선 경계가 LSO이며, read_committed는 그 경계 앞의 레코드만 반환한다. 미완료 트랜잭션이 없으면 LSO는 하이 워터마크를 따른다. 진행 중인 트랜잭션에 속한 메시지 뒤에 나오는 메시지는, 트랜잭션과 무관한 것이라도 그 트랜잭션이 끝날 때까지 보류된다. 그래서 열린 트랜잭션이 있으면 read_committed 컨슈머는 하이 워터마크까지 읽지 못한다.[7.4] KafkaConsumer Javadoc은 이런 컨슈머의 소비 지연 지표도 LSO를 기준으로 계산된다고 덧붙인다.[7.9]
설계 문서는 exactly-once를 원한다면 read_committed가 일반적으로 좋은 관행이며, 엄밀히 말해 필수는 아니지만 쓰지 않으면 abort된 트랜잭션과 아직 끝나지 않은 트랜잭션의 레코드를 보게 된다고 말한다.[7.3]
예측 질문의 payment-requests에는 abort된 첫 시도의 결제 요청과, 재처리로 커밋된 두 번째 결제 요청이 둘 다 물리적으로 쓰여 있다. 결제 서비스(read_committed)는 커밋된 것 하나만 본다. 감사 로그 수집기(read_uncommitted)는 둘 다 본다.
예를 들어 한 파티션에서 오프셋 40부터 시작한 트랜잭션이 열려 있고 하이 워터마크가 50이면, 배타 경계 LSO는 40이다. 39까지 읽을 수 있고 40부터는 보류된다. 오프셋 45에 다른 프로듀서가 쓴 비트랜잭션 레코드가 있어도 먼저 돌려주지 않는다. 먼저 45를 주고 나중에 40을 주면 오프셋 순서를 거슬러야 하기 때문이다. 이 예시는 마커의 구체적 위치를 생략한 가상 로그다.
트랜잭션을 abort하면 40 이후를 통째로 지우는 것이 아니라, 해당 트랜잭션에 속한 레코드만 반환 대상에서 제외한다. 그 뒤의 별도 트랜잭션이나 비트랜잭션 레코드는 가시성 경계를 충족하면 반환할 수 있다. read_uncommitted도 복제되지 않은 로그 끝을 자유롭게 읽는 설정은 아니다. 4장의 복제 커밋 경계와 이 절의 트랜잭션 커밋을 다른 층으로 구분한다.[7.4] [7.9]
설정 요건과 좀비 차단
트랜잭션을 쓰려면 양쪽 설정이 맞아야 한다. 컨슈머는 isolation.level=read_committed와 enable.auto.commit=false를 설정해야 하고, 프로듀서는 transactional.id를 설정해야 한다.[7.3] transactional.id를 설정하면 멱등성은 자동으로 켜진다.[7.2]
transactional.id는 프로듀서를 트랜잭션 모드로 만들고, 설계 문서에 따르면 재시작된 애플리케이션이 이전 인스턴스가 진행 중이던 트랜잭션을 abort하도록 보장한다.[7.3] Javadoc은 이 ID의 목적을 한 프로듀서 인스턴스의 여러 세션에 걸친 트랜잭션 복구라고 설명하고, 파티션을 나눠 처리하는 애플리케이션에서는 프로듀서 인스턴스마다 고유해야 한다고 적는다.[7.6]
네트워크가 잠시 끊겨 죽은 줄 알았던 옛 인스턴스(이른바 좀비)가 되살아나 같은 ID로 쓰려 하면, 새 인스턴스가 이미 세대를 올려 두었기 때문에 옛 인스턴스의 쓰기가 거절된다. Javadoc 예제는 ProducerFencedException 같은 복구 불가능한 예외를 받으면 프로듀서를 닫는 것 외에는 선택지가 없다고 보여 준다.[7.6]
이 거절을 펜싱(fencing), 즉 옛 쓰기 주체의 권한 차단이라고 한다. producerEpoch는 어떤 세대의 쓰기인지 구별하는 번호다. transactional.id는 “이번 이벤트의 고유 ID”가 아니라 복구 가능한 논리적 프로듀서의 이름으로 이해한다. 동시에 활동하는 서로 다른 프로듀서가 같은 ID를 쓰면 서로를 차단할 수 있고, 같은 논리적 작업을 인계하는 재시작마다 무작위 새 ID를 만들면 그 이름으로 옛 인스턴스를 차단하는 복구 연결을 잃는다. 인스턴스별 고유성과 논리적 작업 인계 때의 안정성을 함께 정해야 한다.[7.6]
인프라 쪽 요건도 있다. 트랜잭션은 기본적으로 브로커가 적어도 셋인 클러스터를 요구한다. 트랜잭션 상태를 저장하는 내부 토픽의 복제 계수 기본값(transaction.state.log.replication.factor)이 3이고 최소 ISR(transaction.state.log.min.isr)이 2이기 때문이다.[7.2] [7.8] Javadoc은 트랜잭션에 포함되는 토픽도 복제 계수 3 이상, min.insync.replicas=2로 두라고 권한다.[7.6] 4장의 내구성 설정이 트랜잭션의 전제라는 뜻이다.
트랜잭션이 영원히 열려 있을 수도 없다. 프로듀서의 transaction.timeout.ms(기본 1분)가 지나면 트랜잭션 코디네이터가 먼저 abort한다. 이 값은 브로커의 transaction.max.timeout.ms(기본 15분)를 넘을 수 없으며, 문서는 이 상한이 트랜잭션에 포함된 토픽을 읽는 소비자를 멈춰 세우지 않기 위한 것이라고 설명한다.[7.2] [7.8] 열린 트랜잭션은 그 뒤의 모든 메시지를 read_committed 소비자에게서 가린다.
abort 뒤 애플리케이션이 할 일
트랜잭션이 abort되면 출력 레코드는 read_committed 소비자에게 보이지 않고, 오프셋 커밋도 되돌아간다. 하지만 컨슈머의 메모리 속 위치는 자동으로 되감기지 않는다. 설계 문서는 저장된 위치가 예전 값으로 돌아가더라도 컨슈머가 커밋된 오프셋을 다시 가져와야 하며, abort 시 애플리케이션의 상태, 특히 컨슈머의 현재 위치를 명시적으로 되돌려야 abort된 트랜잭션이 처리한 레코드를 다시 처리할 수 있다고 강조한다.[7.1] [7.3]
문서가 제시하는 방법은 둘이다. 간단한 정책은 프로듀서와 컨슈머 객체를 버리고 새로 만드는 것이다. 컨슈머를 새로 만들면 그룹이 리밸런스되고 마지막 커밋 오프셋을 가져오므로 abort 직전 상태로 되감긴다. 더 정교한 애플리케이션은 KafkaConsumer.committed로 커밋된 오프셋을 가져와 KafkaConsumer.seek으로 위치를 되돌린다.[7.3]
예외를 다루는 방식도 4.x 문서에서 범주로 정리되었다.[7.3]
| 범주 | 누가 처리하는가 | 애플리케이션이 할 일 |
|---|---|---|
RetriableException, RefreshRetriableException |
클라이언트가 내부에서 재시도 | 없음(애플리케이션까지 올라오지 않음) |
AbortableException |
애플리케이션 | 트랜잭션 abort, 컨슈머 위치 되돌리기 |
ApplicationRecoverableException |
애플리케이션 | 자체 복구 전략. 프로듀서 재시작을 반드시 포함 |
InvalidConfigurationException |
애플리케이션 | 설정 처리. 재시작은 선택 |
그 밖의 KafkaException |
애플리케이션 | 상황에 맞게 처리 |
4.x의 서버 쪽 방어
Kafka 4.0부터 트랜잭션 프로토콜에 서버 쪽 방어가 추가되었다(KIP-890, “Transactions Server Side Defense”). 서버에서 켜져 있고 4.0 이상 프로듀서를 쓰면, 트랜잭션마다 프로듀서 에포크를 올려 각 트랜잭션이 의도한 메시지만 담고 중복이 다음 트랜잭션의 일부로 쓰이지 않도록 한다.[7.7] 서버에서는 4.0부터 자동으로 켜져 있으며 피처 플래그 transaction.version으로 제어한다. 서버를 업그레이드하면 프로듀서는 재시작 없이 다음 연결 때 새 프로토콜을 사용하고, 트랜잭션 중간이 아니라 다음 트랜잭션 시작부터 적용한다. 되돌리기도 안전하다.[7.7]
운영 지표를 볼 때 알아 둘 부작용이 있다. 새 프로토콜에서는 서버가 CONCURRENT_TRANSACTIONS 오류를 받으면 클라이언트에 돌려주기 전에 스스로 몇 번 재시도한다.
그 결과 produce 요청의 지연 지표가 높게 보일 수 있지만, 트랜잭션 시작부터 커밋까지의 종단 간 지연은 늘지 않는다. 시간이 클라이언트 쪽 백오프에서 produce 지연으로 옮겨 갈 뿐이다.[7.7] 업그레이드 뒤 produce 지연만으로 성능 저하를 판단하지 말고 종단 간 지연도 함께 확인한다.
Kafka 밖의 경계: 외부 데이터베이스
변환기는 트랜잭션 안에서 데이터베이스의 payment_attempts 테이블에 INSERT를 했다. 그러나 Kafka 트랜잭션이 원자적으로 묶는 것은 Kafka에 쓰는 레코드와 Kafka에 커밋하는 오프셋뿐이다.[7.1] [7.3] 데이터베이스는 Kafka 트랜잭션 코디네이터가 모르는 시스템이다.
Kafka 트랜잭션이 abort되어도 데이터베이스 INSERT는 되돌려지지 않는다. 재처리하면서 같은 INSERT가 한 번 더 실행되므로 테이블에는 두 행이 남는다.
설계 문서는 외부 시스템에 쓸 때의 한계를 “소비자의 위치와 실제로 저장된 출력을 맞춰야 하는 문제”로 정리한다. 고전적인 방법은 두 저장소 사이에 2단계 커밋을 두는 것이지만, 더 간단하고 일반적인 방법은 오프셋을 출력과 같은 곳에 저장하는 것이다. 많은 출력 시스템이 2단계 커밋을 지원하지 않기 때문이다.[7.1] 문서는 Kafka Connect가 HDFS에 데이터와 오프셋을 함께 써서 둘 다 갱신되거나 둘 다 갱신되지 않게 하는 예를 든다.
그리고 다른 목적지 시스템에 대한 exactly-once는 일반적으로 그 시스템의 협력이 필요하며, Kafka는 그것을 가능하게 하는 기본 부품을 제공할 뿐이라고 결론짓는다.[7.1]
KafkaConsumer Javadoc의 “Storing Offsets Outside Kafka” 절은 이 방법을 구체적으로 설명한다. 결과를 관계형 데이터베이스에 저장한다면 오프셋도 같은 데이터베이스에 저장해 한 트랜잭션으로 커밋할 수 있다.
그러면 트랜잭션이 성공해 오프셋이 갱신되거나, 결과도 저장되지 않고 오프셋도 갱신되지 않는다. 절차는 자동 커밋을 끄고, 각 레코드의 오프셋으로 위치를 저장하고, 재시작할 때 seek()으로 위치를 복원하는 것이다. 파티션 할당이 자동으로 바뀌는 경우에는 할당 변경을 따로 처리해야 한다.[7.9]
변환기에 적용할 수 있는 선택지를 정리하면 이렇다.
- 데이터베이스 쓰기를 멱등하게 만든다.
payment_attempts에 재전달에도 유지되는 이벤트 ID 또는 결제 시도 ID로 유일 제약을 걸고 중복 삽입을 무시한다. Kafka 트랜잭션은 그대로 두고, 데이터베이스 쪽 중복만 흡수한다. - 데이터베이스를 결과 저장소로 삼고 오프셋도 거기에 둔다. 결제 시도 기록과 입력 오프셋을 같은 데이터베이스 트랜잭션으로 쓰고, Kafka 쪽 출력은 데이터베이스에서 따로 내보낸다. 흔히 아웃박스(outbox) 패턴이라고 부르는 구조로, Kafka 문서의 용어는 아니지만 “오프셋을 출력과 같은 곳에” 원칙을 따른다.
- 데이터베이스 쓰기를 Kafka 뒤로 옮긴다. 변환기는 Kafka에만 쓰고, 데이터베이스 기록은
payment-requests를read_committed로 읽는 별도 소비자가 멱등하게 한다.
외부 API에는 같은 DB 트랜잭션에 오프셋과 효과를 넣는 방법을 그대로 적용할 수 없다. 예를 들어 결제 API가 요청을 성공시켰는데 응답만 사라지면, 로컬 DB의 “발송 완료” 기록이 없어도 실제 결제는 이미 일어났을 수 있다. 로컬에서 이벤트 ID를 기록하는 것만으로 API 실행과 기록 사이의 실패 창이 없어지지는 않는다. 상대 API가 안정적인 멱등 키로 중복 요청을 흡수하거나, 요청 ID로 처리 결과를 조회해 조정하는 계약이 필요하다. 이는 외부 시스템의 협력이 필요하다는 문서의 한계에서 도출한 설계 설명이다.[7.1]
Kafka Streams 문서는 입력 토픽 오프셋 커밋, 상태 저장소 갱신, 출력 토픽 쓰기를 원자적으로 완료한다고 말한다. 상태 저장소도 Kafka의 변경 로그 토픽으로 뒷받침되기 때문이다.[7.10]
다만 Streams를 사용한다는 사실만으로 이 계약이 켜지지는 않는다. Kafka 4.3 Streams 문서의 기본 processing.guarantee는 at_least_once이며, exactly-once 처리는 exactly_once_v2를 선택해 사용한다. Streams가 관리하는 상태 저장소는 임의의 외부 DB와 다르다. 처리 함수 안에서 외부 결제 API를 호출하면 그 효과는 여전히 Kafka 트랜잭션 밖이다.[7.10]
DB에 결과와 오프셋을 함께 저장하는 앞의 방법을 쓸 때는 그 DB 위치를 복구의 기준으로 삼아야 한다. 파티션을 새로 할당받은 시점에도 DB의 다음 위치를 조회하고 seek()으로 맞춘다. 예전 Kafka 커밋과 DB 위치를 번갈아 기준으로 삼으면 원자적으로 저장한 위치의 의미가 사라진다. 리밸런스 때 이전 처리자가 계속 DB에 쓰지 못하도록 소유권 이전도 조정해야 한다. Javadoc이 자동 할당에서는 특별한 주의와 할당 변경 콜백이 필요하다고 하는 이유다.[7.9]
exactly-once가 닿는 곳과 닿지 않는 곳
- 멱등 프로듀서는 한 프로듀서 세션의 내부 재시도로 생기는 로그 중복을 막는다. 애플리케이션 재전송, 세션을 넘는 재전송, 소비자 쪽 재처리는 막지 못한다.
- 트랜잭션은 여러 파티션에 대한 쓰기와 오프셋 커밋을 원자적으로 확정하거나 무효로 만든다. abort된 레코드도 로그에 남으며,
read_committed소비자만 그것을 걸러 낸다. - abort 뒤 컨슈머 위치 되돌리기는 애플리케이션의 책임이다.
- 외부 데이터베이스, 외부 API, 이메일 발송 같은 부수 효과는 Kafka 트랜잭션 밖이다. 그쪽의 exactly-once는 오프셋을 결과와 함께 저장하거나 쓰기를 멱등하게 만들어서 얻어야 한다.
확인 질문
- 감사 로그 수집기가 “결제 요청 건수”를 세어 일일 보고서를 만든다. 오늘 트랜잭션 abort가 세 번 있었다. 보고서의 숫자는 결제 서비스가 실제로 처리한 요청 수와 어떻게 다를까? 수집기에서 무엇을 바꾸면 될까?
- 변환기 인스턴스 하나가 외부 API 호출에서 멈춰, 트랜잭션을 연 채로 50초 동안 아무것도 하지 않았다. 설정은 기본값이다. 그 50초 동안
payment-requests를read_committed로 읽는 결제 서비스에는 무슨 일이 생길까? 멈춤이 90초였다면?
확인 질문 1 해설
read_uncommitted 수집기는 abort된 트랜잭션의 레코드도 세므로, abort가 있을 때마다 그 트랜잭션에 담겼던 요청 수만큼 보고서 숫자가 부풀려진다. 수집기를 isolation.level=read_committed로 바꾸면 커밋된 요청만 센다. 이렇게 바꾸면 열린 트랜잭션 뒤의 레코드가 트랜잭션이 끝날 때까지 보류되므로, 수집기의 소비 지연이 조금 늘어날 수 있다는 점도 함께 알아 두자.
확인 질문 2 해설
열린 트랜잭션의 첫 레코드가 LSO를 붙잡는다. 결제 서비스는 그 지점 뒤의 레코드를 받을 수 없다. 다른 변환기 인스턴스가 같은 파티션에 커밋한 레코드라도 오프셋이 그 뒤에 있으면 보류된다. 50초 동안 결제 서비스 쪽 해당 파티션의 소비가 멈추고, LSO 뒤 데이터와 업무 지연이 쌓인다. 다만 컨슈머 position이 LSO에 도달했다면 LSO 기준 오프셋 lag는 낮게 유지될 수 있다. HW와 LSO의 차이 및 트랜잭션 지속 시간을 함께 관측해야 한다. 트랜잭션이 끝나면 한꺼번에 풀린다.
90초라면 기본 transaction.timeout.ms(1분)를 넘기므로 트랜잭션 코디네이터가 먼저 트랜잭션을 abort한다. 결제 서비스는 약 1분 뒤 그 트랜잭션의 레코드를 건너뛰고 뒤의 레코드를 받기 시작한다. 멈춰 있던 변환기는 깨어나 커밋하려 할 때 오류를 받게 되고, 위치를 되돌려 재처리해야 한다.
8장. 데이터의 수명 | 삭제 · 보존 · 컴팩션
앞 장들은 데이터가 남아 있다는 전제로 다시 읽었다. 이제 “무엇을 얼마나 오래 남겨야 하는가”를 정한다. 정상적인 orders 소비에서는 알림과 정산 그룹이 각자 읽고 커밋해도 주문 로그는 그대로 남는다. 나중에 다시 읽을 수 있는 이유다. 대신 브로커가 저장 공간을 정리할 때는 그 그룹들이 다 읽었는지 기다리지 않는다.
두 요구를 나누면 정리 정책이 이해된다. 주문 발생 이력을 다시 계산하려면 각 사건의 기록이 필요하다. 검색 인덱스를 다시 만들려면 상품마다 마지막 상태가 필요하다. 첫 요구에는 오래된 구간을 버리는 보존 정책, 둘째에는 같은 키의 옛 버전을 버리는 컴팩션을 검토한다. 무엇을 버려도 되는지는 소비자가 필요로 하는 정보에 달려 있다.[8.2]
가상 시나리오 · 30시간 걸린 인덱스 초기 적재
inventory 토픽은 상품 번호(SKU)를 키로, 그 상품의 최신 재고 상태를 값으로 담는다. cleanup.policy=compact로 만들어 키마다 최신 값만 남도록 했다. 상품이 단종되면 재고 서비스는 그 SKU를 키로 값이 비어 있는(null) 레코드를 보내 “삭제”를 알린다.
검색 팀이 새 검색 인덱서를 만들었다. 인덱서는 inventory를 처음부터 끝까지 읽어 인덱스를 채운 뒤, 이후 변경을 계속 따라간다. 운영 클러스터에 부담을 주지 않으려고 읽기 속도를 제한했더니, 처음부터 끝까지 읽는 데 30시간이 걸렸다. 적재가 끝난 뒤 확인해 보니, 이미 단종된 상품 몇 개가 검색 결과에 남아 있었다. 토픽 설정은 cleanup.policy 외에는 기본값이다.
예측 질문
처음부터 읽는 데 30시간이 걸린 인덱서에 단종 상품이 남아 있을 수 있는 이유는 무엇일까? 어떤 설정의 어떤 값과 30시간을 비교해야 할까? 그리고 인덱서가 끝까지 따라잡은 뒤에도 그 상품들은 계속 남아 있을까?
시간·크기 보존과 세그먼트 단위 삭제 외에 로그 컴팩션(log compaction)이라는 수명 모델도 있다. compaction은 흔히 “압축”으로 옮기지만, 데이터 크기를 줄이는 compression(프로듀서의 compression.type)과는 전혀 다른 개념이다. 이 책에서는 헷갈리지 않도록 컴팩션이라고 쓴다.
세그먼트와 보존: 언제 사라지는가
파티션 디렉터리 안의 로그는 하나의 거대한 파일이 아니라 여러 세그먼트(segment) 파일로 나뉜다. 각 파일 이름은 그 파일의 첫 오프셋이다. 첫 파일은 00000000000000000000.log이고, 마지막 파일이 설정된 크기에 이르면 새 파일로 넘어간다.[2.5] 토픽 설정으로 보면 세그먼트는 segment.bytes(기본 1GiB)에 이르거나 segment.ms(기본 7일)가 지나면 새로 만들어진다. segment.ms는 파일이 다 차지 않아도 강제로 넘겨서, 오래된 데이터를 보존 정책이 지우거나 컴팩션할 수 있게 하려는 설정이다.[2.8]
삭제는 이 세그먼트 단위로 일어난다. 구현 문서는 삭제 기준을 다음과 같이 설명한다.[2.5]
- 데이터는 한 번에 세그먼트 하나씩 지워진다.
- 삭제 대상은 시간과 크기 두 기준으로 정한다.
- 시간 기준에서는 세그먼트 안의 레코드 타임스탬프 중 가장 큰 값이 그 세그먼트 전체의 보존 시각을 정한다.
- 크기 기준은 기본적으로 꺼져 있고, 켜면 파티션 크기가 한도 안으로 들어올 때까지 가장 오래된 세그먼트부터 지운다.
- 두 기준을 함께 켜면 어느 한쪽이라도 만족한 세그먼트는 지워진다.
시간 기준의 한도는 토픽 설정 retention.ms이고 기본값은 7일이다. -1로 두면 시간 제한이 없다. 크기 한도 retention.bytes는 기본값 -1(제한 없음)이며 파티션 단위로 적용된다. 토픽 전체 용량을 계산하려면 파티션 수를 곱해야 한다.[2.8] 브로커는 삭제할 로그가 있는지 log.retention.check.interval.ms(기본 5분)마다 확인한다.[2.9]
세그먼트 단위 삭제에는 두 가지를 고려해야 한다. 첫째, 레코드는 retention.ms가 지났다고 곧바로 사라지지 않는다. 그 레코드가 든 세그먼트의 가장 새 레코드까지 기한이 지나야 하고, 아직 쓰는 중인 마지막 세그먼트라면 먼저 새 세그먼트로 넘어가야 하며, 삭제 점검 주기도 기다려야 한다. 따라서 “7일 보존”은 도착 시각부터 정확히 7일 뒤 삭제한다는 계약이 아니다. 크기 기준을 함께 쓰거나 과거 타임스탬프를 기록한 경우에는 도착 후 7일 이내에 삭제될 수도 있다. 둘째, 보존 시각은 레코드 타임스탬프로 계산된다.
토픽의 기본 타임스탬프 방식은 프로듀서가 넣은 생성 시각(CreateTime)이다.[2.8] 프로듀서가 과거 시각을 달아 보내면 그 세그먼트는 일찍 삭제 대상이 될 수 있다. 4.3의 기본 설정은 브로커 시각보다 1시간 넘게 미래인 타임스탬프는 거부하지만, 과거 쪽 한도(message.timestamp.before.max.ms)는 기본적으로 제한이 없다.[2.11] 과거 이벤트를 원래 시각 그대로 다시 넣는 작업이라면 보존 설정을 먼저 확인해야 한다.
사라진 자리를 가리키는 오프셋
가상의 정산 배치가 9일 동안 중지된 경우를 생각해 보자. 데이터와 복구 위치는 서로 다른 수명을 가진다. 정산 배치는 지난번에 어디까지 읽었는지를 커밋해 두었을 것이다. 5장에서 배운 오프셋 commit에는 만료 조건도 있다.
커밋된 오프셋에도 수명이 있다. 브로커 설정 offsets.retention.minutes의 기본값은 10080분, 즉 7일이다. 컨슈머 그룹에 소비자가 하나도 남지 않은(비어 있는) 상태로 이 기간이 지나면, 그 그룹이 커밋해 둔 오프셋은 만료되어 버려진다.[2.9]
컨슈머 설정 auto.offset.reset은 “Kafka에 초기 오프셋이 없거나, 현재 오프셋이 서버에 더는 존재하지 않을 때(예: 데이터가 삭제된 경우)” 무엇을 할지 정한다. earliest는 남아 있는 가장 오래된 오프셋으로, latest는 가장 최근 오프셋으로 옮긴다.
by_duration:<기간>은 현재 시각에서 그 기간만큼 앞선 지점으로 옮기고, none은 예외를 던진다. 기본값은 latest다.[2.10] 구현 문서도 존재하지 않는 오프셋을 요청한 클라이언트는 범위를 벗어났다는 오류를 받고, 용도에 따라 스스로 위치를 다시 잡거나 실패하게 된다고 설명한다.[2.5]
이 사례에서는 토픽이 기본 보존 설정을 쓰고, 그룹은 비어 있으며, 정산 컨슈머는 기본 reset 정책을 쓴다. 정산 그룹은 9일 동안 비어 있었으므로 커밋해 둔 오프셋이 7일 기한을 넘겨 만료되었다. 다시 뜬 정산 배치는 돌아갈 위치가 없고, 기본값 latest에 따라 로그의 끝에서 시작한다. 토픽에 아직 남아 있는 주문도 건너뛴다. 오류는 나지 않는다. 배치는 정상적으로 떠서 “새 주문이 없다”고 보고할 뿐이다.
설령 오프셋이 만료되지 않았더라도 결과는 크게 다르지 않다. 9일 전 위치가 아직 남아 있으면 그 지점부터 재개한다. 이미 삭제되어 범위 밖이라면 auto.offset.reset이 적용된다. 그림 8-2의 그룹 C가 바로 이 상태다.
두 가지 정리 정책
토픽 설정 cleanup.policy가 로그 세그먼트를 어떻게 정리할지 정한다.[8.1]
delete(기본값): 보존 시간이나 크기 한도에 이른 오래된 세그먼트를 버린다. 2장의 모델이다.compact: 로그 컴팩션을 켠다. 키마다 최신 값을 남긴다.delete,compact: 둘을 함께 쓴다. 오래된 세그먼트는 보존 설정에 따라 버리고, 남아 있는 세그먼트는 컴팩션한다.- 빈 목록: 어떤 정리도 하지 않고 세그먼트를 무기한 보존한다.
두 정책은 “무엇을 남기는가”의 기준이 다르다. delete는 시간 기준으로 앞부분을 통째로 잘라 내고, compact는 키를 기준으로 같은 키의 오래된 값을 골라 낸다.
가상 정상 흐름 · 재고 상태를 먼저 만들어 보기
같은 SKU의 값이 “이전 값에 더할 증감분”이 아니라 “그 시점의 전체 재고 상태”라고 하자. SKU-7에 재고 10, 이어서 재고 8이 기록되면 컨슈머는 첫 값을 넣고 다음 값으로 덮어쓴다. SKU-9의 전체 상태를 넣은 뒤 같은 키의 null 값이 오면 로컬 인덱스에서 그 키를 지운다. 이 두 동작, 최신 값으로 덮어쓰기와 삭제 표시 반영, 이 상태 재구축의 규칙이다.
반대로 SKU-7의 값이 +10, -2처럼 증감분뿐이면 마지막 -2만 남겨서는 재고 8을 복원할 수 없다. 컴팩션이 보존하는 것은 마지막 레코드 값이지, 업무 연산을 모두 실행한 최종 잔액을 계산한 값이 아니다. 키별 상태를 재구축하려면 값의 데이터 모델부터 그 목적에 맞아야 한다. 이 판단은 문서의 키별 최종 값 보장에서 도출한 설계 조건이다.
컴팩션: 키마다 마지막 값
설계 문서에 따르면 로그 컴팩션은 한 토픽 파티션의 로그 안에서 각 메시지 키의 적어도 마지막으로 알려진 값을 항상 남기도록 보장한다.[8.2] 시간 기준 보존이 개별 기록이 독립적인 로그 데이터에 잘 맞는다면, 컴팩션은 키가 있는 가변 데이터의 변경 기록(예: 데이터베이스 테이블의 변경)에 맞는다. 문서는 데이터베이스 변경 구독, 이벤트 소싱, 로컬 상태를 가진 프로세스의 고가용성 기록(journaling)을 대표적인 쓰임새로 든다.[8.2] 공통점은 평소에는 실시간 변경만 따라가다가, 기계가 고장 나거나 데이터를 다시 적재해야 할 때 전체 상태를 처음부터 다시 읽어야 한다는 것이다. 검색 인덱서가 바로 그 경우다.
컴팩션된 로그는 두 부분으로 이루어진다.[8.3]
- 머리(head)는 전통적인 Kafka 로그와 같다. 오프셋이 빽빽하게 이어지고 모든 메시지가 남아 있다.
- 꼬리(tail)는 컴팩션된 부분이다. 같은 키의 더 새 값이 있는 레코드가 빠져 있다.
컴팩션이 지키는 규칙은 다섯 가지다.[8.4]
- 머리 안에서 계속 따라가는 컨슈머는 쓰인 모든 메시지를 보며, 그 메시지들의 오프셋은 연속적이다.
- 메시지의 순서는 언제나 유지된다. 컴팩션은 순서를 바꾸지 않고 일부를 뺄 뿐이다.
- 메시지의 오프셋은 절대 바뀌지 않는다. 오프셋은 로그 위치의 영구적인 식별자다.
- 로그의 처음부터 읽는 컨슈머는 적어도 모든 레코드의 최종 상태를 쓰인 순서대로 본다.
- 삭제된 레코드의 삭제 표시도 모두 보인다. 단, 컨슈머가
delete.retention.ms(기본 24시간)보다 짧은 시간 안에 로그의 머리에 도달해야 한다.
세 번째 규칙 때문에 컴팩션된 꼬리에는 빈 오프셋이 생긴다. 문서는 빠진 오프셋도 여전히 유효한 위치이며, 그 위치는 그 뒤에 실제로 있는 가장 가까운 오프셋과 구별되지 않는다고 설명한다.
예를 들어 36, 37, 38이 모두 같은 위치여서 어디서 읽기 시작하든 38부터 받는다.[8.3] 2장에서 오프셋이 연속된다는 보장이 없다고 한 또 하나의 이유다. 메시지 형식 문서는 여기에 한 가지 세부를 더한다. 컴팩션 뒤에도 배치의 첫 오프셋·마지막 오프셋과 시퀀스 번호를 보존해야 프로듀서 상태를 복원할 수 있으므로, 레코드가 모두 정리된 빈 배치가 로그에 남아 있을 수 있다.[8.7]
컴팩션을 DB 스냅숏과 같은 읽기 기능으로 취급하지는 않는다. DB의 일관된 스냅숏은 정해진 기준 시점의 행 집합을 읽는 모델이고, 컴팩션은 계속 변하는 파티션 로그에서 옛 값을 백그라운드로 제거하는 정책이다. 현재 상태의 재구축에 쓸 수 있지만, 토픽 전체를 한 시점으로 고정해 읽는 명령이나 키 조회 API를 제공하는 것은 아니다. 여러 파티션을 읽는 동안에도 새 값이 들어오며, 모든 파티션의 동일 시각 상태나 과거 특정 시점의 상태가 자동으로 얻어지지는 않는다.
따라서 “처음부터 읽어 카탈로그를 만든다”는 말에는 빈 상태 저장소에서 시작하고, 각 키를 덮어쓰거나 삭제하며, 정한 파티션별 경계까지 따라잡고, 이후 변경도 이어서 처리한다는 절차가 들어 있다. 앞부분에 같은 키의 옛 값이 남아 있어도 순서대로 덮어쓰면 마지막 값으로 수렴한다. 이미 옛 값을 읽은 뒤 삭제 표시를 놓치는 경우는 다르다. 로그에서 레코드가 없어졌다는 사실만으로는 컨슈머의 로컬 DB가 지워지지 않는다. 아래의 삭제 표시 사례는 바로 이 차이를 다룬다.[8.3] [8.4]
삭제 표시와 delete.retention.ms
컴팩션에서 “삭제”는 키는 있고 값이 null인 레코드로 표현한다. 이것을 삭제 표시(tombstone)라고 부른다. 삭제 표시는 같은 키의 이전 메시지를 모두 지우게 만드는데, 삭제 표시 자신도 일정 시간이 지나면 공간을 비우기 위해 로그에서 정리된다.[8.3] 그 시간이 토픽 설정 delete.retention.ms이며 기본값은 1일이다. 문서는 이 설정이 오프셋 0부터 읽기 시작한 컨슈머가 최종 상태의 유효한 스냅숏을 얻기 위해 읽기를 끝내야 하는 시간의 한도이기도 하다고 설명한다. 그보다 오래 걸리면 스캔을 끝내기 전에 삭제 표시가 정리될 수 있다.[8.6]
예측 질문에서 단종된 상품 SKU-9의 레코드를 시간 순서로 따라가 보자.
- 인덱서가 읽기를 시작한 지 2시간째, 로그 앞부분에서 SKU-9의 마지막 재고 값을 읽어 인덱스에 넣는다. 이 값은 아직 정리되지 않았다. SKU-9의 삭제 표시가 쓰인 지 얼마 되지 않았고, 컴팩션이 그 뒤로 아직 돌지 않았기 때문이다.
- 그 뒤 컴팩션이 돌며 SKU-9의 옛 값은 삭제 표시 때문에 정리된다. 삭제 표시는
delete.retention.ms동안 남는다. - 24시간이 지나 다음 컴팩션에서 삭제 표시도 정리된다.
- 인덱서가 30시간째 그 위치에 도착했을 때 SKU-9의 삭제 표시는 이미 없다.
인덱서는 SKU-9의 값은 보았지만 삭제는 보지 못했다. 삭제 표시의 정리는 읽기와 동시에 진행되므로, 컨슈머가 delete.retention.ms보다 더 뒤처지면 삭제 표시를 놓칠 수 있다.[8.4] 인덱서가 따라잡은 뒤에도 SKU-9는 남는다. 단종된 상품의 키로는 더 이상 새 레코드가 오지 않으니, 이후 레코드를 읽어도 인덱스를 바로잡을 계기가 없다.
대응은 두 방향이다. 초기 적재에 걸리는 시간보다 delete.retention.ms를 넉넉히 크게 잡거나(토픽 설정 변경), 적재 속도를 삭제 표시 보존 기간 안에 끝나도록 맞추는 것이다. 어느 쪽이든 “처음부터 읽는 소비자가 끝까지 가는 데 걸리는 시간”이 이 토픽의 설계 변수가 된다.
여기에서 24시간은 삭제 레코드가 쓰인 정확한 순간부터 그 시간이 지나면 반드시 제거된다는 타이머로 읽지 않는다. 실제 정리는 클리너의 처리와 세그먼트 조건에 달려 있다. 사례의 시간선은 삭제 표시가 읽기 도중 실제로 정리되어 사라진 한 경우를 보여 준다. 설정을 나중에 늘려도 이미 사라진 삭제 표시는 복원되지 않는다. 그런 상태에서 기존 인덱스를 고치려면 남아 있는 로그로 새 빈 인덱스를 적절한 조건에서 재구축하거나, 원본 상태와 대조해 잘못 남은 키를 지우는 별도 복구가 필요하다. 이는 가상 사례에서 도출한 복구 선택지이며 컴팩션의 자동 교정 기능은 아니다.
컴팩션은 언제 일어나는가
컴팩션은 쓰기와 동시에 일어나지 않는다. 로그 클리너(log cleaner)라는 백그라운드 스레드 풀이 세그먼트를 다시 복사하면서 컴팩션한다. 각 스레드는 머리 대 꼬리 비율이 가장 높은 로그를 고르고, 머리에 있는 키마다 마지막 오프셋을 요약한 뒤, 로그를 처음부터 다시 복사하며 뒤에 같은 키가 또 나오는 레코드를 뺀다. 새로 만든 깨끗한 세그먼트는 곧바로 로그에 바꿔 끼우므로 추가로 필요한 디스크 공간은 세그먼트 하나 정도다.[8.5] 클리닝은 읽기를 막지 않으며, 생산자와 소비자에 영향을 주지 않도록 I/O 사용량을 제한할 수 있다.[8.3]
어떤 로그가 언제 클리닝 대상이 되는지는 몇 가지 설정이 정한다.[8.5] [8.6]
min.cleanable.dirty.ratio(기본 0.5): 로그에서 아직 컴팩션되지 않은(더러운) 부분의 비율이 이 값을 넘어야 클리닝한다. 값이 높을수록 클리닝 간격이 길어지고 중복 정리의 효율은 높아지지만, 그동안 중복이 차지하는 공간은 커진다.min.compaction.lag.ms(기본 0): 메시지가 쓰인 뒤 최소한 이 시간 동안은 컴팩션하지 않는다. 머리에 남는 시간의 하한이다.max.compaction.lag.ms(기본 사실상 무한대): 쓰인 뒤 이 시간이 지나면 더러운 비율과 관계없이 컴팩션 대상이 된다. 쓰기가 드문 로그가 끝없이 컴팩션되지 않는 일을 막는다. 문서는 이 기한이 클리너 스레드 여유와 실제 컴팩션 시간에 좌우되므로 엄격한 보장은 아니라고 밝히고,uncleanable-partitions-count,max-clean-time-secs,max-compaction-delay-secs지표를 지켜보라고 권한다.- 지금 쓰고 있는 마지막 세그먼트(활성 세그먼트)는 컴팩션하지 않는다. 그 안의 메시지가 모두 최소 지연 시간보다 오래되었어도 마찬가지다.
마지막 규칙은 이 장의 ‘세그먼트와 보존’에서 설명한 segment.ms와 이어진다. 쓰기가 드문 컴팩션 토픽에서는 활성 세그먼트가 오래 넘어가지 않을 수 있고, 그 안의 오래된 값들은 그동안 정리되지 않는다. 컴팩션 토픽을 “키마다 값이 하나뿐인 테이블”로 믿고 설계하면 안 되는 이유다. 컨슈머는 같은 키의 값을 여러 번 받을 수 있다고 가정하고, 받을 때마다 덮어쓰는(upsert) 방식으로 처리해야 한다.
compact와 delete를 함께 쓰면
cleanup.policy=delete,compact는 키마다 최신 값을 남기면서도 전체 보존 기간을 두고 싶을 때 쓴다. 그런데 설정 문서의 설명대로라면 오래된 세그먼트는 보존 시간과 크기 설정에 따라 먼저 버려지고, 남은 세그먼트만 컴팩션된다.[8.1] 따라서 retention.ms보다 오래 갱신되지 않은 키는 마지막 값까지 함께 사라진다. “키마다 최신 값”이라는 컴팩션의 보장이 “보존 기간 안에 한 번이라도 갱신된 키에 한해서”로 좁아지는 것이다. 재고처럼 오래 변하지 않는 상품이 있는 토픽에 이 조합을 쓰면, 새로 적재하는 소비자는 그런 상품이 있다는 것조차 모르게 된다.
Kafka가 스스로 쓰는 컴팩션 토픽
컴팩션은 Kafka 내부에서도 쓰인다. 5장에서 본 __consumer_offsets는 파티션별로 가장 최근 커밋만 필요하므로 컴팩션되는 토픽이다.[8.9] Kafka Streams는 상태 저장소마다 변경 로그(changelog) 토픽을 두는데, 이 토픽들도 무한히 자라지 않도록 컴팩션을 켠다.[8.9]
내부 상태에는 컴팩션과 다른 수명 규칙도 있다. 이 장에서 설명했듯이 빈 그룹의 커밋 오프셋은 offsets.retention.minutes(기본 7일)가 지나면 만료되고, 트랜잭션 코디네이터는 트랜잭션 상태 갱신이 transactional.id.expiration.ms(기본 7일) 동안 없으면 그 트랜잭션 ID를 만료시킨다.
진행 중인 트랜잭션의 ID는 만료되지 않는다.[8.10] 컴팩션이 “키마다 마지막 값”을 지켜 주더라도, 그 키 자체를 지우는 별도의 만료 규칙이 있는 셈이다.
한 걸음 더: 계층형 저장소
보존 기간을 길게 잡고 싶은데 브로커 디스크가 부담이라면 계층형 저장소(tiered storage)가 선택지가 될 수 있다. 계층형 저장소를 켜면 로컬 디스크 계층과, HDFS나 S3 같은 외부 저장소를 쓰는 원격 계층으로 나뉘고, 완료된 로그 세그먼트가 원격 계층에 저장된다.[8.8] 기본으로는 꺼져 있고, 브로커 설정과 함께 토픽마다 remote.storage.enable을 켜야 한다.[8.8]
토픽의 local.retention.ms 기본값 -2는 로컬 보존에 retention.ms를 그대로 쓴다는 뜻이며, 실제 값은 retention.ms보다 작거나 같아야 한다.[8.8] 전체 보존과 로컬 보존은 별개다. 실제로 사용할 때는 공식 문서의 제한 사항 절부터 확인한다.
두 정책이 각각 약속하는 것
delete는 시간·크기 기준으로 오래된 세그먼트를 통째로 버린다. 키와 무관하다.compact는 키마다 적어도 마지막 값을 남기고, 순서와 오프셋을 바꾸지 않는다. 즉시 일어나지 않으며, 활성 세그먼트는 대상이 아니다.- 처음부터 읽는 소비자가 삭제 표시까지 모두 보려면
delete.retention.ms안에 머리에 도달해야 한다. delete,compact에서는 보존 기간보다 오래 갱신되지 않은 키가 통째로 사라진다.- 컴팩션 토픽의 소비자는 같은 키를 여러 번 받을 수 있다고 가정해야 한다.
확인 질문
- 같은 인덱서가 속도 제한을 풀어 20시간 만에 처음부터 끝까지 읽었다. 단종 상품이 남는 문제는 어떻게 될까? 모든 상황에서 해결된다고 말할 수 있을까?
- 디스크 사용량을 줄이려고
inventory의 정책을delete,compact로 바꾸고retention.ms를 7일로 두었다. 한 달 뒤 새 인덱서를 처음부터 적재했더니 멀쩡히 판매 중인 상품 일부가 검색되지 않았다. 무엇이 사라졌고, 왜일까?
확인 질문 1 해설
20시간은 24시간보다 짧으므로, 설계 문서의 보장대로라면 처음부터 읽기 시작해 머리에 도달하는 동안 모든 삭제 표시를 볼 수 있다. 다만 여유가 4시간뿐이다. 중간에 인덱서가 재시작되어 처음부터 다시 읽거나, 트래픽이 늘어 속도가 떨어지면 다시 24시간을 넘길 수 있다. “해결됐다”기보다 “이번에는 기한 안에 들어왔다”에 가깝다. 적재 시간과 delete.retention.ms 사이에 넉넉한 차이를 두는 편이 안전하다.
확인 질문 2 해설
delete,compact에서는 오래된 세그먼트가 보존 기간에 따라 먼저 버려지고, 남은 부분만 컴팩션된다. 한 달 동안 재고 상태가 한 번도 바뀌지 않은 상품은 마지막 값이 7일보다 오래된 세그먼트에 있었고, 그 세그먼트가 통째로 삭제되면서 함께 사라졌다. 새 인덱서는 그런 상품이 존재한다는 것조차 알 수 없다. 키마다 최신 상태를 보존하려는 토픽이라면 compact만 쓰고, 디스크는 min.cleanable.dirty.ratio나 세그먼트 설정으로 조절하는 편이 맞다.
9장. 설정은 조합으로 읽어야 한다 | acks · ISR · min.insync.replicas 외
먼저 정상 쓰기에서 설정이 만나는 지점을 다시 잡자. 프로듀서가 레코드를 파티션별로 모아 리더에 보내고, 팔로워들이 복제하며, 리더가 요청의 확인 조건을 충족하면 성공을 응답한다. 컨슈머는 복제 커밋 경계 안의 데이터를 읽고, read_committed라면 트랜잭션의 확정 여부도 확인한다. 배치 대기, 복제 확인, 소비 가시성은 서로 다른 단계다.
여기서 복제 계수는 배치해 둔 복제본 수, ISR은 지금 동기화 조건을 만족하는 집합, min.insync.replicas는 성공에 요구하는 ISR 크기의 하한이다. acks=all은 그 하한만큼 골라 기다리는 뜻이 아니라 현재 ISR 전체의 복제 확인을 기다리는 뜻이다. 아래의 조합은 이 세 역할을 바꾸지 않고, 어떤 장애에서 어떤 조건이 먼저 깨지는지 비교한다.[9.1] [9.2]
가상 시나리오 · “가장 안전한” 토픽 템플릿
플랫폼 팀이 중요한 토픽에 쓸 표준 템플릿을 만들었다. “절대 잃지 않는다”를 목표로 다음처럼 정했다.
- 복제 계수 3,
min.insync.replicas=3 - 프로듀서
acks=all, 멱등성 기본값(켜짐) unclean.leader.election.enable=false
이 템플릿으로 payments, payment-requests를 다시 만들었다. 다음 주 화요일 새벽, 커널 패치를 위해 브로커 세 대를 한 대씩 재시작하는 작업이 예정되어 있다. 한 대를 내렸다가 올리고 복제가 따라잡기까지 대략 5분이 걸린다.
예측 질문
브로커를 한 대씩 롤링 재시작하는 동안, 이 템플릿으로 만든 토픽에 쓰는 결제 서비스와 그 토픽을 읽는 정산 배치는 각각 무엇을 겪을까?
4장은 acks, ISR, min.insync.replicas를 하나씩 설명했다. 설정 문서는 항목별로 정리되어 있지만, 장애 때의 동작은 이 설정들의 조합에 따라 달라진다.
설정은 쓰기 경로 위에 놓여 있다
linger.ms는 전송 전 모으는 시간을, acks는 기록·복제 뒤 성공을 듣는 조건을 정한다. 하나를 줄여도 다른 대기는 사라지지 않는다. 버퍼 확대 역시 더 많은 레코드를 기다리게 할 뿐 복제나 디스크의 지속 처리 능력을 늘리지는 않는다.
앞 단계가 약속하지 않은 것은 뒤 단계가 복구하지 못한다. 예를 들어 acks=1로 받은 레코드는 리더 선출 규칙을 아무리 엄격하게 해도 커밋되기 전에 리더가 죽으면 사라진다(4장). 반대로 뒤 단계가 앞 단계의 약속을 좁히기도 한다. unclean.leader.election.enable=true는 ISR이 모두 사라진 순간 이미 커밋된 레코드를 잃을 수 있게 만든다.
acks × min.insync.replicas × ISR 크기
복제 계수 3인 토픽에서 ISR이 줄어들 때 세 가지 조합이 어떻게 반응하는지 정리하면 다음과 같다. 표의 각 칸은 4장에서 본 문서의 규칙들을 조합한 결과다.[9.1] [9.2] [9.9]
표는 ISR이 해당 크기로 안정되어 있고, 리더와 필요한 복제본이 요청을 정상 처리하며, 트랜잭션의 추가 가시성 제약은 없는 상태를 가정한다. “성공”은 타임아웃이나 네트워크 장애가 절대 없다는 뜻이 아니라 그 ISR 크기에서 복제 조건상 허용된다는 뜻이다. ISR이 3이고 min이 2인 첫 칸에서도 세 번째 ISR 복제본이 느리면 기다린다. “2개면 충분하다”는 하한과 “지금 3개를 기다린다”는 확인 대상은 동시에 참이다.
| 현재 ISR | acks=all, min 2 |
acks=all, min 1 |
acks=1, min 2 |
|---|---|---|---|
| 3개 | 성공. 3곳에 기록 후 응답 | 성공. 3곳 기록 후 응답 | 성공. 리더 기록 후 응답 |
| 2개 | 성공. 2곳 기록 후 응답 | 성공. 2곳 기록 후 응답 | 성공. 곧 커밋됨 |
| 1개 | 거부(NotEnoughReplicas) |
성공. 1곳에만 기록 | 성공. 하지만 컨슈머에게 안 보임 |
ISR이 하나인 세 번째 행에서는 다음 차이가 생긴다.
- 가운데 열은 가용성을 지키지만, 성공 응답을 받은 레코드가 복제본 하나에만 있다. 그 브로커를 잃으면 사라진다. 설계 문서가 “최대 가용성을 보장하지만 내구성을 선호하는 사용자에게는 바람직하지 않을 수 있다”고 한 상황이다.[9.2]
- 왼쪽 열은 쓰기를 거절해 내구성을 지킨다. 대가는 쓰기 가용성이다.[9.2]
- 오른쪽 열은 둘 다 아니다. 리더는 쓰기를 받아들이지만 ISR이 기준에 못 미치는 동안에는 하이 워터마크가 전진하지 못해 컨슈머가 볼 수 없고, 그 사이 리더를 잃으면 사라진다.[9.1] [9.9]
NotEnoughReplicas에는 짝이 있다. NotEnoughReplicasAfterAppendException은 ISR이 부족하다는 사실을 메시지를 이미 로그에 추가한 뒤에 알게 되었을 때 나는 예외이며, Javadoc은 이 경우 프로듀서가 재시도하면 중복이 생긴다고 적는다.[9.8] 멱등성이 켜져 있다면 같은 시퀀스 번호의 재전송은 7장에서 본 방식으로 걸러지므로, 이 예외는 멱등성을 껐을 때 재시도 중복이 생길 수 있는 사례다. 두 예외 모두 재시도 가능한 예외(RetriableException)의 하위 클래스다.[9.8]
min.insync.replicas를 복제 계수와 같게 두면
예측 질문의 템플릿은 복제 계수 3에 min.insync.replicas=3이다. 브로커 한 대를 내리면, 그 브로커가 복제본을 가진 모든 파티션의 ISR이 2가 된다. 2는 3보다 작으므로 acks=all 쓰기가 모두 거절된다. 브로커가 다시 올라와도 곧바로 ISR에 들어가지 못한다. 4장에서 본 대로 리더를 따라잡아야 ISR에 다시 합류하기 때문이다.
결제 서비스의 프로듀서는 거절을 재시도 가능한 오류로 받고 재시도를 계속한다. 재시도는 delivery.timeout.ms(기본 2분)가 상한이다.[9.3] 한 대의 재시작과 따라잡기에 5분이 걸린다면 그동안 보낸 레코드의 상당수가 2분 뒤 실패로 돌아온다. 브로커가 세 대이니 이런 창이 세 번 온다. 정산 배치는 이미 커밋된 데이터는 계속 읽지만, 새 결제가 커밋되지 않으므로 금세 읽을 것이 없어진다.
“절대 잃지 않는다”는 목표는 지켜진다. 대신 어느 브로커든 한 대만 재시작해도 쓰기 장애가 된다. 설계 문서가 말한 “최소 ISR 크기를 높이면 일관성이 좋아지는 대신 가용성이 떨어진다”는 트레이드오프의 끝 지점이다.[9.2] 계획된 유지 보수 중에도 쓰기가 계속되어야 한다면 이 설정은 맞지 않는다.
여기에서 목표가 지켜진다는 것은 이 롤링 재시작 사례에서 복제 문턱을 낮추지 않아 쓰기를 거절했다는 뜻으로 한정한다. 복제본 전체의 영구 손실, 공통 전원 장애에서의 미동기화 데이터 손실, 보존 정책에 의한 삭제까지 막는 절대적 보장은 아니다. acks=all도 매번 모든 저장장치의 fsync 완료를 뜻하지 않는다는 1·4장의 조건을 유지한다.
흔히 쓰는 조합과 그 조건
설정 문서가 대표적인 예로 드는 조합은 복제 계수 3, min.insync.replicas=2, acks=all이다.[9.1] 이 조합이 무엇을 견디는지 조건을 붙여 정리하면 이렇다.
- 브로커 한 대가 내려가도 ISR 2가 남으므로 쓰기가 계속된다. 롤링 재시작이 쓰기 장애가 되지 않는다.
- 성공 응답을 받은 레코드는 적어도 두 복제본에 있다. 그중 하나라도 살아 있으면 잃지 않는다.
- 브로커 두 대가 동시에 내려가면 쓰기는 거절된다. 이미 커밋된 데이터는 남은 한 대가 가지고 있다.
이 조합에도 전제가 있다. 첫째, 롤링 재시작 도중 다른 브로커 하나가 느려져 ISR에서 빠지면(기준은 replica.lag.time.max.ms, 기본 30초) 그 순간 ISR은 1이 되어 쓰기가 멈춘다.
재시작 중에는 여유가 0이라는 뜻이다. 둘째, 이 보장은 모든 프로듀서가 acks=all일 때만 성립한다. 같은 토픽에 acks=1로 쓰는 프로듀서가 하나라도 있으면 그 레코드에는 표의 오른쪽 열이 적용된다. 셋째, 복제본이 같은 랙이나 같은 가용 영역에 몰려 있으면 “세 대 중 두 대가 동시에 사라지는” 일이 생각보다 흔해진다. 운영 문서의 랙 인식 기능은 한 파티션의 복제본을 min(랙 수, 복제 계수)개의 서로 다른 랙에 퍼뜨린다.[9.7]
복제 계수 2는 더 까다롭다. min.insync.replicas=2로 두면 브로커 하나만 재시작해도 쓰기가 멈추고, 1로 두면 한 대가 내려간 동안의 쓰기가 한 복제본에만 남는다. 운영 문서가 무중단 재시작을 위해 복제 계수 2 또는 3을 권하면서도, 4장에서 본 설계 문서의 예시가 굳이 복제본 두 개짜리 토픽의 위험을 드는 이유다.[9.2] [9.7]
unclean, ELR, 그리고 min.insync.replicas
4장에서 ISR이 모두 사라졌을 때의 선택으로 unclean.leader.election.enable을 보았다. 이 설정은 표의 어느 칸도 바꾸지 않는다. 바꾸는 것은 “ISR이 전부 사라진 뒤”의 결과다. false이면 기다리고(가용성 포기), true이면 ISR 밖의 복제본으로 즉시 재개한다(커밋된 데이터 손실 가능).[9.2]
4.3에서는 여기에도 4장의 ELR 조건을 적용한다. unclean을 꺼도 ISR이 비었을 때 안전한 선출 후보인 ELR이 있으면 그 후보로 복구할 수 있다. ELR 선출은 뒤처진 아무 복제본을 택하는 unclean 선출이 아니다. 반대로 데이터가 남은 복제본이 있어도 지금 읽기 가용성이 확보된다는 뜻은 아니다. 선출 가능한 복제본과 컨트롤러의 선출 진행 여부를 확인해야 한다. “ISR이 없으면 기다린다”는 앞의 설명은 ELR 등 안전한 대체 후보까지 사용할 수 없는 경우로 한정한다.[9.6]
ELR이 켜진 4.1 이후 새 클러스터에서는 min.insync.replicas를 다루는 규칙이 달라진다. ELR 문서에 따르면 ELR이 켜지면 다음이 적용된다.[9.6]
- 클러스터 수준
min.insync.replicas가 없으면 활성 컨트롤러의 정적 설정값으로 추가된다. 클러스터 수준 값은 지울 수 없다. - 클러스터 수준 값을 갱신하면(값이 같더라도) 모든 ELR 상태가 초기화된다.
- 브로커 수준에 설정해 둔
min.insync.replicas는 제거되고, 브로커 수준 변경은 허용되지 않는다. 필요하면 클러스터 수준에 설정해야 한다. - 토픽의
min.insync.replicas를 바꾸면 그 토픽의 ELR 상태가 초기화된다.
설정 문서도 ELR이 켜지면 이 설정의 의미가 바뀐다고 경고하고 ELR 절을 참조하라고 한다.[9.1] 운영 스크립트가 브로커별로 min.insync.replicas를 바꾸는 방식이었다면, 4.x로 넘어오면서 그 스크립트부터 고쳐야 한다.
프로듀서 설정끼리의 조합
프로듀서 설정 사이에도 서로 얽힌 규칙이 있다. 설정 문서의 각 항목에 이런 조건들이 적혀 있다.[9.3]
- 멱등성과 그 전제. 멱등성은
acks=all,retries > 0,max.in.flight.requests.per.connection ≤ 5를 요구한다. 충돌하는 값을 주고 멱등성을 명시적으로 켜지 않으면 멱등성이 조용히 꺼진다. 명시적으로 켰다면 설정 예외가 난다. 중요한 토픽의 프로듀서라면enable.idempotence=true를 명시해 두는 편이, 누군가acks를 바꿨을 때 조용히 보장을 잃는 대신 시작 시점에 실패하게 만든다. - 시간 설정의 크기 관계.
delivery.timeout.ms는request.timeout.ms와linger.ms의 합보다 크거나 같아야 한다.request.timeout.ms(기본 30초)는 불필요한 재시도로 인한 중복 가능성을 줄이기 위해 브로커의replica.lag.time.max.ms보다 크게 두라고 문서는 권한다. 두 값의 기본값은 모두 30초다. - 배치와 지연.
linger.ms의 기본값은 4.0에서 0에서 5로 바뀌었다. 문서는 더 큰 배치가 주는 효율 덕분에 약간 늘어난 대기에도 지연이 비슷하거나 낮아지는 경우가 많기 때문이라고 설명한다.batch.size(기본 16384바이트)에 먼저 도달하면 기다리지 않고 보낸다. - 버퍼가 찼을 때. 브로커로 보내는 속도보다 레코드가 빨리 쌓이면
buffer.memory(기본 32MiB)가 차고,send()는max.block.ms(기본 1분)까지 멈췄다가 예외를 던진다. 앞 절의 “쓰기 거절” 상황이 길어지면 재시도할 레코드가 버퍼에 쌓여 결국 애플리케이션 스레드가send()에서 멈추게 된다. 브로커 쪽 장애가 호출자 쪽 지연으로 번지는 경로다.
컨슈머 설정끼리의 조합
컨슈머 쪽 조합은 5장과 6장에서 대부분 보았다. 한곳에 모으면 이렇다.[9.4]
enable.auto.commit=true는 받은 레코드를 다음poll()전에 다 처리하는 구조에서만 at-least-once다. 처리를 다른 스레드로 넘기면 누락을 만든다(6장).max.poll.records(기본 500) × 레코드당 최악의 처리 시간 <max.poll.interval.ms(기본 5분)가 성립해야 리밸런스 반복을 피한다(5장).- 세션 타임아웃은
classic프로토콜이면 컨슈머 설정session.timeout.ms,consumer프로토콜이면 브로커 설정group.consumer.session.timeout.ms가 정한다. 프로토콜을 바꾸면 이 값을 어디서 조정하는지도 바뀐다(5장). - 트랜잭션으로 쓴 토픽을 읽는다면
isolation.level=read_committed를 쓸지 결정해야 한다. 기본값read_uncommitted는 abort된 레코드까지 보여 준다(7장). auto.offset.reset은 소비자의 역할에 맞춰 고른다. 기본값latest는 새 그룹과 만료된 그룹에서 조용한 누락을 만든다(8장).
기본값이 만드는 토픽
설정 조합 가운데 가장 흔히 놓치는 것은 아무것도 설정하지 않았을 때 만들어지는 토픽이다. 브로커 기본값을 보면 이렇다.[9.5]
auto.create.topics.enable의 기본값은true다. 존재하지 않는 토픽에 쓰거나 읽으려 하면 자동으로 만들어진다.- 자동 생성 토픽의 파티션 수
num.partitions와 복제 계수default.replication.factor는 기본값이 모두 1이다.
누군가 설정 파일에 payments 대신 payment라고 오타를 내면, 그 프로듀서는 오류 없이 파티션 1개, 복제본 1개짜리 새 토픽에 결제를 쓰기 시작한다. 이 토픽은 4장의 어떤 보호도 받지 못하고, 정산 배치는 이 토픽을 읽지도 않는다. 운영 문서가 자동 생성을 쓴다면 자동 생성 토픽의 기본 설정을 조정하라고 권하는 이유이며, 운영 클러스터에서는 자동 생성을 끄는 선택도 흔하다.[9.7]
반대 방향의 함정도 있다. 내부 토픽인 __consumer_offsets의 복제 계수 기본값은 3이고, 클러스터 크기가 이 값에 이를 때까지 내부 토픽 생성이 실패한다.[9.5] 트랜잭션 상태 토픽도 마찬가지다(7장). 브로커 한두 대짜리 개발 환경에서 컨슈머 그룹이나 트랜잭션이 동작하지 않는다면 이 값부터 확인해 볼 만하다.
오타 사례는 자동 생성이 허용되고 해당 클라이언트에 생성 권한이 있는 환경을 전제로 한다. 존재하지 않는 토픽을 만난 모든 구성에서 반드시 자동 생성되는 것은 아니다. 자동 생성 차단과 별개로, 의도한 토픽을 배포 전에 명시적으로 만들고 파티션 수·복제 계수·실제 토픽 설정을 확인하면 기본값에 맡긴 새 토픽과 승인된 토픽을 구분할 수 있다. 이는 설정 문서의 기본값에서 도출한 운영 절차다.
설정은 어디에 걸리는가
같은 이름의 설정이 여러 곳에 있으면 어느 값이 우선하는지 확인해야 한다. 토픽 설정 문서의 각 항목에는 “Server Default Property”가 적혀 있다.
예를 들어 토픽의 retention.ms에 대응하는 브로커 기본값은 log.retention.ms다.[9.10] 토픽을 만들 때나 나중에 kafka-configs.sh로 준 토픽 설정은 브로커 기본값보다 우선한다.[9.7] 그래서 “브로커 기본값을 바꿨는데 이 토픽은 그대로다”라는 상황이 생긴다. 그 토픽에 이미 재정의된 값이 있기 때문이다. 조합을 점검할 때는 브로커 설정 파일이 아니라 토픽별 실제 적용값을 확인해야 한다.
확인 질문
- 템플릿을 복제 계수 3,
min.insync.replicas=2로 고쳤다. 롤링 재시작 도중 두 번째 브로커를 재시작하고 있을 때, 세 번째 브로커의 디스크가 느려져 40초 동안 리더를 따라가지 못했다. 그 40초 동안 결제 서비스에는 무슨 일이 생길까? - 새로 만든 정산 보조 서비스의 설정 파일에 토픽 이름이
payment로 잘못 들어갔다. 프로듀서도 컨슈머도 오류를 내지 않았다. 일주일 뒤 이 사실을 알았을 때, 그동안의 데이터는 어떤 상태이고 무엇을 확인해야 할까?
확인 질문 1 해설
두 번째 브로커가 재시작 중이라 ISR은 이미 2다. 여기에 세 번째 브로커가 replica.lag.time.max.ms(기본 30초)를 넘겨 뒤처지면 ISR에서 빠지고 ISR은 1이 된다.
30초가 지난 시점부터 나머지 10초 동안 acks=all 쓰기가 거절되고, 그 뒤 세 번째 브로커가 따라잡아 다시 ISR에 들어오거나 재시작한 브로커가 합류하면 풀린다. 처음 30초 동안은 오히려 쓰기가 느려진다. 리더가 ISR에 남아 있는 느린 브로커의 확인을 기다리기 때문이다. 재시작 중에는 여유가 0이라는 뜻이며, 10장에서 볼 AtMinIsrPartitionCount 같은 지표가 이런 창을 미리 보여 준다.
확인 질문 2 해설
브로커 기본값에 따라 payment 토픽이 자동으로 만들어졌을 것이다. 파티션 1개, 복제본 1개다. 일주일 동안의 데이터는 그 한 브로커에만 있고, 그 브로커가 디스크를 잃었다면 이미 사라졌다. 보존 기간은 기본 7일이므로 앞부분은 곧 지워지기 시작한다. 확인할 것은 토픽이 실제로 어떤 설정으로 만들어졌는지(kafka-topics.sh --describe), 어떤 소비자가 이 토픽을 읽고 있는지(아마 아무도 없음), 그리고 원래 토픽으로 옮겨야 할 데이터의 범위다. 재발 방지로는 운영 클러스터의 자동 토픽 생성을 끄는 것이 가장 확실하다.
10장. 증상에서 관측으로 | 지연 · 복제 · 소비 상태
정상 운영의 기준은 “컨슈머 프로세스가 살아 있다”보다 구체적이다. 주문이 들어오면 프로듀서의 전송이 성공하고, 컨슈머가 읽고, 알림 API가 완료되고, 복구 위치의 커밋이 뒤따른다. 이 경로에서는 유입과 처리 속도가 균형을 이루고 커밋 위치도 실제 완료를 따라간다. 어떤 구간이 늦는지에 따라 고객이 기다리는 시간과 재시작 후 재처리 범위는 다르게 변한다.
관측은 이 각 단계의 진척을 숫자나 로그로 확인하는 일이다. 지표(metric)는 관측값을 시간에 따라 모은 숫자이고, 가설은 그 변화의 원인에 대한 잠정 설명이다. 아래에서 평균은 전반적인 추세, 최대값과 분포는 일부 요청이나 파티션의 정체를 찾는 데 쓴다. 하나의 평균이 정상이라고 모든 파티션이나 업무가 정상인 것은 아니다. Kafka 지표와 실제 알림 완료 시각을 연결해야 한다.
가상 시나리오 · 새벽 3시의 알림 지연
새벽 3시 7분, 당직 엔지니어의 휴대폰이 울렸다. 고객 센터 경보다. “주문 알림이 20분씩 늦게 온다.” 대시보드를 열어 보니 알림 서비스(그룹 notify)의 소비 지연 그래프가 1시간 전부터 계속 오르고 있다. 다른 상태는 아직 확인하지 못했다.
예측 질문
알림 서비스의 소비 지연이 계속 오르는 원인에 대한 가설을 셋 세워 보자. 그리고 가설마다 “이 관측치가 이렇게 나오면 이 가설이 맞다”고 가를 수 있는 관측치를 하나씩 짝지어 보자.
앞의 장들은 구성을 보고 결과를 예측했다. 운영에서는 방향이 반대다. 결과(증상)를 보고 구성과 원인을 거꾸로 추정해야 한다. “어느 단계가 막혔는지를 가르는 질문”을 먼저 세우고, 그 질문에 답하는 지표를 필요할 때 찾아 쓰면 된다.
소비 지연이라는 숫자는 두 개다
먼저 “소비 지연(lag)”이 무엇을 기준으로 한 숫자인지부터 확인해야 한다. 5장에서 본 세 경계(커밋된 오프셋, 위치, 로그 끝)를 떠올리자.
운영 도구 kafka-consumer-groups.sh --describe는 파티션마다 CURRENT-OFFSET, LOG-END-OFFSET, LAG을 보여 준다. 운영 문서는 이 도구가 그룹의 모든 컨슈머 위치와 로그 끝으로부터 얼마나 뒤처졌는지를 보여 준다고 설명한다.[10.1] 여기서 CURRENT-OFFSET은 그룹이 커밋한 오프셋이다. 도구는 그룹 코디네이터에 저장된 값을 조회할 수밖에 없기 때문이다.
반면 컨슈머 클라이언트가 내보내는 records-lag-max 지표는 문서에 “커밋된 오프셋이 아니라 현재 오프셋(위치) 기준”이라고 명시되어 있다.[10.2] 트랜잭션 토픽을 read_committed로 읽는다면 이 지표는 로그 끝이 아니라 LSO를 기준으로 계산된다.[10.9]
두 숫자가 크게 다르면 기준점부터 확인한다. 같은 끝 경계를 비교하는 상황에서는 위치와 커밋 사이의 차이가 커밋 지연을 시사한다. 그러나 read_committed에서는 열린 트랜잭션이 LSO를 붙잡아 클라이언트 지표만 작게 보일 수도 있다. 위치는 읽기 진척이지 외부 업무 완료 증명이 아니다. LSO와 HW의 차이, 커밋 오류, 실제 처리 완료 지점을 함께 확인해야 재시작 뒤 재처리 범위를 판단할 수 있다(6·7장).
가상 관측 · 기준점을 먼저 맞춰 보기
트랜잭션이 없는 한 파티션에서 같은 시점의 끝 경계를 52, 컨슈머 position을 49, 그룹의 커밋을 44로 관측했다고 하자. 도구의 커밋 기준 거리는 8, 클라이언트의 위치 기준 거리는 3, position과 커밋 사이 거리는 5다. 이 5는 “받았지만 커밋 기준에 아직 포함하지 않은 오프셋 구간”이며, 곧바로 미처리 5건이라고 해석할 수는 없다. 받은 레코드를 이미 완료했을 수도 있고 처리 큐에 남겼을 수도 있다.
열린 트랜잭션 때문에 LSO가 49에 머물고 position도 49라면, 클라이언트 lag가 0이어도 뒤의 복제된 레코드를 아직 받지 못한 상태일 수 있다. 따라서 먼저 끝 경계가 로그 끝·복제 커밋 경계·LSO 중 무엇인지, 수집 시점과 파티션이 같은지 확인한다. 또 오프셋에는 컴팩션이나 제어 레코드로 생긴 빈 번호가 있으므로 거리와 실제 업무 레코드 수는 항상 같지 않다.[10.2] [10.9]
오프셋 lag와 업무 지연 시간도 단위가 다르다. lag는 오프셋 경계 사이의 거리이고, 고객이 기다리는 시간은 이벤트 발생 또는 접수부터 실제 알림 완료까지의 경과 시간이다. 비동기 큐가 쌓이면 poll()은 빠르게 돌아가 lag가 작아도 알림 완료가 늦는다. 가상 알림 서비스라면 이벤트 시각과 API 완료 시각을 함께 기록하되, 시계 차이와 재전달도 고려해 업무 지연을 따로 관측한다.
막힌 곳을 찾는 순서
소비 지연이 오른다는 것은 “들어오는 속도 > 처리하는 속도”라는 뜻이다. 원인은 세 방향으로 나눠 살펴볼 수 있다. 들어오는 속도가 늘었는가, 처리하는 속도가 줄었는가, 아니면 처리하는 쪽이 아예 일을 못 하고 있는가. 이 질문을 관측치로 옮기면 다음 흐름이 된다.
컨슈머와 그룹 쪽 신호
컨슈머 클라이언트와 그룹 코디네이터가 내보내는 지표 가운데 이 흐름에 쓰이는 것을 모으면 다음과 같다.[10.3] [10.4]
| 지표 | 뜻 | 무엇을 가르는가 |
|---|---|---|
time-between-poll-avg, time-between-poll-max |
poll() 호출 사이의 평균·최대 간격 |
처리 시간이 늘었는지. 최대값이 max.poll.interval.ms에 다가가면 위험 |
last-poll-seconds-ago |
마지막 poll() 이후 흐른 초 |
컨슈머가 멈췄는지 |
poll-idle-ratio-avg |
poll()이 사용자 코드 처리 대신 데이터를 기다리며 쉬는 시간의 비율 |
낮으면 처리가 바쁨, 높으면 데이터가 없음 |
records-lag, records-lag-max |
파티션별·최대 소비 지연(위치 기준) | 지연이 특정 파티션에 몰렸는지 |
commit-latency-avg, commit-rate |
커밋 요청의 지연과 빈도 | 커밋이 느리거나 멈췄는지 |
rebalance-rate-per-hour, failed-rebalance-rate-per-hour, last-rebalance-seconds-ago |
시간당 리밸런스 참여 횟수, 실패 횟수, 마지막 리밸런스 이후 경과 | 그룹이 불안정한지 |
consumer-group-rebalance-rate (브로커) |
코디네이터가 본 컨슈머 그룹 리밸런스 비율 | 여러 그룹에 걸친 문제인지 |
poll-idle-ratio-avg가 높다는 관측은 컨슈머가 레코드 처리를 기다리는 데 비해 poll() 안에서 보내는 비율이 높다는 신호다. 그것만으로 “업무 요청이 없다”고 확정하지 않는다. 데이터 유입이 적은 경우뿐 아니라 가져오기 지연, 할당량에 의한 대기, 열린 트랜잭션 때문에 반환할 데이터가 막힌 경우도 다른 관측과 대조한다. 낮은 값 역시 동기 처리 루프에서는 처리 시간 증가를 의심하게 하지만, 별도 스레드의 업무 완료 시간을 직접 측정하지는 않는다.[10.3]
도구 쪽에서는 kafka-consumer-groups.sh --describe --members로 그룹의 활성 멤버 목록과 멤버별 파티션 수를, --members --verbose로 멤버별 할당을 볼 수 있다.[10.1] 운영 문서는 그룹이 consumer 프로토콜을 쓰면 관리 클라이언트가 그룹이 구독하는 모든 토픽에 대한 DESCRIBE 권한을 가져야 한다고 덧붙인다.[10.1] 보안이 걸린 클러스터에서 당직용 계정의 권한을 미리 확인해 둘 이유다.
복제와 컨트롤러 쪽 신호
소비 지연의 원인이 브로커 쪽에 있을 수도 있고, 반대로 쓰기 쪽 증상(“결제 쓰기가 실패한다”)을 추적하다 여기로 올 수도 있다. 운영 문서가 그래프와 경보를 권하는 브로커 지표 가운데 복제 상태를 보여 주는 것들은 다음과 같다. 괄호 안은 문서에 적힌 정상값이다.[10.5]
| 지표 | 뜻(정상값) | 4장·9장과의 연결 |
|---|---|---|
UnderReplicatedPartitions |
ISR < 전체 복제본인 파티션 수 (0) | 복제본 하나 이상이 빠짐. 쓰기는 계속될 수 있음 |
AtMinIsrPartitionCount |
ISR = min.insync.replicas인 파티션 수 (0) |
여유 0. 하나 더 빠지면 acks=all 쓰기 거절 |
UnderMinIsrPartitionCount |
ISR < min.insync.replicas인 파티션 수 (0) |
acks=all 쓰기가 지금 거절되고 있음 |
IsrShrinksPerSec, IsrExpandsPerSec |
ISR 축소·확장 비율 (브로커 재시작 때 말고는 0) | 팔로워가 들락날락함. 디스크·네트워크 의심 |
FailedIsrUpdatesPerSec |
ISR 갱신 실패 비율 (0) | 컨트롤러와의 통신 문제 |
MaxLag (ReplicaFetcherManager) |
팔로워와 리더 사이 최대 지연(메시지 수) | produce 요청의 최대 배치 크기에 비례해야 정상 |
OfflinePartitionsCount (컨트롤러) |
리더가 없는 파티션 수 | 읽기·쓰기 모두 불가 |
ActiveControllerCount |
이 노드의 활성 컨트롤러 여부 (클러스터 전체에서 한 노드만 1) | 대기 노드들의 0은 정상. 안정 상태에서 전체에 1이 없거나 둘 이상이면 조사 |
UncleanLeaderElectionsPerSec |
unclean 선출 비율 (0) | 커밋된 데이터 손실이 있었을 수 있음 |
ElectionFromEligibleLeaderReplicasPerSec |
ELR에서의 선출 비율 (0) | ISR이 비었는데 ELR 덕분에 복구함 |
세 줄을 묶어 보면 쓰기 쪽 상태를 단계별로 읽을 수 있다. UnderReplicatedPartitions > 0은 “복제본이 모자라지만 아직 괜찮음”, AtMinIsrPartitionCount > 0은 “하나만 더 빠지면 쓰기 장애”, UnderMinIsrPartitionCount > 0은 “지금 acks=all 쓰기가 실패 중”이다. 9장의 표를 운영 지표로 옮긴 셈이다.
이 표현은 요청이 실제로 들어오고 있다는 가정하의 복제 위험 분류다. UnderMinIsrPartitionCount는 기준 아래인 파티션 수이지 실패 요청 횟수가 아니다. 쓰기 요청이 없으면 파티션 위험은 있어도 전송 오류율은 0일 수 있다. UnderReplicatedPartitions만으로 “괜찮음”을 확정하지도 않는다. ISR이 줄었지만 아직 min보다 큰 상태인지, 이미 min과 같거나 아래인지 함께 본다.
표의 이름은 Kafka가 내보내는 지표 이름이다. 모니터링 시스템에 따라 이름이 변환될 수 있으므로 해당 JMX 지표의 브로커·컨트롤러·클라이언트와 토픽·파티션 범위를 맞춘다. 순간 파티션 수, 초당 비율, 누적 횟수를 같은 값처럼 더하지 않는다. 예를 들어 계획된 재시작에서 ISR 축소율이 잠깐 오른 뒤 ISR 확장이 이어지는 것과, 축소·확장이 계속 반복되는 것은 다른 가설을 요구한다.[10.5]
요청 지연을 쪼개 보기
1.2절의 요청 경로와 1.4절의 writeback을 함께 보면 디스크 지연이 늘어난 방식도 나눌 수 있다. 캐시가 쓰기를 받아주는 동안과 dirty 데이터가 쌓여 쓰기가 막히는 동안은 다르다. 조사 가설: 로컬 처리 시간이 커졌다면 저장장치와 OS의 writeback, CPU·GC와 요청 처리 부하를 함께 확인한다. 같은 시간 지표가 커졌다는 사실만으로 디스크나 GC 하나를 원인으로 확정하지 않는다.
브로커가 요청을 느리게 처리하는 것 같다면, 요청 지연을 구간별로 나눠 볼 수 있다. 운영 문서는 요청 종류별(Produce, FetchConsumer, FetchFollower) 전체 시간 TotalTimeMs가 대기열 시간, 로컬 처리 시간, 원격 대기 시간, 응답 대기열 시간, 응답 전송 시간으로 나뉜다고 설명한다.[10.6]
RequestQueueTimeMs: 요청이 요청 대기열에서 기다린 시간. 크면 브로커의 처리 스레드가 부족하다.LocalTimeMs: 리더가 요청을 처리한 시간. 디스크 쓰기나 읽기가 느리면 커진다.RemoteTimeMs: 팔로워를 기다린 시간. 문서는acks=-1(all)인 produce 요청에서 0이 아니라고 적는다. 4장에서 본 “ISR 전체의 확인”을 기다리는 시간이 여기에 잡힌다.ResponseQueueTimeMs,ResponseSendTimeMs: 응답이 대기열에서 기다리고 전송되는 시간.
함께 볼 지표도 있다. acks=-1을 쓰면 produce 요청 대기실(purgatory) 크기가 0이 아니고, fetch 대기실 크기는 컨슈머의 fetch.max.wait.ms에 따라 달라진다. 요청 처리 스레드의 유휴 비율 RequestHandlerAvgIdlePercent와 네트워크 처리 스레드의 유휴 비율 NetworkProcessorAvgIdlePercent는 0과 1 사이이며 0.3보다 큰 것이 이상적이라고 문서는 적는다.[10.6]
할당량(quota)에 걸린 클라이언트는 사용자·클라이언트 ID별 throttle-time이 0보다 커진다.[10.6] 설계 문서에 따르면 브로커는 할당량을 넘은 클라이언트에게 지연 시간을 담은 응답을 즉시 돌려주고, 그 시간 동안 해당 채널을 음소거한다. fetch 응답이라면 데이터가 없다.[10.10] 컨슈머가 “느린데 브로커는 한가한” 상황이라면 할당량을 의심할 수 있다.
예를 들어 결제 서비스의 produce 지연이 올랐고, 분해해 보니 RemoteTimeMs만 커졌다면 리더 처리보다는 팔로워의 확인을 기다리는 시간이 늘어난 것이다. 팔로워 하나가 느려져 ISR에 남은 채 뒤처지고 있을 가능성이 높다. MaxLag와 곧 이어질 IsrShrinksPerSec을 함께 확인해 그 가설을 검토한다.
요청 시간 분해는 먼저 조사할 구간을 좁힌다. RequestQueueTimeMs가 크다고 곧바로 처리 스레드 수만 늘리거나, LocalTimeMs가 크다고 디스크만 교체하는 결론으로 뛰지는 않는다. CPU·GC·잠금·디스크·요청 유입량이 처리 능력을 제한할 수 있기 때문이다. 같은 구간의 부하와 유휴 비율, OS 지표를 함께 확인해 “처리 용량이 모자라다”와 “한 종류의 작업이 막힌다”를 가른다. 이 연결은 지표 정의에서 도출한 조사 가설이며 지표 하나의 공식 원인 판정 규칙은 아니다.
프로듀서 쪽 신호
쓰기 쪽 증상은 프로듀서 지표에서 먼저 보인다.[10.7]
record-error-rate: 오류로 끝난 전송의 초당 평균. 0보다 크면 애플리케이션이 실패 콜백을 받고 있다.record-retry-rate: 재시도한 전송의 초당 평균. 오류로 끝나지 않았더라도 재시도가 늘면 브로커 쪽 문제(리더 변경, ISR 부족)가 진행 중일 수 있다.record-queue-time-avg: 레코드 배치가 전송 버퍼에서 보낸 평균 시간. 9장에서 본 버퍼 포화의 전조다.request-latency-avg,produce-throttle-time-avg: 요청 지연과 할당량으로 인한 지연.
증상 → 가설 → 관측
새벽 3시의 알림 지연처럼 증상부터 조사할 때 쓸 가설과 관측치를 묶으면 다음과 같다.
| 증상 | 가설 | 가르는 관측 |
|---|---|---|
| 모든 파티션의 지연이 고르게 오름 | 처리 속도 저하(하류 API 지연 등) | time-between-poll-max 증가, poll-idle-ratio-avg 감소 |
| 지연이 톱니 모양으로 오르내림 | 리밸런스 반복 | rebalance-rate-per-hour 증가, last-rebalance-seconds-ago가 계속 작음 |
| 특정 파티션만 지연 | 키 쏠림(핫 파티션), 그 파티션 담당 멤버 문제 | 파티션별 records-lag, --members --verbose로 담당 멤버 확인 |
| 지연은 오르는데 컨슈머는 한가함 | 유입 급증 또는 가져오기 지연 | 토픽별 MessagesInPerSec, FetchConsumer의 TotalTimeMs 분해, throttle-time |
| 도구의 LAG만 크고 클라이언트 지표는 작음 | 커밋 지연 또는 read_committed의 LSO 정체 |
commit-rate, 커밋 예외, LSO–HW 차이·열린 트랜잭션, 실제 업무 처리 완료 |
acks=all 쓰기 실패 |
ISR 부족 | UnderMinIsrPartitionCount, IsrShrinksPerSec |
| 쓰기는 성공하는데 소비자에게 안 옴 | 엄격한 최소 ISR로 하이 워터마크 정지, 또는 열린 트랜잭션 | UnderMinIsrPartitionCount와 acks 설정 확인(9장), read_committed라면 LSO(7장) |
조치할 때 쓰는 도구와 주의점
원인을 찾은 뒤에는 위치를 옮겨야 할 때가 있다. 예를 들어 처리할 가치가 없어진 오래된 알림을 건너뛰거나, 버그 수정 뒤 특정 시각부터 다시 처리하는 경우다. kafka-consumer-groups.sh --reset-offsets가 그 도구다. 운영 문서가 적은 사용 규칙은 이렇다.[10.8]
- 한 번에 한 컨슈머 그룹만 다루며,
--all-topics나--topic으로 범위를 정해야 한다. - 먼저 컨슈머 인스턴스가 모두 멈춰 있는지 확인해야 한다.
- 기본 실행은 어떤 오프셋으로 바뀔지 보여 주기만 한다. 실제로 바꾸려면
--execute를 붙인다.--export로 결과를 CSV로 내보낼 수 있다. - 방식은
--to-datetime,--to-earliest,--to-latest,--shift-by,--from-file,--to-current,--by-duration,--to-offset등이다. - 범위를 벗어난 오프셋은 가능한 끝 값으로 조정된다. 예를 들어 끝이 10인데 15만큼 옮기라고 하면 10이 선택된다.
5장에서 본 것처럼, 컨슈머를 멈추지 않고 오프셋을 바꾸면 살아 있는 컨슈머는 메모리 속 위치로 계속 읽다가 자기 위치를 다시 커밋해, 방금 바꾼 오프셋을 덮어쓸 수 있다. 오프셋을 옮기는 일은 “그룹을 멈추고, 미리 보고, 실행하고, 다시 띄우는” 절차로 다뤄야 한다. 11장에서 이 도구를 재처리 비용과 함께 다시 본다.
관측치를 쓸 때의 주의
- 도구의 LAG는 커밋 기준, 클라이언트의
records-lag-max는 위치 기준이다.read_committed라면 LSO 기준이다. - 지표는 가설을 가르는 도구다. 한 지표만으로 원인을 확정하지 말고, 인과 모델에서 “이 가설이 맞다면 함께 움직여야 할” 다른 지표를 확인한다.
- 문서의 정상값은 기준점일 뿐이다. 브로커 재시작 중의 ISR 축소처럼 예상되는 변동을 구분해야 한다.
- 오프셋 초기화는 그룹이 멈춘 상태에서만 안전하다.
확인 질문
- 새벽의 알림 지연을 조사해 보니
kafka-consumer-groups.sh의 LAG는 파티션마다 수십만인데, 컨슈머의records-lag-max는 수십 수준이고poll-idle-ratio-avg도 높다. 알림은 실제로 늦게 가고 있을까? 이 상태에서 알림 서비스를 재시작하면 어떤 일이 생길까? UnderReplicatedPartitions는 40인데UnderMinIsrPartitionCount와AtMinIsrPartitionCount는 0이다. 모든 토픽은 복제 계수 3,min.insync.replicas=2다. 지금 어떤 상태이며, 이 조합이 가능하려면 무엇이 참이어야 할까?
확인 질문 1 해설
이 지표만으로 알림 완료나 커밋 장애를 단정할 수 없다. read_committed라면 열린 트랜잭션 때문에 LSO가 뒤에 머물러, 클라이언트 lag는 작아도 전달은 실제로 늦을 수 있다. LSO가 로그 끝을 따라가고 있다면 커밋 지연을 조사하되, 비동기 처리 큐와 외부 알림 API의 완료 상태도 확인한다. 실제 처리를 끝냈지만 커밋하지 않은 레코드는 재시작 뒤 다시 처리되어 중복 알림을 만들 수 있다. 먼저 격리 수준·열린 트랜잭션·커밋 위치·업무 완료 지점을 확인하고, 재처리 시의 멱등 계약을 점검한다.
확인 질문 2 해설
40개 파티션에서 복제본이 하나 이상 ISR 밖에 있다. 전제대로 복제 계수 3, min.insync.replicas=2라면 그 파티션들의 ISR은 2 또는 1이다. ISR이 2이면 “ISR = min”이라 AtMinIsrPartitionCount에, 1이면 UnderMinIsrPartitionCount에 잡혀야 한다. 둘 다 0이라는 관측은 전제와 모순이다. 가능한 설명은 전제 쪽에 있다. 그 40개 파티션이 복제 계수가 3보다 큰 토픽이거나, 토픽 수준에서 min.insync.replicas가 1로 재정의되어 있거나(9장의 설정 우선순위), 지표를 모은 브로커 범위나 시점이 서로 다르다. 이 확인 질문의 요점은 지표 사이의 논리적 관계를 알면 모순을 발견할 수 있다는 것이다. 모순은 전제(설정, 수집 방식)를 다시 보라는 신호다.
11장. 가용성, 내구성, 재처리 비용 사이에서 고르기 | 설계 판단의 실제
지금까지 배운 보장은 한 경계에서 얻은 성공이 어디까지 이어지는지에 관한 것이었다. 복제는 Kafka 로그의 생존 조건, 오프셋 커밋은 재시작 지점, 트랜잭션은 Kafka 출력과 입력 위치의 원자성, 보존은 다시 읽을 수 있는 자료의 범위를 정한다. 이 네 가지를 모두 높인다고 외부 업무의 성공이 자동으로 완성되지는 않는다.
설계 판단에서는 먼저 복구해야 할 결과를 정한다. 결제라면 빠진 금액 없이 정산해야 하고, 지도라면 지금 위치를 계속 보여 주어야 하며, 카탈로그라면 새 검색 인덱스를 정확한 현재 상태로 채워야 한다. 아래의 선택은 가상 한결마켓의 이 요구에서 도출한 예시다. 공식 문서가 모든 결제·위치·상품 시스템에 같은 설정을 권고한다는 뜻은 아니다.
가상 시나리오 · 한 템플릿으로 세 스트림을
한결마켓 아키텍처 리뷰에 새 스트림 세 개가 올라왔다.
payments: 결제 결과. 초당 수백 건. 잃으면 돈 문제가 되고, 정산과 감사에서 몇 주 뒤에도 다시 읽는다.rider-locations: 배달 기사 앱이 3초마다 보내는 위치 핑. 초당 수만 건. 고객 앱의 지도에 기사 위치를 보여 주는 데 쓴다.catalog: 상품 정보 변경(이름, 가격, 판매 상태). 하루 수만 건. 검색, 추천, 가격 비교 서비스가 이 토픽으로 자기 상태를 만든다.
한 참석자가 제안했다. “9장에서 정리한 표준 템플릿(복제 계수 3, min.insync.replicas=2, acks=all, 보존 7일)을 셋 다 쓰면 관리가 편합니다.”
예측 질문
rider-locations를 이 표준 템플릿으로 만들면 무엇이 먼저 문제가 될까? 반대로 payments를 “위치 핑처럼” 가볍게(복제 계수 2, min.insync.replicas=1, acks=1, 보존 6시간) 만들면, 어떤 사건에서 무엇을 잃게 될까?
스트림마다 허용할 손실과 중단을 먼저 정해야 설정을 고를 수 있다. 설계 문서는 최소 ISR과 unclean 선출을 일관성·가용성 사이의 선택으로, 프로듀서의 acks를 지연·내구성 사이의 선호로 설명한다.[11.1]
세 가지 비용을 같은 말로 정의하기
리뷰에서 “안전하게”, “가볍게” 같은 말이 오가면 서로 다른 것을 뜻하기 쉽다. 먼저 세 가지 비용을 관찰할 수 있는 결과로 정의하자.
- 가용성: 장애가 난 동안 쓰기와 읽기가 계속되는가. 더 정확히는, 어떤 장애에서 쓰기가 멈추고 어떤 장애에서 읽기가 멈추는가.
- 내구성: 성공 응답을 받은 데이터가 어떤 장애 뒤에도 남는가. 4장에서 본 대로 Kafka의 문서상 보장은 “커밋된 메시지는 동기화 복제본이 하나라도 살아 있는 한 잃지 않는다”이며, 성공 응답이 커밋을 뜻하는지는
acks와min.insync.replicas가 정한다. - 재처리 비용: 과거 데이터를 다시 처리해야 할 때 그것이 가능한지, 가능하다면 얼마나 드는지. 보존 기간이 가능한 범위를 정하고, 중복 부작용과 처리 시간, 다른 소비자에게 주는 부담이 비용을 정한다.
여기에 지연을 덧붙일 수 있다. 설계 문서는 프로듀서가 커밋을 기다리면 10밀리초 수준의 시간이 걸릴 수 있다고 적는다.[11.3] 대부분의 서비스에서 이 차이는 결정적이지 않지만, 초당 수만 건을 보내는 프로듀서의 버퍼와 재시도 동작에는 영향을 준다(9장).
세 비용은 서로 맞바꿔진다. 복제본을 늘리고 min.insync.replicas를 높이면 내구성은 높아지지만 쓰기가 멈추는 장애 조건이 늘고 저장 비용도 커진다. 보존을 늘리면 재처리 범위는 넓어지고 디스크 비용이 늘어난다. 모든 스트림에 가장 “안전한” 값을 주면, 안전이 필요 없는 스트림에서 비용만 치르게 된다.
결제 결과: 내구성을 사고 쓰기 가용성을 낸다
payments에서 가장 비싼 실패는 조용한 손실이다. 결제 서비스는 성공으로 알고 고객은 돈을 냈는데 정산 배치에는 그 결제가 없는 상태다. 그래서 이 스트림에서는 내구성을 우선한다.
- 복제 계수 3,
min.insync.replicas=2, 모든 프로듀서acks=all, 멱등성 명시(9장).[11.2] unclean.leader.election.enable=false(기본값). ISR이 모두 사라지면 기다린다(4장).- 복제본을 서로 다른 랙이나 가용 영역에 배치한다(9장).[11.9]
- 보존은 재처리와 감사에 필요한 기간으로 길게 잡는다. 정산이 월 단위로 돌고 감사 요청이 몇 주 뒤에 온다면 7일은 짧다.
이 선택으로 포기하는 것을 문장으로 적어 두어야 한다. 브로커 두 대가 동시에 사라지면 payments에 대한 쓰기가 멈춘다. 결제 서비스 입장에서 그것은 “결제 결과를 기록할 수 없다”는 장애다. 복제본 세 벌만큼의 디스크와 복제 트래픽도 비용이다. 설계 문서는 과반수 투표 방식과 비교해 ISR 방식이 같은 수의 장애를 견디면서 더 적은 복제본으로 충분하다고 설명하지만, 그래도 복제본 수만큼 디스크를 쓰는 것은 변하지 않는다.[11.10]
이 선택이 틀리는 조건도 있다. 결제 서비스가 “기록할 수 없으면 결제 자체를 멈춰야 하는” 구조라면, 브로커 두 대 장애가 곧 매출 중단이 된다. 이때 흔한 실수는 쓰기 가용성을 되찾으려고 min.insync.replicas를 1로 낮추는 것이다. 그러면 가용성은 돌아오지만 성공 응답 뒤에도 데이터를 잃을 수 있다(4장). 더 나은 방향은 Kafka의 내구성 설정은 그대로 두고, 결제 서비스가 자기 데이터베이스에 결과와 “보낼 이벤트”를 함께 기록한 뒤 Kafka가 돌아오면 내보내는 구조(7장의 아웃박스)로 쓰기 가용성 문제를 애플리케이션 쪽에서 흡수하는 것이다. 가용성과 내구성의 충돌을 한 설정값 안에서 풀려고 하지 말고, 충돌을 흡수할 층을 따로 두는 것이다.
아웃박스도 무한한 장애 흡수 공간은 아니다. Kafka에 전송할 수 없는 동안 업무 DB는 계속 사용 가능하고 outbox를 저장할 공간이 있어야 한다. 복구 후 전송 속도가 새 이벤트 유입보다 빨라야 밀린 이벤트가 줄어든다. 그동안 결제 기록이 DB에 남는 것과 정산 소비자가 곧바로 받는 것은 다른 가용성이다. 이 설계에서는 outbox의 가장 오래된 미전송 시각과 적체량도 관측한다. 구체적인 DB·relay의 원자성과 재전달 경계는 16장에서 이어 다룬다.
관측도 선택의 일부다. 이 스트림에서는 AtMinIsrPartitionCount가 0이 아니면 “여유가 0”이라는 뜻이므로 경보를 건다(10장). UncleanLeaderElectionsPerSec이 0이 아니면 누군가 설정을 바꿨다는 뜻이다.
배달 기사 위치 핑: 가용성과 지연을 사고 내구성을 낸다
rider-locations는 성격이 정반대다. 핑은 3초마다 새로 오고, 고객 앱이 보고 싶은 것은 가장 최근 위치다. 몇 초 전의 핑 하나가 사라져도 다음 핑이 곧 그 자리를 채운다. 반대로 지도가 멈추는 것, 즉 쓰기나 읽기가 멈추는 것은 사용자에게 바로 보인다.
예측 질문의 표준 템플릿을 이 스트림에 쓰면 먼저 가용성 문제가 생긴다. 롤링 재시작 도중 다른 브로커 하나가 잠시 뒤처지기만 해도 ISR이 1이 되어 acks=all 쓰기가 거절되고(9장), 초당 수만 건의 프로듀서는 재시도할 레코드로 버퍼가 차서 결국 send()에서 멈춘다(9장). 핑은 몇 초만 지나도 쓸모가 없는데, 버퍼에 쌓인 오래된 핑을 재시도하느라 새 핑을 못 보내는 상황이 된다. 두 번째는 비용이다. 7일 보존에 복제본 세 벌이면, 몇 초 뒤면 쓸모없어질 데이터를 일주일 치 세 벌씩 보관한다.
그래서 이 스트림은 다른 선택을 한다.
acks=1은 팔로워 확인을 기다리지 않아, 성공 응답 뒤 복제 전에 리더가 죽으면 핑을 잃을 수 있다.acks=all에min.insync.replicas=1을 쓰면 ISR이 하나여도 쓰기를 허용하지만, 성공 응답은 여전히 현재 ISR 전체의 확인을 기다린다. ISR이 리더 하나일 때만 리더 단독 확인으로 성공하므로 두 설정을 같은 내구성·지연 계약으로 취급하면 안 된다.- 보존은 몇 시간. 장애 분석이나 짧은 재처리에 충분한 정도로만 둔다.
- 컨슈머는 6장의 at-most-once 쪽을 택해도 된다. 지도 표시는 오래된 핑을 다시 처리할 이유가 없다.
- 키는 기사 ID. 한 기사의 핑 순서는 파티션 순서로 지켜진다(3장).
acks=1을 선택할 때는 9장에서 본 엄격한 최소 ISR 조건도 함께 확인한다. min이 2인 채 ISR이 1로 줄면 프로듀서가 성공을 받아도 새 핑의 복제 커밋 경계가 전진하지 않아 지도 소비자에게 즉시 보이지 않을 수 있다. 쓰기 응답의 가용성과 지도에 전달되는 가용성을 따로 검토해야 한다. 또 acks=1은 멱등 프로듀서의 요건과 충돌하므로 내부 재시도에서의 중복·순서 계약도 함께 달라진다.[9.1] [9.3] [9.9]
오래된 핑을 버려도 된다는 요구는 Kafka가 오래된 핑을 자동으로 식별해서 건너뛴다는 뜻이 아니다. 표시 컨슈머가 기사별 최신 시각·버전을 확인해 오래된 위치가 새 위치를 덮지 않게 하는 규칙, 전송·처리에 쓸 기한을 별도로 정한다. at-most-once를 택해도 여러 파티션과 여러 프로듀서의 순서 한계가 없어지지는 않는다. 이는 가상 지도 서비스의 업무 규칙이다.
이 선택이 틀리는 조건은 분명하다. 핑이 지도 표시가 아니라 이동 거리 정산이나 분쟁 증빙에 쓰이기 시작하는 순간이다. 그때는 핑 손실이 정산 금액과 증빙에 영향을 준다. 흔한 해법은 한 토픽에 두 역할을 억지로 맡기지 않고, 실시간 표시용 스트림과 정산용 스트림을 나누는 것이다. 앱이 1분 단위 요약을 따로 보내 payments와 같은 내구성 템플릿의 토픽에 쓰고, 3초 핑은 가벼운 토픽에 그대로 두는 식이다. 쓰임새별로 스트림을 나누면 모든 핑에 정산용 내구성 설정을 적용하는 비용을 줄일 수 있다.
상품 카탈로그: 재처리 비용을 설계의 중심에 둔다
catalog의 소비자들은 이 토픽으로 자기 상태를 만든다. 검색 인덱스, 추천 모델의 상품 표, 가격 비교 캐시가 모두 그렇다. 이 스트림에서 가장 자주 일어나는 “장애”는 브로커 장애가 아니라 새 소비자의 등장과 소비자의 상태 재구축이다. 새 서비스가 생기거나, 인덱스 스키마가 바뀌거나, 버그로 상태가 망가지면 처음부터 다시 읽어야 한다.
이 스트림에는 8장의 컴팩션을 적용할 수 있다.
cleanup.policy=compact. 보존 기간과 무관하게 모든 상품의 최신 값이 남는다. 새 소비자는 처음부터 읽기만 하면 전체 카탈로그를 얻는다. 설계 문서가 컴팩션의 쓰임새로 드는 “캐시 다시 적재”, “검색 노드 복구”와 같은 경우다.[11.7]delete,compact는 쓰지 않는다. 오래 변하지 않은 상품이 사라진다(8장).delete.retention.ms는 가장 느린 소비자의 초기 적재 시간보다 넉넉히 길게 잡는다. 그렇지 않으면 삭제된 상품이 새 소비자의 상태에 남는다(8장).- 소비자는 같은 키를 여러 번 받는다고 가정하고 덮어쓰기로 처리한다.
이 선택은 변경마다 상품의 전체 최신 상태가 들어 있다는 전제다. 가격 증감분이나 일부 필드 패치만 담고 이전 이력을 컴팩션으로 버리면, 새 소비자는 그 마지막 레코드만으로 전체 상품을 만들지 못할 수 있다. 상품 ID의 키 매핑도 유지해야 한다. 컴팩션의 키별 보장은 파티션 안에 적용되므로, 파티션 증설로 같은 상품의 옛 값과 새 값이 다른 파티션에 갈 수 있는 문제를 별도로 다룬다(2·3장).
또 “최신 값이 남는다”에는 삭제 계약이 포함된다. 삭제된 상품의 마지막 값까지 영구 보관하는 것이 아니라 삭제 표시로 이전 값을 제거한다. 처음부터 읽는 소비자는 8장의 삭제 표시 기한과 초기 적재 절차를 만족해야 하며, 읽는 동안 변하는 여러 파티션을 자동으로 일관된 한 시각의 스냅숏으로 보아서는 안 된다.
이 선택으로 포기하는 것은 이력이다. 컴팩션 토픽은 “지금 상태”를 주지만 “지난주 화요일의 가격”은 주지 않는다. 가격 변동 분석이 필요하다면, 같은 변경을 보존 기간이 있는 별도 토픽이나 분석 저장소에 따로 남겨야 한다. 이 선택이 틀리는 조건은 소비자가 멱등하게 덮어쓰지 못하는 경우다. 예를 들어 “가격이 바뀔 때마다 알림을 보내는” 소비자는 처음부터 다시 읽을 때 모든 상품에 대해 알림을 다시 보낸다. 이런 소비자는 상태를 만드는 소비자와 분리해야 한다.
재처리의 실제 비용
재처리 예산에는 캐시에 없는 로그를 읽을 저장장치 대역폭, 실시간 소비자와의 경쟁, 복구 목표 시간을 포함한다. SSD를 선택해도 정산 API나 컨슈머 업무 코드가 병목이면 복구가 그만큼 빨라지지는 않는다.
설계 문서가 Kafka의 장점으로 드는 것 가운데 하나가 되감아 다시 읽는 능력이다. 소비자 코드에 버그가 있었다면 고친 뒤 그 메시지들을 다시 소비할 수 있다.[11.4] 운영 도구도 이를 지원한다.
--reset-offsets --to-datetime으로 특정 시각부터 다시 읽게 할 수 있다(10장).[11.6] 하지만 “다시 읽을 수 있다”와 “다시 읽어도 된다”는 다르다. 재처리의 비용은 네 가지로 나눠 볼 수 있다.
첫째, 범위. 다시 읽을 수 있는 범위는 실제 남아 있는 로그다. 시간·크기 보존, 세그먼트 경계, 타임스탬프와 compaction이 이를 정한다. 버그 발견이 늦으면 필요한 앞부분이 삭제되어 있을 수 있다. 설정된 보존 기간만 보고 날짜별 재처리를 보장하지 말고 log start 오프셋과 남아 있는 레코드를 확인한다.
보존 기간을 정할 때는 디스크 예산뿐 아니라 문제를 발견하는 데 걸리는 시간도 고려해야 한다. “이 소비자에게 문제가 생기면 우리가 알아차리는 데 얼마나 걸리는가”가 하한이 된다. 주말과 연휴를 넘겨야 발견되는 배치 소비자라면(8장의 정산 배치처럼) 7일로는 발견 뒤 재처리에 필요한 여유가 부족할 수 있다.
둘째, 부수 효과. 다시 읽는 소비자가 푸시를 보내거나, 결제를 요청하거나, 이메일을 발송한다면, 재처리는 그 효과를 다시 일으킨다. 6장과 7장에서 본 대로 Kafka 트랜잭션은 외부 부수 효과를 되돌리지 않는다. 그래서 설계할 때부터 소비자를 두 종류로 나누는 것이 좋다. 상태를 만드는 소비자(검색 인덱스, 집계 테이블)는 멱등하게 만들어 언제든 처음부터 다시 돌릴 수 있게 하고, 효과를 내는 소비자(알림, 결제 요청)는 “이미 효과를 냈는지”를 이벤트 ID로 기록해 재처리 때 건너뛰게 한다.
셋째, 다른 소비자에게 주는 부담. 설계 문서는 소비자들이 대부분 따라잡은 클러스터에서는 데이터가 페이지 캐시에서 바로 나가므로 디스크 읽기가 거의 없다고 설명한다.[11.5] 뒤집어 말하면, 며칠 전 데이터를 대량으로 다시 읽는 소비자는 캐시에 없는 데이터를 디스크에서 읽고, 같은 브로커를 쓰는 실시간 소비자와 디스크·캐시를 다툰다. 재처리를 한꺼번에 몰아서 하면 실시간 서비스의 지연이 함께 오를 수 있다.
문서가 설명하는 클라이언트 할당량은 이런 “과도한 클라이언트”로부터 다른 클라이언트를 보호하는 장치다.[11.8] 재처리용 클라이언트 ID에 가져오기 할당량을 걸어 속도를 제한하는 것도 방법이다. 다만 8장의 사례처럼 속도를 너무 낮추면 컴팩션 토픽에서 삭제 표시를 놓칠 수 있으니 두 제약을 함께 봐야 한다.
넷째, 시간. 처음부터 다시 처리하는 데 걸리는 시간 동안 소비자의 결과는 “과거 상태”를 보여 준다. 검색 인덱스를 새로 만드는 동안 옛 인덱스를 계속 서비스하다가 바꿔 끼우는 식의 절차가 필요할 수 있다.
가상 용량 계산 · 유입이 계속되는 동안 따라잡기
미처리 레코드가 60만 건이고 새 유입이 초당 2천 건, 같은 작업을 실제 완료하는 속도가 초당 8천 건으로 유지된다고 가정하자. 적체는 초당 6천 건씩 줄므로 따라잡는 데 약 100초가 걸린다. 60만을 단순히 8천으로 나누면 따라잡는 동안 새로 들어오는 레코드를 빼먹는다. 완료 속도가 유입 속도보다 작거나 같다면 이 조건에서는 적체를 없앨 수 없다.
이것은 Kafka의 성능 수치가 아니라 일정한 속도를 가정한 계산이다. 실제 예산에는 읽기·역직렬화·업무 처리·외부 API·커밋 가운데 가장 느린 단계, 파티션별 키 쏠림, 리밸런스와 재시도, 실시간 서비스 보호를 위한 할당량을 포함한다. 처리 속도를 제한하면 따라잡는 시간이 길어지므로, 로그가 먼저 삭제되는 시점과 삭제 표시를 놓치는 기한 안에도 들어가는지 함께 확인한다.
결정을 문장으로 남기기
트레이드오프를 골랐다면 그 선택을 다음 사람이 읽을 수 있는 문장으로 남겨야 한다. 형식은 자유지만, 적어도 다음 네 가지는 들어가야 이 책의 기준(실패 결과와 반례를 설명할 수 있는가)을 통과한다.
- 무엇을 택했나: 설정값 자체.
- 어떤 장애에서 무엇이 멈추고 무엇이 사라지나: 4장~9장의 인과 모델로 쓴 예측.
- 이 선택이 틀리게 되는 조건: 반례. 쓰임새가 바뀌거나 트래픽이 바뀌면 어떻게 되는가.
- 이 선택이 시험받고 있음을 알려 주는 관측치: 10장의 지표.
payments를 예로 들면 이렇다.
| 항목 | 내용 |
|---|---|
| 택한 것 | RF 3, min.insync.replicas=2, acks=all(멱등성 명시), unclean 비활성, 랙 분산, 보존 35일 |
| 멈추는 것 | 브로커 2대 동시 부재 시 쓰기 거절. 결제 서비스는 아웃박스에 쌓았다가 복구 후 전송 |
| 사라지는 것 | 성공 응답을 받은 결제는 동기화 복제본 하나라도 살아 있으면 잃지 않음. ISR 전체를 잃으면 기다림 |
| 틀리는 조건 | acks=1 프로듀서가 섞일 때, 복제본이 한 영역에 몰릴 때, 누군가 unclean을 켤 때 |
| 관측치 | AtMinIsrPartitionCount > 0 경보, UnderMinIsrPartitionCount, UncleanLeaderElectionsPerSec, 프로듀서 record-error-rate |
같은 표를 rider-locations와 catalog에도 채워 보면, 리뷰 자리에서 세 스트림에 서로 다른 설정이 필요한 이유를 설명할 수 있다. 하나의 템플릿은 “관리가 편하다”는 장점이 있지만, 네 번째 항목(관측치)과 두 번째 항목(예측)이 스트림마다 다르므로 관리 편의만으로 같은 설정을 적용할 수는 없다.
반례로 점검하기
이 책에서 본 보장이 깨지는 조건은 다음과 같다. 새 설계를 리뷰할 때 이 목록을 거꾸로 짚어 보면, 빠진 가정을 찾기 쉽다.
- “복제 계수 3이니 안전하다”가 깨지는 조건:
min.insync.replicas=1에서 ISR이 하나로 줄었을 때,acks=1프로듀서, unclean 선출 활성, 복제본의 물리적 집중(4장, 9장). - “exactly-once를 켰다”가 깨지는 조건: 외부 데이터베이스·API 부수 효과,
read_uncommitted소비자, abort 뒤 위치를 되돌리지 않는 애플리케이션, 애플리케이션 수준 재전송(7장). - “컴팩션 토픽이니 전체 상태가 있다”가 깨지는 조건:
delete.retention.ms보다 느린 초기 적재,delete,compact정책, 같은 키를 여러 번 받는 것을 가정하지 않은 소비자(8장). - “소비는 멈춘 곳에서 이어진다”가 깨지는 조건: 커밋 오프셋 만료, 보존 기간 경과, 그룹 이름 변경, 커밋이 처리를 앞지르는 구조(8장, 5장, 6장).
- “같은 키는 순서대로 온다”가 깨지는 조건: 파티션 수 변경, 여러 프로듀서의 경쟁 쓰기, 멱등성이 꺼진 재시도, 소비자의 병렬 처리, share group(3장).
확인 질문
- 반년 뒤
rider-locations의 핑을 이용해 배달 기사의 “이동 거리 수당”을 계산하자는 요구가 나왔다. 지금 설정(acks=1, 보존 몇 시간, at-most-once 소비)을 유지한 채 수당 계산 소비자를 붙이면 어떤 문제가 생길까? 설정을 바꾸는 방법과 스트림을 나누는 방법 중 무엇을 권하겠는가? - 알림 서비스에 버그가 있어 지난 3일 동안 일부 주문의 “배송 시작” 알림이 빠졌다. 버그를 고친 뒤
--reset-offsets --to-datetime으로 3일 전부터 다시 처리하자는 제안이 나왔다. 무엇이 문제가 되고, 어떻게 바꾸면 될까?
확인 질문 1 해설
세 가지 문제가 생긴다. acks=1이라 리더 장애 때 핑이 조용히 사라지고, 사라진 구간만큼 이동 거리가 짧게 계산된다. 보존이 몇 시간이라 수당 계산에 오류가 있어도 다시 계산할 수 없다. 현재 소비자들이 at-most-once 방식이라는 점은 새 소비자에 직접 영향을 주지는 않지만, 같은 토픽을 쓰는 한 새 소비자도 이 토픽의 내구성 이상을 기대할 수 없다.
설정을 바꾸면(acks=all, min.insync.replicas=2, 긴 보존) 실시간 지도 표시까지 무거워지고, 앞의 예측 질문에서 본 가용성 문제가 지도 서비스로 번진다. 스트림을 나누는 편을 권한다. 앱이나 수집 계층이 이동 거리 계산에 필요한 요약(예: 1분 단위 구간 거리)을 따로 만들어 내구성 템플릿의 토픽에 쓰고, 3초 핑 토픽은 지금처럼 가볍게 둔다. 두 쓰임새가 다른 비용을 치르게 하는 것이다.
확인 질문 2 해설
3일 전부터 모든 레코드를 다시 처리하면, 빠졌던 “배송 시작” 알림뿐 아니라 그 3일 동안 이미 정상적으로 보낸 모든 알림이 다시 나간다. 알림은 외부 부수 효과이고 되돌릴 수 없다. 또 그룹이 살아 있는 채로 오프셋을 바꾸면 살아 있는 컨슈머가 자기 위치를 다시 커밋해 초기화가 덮어써질 수 있다(10장).
바꾸는 방법은 이렇다. 알림 서비스가 “이미 보낸 알림”을 이벤트 ID로 기록하고 있다면, 그룹을 멈춘 뒤 3일 전으로 초기화해도 이미 보낸 것은 건너뛴다. 그런 기록이 없다면 전체 그룹을 되감는 대신, 별도 그룹 이름의 임시 소비자를 3일 전부터 돌려 “배송 시작 이벤트 중 알림 기록이 없는 것”만 골라 보내는 편이 안전하다. 어느 쪽이든 3일이 보존 기간 안이라는 전제를 먼저 확인해야 한다.
12장. 데이터와 메타데이터는 어떻게 다르게 복제되는가 | KRaft
데이터 복제본과 컨트롤러 정족수는 서로 다른 내용을 저장하고 다른 성공 조건을 따른다. 전자는 주문 같은 레코드를, 후자는 어느 브로커가 어떤 역할을 맡는지 정하는 메타데이터를 다룬다.
토픽 생성과 주문 발행을 나누어 따라가기
메타데이터(metadata)는 데이터를 해석하고 배치하는 데 필요한 정보다. 가상의 한결마켓에서 orders 토픽의 파티션 수, 복제본 배치, 현재 리더가 어느 브로커인지는 메타데이터다. 주문 O-104의 상품과 금액은 그 토픽에 넣을 업무 데이터다. 메타데이터를 복제한다고 주문 본문이 컨트롤러에 복제되는 것은 아니다.
새 토픽을 만든 뒤 첫 주문을 보내는 정상 흐름은 다음과 같다.
- 관리 클라이언트가 브로커에 토픽 생성 요청을 보낸다. 브로커는 활성 컨트롤러로 관리 요청을 전달한다. 활성 컨트롤러는 현재 메타데이터 변경을 이끄는 컨트롤러다.
- 컨트롤러는 토픽과 파티션의 배치를 메타데이터 로그에 기록하고 정족수의 합의를 거쳐 확정한다. 브로커들은 이 메타데이터를 받아 자신이 맡을 파티션과 리더 정보를 반영한다.
- 프로듀서는 브로커에서 메타데이터를 조회해
orders의 대상 파티션 리더를 찾는다. 주문 본문은 그 리더 브로커에 보낸다. - 리더 브로커가 주문을 데이터 로그에 추가하고, 해당 파티션의 팔로워 브로커들이 복제한다. 프로듀서 응답 조건은 4·9장에서 다룬
acks와 ISR 등의 조합이다. 컨트롤러 과반이 주문 본문을 받았다는 조건이 아니다.
제어 경로: 토픽 생성 → 브로커 → 활성 컨트롤러 → 메타데이터 정족수
↓ 메타데이터 반영
데이터 경로: 주문 프로듀서 → 파티션 리더 브로커 → 팔로워 브로커
↓
주문 컨슈머
위 그림의 세로 화살표는 메타데이터가 브로커의 동작을 정한다는 뜻이다. 주문 레코드가 컨트롤러를 경유한다는 뜻은 아니다. 컨트롤러가 관리 요청을 처리하는 근거는 13장의 ACL 문서, 프로듀서가 파티션 리더에 직접 보내는 근거는 Kafka 4.3 Design의 Producer 절이다.
과반의 실패와 데이터 복제본의 실패가 다른 이유
정족수는 합의에 참여하는 컨트롤러 집합이다. 과반이 없는 두 편이 동시에 서로 다른 변경을 확정하지 못하게 하는 것이 합의의 목적이다. 하나의 활성 컨트롤러가 실패해도 충분한 컨트롤러가 남아 있으면 새 활성 컨트롤러를 선출할 수 있다. 반면 데이터 파티션 리더 장애는 해당 파티션의 복제본 상태를 보고 새 데이터 리더를 고르는 문제다. 새 리더 선출을 관리할 제어 경로도 정상이어야 한다.
따라서 장애를 볼 때는 두 질문을 분리한다. “주문 복제본이 남아 있는가?”는 데이터 내구성 질문이고, “그 복제본을 새 리더로 삼을 메타데이터 변경을 확정할 수 있는가?”는 제어 가용성 질문이다. 컨트롤러 과반 상실을 모든 주문 파일이 삭제된 상황으로 해석하면 안 된다. 반대로 기존 리더가 잠시 응답한다고 토픽 생성·리더 변경까지 정상이라고 판단할 수도 없다. 기존 데이터 요청의 지속 여부는 브로커 세션과 장애 상태에 의존하므로 무조건 지속된다고 보장하지 않는다.
KRaft의 메타데이터 로그 위치와 주문 토픽의 데이터 로그 위치를 관측·백업에서 구분하는 이유도 여기에 있다. 두 저장 영역은 역할이 다르며, 한쪽 디스크만 복원한다고 다른 쪽의 상태가 자동으로 복원되지 않는다.
컨트롤러는 브로커의 세션을 관리하고(4장), ISR 변경을 클러스터 메타데이터에 기록하고(4장), 리더를 선출하며(4장), ELR을 기록한다(4장, 9장). KRaft는 이 메타데이터를 관리하는 방식이다.
Kafka 4.3은 KRaft 모드만 지원하며 ZooKeeper 모드는 제거되었다. ZooKeeper 모드 클러스터는 KRaft로 먼저 이전해야 4.3으로 올릴 수 있다.[A.1]
KRaft 모드에서 각 서버는 process.roles로 브로커, 컨트롤러, 또는 둘 다(“combined”)가 된다. combined 서버는 개발 환경처럼 규모가 작은 환경에서 운영하기 쉽다. 다만 컨트롤러가 나머지 시스템과 덜 격리되고, 컨트롤러만 따로 롤링하거나 확장할 수 없어 중요한 운영 환경에는 권하지 않는다.[A.2]
컨트롤러로 선택된 서버들이 메타데이터 정족수(quorum)에 참여하며, 각 컨트롤러는 활성 컨트롤러이거나 대기 상태다. 보통 3대나 5대를 둔다. 가용성을 유지하려면 과반이 살아 있어야 하므로 3대면 1대, 5대면 2대의 장애를 견딘다.[A.2] 4장에서 본 데이터 복제(ISR 방식)와 달리, 메타데이터는 과반수 방식으로 합의한다. 설계 문서도 과반수 방식이 공유 클러스터 설정에 더 흔히 쓰인다고 설명한다.[A.5]
동적 정족수에서는 브로커와 컨트롤러가 controller.quorum.bootstrap.servers로 컨트롤러를 찾는다. 정적 정족수에서는 각 노드가 전체 컨트롤러의 ID·주소를 controller.quorum.voters에 지정한다.
동적 구성으로 전환할 때는 KRaft feature version과 공식 설정 변경 절차를 함께 확인한다.[A.2] 4.1부터는 정적 컨트롤러 구성을 동적 구성으로 올려 컨트롤러를 추가·제거할 수 있다.[A.2]
여러 기능은 “피처 플래그”로 켜진다. 새 컨슈머 리밸런스 프로토콜은 group.version(5장), 트랜잭션 서버 방어는 transaction.version(7장), ELR은 eligible.leader.replicas.version(4장)이다.[A.3] 업그레이드를 마무리할 때 kafka-features.sh로 릴리스 버전을 확정하는 단계도 같은 체계다(부록 A).
ZooKeeper 모드의 비밀번호 암호화 관련 설정은 사라졌고, KRaft 모드에서는 민감한 데이터를 레코드에 저장하며 Kafka 안에서 암호화하지 않는다고 문서는 적는다.[A.4] 따라서 메타데이터 로그 디렉터리와 백업에 접근할 수 있는 주체를 제한해야 한다.
클러스터 전체에서 ActiveControllerCount가 1인 노드가 정확히 하나여야 한다(10장). 운영 문서의 KRaft 페이지에는 메타데이터 정족수 도구, 로그 덤프 도구, 메타데이터 셸 같은 디버깅 도구가 정리되어 있다.[A.2]
- A.1 4.3은 KRaft만 지원, ZooKeeper 모드 제거, 업그레이드 조건 (Upgrading § Upgrading Servers to 4.3.0) https://kafka.apache.org/43/getting-started/upgrade/#upgrading-servers-to-430-from-any-version-33x-through-420
- A.2
process.roles와 combined 모드의 한계, 컨트롤러 3·5대와 과반,controller.quorum.bootstrap.servers, 4.1의 정적→동적 구성, 디버깅 도구 (KRaft) https://kafka.apache.org/43/operations/kraft/ - A.3
group.version,transaction.version,eligible.leader.replicas.version(Consumer Rebalance Protocol; Transaction Protocol; Eligible Leader Replicas) https://kafka.apache.org/43/operations/consumer-rebalance-protocol/ · https://kafka.apache.org/43/operations/transaction-protocol/ · https://kafka.apache.org/43/operations/eligible-leader-replicas/ - A.4 비밀번호 암호화 설정 제거, KRaft는 민감 데이터를 레코드에 암호화 없이 저장 (KRaft vs ZooKeeper § Configurations) https://kafka.apache.org/43/getting-started/zk2kraft/#configurations
- A.5 과반수 방식이 공유 클러스터 설정에 더 흔함 (Design § Replicated Logs) https://kafka.apache.org/43/design/design/#replicated-logs-quorums-isrs-and-state-machines-oh-my
13장. 누가 접근하고 어디에서 비용이 발생하는가 | 보안·성능
연결을 암호화하는 일과 읽기를 허용하는 일
가상의 검색 서비스가 주문 토픽을 읽는 상황에서 보안은 세 가지 질문으로 나뉜다.
| 기능 | 답하는 질문 | 이 기능만으로 해결되지 않는 것 |
|---|---|---|
| 암호화(encryption) | 통신 중 내용을 제삼자가 읽거나 바꾸지 못하게 하는가 | 연결한 서비스가 주문을 읽을 권한이 있는가 |
| 인증(authentication) | 연결 상대가 어떤 신원인가 | 그 신원이 모든 토픽에 접근해도 되는가 |
| 인가(authorization) | 그 신원에게 이 자원의 이 작업을 허용하는가 | 이미 복사한 개인정보의 이용·보존 정책 |
TLS는 통신 경로를 보호하며 서버 인증서를 검증할 때 클라이언트가 접속 상대를 확인하게 한다. 클라이언트 인증서까지 사용하는 구성에서는 브로커도 클라이언트를 인증할 수 있다. SASL은 선택한 메커니즘으로 신원을 확인한다. SASL_SSL은 SASL 인증과 TLS 암호화를 함께 사용하는 Kafka 보안 프로토콜이다. SASL을 선택했다는 말만으로 전송 내용이 암호화되었다고 판단하면 안 된다.
브로커가 인증한 신원은 principal로 표현된다. ACL(Access Control List)은 이 principal이 어떤 토픽·그룹 등의 자원에서 어떤 작업을 할 수 있는지 적은 규칙이다. 다음은 실행 명령이 아니라 권한 판단을 설명하는 정상 흐름이다.
검색 서비스 → 보안 listener에 연결 → TLS 검증 / 구성된 인증
→ principal 확인
→ 요청 자원·작업의 ACL 검사
→ 허용된 Fetch 요청 처리
검색 서비스의 인증이 성공해도 주문 토픽 읽기 권한이나 컨슈머 그룹 사용 권한이 빠지면 소비가 실패할 수 있다. 프로듀서도 목적지 토픽 쓰기 권한이 필요하며, 토픽을 스스로 만들거나 트랜잭션을 사용하면 그 작업에 필요한 권한이 추가된다. 네트워크 연결 성공, 인증 성공, 업무 요청 성공은 서로 다른 관측 지점이다. ACL의 정확한 작업·자원 조합은 아래 B.3 문서에서 사용하는 API에 맞춰 대조한다.
Listener는 서버가 연결을 받는 이름과 주소의 구성 단위다. 클라이언트용, 브로커 간 통신용, 컨트롤러용 경로는 역할이 다르므로 한 경로를 보호했다고 나머지도 보호된 것으로 간주하지 않는다. Kafka 연결 보안은 외부 DB와 검색 엔진의 연결 보안을 대신하지 않으며, 14장의 Connect REST 관리 경로에도 별도 보호가 필요하다.
관련 설정 이름과 TLS·SASL 조합은 Kafka 4.3 Listener Configuration에서 확인할 수 있다. 보안을 쓰지 않을 수 있다는 제품 지원 범위는 인터넷이나 비신뢰 네트워크에 그대로 노출해도 된다는 운영 권고가 아니다.
비용은 레코드가 통과하는 경로에서 찾는다
배치(batch)는 여러 레코드를 묶어 요청과 저장 단위의 고정 비용을 나누는 방식이다. 압축(compression)은 그 묶음 안의 반복을 줄여 전송·저장 바이트를 줄이지만 압축·해제에 CPU를 쓴다. 페이지 캐시는 운영체제가 파일 내용을 메모리에 보관하는 영역이다. sendfile은 파일 내용을 애플리케이션 버퍼로 다시 복사하는 경로를 줄여 소켓으로 보내는 운영체제 기능이다.
따라잡은 소비자는 최근 데이터를 페이지 캐시에서 읽을 가능성이 높다. 오래된 이력을 빠르게 재처리하는 소비자는 캐시에 없는 세그먼트를 읽어 디스크 작업을 늘릴 수 있다. TLS를 켜면 Kafka의 sendfile 경로는 쓰지 않지만 페이지 캐시 자체가 사라지는 것은 아니다. 그러므로 “암호화를 켰으니 모든 읽기가 디스크를 거친다”는 예측은 틀리다.
한결마켓이 TLS 도입 뒤 지연 증가를 발견했다면, 같은 유입량과 배치·압축·재처리 범위를 놓고 CPU 사용량, 요청 지연, 네트워크 바이트, 디스크 읽기를 비교한다. 압축률이 좋아 네트워크 부담이 줄었는지, CPU가 병목이 되었는지, 과거 재처리가 디스크 경쟁을 만들었는지에 따라 조치가 다르다. 배치를 크게 해 처리량을 높이는 선택에도 기다리는 시간이 늘 수 있다는 비용이 있다. 다음 문단의 TLS 비용 설명은 고정 성능 저하율을 약속하는 벤치마크가 아니라 전송 경로의 차이를 설명한다.
암호화 전후의 성능을 비교하려면 배치 크기·압축·유입량·읽기 범위를 맞춰야 한다. 보안 비용을 줄일 때는 인증이나 암호화를 끄기보다 배치와 CPU 예산을 함께 검토한다.
Kafka는 클라이언트·다른 브로커·도구가 브로커에 연결할 때 SSL 또는 SASL로 인증할 수 있다. SASL 메커니즘으로는 GSSAPI(Kerberos), PLAIN, SCRAM-SHA-256/512, OAUTHBEARER가 있다.
연결은 SSL로 암호화할 수 있으며, 문서는 SSL을 켜면 CPU와 JVM 구현에 따라 성능 저하가 있다고 적는다. 인가는 플러그인 방식이다. 보안은 선택 사항이라 보안 없는 클러스터도, 인증·비인증과 암호화·비암호화 클라이언트가 섞인 클러스터도 지원된다.[B.1]
11장에서 본 것처럼 따라잡은 소비자는 페이지 캐시와 sendfile 덕분에 거의 디스크를 읽지 않는다. 설계 문서는 TLS/SSL 라이브러리가 사용자 공간에서 동작하므로 SSL을 켜면 sendfile을 쓰지 않는다고 적는다.[B.2] 암호화가 CPU뿐 아니라 데이터 전송 경로 자체를 바꾼다는 뜻이다.
기본 인가 구현(StandardAuthorizer)은 ACL을 KRaft 메타데이터 로그에 저장한다. 어떤 자원에 맞는 ACL이 하나도 없으면 Kafka는 그 자원에 대한 접근을 제한하며, 슈퍼 사용자만 접근할 수 있다. allow.everyone.if.no.acl.found=true로 바꾸면 ACL이 없는 자원은 모두에게 열리지만, ACL이 하나라도 있는 자원에는 그 규칙이 그대로 적용된다.[B.3]
CreateTopics 같은 관리 요청은 브로커로 들어가지만 브로커가 활성 컨트롤러로 전달하고, 인가는 컨트롤러에서 한다. 그래서 사용자 정의 principal.builder.class를 쓴다면 그 클래스가 KafkaPrincipalSerde를 구현해야 principal을 전달할 수 있다.[B.3] 10장에서 본 것처럼 consumer 프로토콜 그룹을 조회하려면 구독 토픽 전체에 대한 DESCRIBE 권한이 필요하다는 점도 운영 계정 설계에 영향을 준다.
운영 중인 클러스터에는 보안을 단계적으로 도입한다. 문서는 노드를 차례로 재시작해 보안 포트를 추가로 열고, 클라이언트를 보안 포트로 옮기고, 필요하면 다시 재시작해 브로커 간 보안을 켜고, 마지막 재시작으로 PLAINTEXT 포트를 닫는 절차를 제시한다. 그동안 PLAINTEXT 포트는 계속 열어 두어야 하며, 재시작한 복제본이 ISR로 돌아온 뒤 다음 노드로 넘어가는 것이 좋다고 문서는 권한다.[B.4] 이때도 4장과 9장의 롤링 재시작 주의 사항을 따른다.
- B.1 인증(SSL, SASL 메커니즘), SSL 암호화와 성능 저하, 플러그인 인가, 보안의 선택성 (Security Overview) https://kafka.apache.org/43/security/security-overview/
- B.2 SSL 사용 시
sendfile미사용 (Design § Efficiency) https://kafka.apache.org/43/design/design/#efficiency - B.3
StandardAuthorizer와 메타데이터 로그의 ACL, ACL이 없을 때의 동작과allow.everyone.if.no.acl.found, KRaft principal 전달과KafkaPrincipalSerde(Authorization and ACLs) https://kafka.apache.org/43/security/authorization-and-acls/ - B.4 운영 중인 클러스터에 보안을 단계적으로 도입하는 절차 (Incorporating Security Features in a Running Cluster) https://kafka.apache.org/43/security/incorporating-security-features-in-a-running-cluster/
14장. 외부 시스템을 연결할 때 무엇을 맡길 것인가 | Kafka Connect
가상의 한결마켓은 주문 데이터베이스를 검색 엔진과 데이터 웨어하우스에 복제하려 한다. 백엔드 개발자는 SQL 조회 후 프로듀서로 보내는 프로그램을 만들 수 있다. 반대 방향에서는 컨슈머로 읽고 검색 엔진 API를 호출할 수 있다. 각 프로그램은 조회 위치 저장, 장애 후 재개, 작업 분할, 스키마 변환, 설정 배포와 상태 관측도 맡아야 한다. Kafka Connect는 이런 연동 작업을 공통 실행 환경에서 운영하는 프레임워크다. 데이터베이스를 자동으로 이해하는 기능은 아니다. 실제 읽기·쓰기 방법은 설치한 커넥터 구현이 제공한다.
Source·Sink, Connector·Task·Worker는 각각 무엇인가
Source connector는 외부 시스템에서 읽어 Kafka로 보낸다. Sink connector는 Kafka에서 읽어 외부 시스템에 쓴다. 이름의 기준은 Kafka다. 데이터베이스는 방향에 따라 source가 될 수도, sink가 될 수도 있다.
Connector는 연동 작업의 설정을 받아 실제 작업 단위를 만든다. Task가 그 작업을 실행한다. Worker는 connector와 task를 실행하는 프로세스다. 분산 모드에서 connector 인스턴스와 그 task들이 같은 worker에 있을 필요는 없다. worker를 늘리는 것과 task 수를 늘리는 것도 다르다. 실행할 task가 하나뿐이면 worker를 늘려도 그 task의 읽기 작업이 여러 개로 나뉘지 않는다.
Connect는 브로커 안의 기능인가, 별도 프로그램인가
Connect worker는 브로커·컨트롤러와 별도로 시작하는 JVM 프로세스다. Kafka 배포판에 시작 스크립트가 함께 들어 있어도 브로커를 시작한 것만으로 Connect가 실행되지는 않는다. Worker는 일반 Kafka 클라이언트처럼 브로커에 연결하면서, 외부 DB나 검색 엔진에도 연결한다. Connector와 task는 보통 이 worker 프로세스 안의 코드와 실행 스레드이며, task 하나마다 별도 JVM을 자동 생성하는 구조가 아니다.
실행 위치는 서버일 수도 컨테이너일 수도 있다. 같은 물리 서버에 브로커와 worker를 배치해도 두 프로세스의 역할은 합쳐지지 않는다. DB 읽기와 converter 변환의 CPU·메모리 부담, 검색 API 연결 수, 플러그인 장애의 영향 범위는 worker 쪽에서 발생한다. Worker를 별도로 배치하면 이런 연동 부하를 브로커 자원과 분리할 수 있지만 별도 배포·감시·용량 관리가 필요하다.
이 그림은 실행 구조다. 데이터가 꼭 한 worker의 source에서 같은 worker의 sink로 직접 넘어간다는 뜻은 아니다. Source worker는 Kafka에 쓰고, sink worker는 Kafka에서 읽는다. 서로 다른 worker나 서로 다른 Connect 클러스터여도 같은 토픽을 매개로 연결할 수 있다. 브로커는 레코드를 저장·복제·전달할 뿐 PostgreSQL 드라이버나 검색 엔진 플러그인을 실행하지 않는다.
플러그인 설치와 커넥터 등록은 서로 다른 작업이다
플러그인(plugin)은 connector·task·converter·SMT 같은 확장 구현의 실행 코드다. 예를 들어 Debezium PostgreSQL 배포물에는 PostgreSQL 변경을 읽는 커넥터 구현과 필요한 의존성이 들어 있다. 운영자는 배포물을 worker 파일 시스템의 플러그인 디렉터리에 배치하고 plugin.path로 그 위치를 지정한다. Worker는 시작할 때 발견한 클래스를 사용한다. 플러그인 추가·교체에는 worker 재시작을 포함한 배포 절차와 버전 호환성 확인이 필요하다.
등록(register)은 이미 설치된 구현을 사용해 “이 DB의 이 테이블을 이 이름의 작업으로 읽어라”라는 설정을 Connect에 전달하는 일이다. Distributed 모드에서는 REST API에 JSON 설정을 보낸다. REST 요청은 JAR을 내려받거나 모든 worker에 설치하는 패키지 배포 기능이 아니다. 같은 클래스의 connector를 서로 다른 DB·이름으로 여러 개 등록할 수 있으며, 설치만 하고 아무 작업도 등록하지 않을 수도 있다.
| 대상 | 예시 | 담당하는 역할 |
|---|---|---|
| worker 설정 | bootstrap.servers, plugin.path, Connect group.id |
프로세스의 Kafka 연결·플러그인 탐색·분산 클러스터 구성 |
| connector 설정 | name, connector.class, DB 주소, 테이블 필터 |
어떤 구현으로 어떤 연동을 운영할지 지정 |
| task 설정 | connector가 생성한 테이블·스트림별 작업 설정 | 각 실행 단위가 맡는 범위 지정 |
| 업무 레코드 | 주문 키·상태·변경 메타데이터 | 실제로 Kafka와 외부 시스템 사이를 이동하는 데이터 |
Distributed 모드에서는 어느 worker에 task가 재할당될지 바뀔 수 있다. 따라서 그 task를 맡을 모든 worker에 필요한 플러그인과 호환되는 의존성을 배포해야 한다. 한 worker의 GET /connector-plugins 결과만으로 전체 worker의 설치 일치를 확인할 수 없다. 이 API는 요청을 받은 worker의 플러그인을 조사한다. 클래스가 발견되어도 DB 권한·네트워크·레코드 형식까지 정상이라는 뜻은 아니다.
이 구분의 근거는 Kafka 4.3 Connect User Guide의 Running Kafka Connect·Configuring Connectors·Plugin Discovery·REST API다. 플러그인은 설정값처럼 보여도 worker에서 임의 코드를 실행할 수 있으므로 신뢰할 수 있는 배포물만 설치한다.
프로듀서·컨슈머를 직접 쓸 것인가, Connect를 쓸 것인가
주문을 승인한 순간 발생하는 도메인 이벤트처럼 애플리케이션이 의미와 발행 시점을 아는 경우에는 직접 프로듀서를 쓰는 것이 자연스럽다. 기존 시스템의 데이터를 반복적으로 추출하거나 Kafka 레코드를 외부 저장소에 적재하는 작업은 Connect가 잘 맞는다.
비교 대상은 코드 몇 줄의 길이가 아니라 운영 책임이다. 직접 구현하면 자유로운 업무 로직과 트랜잭션 제어를 얻는 대신 체크포인트, 중복 처리, 재배분과 운영 API를 직접 책임진다. Connect를 고르면 이 공통 부분은 줄지만 커넥터가 지원하는 데이터 모델·분할 방식·오류 처리와 버전에 제약받는다.
“커넥터가 있으니 요구사항을 충족한다”는 판단은 위험하다. JDBC 조회 커넥터가 설치되어도 조회 사이에 생겼다 지워진 행은 못 볼 수 있다. 이벤트마다 세 외부 API를 호출해야 하는 업무를 SMT에 밀어 넣으면 단순 연동 프레임워크를 업무 오케스트레이터로 사용하는 셈이다. 요구하는 결과, 필요한 이력, 장애 복구 단위부터 정한 뒤 도구를 고른다.
먼저 레코드의 형태와 변환 단계를 구분한다
Connect 레코드는 worker 내부에서 다루는 키·값·토픽·헤더 등의 구조다. Source 레코드에는 외부 시스템의 스트림 식별 정보와 읽기 위치도 붙는다. Sink 레코드에는 Kafka의 토픽·파티션·오프셋 정보가 붙는다. 이 내부 객체가 그대로 Kafka 파일에 저장되는 것은 아니다.
스키마(schema)는 필드 이름·타입·선택 가능 여부 등 값의 구조를 기술한다. 예를 들어 주문의 id는 정수이고 status는 문자열이라는 기술이 스키마다. Connect는 스키마가 있는 값과 없는 값을 모두 표현할 수 있지만, 개별 커넥터가 두 형태를 모두 지원한다는 뜻은 아니다.
Converter는 Connect의 키·값 모델을 Kafka에 저장할 바이트로 직렬화하거나, 읽은 바이트를 Connect 모델로 역직렬화한다. 키와 값의 converter를 따로 고른다. SMT는 한 Connect 레코드의 필드나 키·토픽 등을 바꾸는 작은 변환이다. Converter가 표현 포맷을 바꾸는 층이라면 SMT는 직렬화 전 또는 역직렬화 후의 레코드 내용에 작용한다.
SourceRecord·SinkRecord는 앞서 정의한 내부 레코드의 방향별 이름이다. 그림의 단계는 논리적 데이터 경로이며, 모두 별도 서버라는 뜻은 아니다. Converter·SMT·Kafka 클라이언트는 worker에서 실행된다. 아래에서는 SMT 없는 source와, 변경 이벤트를 검색 문서로 바꾸는 sink 쪽 변환을 가정한다. 구체적인 sink 구현이 이 입력 형식과 변환을 지원하는지는 따로 확인해야 한다.
작은 설정 예제: Worker를 시작하는 설정과 작업을 등록하는 설정
가상의 한결마켓은 PostgreSQL market DB의 public.orders를 Debezium PostgreSQL 3.3.0.Final로 읽는다. 이 사례에서는 public.orders.id가 변경되지 않는 기본 키라고 가정한다. Debezium의 기본 메시지 키는 테이블 기본 키에서 만들어지므로 키는 id 필드를 가진 구조다. 그 값을 검색 문서 ID 104로 사용하는 매핑은 sink와 변환 설정의 별도 계약이다. id라는 열이 있다는 사실만으로 메시지 키가 자동 결정되지는 않는다. 다음은 구성 요소의 관계를 보여 주는 설명용 부분 예제다. 실행 검증한 배포 절차가 아니며, 실제 주소·비밀 설정·권한·토픽 복제 정책이 준비되어야 한다.
# distributed worker 설정의 일부
bootstrap.servers=kafka-1:9092,kafka-2:9092
plugin.path=/opt/connect/plugins
key.converter=org.apache.kafka.connect.json.JsonConverter
value.converter=org.apache.kafka.connect.json.JsonConverter
key.converter.schemas.enable=true
value.converter.schemas.enable=true
group.id=hangeul-connect
config.storage.topic=hangeul-connect-configs
offset.storage.topic=hangeul-connect-offsets
status.storage.topic=hangeul-connect-status
Kafka 배포판에서는 bin/connect-distributed.sh <worker-properties-file>로 worker를 시작한다. 첫 인자는 worker가 시작할 때 읽는 설정 파일이다. 파일을 작성하는 것만으로 worker가 실행되지는 않는다. Distributed 모드의 connector JSON은 이 명령의 추가 인자로 넘기지 않고, 시작된 worker의 REST 서버에 별도로 등록한다. 아래 예제는 그 두 설정의 관계를 보여 주며 실제 배포를 실행한 것은 아니다. 시작 방식의 근거는 Kafka 4.3 Connect User Guide의 Running Kafka Connect다.
bootstrap.servers는 worker의 Kafka 연결 시작점이다. plugin.path는 worker 파일 시스템의 코드 위치다. JsonConverter는 키와 값을 JSON 표현으로 바꾼다. 여기서는 schemas.enable=true이므로 일반 필드 JSON만 쓰는 것이 아니라 schema와 payload를 가진 표현을 사용한다. Source와 sink에서 이 설정을 동일하게 맞추는 것은 바이트 포맷을 해석하기 위한 조건이다. 업무 필드를 검색 문서로 바꾸는 일까지 끝내 주지는 않는다.
위 세 내부 토픽은 업무 토픽과 별개다. 예제에는 생성 명령과 복제 계수를 넣지 않았다. 실제로는 아래 내부 토픽 표의 파티션·compaction·복제 조건을 갖추고, 내부 토픽용 Kafka 클라이언트와 source producer·sink consumer의 보안 설정을 각각 대조해야 한다. 브로커가 한 대뿐인 실험과 다중 브로커 운영 환경에 같은 복제 설정을 그대로 적용할 수 없다.
별도로 POST /connectors에 전달할 connector 등록 본문은 다음처럼 구성한다. 주소와 암호는 자리표시자이며 요청을 실제 전송하지 않았다.
{
"name": "hangeul-orders-source",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"tasks.max": "1",
"database.hostname": "postgres.example.invalid",
"database.port": "5432",
"database.user": "connect_orders",
"database.password": "REPLACE_WITH_SECURE_SECRET_CONFIGURATION",
"database.dbname": "market",
"topic.prefix": "hangeul",
"table.include.list": "public\\.orders",
"plugin.name": "pgoutput",
"slot.name": "hangeul_orders_slot",
"publication.name": "hangeul_orders_pub",
"publication.autocreate.mode": "disabled",
"snapshot.mode": "initial"
}
}
이 설정은 브로커에 등록하는 토픽 설정이 아니라 Connect REST 서버에 제출하는 연동 작업 설정이다. name은 작업의 관리 이름이고 connector.class는 worker에 이미 설치되어 있어야 할 구현이다. database.*는 source task가 접속할 DB를 정한다. table.include.list는 테이블 식별자에 맞추는 정규식이며 JSON의 이중 백슬래시는 정규식에서 점을 문자 그대로 지정하기 위한 escape다.
topic.prefix=hangeul과 기본 테이블별 명명 규칙에 따라 업무 토픽은 hangeul.public.orders가 된다. Sink의 입력 topics는 이 실제 출력 토픽과 맞아야 한다. slot.name은 PostgreSQL의 logical replication slot 이름이고 publication.name은 내보낼 테이블 집합을 정한 publication 이름이다. 이 두 용어는 15장에서 정상 동작과 보존 비용을 설명한다. publication.autocreate.mode=disabled로 두었으므로 DB 관리자가 public.orders를 포함하는 publication을 먼저 준비해야 한다.
특히 plugin.name=pgoutput은 Connect의 plugin.path와 다르다. 전자는 PostgreSQL 서버 쪽 logical decoding 출력 플러그인을 고르는 Debezium 설정이고, 후자는 JVM worker가 커넥터 코드를 찾는 경로다. Debezium 플러그인 배포, PostgreSQL의 logical replication 설정·접속 허용·계정 권한, publication과 slot의 수명 정책이 준비되지 않으면 JSON이 형식상 맞아도 연동되지 않는다. snapshot.mode=initial은 처음에 기존 행을 읽고 이어서 변경을 읽는 선택이지 영구적으로 테이블 전체를 매번 다시 읽는 선택이 아니다.
이 값들은 Debezium PostgreSQL 3.3.0.Final의 Connector properties·Setting up PostgreSQL에서 대조한 것이다. 검색 sink는 이 source와 별도의 connector로 등록한다. Source가 검색 엔진을 알거나 sink를 자동으로 만드는 것은 아니다. Sink 등록에는 해당 검색 플러그인의 실제 connector.class, 입력 topics=hangeul.public.orders, 검색 주소·인증, 문서 ID 매핑, envelope 변환과 삭제 정책이 필요하다. 여기서는 검색 제품과 플러그인을 정하지 않았으므로 동작을 보장하는 가짜 클래스나 완성형 sink JSON을 제시하지 않는다.
설정에서 실행으로: Connector가 Task를 만드는 정상 흐름
- 운영자가 worker를 시작한다. Worker가 Kafka에 연결하고 설치된 플러그인을 발견한다.
- 운영자가 위 connector JSON을 등록한다. Connect는 구현과 설정을 검사하고 distributed 모드의 설정 토픽에 작업 설정을 기록한다. 비동기로 실행될 작업이므로 REST 성공만으로 DB 읽기 성공을 확정하지 않는다.
- Connector 인스턴스가 설정을 받고 작업 분할 정보를 만든다. 개발 API의
taskConfigs(maxTasks)는 각 task에 줄 설정 목록을 반환한다. 운영자가 task마다 REST 등록을 하는 구조가 아니다. - 분산 실행 환경이 connector와 task를 worker들에 할당한다. 할당받은 worker는 task 구현을 생성하고 설정으로 시작한다. Connector와 그 task가 같은 프로세스에 있어야 하는 것은 아니다.
- Source task가 저장된 외부 읽기 위치를 확인한다. 신규 작업은 설정된 snapshot 절차를 수행하고, 기존 작업은 커넥터의 재개 계약에 따라 읽기를 이어 간다.
- Worker가 task의
poll()에서 받은 source 레코드를 SMT·converter·producer 경로로 보낸다. 지속적으로 데이터를 복사하는 주체는 connector 관리 객체가 아니라 task다.
taskConfigs와 poll은 구현 관계를 이해하기 위한 API 이름이다. 커넥터 사용자가 직접 호출할 명령은 아니다. 이 분리의 근거는 Kafka 4.3 Connector Development Guide의 Connectors and Tasks·Source Tasks·Resuming from Previous Offsets다. 이제 최대 task 수와 실제 분할 수의 차이를 읽을 수 있다.
tasks.max는 최대 task 수이지 반드시 만들어지는 task 수가 아니다. Confluent JDBC Source의 조회한 current overview는 한 task가 하나 이상의 테이블을 읽을 수 있고, query 모드에서는 한 task만 지원한다고 설명한다. 하나의 테이블이나 하나의 사용자 지정 query를 tasks.max만큼 자동 분할하는 수단으로 이해하면 안 되며, 테이블 모드에서 가능한 작업 분할은 설치한 플러그인 버전과 구현으로 확인한다. Debezium PostgreSQL 3.3 커넥터는 한 task로 실행된다. 이는 PostgreSQL 커넥터의 제약이며, Connect 프레임워크 전체가 단일 task라는 뜻은 아니다.
외부 DB → Source Task → Converter → Kafka topic
↑
Worker가 실행·오프셋 저장
Kafka topic → Converter → Sink Task → 외부 검색 엔진
↑
Worker가 실행·커밋 조율
Source 쪽 SMT는 Connect 레코드에 적용된 뒤 converter로 직렬화된다. Sink 쪽은 converter로 역직렬화한 레코드에 SMT를 적용한다. 위 그림은 데이터 이동 경로를 줄여 나타낸 것이며 SMT 단계는 생략했다.
정상 동작을 한 행에서 검색 문서까지 추적한다
한결마켓의 기존 주문 id=104는 초기 snapshot으로 이미 전달되었다고 하자. 사용자가 결제를 마쳐 DB에서 status가 CREATED에서 PAID로 바뀌고 트랜잭션이 커밋된다. 다음 표의 값은 이해를 위한 가상 값이며 원문 전체를 재현한 Kafka 출력이 아니다.
| 지점 | 보이는 데이터 또는 위치 | 처리하는 주체 |
|---|---|---|
| DB | orders(id=104, status=PAID)와 커밋된 변경 |
PostgreSQL |
| source task | 키 id=104, 값의 op=u, after.status=PAID, 원본 변경 위치 |
Debezium task |
| source worker | Connect 레코드 → 선택한 JSON 바이트 | converter와 Kafka producer |
| Kafka | hangeul.public.orders의 특정 파티션·오프셋에 키·값 바이트 저장 |
파티션 리더와 복제본 브로커 |
| sink worker | 바이트 → Connect 레코드 → 검색용 문서 값 | consumer·converter·설정된 변환 |
| 검색 엔진 | 문서 ID 104의 상태를 PAID로 반영 |
검색 sink task와 목적지 API |
- PostgreSQL은 커밋된 변경을 logical decoding 경로로 제공한다. Debezium task는 그 변경을 읽어 키와 변경 envelope를 가진 source 레코드를 만든다. Envelope는 변경 종류와 변경 전·후 값 같은 정보를 담는 바깥 구조다.
op=u는 update를 뜻한다.before가 항상 모든 열을 담는다고 가정하지 않으며, 15장의 replica identity 조건을 따른다. - SMT가 없으면 worker는 이 레코드를 그대로 converter에 넘긴다. 예제의 JSON converter는 스키마와 값을 표현하는
schema·payload구조로 직렬화한다. Kafka producer는 대상 토픽·키·파티션 조건에 따라 리더 브로커에 보낸다. - 브로커는 레코드를 데이터 로그에 저장·복제하고 producer에 응답한다. 일반적인 at-least-once source 실행에서 worker는 성공적으로 전달한 데이터의 외부 위치를 source 오프셋 저장소에 기록하도록 조율한다. 외부 위치를 어떻게 재개 지점으로 해석할지는 커넥터 구현의 책임이다.
- 별도로 등록된 검색 sink의 consumer가 토픽을 읽는다. Sink 쪽 JSON converter가 같은 형식의 바이트를 역직렬화한다. 그 다음, 이 예제에서 선택한 변환이
after를 검색 문서 값으로 꺼내고 주문 키를 문서 ID 계약에 맞춘다고 가정한다. 실제로는 sink가 envelope를 지원하는지, 적절한 SMT를 구성해야 하는지 확인해야 한다. - Sink task는 변환된 레코드를 받아 검색 API를 호출한다. 검색 엔진은 문서 ID
104에status=PAID를 반영한다. 목적지의 쓰기 응답과 내구성·검색 노출 시점의 계약은 그 검색 제품에 속한다. Kafka producer 성공 응답은 이 단계의 완료를 뜻하지 않는다. - Worker는 sink task와 외부 쓰기 완료 지점을 조율한 뒤 그 consumer group의 Kafka 오프셋을 커밋한다. Sink API의
put()이 반환했다고 곧바로 모든 외부 쓰기가 완료되었다고 가정하면 안 된다. Task가 배치 버퍼를 사용할 수 있으므로flush·preCommit등 구현의 커밋 계약에 맞춘 완료 위치가 필요하다.
DB 변경 위치 ─ Source task ─ JSON 바이트 ─ Kafka 파티션·오프셋
│ │
└─ source 체크포인트 └─ Sink consumer ─ 검색 쓰기 확인
Connect offset 저장소 │
sink group 오프셋 커밋
체크포인트(checkpoint)는 실패 후 어디서부터 재개할지 저장한 위치다. 위 두 위치는 다른 작업의 체크포인트다. Source 위치는 PostgreSQL 로그 위치 등 원본을 다시 읽는 데 필요하고, Kafka 파티션·오프셋은 Kafka 로그를 다시 읽는 데 필요하다. 외부 로그의 LSN을 Kafka 오프셋과 동일한 숫자로 취급하거나, source 위치 저장을 검색 완료로 해석하면 안 된다. 자세한 sink의 쓰기·flush 경계는 Kafka 4.3 Connector Development Guide의 Sink Tasks에서 대조했다.
등록 상태, 실행 상태, 업무 완료를 각각 확인한다
위 사례에서 DB 변경은 생겼는데 검색이 갱신되지 않았다면 처음부터 EOS 설정을 바꾸기보다 경로를 끊어 살핀다. Source task가 변경을 읽었는지, Kafka 업무 토픽에 새 레코드가 있는지, sink가 그 형식을 해석했는지, 검색 API가 성공했는지를 순서대로 확인한다. 예를 들어 일반 JSON producer가 {"id":104,"status":"PAID"}만 쓴 토픽을 schema-enabled JSON converter로 읽으면, producer와 브로커는 정상이어도 converter 단계에서 실패할 수 있다.
RUNNING은 task가 동작 중이라는 상태이지 최신 주문이 검색에 반영되었다는 증명이 아니다. 새 변경이 없는 정상 상태에서도 처리량은 0일 수 있다. 반대로 외부 쓰기를 비동기 버퍼에 넣은 뒤 반환하는 구현에서는 레코드를 받았다는 지표와 목적지 반영 지표 사이에 차이가 난다. 이 차이를 알아야 지연을 source 추출, Kafka 저장, sink 소비, 목적지 반영으로 구분할 수 있다.
Standalone·Distributed와 세 내부 토픽
Standalone 모드는 한 프로세스에서 작업을 실행하며 source 위치를 offset.storage.file.filename이 지정한 파일에 보관한다. 다른 프로세스가 같은 위치에서 자동으로 이어받게 하는 분산 조정 기능은 없다. 파일이 사라지면 외부 시스템을 어디까지 읽었는지 잃을 수 있다. 개발이나 단순한 단일 프로세스 작업에는 적합하지만, worker 장애 시 다른 worker가 인계하는 요구에는 distributed 모드를 검토한다.
Distributed 모드에서는 같은 Connect group.id의 worker들이 connector와 task를 나누어 실행하고, 설정·위치·상태를 Kafka 토픽에 기록한다.
| 설정 | 저장하는 것 | 구조에서 필요한 조건 |
|---|---|---|
config.storage.topic |
connector와 task의 설정 | 단일 파티션, 복제, compaction |
offset.storage.topic |
source의 외부 시스템 읽기 위치 | 여러 파티션 가능, 복제, compaction |
status.storage.topic |
connector·task의 상태 | 여러 파티션 가능, 복제, compaction |
내부 토픽의 이름과 group.id는 독립적인 Connect 클러스터 사이에 섞어 쓰지 않는다. 이 세 토픽이 sink의 업무 레코드를 담는 것도 아니다. Sink가 Kafka를 어디까지 읽었는지는 sink 컨슈머 group의 committed offsets이며, source가 외부 DB의 어디까지 읽었는지는 커넥터가 만든 source 오프셋이다. offset.storage.topic을 지우는 것과 sink 컨슈머 offsets를 reset하는 것은 완전히 다른 복구 작업이다.
누가 분산 실행을 조정하고 브로커는 무엇을 하는가
Distributed worker들은 Kafka의 그룹 관리 프로토콜을 이용해 같은 Connect 클러스터의 구성원이 되고 작업 할당을 조정한다. Connect 측은 connector·task의 설정과 할당을 해석하고 실행한다. 브로커의 그룹 코디네이터는 멤버십·그룹 프로토콜을 지원하지만, DB 테이블을 몇 조각으로 나눌지 판단하거나 커넥터 JAR을 실행하지 않는다. KRaft 컨트롤러가 Connect task를 직접 배치하는 것도 아니다.
브로커는 이 과정에서 두 역할을 동시에 가진다. 하나는 업무 토픽을 저장·복제하는 데이터 저장소이고, 다른 하나는 Connect 내부 토픽과 그룹 관리의 기반이다. 설정 토픽을 읽은 다른 worker가 작업을 이어받을 수 있는 것은 설정·위치가 공유되고 작업 코드도 준비되어 있기 때문이다. Kafka가 설정 문자열을 보관했다는 사실만으로 새 worker에 실행 코드나 DB 계정 권한이 생기는 것은 아니다.
Source의 작업 분할은 커넥터가 제시하는 원본 분할 방식에 의존한다. Sink는 Kafka 입력 파티션의 할당도 따른다. 같은 sink group에서 한 파티션을 동시에 여러 consumer가 맡지 않으므로, task·worker를 늘려도 입력 파티션과 목적지 처리 방식이 병렬성의 한계가 될 수 있다. Connect worker의 group.id와 각 sink의 소비 group은 서로 다른 역할이다. 기본 sink group 이름은 connect-<connector 이름>이며 override 정책과 설정에 따라 달라질 수 있으므로 운영에서 실제 group을 확인한다.
근거: Kafka 4.3 Connect Overview의 group management 설명, User Guide의 Running Kafka Connect·ACL requirements. 다음 장애 설명은 이 정상 인계 구조에 저장 시점의 실패 창을 추가한 것이다.
Worker 재배분은 왜 중복을 만들 수 있는가
Worker가 사라지거나 구성원이 바뀌면 작업 할당이 변경된다. 인계받은 task는 저장된 위치를 기준으로 이어간다. “마지막으로 실행한 위치”와 “마지막으로 저장한 위치” 사이에는 간격이 있다. 일반적인 source task가 Kafka에 레코드를 성공적으로 보냈지만 source 오프셋을 저장하기 전에 종료되면, 재시작 후 같은 외부 데이터를 다시 읽어 보낼 수 있다.
Sink도 같은 실패 창을 가진다. 외부 DB에 쓰기가 성공한 뒤 컨슈머 오프셋 커밋 전에 worker가 죽으면 이미 쓴 레코드를 다시 받는다. Worker를 한 대 더 띄우면 장애 인계는 가능해지지만 외부 DB에 이미 쓴 효과를 되돌리지 않는다. 따라서 작업 재배분은 가용성을 위한 장치이고, 부수 효과의 exactly-once는 별도 계약이다. Task 장애가 항상 worker 탈락이나 그룹 전체 재배분을 뜻하는 것도 아니다. FAILED task의 원인과 커넥터별 재시작 정책을 확인해야 한다.
Connect의 Exactly-once는 어디까지인가
Kafka 4.3 프레임워크는 조건을 갖춘 source와 sink 커넥터에 exactly-once 지원 기반을 제공한다. 그러나 공식 가이드는 개별 커넥터가 이 기능을 활용할 수 있는지에 의존한다고 명시한다.
Source exactly-once는 distributed 모드에서만 지원된다. 새 클러스터에서는 모든 worker의 exactly.once.source.support=enabled가 필요하다.
기존 클러스터 전환은 preparing 단계를 포함한 공식 롤링 절차를 따라야 한다. Kafka 출력과 source 위치를 트랜잭션으로 묶고 이전 task의 쓰기를 fencing하는 프레임워크 기능이 있다고 해서, 임의의 외부 API를 읽는 커넥터가 자동으로 복구 가능한 source가 되지는 않는다. 지원하는 커넥터인지, 외부 위치가 재현 가능한지, 버전·권한·운영 전환 조건이 맞는지를 함께 확인한다.
Sink에서 consumer.isolation.level=read_committed는 abort된 Kafka 트랜잭션의 레코드를 외부로 내보내지 않기 위한 조건이다. 그 설정만으로 외부 DB 쓰기와 오프셋 commit을 하나의 트랜잭션으로 만들지는 않는다. 외부 저장소의 멱등 쓰기, 이벤트 ID 중복 제거, 저장소 트랜잭션 또는 커넥터의 체크포인트 통합이 실제 보장을 정한다.
예를 들어 Confluent JDBC Sink는 at-least-once 전달을 설명하며, 설정된 primary 키와 upsert로 재전달에 따른 중복 행을 줄이는 방식을 제공한다. 이것은 “같은 키의 현재 상태를 덮어쓴다”는 모델에 맞는다. balance = balance + amount처럼 호출 자체가 누적 효과를 만드는 작업의 중복 방지를 일반적인 upsert와 같은 것으로 취급하면 안 된다.
Converter·Schema·SMT는 어디에서 책임지는가
Kafka가 보관하는 키와 값은 바이트다. 커넥터는 Connect의 schema와 값 모델로 데이터를 다룬다. Converter는 이 모델과 바이트 사이를 연결한다. 따라서 프로듀서가 일반 JSON 바이트를 쓰는데 sink의 JSON converter가 schema envelope를 기대하면, Kafka에 정상 저장된 레코드도 sink에서 해석되지 않을 수 있다. 토픽에 무엇이 저장되었는지와 converter가 무엇을 기대하는지부터 맞춘다.
Schema Registry는 Kafka Connect의 필수 내장 서버가 아니다. Avro·Protobuf·JSON Schema용 특정 converter와 별도 레지스트리 서비스를 선택하면 사용하는 구성 요소다. Connect schema와 레지스트리의 schema ID도 같은 개념이 아니다. 포맷 선택에는 schema evolution, 소비자 호환성, 장애 시 레지스트리 의존성을 함께 고려한다.
SMT(Single Message Transformation)는 개별 레코드의 필드 이름 변경, 필드 추출, 토픽 이름 변경처럼 가벼운 변환에 적합하다. 여러 레코드를 모아 join하거나 시간 창을 유지하는 연산은 그 목적이 아니다. 키를 추출하는 SMT는 목적지의 primary 키나 파티셔닝 계약을 바꿀 수 있으므로 단순한 표현 변경으로만 보지 않는다. Predicate를 쓰면 tombstone 등 특정 레코드를 구분해 변환을 적용할 수 있다.
Retry·errors.tolerance·DLQ가 해결하지 않는 것
형식이 잘못된 한 레코드와 목적지 전체의 장애는 복구 방법이 다르다. 전자는 격리한 뒤 나머지를 처리할 수 있지만, 후자는 모든 레코드를 격리하면 업무 데이터 전체가 실패 큐로 이동한다. 따라서 “실패하면 DLQ”라는 한 규칙으로 묶지 않는다.
errors.retry.timeout과 errors.retry.delay.max.ms는 프레임워크가 다루는 재시도 가능한 오류에 대한 설정이다. 커넥터 내부의 DB 연결 재시도나 배치 쓰기 재시도에는 별도 설정과 구현이 있을 수 있다. errors.tolerance=all은 프레임워크가 허용하는 오류를 건너뛰고 계속하는 선택이다. 실패가 성공으로 바뀌는 기능은 아니며, 원래 목적지에는 해당 레코드가 빠진다.
Kafka 4.3 가이드의 DLQ 설정은 sink connector에서의 오류 보고를 설명한다. Converter·SMT 등 처리 단계의 오류와, sink가 errant record reporter로 보고하는 레코드를 구분해야 한다. Source의 임의 예외나 커넥터가 보고하지 않은 외부 쓰기 오류까지 전부 DLQ로 간다고 보장할 수 없다.
DLQ에는 접근 권한, 보존, 감시, 오류 수정 후 재주입 절차가 필요하다. 원본 토픽·파티션·오프셋을 오류 context 헤더로 남기면 추적에 도움이 된다. 오류 로그에 메시지 내용을 포함하면 개인정보나 인증 정보가 노출될 수 있다. 격리된 레코드를 다시 넣을 때도 원래 이벤트 ID와 중복 방지 계약을 유지한다.
REST 관리·모니터링·보안은 어떻게 연결되는가
Distributed 모드는 REST로 connector 설정을 만들고 수정한다. GET /connectors/{name}/status는 connector뿐 아니라 개별 task 상태도 살펴보기 위한 출발점이다. 설정 validation으로 미지원 설정을 발견하고, 실패 task의 trace와 외부 시스템 로그를 함께 본다. Connector가 RUNNING이어도 task 하나가 FAILED이면 그 task가 맡은 일부 데이터만 멈출 수 있다.
pause, resume, restart, stop은 서로 다른 작업이다. REST 응답이 성공해도 실제 상태 전환은 비동기일 수 있으므로 status와 처리량으로 결과를 확인한다. restart 시 failed task만 대상으로 하는지 connector 자체도 포함하는지 해당 요청 옵션을 확인한다. 위치를 수정하는 REST 작업도 connector 정지 등 공식 전제조건을 갖는다. 이름을 바꾸어 새 connector를 생성한다고 기존 위치를 그대로 이어받는 것은 아니다.
관측 대상은 task 상태, source poll과 write 처리량, source 오프셋 저장 실패, sink lag와 오프셋 commit 실패, 외부 DB 지연, 오류·DLQ 증가다. 입력이 없어서 처리량이 0인 경우와 권한·연결 문제로 정지한 경우를 source 시스템 지표로 구분한다. Worker가 살아 있다는 health check만으로 연동 성공을 판정하지 않는다.
Connect REST는 기본적으로 보호되지 않는다. 네트워크 접근 제한, 인증·TLS 구성, 플러그인 신뢰성, 비밀 설정 공급과 최소 권한을 운영 조건으로 둔다. Kafka TLS를 설정했다고 REST도 보호된 것은 아니다. 커넥터 플러그인은 worker 안에서 코드를 실행하며 클라이언트 override도 공격면이 된다. Kafka 4.3 가이드가 권고하는 Allowlist 정책처럼 필요한 override만 허용하고, 서비스 계정에 토픽·그룹·내부 토픽·트랜잭션 ID별 권한을 구분한다.
확인 질문: 장애 인계와 업무 중복은 같은 문제인가
가상 검색 연동은 상품 ID를 키로 upsert한다. 포인트 연동은 주문 이벤트마다 포인트를 더한다. 두 sink 모두 외부 쓰기 직후 worker가 죽었고 오프셋은 저장되지 않았다. 각 연동에서 요구할 중복 방지 계약은 무엇인가? read_committed와 worker 증설만으로 해결되는가?
해설
재시작 후 같은 Kafka 레코드를 다시 받는 정상적인 at-least-once 복구를 가정한다. 검색은 동일 상품의 동일 버전 값을 upsert하면 재전달이 새 행을 만들지 않는다.
다만 더 오래된 버전이 뒤늦게 도착하는 문제까지 막으려면 version 검사가 필요하다. 포인트는 동일 이벤트 ID에 unique 제약을 두고, 중복 여부 기록과 포인트 적립을 같은 DB 트랜잭션에서 수행해야 한다. 외부 효과가 HTTP 호출이라면 대상 API의 멱등 키(idempotency key) 계약이 필요하다. Worker 증설은 인계 가용성만, read_committed는 abort된 Kafka 레코드 필터링만 담당한다. 대상 커넥터가 그런 업무 트랜잭션을 제공하지 않는다면 직접 컨슈머나 목적지 API 설계를 선택해야 한다.
근거: Kafka 4.3 Connect User Guide, Confluent JDBC Sink. Confluent 문서는 current 출판면이므로 설치한 플러그인 버전의 동작은 별도 대조해야 한다.
15장. 데이터베이스 변경을 이벤트로 읽는 법 | JDBC Polling·CDC·Snapshot
가상의 주문 DB를 외부 서비스로 복제한다고 하자. “주문 테이블을 Kafka로 옮긴다”는 설명만으로는 방식이 정해지지 않는다. 현재 상태를 주기적으로 맞추려는지, 중간 상태를 포함한 모든 변경을 전달하려는지, 삭제와 초기 적재를 어떻게 다룰지가 다르다.
먼저 현재 상태와 변경 이력을 구분한다
Polling은 일정 간격으로 현재 테이블을 조회하는 방식이고, CDC는 DB가 제공하는 변경 정보를 따라 읽는 방식이다. Snapshot은 어떤 기준에서 존재하는 행들을 읽어 초기 상태를 채우는 절차다. 셋은 배타적인 제품명이 아니다. CDC 커넥터가 처음에 snapshot을 수행한 뒤 변경 로그를 읽을 수 있다.
가상의 주문 104가 CREATED → PAID → CANCELED 순서로 변경되었다고 하자. 검색 문서를 최신 상태로 맞추려면 최종 CANCELED 상태가 필요하다. “결제가 승인된 모든 주문을 정산해야 한다”면 중간의 PAID 전이도 필요하다. 같은 테이블을 복제한다는 말로 이 두 계약을 합칠 수 없다.
시간 순서: CREATED ── PAID ── CANCELED
주기 조회: ↑ ↑
CREATED 읽음 CANCELED 읽음
변경 로그: INSERT UPDATE UPDATE
각 변경을 포착할 수 있는 원본 근거
이 그림은 정상적인 SQL 조회와 로그 포착의 관측 차이를 보여 준다. CDC도 커넥터가 지원·포함하는 테이블과 연산, 로그 보존·권한·필터 조건을 갖춰야 한다. 이미 없어진 과거 변경까지 되살리는 기능은 아니다.
JDBC Polling은 무엇을 관측할 수 있는가
Confluent JDBC Source는 주기적인 SQL 조회로 행을 가져온다. bulk는 전체 조회이고 incrementing은 증가하는 열을 기준으로 신규 행을 찾는다.
정상 polling을 조금 더 구체화하면, task는 저장된 기준 위치로 SQL 조건을 만들고 DB가 반환한 행을 Connect 레코드로 바꾼다. Worker가 Kafka에 보내고 외부 조회 위치를 저장하면 다음 조회에서 그 기준보다 뒤의 행을 찾는다. 다음 의사 SQL은 실제 JDBC 커넥터가 생성하는 SQL의 재현이 아니라 기준 열의 의미를 설명한다.
-- 증가 열을 쓰는 조회의 개념: 바인딩 값은 저장된 마지막 ID
SELECT id, status FROM orders WHERE id > :last_seen_id ORDER BY id;
신규 행 id=104가 조회되어 발행된 뒤 다음 조회는 그 ID 이후를 찾는다. 같은 id=104의 상태를 수정해도 ID가 증가하지 않으므로 이 조건은 수정된 행을 다시 고르지 않는다. 수정 시간 기준이라면 모든 수정 경로에서 그 열을 갱신해야 한다. JDBC라는 이름은 DB 연결·조회 기술을 가리키며, 그 자체가 트랜잭션 변경 로그를 모두 읽는다는 뜻은 아니다. Query mode와 DB별 SQL·타입 지원은 커넥터 설정 계약을 따른다.
이미 조회한 ID의 행이 나중에 수정되는 경우, 증가 열만으로는 그 변경을 발견하지 못한다. timestamp는 수정 시간 열을 쓰고 timestamp+incrementing은 같은 타임스탬프의 행을 식별하는 증가 열을 함께 사용한다. 어떤 열을 기준으로 삼을지, nullable 값이나 DB 타입을 지원하는지, 모든 변경 경로가 해당 열을 갱신하는지 확인해야 한다.
Checkpoint는 테이블에 대한 마지막 조회 위치다. Kafka로 레코드를 보낸 뒤 위치 저장 전 실패하면 같은 행을 다시 보낼 수 있다. 또한 증가 번호의 부여 순서와 DB commit 순서는 같지 않을 수 있다. 앞선 번호의 트랜잭션이 오래 열린 동안 더 큰 번호가 먼저 조회되면 뒤늦게 커밋되는 행을 놓칠 위험이 있다. timestamp delay가 제공하는 완충도 임의로 오래 열린 트랜잭션에 대한 무조건적 해결책은 아니다. 격리 수준, 수정 시간의 생성 방식, query 조건과 실제 트랜잭션 패턴을 함께 확인한다.
조회 사이에 한 주문이 CREATED에서 PAID를 거쳐 CANCELED가 되었다면 마지막 상태만 볼 수 있다. 이런 복제는 “모든 상태 전이 이벤트를 기록한다”는 계약과 다르다. bulk 재조회도 중간 상태를 복원하지 않는다.
물리 삭제는 왜 Polling에서 빠지는가
현재 남아 있는 행만 조회하는 커넥터는 삭제된 행 자체를 읽을 수 없다. 검색에 이미 복제된 상품을 원본 DB에서 물리 삭제해도 다음 조회 결과에 그 행이 없다는 사실만으로 삭제 이벤트가 생성되지는 않는다. Polling 설계를 그대로 두고 poll.interval.ms를 줄이는 것은 해결이 아니다.
대안은 요구사항에 따라 달라진다. 삭제 여부를 남기는 soft delete와 수정 타임스탬프를 함께 쓰거나, 삭제 이력·outbox 테이블을 조회하거나, 삭제를 포함한 변경 로그를 읽는 CDC를 쓴다. 전체 스냅샷을 비교하여 없는 키를 찾는 별도 reconciliation도 가능하지만, 이는 JDBC source의 자동 기능이 아니라 추가 알고리즘이다. Soft delete도 커넥터가 그 행을 추출하고 downstream이 삭제 의미를 해석해야 완결된다.
PostgreSQL CDC의 구성 요소는 어디에 있는가
WAL(Write-Ahead Log)은 PostgreSQL이 변경 복구 등을 위해 유지하는 로그다. Logical decoding은 이 로그를 행 수준의 변경 정보로 제공하는 기능이다. LSN(Log Sequence Number)은 WAL 내 위치를 식별한다. 여기서 LSN은 Kafka 토픽의 offset이 아니라 PostgreSQL 쪽 재개 위치의 근거다.
Replication slot은 PostgreSQL이 이 소비 경로에 필요한 로그 유지 상태를 관리하는 서버 측 객체다. Publication은 pgoutput 기반 logical replication에서 내보낼 테이블과 연산의 범위를 지정하는 서버 측 구성이다. Replica identity는 update·delete의 이전 행 식별에 어떤 정보를 제공할지 정하는 테이블 설정이다. 보통 기본 키 정보가 중요하며, 삭제 전 전체 행을 요구한다면 실제 identity 설정과 출력 내용을 확인해야 한다.
PostgreSQL 서버 Connect worker
업무 트랜잭션 → WAL → logical decoding ─────→ Debezium Source Task
pgoutput / slot ↓
publication 대상 Connect 변경 레코드
↓ converter / producer
Kafka 브로커 업무 토픽
Publication·slot·pgoutput은 PostgreSQL 쪽에 있고 Debezium task는 worker 쪽에 있다. DB는 변경 로그를 제공하고, task가 이를 Connect 레코드로 옮기며, worker의 producer가 Kafka로 보낸다. Debezium을 브로커에 설치하거나 Kafka가 SQL 트랜잭션 로그를 직접 읽는 것으로 이해하면 경로와 장애 위치가 뒤섞인다.
예를 들어 14장의 등록 설정은 관리자가 준비한 hangeul_orders_pub에서 public.orders 변경을 읽는다. Publication에 대상 테이블이 없거나 계정에 replication·snapshot 읽기 권한이 없으면 토픽 이름과 converter를 맞춰도 변경을 가져오지 못한다. 반대로 DB 변경을 읽었는데 Kafka 쓰기 권한이 없으면 task와 worker의 Kafka 연결 지점에서 막힌다.
Slot을 유지한다고 WAL 디스크 공간이 무한히 보장되지는 않는다. 오래 멈춘 connector가 따라잡아야 할 로그가 쌓이면 원본 DB의 공간과 보존 한계도 확인한다. Source 체크포인트가 남아 있어도 그 LSN의 WAL을 서버가 더 제공하지 못하면 기존 위치에서의 이어 읽기가 실패할 수 있다. Snapshot으로 현재 상태를 재구축할 수 있는 경우에도 사라진 중간 변경 이력이 복원된 것은 아니다.
근거는 Debezium PostgreSQL 3.3.0.Final의 How the connector works·Setting up PostgreSQL·Replica identity·WAL disk space consumption이다. 이 관계를 이해한 뒤 아래 로그 보존과 failover 조건을 읽는다.
CDC는 행 조회가 아니라 변경 로그를 읽는다
원본 DB의 변경 로그와 Kafka 로그는 읽기 위치와 보존 조건이 서로 다르다. Kafka의 보존 기간을 늘려도 아직 전달되지 않은 WAL은 복원되지 않는다. 복구할 때는 전달 완료 위치와 원본에서 다시 읽을 수 있는 범위를 각각 확인한다.
CDC(Change Data Capture)는 변경을 포착하는 접근이다. 여기서 다루는 Debezium PostgreSQL 3.3.0.Final은 PostgreSQL logical decoding을 이용해 insert·update·delete를 Kafka 변경 이벤트로 보낸다. 커넥터마다 source 위치 형식과 DB 준비 조건은 다르다. PostgreSQL의 LSN, replication slot, publication, replica identity를 MySQL binlog 설정과 같은 것으로 설명하면 안 된다.
Replication slot은 소비가 밀린 동안 필요한 WAL을 유지하는 데 영향을 준다. Kafka나 connector가 오래 멈추면 DB의 WAL 공간이 커질 수 있다. 반대로 슬롯 제거나 보존 한계로 필요한 WAL이 사라지면 저장된 source 오프셋에서 복구할 수 없다. “Kafka에 lag가 쌓인다”는 모델뿐 아니라 원본 DB에도 보존 부담이 생긴다는 점을 관측해야 한다. DB failover 뒤 slot과 source 위치를 어떻게 유지할지도 PostgreSQL 버전과 운영 구조에 의존한다.
Initial Snapshot과 Streaming은 어떻게 이어지는가
신규 커넥터가 로그만 읽으면 연결하기 전에 존재한 행은 알 수 없다. Initial snapshot은 기준 상태를 먼저 읽어 채우고, 이후 변경 스트림으로 이어가는 절차다. Debezium PostgreSQL의 기본적인 initial snapshot은 일관된 DB snapshot과 WAL 위치를 연결하여 snapshot 중 커밋된 변경을 streaming에서 놓치지 않게 설계되어 있다.
이때 “전체 행을 읽은 뒤 그때부터 로그를 시작한다”고 생각하면 안 된다. 그렇게 구현하면 테이블을 읽는 동안 생긴 변경이 빈틈으로 빠진다. 기준 WAL 위치를 확보하고 일관된 snapshot을 읽은 뒤 그 위치와 연결된 streaming을 시작해야 한다. 따라서 기준 상태에 없던 변경도 뒤에서 따라올 수 있고, 같은 행의 snapshot 이벤트와 update 이벤트를 모두 받는 것이 정상이다. Connector의 snapshot mode, 격리·잠금, 필터, 권한 조건을 확인한다.
Snapshot 중 실패하면 재시작 시 snapshot을 다시 수행하거나 저장된 상태에서 다른 절차를 밟을 수 있다. 모든 snapshot mode가 같은 재개 계약을 갖지는 않는다. Incremental snapshot은 초기 snapshot과도 다르며 chunk와 watermark를 사용한다. 따라서 “snapshot을 지원한다”는 한 문장으로 무중단 초기화·중복 없음·임의 지점 재개를 동시에 주장할 수 없다. Debezium의 장애 복구 설명도 중복 변경 이벤트가 발생할 수 있는 at-least-once 경계를 명시한다.
Snapshot 도중 주문이 바뀌면 어떤 레코드를 받는가
가상의 주문 104가 snapshot의 일관된 기준 상태에서는 CREATED이고, snapshot을 읽는 동안 다른 트랜잭션이 PAID로 수정해 커밋되었다고 하자. 커넥터는 초기 행을 읽은 op=r(read) 이벤트를 만들고, 이어지는 streaming에서 op=u(update) 이벤트를 내보낼 수 있다. 전자는 기준 상태, 후자는 그 뒤의 변경이다. 같은 키를 두 번 받았다는 사실만으로 중복 장애라고 판정하지 않는다.
그림은 “테이블 읽기를 마친 뒤 현재 WAL 끝에서 시작”하는 방식과 다르다. 후자의 방식이면 snapshot 중 커밋된 변경이 읽기 사이의 빈틈에서 사라질 수 있다. 기본 initial 절차는 트랜잭션과 로그 위치를 연결하고 완료 상태를 source 오프셋에 남긴다. 실제 일관성은 선택한 snapshot.isolation.mode와 DB 조건에 의존하므로 모든 사용자 지정 모드를 같은 계약으로 해석하지 않는다.
검색 sink는 기준 상태를 적재한 뒤 후속 변경으로 현재 문서를 갱신할 수 있다. 정산 소비자는 snapshot의 r을 “지금 새 결제가 발생했다”로 처리하면 안 된다. Snapshot·변경 종류를 구분하는 이벤트 계약이 필요하다. 현재 상태를 읽어 적재하는 일과 업무 행위가 새로 일어난 사실을 읽는 일의 차이는 16장의 outbox에서도 이어진다.
근거: Debezium PostgreSQL 3.3.0.Final의 Snapshots·Change event values.
Delete 이벤트와 Tombstone을 혼동하지 않기
Debezium의 delete 변경 이벤트는 삭제가 일어났다는 사실을 담는다. PostgreSQL replica identity에 따라 삭제 전 행의 어떤 값이 제공되는지가 달라진다. 기본적인 delete 표현에서는 after가 null인 envelope가 있고, 설정에 따라 그 뒤 같은 키에 값이 null인 tombstone을 보낸다. Envelope 안의 null과 Kafka 레코드 값 자체의 null은 다른 것이다.
Delete 이벤트는 소비자가 외부 상태를 지우도록 알려 주고, tombstone은 compacted 토픽에서 해당 키의 이전 레코드들을 제거하는 데 사용된다. Tombstone만 없애면 로그 청소의 계약이 달라지고, delete 이벤트만 제거하면 업무 소비자가 삭제 의미를 놓칠 수 있다.
SMT로 envelope를 펼치거나 null 레코드를 필터링한다면 결과 포맷을 sink의 delete 설정과 같이 확인한다. Confluent JDBC Sink의 delete 처리는 delete.enabled와 키 기반 기본 키(primary key) 조건을 갖는다. 모든 sink가 Debezium envelope를 그대로 이해하는 것은 아니다.
확인 질문: 주문 상태 이력을 복제할 것인가, 현재 상태를 복제할 것인가
현재 행 polling을 쓰는 주문 복제에서 하루 동안 네 번 수정된 주문이 마지막 상태로 한 번 전달되었다. 요구가 “검색 화면은 최신 상태면 된다”에서 “모든 상태 전이를 정산 근거로 남겨야 한다”로 바뀌었다. Polling 간격을 줄이면 충분한가? 원본에서 이미 삭제된 행은 어떻게 회복하는가?
해설
더 자주 조회해도 조회 사이의 변경을 모두 볼 수 있다는 보장은 없다. 요구가 상태 동기화에서 변경 이력 전달로 바뀌었으므로 CDC나 트랜잭션에 함께 쓰는 이벤트 이력/outbox를 검토한다. 물리 삭제된 행은 현재 테이블만으로 복원할 수 없다. DB 변경 로그·삭제 이력·백업 등 남아 있는 근거가 필요하다. CDC로 바꾸더라도 이미 사라진 WAL 이전의 삭제가 자동으로 복구되지는 않는다. 초기 상태 재구축과 기존 downstream의 삭제 reconciliation을 별도로 설계한다.
근거: Confluent JDBC Source, 설정 reference, Debezium PostgreSQL 3.3.0.Final 공식 원문. JDBC current는 고정 버전 보장이 아닌 조회 시점의 문서다.
Debezium 근거는 같은 프로젝트의 고정 release tag 원문으로 대조했다. 기존 검토에서 웹 출판면 접근 차단을 기록했으나, 이번 보강에서는 버전을 고정하기 위해 release tag 원문을 직접 조회했다.
16장. DB 변경과 발행의 빈틈을 없애는 법 | Transactional Outbox
주문 생성 DB 트랜잭션이 성공한 다음 프로듀서가 Kafka에 이벤트를 보내는 구조를 생각해 보자. 두 작업 사이에서 프로세스가 죽으면 주문은 있는데 이벤트가 없다. 반대로 Kafka에 먼저 보낸 다음 DB를 커밋하면 DB가 롤백되어도 주문 이벤트가 남을 수 있다. Kafka 트랜잭션만으로 이 두 저장소의 원자성을 얻지는 못한다.
업무 행과 Outbox 행을 같은 DB 트랜잭션에 넣는다
Transactional outbox는 주문 행과 발행할 이벤트 행을 같은 DB 트랜잭션에 기록한다. 둘 다 커밋되거나 둘 다 롤백된다. 이 경계에서 얻는 것은 “주문은 커밋됐는데 보낼 이벤트 기록 자체가 없다”는 빈틈의 제거다. 아직 Kafka 전달 성공이나 소비자의 업무 효과 완료를 보장한 것은 아니다.
이후 별도 relay가 outbox를 읽어 Kafka로 보낸다. Relay는 직접 만든 polling 프로그램일 수도, Debezium CDC connector와 Outbox Event Router SMT 조합일 수도 있다.
전달 실패 때 DB에 남은 outbox 기록이나 source 위치로 재시도할 수 있다. 이때 Kafka 발행 성공 후 relay checkpoint 저장 전에 죽으면 재전달 가능성이 남는다. Outbox는 dual-write의 누락 문제를 줄이는 패턴이지, 중복이 존재하지 않는 전송 방식의 다른 이름이 아니다.
주문 서비스
DB transaction ── 주문 행 + outbox(event_id, aggregate_id, payload)
│
relay / CDC Source Task
│
Kafka orders
│
consumer → 멱등한 업무 반영
CDC로 주문 행을 읽는 것과 Outbox로 이벤트를 남기는 것
15장의 CDC를 켜면 DB에 커밋된 행 변경을 포착할 수 있다. 그런데 orders.status가 바뀌었다는 기술적 사실과 OrderPaid라는 업무 이벤트의 의미는 같지 않을 수 있다. 한 업무가 여러 테이블을 바꾸거나, 기존 행을 정정하는 관리 작업이 있을 수 있기 때문이다. Outbox는 애플리케이션이 업무 의미를 아는 시점에 발행할 내용을 별도 행으로 명시한다. CDC는 그 행을 전달하는 방법으로 사용할 수 있다.
Relay는 DB에 남긴 발행 예정 기록을 읽어 Kafka로 옮기는 별도 실행 주체다. HTTP 요청을 처리한 서비스가 종료되어도 relay가 나중에 이어서 전달할 수 있도록 기록을 남긴다. Relay가 있다는 것은 비동기 전달이라는 뜻이므로 주문 커밋 직후 검색·정산까지 동시에 완료된다고 기대하지 않는다.
가상의 한결마켓이 새 주문을 생성할 때의 설명용 SQL은 다음과 같다. 테이블·열·타입은 사례를 위한 가상 설계이며 실행 검증한 migration이 아니다. 여기의 event_id·aggregate_id 이름은 아래 Debezium SMT 기본 열 이름과 다르므로 실제 적용 시 매핑이나 테이블 설계를 맞춰야 한다.
BEGIN;
INSERT INTO orders (id, status)
VALUES (104, 'CREATED');
INSERT INTO outbox (event_id, aggregate_id, event_type, payload)
VALUES ('E-104-created', '104', 'OrderCreated',
'{"orderId":104,"status":"CREATED"}');
COMMIT;
DB 트랜잭션이 커밋되면 주문 행과 발행 예정 기록이 함께 반영되고, 롤백되면 둘 다 이 트랜잭션의 결과로 반영되지 않는다. 다만 COMMIT 응답 유실이나 연결 장애에서는 호출자가 커밋 여부를 확정하지 못할 수 있다. 원자성은 둘 중 하나만 반영되지 않는다는 보장이지, 모든 통신 실패에서 호출자가 결과를 안다는 보장이 아니다. 업무 요청 재시도에는 안정적인 요청·이벤트 ID와 결과 조회 또는 중복 방지 계약이 별도로 필요하다. 같은 DB 트랜잭션이라는 말은 한 요청 안에서 두 insert를 순서대로 실행했다는 뜻이 아니라, 하나의 원자적 커밋 경계로 묶었다는 뜻이다. 주문 insert를 먼저 커밋하고 outbox insert를 따로 커밋하면 원래의 빈틈이 되살아난다.
정상 전달에서는 기록의 의미가 단계마다 바뀐다
- 주문 서비스가 업무 값과 안정적인 event ID를 정하고 위 두 행을 함께 커밋한다. 이 시점에 보장된 것은 원본 DB의 주문과 발행 예정 기록의 관계다.
- Relay가 커밋된 outbox 행을 읽는다. Polling relay는 행을 조회하고, CDC relay는 outbox insert의 변경 로그를 읽는다. 둘 중 어떤 방식을 쓰는지에 따라 위치 저장과 보존 정책이 달라진다.
- Debezium CDC와 Outbox Event Router를 선택했다면 source task가 만든 변경 envelope를 worker 안의 SMT가 업무 이벤트로 바꾼다. Converter는 그 결과를 바이트로 바꾸고 producer가 Kafka 토픽에 보낸다. SMT가 DB에서 행을 읽거나 별도 프로듀서 서버 역할을 하는 것은 아니다.
- Kafka가 레코드를 저장·복제한다. Consumer는 자신의 오프셋에서 이벤트를 읽어 업무 효과를 반영한다. 포인트 적립이라면 event ID 처리 기록과 적립을 같은 목적지 DB 트랜잭션에 넣는다.
- Consumer가 업무 반영 성공 뒤 Kafka 오프셋을 커밋한다. 그 사이 실패해 같은 이벤트가 다시 오면 동일 event ID로 이미 처리한 효과인지 판정한다.
이 그림은 세 저장 경계를 하나의 전역 트랜잭션으로 연결하지 않는다. Source EOS를 사용할 수 있는 조건을 갖췄더라도 목적지 DB의 업무 원자성은 별도다. 일반 at-least-once relay에서 같은 outbox 이벤트가 Kafka에 재전달될 수 있고, consumer는 Kafka 오프셋 커밋 전 실패로 같은 레코드를 다시 읽을 수도 있다. 안정적인 event ID는 두 경우 모두 업무 효과를 중복 적용하지 않기 위한 근거다.
Debezium SMT의 기본 열 모델을 그대로 쓰는 경우에는 id, aggregatetype, aggregateid, type, payload 등을 준비한다. 예를 들어 id=E-104-created, aggregatetype=orders, aggregateid=104인 기록은 기본 라우팅에서 outbox.event.orders로 가며, aggregate ID는 레코드 키, event ID는 헤더의 중복 제거 근거, payload는 메시지 값의 중심이 된다. 실제 타입과 직렬화는 선택한 테이블·converter에 맞춘다. 이 예시 ID는 사람에게 보이기 쉽게 쓴 문자열이며 공식 예제의 UUID 열을 그대로 쓴다면 실제 UUID 값을 넣어야 한다.
앞의 일반 도식은 orders를 개념적인 목적지 이름으로 썼고, 위 이름은 Event Router의 기본 라우팅을 그대로 선택했을 때의 구체적인 토픽 이름이다. 임의로 두 이름을 같은 토픽이라고 가정하지 않는다. Event Router를 쓰지 않는 relay는 자신의 토픽·키·헤더 계약을 구현해야 한다.
위 매핑은 Debezium Outbox Event Router 3.3.0.Final의 Expected outbox table structure·Basic configuration·Options for applying the transformation selectively에서 대조했다. 다음 절은 이 정상 흐름에 이벤트 식별·순서·정리 조건을 추가한다.
Event ID와 Aggregate ID는 서로 다른 계약이다
Debezium Outbox Event Router 3.3.0.Final의 기본 구조에서 id는 이벤트 고유 ID이며 emitted message의 헤더에 실린다. 소비자는 이 값을 중복 제거 근거로 사용할 수 있다. aggregateid는 같은 주문처럼 동일한 aggregate에 속한 이벤트의 키로 사용된다. 하나는 이 이벤트가 전에 처리됐는가, 다른 하나는 관련 이벤트를 어느 파티션에 보낼 것인가의 계약이다.
주문 ID만으로 중복 제거하면 그 주문의 첫 이벤트 이후 정상적인 후속 이벤트도 버리게 된다. 반대로 매 재전송마다 새로운 event ID를 만들면 중복을 새 이벤트로 오인한다. 업무 트랜잭션에서 결정된 event ID를 재전달에도 유지해야 한다. Aggregate 키도 파티션 수와 파티셔너 조건에 따른 순서 한계를 그대로 가진다. Outbox가 여러 DB나 여러 프로듀서의 전역 시간 순서를 자동으로 만든다고 설명하면 안 된다.
Outbox Event Router는 기본적으로 insert 이벤트를 이용하는 구조이며 update를 허용하는 일반 상태 테이블 변환기가 아니다. Outbox 행 정리 과정의 delete는 해당 SMT가 필터링한다. 이것은 15장의 일반 상품 삭제 이벤트를 필터링해도 된다는 뜻이 아니다. Table filter와 SMT 적용 범위를 outbox에 한정하고 heartbeat·다른 테이블 이벤트를 같은 포맷으로 오인하지 않도록 한다.
삭제·보존·복구도 패턴의 일부다
Polling relay가 “발행 완료”를 DB에 표시하는 구조에서는 발행 성공과 완료 표시 사이의 중복 창을 처리해야 한다. CDC relay는 source 오프셋과 DB 로그 보존이 복구를 정한다. Outbox 테이블을 언제 정리할지, connector를 오래 멈췄을 때 WAL이 남아 있는지, 새 snapshot이 과거 outbox를 다시 발행할지도 정해야 한다.
Downstream이 포인트 적립을 한다면 이벤트 ID unique 제약과 적립을 같은 DB 트랜잭션으로 묶고, Kafka 오프셋은 그 트랜잭션 성공 뒤 커밋한다. 중복 여부 기록만 먼저 커밋하면 적립 직전 장애에서 정상 이벤트가 영원히 중복으로 분류될 수 있다. 두 작업은 순서만 맞출 것이 아니라 같은 원자적 경계 안에 두어야 한다.
확인 질문: Outbox를 쓰면 무엇이 Exactly-once인가
주문과 outbox는 함께 커밋되었다. CDC가 Kafka에 보낸 뒤 checkpoint 저장 전 종료되었고, 포인트 소비자는 이벤트 ID 처리표를 커밋한 다음 별도 트랜잭션으로 포인트를 적립한다. 어디에 누락·중복 가능성이 남는가?
해설
원본 주문과 이벤트 기록의 원자성은 확보했다. 그러나 CDC 복구에서 동일 이벤트 재전달이 가능하고, 소비자의 분리된 두 DB 트랜잭션 사이에서 종료되면 처리표에는 있지만 포인트는 적립되지 않는다. 처리표 insert와 적립을 동일 DB 트랜잭션에 넣어야 한다. unique 제약 충돌은 이미 성공한 효과의 재전달인지 판정하는 수단이며 임의의 예외를 모두 성공으로 삼키면 안 된다. 외부 알림·결제 API까지 포함한다면 해당 외부 서비스의 멱등 계약 또는 별도 outbox가 더 필요하다.
근거: Debezium Outbox Event Router 3.3.0.Final 공식 원문. 업무 DB와 Kafka의 원자성 경계는 6·7장의 실패 모델과 함께 읽는다.
부록 A. 업그레이드와 호환성 | 되돌릴 수 있는가를 먼저 묻기
업그레이드할 때는 목표 버전에 해당하는 절을 찾아 경로와 절차를 확인한다. 4.3으로 올릴 때의 조건과 절차는 다음과 같다.[D.1]
- 3.3.x부터 4.2.0까지의 버전에서 4.3.0으로 올릴 수 있다. 4.3은 KRaft 모드만 지원하므로 소프트웨어와 메타데이터 버전이 적어도 3.3.x여야 하고, 3.3.x보다 오래된 KRaft 클러스터는 먼저 3.9.x로 올리기를 권한다.
- 롤링 업그레이드는 브로커를 한 대씩 내리고, 코드를 바꾸고, 다시 띄운다. 동작과 성능을 확인한 뒤
kafka-features.sh로 릴리스 버전 4.3을 확정해 업그레이드를 마무리한다. - 이 버전에는 메타데이터 변경이 있어 클러스터 메타데이터 다운그레이드를 지원하지 않는다. 현재 버전과 목표 버전 사이에 메타데이터 변경이 없을 때만 다운그레이드가 가능하다.
바이너리 교체와 피처 확정을 나누면 되돌릴 수 있는 단계를 구분할 수 있다. 바이너리만 바꾼 상태에서는 문제가 생기면 되돌릴 여지가 있지만, 메타데이터 변경이 있는 버전을 확정하면 되돌릴 수 없다. 확정은 “관찰을 마친 뒤” 하는 별도의 결정이다.
수정 사항과 실행 환경, 호환성도 확인한다.
- 4.3.1. Kafka Streams의 RocksDB 관련 네이티브 메모리 누수, 재시작 시 오래된 오프셋으로 인한
OffsetOutOfRangeException등의 수정이 포함되어 있다.[D.1] - Java. Java 17, 21, 25를 완전히 지원하고, Java 11은 클라이언트와 Streams 등 일부 모듈에만 지원한다.[D.2]
- 리밸런스 프로토콜 설정.
group.coordinator.rebalance.protocols는 폐기 예정이며 5.0에서 제거된다. 5.0부터는 모든 프로토콜이 항상 켜지고 피처 버전으로만 제어된다.[D.1] 5장의consumer프로토콜 전환 일정과 함께 보면 된다. - 클라이언트·브로커 호환성. 버전 조합별 지원 여부는 호환성 페이지의 표로 확인한다.[D.3]
- D.1 4.3.0 업그레이드 경로와 절차, 메타데이터 다운그레이드 불가, 4.3.1 수정 사항,
group.coordinator.rebalance.protocols폐기 예정 (Upgrading § Upgrading to 4.3.1, § Upgrading to 4.3.0) https://kafka.apache.org/43/getting-started/upgrade/#upgrading-to-430 - D.2 지원 Java 버전 (Java Version) https://kafka.apache.org/43/operations/java-version/
- D.3 클라이언트·브로커·Streams 호환성 표 (Compatibility) https://kafka.apache.org/43/getting-started/compatibility/
부록 B. 선택 실험 | 읽은 것을 손으로 확인하고 싶을 때
본문은 실험 없이 읽어도 된다. 예측이 맞는지 직접 확인하려면 공식 빠른 시작(Quickstart)의 1~5단계로 로컬에 브로커 하나를 띄운다. 이 환경에서 아래 실험 대부분을 해 볼 수 있다. 빠른 시작은 Java 17 이상을 요구하고, kafka-storage.sh random-uuid로 클러스터 ID를 만든 뒤 kafka-storage.sh format --standalone으로 로그 디렉터리를 포맷하고 kafka-server-start.sh config/server.properties로 서버를 띄운다.[E.1]
명령의 세부 옵션은 버전마다 바뀔 수 있으므로 각 도구의 --help로 확인한다. 아래 실험에는 예측과 관찰 대상을 적었다.
- 오프셋과 소비자 위치(2장, 5장). 파티션 3개짜리 토픽을 만들고(
kafka-topics.sh --create,--describe), 콘솔 프로듀서로 몇 줄을 쓴다. 그룹 이름을 준 콘솔 컨슈머로 읽고 멈춘 뒤kafka-consumer-groups.sh --describe로CURRENT-OFFSET과LAG을 본다. 예측: 컨슈머를 멈춘 채 더 쓰면 LAG만 늘어난다. 새 그룹 이름으로 읽으면 기본latest때문에 기존 메시지를 받지 못한다(--from-beginning을 주면 받는다). - 오프셋 초기화(10장). 그룹을 멈춘 상태에서
--reset-offsets --to-earliest를--execute없이 먼저 실행해 미리 보기를 확인하고, 그다음 실행한다. 예측: 다시 읽기 시작한다. 컨슈머를 띄워 둔 채 실행하면 어떻게 되는지도 예측해 본다. - 세그먼트 단위 보존(8장).
kafka-configs.sh --alter --add-config로 토픽의segment.ms와retention.ms를 아주 작게 준 뒤 메시지를 쓰고 기다린다. 예측: 정확히retention.ms뒤가 아니라 세그먼트가 넘어가고 보존 점검 주기(기본 5분)가 지난 뒤 앞부분이 사라진다.--from-beginning으로 읽어 첫 오프셋이 0이 아닌지 확인한다. - 컴팩션(8장).
cleanup.policy=compact, 작은segment.ms, 낮은min.cleanable.dirty.ratio로 토픽을 만들고 같은 키로 여러 번 쓴다. 예측: 시간이 지나면 처음부터 읽을 때 키마다 마지막 값만 남고, 오프셋에 빈자리가 생긴다. 가장 최근(활성) 세그먼트 안의 중복은 남아 있다. - 복제와
min.insync.replicas(4장, 9장). 브로커가 셋 이상 필요하다. 공식 문서의 Docker 이미지 안내나 여러 설정 파일로 3노드를 띄울 수 있다면, 복제 계수 3·min.insync.replicas=2토픽에acks=all로 쓰면서 브로커를 하나, 둘 차례로 내려 본다. 예측: 하나일 때는 계속 쓰이고, 둘일 때는 쓰기가 거절된다.
실험 결과가 예측과 다르면 무엇을 잘못 예측했는지 한 줄로 남기고, 해당 장의 출처 절로 돌아가 원문과 대조한다.
- E.1 Java 17 이상, 클러스터 ID 생성과
format --standalone, 서버 시작, 토픽 생성·조회, 콘솔 프로듀서·컨슈머 (Quickstart) https://kafka.apache.org/43/getting-started/quickstart/
부록 C. 용어 찾아보기 | 처음 자세히 다룬 곳
| 용어 | 한 줄 뜻 | 자세히 |
|---|---|---|
| Data plane·Control plane | 레코드 쓰기·복제·읽기 경로와 metadata 관리 역할의 구분 | 1.1절 |
| Record batch·Serialization | 레코드를 바이트로 만들고 partition별로 묶는 전송·저장 단위 | 1.2절 |
| Sparse index | 모든 레코드 대신 일정 간격의 위치를 기록하는 색인 | 1.3절 |
| Page cache·Dirty page·Writeback | OS의 파일 캐시, 디스크 미반영 페이지와 내려쓰기 | 1.4절 |
| JVM heap·GC·Duplicate cache | 객체 관리 비용과 OS 캐시의 중복 점유를 구분 | 1.4절 |
| Sendfile·Zero-copy | 조건에 맞는 파일 전송에서 사용자 공간 왕복 복사를 줄이는 경로 | 1.5절 |
| 이벤트(event), 레코드 | “무언가 일어났다”는 사실 하나. 키·값·타임스탬프·헤더 | 2장 |
| 토픽(topic) | 이벤트를 저장하는 이름 붙은 흐름 | 2장 |
| 파티션(partition) | 토픽을 나눈 추가 전용 로그. 순서의 단위 | 2장, 3장 |
| 오프셋(offset) | 파티션 안의 위치 번호 | 2장 |
| 세그먼트(segment) | 파티션 로그를 나눈 파일. 삭제의 단위 | 8장 |
| 보존(retention) | 시간·크기 기준 삭제 정책 | 8장 |
| 컨슈머 그룹(consumer group) | 파티션을 나눠 읽는 컨슈머의 모임 | 3장, 5장 |
| share group | 파티션을 여러 소비자가 나눠 받고 레코드별로 확인하는 그룹 | 3장 |
| 리더·팔로워(leader, follower) | 파티션 복제본의 역할 | 4장 |
| ISR(in-sync replicas) | 리더를 충분히 따라가는 복제본 집합 | 4장 |
| 커밋된 메시지 | ISR 전체가 받은 메시지. 컨슈머에게 보임 | 4장 |
| 하이 워터마크(high watermark) | 커밋된 구간의 끝 | 4장 |
acks |
프로듀서가 성공을 듣는 시점 | 4장, 9장 |
min.insync.replicas |
acks=all 쓰기에 필요한 최소 ISR 크기 |
4장, 9장 |
| unclean leader election | ISR 밖 복제본을 리더로 뽑는 최후 수단 | 4장 |
| ELR(Eligible Leader Replicas) | ISR 밖이지만 안전하게 리더가 될 수 있는 복제본 | 4장, 9장 |
| 위치(position) | 컨슈머가 다음에 받을 오프셋. 메모리에만 있음 | 5장 |
| 오프셋 커밋(offset commit) | 재시작 지점을 코디네이터에 저장 | 5장, 6장 |
| 그룹 코디네이터 | 그룹의 오프셋과 멤버십을 관리하는 브로커 | 5장 |
| 리밸런스(rebalance) | 그룹의 파티션 할당을 다시 계산 | 5장 |
| 정적 멤버십(static membership) | 고정 ID로 재시작 시 리밸런스를 피함 | 5장 |
| 실패 창(failure window) | 처리와 커밋 사이, 죽으면 중복·누락이 생기는 구간 | 6장 |
| at-most-once, at-least-once, exactly-once | 전달 보장의 세 수준 | 6장, 7장 |
| 멱등 프로듀서 | 시퀀스 번호로 재시도 중복을 막는 프로듀서 | 7장 |
트랜잭션, transactional.id |
여러 파티션 쓰기와 오프셋 커밋을 원자적으로 | 7장 |
read_committed, LSO |
커밋된 트랜잭션만 읽기, 그 경계 | 7장 |
| 로그 컴팩션(log compaction) | 키마다 마지막 값을 남기는 정리 정책 | 8장 |
| 삭제 표시(tombstone) | 값이 null인 레코드. 컴팩션에서 삭제를 뜻함 | 8장 |
| 소비 지연(lag) | 로그 끝과 커밋(또는 위치) 사이의 거리 | 10장 |
| 컨트롤러, KRaft | 클러스터 메타데이터를 관리하는 역할과 방식 | 4장, 12장 |
| Connector·Task·Worker | 연동 설정·작업 단위·실행 프로세스 | 14장 |
| Converter·SMT | Connect 모델의 직렬화와 개별 레코드 변환 | 14장 |
| CDC·Snapshot | 변경 로그 읽기와 초기 기준 상태의 연결 | 15장 |
| Transactional Outbox | 업무 행과 발행할 이벤트 기록의 DB 원자성 | 16장 |
부록 D. 공식 문서 찾아보기 | 장별로 먼저 열 페이지
각 장 끝의 출처 절은 문장 단위의 근거를 담고, 이 표는 “이 주제를 원문으로 더 읽고 싶을 때 어디서 시작할까”를 안내한다. Kafka 근거는 https://kafka.apache.org/43/ 아래에 있고, JDBC·Debezium의 동작은 해당 프로젝트 공식 문서로 구분한다.
| 장 | 먼저 열 페이지 | 필요할 때 찾아볼 페이지 |
|---|---|---|
| 1장 | Design § Persistence·Efficiency, Network Layer | Log, Message Format, KRaft, Hardware and OS |
| 2장 | Introduction, Log | Topic Configs |
| 3장 | Design § The Consumer | Producer Configs |
| 4장 | Design § Replication | ELR, Hardware and OS |
| 5장 | Distribution, Consumer Rebalance Protocol | Consumer Configs |
| 6장 | Design § Message Delivery Semantics | KafkaConsumer·KafkaProducer Javadoc |
| 7장 | Design § Using Transactions, Transaction Protocol | Message Format |
| 8장 | Design § Log Compaction | Tiered Storage |
| 9장 | Design § Availability and Durability Guarantees | Broker Configs |
| 10장 | Monitoring | Basic Kafka Operations |
| 11장 | 앞의 모든 장의 페이지 | Design § Quotas |
| 12장 | KRaft | KRaft vs ZooKeeper |
| 13장 | Security Overview | Authorization and ACLs |
| 14장 | Connect User Guide | JDBC Sink |
| 15장 | JDBC Source | Debezium PostgreSQL v3.3.0.Final |
| 16장 | Debezium Outbox v3.3.0.Final | 6·7장의 처리·트랜잭션 실패 모델 |
설정 참조 페이지(브로커·토픽·프로듀서·컨슈머 설정), 모니터링 지표 표, 프로토콜 명세는 처음부터 읽기보다 증상이나 질문에 해당하는 항목을 찾아 답을 확인할 때 사용한다.
공식 근거 찾아보기
개별 주장 번호는 아래 원문 절에 연결한다. 이 목록은 공식 문서 전체의 정독 증명이 아니다. 수집·사용 범위·대조 상태는 저장소의 docs/kafka-textbook/ 기록으로 구분한다.
주장별 Kafka 4.3 원문
- 1.1 Leader 직접 전송, metadata 발견, consumer pull, follower fetch와 조건부 follower 읽기 (Design § The Producer, § The Consumer, § Replication) https://kafka.apache.org/43/design/design/
- 1.2 Broker·controller 역할, combined 모드, metadata quorum과 과반 (KRaft § Process Roles, § Controllers) https://kafka.apache.org/43/operations/kraft/
- 1.3 FindCoordinator, offset topic에 저장, coordinator의 메모리 캐시 (Distribution § Consumer Offset Tracking) https://kafka.apache.org/43/implementation/distribution/#consumer-offset-tracking
- 1.4 Serializer, partition별 버퍼, background I/O와 배치; batch에 하나 이상의 record (KafkaProducer Javadoc; Message Format) https://kafka.apache.org/43/javadoc/org/apache/kafka/clients/producer/KafkaProducer.html · https://kafka.apache.org/43/implementation/message-format/
- 1.5 배치의 고정 비용 분담과 end-to-end compression; topic codec 유지·재지정 (Design § Efficiency, § End-to-end Batch Compression; Topic Configs § compression.type) https://kafka.apache.org/43/design/design/#efficiency · https://kafka.apache.org/43/configuration/topic-configs/#topicconfigs_compression.type
- 1.6 Acceptor·processor의 NIO 연결 처리와 TransferableRecords·transferTo (Network Layer) https://kafka.apache.org/43/implementation/network-layer/
- 1.7 Segment 추가·탐색·삭제; 현재 record batch 형식은 Message Format과 대조 (Log; Message Format) https://kafka.apache.org/43/implementation/log/ · https://kafka.apache.org/43/implementation/message-format/
- 1.8 성긴 offset index와 timestamp index, 색인 간격·크기·탐색 비용 (Topic Configs § index.interval.bytes) https://kafka.apache.org/43/configuration/topic-configs/#topicconfigs_index.interval.bytes
- 1.9 Page cache의 dirty 데이터와 OS background writeback, 내려쓰기가 유입을 못 따라갈 때 쓰기 대기 (Hardware and OS § Understanding Linux OS Flush Behavior) https://kafka.apache.org/43/operations/hardware-and-os/#understanding-linux-os-flush-behavior
- 1.10 순차 I/O 설계 배경, 객체 overhead·GC와 OS cache 중복, pagecache 중심 선택과 프로세스 재시작 (Design § Persistence) https://kafka.apache.org/43/design/design/#dont-fear-the-filesystem
- 1.11 공통 이진 형식과 파일 전송의 복사 절감, 최신 로그의 캐시 재사용, SSL에서는 sendfile 미사용 (Design § Efficiency) https://kafka.apache.org/43/design/design/#efficiency
- 2.1 이벤트의 구성(키·값·타임스탬프·헤더), 프로듀서와 컨슈머의 분리, 토픽의 다중 생산·구독, 소비 뒤에도 지워지지 않고 토픽별 설정으로 보존, 파티션 분산과 같은 키의 같은 파티션 배치 (Introduction § Main Concepts and Terminology) https://kafka.apache.org/43/getting-started/introduction/#main-concepts-and-terminology
- 2.2 프로듀서가 파티션 리더에게 직접 전송, 모든 노드가 메타데이터 응답, 키 기반 의미적 파티셔닝 (Design § The Producer › Load balancing) https://kafka.apache.org/43/design/design/#load-balancing
- 2.3 기본 파티셔너: 키가 있으면 키 해시, 없으면
batch.size단위로 바뀌는 sticky 파티션 (Producer Configs §partitioner.class) https://kafka.apache.org/43/configuration/producer-configs/#producerconfigs_partitioner.class
- 2.4 파티션은 한 서버에 통째로, 파티션 수가 컨슈머 병렬성의 상한, 파티션별 폴더 / 파티션 증설 시 키 매핑 변화와 기존 데이터 미재배치, 파티션 감소 불가 (Basic Kafka Operations § Adding and removing topics, § Modifying topics) https://kafka.apache.org/43/operations/basic-kafka-operations/#adding-and-removing-topics · https://kafka.apache.org/43/operations/basic-kafka-operations/#modifying-topics
- 2.5 파티션 디렉터리와 첫 오프셋으로 이름 붙은 세그먼트 파일, 64비트 오프셋, GUID 대신 오프셋을 쓴 이유, 마지막 파일에만 추가, 범위 밖 오프셋 요청 시 오류, 세그먼트 단위 삭제와 최대 타임스탬프 기준·크기 기준 (Implementation § Log(Writes, Reads, Deletes)) https://kafka.apache.org/43/implementation/log/
- 2.6 소비 위치는 파티션당 정수 하나, 되감아 다시 읽기 / 일정 시간 성능 덕분에 소비 즉시 지우지 않고 오래 보관 (Design § Consumer Position, § Constant Time Suffices) https://kafka.apache.org/43/design/design/#consumer-position · https://kafka.apache.org/43/design/design/#constant-time-suffices
- 2.7 위치는 다음에 받을 레코드의 오프셋, 오프셋이 연속되지 않을 수 있는 경우 (KafkaConsumer Javadoc(4.3) § Offsets and Consumer Position) https://kafka.apache.org/43/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html
- 2.8
retention.ms(기본 7일,-1무제한),retention.bytes(기본-1, 파티션 단위),segment.bytes(기본 1GiB),segment.ms(기본 7일),message.timestamp.type(기본CreateTime) (Topic Configs) https://kafka.apache.org/43/configuration/topic-configs/#topicconfigs_retention.ms
- 2.9
log.retention.check.interval.ms(기본 5분),offsets.retention.minutes(기본 10080, 빈 그룹의 커밋 오프셋 만료) (Broker Configs) https://kafka.apache.org/43/configuration/broker-configs/#brokerconfigs_offsets.retention.minutes
- 2.10
auto.offset.reset의 값(earliest,latest,by_duration,none)과 기본값latest(Consumer Configs) https://kafka.apache.org/43/configuration/consumer-configs/#consumerconfigs_auto.offset.reset
- 2.11
message.timestamp.after.max.ms(기본 1시간, 초과 시 거부),message.timestamp.before.max.ms(기본 무제한) (Topic Configs) https://kafka.apache.org/43/configuration/topic-configs/#topicconfigs_message.timestamp.after.max.ms
- 3.1 한 토픽 파티션의 컨슈머는 쓰인 순서와 정확히 같은 순서로 읽는다 (Introduction § Main Concepts and Terminology) https://kafka.apache.org/43/getting-started/introduction/#main-concepts-and-terminology
- 3.2 토픽은 완전히 순서가 정해진 파티션들의 집합, 각 파티션은 구독 그룹마다 어느 순간 정확히 한 컨슈머가 소비 (Design § Consumer Position) https://kafka.apache.org/43/design/design/#consumer-position
- 3.3 메시지는 항상 오프셋 순서로 반환 (Consumer Configs §
isolation.level) https://kafka.apache.org/43/configuration/consumer-configs/#consumerconfigs_isolation.level
- 3.4 멱등성 없이 in-flight > 1과 재시도가 겹치면 순서가 바뀔 수 있음, 멱등성 사용 시 5 이하에서 순서 보존, 충돌 설정 시 멱등성이 조용히 꺼짐 (Producer Configs §
max.in.flight.requests.per.connection,retries,enable.idempotence,acks) https://kafka.apache.org/43/configuration/producer-configs/#producerconfigs_max.in.flight.requests.per.connection
- 3.5 파티션 수가 컨슈머 병렬성에 미치는 영향, 파티션 증설 시 키 매핑 변화·기존 데이터 미재배치·메타데이터 전파 지연·
latest컨슈머의 누락 위험 (Basic Kafka Operations § Adding and removing topics, § Modifying topics) https://kafka.apache.org/43/operations/basic-kafka-operations/#modifying-topics
- 3.6 소비와 처리를 분리하면 순서 보장에 특별한 주의가 필요하고 커밋 조정이 어려워짐,
TopicPartition해시로 스레드별 큐를 두는 변형 (KafkaConsumer Javadoc(4.3) § Multi-threaded Processing › 2. Decouple Consumption and Processing) https://kafka.apache.org/43/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html
- 3.7 share group의 협력 소비, 파티션의 다중 할당, 소비자 수 > 파티션 수, 레코드별 확인과 전달 횟수, 획득 잠금(기본 30초) / 4.2에서 운영 사용 가능, 하나씩 처리하는 경우에 사용 (Design § The Share Consumer; Upgrading § Notable changes in 4.2.0) https://kafka.apache.org/43/design/design/#the-share-consumer · https://kafka.apache.org/43/getting-started/upgrade/#notable-changes-in-420
- 3.8 두 프로듀서가 같은 파티션에 쓸 수 있음(소개 문서 그림 설명) (Introduction § Main Concepts and Terminology) https://kafka.apache.org/43/getting-started/introduction/#main-concepts-and-terminology
- 4.1 데이터는 즉시 파일 시스템의 로그에 쓰되 반드시 디스크로 플러시하지는 않으며 사실상 페이지 캐시로 옮겨짐 (Design § Persistence › Don't fear the filesystem!) https://kafka.apache.org/43/design/design/#dont-fear-the-filesystem
- 4.2 내구성은 디스크 동기화를 요구하지 않고 실패한 노드는 복제본에서 복구, 애플리케이션 fsync를 끄는 기본값 권장, fsync 확인되지 않은 세그먼트는 복구 시 CRC 검사 (Hardware and OS § Application vs. OS Flush Management) https://kafka.apache.org/43/operations/hardware-and-os/#application-vs-os-flush-management
- 4.3
flush.messages,flush.ms기본값과 “설정하지 말고 복제로 내구성을 얻으라”는 권고 (Topic Configs) https://kafka.apache.org/43/configuration/topic-configs/#topicconfigs_flush.messages
- 4.4 시작 시 최신 세그먼트 검증, 손상 시 마지막 유효 오프셋까지 절단, 두 종류의 손상 (Implementation § Log › Guarantees) https://kafka.apache.org/43/implementation/log/#guarantees
- 4.5 복제 단위와 리더·팔로워, 팔로워의 pull, 동기화의 두 조건(컨트롤러 세션·지연 한도), ISR 관리, 실패/복구 모델과 비잔틴 제외, 커밋의 정의, 커밋된 메시지만 소비자에게 전달,
acks와 무관한 가시성 조건, 보장 문장, 네트워크 분할 시 가용성 (Design § Replication) https://kafka.apache.org/43/design/design/#replication
- 4.6
broker.session.timeout.ms(기본 9초),replica.lag.time.max.ms(기본 30초)의 정의 (Broker Configs) https://kafka.apache.org/43/configuration/broker-configs/#brokerconfigs_replica.lag.time.max.ms
- 4.7 ISR은 클러스터 메타데이터에 저장되고 ISR만 선출 자격, 커밋은 ISR 전체 수신, 안정 저장소를 가정하지 않고 재합류 전 완전 재동기화 (Design § Replicated Logs: Quorums, ISRs, and State Machines) https://kafka.apache.org/43/design/design/#replicated-logs-quorums-isrs-and-state-machines-oh-my
- 4.8
acks=0/1/all의 의미, 기본값all(Producer Configs §acks) https://kafka.apache.org/43/configuration/producer-configs/#producerconfigs_acks
- 4.9
acks=all은 현재 ISR 기준, 복제본 2개 예시, 최소 ISR 크기와 unclean 비활성화라는 두 가지 내구성 우선 설정 (Design § Availability and Durability Guarantees) https://kafka.apache.org/43/design/design/#availability-and-durability-guarantees
- 4.10
min.insync.replicas의 의미,NotEnoughReplicas(AfterAppend), 가시성 조건, RF 3·min 2·acks all 예, 기본값 1, ELR 시 의미 변화 (Topic Configs §min.insync.replicas) https://kafka.apache.org/43/configuration/topic-configs/#topicconfigs_min.insync.replicas
- 4.11 컨트롤러가 브로커 실패를 감지해 남은 ISR에서 새 리더 선출, 알림 일괄 처리, 컨트롤러 재선출 (Design § Replica Management) https://kafka.apache.org/43/design/design/#replica-management
- 4.12 모든 복제본이 죽으면 보장 불성립, 두 가지 선택과 트레이드오프, 0.11.0.0 이후 기본 동작 (Design § Unclean leader election: What if they all die?) https://kafka.apache.org/43/design/design/#unclean-leader-election-what-if-they-all-die
- 4.13
unclean.leader.election.enable(기본false), KRaft에서 동적 활성화 시 선출 스레드 주기(기본 5분)와kafka-leader-election.sh(Topic Configs / Broker Configs) https://kafka.apache.org/43/configuration/topic-configs/#topicconfigs_unclean.leader.election.enable
- 4.14 엄격한 최소 ISR 규칙(ISR < min ISR이면 하이 워터마크 전진 불가), ELR 선출 순서, 4.1부터 새 클러스터 기본 활성, 4.0에서
eligible.leader.replicas.version=1/ read_committed 설명의 하이 워터마크 용어 (Eligible Leader Replicas § Overview, § Upgrade & Downgrade; Consumer Configs §isolation.level) https://kafka.apache.org/43/operations/eligible-leader-replicas/
- 4.15 정상 종료 시 로그 동기화와 리더십 이전, 모든 파티션에 살아 있는 다른 복제본이 있어야 성공 (Basic Kafka Operations § Graceful shutdown) https://kafka.apache.org/43/operations/basic-kafka-operations/#graceful-shutdown
- 5.1 위치와 커밋된 위치의 구분, 커밋 값은 다음에 소비할 오프셋, 자동 커밋의 at-least-once 조건, 하트비트·세션 타임아웃·
max.poll.interval.ms와CommitFailedException,close()시 커밋과 그룹 떠나기,ConsumerRebalanceListener(KafkaConsumer Javadoc(4.3) § Offsets and Consumer Position, § Detecting Consumer Failures, § Usage Examples,commitSync,close) https://kafka.apache.org/43/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html
- 5.2 그룹 코디네이터와
FindCoordinator,__consumer_offsets(컴팩션)에 기록, 모든 복제본 수신 후 응답, 메모리 캐시와CoordinatorLoadInProgressException(Implementation § Distribution › Consumer Offset Tracking) https://kafka.apache.org/43/implementation/distribution/#consumer-offset-tracking
- 5.3
enable.auto.commit(기본 true),auto.commit.interval.ms(5초),session.timeout.ms(45초, consumer 프로토콜에서는 미지원),heartbeat.interval.ms(3초),max.poll.interval.ms(5분, 정적 멤버 동작),max.poll.records(500),group.protocol(기본 classic),group.instance.id,partition.assignment.strategy기본 목록 (Consumer Configs) https://kafka.apache.org/43/configuration/consumer-configs/#consumerconfigs_max.poll.interval.ms
- 5.4
offsets.commit.timeout.ms(5초),offsets.topic.replication.factor(3),group.consumer.session.timeout.ms(45초),group.consumer.heartbeat.interval.ms(5초) (Broker Configs) https://kafka.apache.org/43/configuration/broker-configs/#brokerconfigs_group.consumer.session.timeout.ms
- 5.5 KIP-848 4.0 GA, 점진적 설계와 전역 동기화 장벽 제거, 서버 측 설정과 할당기(
uniform기본,range),group.protocol=consumer필요, 쓸 수 없는 설정과 API, 온라인·오프라인 전환 조건, 향후 일정, 제한 사항 (Consumer Rebalance Protocol) https://kafka.apache.org/43/operations/consumer-rebalance-protocol/
- 5.6 동적 멤버십의 문제, 고정 ID로 리밸런스 방지, 중복 ID 시
FencedInstanceIdException(Design § Static Membership) https://kafka.apache.org/43/design/design/#static-membership
- 6.1 at-most-once·at-least-once·exactly-once의 정의, 소비자의 두 가지 순서와 결과, 기본 키에 의한 멱등 갱신, 네트워크 오류 시 커밋 여부 불확실(자동 증가 키 비유), 0.11.0.0 이전 재전송의 결과, 기본은 at-least-once이고 재시도 끄기+처리 전 커밋으로 at-most-once 구현 (Design § Message Delivery Semantics) https://kafka.apache.org/43/design/design/#message-delivery-semantics
- 6.2 수동 커밋 예제(데이터베이스 삽입 후 커밋, 사이에 죽으면 마지막 배치 재삽입), 자동 커밋은
poll()반환 시 소비로 간주, 자동 커밋의 at-least-once 조건 (KafkaConsumer Javadoc(4.3) § Usage Examples › Automatic Offset Committing, Manual Offset Control) https://kafka.apache.org/43/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html
- 6.3
retries(기본 2147483647, 같은 레코드 재전송,delivery.timeout.ms로 조절 권장),delivery.timeout.ms(기본 2분, 성공·실패 보고의 상한) (Producer Configs) https://kafka.apache.org/43/configuration/producer-configs/#producerconfigs_delivery.timeout.ms
- 6.4 애플리케이션 수준 재전송은 중복 제거 불가, 무한 재시도에도 오류 시 마지막 메시지 중복 확인, 멱등성은 한 세션 안에서만 보장 (KafkaProducer Javadoc(4.3) 클래스 설명) https://kafka.apache.org/43/javadoc/org/apache/kafka/clients/producer/KafkaProducer.html
- 6.5 외부 시스템에 쓸 때 2단계 커밋 대신 오프셋을 출력과 같은 곳에 저장 (Design § Message Delivery Semantics) https://kafka.apache.org/43/design/design/#message-delivery-semantics
- 7.1 멱등 전달(프로듀서 ID와 시퀀스 번호), 여러 파티션에 대한 원자적 쓰기, 오프셋이 내부 토픽의 메시지라 같은 트랜잭션에 포함, abort 시 위치 복귀와 재조회 필요, 격리 수준, 외부 시스템과 2단계 커밋 대신 오프셋을 출력과 같은 곳에 저장, 다른 목적지의 exactly-once는 그 시스템의 협력 필요 (Design § Message Delivery Semantics) https://kafka.apache.org/43/design/design/#message-delivery-semantics
- 7.2
enable.idempotence(기본 true, 요건과 충돌 시 동작),max.in.flight.requests.per.connection(브로커가 프로듀서당 최대 5배치 보관),transactional.id(멱등성 자동, 기본 3브로커 요구),transaction.timeout.ms(기본 1분, 코디네이터가 abort) (Producer Configs) https://kafka.apache.org/43/configuration/producer-configs/#producerconfigs_transactional.id
- 7.3 트랜잭션은 프로듀서만, 컨슈머 위치의 트랜잭션 갱신, exactly-once의 세 요소, 컨슈머당 프로듀서 하나, read_committed 권장, 필수 설정,
transactional.id로 이전 인스턴스의 트랜잭션 abort, 트랜잭션 마커, abort 시 위치 명시적 복귀, 예외 범주, 객체 재생성 또는committed+seek(Design § Using Transactions) https://kafka.apache.org/43/design/design/#using-transactions
- 7.4
read_committed/read_uncommitted(기본), LSO까지만 읽기, 열린 트랜잭션 뒤 메시지 보류 (Consumer Configs §isolation.level) https://kafka.apache.org/43/configuration/consumer-configs/#consumerconfigs_isolation.level
- 7.5 레코드 배치의
producerId·producerEpoch·baseSequence와 트랜잭션·제어 배치 속성, 제어 배치(커밋·abort 마커)는 애플리케이션에 전달되지 않음 (Message Format § Record Batch, § Control Batches) https://kafka.apache.org/43/implementation/message-format/#record-batch
- 7.6 멱등성은 한 세션 안에서만,
transactional.id의 목적과 인스턴스별 고유성, 트랜잭션 토픽의 RF 3·min ISR 2 권장, 복구 불가 예외(ProducerFencedException등) 시 프로듀서 종료 (KafkaProducer Javadoc(4.3) 클래스 설명) https://kafka.apache.org/43/javadoc/org/apache/kafka/clients/producer/KafkaProducer.html
- 7.7 KIP-890(4.0), 트랜잭션마다 에포크 증가,
transaction.version, 클라이언트 전환 시점, 안전한 다운그레이드, 서버 측 재시도로 produce 지연이 높게 보이지만 종단 간 지연은 불변 (Transaction Protocol) https://kafka.apache.org/43/operations/transaction-protocol/
- 7.8
transaction.state.log.replication.factor(3),transaction.state.log.min.isr(2),transaction.max.timeout.ms(15분, 소비자 정체 방지) (Broker Configs) https://kafka.apache.org/43/configuration/broker-configs/#brokerconfigs_transaction.max.timeout.ms
- 7.9 read_committed의 LSO와 지연 지표 기준, 오프셋을 외부 저장소에 결과와 함께 원자적으로 저장하는 방법과 절차 (KafkaConsumer Javadoc(4.3) § Reading Transactional Messages, § Storing Offsets Outside Kafka) https://kafka.apache.org/43/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html
- 7.10 Kafka Streams는 입력 오프셋 커밋·상태 저장소 갱신·출력 쓰기를 원자적으로 완료 / 상태 저장소마다 컴팩션된 변경 로그 토픽 유지 (Streams Core Concepts § Processing Guarantees; Streams Architecture § Fault Tolerance) https://kafka.apache.org/43/streams/core-concepts/#processing-guarantees · https://kafka.apache.org/43/streams/architecture/
- 8.1
cleanup.policy의 값(delete기본,compact, 둘 다, 빈 목록=무기한)과delete,compact의 동작 (Topic Configs §cleanup.policy) https://kafka.apache.org/43/configuration/topic-configs/#topicconfigs_cleanup.policy
- 8.2 키마다 적어도 마지막 값 보장, 키가 있는 가변 데이터의 변경 기록, 쓰임새(변경 구독·이벤트 소싱·고가용성 기록), 토픽별 정책 (Design § Log Compaction) https://kafka.apache.org/43/design/design/#log-compaction
- 8.3 머리와 꼬리, 오프셋 불변과 빈 오프셋의 위치 동등성(36·37·38 예), 삭제 표시(null 값)와 그 정리, 백그라운드 재복사, 읽기 비차단과 I/O 제한 (Design § Log Compaction Basics) https://kafka.apache.org/43/design/design/#log-compaction-basics
- 8.4 컴팩션의 보장 다섯 가지, 삭제 표시는
delete.retention.ms(기본 24시간) 안에 머리에 도달해야 모두 보이며 더 뒤처지면 놓칠 수 있음 (Design § What guarantees does log compaction provide?) https://kafka.apache.org/43/design/design/#what-guarantees-does-log-compaction-provide
- 8.5 로그 클리너의 동작, 추가 공간은 세그먼트 하나, 클리너 기본 활성, 활성 세그먼트 제외, 최대 지연은 엄격한 보장이 아님과 관련 지표 (Design § Log Compaction Details, § Configuring The Log Cleaner) https://kafka.apache.org/43/design/design/#log-compaction-details
- 8.6
delete.retention.ms(기본 1일, 처음부터 읽는 소비자의 한도),min.compaction.lag.ms(0),max.compaction.lag.ms,min.cleanable.dirty.ratio(0.5와 대상 조건) (Topic Configs) https://kafka.apache.org/43/configuration/topic-configs/#topicconfigs_delete.retention.ms
- 8.7 컴팩션 시 배치의 첫·마지막 오프셋과 시퀀스 보존, 빈 배치가 남을 수 있음 (Message Format § Record Batch) https://kafka.apache.org/43/implementation/message-format/#record-batch
- 8.8 로컬·원격 두 계층, 완료된 세그먼트를 원격에 저장, 기본 비활성과
remote.storage.enable(Tiered Storage § Overview, § Configuration) /local.retention.ms기본 -2(Topic Configs) https://kafka.apache.org/43/operations/tiered-storage/ · https://kafka.apache.org/43/configuration/topic-configs/#topicconfigs_local.retention.ms
- 8.9
__consumer_offsets는 컴팩션 토픽(Implementation § Distribution) / Streams 변경 로그 토픽의 컴팩션(Streams Architecture § Fault Tolerance) https://kafka.apache.org/43/implementation/distribution/#consumer-offset-tracking · https://kafka.apache.org/43/streams/architecture/
- 8.10
offsets.retention.minutes(기본 10080),transactional.id.expiration.ms(기본 7일, 진행 중 트랜잭션은 만료되지 않음) (Broker Configs) https://kafka.apache.org/43/configuration/broker-configs/#brokerconfigs_transactional.id.expiration.ms
- 9.1
min.insync.replicas의 의미·예외·가시성 조건, RF 3·min 2·acks all 예, ELR 시 의미 변화 (Topic Configs §min.insync.replicas) https://kafka.apache.org/43/configuration/topic-configs/#topicconfigs_min.insync.replicas
- 9.2
acks=all은 현재 ISR 기준, 최대 가용성과 내구성의 트레이드오프, 최소 ISR과 unclean 비활성화 (Design § Availability and Durability Guarantees, § Unclean leader election) https://kafka.apache.org/43/design/design/#availability-and-durability-guarantees
- 9.3 멱등성의 전제와 조용한 비활성화,
delivery.timeout.ms(2분)와request.timeout.ms(30초)·linger.ms의 관계,request.timeout.ms>replica.lag.time.max.ms권고,linger.ms기본 5(4.0 변경),batch.size(16384),buffer.memory(32MiB),max.block.ms(1분) (Producer Configs) https://kafka.apache.org/43/configuration/producer-configs/
- 9.4
enable.auto.commit,max.poll.records,max.poll.interval.ms,session.timeout.ms,isolation.level,auto.offset.reset(Consumer Configs) https://kafka.apache.org/43/configuration/consumer-configs/
- 9.5
auto.create.topics.enable(기본 true),num.partitions(1),default.replication.factor(1),offsets.topic.replication.factor(3, 클러스터 크기 충족 전 생성 실패) (Broker Configs) https://kafka.apache.org/43/configuration/broker-configs/#brokerconfigs_auto.create.topics.enable
- 9.6 ELR 활성 시
min.insync.replicas설정 규칙 (Eligible Leader Replicas § Tool) https://kafka.apache.org/43/operations/eligible-leader-replicas/#tool
- 9.7 자동 생성 토픽의 기본 설정 조정 권고, 복제 계수 2 또는 3 권장, 명령줄 설정이 서버 기본값을 재정의(Basic Kafka Operations § Adding and removing topics) / 랙 인식 복제본 배치(§ Balancing replicas across racks) https://kafka.apache.org/43/operations/basic-kafka-operations/#adding-and-removing-topics · https://kafka.apache.org/43/operations/basic-kafka-operations/#balancing-replicas-across-racks
- 9.8
NotEnoughReplicasException과NotEnoughReplicasAfterAppendException은RetriableException의 하위 클래스, 후자는 추가 후 발견되어 재시도 시 중복 (Javadoc(4.3) org.apache.kafka.common.errors) https://kafka.apache.org/43/javadoc/org/apache/kafka/common/errors/NotEnoughReplicasAfterAppendException.html
- 9.9 엄격한 최소 ISR 규칙: ISR이 min ISR보다 작으면 하이 워터마크 전진 불가 (Eligible Leader Replicas § Overview) https://kafka.apache.org/43/operations/eligible-leader-replicas/#overview
- 9.10 토픽 설정별 “Server Default Property”(예:
retention.ms↔log.retention.ms) (Topic Configs) https://kafka.apache.org/43/configuration/topic-configs/#topicconfigs_retention.ms
- 10.1
kafka-consumer-groups.sh --describe의 CURRENT-OFFSET·LOG-END-OFFSET·LAG,--members,--members --verbose, consumer 프로토콜 그룹의 DESCRIBE 권한 요건 (Basic Kafka Operations § Checking consumer position, § Managing consumer groups) https://kafka.apache.org/43/operations/basic-kafka-operations/#checking-consumer-position
- 10.2
records-lag-max는 커밋된 오프셋이 아니라 현재 오프셋 기준, 파티션별records-lag(Monitoring § Consumer Fetch Metrics) https://kafka.apache.org/43/operations/monitoring/#consumer-fetch-metrics
- 10.3
time-between-poll-avg/max,last-poll-seconds-ago,poll-idle-ratio-avg,commit-latency-avg,commit-rate,rebalance-rate-per-hour,failed-rebalance-rate-per-hour,last-rebalance-seconds-ago(Monitoring § Consumer monitoring, § Consumer Group Metrics) https://kafka.apache.org/43/operations/monitoring/#consumer-monitoring
- 10.4
consumer-group-rebalance-rate등 그룹 코디네이터 지표 (Monitoring § Group Coordinator Monitoring) https://kafka.apache.org/43/operations/monitoring/#group-coordinator-monitoring
- 10.5
UnderReplicatedPartitions,AtMinIsrPartitionCount,UnderMinIsrPartitionCount,IsrShrinksPerSec/IsrExpandsPerSec,FailedIsrUpdatesPerSec,MaxLag,ActiveControllerCount,UncleanLeaderElectionsPerSec,ElectionFromEligibleLeaderReplicasPerSec,MessagesInPerSec과 정상값 /OfflinePartitionsCount(Monitoring(권장 그래프·경보 표), § KRaft Controller Monitoring Metrics) https://kafka.apache.org/43/operations/monitoring/ · https://kafka.apache.org/43/operations/monitoring/#kraft-controller-monitoring-metrics
- 10.6
TotalTimeMs와 구성 요소,acks=-1에서의RemoteTimeMs·produce 대기실, fetch 대기실과fetch.max.wait.ms, 처리 스레드 유휴 비율(0.3 초과 권장), 할당량throttle-time(Monitoring(권장 그래프·경보 표)) https://kafka.apache.org/43/operations/monitoring/
- 10.7
record-error-rate,record-retry-rate,record-queue-time-avg,request-latency-avg,produce-throttle-time-avg(Monitoring § Producer monitoring) https://kafka.apache.org/43/operations/monitoring/#producer-monitoring
- 10.8
--reset-offsets의 범위, 인스턴스 비활성 확인, 기본은 미리 보기·--execute·--export, 방식 목록, 범위 밖 오프셋 조정 (Basic Kafka Operations § Managing consumer groups) https://kafka.apache.org/43/operations/basic-kafka-operations/#managing-consumer-groups
- 10.9 read_committed 컨슈머의 지연 지표는 LSO 기준 (KafkaConsumer Javadoc(4.3) § Reading Transactional Messages) https://kafka.apache.org/43/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html
- 10.10 할당량 위반 시 지연 응답과 채널 음소거, fetch 응답은 데이터 없음 (Design § Quotas › Enforcement) https://kafka.apache.org/43/design/design/#enforcement
- 11.1 최소 ISR 크기(일관성↔가용성), unclean 선출(가용성↔일관성),
acks(지연↔내구성)가 트레이드오프라는 설명 (Design § Replication, § Unclean leader election, § Availability and Durability Guarantees) https://kafka.apache.org/43/design/design/#availability-and-durability-guarantees
- 11.2 RF 3·min 2·acks all 조합 (Topic Configs §
min.insync.replicas) https://kafka.apache.org/43/configuration/topic-configs/#topicconfigs_min.insync.replicas
- 11.3 커밋을 기다리면 10ms 수준, 지연에 민감한 경우 내구성 수준 선택, at-most-once 구현 (Design § Message Delivery Semantics) https://kafka.apache.org/43/design/design/#message-delivery-semantics
- 11.4 예전 오프셋으로 되감아 재소비, 버그 수정 후 재처리 (Design § Consumer Position) https://kafka.apache.org/43/design/design/#consumer-position
- 11.5 페이지 캐시와 sendfile 덕분에 대부분 따라잡은 소비자는 디스크 읽기 없이 캐시에서 제공 (Design § Efficiency) https://kafka.apache.org/43/design/design/#efficiency
- 11.6
--reset-offsets와--to-datetime등 (Basic Kafka Operations § Managing consumer groups) https://kafka.apache.org/43/operations/basic-kafka-operations/#managing-consumer-groups
- 11.7 컴팩션의 쓰임새(캐시 재적재, 검색 노드 복구 등)와 보장 (Design § Log Compaction) https://kafka.apache.org/43/design/design/#log-compaction
- 11.8 할당량은 과도한 클라이언트로부터 다른 클라이언트와 브로커를 보호 (Design § Quotas › Why are quotas necessary?) https://kafka.apache.org/43/design/design/#why-are-quotas-necessary
- 11.9 랙 인식 복제본 배치 (Basic Kafka Operations § Balancing replicas across racks) https://kafka.apache.org/43/operations/basic-kafka-operations/#balancing-replicas-across-racks
- 11.10 ISR 방식은 f+1 복제본으로 f개 장애를 견디며 과반수 방식보다 복제본·처리량 비용이 적음 (Design § Replicated Logs: Quorums, ISRs, and State Machines) https://kafka.apache.org/43/design/design/#replicated-logs-quorums-isrs-and-state-machines-oh-my
- A.1 4.3은 KRaft만 지원, ZooKeeper 모드 제거, 업그레이드 조건 (Upgrading § Upgrading Servers to 4.3.0) https://kafka.apache.org/43/getting-started/upgrade/#upgrading-servers-to-430-from-any-version-33x-through-420
- A.2
process.roles와 combined 모드의 한계, 컨트롤러 3·5대와 과반,controller.quorum.bootstrap.servers, 4.1의 정적→동적 구성, 디버깅 도구 (KRaft) https://kafka.apache.org/43/operations/kraft/
- A.3
group.version,transaction.version,eligible.leader.replicas.version(Consumer Rebalance Protocol; Transaction Protocol; Eligible Leader Replicas) https://kafka.apache.org/43/operations/consumer-rebalance-protocol/ · https://kafka.apache.org/43/operations/transaction-protocol/ · https://kafka.apache.org/43/operations/eligible-leader-replicas/
- A.4 비밀번호 암호화 설정 제거, KRaft는 민감 데이터를 레코드에 암호화 없이 저장 (KRaft vs ZooKeeper § Configurations) https://kafka.apache.org/43/getting-started/zk2kraft/#configurations
- A.5 과반수 방식이 공유 클러스터 설정에 더 흔함 (Design § Replicated Logs) https://kafka.apache.org/43/design/design/#replicated-logs-quorums-isrs-and-state-machines-oh-my
- B.1 인증(SSL, SASL 메커니즘), SSL 암호화와 성능 저하, 플러그인 인가, 보안의 선택성 (Security Overview) https://kafka.apache.org/43/security/security-overview/
- B.2 SSL 사용 시
sendfile미사용 (Design § Efficiency) https://kafka.apache.org/43/design/design/#efficiency
- B.3
StandardAuthorizer와 메타데이터 로그의 ACL, ACL이 없을 때의 동작과allow.everyone.if.no.acl.found, KRaft principal 전달과KafkaPrincipalSerde(Authorization and ACLs) https://kafka.apache.org/43/security/authorization-and-acls/
- B.4 운영 중인 클러스터에 보안을 단계적으로 도입하는 절차 (Incorporating Security Features in a Running Cluster) https://kafka.apache.org/43/security/incorporating-security-features-in-a-running-cluster/
- C.1 Connect의 기능(분산·단독 모드, REST, 자동 오프셋 관리, 그룹 관리 기반 확장) (Kafka Connect Overview) https://kafka.apache.org/43/kafka-connect/overview/
- C.2 오류 보고, 기본 실패 동작, 로그와 데드 레터 큐 (Kafka Connect User Guide § Error Reporting in Connect) https://kafka.apache.org/43/kafka-connect/user-guide/#error-reporting-in-connect
- C.3 싱크·소스 exactly-once 지원 조건,
exactly.once.source.support와 두 번의 롤링 업그레이드 (User Guide § Exactly-once support) https://kafka.apache.org/43/kafka-connect/user-guide/#exactly-once-support
- C.4 플러그인의 임의 코드 실행, 기본적으로 보호되지 않는 REST API,
connector.client.config.override.policy=Allowlist권장 (User Guide § Security) https://kafka.apache.org/43/kafka-connect/user-guide/#security
- C.5 Kafka Streams는 입력·출력이 Kafka에 저장되는 애플리케이션용 클라이언트 라이브러리 (Streams Introduction) https://kafka.apache.org/43/streams/introduction/
- C.6 파티션과 태스크, 상태 저장소의 변경 로그(컴팩션), 복구 시간과 대기 복제본 (Streams Architecture) https://kafka.apache.org/43/streams/architecture/
- C.7
processing.guarantee(기본at_least_once,exactly_once_v2)와 원자적 완료 (Streams Core Concepts § Processing Guarantees) https://kafka.apache.org/43/streams/core-concepts/#processing-guarantees
- D.1 4.3.0 업그레이드 경로와 절차, 메타데이터 다운그레이드 불가, 4.3.1 수정 사항,
group.coordinator.rebalance.protocols폐기 예정 (Upgrading § Upgrading to 4.3.1, § Upgrading to 4.3.0) https://kafka.apache.org/43/getting-started/upgrade/#upgrading-to-430
- D.2 지원 Java 버전 (Java Version) https://kafka.apache.org/43/operations/java-version/
- D.3 클라이언트·브로커·Streams 호환성 표 (Compatibility) https://kafka.apache.org/43/getting-started/compatibility/
- E.1 Java 17 이상, 클러스터 ID 생성과
format --standalone, 서버 시작, 토픽 생성·조회, 콘솔 프로듀서·컨슈머 (Quickstart) https://kafka.apache.org/43/getting-started/quickstart/