Kafka Streams 실전 — Connect·스키마 정리

2026-05-02AWS SAA-C03 스터디

Apache Kafka 입문 정리 시리즈 마지막 7편. Kafka Connect의 소스/싱크 커넥터, Kafka Streams의 KStream vs KTable과 Exactly-Once, Schema Registry의 Avro·Protobuf·진화 호환성, 4가지 케이스 스터디(스트리밍 서비스 예시·택시 호출 서비스 예시·소셜 미디어 예시·은행 서비스 예시), 파티션 수·복제 팩터 선택 가이드, 토픽 네이밍, Log Compaction, 빅데이터 아키텍처에서 카프카의 위치까지 시리즈 마무리.

📚 Apache Kafka 입문 정리 · 7편 / 14편 — Connect·스키마 정리

여기까지 와줘서 정말 고마워요. 이 글은 Apache Kafka 입문 정리 시리즈의 마지막 7편이자 완결편입니다. 1편에서 "카프카는 회사 안 중앙 우체국이다"라는 큰 그림을 잡고 시작해서, 2편 아키텍처, 3편 Producer, 4편 Consumer, 5편 운영, 6편 보안·튜닝까지 차근차근 쌓아 왔어요. 이번 글은 그 위에 카프카 본체 바깥의 도구들 — Kafka Connect, Kafka Streams, Schema Registry — 와 실전 아키텍처 패턴을 얹는 자리입니다. 그중에서도 실시간 가공 라인 역할을 하는 Kafka Streams가 7편의 큰 축이에요.

왜 고급 단원이 처음엔 어렵게 느껴질까요

이유가 분명해요. 1~6편까지는 카프카 안쪽 이야기였어요. 브로커가 어떻게 동작하고, 파티션이 어떻게 나뉘고, 컨슈머가 어떻게 메시지를 가져가고. 그런데 7편은 갑자기 바깥으로 시야가 넓어집니다. "카프카에 데이터를 어떻게 넣고 빼느냐", "카프카 데이터를 어떻게 실시간 가공하느냐", "카프카에 흐르는 데이터 양식을 어떻게 표준화하느냐" — 이 세 가지가 한꺼번에 등장해요.

게다가 도구마다 자기만의 용어가 또 줄지어 나옵니다. Source Connector, Sink Connector, Worker, Task, KStream, KTable, GlobalKTable, Topology, State Store, Subject, Compatibility, Avro, Protobuf, JSON Schema. 1편 첫머리에서 느꼈던 "외국 SF 같다"는 그 막막함이 다시 한 번 옵니다.

해결법은 1편 때와 같아요. 각 도구가 어디에 자기 자리가 있는지 큰 그림부터 잡고 가면 디테일이 자연스럽게 얹힙니다. 회사 비유로 풀면 — 카프카(중앙 우체국)는 그대로 있고, 그 우체국에 외부 회사와 연결되는 표준 케이블(Connect), 편지를 받자마자 가공하는 라인(Streams), 편지 양식을 표준화해 주는 부서(Schema Registry) 가 차례로 붙는 거예요. 이 세 친구의 자리만 또렷이 잡아 두면 나머지는 디테일입니다.

여기서 시험 함정이 하나 있어요. "Kafka Connect, Streams, Schema Registry는 카프카의 일부다" 라고 말하면 절반만 맞아요. 카프카 본체(브로커 클러스터)와 별개의 프로젝트예요. 같은 Apache Kafka 우산 안에 있긴 하지만, 운영 측면에서는 각자 별도로 띄우고 관리해야 합니다. 카프카 없이 못 살지만, 카프카 본체에 박혀 있는 건 아니라는 점이 헷갈리기 쉬워요.

Kafka Connect — 외부 시스템 표준 케이블

Kafka Connect는 카프카와 외부 시스템을 연결해 주는 통합 프레임워크입니다. 회사 비유로 — 우체국과 외부 회사·창고·은행을 연결하는 표준 규격 케이블이에요. 매번 케이블을 새로 만들어 끼우는 게 아니라, 미리 만들어진 표준 케이블을 꽂기만 하면 데이터가 양방향으로 흐릅니다. 전체 사양은 Kafka Connect 공식 문서에서 확인할 수 있어요.

왜 Connect가 필요할까

여기서 시험 함정이 하나 있어요. "그냥 Producer/Consumer 코드를 직접 짜면 되지 않나요?" 처음엔 그렇게 생각하기 쉽습니다. MySQL에서 데이터를 읽어 카프카에 넣는 코드는 한 100줄이면 짤 수 있어 보이거든요.

문제는 그게 시작일 뿐이라는 거예요. 실제로 운영에 들어가면 다음 같은 요구사항이 줄줄이 따라옵니다.

  • 재시도 로직 — 일시적 네트워크 오류 발생 시 어떻게 재시도할지
  • 오프셋 관리 — 어디까지 읽었는지 영속 저장
  • 장애 복구 — 프로세스가 죽으면 다시 띄워서 정확한 위치부터 재개
  • 병렬 처리 — 여러 워커에 분산해서 처리량 확보
  • 모니터링·메트릭 — Lag, 에러율 같은 지표 노출
  • Schema 변환 — JSON, Avro, Protobuf 사이 변환

이걸 매 외부 시스템마다 새로 짜는 건 미친 짓이에요. Kafka Connect는 이 모든 걸 프레임워크 수준에서 해결하고, 각 외부 시스템과의 연결 부분만 커넥터(Connector) 라는 작은 플러그인으로 갈아 끼우게 해 둔 도구입니다.

Source Connector vs Sink Connector

방향이 두 가지예요. 회사 비유로 풀면 — 외부 회사가 우체국으로 자료를 보내는 케이블이 Source, 우체국이 외부 회사로 자료를 내보내는 케이블이 Sink입니다.

Source Connector (소스):
외부 시스템 → [Kafka Connect] → Kafka Topic
예) MySQL의 변경 이벤트 → Kafka

Sink Connector (싱크):
Kafka Topic → [Kafka Connect] → 외부 시스템
예) Kafka 토픽 데이터 → Elasticsearch

Confluent Hub에 212개 이상의 사전 제작 커넥터가 있어요. 자주 쓰이는 것만 추려 보면.

종류대표 커넥터자주 쓰는 자리
SourceDebeziumMySQL/PostgreSQL/MongoDB의 변경 사항을 실시간으로 카프카에 넣음 (CDC)
SourceJDBC Source모든 JDBC 호환 DB 테이블을 주기적으로 폴링해 카프카에 넣음
SourceS3 SourceS3 버킷 파일을 카프카로 적재
SinkElasticsearch Sink카프카 데이터를 검색 엔진에 색인
SinkS3 Sink카프카 데이터를 S3에 영속 저장 (데이터 레이크)
SinkJDBC Sink카프카 데이터를 RDBMS 테이블에 적재
SinkBigQuery / Snowflake카프카 데이터를 데이터 웨어하우스에 적재

Connect 아키텍처 — Connector, Task, Worker

용어가 세 개라 헷갈리기 쉬워요. 한 줄씩 정리합니다.

  • Connector — 데이터 통합의 설정 단위. "MySQL의 mydb 데이터베이스를 카프카 dbserver1.mydb.* 토픽에 넣어 줘" 같은 한 줄짜리 명세예요.
  • Task — 그 Connector가 실제 일을 할 때 만들어 내는 실행 단위. 병렬화의 기본 단위입니다. tasks.max=4면 최대 4개 Task가 병렬로 돕니다.
  • Worker — Task를 실행하는 프로세스(JVM). Connect 클러스터의 노드입니다.
