"실시간 스트림 처리가 필요하다는 건 알겠어요. 그런데 어떤 기술을 써야 할까요?"
"Apache Flink, Spark Streaming, Kafka Streams... 너무 많은 선택지가 있어서 헷갈려요."
"Flink가 좋다고 하는데, 정확히 어떤 점이 좋은 건가요?"
목차
1. 들어가며
2. 실시간 스트림 처리 기술 비교
2.1. 스트림 처리의 요구사항
2.2. 주요 스트림 처리 프레임워크 비교
2.3. 왜 Apache Flink 인가?
3. Apache Flink 아키텍처 분석
3.1. Flink의 핵심 개념
3.2. 데이터 플로우 그래프와 DAG, 실행 계획
3.3. Flink 런타임 아키텍처
마치며 / Reference
1. 들어가며
이전 글 "실시간 데이터 처리 [1/3] - 배치 그리고 스트림"에서 우리는 실시간 스트림 처리의 필요성과 개념적 배경을 살펴봤습니다. 매일 수많은 데이터가 실시간으로 발생하고, 이런 데이터들을 어떻게 활용할 수 있을지에 대한 고민으로 시작했죠.
커머스 서비스에서는 "방금 인기가 급상승한 상품을 바로 추천에 반영할 순 없을까?", "새로운 상품의 성과를 실시간으로 확인하고 싶은데...", "프로모션 효과를 보면서 바로 전략을 수정하고 싶어요"와 같은 요구사항이 늘어나고 있습니다.
전통적인 배치 처리 방식은 데이터를 일정 기간 모아두었다가 한꺼번에 처리합니다. 이는 마치 강물을 댐에 가두었다가 한꺼번에 처리하는 것과 비슷하죠. 안정적이고 효율적이지만, 실시간성이 중요한 요구사항들이 늘어나면서 한계도 분명해졌습니다.
반면, 실시간 스트림 처리는 데이터를 '흐름' 그 자체로 바라보고, 댐에 가두지 않고 흐르는 강물을 따라 실시간으로 처리하는 방식입니다. 이를 통해 데이터가 생성되는 즉시 처리하여 즉각적인 인사이트와 신속한 대응이 가능해집니다.
이번 글에서는 실시간 스트림 처리를 구현하기 위한 기술, 특히 Apache Flink에 초점을 맞춰보겠습니다. 다양한 스트림 처리 기술들을 비교하고, Flink의 내부 아키텍처와 동작 원리를 코드 레벨에서 심층적으로 분석하며, 간단한 Flink 애플리케이션 예시를 살펴보겠습니다.
이 글을 통해 "왜 Flink인가?, Flink는 뭐지?” 에 대한 생각을 공유 드리려 합니다.
2. 실시간 스트림 처리 기술 비교
실시간 스트림 처리를 위한 다양한 기술들이 존재합니다. 각 기술은 서로 다른 접근 방식과 특징을 가지고 있어, 특정 사용 사례에 더 적합할 수 있습니다. 주요 프레임워크들을 비교한 후, Apache Flink가 어떤 점에서 차별화되는지 알아보겠습니다.
2.1. 스트림 처리의 요구사항
2.1.1. 높은 처리량과 낮은 지연시간
실시간 처리의 가장 기본적인 요구사항은 대량의 데이터를 빠르게 처리해야 합니다.
처리량(Throughput): 단위 시간당 처리할 수 있는 이벤트의 수입니다. 예를 들어, 대규모 전자상거래 플랫폼은 초당 수만 건의 클릭 이벤트를 처리해야 할 수 있습니다.
지연시간(Latency): 이벤트가 발생한 시점부터 처리 완료까지 걸리는 시간입니다. 실시간 사기 탐지와 같은 애플리케이션에서는 밀리초 단위의 지연시간이 중요할 수 있습니다.
이상적인 스트림 처리 시스템은 높은 처리량과 낮은 지연시간을 동시에 제공해야 합니다. 하지만 실제로는 이 둘 사이에 트레이드오프가 존재하는 경우가 많습니다.
2.1.2. 정확히 한 번(exactly-once) 처리
데이터 처리의 신뢰성은 비즈니스 애플리케이션에서 매우 중요합니다. 스트림 처리 시스템은 다음과 같은 처리 보장 수준을 제공할 수 있습니다.
- 최대 한 번(at-most-once): 각 이벤트는 한 번 이하로 처리됩니다. 이벤트 손실이 발생할 수 있지만, 중복 처리는 없습니다.
- 최소 한 번(at-least-once): 각 이벤트는 한 번 이상 처리됩니다. 이벤트 손실은 없지만, 중복 처리가 발생할 수 있습니다.
- 정확히 한 번(exactly-once): 각 이벤트는 정확히 한 번만 처리됩니다. 이벤트 손실이나 중복 처리가 없습니다.

