Kafka Streams로 실시간 처리하기: KStream·KTable, 상태 저장소, 윈도우 집계, 조인, EOS

이 글의 핵심

Apache Kafka Streams는 Kafka 위에서 동작하는 클라이언트 라이브러리로, 이벤트 스트림에 대해 상태 기반 변환·집계·조인·윈도우 연산을 수행합니다. 이 글에서는 KStream과 KTable의 차이, 상태 저장소와 장애 복구, 윈도 유형별 특성, 조인·집계 패턴, 정확히 한 번 처리(EOS), 그리고 실시간 분석 시스템을 설계할 때의 실무 관점을 정리합니다.

Apache Kafka Streams는 별도의 스트림 처리 클러스터 없이 Kafka 클러스터와 애플리케이션 프로세스만으로 이벤트 스트림을 변환, 집계, 조인할 수 있게 해 주는 Java 라이브러리입니다(Scala용 래퍼도 제공됩니다). 마이크로서비스 안에서 실시간 지표, 세션 분석, 사기 탐지 규칙의 전처리 같은 작업에 쓰입니다.

이 글은 Kafka의 토픽, 파티션, 컨슈머 그룹 개념에 익숙하다고 가정합니다. 필요하면 Kafka 입문을 먼저 보시기 바랍니다. 코드는 Kafka Streams 3.x 이후의 API를 기준으로 합니다.


Kafka Streams의 위치와 핵심 개념

왜 Kafka Streams인가

Apache Flink나 Spark Structured Streaming은 별도 클러스터에 잡을 제출해 실행합니다. Kafka Streams는 애플리케이션에 라이브러리로 포함되며, 처리 능력을 늘리려면 같은 애플리케이션 인스턴스를 더 띄우면 됩니다. 인스턴스들은 같은 application.id를 공유하는 컨슈머 그룹으로 묶여 파티션을 나눠 갖습니다. 운영할 클러스터가 하나 줄어드는 대신, 상태가 각 인스턴스의 로컬 디스크에 있다는 점과 재처리 정책은 개발팀이 직접 이해하고 관리해야 합니다.

토폴로지(Topology)와 노드

Streams 애플리케이션은 연산 그래프인 토폴로지로 표현됩니다. 토폴로지는 Kafka 토픽을 읽는 소스, map, filter, join 같은 연산을 수행하는 프로세서, 결과를 Kafka 토픽에 기록하는 싱크로 구성됩니다. DSL로 작성한 KStream/KTable 코드는 내부적으로 이 그래프로 변환됩니다.

처리 보장은 기본값이 at-least-once이고, 설정으로 exactly-once를 선택할 수 있습니다(뒤에서 다룹니다).

스레드, 태스크, 파티션

애플리케이션 인스턴스는 하나 이상의 스트림 스레드(num.stream.threads)로 실행됩니다. 입력 토픽의 파티션마다 스트림 태스크가 하나씩 만들어지고, 태스크가 스레드에 배정됩니다. 태스크 수가 곧 병렬도의 상한이므로, 입력 파티션이 12개라면 모든 인스턴스의 스레드를 합쳐 12개를 넘겨도 남는 스레드는 놀게 됩니다. 처리량 목표가 있다면 토픽 파티션 수부터 함께 설계해야 합니다.

리파티션(Repartition)

selectKey나 키를 바꾸는 map, groupBy처럼 키가 달라지는 연산 뒤에 집계나 조인이 오면, 같은 키가 같은 파티션에 모이도록 Streams가 내부 리파티션 토픽을 만들어 데이터를 다시 씁니다. 이 과정은 브로커를 한 번 더 왕복하므로 비용과 지연이 늘어납니다. 가능하면 입력 토픽을 처음부터 집계·조인에 쓸 키로 파티셔닝하는 편이 낫습니다.

DSL과 Processor API