[Kafka Connect Cluster]
 ┌────────────────────────────────────┐
 │  Worker 1            Worker 2      │
 │  ┌──────────────┐  ┌──────────────┐│
 │  │ Source Task 1│  │ Source Task 2││
 │  │ Sink Task 1  │  │ Sink Task 2  ││
 │  └──────────────┘  └──────────────┘│
 └────────────────────────────────────┘
        ↕                ↕
   [Kafka Cluster]

여기서 시험 함정이 하나 있어요. Connector 1개 = Task 1개가 아닙니다. 한 Connector가 여러 Task로 쪼개져 병렬 실행돼요. 예를 들어 MySQL Source 커넥터 1개에 tasks.max=8을 주면, 테이블 8개를 8개 Task에 나눠 각자 다른 Worker에서 동시에 처리합니다. 이 분산 메커니즘 덕분에 카프카로 넣는 처리량을 수평 확장할 수 있어요.

Standalone vs Distributed 모드

Connect를 띄우는 방식이 둘이에요.

Standalone 모드 — 한 프로세스 안에서 Worker 1개로 모든 Task를 돌립니다. 개발·테스트용. 오프셋을 로컬 파일에 저장해요.

# connect-standalone.properties (워커 설정)
bootstrap.servers=localhost:9092
key.converter=org.apache.kafka.connect.json.JsonConverter
value.converter=org.apache.kafka.connect.json.JsonConverter
offset.storage.file.filename=/tmp/connect.offsets

# source-connector.properties (커넥터 설정)
name=file-source-connector
connector.class=FileStreamSource
tasks.max=1
file=/tmp/test.txt
topic=file-topic

# 실행
connect-standalone.sh config/connect-standalone.properties \
  config/connect-file-source.properties

Distributed 모드 — 여러 Worker가 묶여 클러스터로 동작합니다. 운영용. 오프셋·설정·상태를 카프카 내부 토픽에 저장해요(connect-offsets, connect-configs, connect-status). 워커가 죽어도 다른 워커가 그 Task를 이어서 실행해 줍니다.

# connect-distributed.properties
bootstrap.servers=localhost:9092
group.id=connect-cluster
key.converter=org.apache.kafka.connect.json.JsonConverter
value.converter=org.apache.kafka.connect.json.JsonConverter
config.storage.topic=connect-configs
offset.storage.topic=connect-offsets
status.storage.topic=connect-status
config.storage.replication.factor=3
offset.storage.replication.factor=3
status.storage.replication.factor=3

# 실행
connect-distributed.sh config/connect-distributed.properties

여기서 시험 함정이 하나 있어요. Distributed 모드에서는 커넥터 설정을 파일이 아니라 REST API로 등록합니다. Standalone은 파일로 시작할 때 같이 읽지만, Distributed는 워커가 떠 있는 상태에서 REST로 동적으로 추가·삭제·일시중지·재시작하는 게 정상 흐름이에요.

REST API로 커넥터 관리

운영에서 가장 자주 쓰는 명령들입니다(기본 포트 8083).

# 커넥터 목록
curl http://localhost:8083/connectors

# 커넥터 생성 (Debezium MySQL Source 예시)
curl -X POST http://localhost:8083/connectors \
  -H "Content-Type: application/json" \
  -d '{
    "name": "debezium-mysql-source",
    "config": {
      "connector.class": "io.debezium.connector.mysql.MySqlConnector",
      "tasks.max": "1",
      "database.hostname": "mysql",
      "database.port": "3306",
      "database.user": "debezium",
      "database.password": "dbz",
      "database.server.id": "184054",
      "database.server.name": "dbserver1",
      "database.include.list": "mydb",
      "database.history.kafka.bootstrap.servers": "localhost:9092",
      "database.history.kafka.topic": "schema-changes.mydb"
    }
  }'

# 상태 조회·일시중지·재시작·삭제
curl http://localhost:8083/connectors/debezium-mysql-source/status
curl -X PUT http://localhost:8083/connectors/debezium-mysql-source/pause
curl -X POST http://localhost:8083/connectors/debezium-mysql-source/restart
curl -X DELETE http://localhost:8083/connectors/debezium-mysql-source

한 줄 정리 — Connect는 외부 시스템 ↔ 카프카 표준 케이블. 코드 없이 설정만으로 데이터 통합. Source/Sink 두 방향, Connector(설정) → Task(실행) → Worker(프로세스) 계층, 운영은 Distributed + REST API.

Kafka Streams — 실시간 가공 라인

Kafka Connect가 "데이터를 카프카에 넣고 빼는 케이블"이라면, Kafka Streams는 그 카프카 안에 흐르는 데이터를 실시간으로 가공·변환하는 자리예요. 회사 비유로 — 우체국 안에 들어 있는 조립 라인이라고 보면 됩니다. 들어온 편지를 분류·집계·다른 편지와 합치고, 다시 다른 게시판으로 보내는 작업이 끊임없이 이어집니다. 더 깊은 API는 Kafka Streams 공식 문서에 한 곳에 모여 있어요.

Streams는 라이브러리다 — 클러스터가 아니라

여기서 시험 함정이 하나 있어요. Kafka Streams는 별도 클러스터가 아닙니다. 그냥 Java 라이브러리예요. Spark나 Flink처럼 별도 클러스터를 띄울 필요가 없습니다. 여러분이 만든 평범한 Java 애플리케이션 안에 Kafka Streams 라이브러리를 추가하면, 그 앱이 곧 스트림 처리 노드가 됩니다.

이 차이가 운영 측면에서 정말 큽니다. 스트림 처리를 위해 별도 인프라(JobManager, TaskManager 같은)를 띄우지 않고, Docker로 평범한 Java 앱 띄우듯 Kafka Streams 앱을 띄우면 끝이에요. 스케일링도 그 앱 인스턴스 수를 늘리면 자동으로 카프카 Consumer Group 메커니즘을 통해 파티션이 재분배됩니다.

Spark/Flink: 별도 클러스터 운영 → JobManager, TaskManager 따로 띄워야 함
Kafka Streams: 그냥 Java 앱 → 인스턴스 수 늘리면 자동 스케일

특징을 한 표에 정리하면.

항목Kafka StreamsApache SparkApache Flink
외부 클러스터불필요필요필요
배포 방식Java 앱별도 클러스터별도 클러스터
지연밀리초수 초(마이크로배치)밀리초
학습 곡선낮음높음높음
대규모 처리중간매우 높음높음

가장 단순한 예시 — WordCount

이론이 길어지면 막막해지니 코드를 한 번 보고 가요. "들어온 문장을 단어로 쪼개서 단어별 횟수를 세는" 가장 단순한 스트리밍 앱입니다.

import org.apache.kafka.streams.*;
import org.apache.kafka.streams.kstream.*;
import java.util.Properties;
import java.util.Arrays;