금융 거래나 결제 처리와 같은 중요한 애플리케이션에서는 "정확히 한 번" 처리 보장이 필수적입니다. 하지만 이를 구현하는 것은 기술적으로 복잡하며, 모든 스트림 처리 시스템이 이를 지원하는 것은 아닙니다.
2.1.3. 상태 관리와 장애 복구
대부분의 실제 스트림 처리 애플리케이션은 상태를 유지해야 합니다. 예를 들어, 시간 윈도우 집계, 패턴 감지, 또는 머신러닝 모델 업데이트와 같은 작업은 모두 상태 정보를 필요로 합니다.
아래와 같은 상태 관리 기능을 제공해야 합니다.
- 확장 가능한 상태 저장: 메모리 내 상태부터 대규모 디스크 기반 상태까지 다양한 크기의 상태를 효율적으로 관리할 수 있어야 합니다.
- 내결함성: 시스템 장애 발생 시 상태 정보를 잃지 않고 복구할 수 있어야 합니다.
- 일관된 상태 관리: 분산 환경에서도 일관된 상태 관리를 보장해야 합니다.
2.1.4. 이벤트 시간 처리
실제 세계에서 이벤트가 발생한 시간(이벤트 시간)과 시스템에 도착한 시간(처리 시간)은 다를 수 있습니다. 네트워크 지연, 일시적인 연결 문제, 또는 모바일 장치의 오프라인 작동 등으로 인해 이벤트가 순서대로 도착하지 않을 수 있습니다.
아래와 같은 시간 처리 기능을 제공해야 합니다.
- 이벤트 시간 기반 처리: 이벤트가 실제로 발생한 시간을 기준으로 처리할 수 있어야 합니다.
- 워터마크(Watermark): 지연된 이벤트를 처리하기 위한 메커니즘을 제공해야 합니다.
- 시간 윈도우 연산: 시간 기반 집계 및 분석을 지원해야 합니다.
2.2. 주요 스트림 처리 프레임워크 비교
현재 데이터 스트리밍 생태계에는 다양한 프레임워크가 있습니다만 대표(?)적인 4가지 기술을 비교해보겠습니다.
스트림 처리 기술 비교
특징/이름 | Apache Flink | Apache Spark Streaming | Apache Kafka Streams | Apache Storm |
처리 모델 | 스트림 처리 | 마이크로 배치 | 스트림 처리 | 스트림 처리 |
지연시간 | 매우 낮음 (밀리초 단위) | 중간 (초 단위) | 낮음 | 매우 낮음 |
처리량 | 매우 높음 | 높음 | 중간 | 높음 |
핵심 개념 | DataStream API, ProcessFunction, 윈도우 API, 상태 백엔드, 체크포인팅 | DStream, 마이크로 배치, RDD, Structured Streaming | KStream, KTable, Processor API, DSL | Topology, Spout, Bolt, Tuple |
상태 관리 | 강력함 (메모리, RocksDB 등 다양한 백엔드 지원) | 제한적 (RDD와 체크포인트) | 로컬 상태 저장소 (RocksDB 기반) | 제한적 (Trident 필요) |
처리 보장 | 정확히 한 번 | 정확히 한 번 | 정확히 한 번 | 최소 한 번 (기본), Trident로 정확히 한 번 가능 |
이벤트 시간 처리 | 강력한 지원 (이벤트 시간, 처리 시간, 수집 시간, 워터마크) | 지원 (Spark 2.0+) | 지원 | 제한적 |
윈도우 연산 | 다양한 윈도우 타입 지원 (텀블링, 슬라이딩, 세션 등) | 지원 | 지원 | 제한적 |
API 수준 | 저수준 + 고수준 (DataStream, Table API, SQL) | 주로 고수준 (DataFrame, SQL) | 중간 수준 (DSL, Processor API) | 저수준 |
언어 지원 | Java, Scala, Python | Java, Scala, Python, R | Java, Kotlin | Java, Python, Ruby 등 다양한 언어 |
통합 생태계 | 중간 | 매우 풍부함 (MLlib, GraphX, Spark SQL) | Kafka 중심 | 제한적 |
설정 복잡성 | 중간~높음 | 중간 | 낮음 (일반 Java 앱으로 실행) | 중간 |
장점 | - 진정한 스트림 처리 - 매우 낮은 지연시간 - 강력한 상태 관리 - 정확히 한 번 처리 - 고급 시간 처리 - 배치와 스트림 통합 |
- Spark 생태계 통합 - 배치와 스트림 코드 통합 - 풍부한 라이브러리 - 강력한 커뮤니티 |
- 간편한 설정과 배포 - Kafka와의 통합 -정확히 한 번 처리 - 로컬 상태 저장소 |
-매우 낮은 지연시간 - 간단한 프로그래밍 모델 - 다양한 언어 지원 |
단점 | -복잡한 설정 및 배포 - 메모리 튜닝 필요 |
- 마이크로 배치로 인한 높은 지연시간 - 진정한 이벤트별 처리 아님 - 복잡한 상태 관리 - 높은 메모리 사용량 |
- Kafka 의존성 - 제한된 커넥터 - 복잡한 처리에 제한적 - 낮은 처리량 |
- 기본적으로 최소 한 번 처리 - 제한된 상태 관리 - 이벤트 시간 처리 부족 - 복잡한 연산에 제한적 |
주요 사용 사례 | - 복잡한 이벤트 처리 - 실시간 분석 및 대시보드 - 이상 탐지 - 시간에 민감한 처리 |
- 배치+스트림 통합 처리 - ML 파이프라인 - 복잡한 분석 작업 - 지연 허용 애플리케이션 |
- Kafka 기반 마이크로서비스 - 스트림-테이블 조인 - 이벤트 기반 애플리케이션 - 간단한 데이터 변환 |
- 실시간 알림 - 간단한 스트림 처리 - 매우 낮은 지연시간 필요 애플리케이션 |
2.3. 왜 Apache Flink 인가?
위 비교표에서 볼 수 있듯이, 각 프레임워크는 저마다의 강점과 약점을 가지고 있습니다. 케바케이죠.! 그렇다면 어떤 상황에서 Flink를 선택해야 할까요?
2.3.1. Flink만의 특징
진정한 스트리밍 아키텍처
Flink는 처음부터 스트림 처리를 위해 설계되었습니다. 마이크로 배치 방식이 아닌, 각 이벤트를 개별적으로 처리하는 진정한 스트리밍 모델을 사용합니다. 이는 매우 낮은 지연시간(밀리초 단위)을 가능하게 합니다.
강력한 상태 관리
Flink는 분산 환경에서 일관된 상태 관리를 위한 고급 기능을 제공합니다. 메모리 내 상태, RocksDB 기반 상태, 커스텀 상태 백엔드 등 다양한 옵션을 지원하며, 체크포인팅 메커니즘을 통해 장애 발생 시에도 정확히 한 번 처리를 보장합니다.
고급 시간 처리
Flink는 이벤트 시간, 처리 시간, 수집 시간 등 다양한 시간 개념을 지원합니다. 특히 이벤트 시간 처리와 워터마크 메커니즘은 지연된 데이터를 정확하게 처리할 수 있게 해줍니다. 이는 시간에 민감한 애플리케이션에서 중요한 요소입니다.
2.3.2. 적합한 사용 예시
금융 서비스
- 실시간 사기 탐지
- 알고리즘 트레이딩
- 리스크 분석 및 규제 준수
전자상거래 및 리테일
- 실시간 재고 관리
- 개인화된 추천
- 동적 가격 책정
통신 및 네트워크
- 네트워크 모니터링 및 이상 탐지
- 통화 데이터 레코드(CDR) 처리
- 고객 경험 분석
제조 및 IoT
- 센서 데이터 처리 및 분석
- 예측 유지보수
- 품질 관리 및 공정 최적화
디지털 광고
- 실시간 입찰(RTB)
- 광고 성과 분석
- 사용자 행동 추적
Flink는 실시간 데이터 처리의 핵심 엔진으로 활용되고 있습니다. 특히 낮은 지연시간, 높은 처리량, 정확히 한 번 처리 보장이 중요한 애플리케이션에 궁합이 좋습니다.
3. Apache Flink 아키텍처 분석
Flink의 핵심 개념, 데이터 플로우 그래프, 런타임 아키텍처를 간단히 소스 코드 레벨에서 살펴보려 합니다.
3.1. Flink의 핵심 개념
Flink의 개념 3가지가 있습니다.
- 데이터플로우 프로그래밍 모델
- 스트림과 변환
- 시간 개념: 이벤트 시간, 처리 시간, 수집 시간
3.1.1. 데이터플로우 프로그래밍 모델
Flink는 데이터플로우 프로그래밍 모델을 기반으로 합니다. 이 모델에서 프로그램은 데이터 소스, 변환(transformation), 그리고 데이터 싱크로 구성된 방향성 그래프(directed graph)로 표현됩니다.
// 기본적인 Flink 데이터 흐름 예시
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 데이터 소스
DataStream<String> source = env.addSource(new FlinkKafkaConsumer<>("topic", new SimpleStringSchema(), properties));
// 변환 (transformation)
DataStream<Word> words = source
.flatMap((String line, Collector<Word> out) -> {
for (String word : line.split(" ")) {
out.collect(new Word(word, 1));
}
})
.returns(TypeInformation.of(Word.class));
DataStream<Word> wordCounts = words
.keyBy(word -> word.word)
.window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
.reduce((a, b) -> new Word(a.word, a.count + b.count));
// 데이터 싱크
wordCounts.addSink(new FlinkKafkaProducer<>("output-topic", new WordSchema(), properties));
// 실행
env.execute("Word Count Example");
위 예시에서 볼 수 있듯이, Flink 프로그램은 데이터 소스(Kafka 토픽)에서 데이터를 읽어와 일련의 변환(단어 분리, 집계)을 적용한 후, 결과를 데이터 싱크(다른 Kafka 토픽)에 쓰는 형태로 구성됩니다.
3.1.2. 스트림과 변환(Transformations)
Flink의 기본 데이터 추상화는 DataStream입니다. DataStream은 시간에 따라 연속적으로 도착하는 데이터 요소의 시퀀스를 나타냅니다. DataStream은 불변(immutable)이며, 변환 연산자를 적용하면 새로운 DataStream이 생성됩니다.
DataStream을 변환할 수 있는데요.
다양한 변환 연산자를 제공합니다.
- 단일 요소 변환: map(), flatMap(), filter()
- 키 기반 변환: keyBy(), reduce(), aggregations
- 다중 스트림 변환: union(), join(), coGroup()
- 분배 변환: shuffle(), broadcast()
- 윈도우 변환: window(), timeWindow(), countWindow()
이러한 변환들은 함수형 인터페이스를 통해 구현되며, 사용자 정의 로직을 쉽게 적용할 수 있습니다. (이런게 있구나 정도만 이해하고 다음 글에서 좀 더 자세히 살펴보겠습니다.)
3.1.3. 시간 개념: 이벤트 시간, 처리 시간, 수집 시간
Flink는 세 가지 다른 시간 개념을 지원합니다.
- 이벤트 시간(Event Time): 이벤트가 실제로 발생한 시간
- 처리 시간(Processing Time): 이벤트가 처리되는 시간
- 수집 시간(Ingestion Time): 이벤트가 Flink 시스템에 유입된 시간
이벤트 시간 처리는 특히 중요합니다. 실제 세계에서는 이벤트가 발생한 순서와 시스템에 도착하는 순서가 다를 수 있기 때문입니다. Flink는 이벤트 시간 처리를 위해 워터마크(Watermark) 메커니즘을 제공합니다.
// 이벤트 시간 처리 예시
DataStream<Event> events = source
// 이벤트 시간 추출기 설정
.assignTimestampsAndWatermarks(
WatermarkStrategy
.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withTimestampAssigner((event, timestamp) -> event.getTimestamp())
);
// 이벤트 시간 기반 윈도우 처리
events
.keyBy(event -> event.getKey())
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.reduce((a, b) -> a.merge(b))
.addSink(new ResultSink());
위 코드에서는 이벤트가 최대 5초까지 순서가 뒤바뀔 수 있다고 가정하고, 이벤트 시간을 기준으로 1분 단위 윈도우 집계를 수행합니다.
데이터플로우 프로그래밍 모델, 스트림과 변환, 시간 개념을 간단히 살펴보았습니다.
좀 더 자세히 데이터플로우 프로그래밍 모델과 방향성 그래프에 대해 설명드리겠습니다.
3.2. 데이터 플로우 그래프와 DAG, 실행 계획
3.2.1. 논리적 그래프와 실행 그래프의 변환 과정
실행 계획(Execution Plan)의 개념
데이터베이스 시스템에 익숙하신 분들이라면 SQL 쿼리의 실행 계획(Execution Plan)에 대해 들어보셨을 것입니다. 데이터베이스에서 쿼리 옵티마이저는 SQL 문을 분석하여 가장 효율적인 방법으로 데이터를 검색하기 위한 실행 계획을 생성합니다. 예를 들어, 어떤 인덱스를 사용할지, 조인을 어떤 순서로 수행할지 등을 결정합니다. (이전 글: MySQL 실행 계획과 결과 컬럼 설명)
Flink에서도 이와 유사한 개념이 적용됩니다. Flink는 사용자가 정의한 연산들에 대한 "실행 계획(Execution Plan)"을 생성합니다. 이 실행 계획은 데이터 소스에서 시작하여 여러 변환 연산을 거쳐 데이터 싱크로 끝나는 데이터 흐름 그래프(Dataflow Graph)로 표현됩니다.
// 단순한 WordCount (실제 코드는 아니지만 이해를 돕기 위해 간단한 예시)
final val env = ExecutionEnvironment.getExecutionEnvironment();
List<String> lines = Arrays.asList("hello world", "hello flink", "apache flink example");
DataSet<String> text = env.fromCollection(lines);
text.flatMap()
.groupBy()
.sum()
text.sink()
// 결과 예시
4> (hello,1)
4> (hello,2)
11> (flink,1)
11> (apache,1)
11> (flink,2)
7> (example,1)
8> (world,1)
위 코드는 간단한 Flink 프로그램 예시입니다. 이 코드는 내부적으로 다음과 같은 과정을 거쳐 실행 계획으로 변환됩니다.
- 논리적 그래프(Logical Graph): 사용자 코드에서 정의한 연산들을 그래프 형태로 표현
- 데이터 소스: env.fromCollection(lines)
- 변환 연산: flatMap(), groupBy(), sum()
- 데이터 싱크: sink()
- 최적화된 논리적 그래프(Optimized Logical Graph)
- 연산자 체이닝, 병렬화 등의 최적화 적용
- 불필요한 연산 제거
- 실행 그래프(Execution Graph)
- 실제 분산 환경에서 실행하기 위한 형태로 변환
- 태스크 및 태스크 매니저 할당 계획 포함
아래 이미지의 오른쪽 부분은 실제 Flink UI에서 볼 수 있는 실행 계획을 보여줍니다. 여기서 각 노드는 연산자를 나타내며, 노드 간의 화살표는 데이터 흐름을 나타냅니다. 이 예시에서는 다음과 같은 연산자들이 보입니다.