대부분의 애플리케이션은 Streams DSL(KStream, KTable)로 작성합니다. Processor API는 노드와 상태 저장소를 직접 연결하고, 펑추에이터(punctuator)로 주기적 작업을 예약하는 등 DSL로 표현하기 어려운 제어가 필요할 때 씁니다. 유지보수를 생각하면 DSL로 가능한지 먼저 검토하고, 필요한 부분에만 process()로 Processor API를 섞는 방식이 일반적입니다.

대화형 질의(Interactive Queries)

상태 저장소의 키-값 내용을 애플리케이션이 REST 등으로 노출하면 실시간 대시보드나 디버깅에 활용할 수 있습니다. 각 인스턴스는 자기가 맡은 파티션의 상태만 갖고 있으므로, 대화형 질의(Interactive Queries)는 메타데이터 API(queryMetadataForKey)로 해당 키를 가진 인스턴스를 찾아 요청을 그쪽으로 전달해야 합니다. 리밸런싱 중에는 담당 인스턴스가 바뀌므로 조회 실패를 재시도하는 경로도 함께 만들어야 합니다.


KStream vs KTable vs GlobalKTable

KStream: 이벤트 스트림

KStream은 (키, 값) 레코드의 불변 이벤트 로그입니다. 각 레코드는 클릭이나 주문 생성처럼 한 번 일어난 사실을 나타내며, 같은 키로 여러 이벤트가 올 수 있고 모두 의미가 있습니다. 시계열 분석, 세션 추적, 집계하기 전의 원본 이벤트 처리에 맞습니다.

KTable: 업데이트 가능한 changelog 스트림

KTable은 같은 키의 레코드를 “이전 값을 덮어쓰는 갱신”으로 해석해 키별 최신 값을 나타내는 뷰입니다. 값이 null인 레코드는 해당 키의 삭제(tombstone)로 해석됩니다. 사용자 프로필, 상품 마스터처럼 최신 상태만 필요한 차원 데이터에 맞습니다.

GlobalKTable: 전 파티션 복제

GlobalKTable은 모든 인스턴스가 토픽의 전체 파티션을 읽어 각자 로컬 저장소에 전체 사본을 둡니다. 그래서 스트림과 조인할 때 파티션을 맞출 필요가 없고, 스트림 레코드의 값에서 조인 키를 뽑아내는 조인(KeyValueMapper 사용)도 리파티션 없이 할 수 있습니다. 대신 인스턴스마다 전체 데이터를 디스크에 두고, 시작할 때 전체를 읽어 들여야 하므로 큰 테이블에는 맞지 않습니다.

선택 요약

추상화의미주 용도
KStream이벤트 나열로그, 시계열, 다중 이벤트
KTable키당 최신 상태스냅샷, 차원 데이터
GlobalKTable전체 사본을 가진 작은 테이블룩업, 값 기반 키 조인

상태 저장소와 상태 관리

로컬 상태와 RocksDB

윈도우 집계, 조인, aggregate는 상태가 필요합니다. Kafka Streams는 기본적으로 로컬 키-값 저장소(기본 구현은 RocksDB)에 상태를 둡니다. 읽기는 로컬에서 처리되므로 빠르고, 변경분은 내부 changelog 토픽에도 기록되어 장애가 나면 이 토픽으로 상태를 복원합니다. changelog 토픽은 컴팩션(윈도우 저장소는 컴팩션과 삭제 정책 병행)으로 관리되어 키별 최신 값만 남습니다.

상태 복구와 standby

인스턴스가 죽으면 그 태스크가 다른 인스턴스로 재할당되고, 새 태스크는 changelog 토픽을 처음부터 읽어 상태를 다시 만듭니다. 상태가 크면 이 복원에 오래 걸리고, 그동안 해당 파티션의 처리가 멈춥니다. num.standby.replicas로 standby 복제본을 두면 다른 인스턴스가 changelog를 미리 따라 읽어 두므로 장애 시 복구 시간이 줄어듭니다. 대신 디스크와 네트워크 사용량이 그만큼 늘어납니다.

