"Batch is a special case of streaming."
Apache Flink의 핵심 철학을 한 문장으로 요약하면 이렇다. 대부분의 데이터 처리 프레임워크가 배치 처리를 기본으로 놓고 스트리밍을 추가 기능으로 제공하는 반면, Flink는 처음부터 스트림 처리를 기본으로 설계되었다. 배치 처리는 스트림의 특수한 경우일 뿐이라는 것이다. 이 관점의 차이가 아키텍처 전반에 걸쳐 근본적인 설계 차이를 만들어낸다.
Flink란?
Apache Flink는 분산 스트림 처리 엔진(Distributed Stream Processing Engine)이다. 무한히 흘러오는 데이터(Unbounded Stream)를 실시간으로 처리하는 것이 본래의 목적이며, 유한한 데이터셋(Bounded Stream)에 대한 배치 처리도 동일한 엔진 위에서 수행한다.
스트림 우선(Stream-first) vs 배치 우선(Batch-first)
Flink의 정체성을 이해하려면 Apache Spark와 비교하는 것이 가장 직관적이다.
| Apache Spark | Apache Flink | |
|---|---|---|
| 기본 모델 | 배치(Batch) 우선 | 스트림(Stream) 우선 |
| 스트리밍 방식 | 마이크로 배치(Micro-batch) | 진정한 이벤트 단위 처리 |
| 지연 시간 | 수백 ms ~ 수 초 | 수 ms ~ 수십 ms |
| 상태 관리 | 외부 저장소 의존 | 네이티브 상태 관리 |
Spark Structured Streaming은 들어오는 데이터를 작은 배치(마이크로 배치)로 묶어 처리한다. 아무리 배치 간격을 줄여도 최소 지연 시간이 배치 간격만큼 발생할 수밖에 없다. 반면 Flink는 레코드가 도착하는 즉시 개별적으로 처리한다. 각 이벤트가 상태를 가진 연산자(Stateful Operator) 를 통과하며, 분산 스냅샷(Distributed Snapshot)을 통해 일관성을 보장한다.
이 차이는 단순한 성능 문제가 아니다. 사기 탐지(Fraud Detection), 실시간 추천, 게임 매치메이킹처럼 밀리초 단위의 응답이 요구되는 시스템에서는 구조적으로 다른 접근이 필요하다. Flink가 이런 영역에서 강점을 보이는 이유다.
유한 스트림과 무한 스트림
Flink는 데이터를 두 가지로 구분한다.
- 무한 스트림(Unbounded Stream): 시작은 있지만 끝이 없다. Kafka 토픽에서 계속 흘러오는 이벤트가 대표적이다. 데이터가 도착하는 대로 연속적으로 처리해야 한다.
- 유한 스트림(Bounded Stream): 시작과 끝이 명확하다. 파일 시스템에 저장된 로그 파일이나 데이터베이스 테이블이 이에 해당한다. 기존의 "배치 처리"가 바로 이것이다.
Flink의 관점에서 배치 처리란, 끝이 정해진 스트림을 처리하는 것에 불과하다. 동일한 엔진, 동일한 API로 두 가지를 모두 처리할 수 있다는 점이 핵심이다.
아키텍처: JobManager와 TaskManager
Flink 클러스터는 크게 JobManager와 TaskManager라는 두 종류의 프로세스로 구성된다. 일반적인 마스터-워커(Master-Worker) 패턴을 따르지만, 그 내부 구조에는 스트림 처리에 특화된 설계가 녹아 있다.
JobManager: 지휘자
JobManager는 Flink 클러스터의 마스터 노드로, 작업의 스케줄링과 조율을 담당한다. 오케스트라의 지휘자처럼, 전체 데이터 흐름을 관리하고 각 연산자가 올바른 타이밍에 실행되도록 조율한다.
JobManager의 주요 역할은 다음과 같다.
- JobGraph를 ExecutionGraph로 변환: 사용자가 제출한 논리적 데이터 흐름(JobGraph)을 실제 실행 가능한 병렬 실행 계획(ExecutionGraph)으로 변환한다.
- 태스크 스케줄링: 각 태스크를 어느 TaskManager의 어떤 슬롯에서 실행할지 결정한다.
- 체크포인트 조율: 장애 복구를 위한 분산 스냅샷(Checkpoint)을 트리거하고 관리한다.
- 장애 복구: TaskManager 장애 시 태스크를 재배치하고 상태를 복원한다.
고가용성(High Availability) 설정에서는 여러 JobManager를 두고, 그 중 하나가 리더(Leader)로 동작하며 나머지는 대기(Standby) 상태를 유지한다. 리더에 장애가 발생하면 대기 중인 JobManager가 즉시 리더 역할을 이어받는다.
TaskManager: 연주자
TaskManager는 실제로 데이터 처리를 수행하는 워커 노드다. Flink 클러스터에는 하나 이상의 TaskManager가 존재하며, 각 TaskManager는 여러 태스크 슬롯(Task Slot) 을 제공한다.
TaskManager의 주요 역할은 다음과 같다.
- 태스크 실행: JobManager로부터 할당받은 태스크를 실행한다.
- 데이터 교환: TaskManager 간에 데이터 스트림을 버퍼링하고 교환한다.
- 상태 관리: 각 연산자의 상태(State)를 로컬에서 관리하고, 체크포인트 시 스냅샷을 생성한다.
Task Slot: 리소스 단위
태스크 슬롯(Task Slot) 은 TaskManager 내의 리소스 스케줄링 최소 단위다. 하나의 TaskManager가 3개의 슬롯을 가진다면, 해당 TaskManager의 메모리를 3등분하여 각 슬롯에 할당한다.
슬롯이 존재하는 이유는 리소스 격리다. 서로 다른 Job의 태스크가 메모리를 놓고 경쟁하지 않도록 분리한다. 다만 CPU는 격리하지 않으며, 현재 슬롯은 메모리 격리만 수행한다.
흥미로운 점은 슬롯 공유(Slot Sharing) 다. 같은 Job 내의 서로 다른 연산자들은 하나의 슬롯을 공유할 수 있다. 예를 들어, Source 연산자와 Map 연산자, Sink 연산자가 하나의 슬롯 안에서 파이프라인으로 실행될 수 있다. 이를 통해 슬롯 수만큼의 병렬도를 확보하면서도, 가벼운 연산자가 슬롯을 독점하여 리소스가 낭비되는 것을 방지한다.
병렬 처리 모델
Flink는 데이터 병렬성(Data Parallelism)을 기반으로 동작한다. 각 연산자(Operator)는 설정된 병렬도(Parallelism) 만큼 서브태스크(Subtask) 로 분할되어 서로 다른 스레드에서 독립적으로 실행된다.
예를 들어, Source 연산자의 병렬도가 2이고 Map 연산자의 병렬도가 4라면, Source는 2개의 서브태스크로, Map은 4개의 서브태스크로 실행된다. Source의 출력은 Map의 서브태스크들로 재분배(Redistribute)된다.
Flink는 또한 연산자 체이닝(Operator Chaining) 을 수행한다. 같은 병렬도를 가지며 사이에 데이터 재분배가 없는 연속된 연산자들을 하나의 태스크로 묶어 동일한 스레드에서 실행한다. 이는 스레드 간 전환 오버헤드와 네트워크 직렬화 비용을 제거하여 처리량(Throughput)을 높이고 지연(Latency)을 줄이는 핵심 최적화 기법이다.
DataStream API: 스트림 프로그래밍의 기본
DataStream API는 Flink에서 스트림 처리 애플리케이션을 작성하기 위한 핵심 API다. 데이터의 읽기(Source), 변환(Transformation), 쓰기(Sink)를 선언적으로 정의할 수 있다.
프로그램 구조
모든 Flink 프로그램은 동일한 패턴을 따른다.
// 1. 실행 환경 생성
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 2. 소스에서 데이터 읽기
DataStream<String> text = env.readTextFile("input.txt");
// 3. 변환 수행
DataStream<Tuple2<String, Integer>> counts = text
.flatMap(new Tokenizer())
.keyBy(value -> value.f0)
.sum(1);
// 4. 싱크로 결과 출력
counts.print();
// 5. 실행 트리거
env.execute("Word Count");
StreamExecutionEnvironment은 모든 Flink 프로그램의 시작점이다. 소스, 변환, 싱크를 정의한 후 execute()를 호출해야 비로소 프로그램이 실행된다. 이 호출 전까지는 데이터 흐름 그래프를 구성하기만 할 뿐, 실제 데이터 처리는 일어나지 않는다. 이를 지연 실행(Lazy Evaluation) 이라 한다.
Word Count 예제
스트림 처리의 "Hello World"인 Word Count 예제를 통해 DataStream API를 살펴보자.
import org.apache.flink.api.common.functions.FlatMapFunction;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.util.Collector;
public class WordCount {
public static void main(String[] args) throws Exception {
// 실행 환경 생성
StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
// 소스: 소켓에서 텍스트 스트림 수신
DataStream<String> text = env.socketTextStream("localhost", 9999);
// 변환: 단어 분리 -> 키 지정 -> 합계
DataStream<Tuple2<String, Integer>> counts = text
.flatMap(new FlatMapFunction<String, Tuple2<String, Integer>>() {
@Override
public void flatMap(String value, Collector<Tuple2<String, Integer>> out) {
for (String word : value.split("\\s")) {
if (word.length() > 0) {
out.collect(new Tuple2<>(word, 1));
}
}
}
})
.keyBy(value -> value.f0)
.sum(1);
// 싱크: 콘솔 출력
counts.print();
// 실행
env.execute("Socket Word Count");
}
}
이 예제의 데이터 흐름을 단계별로 살펴보면 다음과 같다.
- Source:
socketTextStream으로 소켓에서 텍스트 라인을 수신한다. - FlatMap: 각 라인을 공백 기준으로 분리하여
(단어, 1)형태의 튜플로 변환한다. - KeyBy: 단어를 기준으로 스트림을 파티셔닝한다. 같은 단어는 같은 서브태스크로 보내진다.
- Sum: 동일 키(단어)에 대해 두 번째 필드(카운트)를 누적 합산한다.
- Sink:
print()로 결과를 표준 출력에 기록한다.
주요 변환 연산자
DataStream API가 제공하는 핵심 변환 연산자를 정리하면 다음과 같다.
| 연산자 | 설명 | 입출력 |
|---|---|---|
| map | 입력 하나를 출력 하나로 변환 | 1:1 |
| flatMap | 입력 하나를 0개 이상의 출력으로 변환 | 1:N |
| filter | 조건에 맞는 요소만 통과 | 1:0 or 1 |
| keyBy | 키 기준으로 스트림을 논리적으로 파티셔닝 | 파티셔닝 |
| reduce | 키별로 누적 연산 수행 | N:1 (키 단위) |
| window | 키별 스트림을 시간/개수 기반 윈도우로 그룹화 | 그룹화 |
| union | 두 개 이상의 동일 타입 스트림을 합침 | 합류 |
이 연산자들을 조합하여 복잡한 스트림 처리 파이프라인을 구성할 수 있다.
실행 모델: 시간과 데이터 흐름
스트림 처리에서 "시간"은 단순한 개념이 아니다. Flink는 시간의 의미를 명확하게 구분하여, 분산 환경에서도 정확한 결과를 보장한다.
이벤트 타임 vs 처리 타임
Flink는 두 가지 시간 개념을 지원한다.
처리 타임(Processing Time) 은 데이터가 연산자에 도달한 시점, 즉 처리 머신의 시스템 시계(Wall Clock) 기준 시간이다. 가장 단순하고 지연이 적지만, 분산 환경에서는 비결정적(Non-deterministic)이다. 네트워크 지연이나 시스템 부하에 따라 동일한 데이터에 대해 다른 결과가 나올 수 있다.
이벤트 타임(Event Time) 은 이벤트가 실제로 발생한 시점으로, 보통 데이터 레코드 안에 타임스탬프로 포함되어 있다. 이벤트 타임 기반 처리에서는 시간의 진행이 데이터에 의해 결정되므로, 데이터가 늦게 도착하거나 순서가 뒤바뀌어도 결정적(Deterministic)이고 재현 가능한 결과를 보장한다.
예를 들어 IoT 센서에서 5분 단위로 데이터를 집계한다고 하자. 센서 A의 데이터가 네트워크 문제로 3분 늦게 도착했다면, 처리 타임 기준에서는 잘못된 윈도우에 배치된다. 이벤트 타임 기준에서는 레코드에 포함된 원래 시각을 보고 올바른 윈도우에 배치할 수 있다.
워터마크(Watermark)
이벤트 타임을 사용할 때 핵심적인 질문이 하나 있다. "특정 시점 이전의 데이터가 모두 도착했음을 어떻게 알 수 있는가?"
Flink는 이 문제를 워터마크(Watermark) 로 해결한다. 워터마크 W(t)는 "시각 t 이전의 이벤트는 더 이상 도착하지 않을 것"이라는 선언이다. 워터마크가 윈도우의 끝 시각을 넘어서면, Flink는 해당 윈도우의 데이터 수집이 완료되었다고 판단하고 결과를 출력한다.
워터마크는 스트림 안에 삽입되어 데이터와 함께 흘러간다. 소스 연산자에서 생성되며, 데이터의 타임스탬프를 기반으로 주기적으로 발행된다. 실제 환경에서는 지연 도착 데이터를 허용하기 위해 약간의 여유(Tolerance)를 두고 워터마크를 설정하는 것이 일반적이다.
데이터 흐름 그래프
Flink 프로그램은 내부적으로 데이터 흐름 그래프(Dataflow Graph) 로 변환되어 실행된다. 이 변환 과정은 여러 단계를 거친다.
- StreamGraph: 사용자가 작성한 코드가 가장 먼저 변환되는 논리적 그래프다. 각 연산자와 그 연결 관계를 표현한다.
- JobGraph: StreamGraph에서 연산자 체이닝 등의 최적화가 적용된 그래프다. JobManager에 제출되는 단위다.
- ExecutionGraph: JobManager가 JobGraph를 병렬도에 따라 확장한 물리적 실행 계획이다. 병렬도가 4인 연산자는 4개의 실행 정점(Execution Vertex)으로 확장된다.
사용자가 execute()를 호출하면 이 그래프들이 순차적으로 생성되고, 최종적으로 ExecutionGraph에 따라 각 서브태스크가 TaskManager의 슬롯에 배치되어 실행된다. 이 과정이 자동으로 이루어지기 때문에, 개발자는 논리적인 데이터 변환에만 집중하면 된다.
정리
Apache Flink의 핵심 개념을 다시 정리하면 다음과 같다.
- 스트림 우선 철학: 배치는 유한 스트림의 특수한 경우다. 하나의 엔진으로 실시간 처리와 배치 처리를 모두 수행한다.
- JobManager와 TaskManager: 마스터-워커 패턴으로, JobManager가 작업을 조율하고 TaskManager가 실제 데이터를 처리한다.
- Task Slot: TaskManager 내 리소스 격리 단위로, 슬롯 공유를 통해 리소스 효율성을 높인다.
- DataStream API: StreamExecutionEnvironment을 시작점으로, Source-Transformation-Sink 패턴으로 스트림 처리를 선언적으로 정의한다.
- 이벤트 타임과 워터마크: 분산 환경에서도 결정적이고 정확한 시간 기반 처리를 가능하게 하는 메커니즘이다.
- 데이터 흐름 그래프: 사용자 코드가 StreamGraph, JobGraph, ExecutionGraph로 변환되어 자동으로 병렬 실행된다.
Flink는 "데이터가 끊임없이 흘러오는 세상"을 위해 설계된 엔진이다. 이벤트가 발생하는 즉시 처리하고, 상태를 안전하게 관리하며, 장애에도 정확성을 보장하는 것이 Flink의 존재 이유다. 이후 글에서는 Flink의 상태 관리(State Management) 와 체크포인팅(Checkpointing) 메커니즘을 깊이 있게 다룰 예정이다.