CCDAK 도메인 · 가중치 28%
Application Development
6개 도메인 중 가장 큰 도메인입니다. 여기서 실점하면 합격이 어렵습니다. 묻는 것은 한 가지로 요약됩니다 — "이 설정 조합으로 애플리케이션을 돌리면 무슨 일이 일어나는가". 설정명을 외우는 문제보다, 설정 두세 개가 얽혀 있을 때의 결과를 판단하는 문제가 많습니다.
이 도메인의 학습 목표
- 프로듀서 전송 경로에서 어느 스레드가 무엇을 하는지 구분하고, 콜백 호출 시점을 설명할 수 있습니다.
- 멱등성과
max.in.flight.requests.per.connection의 관계, 순서 보장의 근거를 설명할 수 있습니다. - 컨슈머 타임아웃 4종이 서로 다른 축임을 구분하고, 어느 것이 먼저 터지는지 판단할 수 있습니다.
assign()과subscribe()의 차이, 리밸런스가 발생하는 조건을 전부 나열할 수 있습니다.- 멱등 프로듀서 · 트랜잭션 ·
isolation.level의 경계를 구분할 수 있습니다.
이 도메인이 묻는 것
Application Development는 4장 Producer 심화, 5장 Consumer 심화, 6장 전달 보장과 트랜잭션의 범위와 거의 겹칩니다. 출제 형태는 크게 세 가지입니다.
- 설정값과 소속 — 어떤 설정의 기본값은 무엇이고, 그 설정은 프로듀서·컨슈머·브로커·토픽 중 어디에 두는가. matching 유형에서 특히 자주 나옵니다.
- 시나리오 판단 — "컨슈머가 계속 리밸런스한다", "중복이 생겼다", "순서가 뒤바뀌었다"에서 원인 설정과 1차 처방을 고르는 문제.
- 코드 판독 — 10~20줄의 Java 코드나 properties를 보여 주고 동작 결과·문제점·개선점을 고르는 문제. 아래 코드 읽기 대비 절에서 훈련합니다.
핵심 개념 (1) 프로듀서
send()는 비동기, 콜백은 Sender 스레드에서
producer.send()는 레코드를 직렬화하고 파티션을 결정해
RecordAccumulator라는 메모리 버퍼에 넣고 즉시 반환합니다.
실제 네트워크 전송은 별도의 Sender(I/O) 스레드가 배치 단위로 수행합니다.
따라서 콜백 onCompletion(metadata, exception)은
애플리케이션 스레드가 아니라 Sender 스레드에서,
브로커 응답을 받은 시점(또는 최종 실패가 확정된 시점)에 호출됩니다.
metadata와exception중 하나는 반드시null입니다. 성공이면 exception이 null입니다.- 같은 파티션으로 보낸 레코드의 콜백은 전송 순서대로 실행됩니다. 파티션이 다르면 순서 보장이 없습니다.
- 콜백 안에서 블로킹 작업(DB 호출,
get(), 긴 락)을 하면 Sender 스레드가 멈춰 전체 전송이 느려집니다. - 동기 전송이 필요하면
send(record).get()이지만, 배치 효과가 사라져 처리량이 급락합니다.
멱등성과 순서 보장
Kafka 4.3의 enable.idempotence 기본값은 true입니다(3.0부터).
멱등 프로듀서는 PID(Producer ID) + 파티션별 시퀀스 번호를 붙여 보내고,
브로커가 시퀀스 번호로 중복을 판별해 폐기합니다.
재시도 때문에 같은 배치가 두 번 도착해도 로그에는 한 번만 기록됩니다.
멱등성이 켜져 있으면 max.in.flight.requests.per.connection이
5 이하여야 합니다. 기본값이 정확히 5인 이유가 여기 있습니다.
브로커가 파티션별로 최근 5개 배치의 시퀀스 번호를 기억하므로,
그 범위 안에서는 재시도로 순서가 어긋나도 브로커가 재정렬·중복 제거를 해 줍니다.
6 이상으로 설정하면 프로듀서 생성 시 ConfigException이 발생합니다.
파티셔닝 — 키가 있을 때와 없을 때
키가 있으면 murmur2(key) % numPartitions로 결정됩니다. 결정적이므로
같은 키는 항상 같은 파티션으로 갑니다. 이것이 파티션 단위 순서 보장의 근거입니다.
키가 null이면 기본 파티셔너가 sticky batching으로 분배합니다.
한 파티션에 배치를 채우고, 배치가 마감되면 다음 파티션으로 전환하는 방식입니다.
레코드 하나하나를 돌려 담는 순수 라운드로빈이 아닙니다.
Kafka 2.4 이전의 DefaultPartitioner는 레코드 단위 라운드로빈이었습니다.
재시도 가능한 에러와 불가능한 에러
기준은 예외가 RetriableException을 상속하는지입니다.
재시도 가능한 예외는 프로듀서가 스스로 다시 보냅니다.
재시도 불가능한 예외는 즉시 콜백으로 올라옵니다.
| 구분 | 대표 예외 | 대응 |
|---|---|---|
| 재시도 가능 (retriable) |
LeaderNotAvailableException,
NotLeaderOrFollowerException,
NotEnoughReplicasException,
NetworkException,
TimeoutException,
CoordinatorNotAvailableException
|
프로듀서가 자동 재시도. 총 예산은 delivery.timeout.ms가 결정 |
| 재시도 불가 (non-retriable) |
RecordTooLargeException,
SerializationException,
AuthorizationException,
InvalidTopicException
|
재시도해도 같은 결과. 코드·설정·권한을 고쳐야 함 |
핵심 개념 (2) 컨슈머
타임아웃 4종 — 하트비트 축과 poll 축은 별개입니다
이 도메인에서 가장 많이 틀리는 지점입니다. 하트비트는 백그라운드 스레드가, poll은 애플리케이션 스레드가 담당합니다. 두 축은 서로 독립적으로 타임아웃됩니다.
| 설정 | 기본값 | 무엇을 재는가 | 초과하면 |
|---|---|---|---|
heartbeat.interval.ms |
3000 | 백그라운드 스레드가 하트비트를 보내는 주기 | (주기이므로 초과 개념 없음). session.timeout.ms의 1/3 이하 권장 |
session.timeout.ms |
45000 | 하트비트가 끊긴 뒤 코디네이터가 기다리는 시간 | 컨슈머 사망으로 판정 → 리밸런스 |
max.poll.interval.ms |
300000 | 두 poll() 호출 사이의 최대 허용 시간 |
그룹에서 제거 → 리밸런스. 프로세스는 살아 있음 |
max.poll.records |
500 | 한 번의 poll()이 반환하는 최대 레코드 수 |
(상한이므로 초과 없음). 처리 시간을 좌우하는 실질 손잡이 |
subscribe()와 assign()
| 관점 | subscribe(topics) |
assign(partitions) |
|---|---|---|
| 그룹 관리 | 참여 (코디네이터가 할당) | 참여하지 않음 |
| 리밸런스 | 발생 | 발생하지 않음 |
group.id | 필수 | 없어도 동작 (커밋하려면 필요) |
| 파티션 수 증가 | 자동으로 새 파티션을 할당받음 | 감지하지 않음 — 코드를 고쳐야 함 |
| 용도 | 일반적인 확장형 소비 | 특정 파티션만 읽기, 정밀 제어, 재처리 도구 |
리밸런스가 발생하는 조건 (전부)
- 컨슈머가 그룹에 합류하거나 정상 이탈(
close()) - 컨슈머 크래시 —
session.timeout.ms초과로 감지 max.poll.interval.ms초과 — 처리가 너무 오래 걸림- 구독 토픽의 파티션 수 증가
- 정규식 구독(
subscribe(Pattern))에 매칭되는 새 토픽 생성 - 그룹 코디네이터 장애(코디네이터를 맡던 브로커 이탈)
반대로 assign()을 쓰는 컨슈머에서는 위의 어떤 이유로도 리밸런스가 일어나지 않습니다.
group.instance.id를 지정한 static membership은
재시작 시 같은 멤버로 복귀해 재시작 때문에 생기는 리밸런스를 피합니다
(리밸런스 자체를 없애는 것이 아닙니다).
오프셋 커밋과 auto.offset.reset
enable.auto.commit의 기본값은 true이고
auto.commit.interval.ms는 5000입니다.
자동 커밋은 poll()이 호출될 때 "이전에 반환한 레코드들"을 커밋하는 방식이라,
처리 도중 크래시하면 이미 커밋된 레코드가 유실될 수 있습니다.
정확한 제어가 필요하면 enable.auto.commit=false + 수동 커밋입니다.
auto.offset.reset의 기본값은 latest이며,
커밋된 오프셋이 없을 때 또는 커밋된 오프셋이 로그 범위를 벗어났을 때만 적용됩니다.
정상적으로 커밋된 오프셋이 있으면 이 설정은 아무 일도 하지 않습니다.
핵심 개념 (3) 전달 보장과 트랜잭션
| 보장 | 프로듀서 | 컨슈머 | 결과 |
|---|---|---|---|
| at-most-once | acks=0 또는 재시도 없음 |
처리 전 커밋 | 유실 가능, 중복 없음 |
| at-least-once (기본 동작) |
acks=all + 재시도 |
처리 후 커밋 | 유실 없음, 중복 가능 |
| exactly-once (Kafka 내부) |
transactional.id + enable.idempotence=true |
isolation.level=read_committed + sendOffsetsToTransaction |
Kafka 토픽 경계 안에서만 정확히 한 번 |
트랜잭션 API 호출 순서
// 1. 프로듀서 생성 시 transactional.id 필수. 애플리케이션 시작 시 한 번만.
producer.initTransactions();
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(200));
if (records.isEmpty()) continue;
// 2. 트랜잭션 시작
producer.beginTransaction();
try {
for (ConsumerRecord<String, String> r : records) {
// 3. 결과 레코드 전송
producer.send(new ProducerRecord<>("out-topic", r.key(), transform(r.value())));
}
// 4. 읽은 오프셋을 "같은 트랜잭션 안에서" 커밋한다.
// consumer.commitSync() 를 쓰면 원자성이 깨진다.
producer.sendOffsetsToTransaction(offsetsOf(records), consumer.groupMetadata());
// 5. 커밋
producer.commitTransaction();
} catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException e) {
// 복구 불가 — 프로듀서를 닫고 종료해야 한다
producer.close();
throw e;
} catch (KafkaException e) {
// 6. 그 밖의 실패는 중단하고 다시 시도한다
producer.abortTransaction();
}
}
isolation.level은 컨슈머 설정입니다
기본값은 read_uncommitted입니다.
즉 프로듀서가 트랜잭션을 써도, 컨슈머가 아무 설정을 하지 않으면
중단(abort)된 트랜잭션의 메시지까지 읽습니다. EOS는 양쪽을 다 설정해야 성립합니다.
read_committed로 바꾸면 컨슈머는 LSO(Last Stable Offset)까지만 읽습니다.
LSO는 "아직 끝나지 않은 트랜잭션의 시작 지점"이므로,
진행 중인 트랜잭션이 하나 있으면 그 뒤에 커밋 완료된 메시지도 보이지 않습니다.
길게 열린 트랜잭션은 컨슈머 지연으로 직결됩니다.
트랜잭션 커밋/중단을 표시하는 컨트롤 레코드는 애플리케이션에 노출되지 않지만
오프셋 번호는 소비하므로, 오프셋에 빈 칸이 생길 수 있습니다.
반드시 외워야 할 설정값
Apache Kafka 4.3 기준입니다. 굵게 표시한 값은 3.x 자료와 다르므로 예전 자료로 공부했다면 여기서 반드시 갱신하세요.
| 설정 | 기본값 | 시험 포인트 |
|---|---|---|
acks |
all |
3.0부터 1→all. all일 때만 min.insync.replicas가 의미를 가짐 |
enable.idempotence |
true |
3.0부터 기본 활성. 켜지면 in-flight ≤ 5, acks=all, retries>0이 강제됨 |
linger.ms |
5 | 4.0에서 0→5로 변경. 시중 자료 대부분이 0으로 적혀 있음 |
batch.size |
16384 | 바이트 단위. linger.ms와 둘 중 먼저 충족되는 쪽에 전송 |
buffer.memory |
33554432 | 32MB. 가득 차면 send()가 max.block.ms까지 블로킹 |
max.block.ms |
60000 | 버퍼 대기 + 메타데이터 대기의 상한. 초과 시 TimeoutException |
max.in.flight.requests.per.connection |
5 | 멱등성 켜면 5 초과 금지. 6 이상이면 ConfigException |
request.timeout.ms |
30000 | 한 번의 요청에 대한 응답 대기 시간 |
delivery.timeout.ms |
120000 | send()부터 성공/실패 확정까지의 전체 예산 |
retries |
2147483647 | 실질 상한은 횟수가 아니라 delivery.timeout.ms |
max.request.size |
1048576 | 정확히 1MB. 브로커의 message.max.bytes(1048588)와 값이 다름 |
compression.type |
none |
프로듀서 기본은 압축 없음. 토픽 기본값은 producer(프로듀서가 준 대로 저장) |
transaction.timeout.ms |
60000 | 트랜잭션이 이 시간 안에 끝나지 않으면 코디네이터가 중단시킴 |
| 설정 | 기본값 | 시험 포인트 |
|---|---|---|
group.protocol |
classic |
KIP-848은 4.0 GA지만 컨슈머 기본값이 아님. consumer로 명시해야 새 프로토콜 |
session.timeout.ms |
45000 | 하트비트 축. 예전 10000에서 변경됨 |
heartbeat.interval.ms |
3000 | session.timeout.ms의 1/3 이하 권장 |
max.poll.interval.ms |
300000 | poll 축. 하트비트와 다른 축 |
max.poll.records |
500 | poll 간격 초과의 1차 처방 손잡이 |
enable.auto.commit |
true |
트랜잭션·정밀 제어 시 false로 |
auto.commit.interval.ms |
5000 | 자동 커밋 주기 |
auto.offset.reset |
latest |
커밋 오프셋이 없거나 범위를 벗어났을 때만 발동 |
isolation.level |
read_uncommitted |
컨슈머 설정. EOS를 원하면 read_committed로 바꿔야 함 |
partition.assignment.strategy |
[RangeAssignor, |
classic 프로토콜에서만 유효. 목록의 첫 항목이 우선 협상됨 |
fetch.min.bytes |
1 | fetch.max.wait.ms(500)와 함께 지연/처리량을 조절 |
fetch.max.bytes |
52428800 | 50MB. 요청 전체 상한 |
max.partition.fetch.bytes |
1048576 | 1MB. 파티션당 상한 |
자주 나오는 함정
이 도메인에서 특히 자주 뒤바뀌어 출제되는 쌍입니다. 40쌍 전체는 함정 사전에 있습니다.
함정 1 — session.timeout.ms vs max.poll.interval.ms
| 관점 | session.timeout.ms | max.poll.interval.ms |
|---|---|---|
| 감시 대상 | 하트비트 (백그라운드 스레드) | poll() 호출 간격 (앱 스레드) |
| 기본값 | 45000 | 300000 |
| 터지는 상황 | 프로세스 사망 · GC 장기 정지 · 네트워크 단절 | 레코드 처리가 오래 걸림 |
| 1차 처방 | 인프라 점검, 값 상향은 신중히 | max.poll.records 축소 |
| 출제 형태 | "프로세스가 죽었을 때 감지하는 설정은?" | "프로세스는 살아 있는데 그룹에서 빠지는 이유는?" |
함정 2 — acks vs min.insync.replicas
| 관점 | acks | min.insync.replicas |
|---|---|---|
| 어디 설정 | 프로듀서 | 브로커 · 토픽 |
| 기본값 | all | 1 |
| 의미 | 몇 개의 응답을 기다릴지 | 쓰기를 성립시키는 최소 ISR 수 |
| 상호작용 | acks=all일 때만 min.insync.replicas가 검사됩니다. acks=1이면 무시됩니다 | |
| 미달 시 | — | NotEnoughReplicasException (재시도 가능) |
| 출제 형태 | "RF=3, min.insync.replicas=2일 때 브로커 몇 대까지 죽어도 쓰기가 되는가" → 1대 | |
함정 3 — 멱등 프로듀서 vs 트랜잭션
| 관점 | 멱등 프로듀서 | 트랜잭션 |
|---|---|---|
| 켜는 방법 | enable.idempotence=true (기본값) | transactional.id 지정 + API 호출 |
| 보장 범위 | 한 세션 × 한 파티션의 재시도 중복 제거 | 여러 파티션·토픽에 걸친 원자적 쓰기 + 오프셋 커밋 |
| 컨슈머 쪽 설정 | 불필요 | isolation.level=read_committed 필요 |
| 프로듀서 재시작 | PID가 새로 발급되어 보장이 끊김 | 같은 transactional.id로 이전 세션을 fence |
| 출제 형태 | "EOS를 위해 필요한 것을 모두 고르시오" — 멱등성만 고르면 오답 | |
함정 4 — request.timeout.ms vs delivery.timeout.ms
| 관점 | request.timeout.ms | delivery.timeout.ms |
|---|---|---|
| 기본값 | 30000 | 120000 |
| 범위 | 브로커에 보낸 한 번의 요청 | send() 반환부터 콜백까지 전체 |
| 포함 관계 | linger.ms + request.timeout.ms ≤ delivery.timeout.ms 가 성립해야 합니다 | |
| 초과 시 | 재시도 대상 | 최종 실패 — TimeoutException으로 콜백 호출 |
| 출제 형태 | "전송이 최대 얼마나 걸릴 수 있는가" → delivery.timeout.ms | |
함정 5 — read_uncommitted vs read_committed
| 관점 | read_uncommitted | read_committed |
|---|---|---|
| 기본값 | 이것이 기본값 | 명시해야 함 |
| 읽는 범위 | high watermark까지 (중단된 트랜잭션 포함) | LSO까지 (커밋된 것만) |
| 지연 | 낮음 | 열린 트랜잭션이 있으면 그 뒤 전체가 대기 |
| 혼동 시 결과 | 프로듀서만 트랜잭션을 쓰고 컨슈머를 그대로 두면 중단된 메시지까지 처리됩니다 | |
| 출제 형태 | "트랜잭션을 썼는데 abort된 데이터가 하위 시스템에 들어갔다. 원인은?" | |
함정 6 — 할당 전략 4종
| 전략 | 할당 방식 | 기존 할당 유지 | 리밸런스 방식 |
|---|---|---|---|
RangeAssignor | 토픽별로 파티션을 연속 구간으로 나눔 | 아니오 | eager |
RoundRobinAssignor | 모든 토픽-파티션을 순서대로 돌려 담음 | 아니오 | eager |
StickyAssignor | 균등 + 기존 할당 최대 유지 | 예 | eager |
CooperativeStickyAssignor | 균등 + 기존 할당 유지 + 증분 재할당 | 예 | cooperative |
eager는 리밸런스 시작 시 모든 컨슈머가 파티션 전체를 반납합니다(stop-the-world).
cooperative는 옮겨야 할 파티션만 두 라운드에 걸쳐 회수·재할당하므로
나머지 파티션은 계속 소비됩니다.
기본값 목록에 RangeAssignor가 먼저 오므로,
cooperative를 쓰려면 명시적으로 CooperativeStickyAssignor만 지정해야 합니다.
eager에서 cooperative로 옮길 때는 두 단계 롤링 배포가 필요합니다
(먼저 두 전략을 함께 나열해 배포하고, 다음 배포에서 eager를 제거).
코드 읽기 문제 대비
시험에 나오는 형태의 스니펫과 판독 포인트입니다. 정답을 보기 전에 스스로 문제점을 찾아보세요.
스니펫 1 — 무엇이 잘못되었는가
acks=all
enable.idempotence=true
max.in.flight.requests.per.connection=10
compression.type=snappy
linger.ms=20
스니펫 2 — 이 컨슈머의 문제는
Properties p = new Properties();
p.put("bootstrap.servers", "kafka-1:9092,kafka-2:9092");
p.put("group.id", "billing-worker");
p.put("key.deserializer", StringDeserializer.class.getName());
p.put("value.deserializer", StringDeserializer.class.getName());
// max.poll.records 와 max.poll.interval.ms 는 기본값을 사용
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(p);
consumer.subscribe(List.of("invoices"));
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
for (ConsumerRecord<String, String> r : records) {
callExternalPaymentApi(r.value()); // 레코드당 평균 1.2초
}
}
스니펫 3 — 이 커밋 코드의 결과
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(200));
consumer.commitSync(); // (A) 처리 전에 커밋
for (ConsumerRecord<String, String> r : records) {
process(r); // (B) 여기서 예외가 나면?
}
}
스니펫 4 — 순서가 보장되는가
// orders 토픽은 파티션 6개
producer.send(new ProducerRecord<>("orders", null, "order-42 CREATED"));
producer.send(new ProducerRecord<>("orders", null, "order-42 PAID"));
producer.send(new ProducerRecord<>("orders", null, "order-42 SHIPPED"));
이어서 볼 곳
도메인 미니 퀴즈
단일 선택 · 복수 선택 · 연결형 · 순서 배열형이 섞여 있습니다. 실제 시험에도 matching과 list order가 출제되므로 함께 연습하세요.
공식 문서 출처
- Producer Configs —
acks,linger.ms,enable.idempotence,max.in.flight.requests.per.connection,delivery.timeout.ms,max.request.size - Consumer Configs —
session.timeout.ms,heartbeat.interval.ms,max.poll.interval.ms,max.poll.records,auto.offset.reset,isolation.level,group.protocol,partition.assignment.strategy - Broker Configs —
message.max.bytes,min.insync.replicas,offsets.retention.minutes - Message Delivery Semantics — at-most-once / at-least-once / exactly-once 정의
- Consumer Rebalance Protocol — KIP-848,
group.protocol=consumer에서 무시되는 설정 - Transactions — 트랜잭션 API와 LSO