상태 크기와 디스크

상태가 무한정 커지지 않도록 윈도우 보존 기간과 grace를 적절히 정하고, 필요 없는 키는 tombstone으로 지워야 합니다. suppress는 출력 빈도를 줄이는 연산이지 상태 크기를 줄이는 연산은 아니라는 점에 주의합니다. 장기 보관이 필요한 결과는 출력 토픽과 커넥터를 통해 외부 저장소로 내보냅니다.


윈도우 연산

Kafka Streams의 시간 윈도우는 레코드의 이벤트 시간(TimestampExtractor가 정하며 기본은 레코드 타임스탬프)을 기준으로 합니다. 각 태스크는 지금까지 본 가장 큰 타임스탬프를 “스트림 시간”으로 삼고, 스트림 시간이 윈도우 종료 시각과 grace를 지나면 그 윈도우를 닫습니다. 그 뒤에 도착한 레코드는 버려지므로, 지연 이벤트가 많은 환경에서는 grace를 충분히 두어야 합니다. 참고로 max.task.idle.ms는 여러 입력 파티션을 함께 처리할 때 비어 있는 파티션의 데이터를 얼마나 기다릴지 정하는 설정으로, 조인 순서를 맞추는 데 쓰입니다.

Tumbling(고정·비중첩)

구간 길이가 고정이고 윈도우가 겹치지 않습니다. “5분마다 페이지뷰 수” 같은 집계에 흔히 씁니다.

Hopping(고정·중첩)

윈도우 길이와 이동 간격(advance)이 다릅니다. 예를 들어 길이 10분, 이동 간격 2분이면 하나의 레코드가 다섯 개의 윈도우에 속하게 됩니다. 더 촘촘한 보고가 가능하지만 결과 레코드와 상태가 그만큼 늘어납니다.

Session(세션)

같은 키의 이벤트 사이 간격이 정해진 비활동 시간(gap) 이내이면 하나의 세션으로 묶습니다. 사용자 행동 세션이나 클릭 스트림 분석에 맞고, 세션 윈도우는 크기가 가변입니다.

Sliding

Kafka 2.7부터 SlidingWindows가 추가되었습니다. 고정된 시각 경계에 맞춰 윈도우를 미리 만드는 hopping과 달리, 레코드 타임스탬프 사이의 차이가 정해진 값 이내인 레코드들을 한 윈도우로 묶어 “최근 N분” 집계를 필요한 만큼의 윈도우로 계산합니다. 이동 간격을 아주 짧게 둔 hopping 윈도우로 같은 효과를 내려 하면 윈도우 수가 폭증하므로 이 경우 sliding이 유리합니다. 스트림-스트림 조인에서 쓰는 JoinWindows도 레코드 간 시간 차이를 기준으로 하는 슬라이딩 방식입니다.

코드로 보는 Tumbling과 Hopping

Kafka 3.0부터 TimeWindows.of(...).grace(...) 대신 grace를 명시적으로 정하는 ofSizeAndGrace/ofSizeWithNoGrace를 쓰도록 바뀌었습니다. 이전 메서드는 deprecated 상태입니다.

import java.time.Duration;
import org.apache.kafka.common.serialization.Serdes;
import org.apache.kafka.streams.kstream.Materialized;
import org.apache.kafka.streams.kstream.TimeWindows;

// Tumbling: 5분 윈도우, 허용 지연(grace) 1분
stream
    .groupByKey()
    .windowedBy(TimeWindows.ofSizeAndGrace(Duration.ofMinutes(5), Duration.ofMinutes(1)))
    .count();

// Hopping: 10분 윈도우를 2분마다 이동 (중첩)
stream
    .groupByKey()
    .windowedBy(
        TimeWindows.ofSizeAndGrace(Duration.ofMinutes(10), Duration.ofMinutes(1))
            .advanceBy(Duration.ofMinutes(2))
    )
    .aggregate(
        () -> 0L,
        (key, value, agg) -> agg + 1L,
        Materialized.with(Serdes.String(), Serdes.Long())
    );

