Kafka Streams 완벽 가이드 — 실시간 스트림 처리·KTable·윈도우·EOS

Kafka Streams 완벽 가이드 — 실시간 스트림 처리·KTable·윈도우·EOS

이 글의 핵심

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

이 글의 핵심

Apache Kafka Streams는 별도의 스트림 처리 클러스터 없이, Kafka 클러스터와 애플리케이션 프로세스만으로 이벤트 스트림을 변환·집계·조인할 수 있게 하는 자바·스칼라 라이브러리입니다. 마이크로서비스 경계 안에서 실시간 지표, 세션 분석, 사기 탐지 룰의 전처리 등에 널리 쓰입니다.

이 가이드에서는 다음을 다룹니다.

  • 핵심 개념: 토폴로지, 프로세서, 태스크, 스트림·테이블 추상화
  • KStream / KTable / GlobalKTable의 의미와 선택 기준
  • 상태 저장소(로컬 RocksDB 등)와 체인지로그 기반 복구
  • 윈도우: Tumbling, Hopping, Session(및 Sliding 개념)
  • 조인·집계 패턴과 파티션·키 설계
  • 정확히 한 번 처리(EOS)의 전제와 설정
  • 실시간 분석 시스템 아키텍처 예시

Kafka 기본 개념(토픽, 파티션, 컨슈머 그룹)은 익숙하다고 가정합니다. 필요하면 Kafka 완벽 가이드를 먼저 보시기 바랍니다.


1. Kafka Streams의 위치와 핵심 개념

1.1 왜 Kafka Streams인가

전통적으로는 Apache Flink, Spark Streaming 같은 별도 클러스터에 잡을 올려 처리합니다. 반면 Kafka Streams는 애플리케이션 라이브러리로 embed되며, 스케일아웃은 애플리케이션 인스턴스 수로 조절합니다. 운영 모델이 단순해지는 대신, 상태·로컬 디스크·재처리 정책은 개발·운영팀이 명확히 이해해야 합니다.

1.2 토폴로지(Topology)와 노드

Streams 애플리케이션은 연산 그래프로 표현됩니다. 소스(Kafka topic 구독), 프로세서(map, filter, join 등), 싱크(결과를 다시 Kafka 등으로 기록)로 구성됩니다. 내부적으로는 KStream·KTable DSL이 이 그래프로 컴파일됩니다.

처리 보장은 “at-least-once가 기본”이며, 브로커·클라이언트 설정에 따라 exactly-once를 선택할 수 있습니다(후술).

1.3 스레드, 태스크, 파티션

애플리케이션 인스턴스는 하나 이상의 스레드로 실행됩니다. 입력 토픽의 파티션스트림 태스크에 할당되고, 태스크는 스레드에서 돌아갑니다. 파티션 수 ≥ 병렬도 상한이므로, 처리량을 늘리려면 토픽 파티션 수와 인스턴스·스레드 수를 함께 설계해야 합니다.

1.4 리파티션(Repartition)

키를 바꾸는 연산(map으로 키 변경, groupBy 등) 뒤에는 같은 키가 같은 파티션으로 모이도록 내부 리파티션 토픽이 생깁니다. 조인·집계 전에 키 설계와 파티션 수를 맞추지 않으면 비용과 지연이 커집니다.

1.5 DSL과 Processor API

대부분의 애플리케이션은 Streams DSL(KStream, KTable)로 작성합니다. Processor API는 저수준 노드·상태 스토어를 직접 연결해야 할 때 사용합니다. 유지보수와 가독성을 위해 DSL로 가능한지 먼저 검토하고, 커스텀 타임스탬프·복잡한 제어 흐름이 필요할 때만 Processor로 내립니다.

1.6 대화형 질의(Interactive Queries)

상태 저장소에 들어 있는 키-값 뷰를 애플리케이션에서 REST 등으로 노출하면 실시간 대시보드디버깅에 활용할 수 있습니다. IQ(Interactive Queries)는 메타데이터로 “어느 인스턴스가 어느 파티션을 담당하는지”를 찾아 라우팅합니다. 스케일아웃 시 일관된 읽기 경로캐시 무효화 전략을 함께 설계해야 합니다.