- Data Source → FlatMap → GroupCombine: 데이터 소스에서 읽어온 데이터를 FlatMap으로 변환하고 부분 집계
- GroupReduce: 그룹화된 데이터에 대한 최종 집계 수행
- Data Sink: 결과를 저장
각 연산자에는 병렬도(Parallelism) 정보도 표시되어 있어, 해당 연산이 몇 개의 병렬 태스크로 실행될지 알 수 있습니다.
3.2.2. 위상 정렬과 작업 스케줄링
DAG(Directed Acyclic Graph)의 이해
Flink의 데이터 흐름 그래프는 DAG(Directed Acyclic Graph, 방향성 비순환 그래프)의 형태를 가집니다. DAG는 다음과 같은 특성을 가진 그래프입니다.
- Directed(방향성): 모든 엣지(연결선)는 방향을 가집니다. 즉, 데이터는 한 방향으로만 흐릅니다.
- Acyclic(비순환): 그래프 내에 순환(cycle)이 존재하지 않습니다. 즉, 어떤 노드에서 시작해도 다시 그 노드로 돌아오는 경로가 없습니다.

위 DAG의 예시와 DAG가 아닌 예시를 보여줍니다. 유효한 DAG에서는 Task 1에서 시작하여 여러 경로를 통해 Task 6으로 끝나는 방향성 그래프가 있습니다. 반면, 오른쪽의 두 예시는 순환이 존재하거나 양방향 엣지가 있어 DAG가 아닙니다.
DAG는 Flink뿐만 아니라 Airflow, Apache Spark, Apache Beam 등 다양한 데이터 처리 시스템에서 널리 사용되는 개념입니다. 특히 Airflow에서는 워크플로우를 DAG로 정의하여 태스크 간의 의존성을 표현합니다.
그렇다면 어떻게 DAG를 인식하고, 태스크의 작업 순서를 보장할 수 있을까요?
위상 정렬(Topological Sort)
DAG의 중요한 특성 중 하나는 위상 정렬(Topological Sort)이 가능하다는 것입니다. 위상 정렬은 방향성 그래프의 모든 노드를 선형으로 정렬하되, 모든 엣지의 방향을 유지하는 정렬 방법입니다. 쉽게 말해, 선행 작업이 먼저 오도록 노드를 정렬하는 것입니다.

