← all posts
DEV 2026.05.02 · 14 min read Intermediate

Kafka Streams의 모든 설계는 어디에서 왔는가

Topology와 Task의 1:1 대응부터 Outbox Pattern의 원자성 보장까지, Kafka Streams의 아키텍처 결정들을 하나의 철학으로 추적한다.


Kafka Streams는 별도 클러스터가 없다. Flink도, Spark Streaming도 아니다. 의존성 하나로 Spring Boot 애플리케이션 안에 스트림 처리 엔진을 심는다. 이 단순함 뒤에는 하나의 일관된 설계 원칙이 있다 — “Kafka 토픽의 파티션 구조를 처리 단위로 그대로 투영한다”. Topology, Task, RocksDB 상태 저장소, EOS 보장, 심지어 Outbox Pattern까지 — 이 결정들은 전부 같은 뿌리에서 나온다.

처리의 단위: Topology와 Task

Kafka Streams 애플리케이션은 방향성 비순환 그래프(Topology)다. Source 노드에서 Processor 노드를 거쳐 Sink 노드로 흐른다. 이 그래프가 실제 처리로 바뀌는 순간 핵심 규칙이 등장한다 — 파티션 수 = Task 수.

입력 토픽에 파티션이 4개면 StreamTask가 4개 생성된다. 각 Task는 정확히 하나의 파티션을 담당하고, 독립적인 RocksDB 인스턴스를 가진다. userId=alice의 이벤트가 파티션 2에 할당된다면, 그 이벤트는 항상 Task 2에서 처리되고, Task 2의 RocksDB에만 상태가 쌓인다.

이 구조의 의미는 단순하다. 같은 키의 이벤트는 항상 같은 Task에서 처리된다. 분산 상태 공유가 필요 없다. 네트워크를 거치지 않고 로컬 디스크에서 직접 상태를 읽는다.

인스턴스 2개, 파티션 4개:

  Instance 1: [Task 0] [Task 1]
  Instance 2: [Task 2] [Task 3]
  
  각 Task → 독립 RocksDB (/var/kafka-streams/.../taskId/)

상태의 내구성: RocksDB와 Changelog

로컬 RocksDB는 빠르다. 그러나 Task가 다른 인스턴스로 재배치되면 상태가 사라진다. Kafka Streams는 이 문제를 Changelog 토픽으로 해결한다.

레코드를 처리할 때마다 RocksDB의 변경사항이 Changelog 토픽({appId}-{storeName}-changelog)에 비동기로 발행된다. 이 토픽은 cleanup.policy=compact로 운영되어 키별 최신값만 보존한다. Task가 재배치되면 새 인스턴스는 Changelog를 처음부터 재생해 RocksDB를 재구성한다.

운영 함정: state.dir의 기본값

기본 경로는 /tmp/kafka-streams다. 시스템 재시작 시 /tmp가 초기화되면 상태 저장소가 통째로 사라지고, 다음 시작 시 Changelog 전체를 재생해야 한다. 프로덕션에서는 반드시 state.dir=/var/kafka-streams 같은 영구 디렉토리를 설정해야 한다.

Standby Replica(num.standby.replicas=1)는 이 복구 비용을 최소화한다. Standby는 Changelog를 실시간으로 구독해 RocksDB 복사본을 항상 최신으로 유지한다. Active Task가 장애를 일으키면 Standby가 즉시 승격된다. 상태 저장소가 1 GB라면 복구 시간이 수십 초에서 수 초로 줄어든다.

KStream과 KTable — 같은 토픽, 다른 해석

동일한 Kafka 토픽을 KStream으로 읽으면 이벤트의 무한 흐름이 되고, KTable로 읽으면 키별 최신 상태 스냅샷이 된다. 이 구분이 조인 결과를 완전히 바꾼다.

사용자 프로필 토픽을 KStream으로 조인하면 프로필 업데이트 이벤트 10개가 모두 개별 조인 대상이 된다 — 주문 1건에 10개의 결과. KTable로 조인하면 조인 시점의 최신 프로필 1개와만 결합된다.

// KStream-KTable 조인: 주문이 도착한 시점의 KTable 현재 상태와 결합
KStream<String, Order> orders = builder.stream("orders");
KTable<String, User> users = builder.table("user-profiles");

orders.join(users, (order, user) -> enrich(order, user))
      .to("enriched-orders");