첫 번째 블록은 5분 단위 건수를, 두 번째는 겹치는 10분 윈도우마다 건수를 집계합니다. 기본적으로 윈도우 집계 결과는 레코드가 들어올 때마다(캐시 플러시 시점마다) 갱신된 값이 계속 내보내지므로, 윈도우가 닫힌 뒤 최종값 한 번만 받고 싶다면 suppress(Suppressed.untilWindowCloses(...))를 붙입니다.


집계

groupBy 또는 groupByKey 뒤에 aggregate, reduce, count를 씁니다. 윈도우 없이 집계하면 결과는 KTable<K, V>가 되고, 윈도우를 쓰면 KTable<Windowed<K>, V>가 됩니다. 결과를 토픽에 내보내려면 toStream()으로 바꿉니다.

집계 키의 직렬화 형식(JSON, Avro, Protobuf 등)은 집계와 조인의 앞뒤에서 같아야 합니다. 같은 키라도 직렬화된 바이트가 다르면 다른 파티션, 다른 키로 취급되기 때문입니다. 키가 null인 레코드는 groupByKey 집계와 조인에서 버려지므로, 의도치 않게 데이터가 빠지지 않도록 앞단에서 필터링하거나 기본 키를 정합니다. 소스가 at-least-once로 같은 이벤트를 중복 전달할 수 있다면, 이벤트 ID 기반 중복 제거나 다운스트림의 멱등 처리를 함께 설계해야 합니다.


조인 연산

Stream-Stream Join

두 스트림의 레코드는 타임스탬프 차이가 정해진 범위 안일 때만 매칭됩니다. 양쪽 레코드를 그 범위만큼 상태에 보관해야 하므로, 윈도우 크기와 grace가 곧 “얼마나 기다려 매칭할지”와 “상태를 얼마나 둘지”를 결정합니다.

import java.time.Duration;
import org.apache.kafka.common.serialization.Serdes;
import org.apache.kafka.streams.kstream.JoinWindows;
import org.apache.kafka.streams.kstream.KStream;
import org.apache.kafka.streams.kstream.StreamJoined;

KStream<String, Left> left = builder.stream("left-topic");
KStream<String, Right> right = builder.stream("right-topic");

// 양쪽 타임스탬프 차이 5분 이내, 지연 도착 1분 허용
JoinWindows windows = JoinWindows.ofTimeDifferenceAndGrace(
    Duration.ofMinutes(5), Duration.ofMinutes(1));

left.join(
    right,
    (l, r) -> new JoinedValue(l, r),
    windows,
    StreamJoined.with(Serdes.String(), leftSerde, rightSerde)
);

JoinWindows.of(...)도 3.0에서 deprecated되어 위처럼 grace를 명시하는 팩토리 메서드를 씁니다. 시간 차이를 너무 좁게 잡으면 매칭이 빠지고, 너무 넓게 잡으면 상태와 지연이 커집니다.

Stream-Table Join

KStream 레코드가 도착하는 순간의 KTable 값과 조인합니다. 스트림 쪽 레코드만 조인을 트리거하고, 테이블 갱신은 이후 스트림 레코드에만 반영됩니다. 그래서 테이블 데이터가 늦게 들어오면 그 사이의 스트림 레코드는 inner join에서 빠지거나 left join에서 null과 결합됩니다. 테이블이 먼저 채워져야 하는 경우라면 애플리케이션 시작 시점과 데이터 도착 순서를 검증해야 합니다.

Table-Table Join

양쪽 테이블 중 어느 쪽이 갱신되어도 결과 테이블이 갱신됩니다. 키 기준 조인은 두 토픽이 공동 파티셔닝되어 있어야 합니다. Kafka 2.4부터는 한쪽 값에서 뽑은 외래 키로 조인하는 foreign-key join도 지원하며, 이 경우 Streams가 필요한 리파티션을 내부적으로 처리합니다.