public class WordCountApp {
    public static void main(String[] args) {
        Properties props = new Properties();
        props.put(StreamsConfig.APPLICATION_ID_CONFIG, "wordcount-app");
        props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG,
            Serdes.String().getClass());
        props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG,
            Serdes.String().getClass());

        StreamsBuilder builder = new StreamsBuilder();

        // 입력 스트림
        KStream<String, String> textLines =
            builder.stream("word-count-input");

        // 처리 파이프라인 = Topology
        KTable<String, Long> wordCounts = textLines
            .mapValues(value -> value.toLowerCase())
            .flatMapValues(value -> Arrays.asList(value.split(" ")))
            .selectKey((key, value) -> value)
            .groupByKey()
            .count(Materialized.as("counts-store"));

        // 결과를 출력 토픽으로
        wordCounts.toStream()
            .to("word-count-output",
                Produced.with(Serdes.String(), Serdes.Long()));

        KafkaStreams streams = new KafkaStreams(builder.build(), props);
        streams.start();

        Runtime.getRuntime().addShutdownHook(
            new Thread(streams::close));
    }
}

이 코드 한 가지로 1편부터 7편까지의 흐름이 한 번에 드러나요. 입력 토픽 word-count-input에서 메시지를 읽고 → 소문자 변환 → 단어 분할 → 단어를 키로 다시 잡고 → 같은 키로 그룹핑 → 카운트 → 결과를 word-count-output에 씁니다. 이 일련의 흐름을 Topology(처리 그래프) 라고 해요.

KStream vs KTable — 두 모델의 차이

여기서 시험 함정이 하나 있어요. KStream과 KTable은 단순히 이름이 비슷한 게 아닙니다. 데이터를 보는 시각이 근본적으로 달라요.

회사 비유로 풀면 — KStream은 이벤트 스트림(계속 흐르는 강물), KTable은 최신 상태 테이블(지금 잔액이 적힌 통장).

KStream — 이벤트 스트림 (모든 레코드가 새 사건)
  [0: K=alice, V=login]
  [1: K=bob,   V=login]
  [2: K=alice, V=logout]
  → 3개 이벤트 모두 유지

KTable — 변경 로그 (같은 키의 최신 값만 유지)
  [0: K=alice, V=active]
  [1: K=bob,   V=active]
  [2: K=alice, V=inactive]
  → 결과: alice=inactive, bob=active

KStream은 "alice가 로그인했고, 로그아웃했다" 라는 사건의 흐름을 그대로 보존합니다. 모든 레코드가 새로운 사실이에요.

KTable은 "지금 시점에 alice는 inactive다" 라는 현재 상태를 들고 있어요. 같은 키로 새 메시지가 오면 옛 값을 덮어씁니다. 통장 잔액 같은 거예요.

언제 무엇을 쓰는지가 중요해요.

  • 클릭 이벤트, 결제 이벤트, 센서 데이터 — KStream
  • 사용자 프로필, 상품 카탈로그, 현재 재고 — KTable

GlobalKTable은 한 단계 더 나아간 친구예요. 보통의 KTable은 파티션 단위로 로컬 스토어에 데이터를 들고 있는데(같은 파티션 데이터만), GlobalKTable은 모든 파티션 데이터를 모든 인스턴스에 복제해 둡니다. 작은 참조 테이블(국가 코드, 통화 환율 같은)을 모든 노드에서 빠르게 조회할 때, 그리고 키 동일성 없이 조인할 때 씁니다.

윈도우 집계 — 시간 단위로 묶기

스트리밍에서 가장 자주 쓰는 패턴이 시간 단위 집계예요. "5분마다 주문 수 세기", "10분 슬라이딩 윈도우 평균" 같은 거.

// 5분 텀블링 윈도우별 주문 수
KTable<Windowed<String>, Long> orderCounts = orders
    .groupByKey()
    .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5)))
    .count()
    .suppress(Suppressed.untilWindowCloses(
        Suppressed.BufferConfig.unbounded()));

윈도우 종류가 여러 개예요.

  • Tumbling Window — 겹치지 않는 고정 길이 (5분 5분 5분…)
  • Hopping Window — 겹치는 고정 길이 (10분 윈도우, 5분마다 하나)
  • Sliding Window — 이벤트 시간 기준 슬라이딩
  • Session Window — 비활성 간격으로 자동 묶임

조인 — 스트림과 테이블 합치기

서로 다른 토픽 데이터를 합쳐서 풍부한 데이터를 만드는 자리.

// 주문 스트림에 사용자 정보(KTable) 붙이기
KStream<String, EnrichedOrder> enrichedOrders = orderStream
    .join(userTable,
        (order, user) -> new EnrichedOrder(order, user),
        Joined.with(Serdes.String(), orderSerde, userSerde));

orders 토픽에서 들어오는 주문 이벤트마다, users KTable에서 그 사용자의 현재 정보를 찾아 합쳐 줍니다. 회사 비유로 — 주문서가 들어올 때마다 직원 정보 디렉토리에서 그 직원의 부서·직책을 찾아 주문서에 도장을 찍어 주는 셈이에요.

Exactly-Once 처리 보장

Kafka Streams의 가장 큰 자랑이 정확히 한 번(Exactly-Once) 처리 보장입니다. 분산 시스템에서 정말 어려운 문제인데, 라이브러리 레벨에서 풀어 줘요.

props.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG,
    StreamsConfig.EXACTLY_ONCE_V2);

이 한 줄이면 끝. 내부적으로 카프카 트랜잭션을 사용해서 "Input Topic에서 읽기 → 처리 → Output Topic에 쓰기 → Consumer Offset 커밋"이 모두 원자적으로 일어나도록 보장합니다.

여기서 시험 함정이 하나 있어요. Exactly-Once는 카프카 → Kafka Streams → 카프카 경계 안에서만 보장됩니다. Streams 앱에서 외부 DB에 쓰는 사이드 이펙트는 해당 사항이 아니에요. 외부 시스템까지 정확히 한 번을 원하면 Idempotent 쓰기(같은 데이터를 여러 번 써도 결과가 동일)를 외부 측에서 직접 만들어 줘야 합니다.

한 줄 정리 — Streams는 Java 라이브러리, KStream(이벤트 흐름)과 KTable(최신 상태)을 다루고, 윈도우/조인/집계가 핵심, Exactly-Once V2가 라이브러리 레벨 강점.

Schema Registry — 데이터 양식 표준 관리실

Kafka Connect가 케이블, Kafka Streams가 라인이라면, Schema Registry는 카프카에 흐르는 데이터의 양식을 표준화하는 부서예요. 회사 비유로 — 모든 부서가 자료 양식을 통일해 쓸 수 있게 표준 양식을 한 곳에서 관리하는 자리입니다.

왜 필요한가 — 양식 충돌의 비극

이걸 안 쓰면 어떤 일이 벌어지는지부터 보고 가요.

문제 상황:
Producer → Kafka: {"name": "Alice", "age": 30}    (JSON)
Consumer → 예상:  {"username": "...", "years": ...}  (필드명 다름!)
                                  ↓
                     런타임 오류, 데이터 파싱 실패

Producer 팀과 Consumer 팀이 서로 다른 부서에 있고, 한쪽이 필드명을 바꿨는데 다른 쪽에 알리지 않으면 운영 중에 조용히 깨집니다. 카프카는 단순한 바이트 운반자라 양식 검증을 안 하거든요. 메시지가 카프카에는 잘 들어가는데, Consumer가 파싱 시점에 떨어지는 거예요. 운영에서 가장 무서운 종류의 장애.