GlobalKTable은 co-partitioning 제약을 없애는 대신 모든 파티션 데이터를 모든 인스턴스에 로드한다. 국가 코드나 카테고리 같은 수만 건 이하의 참조 데이터에는 적합하지만, 수백만 건을 GlobalKTable에 넣으면 인스턴스당 수 GB의 메모리를 소비한다.

윈도우와 시간의 기준

“1분간 주문 수”를 집계할 때 어떤 1분인가. 처리 시각(Processing Time)을 기준으로 삼으면 59초에 발생했지만 20초 지연된 이벤트가 다음 분 윈도우에 들어간다. 재무 보고서에서는 치명적이다.

Event Time 기준으로 전환하면 늦게 도착하는 이벤트(Late Arrival)를 처리해야 한다. grace period가 이 역할을 한다 — 윈도우가 닫힌 후에도 일정 시간 동안 지연 이벤트를 수용한다.

Exactly-Once와 원자성의 범위

processing.guarantee=exactly_once_v2는 poll → process → produce → offset commit 전체를 하나의 트랜잭션으로 묶는다. 크래시가 발생하면 미완료 트랜잭션이 abort되고, read_committed Consumer는 abort된 데이터를 무시한다. 재시작 후 마지막 커밋된 offset부터 정확히 한 번 재처리된다.

EOS-V2(Kafka 2.5+)는 Task당 독립 트랜잭션 Producer를 생성하던 V1과 달리 스레드당 1개의 트랜잭션 Producer를 공유한다. Transaction Coordinator 연결이 Task 수 비례에서 스레드 수 고정으로 줄어 처리량 오버헤드가 V1의 40%에서 20%로 감소한다.

트레이드오프

EOS는 Kafka 토픽 간에만 보장된다. 출력 토픽에서 DB로 저장하는 구간은 EOS 밖이다. 외부 시스템과 연계하려면 별도의 멱등 처리(UPSERT, 처리 이력 테이블)가 필요하다. 처리 로직 자체가 멱등하다면 at_least_once로도 사실상 동일한 결과를 얻을 수 있다.

원자성의 경계: Outbox Pattern

“DB에 주문을 저장하고 Kafka에 이벤트를 발행한다”는 단순한 요구사항이 분산 시스템에서는 원자성 문제가 된다. DB 저장 후 Kafka 발행 전에 장애가 나면 이벤트가 유실되고, Kafka 발행 후 DB 저장 전에 장애가 나면 주문 없이 재고가 감소한다.

Outbox Pattern은 이 문제를 DB 자체의 트랜잭션으로 해결한다. 비즈니스 데이터와 이벤트를 같은 DB 트랜잭션 내에서 함께 기록한다. DB가 커밋되면 두 테이블이 동시에 반영된다.

BEGIN;
INSERT INTO orders (id, status) VALUES ('order-1', 'CREATED');
INSERT INTO outbox_events (aggregate_id, event_type, payload)
    VALUES ('order-1', 'OrderCreated', '{"amount": 100}');
COMMIT;
-- orders와 outbox_events가 원자적으로 커밋됨

Debezium은 DB의 WAL(Write-Ahead Log)을 모니터링해 outbox 테이블의 INSERT를 감지하고 자동으로 Kafka에 발행한다. 애플리케이션 코드는 Kafka를 직접 호출하지 않는다. DB 트랜잭션의 원자성이 곧 Kafka 발행의 원자성이 된다.

정리

  • Task = 파티션: Kafka Streams의 모든 상태 관리는 이 1:1 대응에서 출발한다. 상태 공유가 없으므로 네트워크 없이 로컬에서 처리한다.
  • KStream vs KTable: 같은 토픽도 해석에 따라 이벤트 이력(KStream)이 되거나 현재 스냅샷(KTable)이 된다. 조인 의미가 완전히 달라진다.
  • EOS-V2: 트랜잭션 단위 커밋으로 처리량 20% 감소를 감수하고 정확성을 얻는다. 단, 보장 범위는 Kafka 토픽 간에 한정된다.
  • Outbox Pattern: 분산 시스템의 원자성 문제를 DB 트랜잭션으로 우회한다. Debezium이 코드 대신 DB 변경을 Kafka로 전달한다.

Kafka Streams의 설계 결정들은 “Kafka 클러스터를 처리 인프라로 그대로 재활용한다”는 원칙의 다른 표현이다. 파티션 구조를 Task로 투영하고, 토픽을 상태 복구 매체로 쓰고, 트랜잭션을 처리 보장에 활용한다.