GlobalKTable과 Stream

GlobalKTable은 전체 사본이 각 인스턴스에 있으므로 스트림 쪽만 파티셔닝되어 있으면 되고, 작은 차원 테이블 룩업에 유리합니다.

공동 파티셔닝

키 기준 조인을 하는 두 토픽은 파티션 수가 같고 같은 파티셔너로 같은 키를 같은 파티션에 넣어야 합니다. Streams는 파티션 수가 다르면 시작 시점에 오류를 냅니다. 맞지 않는다면 repartition()으로 원하는 파티션 수의 내부 토픽에 다시 쓰거나, 설계 단계에서 토픽을 맞춥니다. 예전에 쓰던 through()는 2.6에서 deprecated되었고 4.0에서 제거되었습니다.


정확히 한 번 처리(Exactly-Once, EOS)

의미

Kafka Streams의 EOS는 “입력 오프셋 커밋, 상태 저장소 changelog 기록, 출력 토픽 기록”을 하나의 Kafka 트랜잭션으로 묶습니다. 장애로 재처리가 일어나도 커밋되지 않은 트랜잭션의 결과는 read_committed 컨슈머에게 보이지 않으므로, Kafka 안에서는 결과가 중복 반영되지 않습니다. 보장 범위가 “Kafka에서 읽어 Kafka로 쓰는 경로”라는 점이 핵심입니다.

전제 조건

processing.guarantee를 exactly_once_v2로 설정합니다. exactly_once_v2는 브로커 2.5 이상이 필요하고, 이전의 exactly_once(v1)와 exactly_once_beta는 3.0에서 deprecated되어 4.0에서 제거되었습니다. 트랜잭션 상태를 담는 브로커 내부 토픽은 기본적으로 복제 계수 3, min.insync.replicas 2를 요구하므로, 브로커가 3대 미만인 개발 환경에서는 transaction.state.log.replication.factor와 transaction.state.log.min.isr을 낮춰야 트랜잭션이 동작합니다.

성능과 운영

EOS를 켜면 트랜잭션 커밋 비용이 생기고, 기본 commit.interval.ms가 30초에서 100ms로 바뀌어 커밋이 잦아집니다. 다운스트림 컨슈머는 isolation.level=read_committed로 읽어야 커밋되지 않은 결과를 보지 않습니다. 외부 DB 업데이트처럼 Kafka 밖의 부수 효과는 이 트랜잭션에 포함되지 않으므로, 외부 시스템까지 포함한 정확히 한 번 처리는 멱등 키, 유니크 제약, 아웃박스 패턴 같은 방법으로 따로 보완해야 합니다.

설정 예시

Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "my-analytics-app");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka:9092");
props.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG, StreamsConfig.EXACTLY_ONCE_V2);
props.put(StreamsConfig.NUM_STANDBY_REPLICAS_CONFIG, 1);

transactional.id는 Streams가 application.id를 바탕으로 자동으로 정하므로 직접 지정하지 않습니다.


실전: 실시간 분석 시스템 예시

아래는 전자상거래 클릭스트림을 받아 사용자 세션별 지표를 만들어 집계 토픽으로 내보내는 구조입니다.

[ click-events (Kafka) ]
        │
        ▼
┌─────────────────────────┐
│  Kafka Streams 앱        │
│  - 이벤트 파싱, 키=userId │
│  - Session window        │
│  - 집계: 이벤트 수, 첫/끝 │
└───────────┬─────────────┘
            │ 출력 토픽
            ▼
┌─────────────────────────┐
│  session-metrics         │
└───────────┬─────────────┘
            │
     ┌──────┴──────┐
     ▼             ▼
 [대시보드]    [ML 피처 파이프라인]
 (Consumer)    (별도 파이프라인)