여기서 시험 함정이 하나 있어요. "JSON을 쓰면 스키마가 자동으로 들어가지 않나요?" — 아니에요. JSON에는 필드 타입이 표시되지 않고(string인지 int인지), 어떤 필드가 필수인지 옵션인지도 약속되지 않아요. JSON Schema라는 별도 명세가 따로 있지만, 그건 자동으로 따라붙는 게 아닙니다. 카프카 자체는 데이터 양식을 검증하지 않는다 는 점을 1편에서도 말했죠. 그 검증을 외부에서 해 주는 자리가 바로 Schema Registry입니다.

동작 흐름

Producer
  │ 1. 스키마 등록 (최초 1회)
  │ 2. Schema Registry가 스키마 ID 부여
  │ 3. (스키마 ID + 직렬화된 데이터) 를 카프카에 전송
  ▼
Kafka Topic

Consumer
  │ 1. 메시지에서 스키마 ID 추출
  │ 2. Schema Registry에서 스키마 조회 (캐싱)
  │ 3. 그 스키마로 역직렬화
  ▼
처리

Schema Registry
  - 내부 저장소: _schemas 토픽 (카프카 자기 자신 사용)
  - 호환성 검사 엔진

핵심 흐름은 단순해요. 메시지에 데이터 자체와 함께 스키마 ID가 박혀 카프카로 흘러가고, Consumer는 그 ID로 Schema Registry에서 진짜 스키마를 받아 역직렬화합니다. 스키마 본문이 매 메시지마다 같이 따라가지 않으니 메시지 크기가 작고, 동시에 양식이 항상 정확히 들어맞아요.

지원 포맷 세 가지

포맷설명
AvroLinkedIn에서 만든 직렬화 포맷. 바이너리 + 스키마 분리. 카프카 생태계에서 가장 보편적
ProtobufGoogle 개발. gRPC와 함께 자주 쓰임. 다국어 호환 좋음
JSON SchemaJSON을 그대로 쓰면서 스키마만 따로 등록. 가독성 우선 시 선택

각자 강점이 달라요. 데이터 크기와 처리 속도가 중요하면 Avro·Protobuf, 사람이 직접 들여다볼 일이 많으면 JSON Schema. 카프카 입문에서 가장 흔히 만나는 포맷이 Avro니 그 예시를 짚고 갑니다.

Avro 스키마 정의

// user.avsc
{
  "type": "record",
  "name": "User",
  "namespace": "com.example",
  "fields": [
    {"name": "id", "type": "int"},
    {"name": "name", "type": "string"},
    {"name": "email", "type": ["null", "string"], "default": null},
    {"name": "created_at",
     "type": {"type": "long", "logicalType": "timestamp-millis"}}
  ]
}

각 필드마다 타입과 기본값이 명시돼요. email["null", "string"] 으로 nullable이고 기본값이 null이에요. created_at은 logical type으로 타임스탬프임을 명시. 이렇게 한 번 약속을 잡아 두면 모든 Producer·Consumer가 같은 양식을 따라가요.

Avro Serializer 사용

Java 기준으로 Producer 설정.

Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
    StringSerializer.class.getName());
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
    KafkaAvroSerializer.class.getName());
props.put("schema.registry.url", "http://localhost:8081");

User user = new User(1, "Alice", "alice@example.com",
    System.currentTimeMillis());
ProducerRecord<String, User> record =
    new ProducerRecord<>("users", "user-1", user);
producer.send(record);

Consumer 측.

props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
    StringDeserializer.class.getName());
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
    KafkaAvroDeserializer.class.getName());
props.put("schema.registry.url", "http://localhost:8081");
props.put("specific.avro.reader", "true");

ConsumerRecords<String, User> records =
    consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, User> record : records) {
    User user = record.value();
    System.out.println("User: " + user.getName());
}

KafkaAvroSerializer / KafkaAvroDeserializer 가 Schema Registry와 자동으로 통신해 주기 때문에, 사용자 코드에서는 객체를 그냥 보내고 받기만 하면 돼요.

스키마 진화 호환성 — 가장 중요한 주제

여기가 Schema Registry의 진짜 강점이 드러나는 자리입니다. 운영을 시작하면 양식이 반드시 변합니다. 새 필드 추가, 기존 필드 삭제, 타입 변경 — 이 모든 변화가 Producer와 Consumer가 동시에 업데이트되지 않은 상태에서 일어나요.

회사 비유로 — 인사 부서가 직원 신상명세서 양식을 바꿨는데, 옛 양식으로 작성한 옛날 서류와 새 양식을 동시에 처리해야 합니다. 이 호환성을 Schema Registry가 검사·강제해 줘요.

호환성 레벨이 네 가지.

레벨설명허용하는 변경
BACKWARD새 스키마로 옛 데이터를 읽을 수 있음기본값 있는 새 필드 추가, 옵션 필드 삭제
FORWARD옛 스키마로 새 데이터를 읽을 수 있음새 옵션 필드 추가, 기본값 있는 필드 삭제
FULLBACKWARD + FORWARD 둘 다옵션 필드 추가/삭제만
NONE호환성 검사 안 함모든 변경 허용 (위험)

여기서 시험 함정이 하나 있어요. "그럼 무조건 FULL이 가장 안전한 거 아닌가요?" 보통은 BACKWARD가 기본 권장입니다. Consumer는 천천히 새 스키마로 옮겨 가는 게 일반적이라, 새 스키마로 옛 데이터를 읽을 수 있는 BACKWARD가 가장 흔한 시나리오예요. FULL은 양쪽 방향 다 보장해야 해서 변경이 가능한 폭이 좁아져요.

REST API로 스키마 등록·조회·호환성 설정.

# 스키마 등록
curl -X POST http://localhost:8081/subjects/users-value/versions \
  -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  -d '{
    "schema": "{\"type\":\"record\",\"name\":\"User\",\"fields\":[{\"name\":\"id\",\"type\":\"int\"},{\"name\":\"name\",\"type\":\"string\"}]}"
  }'

# 조회
curl http://localhost:8081/subjects
curl http://localhost:8081/subjects/users-value/versions
curl http://localhost:8081/subjects/users-value/versions/1

# 호환성 설정
curl -X PUT http://localhost:8081/config/users-value \
  -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  -d '{"compatibility": "BACKWARD"}'

한 줄 정리 — Schema Registry는 양식 표준 관리실. 스키마 ID로 관리, Avro/Protobuf/JSON Schema 지원, BACKWARD/FORWARD/FULL/NONE 호환성으로 진화 보장.

API 선택 가이드 — 어떤 도구를 언제 쓸까

여기까지 도구 셋을 정리했으니, 한 번 정리하고 갈 자리예요. 카프카 생태계에서 새 작업이 들어오면 어느 도구를 쓸지 결정 트리.

요구사항: 데이터를 외부 시스템에서 카프카로 가져오기
           또는 카프카에서 외부로 내보내기
  → Kafka Connect

요구사항: 카프카 안의 데이터를 변환·집계·조인
  → Kafka Streams (Java 앱)
  → ksqlDB (SQL이 편할 때)

요구사항: 단순 이벤트 발행/구독
  → Producer/Consumer API 직접

