데이터 엔지니어 인터뷰 — Kafka

기본 구조

토픽, 파티션, 오프셋의 관계를 설명해보세요

토픽은 논리적인 메시지 분류이고, 파티션은 그 토픽을 물리적으로 나눈 append-only 로그입니다. 오프셋은 파티션 안에서 메시지의 위치를 가리키는 단조 증가 정수입니다.

중요한 점은 오프셋이 파티션 단위라는 것입니다. 토픽 전체에 걸친 전역 순번은 없습니다. 그래서 순서 보장도 파티션 안에서만 성립합니다.

파티션 수는 어떤 기준으로 정하나요

가장 큰 제약은 컨슈머 병렬도입니다. 한 컨슈머 그룹에서 파티션 하나는 컨슈머 하나에만 배정되므로, 파티션 수가 그룹 내 최대 병렬도가 됩니다. 컨슈머를 파티션 수보다 많이 띄우면 남는 컨슈머는 놉니다.

늘리기는 쉽지만 줄일 수 없다는 점이 중요합니다. 그리고 파티션을 늘리면 키 해싱 결과가 바뀌어 같은 키가 다른 파티션으로 갈 수 있습니다 — 순서 보장이 필요한 토픽이라면 이 시점에 이력이 꼬입니다.

브로커 입장에서는 파티션마다 파일 핸들과 메모리를 쓰고, 리더 선출·복제 대상이 늘어납니다. 무작정 크게 잡을 이유가 없습니다.

메시지 순서는 어디까지 보장되나요

같은 파티션 안에서만 보장됩니다. 순서가 중요한 단위(주문 ID, 디바이스 ID 등)를 키로 지정해 같은 파티션에 떨어지게 만드는 것이 일반적인 방법입니다.

key = order_id  →  hash(key) % partition_count  →  항상 같은 파티션

다만 프로듀서 설정에 따라 순서가 깨질 수 있습니다. max.in.flight.requests.per.connection이 1보다 크고 재시도가 켜져 있으면, 앞 배치가 실패하고 재전송되는 사이 뒤 배치가 먼저 커밋될 수 있습니다. enable.idempotence=true를 켜면 브로커가 시퀀스 번호로 순서를 잡아주므로 in-flight를 5까지 두고도 순서가 유지됩니다.

전달 보장

at-most-once, at-least-once, exactly-once를 구분해보세요

시맨틱 의미 언제 생기나
at-most-once 유실 가능, 중복 없음 처리 전에 오프셋을 먼저 커밋
at-least-once 유실 없음, 중복 가능 처리 후에 오프셋 커밋 (기본)
exactly-once 유실도 중복도 없음 트랜잭션 또는 멱등 처리

실무에서 대부분은 at-least-once + 멱등한 소비자로 갑니다. exactly-once는 Kafka 안에서 끝나는 처리(read-process-write)에서는 트랜잭션으로 가능하지만, 외부 시스템에 쓰는 순간 그 시스템이 트랜잭션에 참여하지 않으면 성립하지 않습니다.

컨슈머에서 중복을 어떻게 막나요

싱크 쪽을 멱등하게 만드는 것이 정석입니다.

  • 메시지에 고유 키가 있으면 그 키로 UPSERT
  • 없으면 topic-partition-offset 조합을 키로 써서 중복 판정
  • 집계라면 원본을 그대로 적재하고 조회 시점에 중복 제거

오프셋 커밋 시점도 함께 봐야 합니다. enable.auto.commit=true는 처리 완료와 무관하게 주기적으로 커밋하므로, 처리 중 죽으면 처리하지 않은 메시지의 오프셋이 이미 커밋된 상태가 될 수 있습니다.

acks 설정은 어떤 의미인가요

프로듀서가 “썼다”고 판단하는 기준입니다.

의미 유실 위험
0 보내고 확인 안 함
1 리더만 기록하면 성공 리더 장애 시 유실
all (-1) ISR 전체가 기록해야 성공 가장 안전

acks=all만으로는 부족하고 min.insync.replicas를 함께 봐야 합니다. 복제본이 3인데 min.insync.replicas=1이면 ISR이 1로 줄어든 상태에서도 쓰기가 성공하므로, 그 브로커가 죽으면 유실됩니다. 보통 복제 3 / min.insync.replicas=2 조합을 씁니다.

컨슈머 그룹

리밸런싱은 언제 일어나고 무엇이 문제인가요

컨슈머가 들어오거나 나갈 때, 파티션 수가 바뀔 때, 그리고 컨슈머가 살아 있다는 신호를 못 보낼 때 일어납니다.

기본 방식(eager)에서는 리밸런싱 동안 그룹 전체가 처리를 멈춥니다(stop-the-world). 컨슈머 하나가 잠깐 느려져서 리밸런싱이 촉발되면 전체가 영향을 받습니다.

이걸 줄이는 방법이 몇 가지 있습니다.

  • partition.assignment.strategyCooperativeStickyAssignor로 — 영향받는 파티션만 옮기고 나머지는 계속 처리
  • group.instance.id를 지정해 static membership 사용 — 재시작해도 같은 파티션을 유지, 배포 때 리밸런싱 회피
  • max.poll.interval.ms를 처리 시간에 맞게 조정

poll 관련 설정에서 자주 나는 사고는 무엇인가요