이를 위상 정렬하면 "1, 2, 3, 6, 7, 5, 4"와 같은 순서가 될 수 있습니다.
위상 정렬은 보통 두 가지로 구현할 수 있습니다.
- DFS(깊이 우선 탐색): 재귀적으로 노드를 탐색하며 후위 순회(post-order traversal)를 수행
- Khan's 알고리즘: 진입 차수(indegree)가 0인 노드부터 시작하여 순차적으로 제거
위상 정렬을 통해 DAG에 대한 순서를 정렬할 수 있습니다.
위 기술들을 통합해서 Flink는 태스크의 실행 순서를 결정하고, 의존성을 고려하여 효율적으로 작업을 스케줄링하게 됩니다.
3.2.3. 소스 코드로 보는 그래프 생성 과정
Flink에서 데이터 흐름 그래프가 생성되는 과정을 실제 소스 코드 레벨에서 간단히 살펴보면
크게 세 단계로 나눌 수 있습니다.
- StreamGraph 생성: 사용자 코드에서 정의한 연산자들을 기반으로 초기 그래프 생성
- JobGraph 변환: StreamGraph를 최적화하여 JobGraph로 변환
- 실행: JobManager에서 JobGraph를 실행 가능한 ExecutionGraph로 변환 후 실행
// Flink 코드 실행 엔트리 포인트 env.execute()
env.execute("Word Count Example");
Flink의 실행 execute 을 타고 들어가면 크게 아래와 같이 볼 수 있습니다.
// StreamExecutionEnvironment 내부 코드 (간략화)
// https://github.com/apache/flink/blob/master/flink-runtime/src/main/java/org/apache/flink/streaming/api/environment/StreamExecutionEnvironment.java#L1838
public JobExecutionResult execute(String jobName) throws Exception {
// StreamGraph 생성
StreamGraph streamGraph = getStreamGraph(jobName);
// 실행
return execute(streamGraph);
}
// getStreamGraph (간략화)
// https://github.com/apache/flink/blob/master/flink-runtime/src/main/java/org/apache/flink/streaming/api/environment/StreamExecutionEnvironment.java#L2034
private StreamGraph getStreamGraph(List<Transformation<?>> transformations) {
return getStreamGraphGenerator(transformations).generate(); // generate()
}
// generate()
// https://github.com/apache/flink/blob/master/flink-runtime/src/main/java/org/apache/flink/streaming/api/graph/StreamGraphGenerator.java#L250
public StreamGraph generate() {
// 생략
transform(transformation); // Node / Edge 분리
return builtStreamGraph;
}
위 코드에서 StreamGraph 생성을 한 뒤 StreamGraph를 최적화하여 JobGraph으로 변경합니다.
// https://github.com/apache/flink/blob/master/flink-runtime/src/main/java/org/apache/flink/streaming/api/graph/StreamGraph.java#L1179
public JobGraph getJobGraph(ClassLoader userClassLoader, @Nullable JobID jobID) {
return StreamingJobGraphGenerator.createJobGraph(userClassLoader, this, jobID);
}
// createJobGraph()
// https://github.com/apache/flink/blob/master/flink-runtime/src/main/java/org/apache/flink/streaming/api/graph/StreamingJobGraphGenerator.java#L142
public static JobGraph createJobGraph(
return new StreamingJobGraphGenerator(
userClassLoader, streamGraph, jobID, serializationExecutor)
.createJobGraph();
}
// https://github.com/apache/flink/blob/master/flink-runtime/src/main/java/org/apache/flink/streaming/api/graph/StreamingJobGraphGenerator.java#L212
private JobGraph createJobGraph() {
}
Stream 특화된 Graph을 JobGraph으로 변경한 뒤 JobGraph는 최종적으로 클러스터에 제출되어 실행됩니다. 이러한 다단계 변환 과정을 통해 Flink는 사용자 코드를 효율적인 분산 실행 계획으로 변환하고, 데이터 의존성을 고려하여 태스크를 스케줄링합니다.
JobGraph에는 위상정렬로 실행 순서를 결정하는 메서드도 있습니다.
아래 메서드는 Kahn's 알고리즘을 사용한 위상정렬을 구현하고 있습니다.
// https://github.com/apache/flink/blob/master/flink-runtime/src/main/java/org/apache/flink/runtime/jobgraph/JobGraph.java#L452
// JobGraph의 JobVertex들을 위상정렬하여 소스(입력이 없는 노드)부터 시작해 실행 순서대로 정렬된 리스트를 반환합니다.
public List<JobVertex> getVerticesSortedTopologicallyFromSources() throws InvalidProgramException {
List<JobVertex> sorted = new ArrayList<JobVertex>(this.taskVertices.size());
Set<JobVertex> remaining = new LinkedHashSet<JobVertex>(this.taskVertices.values());
// start by finding the vertices with no input edges
// and the ones with disconnected inputs (that refer to some standalone data set)
{
Iterator<JobVertex> iter = remaining.iterator();
while (iter.hasNext()) {
JobVertex vertex = iter.next();
if (vertex.isInputVertex()) {
sorted.add(vertex);
iter.remove();
}
}
}
int startNodePos = 0;
// traverse from the nodes that were added until we found all elements
while (!remaining.isEmpty()) {
// first check if we have more candidates to start traversing from. if not, then the
// graph is cyclic, which is not permitted
if (startNodePos >= sorted.size()) {
throw new InvalidProgramException("The job graph is cyclic.");
}
JobVertex current = sorted.get(startNodePos++);
addNodesThatHaveNoNewPredecessors(current, sorted, remaining);
}
return sorted;
}
Flink의 이러한 그래프 기반 실행 모델은 복잡한 데이터 처리 파이프라인을 효율적으로 관리하고 최적화할 수 있게 해주며, Flink가 높은 처리량과 낮은 지연시간을 동시에 달성할 수 있는 메커니즘입니다.
3.3. Flink 런타임 아키텍처
지금까지 Flink 프로그램이 어떻게 데이터 플로우 그래프로 변환되는지 살펴보았습니다.
사용자가 작성한 코드는 StreamGraph로 변환되고, 이는 다시 최적화된 JobGraph로 변환됩니다.
Flink 런타임플로우 아키텍처 전체는 아래와 같습니다.