요구사항: 복잡한 스트림 처리, 상태 관리, 대용량
  → Apache Flink / Spark Streaming

여기서 시험 함정이 하나 있어요. "Streams가 있는데 ksqlDB가 왜 따로 있나요?" 둘은 같은 일을 하지만 사용자 층이 달라요. Streams는 Java 개발자가 코드로 작성하는 자리, ksqlDB는 데이터 엔지니어·분석가가 SQL로 작성하는 자리예요. 같은 토폴로지를 만들어도 진입 장벽이 다릅니다.

-- ksqlDB 예시
CREATE STREAM orders (
  order_id VARCHAR,
  customer_id VARCHAR,
  amount DOUBLE
) WITH (KAFKA_TOPIC='orders', VALUE_FORMAT='JSON');

CREATE TABLE order_summary AS
  SELECT customer_id,
         COUNT(*) AS order_count,
         SUM(amount) AS total_amount
  FROM orders
  WINDOW TUMBLING (SIZE 1 HOUR)
  GROUP BY customer_id;

같은 시간 윈도우 집계를 SQL 몇 줄로 끝낼 수 있어요. 운영팀의 비개발 인력이 직접 스트림을 만질 수 있는 환경을 마련해 줍니다.

케이스 스터디 — 실제 서비스에 카프카 박아 보기

이제 도구들을 실제 시나리오에 어떻게 박는지 네 가지 사례로 풀어 갑니다.

케이스 1 — 스트리밍 서비스 예시 (영상 스트리밍)

넷플릭스와 비슷한 영상 스트리밍 서비스. 사용자가 시청·일시정지·이어보기를 끊임없이 합니다. 이걸 카프카로 받으면.

[사용자 디바이스]
  │ 재생 이벤트 (시청 시작, 일시정지, 재개, 완료)
  ▼
[Video Position Service (Producer)]
  │ topic: shows.views
  ▼
[Kafka Cluster]
  ├─→ [Recommendation Service]   실시간 추천 업데이트
  ├─→ [Analytics Service]        시청 통계 집계
  └─→ [Resume Service]           이어보기 위치 저장 (compacted)

핵심 설계 포인트 두 가지.

  • shows.views 토픽 키 = userId — 같은 사용자의 이벤트는 같은 파티션으로 가서 순서가 보장됨
  • 이어보기 토픽은 cleanup.policy=compact — 같은 키의 옛 메시지는 정리되고 최신 위치만 유지 (Log Compaction에 대해서는 곧 자세히)

케이스 2 — 택시 호출 서비스 예시 (택시 호출)

우버·카카오택시 비슷한 서비스. 택시들이 5초마다 GPS 위치를 보내고, 그걸 다양한 서비스가 동시에 사용해요.

[택시 앱 (Producer)]
  │ GPS 위치 데이터 (5초마다)
  │ key = taxi_id
  ▼
topic: taxis.gps (6개 파티션)
  │
  ├─→ [Notification Service]   근처 손님에게 알림
  ├─→ [ETA Service]            도착 예정 시간 (Streams)
  └─→ [Location Analytics]     지역별 공급/수요 분석 (Streams)

여기서 시험 함정이 하나 있어요. "5초마다 모든 택시가 GPS를 보내면 토픽에 압력이 너무 큰 거 아니에요?" 카프카는 이런 자리를 위해 만들어진 거예요. 택시 1만 대가 5초마다 보내도 초당 2,000건. 카프카 한 노드가 가볍게 처리할 수 있는 양입니다. 핵심은 key = taxi_id 로 같은 택시의 이벤트가 순서 보장되게 만드는 부분이에요.

케이스 3 — 소셜 미디어 예시 (소셜 미디어, CQRS 패턴)

게시물 작성·삭제·좋아요가 모두 이벤트로 흐르고, 읽기와 쓰기가 분리되는 CQRS(Command Query Responsibility Segregation) 패턴.

[사용자 액션]
  │ 게시물 작성/삭제/좋아요
  ▼
[Posts Command Service (Producer)]
  │ topic: posts.created, posts.liked, posts.deleted
  ▼
[Kafka Cluster]
  │
  ├─→ [Feed Service]            팔로워 피드 업데이트
  ├─→ [Notifications Service]   알림 발송
  └─→ [Statistics Service]      좋아요/댓글 집계 (Streams)
                                topic: posts.statistics

결과:
  Command (쓰기) ← Producer  → posts topics
  Query   (읽기) ← Consumer ← posts.statistics (집계 결과)

쓰기와 읽기를 분리하면 각자 최적화가 가능해져요. 쓰기는 빠른 이벤트 적재, 읽기는 미리 집계해 둔 결과를 조회하는 식. 카프카가 그 가운데에서 이벤트 흐름의 단일 진실 저장소 역할을 합니다.

케이스 4 — 은행 서비스 예시 (CDC + Debezium)

은행 계좌 잔액 변경을 실시간으로 처리. 여기서는 CDC(Change Data Capture) 패턴이 핵심이에요.

[MySQL DB]
  │ account_balance 테이블 변경
  ▼
[Debezium Source Connector]
  │ 트랜잭션 로그 읽어서 카프카 이벤트로 변환
  ▼
topic: dbserver1.bank.account_balance
  │
  ├─→ [Fraud Detection (Streams)]
  │    1분 내 3번 이상 결제 실패 패턴 탐지
  │
  ├─→ [Email Notification]
  │    거래 알림 이메일 발송
  │
  └─→ [Data Warehouse Sink]
       BigQuery / Snowflake 적재

회사 비유로 — 은행 통장에 변동이 일어날 때마다 그 사건을 우체국으로 자동 통보하고, 우체국이 다시 사기 탐지팀·이메일팀·분석팀에 동시에 분배하는 식이에요. Debezium이 MySQL의 binlog를 읽어 카프카 이벤트로 자동 변환해 주기 때문에 애플리케이션 코드를 건드리지 않고도 모든 DB 변경을 실시간 스트림으로 만들 수 있어요.

여기서 시험 함정이 하나 있어요. CDC를 안 쓰고 "내 애플리케이션 코드에서 DB 쓸 때 카프카에도 같이 쓰면 되지 않나요?" 라고 생각하기 쉬운데, 이중 쓰기는 일관성 깨뜨리기 쉽습니다. DB 쓰기는 성공했는데 카프카 쓰기가 실패하면 둘이 어긋나요. CDC는 DB 트랜잭션 로그를 사실의 단일 출처로 삼아 그 일관성 문제를 원천 차단합니다.

빅데이터 아키텍처에서 카프카의 위치

큰 그림으로 한 번 더 올라가 봅시다. 빅데이터 인프라 안에서 카프카가 어디에 자리 잡는지.

Lambda 아키텍처

실시간과 배치를 함께 굴리는 전통적 패턴.

[데이터 소스]
     │
     ├───[Kafka]────▶[스트리밍 레이어]──▶[실시간 뷰]
     │               (Kafka Streams)
     │
     └───────────────[배치 레이어]──────▶[배치 뷰]
                     (Spark/Hadoop)
                                            │
                                      [서빙 레이어]
                                     (Cassandra/HBase)

장점은 분명해요. 실시간성을 살리면서 배치의 정확성도 잡습니다. 단점은 같은 로직을 두 번 구현해야 한다는 점이에요. 스트리밍 코드 따로, 배치 코드 따로. 두 결과가 미묘하게 어긋나는 사고가 자주 납니다.