2. KStream vs KTable vs GlobalKTable

2.1 KStream: 이벤트 스트림

KStream은 (키, 값) 레코드의 불변 이벤트 로그입니다. 각 레코드는 한 번의 사실(클릭, 주문 생성 등)을 나타냅니다. “키당 하나”가 아니라 동일 키에 여러 이벤트가 올 수 있습니다.

적합한 경우: 시계열 분석, 세션 추적, 모든 이벤트를 살려야 하는 집계 전 단계.

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

KTable은 동일 키에 대한 업스트림 changelog를 해석해, 키별 최신 값을 나타내는 뷰입니다. null 값은 삭제(tombstone)로 해석됩니다. 시간 의존 조인 등에서는 레코드 타임스탬프가 중요합니다.

적합한 경우: 사용자 프로필, 상품 마스터, “최신 상태만 필요”한 디멘션.

2.3 GlobalKTable: 전 파티션 복제

GlobalKTable작은 참조 데이터모든 인스턴스가 전체 파티션을 읽어 로컬에 복제합니다. 조인 시 태스크별로 KTable 파티션 일치가 필요 없어, 키 기준 stream-table join에서 유연합니다. 대신 메모리·복제 비용이 크므로 대용량 사실 테이블에는 부적합합니다.

2.4 선택 요약

추상화의미주 용도
KStream이벤트 나열로그, 시계열, 다중 이벤트
KTable키당 최신 상태스냅샷·디멘션
GlobalKTable소형 전역 테이블룩업·비대칭 조인

3. 상태 저장소와 상태 관리

3.1 로컬 상태와 RocksDB

윈도우 집계, join, aggregate는 상태가 필요합니다. Kafka Streams는 기본적으로 로컬 키-값 저장소(주로 RocksDB)에 상태를 유지합니다. 읽기는 로컬에서 빠르게 이뤄지며, 변경분은 내부 changelog 토픽에 기록되어 장애 시 재복원에 쓰입니다.

3.2 상태 복구와 standby

인스턴스가 죽으면 파티션이 재할당되고, 새 태스크는 changelog에서 상태를 리플레이해 복구합니다. standby replica를 두면 장애 시 복구 시간을 줄일 수 있습니다(설정·리소스 트레이드오프).

3.3 상태 크기와 디스크

상태가 무한정 커지지 않도록 retention·윈도우 만료·suppress 등으로 출력과 저장을 제어해야 합니다. 장기 보관이 필요하면 싱크 토픽이나 외부 DB로 내보내는 패턴을 씁니다.


4. 윈도우 연산

시간 윈도우는 이벤트 시간(TimestampExtractor)과 진행(소스의 워터마크에 가까운 개념으로 StreamsConfigmax.task.idle.ms 등)에 영향을 받습니다. 운영에서는 지연 이벤트가 윈도 밖으로 밀리면 누락될 수 있음을 반드시 고려합니다.

4.1 Tumbling(고정·비중첩)

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

4.2 Hopping(고정·중첩)

윈도 길이이동 간격(advance)이 다릅니다. 예: 길이 10분, 2분마다 이동 → 겹치는 구간으로 더 촘촘한 보고가 가능합니다. 결과 레코드 수가 늘어납니다.

4.3 Session(세션)

같은 키에 대해 갭(interval) 이내 이벤트를 묶어 세션을 형성합니다. 사용자 행동 세션, 클릭 스트림 분석에 적합합니다. 세션 윈도우는 크기가 가변입니다.

4.4 Sliding과 실무 팁

문서·버전에 따라 Sliding 정의가 혼동되기 쉽습니다. Kafka Streams에서는 시간 윈도우 API를 위 중심으로 이해하고, “슬라이딩에 가까운” 분석은 hopping 또는 KStream 연속 처리로 모델링하는 경우가 많습니다.

grace period(허용 지연)를 두면 늦게 도착한 이벤트를 일정 범위까지 반영할 수 있습니다.

4.5 코드로 보는 Tumbling과 Hopping

