카프카 입문 시리즈 7편: Kafka Streams API 동작 원리
이 글은 Apache Kafka 입문 시리즈의 일곱 번째 글입니다. 1~6편에서 카프카의 구성 요소와 메시지가 흐르는 방식, 그리고 안전한 복제 메커니즘까지 살펴보았습니다. 이번 편에서는 카프카 위에서 데이터를 가공하고 집계하는 라이브러리인 Kafka Streams API의 동작 원리를 들여다봅니다.
왜 Streams API가 필요한가?
이전 편들에서 본 카프카는 메시지를 저장하고 전달하는 데 충실한 시스템입니다. 프로듀서가 토픽에 발행하면 컨슈머가 가져가는 단방향 흐름입니다.
그런데 실제 서비스에서는 단순히 메시지를 받아서 처리하는 것 이상이 필요할 때가 많습니다.
- 결제 이벤트 스트림에서 5분간 결제 실패가 N건 이상이면 알림을 보내고 싶다
- 주문 스트림과 사용자 정보 테이블을 JOIN해서 풍부한 이벤트를 만들고 싶다
- 로그인 시도 스트림을 사용자별로 모아 세션 단위 통계를 내고 싶다
Consumer API만으로 이런 처리를 직접 구현하려면 상태 관리, 윈도우 집계, 장애 복구, 파티션 재분배를 모두 손으로 짜야 합니다. 게다가 처리 결과를 다시 다른 토픽으로 보내려면 Producer까지 연동해야 합니다.
Kafka Streams는 이 모든 것을 라이브러리 한 줄 추가로 해결하기 위해 만들어진 클라이언트 측 라이브러리입니다. 별도의 처리 클러스터 없이, 카프카 클러스터 + 일반 자바 애플리케이션만으로 스트림 처리를 할 수 있게 해줍니다.
핵심은 Streams 애플리케이션도 결국 평범한 JVM 프로세스라는 점입니다. 별도의 마스터/워커 노드가 없고, 인스턴스를 더 띄우면 그만큼 처리량이 늘어나는 구조입니다.
핵심 추상화: Stream과 Table
Kafka Streams를 이해하려면 먼저 두 가지 데이터 모델을 머릿속에 그려야 합니다.
KStream — 사건의 흐름
KStream은 레코드 스트림을 추상화한 타입입니다. 각 레코드는 독립된 사건이며, 같은 키가 와도 이전 레코드를 덮어쓰지 않습니다. 모든 레코드는 INSERT로 해석됩니다.
은행 거래 로그를 떠올려 보면 됩니다. "2026-05-09 alice가 1만원 입금"과 "2026-05-09 alice가 5천원 출금"은 둘 다 사실이며, 뒤에 온 사건이 앞 사건을 무효화하지 않습니다.
KTable — 최신 상태
KTable은 변경 로그 스트림(changelog stream) 을 추상화합니다. 각 레코드의 값은 같은 키에 대한 UPDATE로 해석됩니다. 같은 키의 새 레코드가 오면 이전 값을 덮어씁니다.
사용자 프로필 토픽이 좋은 예입니다. (alice, "Seoul")이 들어왔다가 나중에 (alice, "Busan")이 들어오면, 테이블의 현재 상태는 alice → "Busan"입니다.
KTable의 데이터는 KStream과 똑같이 토픽에서 읽어오지만, 내부적으로는 로컬 상태 저장소에 키별 최신 값을 유지합니다.
GlobalKTable — 모든 인스턴스에 전체 복사
GlobalKTable은 KTable과 의미는 같지만, 모든 애플리케이션 인스턴스가 토픽의 전체 파티션을 읽어 자기 안에 통째로 복사해 둡니다.
저장 공간과 네트워크 비용이 늘어나는 대신, KStream과 GlobalKTable을 JOIN할 때 키 재분배(repartition) 없이 곧바로 룩업이 가능합니다. 그래서 GlobalKTable은 보통 거의 변하지 않는 룩업 데이터(국가 코드 매핑, 상품 카테고리 마스터 등)에 사용합니다.
스트림-테이블 이중성
이 둘은 사실 같은 데이터의 두 가지 표현입니다. KTable의 변경 이력을 펼치면 KStream이 되고, KStream을 같은 키 기준으로 누적·집계하면 KTable이 됩니다. 이 이중성(duality)은 Kafka Streams의 모든 연산이 자연스럽게 결합되는 이유이기도 합니다.
| 구분 | KStream | KTable | GlobalKTable |
| 의미 | 사건의 연속 (INSERT) | 키별 최신 상태 (UPDATE) | 키별 최신 상태 (전체 복제) |
| 같은 키 처리 | 모두 보존 | 덮어쓰기 | 덮어쓰기 |
| 파티션 분배 | 인스턴스별 분담 | 인스턴스별 분담 | 모든 인스턴스가 전체 보유 |
| 대표 사용처 | 거래 로그, 클릭 이벤트 | 사용자 상태, 재고 수량 | 국가 코드, 카테고리 마스터 |
토폴로지: 처리 흐름의 그래프
KStream.filter().mapValues().to(...) 같은 코드를 작성해도, Streams 라이브러리는 이를 한 줄씩 즉시 실행하지 않습니다. 대신 프로세서 토폴로지(processor topology) 라는 그래프를 만들어 두고, 애플리케이션이 시작될 때 이 그래프를 따라 데이터가 흐르도록 실행합니다.
토폴로지는 세 종류의 노드로 구성됩니다.
- Source Processor: 입력 토픽에서 레코드를 읽어 토폴로지에 흘려 보냅니다.
- Stream Processor: 변환·필터·조인·집계 같은 실제 처리 로직을 수행합니다.
- Sink Processor: 결과를 출력 토픽에 씁니다.
Kafka Streams는 두 가지 방식으로 토폴로지를 정의할 수 있게 해줍니다.
Streams DSL (고수준)
KStream, KTable, GlobalKTable을 직접 다루는 선언적 API입니다. filter, map, groupByKey, aggregate, windowedBy, join 같은 연산이 메서드 체이닝으로 제공됩니다. 대부분의 스트림 처리 시나리오는 DSL로 충분히 표현할 수 있습니다.
KStream<String, Order> orders = builder.stream("orders");
KTable<String, User> users = builder.table("users");
orders
.filter((key, order) -> order.getAmount() > 0)
.join(users, (order, user) -> enrich(order, user))
.to("enriched-orders");
Processor API (저수준)
Processor 인터페이스를 직접 구현하고, 토폴로지에 노드와 엣지를 수동으로 연결하는 명령형 API입니다. 상태 저장소와도 직접 상호작용합니다. DSL이 표현하기 어려운 복잡한 로직, 커스텀 집계, 외부 시스템 연동이 필요할 때 사용합니다.
DSL은 내부적으로 Processor API 위에 구축되어 있어, 두 API를 한 토폴로지 안에서 섞어 쓸 수도 있습니다 (KStream.process(), KStream.transform() 등).
실행 단위: Partition, Task, Thread, Instance
토폴로지를 정의하는 것이 "무엇을 할지"라면, 그것을 어떻게 병렬로 실행하는지가 동작 원리의 핵심입니다. Streams는 네 단계 계층으로 병렬성을 표현합니다.
Stream Partition
입력 토픽의 각 파티션이 곧 스트림 파티션입니다. 이 파티션 수가 이후 모든 병렬성의 상한선입니다.
Stream Task
작업의 최소 단위입니다. 하나의 태스크는 하나의 입력 파티션 집합을 책임집니다. 태스크 수는 입력 토픽들 중 가장 파티션이 많은 토픽의 파티션 수로 결정됩니다.
입력 토픽 파티션이 6개라면 → 태스크는 항상 6개입니다. 인스턴스를 몇 개 띄우든 변하지 않습니다.
태스크는 자기 파티션에 대한 토폴로지 인스턴스 + 로컬 상태 저장소(있다면)를 들고 있는 독립 실행 단위입니다. 같은 키의 레코드는 항상 같은 파티션 → 같은 태스크로 라우팅되므로, 태스크 내부에서 키별 상태를 안전하게 유지할 수 있습니다.
Stream Thread
스레드는 태스크를 돌리는 실행 컨텍스트입니다. 한 인스턴스 안에서 num.stream.threads 개수만큼 스레드가 만들어지고, 각 스레드가 1개 이상의 태스크를 라운드로빈처럼 처리합니다. 스레드 간에 공유 상태가 없어 락이나 동기화가 필요 없습니다.
Application Instance
JVM 프로세스 하나가 한 인스턴스입니다. 같은 application.id를 가진 인스턴스들이 자동으로 협력하여 태스크를 나눠 가집니다. 이 협력 메커니즘은 컨슈머 그룹과 동일한 리밸런싱 프로토콜을 사용합니다 (5편 참고).
이 모델의 미덕은 수평 확장이 자명하다는 점입니다. 처리량이 부족하면 같은 application.id로 인스턴스를 더 띄우면 됩니다. 새 인스턴스가 합류하면 리밸런싱이 일어나 태스크가 자동으로 재분배됩니다. 인스턴스가 죽으면 그 인스턴스가 들고 있던 태스크가 살아 있는 다른 인스턴스로 이동합니다.
다만 태스크 수 = 입력 파티션 수이므로, 인스턴스나 스레드를 파티션 수보다 많이 늘려도 일부는 놀게 됩니다. 처음 토픽을 설계할 때 향후 처리량을 예상하고 파티션 수를 잡는 것이 그래서 중요합니다.
상태 저장소: 카프카가 곧 디스크다
count(), aggregate(), windowedBy(), KTable을 사용하는 모든 연산은 상태(state) 가 필요합니다. 카프카는 이 상태를 어떻게 안전하게 보관할까요?
로컬 상태: RocksDB
각 태스크는 자기 파티션의 상태를 로컬 RocksDB에 저장합니다. RocksDB는 임베디드 키-값 저장소로, 같은 JVM 프로세스 내에서 디스크와 메모리를 함께 활용합니다. 기본 InMemoryKeyValueStore도 선택할 수 있지만 운영에서는 보통 RocksDB를 씁니다.
JVM 힙 외부에 데이터를 두므로 GC 영향 없이 수 GB의 상태를 캐싱할 수 있고, 프로세스를 재시작해도 디스크에 남아 있던 데이터를 즉시 사용할 수 있습니다.
원격 백업: Changelog Topic
로컬 디스크는 인스턴스가 죽거나 다른 머신으로 옮겨가면 사라집니다. 그래서 Streams는 모든 상태 변경을 changelog 토픽에 함께 기록합니다.
이 changelog 토픽은 로그 컴팩션(log compaction) 이 적용되어 키별 최신 값만 유지합니다. 그래서 무한히 커지지 않으면서도 "현재 상태로 복원하기에 충분한 정보"를 보존합니다.
장애로 태스크가 다른 인스턴스로 이동하면, 새 인스턴스는 changelog를 처음부터 읽어 RocksDB를 재구축한 뒤 처리를 이어갑니다. 카프카 토픽 자체가 이미 복제(6편의 ISR)로 보호되고 있으므로, 상태 역시 카프카 클러스터의 안전성에 자연스럽게 올라탑니다.
빠른 복구: Standby Replica
changelog 재생도 상태가 크면 수십 분이 걸릴 수 있습니다. num.standby.replicas 옵션을 켜면, 다른 인스턴스가 활성 태스크의 changelog를 미리 따라 읽어 자기 RocksDB에 복사해 둡니다. 처리는 하지 않고 상태만 동기화합니다.
활성 인스턴스가 죽으면 standby가 곧바로 active로 승격되어, 거의 따라잡힌 상태에서 처리를 재개합니다. 다운타임이 분 단위에서 초 단위로 줄어듭니다.
시간과 윈도우
스트림 처리에서 "5분간"은 어떤 5분일까요? 이벤트가 발생한 시각인가, 처리한 시각인가? Kafka Streams는 세 종류의 시간을 구분합니다.
- Event Time: 이벤트가 실제로 발생한 시점 (메시지 페이로드나 타임스탬프 추출기로 결정)
- Processing Time: 스트림 애플리케이션이 그 이벤트를 처리한 시점
- Ingestion Time: 브로커가 토픽에 메시지를 받은 시점
윈도우 집계의 의미는 어떤 시간을 쓰느냐에 따라 크게 달라집니다. 일반적으로 이벤트 시간을 기준으로 하는 것이 도메인 정확성 측면에서 권장됩니다.
Kafka Streams DSL은 네 종류의 윈도우를 제공합니다.
Tumbling Window — 고정 크기, 겹침 없음
연속된 고정 크기 윈도우. 이벤트는 정확히 하나의 윈도우에만 속합니다.
용도: "5분 단위 매출 집계", "분당 에러 수".
Hopping Window — 고정 크기, 겹침 있음
크기는 고정이지만 일정 간격으로 새 윈도우가 시작됩니다. 한 이벤트가 여러 윈도우에 중복 포함될 수 있습니다.
용도: "5분마다 갱신되는 10분 이동 평균".
Sliding Window — 이벤트 기준 후방 회고
각 이벤트를 기준으로 직전 N분의 데이터를 모읍니다. 이벤트가 들어올 때만 윈도우가 갱신됩니다.
용도: "이벤트 발생 시점 기준 직전 1시간 CTR".
Session Window — 활동성 기반
고정된 시간 길이 대신 비활동 간격(inactivity gap) 으로 윈도우 경계를 정합니다. 같은 키의 이벤트가 gap보다 짧은 간격으로 연이어 오면 같은 세션으로 묶이고, gap을 넘어 끊기면 세션이 종료됩니다.
용도: "사용자 브라우징 세션", "기기 연결 세션".
Grace Period
이벤트는 네트워크 지연 등으로 늦게 도착할 수 있습니다. Tumbling/Hopping/Sliding 윈도우는 grace period를 설정해, 윈도우 종료 후에도 그 시간 동안은 늦게 도착한 이벤트를 받아들입니다. 세션 윈도우는 사용자 행동에 의해 정의되므로 grace period 개념이 없습니다.
처리 보장: exactly-once-v2
Streams 애플리케이션 한 번의 작업은 보통 세 가지 효과를 동시에 일으킵니다.
- 입력 토픽의 오프셋을 진행시킴
- 상태 저장소(=changelog)를 갱신함
- 출력 토픽에 결과를 발행함
이 셋이 일부만 성공하면 중복이나 손실이 생깁니다. 예를 들어 출력은 발행됐는데 오프셋 커밋 직전에 죽으면, 재시작 후 같은 입력을 다시 처리해 출력이 두 번 나갑니다.
Kafka Streams는 processing.guarantee=exactly_once_v2 설정으로 이 세 작업을 카프카 트랜잭션 안에 묶어 원자적으로 처리합니다. Kafka 2.5에서 도입된 v2는 이전 EOS의 성능 한계를 개선한 권장 설정입니다.
이 모드를 켜면 다음이 자동으로 보장됩니다.
- 출력 토픽의 메시지는 정확히 한 번만 다운스트림에 보입니다 (소비자 측에서
isolation.level=read_committed필요). - 상태 저장소 갱신과 출력 발행, 오프셋 커밋이 함께 커밋되거나 함께 롤백됩니다.
다만 한 가지 한계는 분명합니다. 카프카 트랜잭션은 카프카 안에서만 유효합니다. 처리 도중에 외부 DB나 HTTP 호출을 하면 그 외부 작업까지 트랜잭션에 포함되지는 않으므로, 외부 시스템과의 정합성은 별도 패턴(idempotent write, outbox 등)으로 풀어야 합니다.
정리
이번 글에서는 Kafka Streams API의 동작 원리를 살펴보았습니다.
- KStream / KTable / GlobalKTable로 이벤트 흐름과 키별 상태를 모델링합니다
- 처리 로직은 토폴로지 그래프로 표현되며, DSL과 Processor API 두 층위가 있습니다
- 입력 파티션이 태스크가 되고, 태스크는 스레드 → 인스턴스 계층에서 자동 분산됩니다
- 상태는 로컬 RocksDB + 카프카 changelog 토픽으로 보관되어, 장애 시 복원 가능합니다
- 이벤트 시간 + 다양한 윈도우로 시간 기반 집계를 표현합니다
- exactly-once-v2로 입력 오프셋, 상태, 출력 발행을 카프카 트랜잭션 안에서 묶습니다
다음 편에서는 Kafka Connect를 다루며, 외부 시스템과 카프카를 코드 없이 연결하는 또 다른 컴포넌트를 살펴보겠습니다.
부록: Q&A
Q1. Streams API와 Consumer + Producer를 직접 쓰는 것의 차이는?
답변 보기
기능적으로는 Consumer + Producer로도 같은 결과를 만들 수 있습니다. 하지만 다음을 직접 구현해야 합니다. - 상태 관리: 키별 집계나 JOIN을 위한 로컬 저장소 + 그 저장소의 백업/복원 - 시간/윈도우: 늦은 이벤트, grace period, 윈도우 만료 처리 - 장애 복구: 인스턴스 추가/제거 시 상태의 재배치 - 트랜잭션: 입력 커밋 + 출력 발행을 원자화 Streams는 이 모두를 라이브러리 수준에서 묶어 제공합니다. 단순 ETL이라면 Consumer + Producer로 충분하지만, 키별 상태나 JOIN, 윈도우 집계가 필요한 순간 Streams 쪽이 압도적으로 효율적입니다.Q2. Streams 애플리케이션을 운영하려면 별도 클러스터가 필요한가?
답변 보기
필요 없습니다. Streams는 라이브러리이지 별도의 처리 엔진이 아닙니다. Spark이나 Flink처럼 마스터/워커 클러스터를 따로 운영하지 않습니다. 필요한 것은 두 가지입니다. 1. 일반적인 카프카 클러스터 2. 같은application.id로 실행되는 JVM 프로세스 N개
이 JVM 프로세스들은 일반 마이크로서비스 배포 방식 그대로 (k8s deployment, EC2 인스턴스, ECS task 등) 운영할 수 있습니다. 인스턴스를 늘리면 카프카의 컨슈머 그룹 리밸런싱이 태스크를 자동으로 재분배합니다.
Q3. KTable과 GlobalKTable은 언제 어떤 것을 써야 하는가?
답변 보기
판단 기준은 데이터 크기, JOIN 시 키 일치 여부, 변경 빈도입니다. - 데이터가 작고 거의 변하지 않으며, JOIN 시 KStream과 키가 일치하지 않을 수 있다면 → GlobalKTable - 데이터가 크고 자주 변하며, KStream과 같은 키로 JOIN할 수 있다면 → KTable GlobalKTable은 모든 인스턴스가 전체 데이터를 들고 있으므로, 1GB 단위 이상의 데이터에 쓰면 메모리/디스크 비용이 빠르게 부담됩니다. 반대로 KTable은 인스턴스별로 일부 파티션만 보유하므로 데이터가 커도 수평 확장이 가능합니다. 룩업 테이블이 작고 정적이라면 GlobalKTable이 코드도 단순하고 repartition도 피할 수 있어 유리합니다.Q4. 태스크 수는 왜 입력 파티션 수와 같은가?
답변 보기
같은 키를 가진 레코드는 카프카 단계에서 이미 같은 파티션으로 라우팅됩니다 (2편 참고). Streams가 키별 상태를 안전하게 유지하려면 같은 키의 모든 레코드가 같은 태스크로 가야 합니다. 가장 단순한 보장 방법이 "한 파티션을 한 태스크가 전담한다"입니다. 그래서 태스크 수는 입력 토픽들 중 가장 파티션이 많은 토픽의 파티션 수로 고정됩니다. 이 매핑은 태스크 ID가 부여된 후 변하지 않습니다. 인스턴스가 추가되거나 죽어도 "어떤 파티션이 어떤 태스크"인지는 그대로이고, 단지 그 태스크가 어느 인스턴스에서 실행되는지만 바뀝니다.Q5. 상태가 큰 애플리케이션이 죽으면 복구가 오래 걸리지 않는가?
답변 보기
기본적으로는 그렇습니다. changelog 토픽을 처음부터 읽어 RocksDB를 다시 만들어야 하므로, 상태가 수십 GB 단위면 분 단위 다운타임이 발생할 수 있습니다. 이를 줄이는 표준 옵션이 두 가지입니다. 1. Standby Replica:num.standby.replicas=1 이상을 설정하면, 다른 인스턴스가 백그라운드에서 changelog를 미리 따라 읽어 RocksDB를 동기화해 둡니다. 장애 시 거의 따라잡힌 상태로 즉시 인계받습니다.
2. State Directory 보존: 인스턴스가 같은 머신에서 재시작될 경우, RocksDB 디렉토리가 남아 있다면 그 지점부터 changelog만 따라잡으면 되므로 빠르게 복구됩니다.
운영에서는 보통 Standby Replica를 1~2로 두어 가용성을 확보합니다.
Q6. exactly-once-v2를 켜면 성능에 어떤 영향이 있는가?
답변 보기
exactly-once-v2는 카프카 트랜잭션을 사용하므로 추가 오버헤드가 있습니다. 매 커밋 주기마다 트랜잭션 시작/커밋 메타데이터가 기록되고, 컨슈머는 read_committed 모드로 동작해 트랜잭션이 끝날 때까지 메시지를 읽지 못합니다. 체감 영향은 두 축입니다. - 처리량: 일반적으로 at-least-once 대비 5~20% 정도 낮아집니다 (워크로드에 따라 다름). - 종단 지연(end-to-end latency):commit.interval.ms 만큼 증가합니다. 기본값은 100ms이지만, 더 높이면 처리량을 회복할 수 있는 대신 지연이 늘어납니다.
금융이나 결제처럼 정합성이 중요한 영역이라면 이 비용은 충분히 감수할 가치가 있습니다. 반대로 단순 텔레메트리 집계라면 at-least-once + 멱등 소비자 패턴이 더 가성비가 좋을 수 있습니다.
참고자료
- Apache Kafka Streams Core Concepts (공식 문서)
- Apache Kafka Streams Architecture (공식 문서)
- Kafka Streams Concepts — Confluent Documentation
- Kafka Streams Architecture — Confluent Documentation
- Tasks, Threads, and Instances in Kafka Streams — Confluent Developer
- Changelogs and Standbys with Kafka Streams — Confluent Developer
- Defining Windows in Kafka Streams — Confluent Developer
- Windowing in Kafka Streams — Confluent Blog
- KIP-447: Producer scalability for exactly once semantics