3.2. 까지 살펴본 내용은 앞 부분인거죠.!

Flink 문법으로 작성된 코드가 최적화 되어 Dataflow Graph 으로 변환되어, 실행 계획이 나왔습니다.
Flink는 이를 어떻게 실행하게 될까요?
3.3.1. Flink 클러스터 구성 요소
Flink 클러스터는 크게 두 가지 주요 구성 요소로 이루어져 있습니다.
- JobManager (마스터): 작업 조정과 관리를 담당합니다.
- 클라이언트로부터 JobGraph를 받아 실행을 조정합니다.
- 태스크 스케줄링과 리소스 할당을 관리합니다.
- 체크포인트 생성을 조정하고 장애 복구를 처리합니다.
- TaskManager (워커): 실제 데이터 처리를 수행합니다.
- 여러 개의 태스크 슬롯을 가지고 있어 병렬 처리가 가능합니다.
- 데이터 스트림을 처리하고 다른 TaskManager와 데이터를 교환합니다.
이 두 구성 요소는 함께 작동하여 데이터 플로우 그래프를 분산 환경에서 효율적으로 실행합니다.
3.3.2. 작업 실행 과정
Flink 작업이 실행되는 전체 과정은 다음과 같습니다.
- 작업 제출: 사용자가 Flink 프로그램을 실행하면, 클라이언트는 프로그램을 데이터 플로우 그래프(JobGraph)로 변환합니다.
- 작업 배포: JobGraph는 JobManager에 제출됩니다. JobManager는 이를 ExecutionGraph로 변환하고, 태스크를 TaskManager에 배포합니다.
- 태스크 실행: TaskManager는 할당받은 태스크를 실행하고, 데이터 스트림을 처리합니다. 태스크 간 데이터 교환은 네트워크를 통해 이루어집니다.
- 상태 관리: 처리 과정에서 생성되는 상태는 로컬에 저장되며, 주기적으로 체크포인트가 생성됩니다.
- 장애 복구: 장애가 발생하면, JobManager는 최근 체크포인트에서 상태를 복구하고 태스크를 재시작합니다.
3.3.3. 병렬 처리와 확장성
Flink는 데이터 병렬성(data parallelism)을 통해 대규모 데이터 처리를 지원합니다. 각 연산자는 여러 개의 병렬 인스턴스로 실행될 수 있으며, 이는 TaskManager의 여러 슬롯에 분산됩니다.
// 병렬도 설정 예시
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(4); // 기본 병렬도 설정
DataStream<String> source = env.addSource(new FlinkKafkaConsumer<>(...))
.setParallelism(8); // 소스 연산자의 병렬도 설정
source.map(...)
.keyBy(...)
.reduce(...)
.addSink(...);
병렬 처리 모델은 Flink가 수평적으로 확장 가능하도록 해주며, 대용량 데이터 스트림을 효율적으로 처리할 수 있게 합니다.
Flink의 런타임 아키텍처는 사용자가 작성한 데이터 플로우 프로그램을 분산 환경에서 효율적으로 실행할 수 있게 해줍니다. JobManager와 TaskManager의 협업을 통해 복잡한 데이터 처리 파이프라인이 병렬로 실행되며, 체크포인팅 메커니즘을 통해 장애 발생 시에도 정확히 한 번 처리를 보장하게 됩니다.
마치며
다양한 실시간 스트림 처리 기술들(Apache Storm, Spark Streaming, Kafka Streams, Flink)을 비교하고, 각 기술의 장단점을 비교했습니다. 특히 Flink가 진정한 스트림 처리, 강력한 상태 관리, 정확히 한 번 처리 보장, 고급 시간 처리 등의 특징으로 복잡한 실시간 데이터 처리에 적합한 선택지일 수 있죠.
Flink의 핵심 개념인 데이터 흐름 프로그래밍 모델, 스트림과 변환, 그리고 이벤트 시간/처리 시간/수집 시간 같은 시간 개념에 대해 알아보았고 이어서 Flink가 사용자 코드를 어떻게 데이터 플로우 그래프로 변환하고, 이 그래프가 어떻게 최적화되어 실행 계획으로 만들어지는지 코드 레벨에서 간단히 분석했습니다.
특히 Flink의 데이터 플로우 그래프가 DAG(방향성 비순환 그래프) 형태를 가지며, 위상 정렬을 통해 실행 순서가 결정된다는 점을 살펴봤습니다. 실제 소스 코드를 통해 StreamGraph가 생성되고 JobGraph로 변환되는 과정도 확인했죠.
사실 Flink의 상태 관리, 체크포인팅, 워터마크 처리 등의 핵심 메커니즘을 소스 코드 레벨에서 더 자세히 살펴보고 싶었으나, 글의 길이와 복잡도를 고려해 이번에는 다루지 못했습니다. 추후 별도의 시리즈로 더 깊이 공부해 공유해보려 합니다. (RocksDB 기반 상태 백엔드의 구현이나 Chandy-Lamport 알고리즘을 응용한 체크포인팅 메커니즘은 분산 시스템 알고리즘 같은게 있는 것 같습니다.)
다음 글에서는 이론에서 벗어나 Flink를 활용한 실제 애플리케이션 사례를 살펴보려 합니다. 실시간 추천 시스템, 이상 탐지 등 실제 문제를 어떻게 해결하는지 코드와 함께 알아보겠습니다.
감사합니다.
reference
- Apache Flink 공식 문서
- Apache Flink 소스코드
- Stream Processing with Apache Flink - Fabian Hueske, Vasiliki Kalavri
- Streaming 101: The world beyond batch
- The Log: What every software engineer should know about real-time data's unifying abstraction
'🏭 Data' 카테고리의 다른 글
트랜스포머 레시피 [1/3] - 재료 준비: 텍스트를 숫자로 변환하기 (2) | 2025.01.26 |
---|---|
실시간 데이터 처리 [1/3] - 배치 그리고 스트림 (0) | 2025.01.19 |
MAB 알고리즘 [2/2] - MAB와 Thompson Sampling의 아키텍처 및 실전 구현 (w. Kotlin) (0) | 2024.10.28 |
MAB 알고리즘 [1/2] - A/B 테스트의 한계, MAB 알고리즘과 Thompson Sampling 이해하기 (5) | 2024.10.26 |
댓글