TumblingTimeWindows.of(Duration)grace만 주면 고정 크기·비중첩 윈도가 됩니다. Hopping은 같은 크기의 윈도에 advanceBy로 이동 간격을 짧게 하여 겹치는 윈도를 만듭니다. 사용 중인 kafka-streams 버전에 따라 import·메서드명이 다를 수 있으므로 릴리스 노트를 확인하세요.

import java.time.Duration;
import org.apache.kafka.streams.kstream.TimeWindows;

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

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

첫 번째 블록은 5분 단위 건수를, 두 번째는 같은 시간대를 여러 번 겹쳐 집계해 보고 밀도를 높입니다. Hopping은 결과 레코드 수가 늘므로 싱크 토픽·대시보드 부하를 함께 고려해야 합니다.


5. 집계

groupBy / groupByKeyaggregate, reduce, count 등을 사용합니다. 테이블과 스트림의 의미에 따라 KTable로 누적 상태가 되거나 윈도우 집계 결과가 KStream으로 나갑니다.

집계 시 유의할 점:

  • 키 스키마: 직렬화 포맷(JSON, Avro, Protobuf 등)을 조인·집계 전후로 통일
  • null 키: groupBy 전에 필터링하거나 기본값 정책 수립
  • 중복 제거: 소스가 at-least-once이면 멱등 키다운스트림 중복 제거 설계가 필요할 수 있음

6. 조인 연산

6.1 Stream-Stream Join

시간 윈도우 내에서만 매칭됩니다. 양쪽 이벤트가 같은 윈도에 들어와야 하므로 윈도 크기·grace가 곧 “얼마나 기다려 매칭할지”를 결정합니다.

// 개념 예: 좌·우 스트림을 허용 시간 차이 내에서 inner join
import java.time.Duration;
import org.apache.kafka.streams.kstream.JoinWindows;
import org.apache.kafka.streams.kstream.StreamJoined;

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

JoinWindows windows = JoinWindows.of(Duration.ofMinutes(5))
    .grace(Duration.ofMinutes(1));

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

JoinWindows시간차·grace는 비즈니스적으로 “매칭 가능한 최대 시각 차이”를 정의합니다. 너무 좁으면 미스 매칭, 너무 넓으면 상태·지연이 커집니다.

6.2 Stream-Table Join

KStream 이벤트가 도착할 때 KTable의 현재 값과 조인합니다. 업데이트 순서가 비즈니스에 영향을 줄 수 있어, “테이블이 먼저인지 스트림이 먼저인지”를 시나리오별로 검증해야 합니다.

6.3 Table-Table Join

양쪽 changelog를 맞추며 키 기준으로 조인합니다. 동일 파티셔닝(같은 파티션 수·같은 키 해시)이 필요합니다.

6.4 GlobalKTable과 Stream

GlobalKTable은 스트림 쪽만 파티션되면 되어 소형 디멘션 룩업에 유리합니다.

6.5 공동 파티셔닝

조인하는 토픽은 가능한 한 파티션 수와 키 전략을 맞춥니다. 맞지 않으면 through()동일 파티션 수의 토픽에 재발행하거나, 설계 단계에서 토픽을 통일합니다.


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

7.1 의미

EOS는 “장애 후 재시도해도 결과가 중복 집계·중복 기록되지 않는다”에 가깝게 동작함을 목표로 합니다. 완전한 수학적 “정확히 한 번”보다는 트랜잭션적 원자성 + 멱등에 가까운 의미로 이해하는 것이 실무에 안전합니다.

7.2 전제 조건

  • 브로커 버전이 트랜잭션 프로듀서를 지원
  • processing.guaranteeexactly_once 계열(버전에 따라 exactly_once_v2 등)로 설정
  • 읽기·쓰기 토픽의 replication·min.insync.replicas 등이 데이터 손실 없이 운영 가능한 수준

7.3 성능과 운영

EOS는 지연·처리 비용이 증가할 수 있습니다. 외부 시스템(예: 임의 DB 업데이트)까지 포함한 엔드투엔드 EOS는 Kafka만으로 자동 해결되지 않습니다. 멱등 프로듀서·아웃박스 패턴·외부 저장소 유일 키 등으로 보완하는 경우가 많습니다.

7.4 설정 예시(개념)