Kappa 아키텍처

스트리밍만으로 단순화한 패턴.

[데이터 소스]
     │
     └───[Kafka]────▶[스트리밍 레이어]────▶[서빙 레이어]
                     (Kafka Streams        (Key-Value Store)
                      또는 Flink)

카프카의 긴 데이터 보존(Retention) 덕분에 가능해진 모델이에요. "옛날 데이터를 처음부터 다시 처리"하는 재처리도 카프카에 그대로 보존된 데이터를 처음부터 스트림으로 다시 흘려서 해결합니다. 로직이 한 벌이라 운영이 단순해요.

여기서 시험 함정이 하나 있어요. "그럼 Kappa가 무조건 좋네요?" — 데이터를 무한정 카프카에 보존하기는 어려워요(비용·디스크). 며칠~몇 주 보존이 일반적이고, 그보다 긴 과거를 다시 처리해야 하면 결국 데이터 레이크/웨어하우스가 필요합니다. Kappa는 카프카에 있는 만큼 다시 처리할 수 있다는 가정 위에 서 있어요.

트랜잭션과 Exactly-Once 깊이 보기 — Kafka Streams 안전망의 정체

Kafka Streams가 EOS_V2를 켜는 한 줄 뒤에는 카프카의 트랜잭션 메커니즘이 깔려 있어요. 이걸 한 번 풀어 두면 헷갈림이 사라집니다.

트랜잭션이란

여러 토픽/파티션에 걸친 원자적 쓰기를 보장합니다. 모두 성공하거나 모두 실패. 회사 비유로 — 한 거래에서 출금 기록·입금 기록·로그 기록 세 가지를 동시에 처리해야 할 때, 셋 모두 성공해야 거래가 확정되고 하나라도 실패하면 다 무효화되는 식이에요.

트랜잭션 사용 사례 (Kafka Streams 내부):
Input Topic → 처리 → Output Topic 1
                  → Output Topic 2
                  → Consumer Offset 커밋

→ 이 네 가지가 모두 원자적으로 성공해야 Exactly-Once 보장

트랜잭셔널 프로듀서

Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");
props.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "my-transactional-id");

KafkaProducer<String, String> producer = new KafkaProducer<>(props);

// 트랜잭션 초기화 (최초 1회)
producer.initTransactions();

try {
    producer.beginTransaction();

    producer.send(new ProducerRecord<>("output-topic-1", key, value1));
    producer.send(new ProducerRecord<>("output-topic-2", key, value2));

    // 컨슈머 오프셋도 트랜잭션에 포함
    Map<TopicPartition, OffsetAndMetadata> offsets = ...;
    producer.sendOffsetsToTransaction(offsets,
        new ConsumerGroupMetadata("my-group"));

    producer.commitTransaction();  // 원자적 커밋
} catch (ProducerFencedException | OutOfOrderSequenceException e) {
    producer.close();  // 복구 불가 → 종료
} catch (KafkaException e) {
    producer.abortTransaction();  // 트랜잭션 중단
}

여기서 시험 함정이 하나 있어요. transactional.id는 인스턴스 재시작 후에도 같은 값을 써야 합니다. 같은 ID를 쓰면 카프카가 "아 이전 인스턴스가 다시 살아났구나" 인식하고 미완료 트랜잭션을 정리해 줘요. 매번 다른 ID를 주면 카프카는 두 다른 인스턴스로 인식해 좀비 트랜잭션이 생길 수 있습니다.

트랜잭셔널 컨슈머 — 격리 수준

// 커밋된 트랜잭션 메시지만 읽기
props.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed");

// read_uncommitted (기본값): 미커밋 메시지도 읽음
// read_committed:           트랜잭션 커밋 완료 메시지만 읽음

EOS 환경에서는 Consumer 측도 read_committed로 잡아야 진정한 Exactly-Once가 완성돼요. 안 그러면 Producer가 abort한 트랜잭션 메시지까지 Consumer가 읽어 버립니다.

Log Compaction — 같은 키의 옛 메시지 정리

이어보기 토픽 같은 자리에서 자주 나오는 정책이에요. 회사 비유로 — 게시판에 같은 사람의 글이 여러 개 쌓이면 가장 최신 글만 남기고 옛 글은 정리하는 청소 정책입니다.

두 가지 정책 — delete vs compact

cleanup.policy=delete (기본):
  retention.ms 기간이 지난 메시지는 자동 삭제
  → 시간 기반 보존, 일반 토픽

cleanup.policy=compact:
  같은 키의 옛 메시지는 정리, 최신만 유지
  → 키-값 상태 저장에 적합

Compact 정책의 동작이 인상적이에요. 같은 키의 메시지가 여러 개 있으면, 백그라운드의 LogCleaner 스레드가 주기적으로 옛 버전을 정리하고 최신 버전만 남깁니다. 결과적으로 토픽이 "키 → 최신 값" 형태의 영구 저장소처럼 동작해요.

Tombstone — "이 키는 삭제됐다" 표식

여기서 시험 함정이 하나 있어요. "Compact 토픽에서 키를 어떻게 삭제하나요?" 답은 Tombstone(묘비) 메시지입니다. value=null 인 메시지를 넣으면 그 키는 "삭제됨"으로 표시되고, 일정 시간(delete.retention.ms) 후에 그 묘비도 정리되며 결국 그 키 전체가 사라져요.

// 묘비 메시지 = value=null
producer.send(new ProducerRecord<>("user-state", "user-123", null));
// → user-123 키 삭제 표시

활용 자리.

  • 사용자 프로필 토픽 — 키 = userId, 최신 프로필만 유지
  • 이어보기 위치 — 키 = (userId, showId), 최신 위치만 유지
  • 재고 상태 — 키 = productId, 최신 재고만 유지
  • Schema Registry 자체 — 내부적으로 _schemas compact 토픽 사용
  • __consumer_offsets — Consumer Group 오프셋 저장 토픽도 compact

한 줄 정리 — delete = 시간 기반 청소, compact = 키별 최신만 유지, Tombstone = value=null로 키 삭제 표시.

Unclean Leader Election — 가용성과 일관성의 트레이드오프

운영에서 정말 헷갈리는 주제예요. 이름이 무서워서 그렇지 핵심은 단순한 트레이드오프입니다.

무엇이 "Unclean"인가

5편에서 봤듯이, 파티션은 리더(Leader) 와 여러 팔로워(Follower) 로 구성돼요. ISR(In-Sync Replicas)에 들어 있는 팔로워는 리더와 거의 같은 데이터를 들고 있고, 리더가 죽으면 ISR 안의 팔로워가 새 리더가 됩니다.

문제는 ISR에 있는 모든 복제본이 동시에 죽었을 때예요.

정상: ISR = {Leader, F1, F2}
사고: 모두 다운
이때 선택지:
  (A) ISR 멤버 한 명이 다시 살아날 때까지 대기
      → 가용성 손실, 일관성 보존
  (B) ISR 밖이지만 살아 있는 (뒤처진) 복제본을 새 리더로
      → 가용성 회복, 일관성 손실 (옛 데이터로 후퇴)

이 (B) 가 Unclean Leader Election이에요. 데이터가 뒤처진 복제본을 강제로 리더로 만드는 거. 일부 메시지가 사라질 수 있습니다.