max.poll.records로 한 번에 많이 가져와 놓고 처리가 오래 걸려 max.poll.interval.ms를 넘기는 경우입니다. 브로커는 그 컨슈머가 죽었다고 판단해 리밸런싱을 일으키고, 컨슈머는 처리를 끝낸 뒤 커밋하려다 실패합니다. 그러면 다시 같은 메시지를 받아 무한 반복에 빠집니다.

해결은 둘 중 하나입니다. max.poll.records를 줄이거나, max.poll.interval.ms를 실제 처리 시간보다 넉넉히 잡는 것입니다.

session.timeout.ms와 헷갈리기 쉬운데, 이쪽은 백그라운드 하트비트 기준이고 max.poll.interval.ms처리 루프가 돌아오는 주기 기준입니다.

컨슈머 랙(lag)이 계속 늘어납니다. 어떻게 접근하나요

먼저 어디가 병목인지 나눕니다.

  1. 파티션별 랙 분포를 봅니다. 특정 파티션만 밀리면 키 쏠림(skew)입니다. 전체가 고르게 밀리면 처리량 부족입니다.
  2. 처리량 부족이면 컨슈머를 늘립니다. 다만 파티션 수가 상한이므로 그 이상은 효과가 없습니다.
  3. 컨슈머 내부가 느리면(외부 API, DB 왕복) 배치로 묶거나 비동기화합니다.
  4. 키 쏠림이면 키 설계를 바꾸거나 파티션 수를 조정합니다.

Kafka Connect

Kafka Connect를 쓰는 이유는 무엇인가요

소스/싱크 연동을 설정으로 처리하기 위해서입니다. 컨슈머를 직접 짜면 오프셋 관리, 재시도, 스케일링, 모니터링을 전부 구현해야 하는데 Connect는 그걸 프레임워크가 담당합니다.

대신 자유도가 낮습니다. 복잡한 변환이 필요하면 커스텀 SMT를 만들거나, 아예 스트리밍 처리(Spark, Flink)로 가는 편이 낫습니다.

SMT(Single Message Transform)는 무엇인가요

Connect 파이프라인 중간에서 메시지 하나 단위로 적용하는 변환입니다. 필드 이름 변경, 타입 캐스팅, 특정 필드 마스킹, 토픽 라우팅 같은 가벼운 작업에 씁니다.

메시지 단위라는 점이 한계입니다. 조인, 집계, 여러 메시지에 걸친 상태 처리는 SMT로 할 수 없습니다.

standalone과 distributed 모드의 차이는 무엇인가요

standalone은 단일 프로세스에서 돌고 오프셋을 로컬 파일에 저장합니다. 개발이나 검증용입니다.

distributed는 여러 워커가 클러스터를 이루고 커넥터의 설정·오프셋·상태를 Kafka 내부 토픽에 저장합니다. 워커가 죽으면 태스크가 다른 워커로 재배치됩니다. 운영에서는 워커가 하나여도 distributed로 띄우는 편이 낫습니다.

운영

로그 컴팩션과 삭제 정책의 차이는 무엇인가요

cleanup.policy=delete는 시간(retention.ms)이나 크기(retention.bytes) 기준으로 오래된 세그먼트를 통째로 지웁니다. 이벤트 스트림에 적합합니다.

cleanup.policy=compact키별 최신 값만 남깁니다. 테이블의 현재 상태를 표현하는 토픽(CDC 스냅샷, 설정 정보)에 적합합니다. 삭제를 표현하려면 값이 null인 툼스톤(tombstone) 메시지를 보냅니다.

둘을 동시에 지정(compact,delete)할 수도 있습니다.

리텐션이 지나 유실된 메시지를 다시 받고 싶다면

받을 수 없습니다. 이게 Kafka를 원본 저장소로 쓰면 안 되는 이유입니다. 재처리가 필요한 파이프라인이라면 원본을 오브젝트 스토리지에 적재해 두고, 재처리는 거기서 하는 구조가 안전합니다.

auto.offset.resetlatest인 컨슈머가 오랫동안 죽어 있다가 살아나면, 커밋된 오프셋이 리텐션 밖으로 밀려 조용히 최신부터 읽기 시작합니다. 유실을 인지하지 못한 채 넘어가는 대표적인 사고입니다.

파티션 리더와 ISR을 설명해보세요

파티션마다 리더 하나와 팔로워 여럿이 있고, 읽기·쓰기는 리더가 처리합니다. ISR(In-Sync Replicas)은 리더를 충분히 따라잡고 있는 복제본 집합입니다.

리더가 죽으면 ISR 안에서 새 리더를 뽑습니다. ISR이 비면 선택지가 둘입니다 — 기다리거나(가용성 손해), ISR 밖 복제본을 리더로 올리거나(unclean.leader.election.enable=true, 데이터 유실). 기본값은 안전한 쪽입니다.

스키마 관리는 어떻게 하나요

JSON을 스키마 없이 흘리면 프로듀서가 필드를 바꿀 때 컨슈머가 조용히 깨집니다. Schema Registry에 Avro/Protobuf 스키마를 등록하고 호환성 규칙(BACKWARD, FORWARD, FULL)을 걸어 두면 호환되지 않는 변경을 등록 시점에 거부합니다.

호환성 방향을 이해하는 것이 중요합니다. BACKWARD는 새 스키마로 옛 데이터를 읽을 수 있다는 뜻이라 컨슈머를 먼저 배포해야 하고, FORWARD는 반대입니다.

관련 글