입력 토픽은 userId로 파티셔닝해 사용자 단위 처리가 리파티션 없이 한 태스크에서 순서대로 일어나게 합니다. 모바일 클라이언트는 이벤트를 모아 늦게 보내는 경우가 많으므로 세션 윈도우에 grace를 둡니다. 운영 중에는 로컬 상태 크기, consumer lag, 리밸런싱과 상태 복원 시간을 지켜봐야 하고, 장기 보관용 데이터는 싱크 커넥터로 데이터 웨어하우스에 넘깁니다.

코드 스케치(Java)

StreamsBuilder builder = new StreamsBuilder();
KStream<String, ClickEvent> clicks = builder.stream("click-events",
    Consumed.with(Serdes.String(), clickEventSerde));

clicks
    .groupByKey()
    .windowedBy(SessionWindows.ofInactivityGapAndGrace(
        Duration.ofMinutes(30), Duration.ofMinutes(5)))
    .count()
    .toStream()
    .to("session-click-counts",
        Produced.with(WindowedSerdes.sessionWindowedSerdeFrom(String.class), Serdes.Long()));

SessionWindows로 30분 비활동 간격을 정의하고 세션마다 이벤트 수를 셉니다. 출력 키는 Windowed<String>이며, 세션 윈도우의 키에는 시작과 끝 시각이 모두 들어가야 하므로 sessionWindowedSerdeFrom을 씁니다. 시간 윈도우용 timeWindowedSerdeFrom을 쓰면 세션 종료 시각이 직렬화되지 않습니다.

세션 윈도우는 새 이벤트가 오면 기존 세션과 합쳐지므로, 출력 토픽에는 이전 세션을 지우는 tombstone(값이 null)과 합쳐진 새 세션 레코드가 함께 나타납니다. 다운스트림 컨슈머는 null 값을 처리할 수 있어야 하고, 최종 세션만 받고 싶다면 suppress(Suppressed.untilWindowCloses(...))를 붙입니다.


테스트·운영·트러블슈팅

TopologyTestDriver를 쓰면 브로커 없이 토폴로지에 레코드를 넣고 시간을 직접 진행시키며 윈도우 동작까지 단위 테스트할 수 있습니다. 운영에서 lag이 갑자기 늘면 리밸런싱과 상태 복원이 그 시점에 있었는지부터 확인합니다. 상태 복원이 길다면 standby 복제본이나 정적 멤버십(group.instance.id)으로 불필요한 리밸런싱을 줄이는 방법을 검토합니다. 버전을 올릴 때는 브로커와 클라이언트, EOS 설정의 호환성을 릴리스 노트에서 확인합니다. 소스를 처음부터 재처리하면 at-least-once 설정에서는 집계가 중복될 수 있으므로, 출력 쪽 멱등 키를 두거나 EOS가 보장하는 범위를 명확히 해 둡니다.


운영 중 자주 만나는 질문

상태가 매우 크거나, 복잡한 이벤트 시간 처리와 SQL, 자원 단위의 동적 확장이 필요하면 Flink가 유리할 수 있습니다. 입력과 출력이 모두 Kafka이고 마이크로서비스 안에서 처리하는 경우라면 Kafka Streams가 운영이 단순합니다.

KTable 조인에서 데이터가 늦게 오면 어떻게 되나요?

Stream-Table 조인은 스트림 레코드가 도착한 시점의 테이블 값만 봅니다. 늦게 들어온 테이블 값은 이미 지나간 스트림 레코드에 소급 적용되지 않으므로, 비즈니스 규칙에 따라 버퍼링, 지연 허용, 재처리 파이프라인을 검토합니다.

상태 저장소 디스크가 가득 찼습니다.

윈도우 보존 기간, changelog 정책, 불필요한 상태 연산을 점검합니다. 근본적으로는 키 카디널리티와 보존 기간 설계의 문제인 경우가 많습니다.


같이 보면 좋은 글