설정과 트레이드오프

# 브로커 설정
unclean.leader.election.enable=false  # 기본값 (Kafka 0.11+)
# true:  뒤처진 복제본 리더 승격 허용 → 가용성 우선, 데이터 손실 가능
# false: ISR 멤버만 리더 가능       → 일관성 우선, 다운타임 가능

여기서 시험 함정이 하나 있어요. "그럼 false가 무조건 좋은 거 아닌가요?" 보통은 그렇지만, 메트릭·로그처럼 가용성이 일관성보다 중요한 토픽은 true가 맞을 수 있어요. 잠깐의 데이터 손실이 큰 다운타임보다 나은 자리. 토픽별로 정책을 따로 잡는 것도 가능합니다.

대용량 메시지 처리 — 외부 스토리지 패턴

카프카 기본 메시지 크기 제한이 1MB인데, 가끔 100MB짜리 영상이나 큰 PDF를 보내야 할 때가 있어요. 두 가지 접근법을 보고 가요.

방법 1: 외부 스토리지 패턴 (권장)

Producer:
  1. 큰 파일(100MB 영상)을 S3에 업로드
  2. Kafka에는 S3 URL만 전송 (소량의 메타데이터)

Consumer:
  1. Kafka에서 S3 URL 수신
  2. S3에서 실제 파일 다운로드
  3. 처리

장점: Kafka를 설계 원칙대로 가볍게 사용

여기서 시험 함정이 하나 있어요. 카프카는 큰 파일 저장소가 아닙니다. 카프카는 작은 메시지 수백만 건을 빠르게 운반하도록 만들어진 시스템이에요. 큰 파일을 카프카에 직접 넣으면 메모리·네트워크 압력이 폭증하고, 복제·압축·인덱싱 모든 부분이 비효율적으로 돌아갑니다. 큰 페이로드는 외부 스토리지에 두고 카프카에는 포인터만 보내는 패턴이 정답입니다.

방법 2: 설정 변경 (비권장)

부득이하게 메시지를 키우려면 네 곳을 다 손봐야 해요.

# 브로커 (server.properties)
message.max.bytes=10485760           # 10MB
replica.fetch.max.bytes=10485760     # 브로커 간 복제 크기

# 토픽별
max.message.bytes=10485760

# 컨슈머
max.partition.fetch.bytes=10485760

# 프로듀서
max.request.size=10485760

브로커만 키우고 나머지를 잊으면 한쪽 어딘가에서 막혀요. 그래서 가능하면 외부 스토리지 패턴을 쓰자는 게 컨센서스입니다.

토픽 네이밍 컨벤션과 파티션 수 선택

운영의 자잘한 결정들이 모이면 큰 차이를 만들어요. 두 가지만 더 짚고 가요.

토픽 네이밍 — 일관성이 생명

표준 패턴 두 가지.

패턴 1: {팀}.{서비스}.{데이터타입}
  payments.billing.invoices-created
  logistics.delivery.tracking-events

패턴 2: {환경}.{서비스}.{이벤트}
  prod.orders.completed
  staging.users.registered

규칙.

  • 소문자 사용
  • 하이픈 또는 점으로 구분
  • 축약 금지 (orders ↔ ord, users ↔ usr 같은 거 X)
  • 환경 접두사 권장prod., staging., dev.

이걸 처음부터 잡지 않으면 나중에 토픽이 수백 개로 늘어났을 때 누가 무슨 토픽 만들었는지 못 추적해요. 모든 신규 토픽은 컨벤션 따르기 를 팀 룰로 못 박아 두는 게 답입니다.

파티션 수 결정

파티션 수 = max(목표 처리량 / 컨슈머당 처리량,
              목표 처리량 / 프로듀서당 처리량)

예시:
- 목표: 1GB/s 처리
- 컨슈머 처리량: 50MB/s
- 1GB / 50MB = 20개 파티션 → 컨슈머 20개 필요

여기서 시험 함정이 하나 있어요. 파티션은 늘리기는 쉽지만 줄이기는 거의 불가능합니다. 줄이려면 토픽 재생성이 필요한데, 운영 중인 토픽은 재생성이 큰일이에요. 그래서 처음에 약간 여유 있게 잡되, 너무 많이 잡으면 또 다른 비용이 듭니다.

너무 많은 파티션의 비용.

  • 컨트롤러 부하 증가 (메타데이터 관리)
  • 파일 핸들 낭비 (각 파티션이 여러 세그먼트 파일)
  • End-to-end 지연 증가 (브로커 간 복제 트래픽)
  • KRaft 이전: 페일오버 시간 증가

일반 권장.

  • 브로커 수 × 2~3 배 정도부터 시작
  • 브로커당 2,000~4,000 파티션 을 상한으로 보고 그 이하 유지

안티패턴 — 흔히 빠지는 함정

마지막으로 운영에서 자주 보는 함정 네 가지를 정리할게요.

안티패턴 1 — 토픽을 큐처럼 사용

"메시지 처리하면 자동으로 사라지는 줄 알았어요"가 가장 흔한 오해예요. 카프카는 retention 기간 동안 무조건 보존합니다. 처리 완료 ≠ 삭제. 단순한 큐 동작이 필요하면 RabbitMQ 같은 도구가 더 자연스러울 수 있어요.

안티패턴 2 — 너무 많은 토픽

나쁜 예: 사용자마다 토픽 생성 → 100만 사용자 = 100만 토픽
좋은 예: 단일 토픽 + 사용자 ID를 키로

토픽은 비싸요. 파일·메타데이터·연결 모두. 데이터 분류는 토픽이 아니라 키와 컨슈머 필터링으로 하는 게 답입니다.

안티패턴 3 — 동기 프로듀서 (배치 없음)

// 나쁜 예: 하나씩 동기 전송 (처리량 폭락)
for (event : events) {
    producer.send(record).get();  // 블로킹!
}

// 좋은 예: 비동기 배치 전송
for (event : events) {
    producer.send(record, callback);  // 비블로킹
}
producer.flush();  // 마지막에 한 번

3편에서 본 배치·압축의 강점을 못 살리는 케이스. 동기 전송은 한 메시지 보내고 ACK 기다리고 다음 메시지를 보내는 패턴이라 처리량이 1/100로 떨어져요.

안티패턴 4 — 자동 커밋 + 느린 처리

// 위험: 처리 중 오프셋 자동 커밋 → 실패 시 메시지 누락
enable.auto.commit=true
auto.commit.interval.ms=5000

// 권장: 수동 커밋
enable.auto.commit=false
consumer.commitSync();  // 처리 완료 후 커밋

여기서 시험 함정이 하나 있어요. 자동 커밋은 poll() 호출 시점에 백그라운드에서 커밋합니다. 메시지를 가져온 직후엔 아직 처리가 안 끝났을 수 있는데, 다음 poll()을 호출하는 순간 "이전 메시지 다 처리했다"고 카프카에 신고해 버려요. 그 사이 앱이 죽으면 처리 못한 메시지가 그대로 사라집니다. 중요한 비즈니스 처리에는 무조건 수동 커밋입니다.

엔터프라이즈 운영 체크리스트

마지막 자리예요. 운영에서 확인해야 할 항목을 카테고리별로 정리합니다.