애플리케이션 속성에 처리 보장트랜잭션 ID 접두사를 지정합니다. 클러스터 버전에 따라 허용 값이 다르므로 공식 문서의 매트릭스를 따릅니다.

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);

EXACTLY_ONCE_V2트랜잭션 프로듀서와 연동되는 경로입니다. 브로커 transaction.state.log.*, min.insync.replicas 등이 튜닝되어 있지 않으면 운영 중 트랜잭션 실패로 이어질 수 있습니다.


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

아래는 전자상거래 클릭스트림을 받아 세션별 체류·전환 힌트를 만들어 집계 토픽으로 내보내는 개략적 구조입니다.

[ click-events (Kafka) ]


┌───────────────────────┐
│  Kafka Streams 앱 A    │
│  - 이벤트 파싱·키=userId │
│  - Session window      │
│  - 집계: 이벤트 수·첫/끝 │
└───────────┬───────────┘
            │ changelog / 출력 토픽

┌───────────────────────┐
│  Kafka Topics          │
│  session-metrics        │
└───────────┬───────────┘

     ┌──────┴──────┐
     ▼             ▼
 [대시보드]    [다운스트림 ML 피처]
 (Consumer)     (별도 파이프라인)

설계 포인트:

  1. 입력 토픽: 파티션 키를 userId 등으로 통일해 순서가 필요한 사용자 단위 처리를 보장
  2. 세션 윈도우 + grace: 모바일 네트워크 지연을 반영한 grace 설정
  3. 상태 모니터링: 로컬 상태 크기, consumer lag, 리밸런싱 알람
  4. 출력: 실시간 지표는 집계 토픽, 장기 보관은 싱크 커넥터로 DWH

8.1 코드 스케치(Java)

아래는 의도를 보여주는 최소 예시입니다. 프로덕션에서는 Serde, 설정, 에러 처리, 테스트를 보강해야 합니다.

// 개념 예시: 세션 윈도우 후 이벤트 수 집계 (의사 코드에 가깝게 단순화)
StreamsBuilder builder = new StreamsBuilder();
KStream<String, ClickEvent> clicks = builder.stream("click-events",
    Consumed.with(Serdes.String(), clickEventSerde));

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

설명: SessionWindows로 비활동 간격을 정의하고, 세션 단위로 count를 냅니다. toStream()으로 윈도우 결과를 다시 스트림으로 펼쳐 출력합니다. 실제로는 grace, suppress(출력 빈도 제어), TimestampExtractor를 함께 다룹니다.


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

  • TopologyTestDriver: 단위 테스트에서 토폴로지를 시간 제어하며 검증
  • 로그: 리밸런싱, 상태 복구 시간이 길면 lag 급증과 연관되는지 확인
  • 버전 업그레이드: Kafka 브로커·클라이언트·EOS 설정의 호환 매트릭스 확인
  • 재처리: 소스가 재전송되면 집계가 중복될 수 있음 → sink 단 멱등 키 또는 정확히 한 번 범위 명확화

10. 정리

Kafka Streams는 “Kafka를 이미 쓰는 팀”이 실시간 변환·집계·조인을 같은 운영 모델로 확장하기 좋은 선택입니다. 핵심은 KStream/KTable 선택, 키·파티션 설계, 상태·윈도우·지연, EOS 범위를 설계 문서에 명시하고, 테스트와 모니터링으로 검증하는 것입니다.


FAQ (요약)

Q. Flink 대신 Kafka Streams를 쓰면 안 되나요?
A. 상태 크기·복잡한 이벤트 시간 처리·SQL·동적 스케일 요구가 크면 Flink 등이 유리할 수 있습니다. Kafka에 강하게 결합된 마이크로서비스 내부 처리에는 Streams가 단순합니다.

Q. KTable 조인 시 데이터가 늦게 오면?
A. Stream-Table join은 도착 순서에 민감할 수 있습니다. 비즈니스 규칙에 따라 버퍼링, 지연 허용, 재처리 파이프라인을 검토합니다.

Q. 상태 저장소 디스크가 가득 찼어요.
A. 윈도 retention, changelog 정책, 불필요한 상태 연산, 외부 싱크 분리를 점검합니다. 근본적으로는 키 카디널리티·보존 기간 설계 문제인 경우가 많습니다.