클러스터 설계

  • [ ] 브로커 수 최소 3개
  • [ ] KRaft Controller 노드 수 홀수 (3 또는 5)
  • [ ] 대형 클러스터에서 브로커와 Controller 분리
  • [ ] rack awareness 설정 — 데이터센터 수준 장애 대비
  • [ ] 데이터 디렉토리 RAID-10 또는 SSD
  • [ ] OS 튜닝 — 파일 디스크립터 한도, 가상 메모리, 스왑 최소화

토픽 설계

  • [ ] 운영 환경 replication-factor=3
  • [ ] 중요 토픽 min.insync.replicas=2
  • [ ] 파티션 수 = 피크 처리량 / 단일 파티션 처리량
  • [ ] 적절한 보존 기간 (retention.ms)
  • [ ] 압축 정책 결정 — delete vs compact
  • [ ] 대형 토픽에 snappy/lz4 압축

프로듀서·컨슈머 설계

  • [ ] 중요 데이터에 acks=all
  • [ ] enable.idempotence=true
  • [ ] 그레이스풀 셧다운 (ShutdownHook)
  • [ ] Consumer 수동 오프셋 커밋
  • [ ] CooperativeStickyAssignor 사용
  • [ ] Consumer Lag 모니터링 알림
  • [ ] 데드레터 큐 토픽 설계

모니터링·알림 (필수)

  • [ ] Under-Replicated Partitions — 즉각 알림
  • [ ] Active Controller 수 ≠ 1 — 즉각 알림
  • [ ] Consumer Group Lag > 임계값
  • [ ] 브로커 디스크 사용률 > 80%
  • [ ] GC 일시정지 시간 > 1초
  • [ ] Request handler idle ratio < 30%
  • [ ] 네트워크 처리량 (BPS In/Out)

시리즈 전체 마무리 — 1편부터 7편까지의 압축 노트

이 한 글이 시리즈 전체를 통째로 이어 주는 자리예요. 7편치 핵심을 한 번에 압축합니다.

  • 1편 — 카프카는 회사 안 중앙 우체국, N×M 통합을 N+M으로 줄이는 허브
  • 1편 — 메시지 큐와 다름, 로그 기반(append-only), 디스크 영속, 다중 소비자 다회 읽기
  • 1편 — 7가지 사용 사례 — 메시지 시스템·활동 추적·로그 수집·스트림 처리·이벤트 소싱·MSA·빅데이터
  • 2편 — Topic은 게시판, Partition으로 쪼개고 Offset으로 위치 표시
  • 2편 — Broker = 우체국 지점, 여러 브로커가 Cluster, 각 파티션은 Leader 1 + Follower N 의 ISR
  • 3편 — Producer는 acks=all + idempotence=true가 기본 안전 설정 (Kafka 3.0+)
  • 3편 — Key가 같으면 같은 파티션 → 순서 보장의 단위
  • 3편 — 배치·압축(snappy/lz4)으로 처리량 끌어올림
  • 4편 — Consumer Group이 파티션을 분산 소비, 같은 그룹 안에서는 한 파티션 = 한 컨슈머
  • 4편수동 커밋 권장, 자동 커밋은 데이터 누락 위험
  • 4편CooperativeStickyAssignor로 리밸런싱 부담 최소화
  • 5편 — KRaft 전환 — Zookeeper 제거, 수백만 파티션 확장
  • 5편 — 운영 체크 — Under-Replicated Partitions, Active Controller, Consumer Lag
  • 6편 — 보안 — TLS·SASL·ACL 세 층, 데이터 거버넌스
  • 7편Connect = 외부 시스템 표준 케이블 (Source/Sink, Standalone/Distributed)
  • 7편Streams = 실시간 가공 라인 (라이브러리), KStream(흐름) vs KTable(최신 상태)
  • 7편 — Streams의 Exactly-Once V2 = 카프카 트랜잭션 + read_committed
  • 7편Schema Registry = 양식 표준 관리실, Avro/Protobuf/JSON Schema
  • 7편 — 호환성 레벨 — BACKWARD/FORWARD/FULL/NONE, 보통 BACKWARD가 기본
  • 7편Log Compaction — 같은 키 최신만 유지, Tombstone(value=null)로 삭제
  • 7편 — Unclean Leader Election = 가용성 vs 일관성 트레이드오프, 기본 false
  • 7편 — 대용량 메시지는 외부 스토리지 + 카프카에는 포인터만
  • 7편 — 토픽 네이밍 — {환경}.{서비스}.{이벤트}, 컨벤션 미리 못 박기
  • 7편 — 빅데이터 — Lambda(스트리밍+배치) vs Kappa(스트리밍만)

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

이 시리즈를 마치며

여기까지 따라와 주셔서 진짜 고맙습니다. 1편을 시작할 때 "Topic·Partition·Offset·Broker·Zookeeper" 라는 단어 줄에 막막했던 그 자리에서, 이제는 Connect·Streams·Schema Registry·Exactly-Once·Log Compaction 같은 단어를 자연스럽게 굴리는 자리까지 와 있어요. 7편이라는 거리가 처음엔 까마득해 보였지만, 같은 비유(우체국·게시판) 위에 한 층씩 얹어 가니 의외로 이어졌습니다.

분명히 말씀드리고 싶은 게 한 가지 있어요. 카프카는 한 번 읽고 외울 수 있는 시스템이 아니에요. 처음 만지면 "이 설정은 왜 이거지?" "왜 이렇게 동작하지?" 하는 의문이 끝없이 나옵니다. 그 의문이 떠오를 때마다 이 시리즈의 해당 편을 다시 펼쳐 주세요. 압축 노트만 다시 훑어 봐도 그 자리의 큰 그림이 빠르게 돌아올 거예요.

그리고 또 한 가지 — 카프카를 쓴다는 것은 시스템을 운영한다는 뜻이에요. 책에서 본 설정으로 한 번에 완성되지 않습니다. 처음엔 작게 시작해서, 데이터가 늘어남에 따라 파티션을 늘리고, Consumer Lag을 보면서 컨슈머를 늘리고, 메트릭을 보면서 acks·압축·배치 사이즈를 미세 조정해 가는 느린 여정이에요. 이 시리즈는 그 여정의 출발선에 서 있는 분에게 지도가 되었으면 합니다.

마지막으로 시험·면접에서 헷갈리는 큰 함정 하나만 다시 짚고 마칠게요. 카프카 자체는 "운반 메커니즘"입니다. 데이터를 이해하지도, 처리하지도, 쿼리하지도 않아요. 그 일들은 Kafka Streams·ksqlDB·외부 시스템이 합니다. 카프카에 SQL을 날리면 안 되고, 카프카에 비즈니스 로직을 박으면 안 돼요. 카프카는 빠르고 안정적으로 바이트 스트림을 운반하는 자리예요. 이 한 줄이 1편부터 7편까지 관통하는 가장 중요한 메시지입니다.

7편의 긴 여정이 여러분의 카프카 학습에 단단한 발판이 되었기를 바랍니다. 같은 우체국 비유로 다른 분산 시스템(예: Pulsar, RabbitMQ, Redis Streams) 글을 또 만나면, 그땐 비교의 눈으로 더 빨리 받아들이실 수 있을 거예요. 그 시리즈에서 또 만나기를 바라며, 지금까지 함께해 주셔서 정말 고맙습니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!