스트림 처리에서 가장 까다로운 문제는 "상태를 어떻게 관리하면서 장애에도 안전하게 동작할 수 있는가" 이다. 단순히 데이터를 흘려보내는 것이라면 상태가 필요 없겠지만, 실시간 집계, 윈도우 연산, 패턴 탐지 같은 작업을 하려면 반드시 이전 데이터를 기억하고 있어야 한다. Apache Flink는 이 문제를 네이티브 상태 관리와 분산 스냅샷 기반 체크포인트로 해결한다.
왜 상태가 필요한가?
"지난 5분간의 평균 거래 금액"을 계산하려면, 5분 동안 들어온 모든 거래 데이터를 어딘가에 저장하고 있어야 한다. "로그인 실패가 3회 연속 발생하면 알림"을 보내려면, 각 사용자별 실패 횟수를 추적해야 한다.
이처럼 스트림 처리 애플리케이션에서 상태(State) 란, 과거 이벤트의 정보를 보존하여 현재와 미래의 이벤트를 처리하는 데 활용하는 데이터를 의미한다. 상태 없이는 각 이벤트를 독립적으로밖에 처리할 수 없으므로, 사실상 의미 있는 스트림 처리 대부분은 상태 기반(Stateful) 이다.
Flink는 이 상태를 애플리케이션 코드 외부에서 직접 관리하기 때문에, 장애 복구 시에도 상태를 정확하게 복원할 수 있다. 이것이 Flink의 핵심 강점이다.
State의 두 가지 유형
Flink의 상태는 크게 Keyed State와 Operator State로 나뉜다. 둘의 차이는 상태가 키에 바인딩되는지, 오퍼레이터 인스턴스에 바인딩되는지에 있다.
Keyed State
Keyed State는 KeyedStream 위에서만 사용할 수 있으며, 각 키마다 독립적인 상태를 유지한다. 사용자 ID별 로그인 실패 횟수, 상품 ID별 누적 매출처럼 키 단위로 분리된 상태가 필요할 때 사용한다.
내부적으로 Keyed State는 Key Group이라는 단위로 관리된다. Key Group은 Flink가 Keyed State를 재분배할 때 사용하는 원자적 단위로, 정의된 최대 병렬도(max parallelism)만큼 존재한다. 병렬도가 변경되면 Key Group 단위로 상태가 재배치된다.
Flink가 제공하는 주요 Keyed State 타입은 다음과 같다.
| State 타입 | 저장 구조 | 용도 |
|---|---|---|
| ValueState<T> | 단일 값 | 키별 단일 값 저장 (예: 마지막 로그인 시간) |
| ListState<T> | 리스트 | 키별 값 목록 저장 (예: 최근 이벤트 이력) |
| MapState<UK, UV> | 키-값 맵 | 키별 매핑 저장 (예: 속성별 카운트) |
| ReducingState<T> | 단일 값 (집계) | 추가 시 자동으로 ReduceFunction 적용 |
| AggregatingState<IN, OUT> | 단일 값 (집계) | ReducingState와 유사하나, 입력 타입과 출력 타입이 다를 수 있음 |
ValueState는 가장 단순하고 자주 사용되는 타입이다. update(T)로 값을 저장하고 value()로 조회한다. ListState는 add(T)로 요소를 추가하고 get()으로 전체 리스트를 순회한다. MapState는 일반적인 Map과 유사하게 put(key, value), get(key) 등의 인터페이스를 제공한다.
ReducingState와 AggregatingState는 요소가 추가될 때마다 즉시 집계를 수행한다는 점에서 ListState와 다르다. 모든 요소를 저장하는 대신 집계된 단일 값만 유지하므로 메모리 효율적이다. 둘의 차이는 ReducingState는 입력과 출력 타입이 동일해야 하지만, AggregatingState는 달라도 된다는 점이다.
Operator State
Operator State(비키 상태) 는 키가 아닌 오퍼레이터의 병렬 인스턴스에 바인딩된다. 하나의 오퍼레이터가 병렬도 4로 실행되면, 4개의 인스턴스 각각이 독립적인 Operator State를 가진다.
대표적인 사용 사례는 Kafka 소스 커넥터다. 각 소스 인스턴스가 자신이 담당하는 파티션의 오프셋(offset)을 상태로 유지한다. 이 오프셋은 특정 키에 종속되는 것이 아니라, 해당 소스 인스턴스 자체에 귀속된다.
병렬도가 변경되면 Operator State는 재분배(redistribute) 되어야 한다. Flink는 ListCheckpointed 또는 CheckpointedFunction 인터페이스를 통해 상태를 리스트 형태로 분할하거나 합치는 방식으로 재분배를 지원한다.
Managed State vs. Raw State
Keyed State와 Operator State 모두 Managed State와 Raw State 두 형태로 존재한다.
- Managed State: Flink 런타임이 직접 관리하는 상태. 내부 해시 테이블이나 RocksDB 같은 자료구조로 저장되며, 직렬화/역직렬화, 체크포인트 생성을 Flink가 자동으로 처리한다.
- Raw State: 오퍼레이터가 자체 자료구조에 보관하는 상태. 체크포인트 시 단순 바이트 배열로 직렬화될 뿐, Flink는 내부 구조를 전혀 알지 못한다.
대부분의 경우 Managed State 사용이 권장된다. Flink가 상태의 구조를 알고 있어야 병렬도 변경 시 재분배나 스키마 진화(schema evolution) 같은 고급 기능을 사용할 수 있기 때문이다.
State Backend: 상태를 어디에 저장할 것인가
Flink의 State Backend는 상태가 물리적으로 어디에, 어떤 형태로 저장되는지를 결정한다. 현재 Flink는 두 가지 State Backend를 제공한다.
HashMapStateBackend
HashMapStateBackend는 상태를 Java 힙(Heap) 메모리에 객체 형태로 저장한다. HashMap 자료구조를 사용하므로 읽기/쓰기가 매우 빠르다.
장점은 명확하다. 직렬화/역직렬화 과정이 없으므로 접근 속도가 가장 빠르다. 하지만 상태 크기가 JVM 힙 메모리에 제한된다는 한계가 있다. 별도 설정을 하지 않으면 Flink는 기본적으로 이 백엔드를 사용한다.
EmbeddedRocksDBStateBackend
EmbeddedRocksDBStateBackend는 상태를 RocksDB라는 임베디드 키-값 저장소에 보관한다. RocksDB는 디스크 기반이므로, 상태 크기가 디스크 용량에 의해서만 제한된다.
대신 모든 상태 접근 시 직렬화/역직렬화가 필요하므로 HashMapStateBackend보다 지연 시간이 높고 처리량이 낮다. 그러나 대규모 상태를 다루는 작업에서는 사실상 유일한 선택지다. 또한 증분 체크포인트(Incremental Checkpoint) 를 지원하는 유일한 백엔드이기도 하다.
선택 기준
| 기준 | HashMapStateBackend | EmbeddedRocksDBStateBackend |
|---|---|---|
| 저장 위치 | JVM 힙 메모리 | 로컬 디스크 (RocksDB) |
| 상태 크기 한계 | 가용 메모리 | 가용 디스크 |
| 접근 속도 | 매우 빠름 | 상대적으로 느림 (직렬화 오버헤드) |
| 증분 체크포인트 | 미지원 | 지원 |
| 적합한 경우 | 상태가 작고 지연에 민감한 작업 | 대규모 상태, 긴 윈도우, HA 구성 |
State Backend는 코드에서 직접 설정하거나, 클러스터 설정(flink-conf.yaml)에서 전역으로 지정할 수 있다.
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStateBackend(new EmbeddedRocksDBStateBackend());
Checkpoint: 분산 스냅샷으로 내결함성 확보
상태를 잘 관리하는 것만으로는 충분하지 않다. 노드가 장애로 죽으면 메모리에 있던 상태도 함께 사라지기 때문이다. Flink는 Checkpoint(체크포인트) 를 통해 이 문제를 해결한다.
Checkpoint의 원리
Checkpoint는 모든 오퍼레이터의 상태를 특정 시점에 일관되게 스냅샷으로 저장하는 메커니즘이다. 장애 발생 시 가장 최근의 완료된 Checkpoint에서 상태를 복원하고, 해당 시점부터 소스의 데이터를 다시 재생(replay)하여 처리를 이어간다.
핵심은 "일관되게" 라는 부분이다. 분산 환경에서 여러 오퍼레이터가 동시에 동작하고 있을 때, 모든 오퍼레이터의 상태가 동일한 논리적 시점을 반영하도록 스냅샷을 찍어야 한다. 이를 위해 Flink는 Chandy-Lamport 알고리즘을 변형한 비동기 배리어 스냅샷(Asynchronous Barrier Snapshotting) 방식을 사용한다.
Checkpoint Barrier와 Chandy-Lamport 알고리즘
동작 과정은 다음과 같다.
- Checkpoint Coordinator(JobManager 내부)가 주기적으로 소스 오퍼레이터에 체크포인트 트리거를 보낸다.
- 소스 오퍼레이터는 현재 오프셋을 기록하고, 데이터 스트림에 Checkpoint Barrier라는 특수 마커를 삽입한다. Barrier에는 체크포인트 ID가 포함된다.
- Barrier는 일반 데이터 레코드와 함께 스트림을 따라 다운스트림으로 흘러간다.
- 각 오퍼레이터는 모든 입력 채널에서 동일한 ID의 Barrier를 수신하면 자신의 상태를 스냅샷으로 저장한다.
- 저장이 완료되면 Barrier를 다시 다운스트림으로 전달한다.
- 모든 싱크(Sink)까지 Barrier가 도달하면 해당 Checkpoint가 완료된다.
Barrier는 스트림을 "Checkpoint 이전"과 "Checkpoint 이후" 두 구간으로 나누는 역할을 한다. Barrier 이전의 모든 데이터는 현재 Checkpoint에 반영되고, Barrier 이후의 데이터는 다음 Checkpoint에 반영된다.
Barrier Alignment과 Exactly-Once
입력 채널이 여러 개인 오퍼레이터(예: join, union)에서는 Barrier Alignment(배리어 정렬) 이 중요하다. 채널 A에서는 이미 Barrier가 도착했는데 채널 B에서는 아직 도착하지 않은 상황이 생길 수 있다.
Aligned Checkpoint(정렬 체크포인트) 에서는, 먼저 도착한 채널의 데이터 처리를 일시 중단하고 나머지 채널의 Barrier가 도착할 때까지 대기한다. 모든 채널의 Barrier가 도착한 시점에 스냅샷을 생성하므로, 스냅샷은 정확히 Barrier 시점까지의 상태만 반영한다. 이것이 exactly-once 처리 보장의 핵심 원리다.
하지만 이 방식은 백프레셔(backpressure) 상황에서 문제가 된다. 느린 채널의 Barrier를 기다리는 동안 빠른 채널의 처리도 막히므로, 체크포인트 완료 시간이 크게 늘어날 수 있다.
Unaligned Checkpoint
Flink 1.11부터 도입된 Unaligned Checkpoint(비정렬 체크포인트) 는 이 문제를 해결한다. 오퍼레이터는 첫 번째 Barrier가 도착하는 즉시 스냅샷을 시작하고, Barrier를 바로 다운스트림으로 전달한다. 아직 처리되지 않은 다른 채널의 인플라이트 데이터(in-flight data) 도 스냅샷에 포함시킨다.
백프레셔 상황에서도 체크포인트 시간이 엔드투엔드 지연 시간에 거의 영향받지 않는다는 장점이 있다. 다만 스냅샷에 인플라이트 데이터까지 포함되므로 스냅샷 크기가 커지고 I/O 부하가 증가한다는 트레이드오프가 있다.
Checkpoint 설정
체크포인트 활성화 및 주요 설정 옵션은 다음과 같다.
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 체크포인트 활성화 (10초 간격)
env.enableCheckpointing(10000);
// exactly-once 모드 (기본값)
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
// 체크포인트 타임아웃 (10분)
env.getCheckpointConfig().setCheckpointTimeout(600000);
// 체크포인트 간 최소 간격 (500ms)
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(500);
// 동시 실행 가능한 체크포인트 수
env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);
// Unaligned Checkpoint 활성화
env.getCheckpointConfig().enableUnalignedCheckpoints();
| 설정 | 설명 | 기본값 |
|---|---|---|
| checkpointInterval | 체크포인트 트리거 주기 (ms) | 비활성화 |
| checkpointingMode | EXACTLY_ONCE 또는 AT_LEAST_ONCE | EXACTLY_ONCE |
| checkpointTimeout | 체크포인트 완료 제한 시간 | 10분 |
| minPauseBetweenCheckpoints | 완료와 다음 시작 사이 최소 간격 | 0 |
| maxConcurrentCheckpoints | 동시 진행 가능한 체크포인트 수 | 1 |
AT_LEAST_ONCE 모드는 Barrier Alignment을 수행하지 않으므로 체크포인트가 더 빠르게 완료되지만, 복구 시 일부 레코드가 중복 처리될 수 있다. 중복이 허용되는 경우(예: 멱등 연산)에 사용하면 성능 이점을 얻을 수 있다.
Savepoint: 수동 스냅샷과 버전 관리
Savepoint란?
Savepoint(세이브포인트) 는 Checkpoint와 마찬가지로 애플리케이션의 전체 상태를 스냅샷으로 저장하는 기능이다. 다만 목적과 관리 방식이 근본적으로 다르다.
Checkpoint가 데이터베이스의 복구 로그(recovery log) 에 해당한다면, Savepoint는 백업(backup) 에 해당한다. Checkpoint는 장애로부터 자동 복구하기 위한 것이고, Savepoint는 사용자가 의도적으로 상태를 보존하기 위한 것이다.
Checkpoint vs. Savepoint
| 기준 | Checkpoint | Savepoint |
|---|---|---|
| 목적 | 장애 자동 복구 | 수동 백업, 버전 관리 |
| 트리거 | Flink가 주기적으로 자동 생성 | 사용자가 수동으로 트리거 |
| 라이프사이클 | Flink가 생성/관리/삭제 | 사용자가 생성/관리/삭제 |
| 포맷 | State Backend에 최적화된 내부 포맷 | 표준화된 포맷 (이식성 우선) |
| 증분 지원 | 지원 (RocksDB) | 항상 전체 스냅샷 |
| 복구 속도 | 빠름 (최적화됨) | 상대적으로 느림 |
Checkpoint는 빠른 생성과 복구에 최적화되어 있어, State Backend의 내부 포맷(예: RocksDB의 네이티브 포맷)을 그대로 활용한다. 반면 Savepoint는 Flink의 표준 직렬화 포맷을 사용하여 State Backend 간 이식성을 보장한다.
Savepoint 활용 시나리오
Savepoint가 진정으로 빛을 발하는 상황은 다음과 같다.
- 애플리케이션 업데이트: 비즈니스 로직을 수정한 새 버전을 배포할 때, 현재 상태를 Savepoint로 저장하고 새 버전에서 해당 Savepoint로부터 재시작한다.
- Flink 클러스터 업그레이드: Flink 버전 자체를 업그레이드할 때 상태를 보존한다.
- A/B 테스트: 동일한 Savepoint에서 두 가지 버전의 애플리케이션을 시작하여 결과를 비교한다.
- 병렬도 변경: 트래픽 변화에 따라 병렬도를 조정할 때, Savepoint를 통해 상태를 재분배한다.
- 일시 중지/재개: 유지보수를 위해 잡을 멈췄다가, 나중에 중단 시점부터 이어서 처리한다.
Savepoint 트리거는 CLI로 간단하게 수행할 수 있다.
# Savepoint 생성
flink savepoint <jobId> [targetDirectory]
# Savepoint에서 잡 시작
flink run -s <savepointPath> <jarFile>
# 잡 중지와 동시에 Savepoint 생성
flink stop --savepointPath <targetDirectory> <jobId>
주의할 점이 하나 있다. Savepoint에서 복원하려면 각 오퍼레이터에 고유한 UID가 할당되어 있어야 한다. Flink는 UID를 기준으로 Savepoint의 상태와 오퍼레이터를 매핑하기 때문이다. UID를 지정하지 않으면 Flink가 자동 생성하지만, 코드 변경 시 UID가 바뀌어 상태 복원에 실패할 수 있다.
Flink vs. Spark: 스트림 처리의 두 거인
Flink와 Apache Spark는 모두 대규모 데이터 처리를 위한 분산 프레임워크지만, 근본적인 설계 철학이 다르다.
처리 모델: 스트림 우선 vs. 배치 우선
Flink는 스트림 우선(stream-first) 이다. 모든 데이터를 연속적인 이벤트 스트림으로 보고, 배치 처리는 "유한한 스트림"의 특수한 경우로 취급한다. 각 이벤트가 도착하는 즉시 처리하는 레코드 단위(record-at-a-time) 처리가 기본이다.
Spark는 배치 우선(batch-first) 이다. 원래 배치 처리를 위해 설계되었으며, 스트림 처리(Spark Structured Streaming)는 마이크로배치(micro-batch) 방식으로 구현된다. 짧은 시간 간격으로 작은 배치를 반복 실행하여 스트림 처리를 근사하는 방식이다.
이 차이가 거의 모든 특성의 차이로 이어진다.
비교
| 기준 | Apache Flink | Apache Spark (Structured Streaming) |
|---|---|---|
| 처리 모델 | 네이티브 스트림 (레코드 단위) | 마이크로배치 |
| 지연(Latency) | 밀리초 단위 | 수백 밀리초 ~ 수 초 |
| 상태 관리 | 네이티브 Keyed/Operator State, 다양한 State Backend | RDD 기반 상태, Spark 3.5+에서 RocksDB 지원 |
| Exactly-Once | Checkpoint Barrier 기반, 경량 | 마이크로배치 트랜잭션 기반 |
| 윈도우(Window) | 이벤트 시간 기반, 유연한 커스텀 윈도우 | 시간 기반 윈도우 (상대적으로 제한적) |
| 배치 처리 | 지원 (유한 스트림으로 처리) | 핵심 강점, 대규모 배치에 성숙한 생태계 |
| 생태계 | 스트림 중심, CEP 라이브러리 등 | ML(MLlib), 그래프(GraphX), SQL 등 풍부 |
| 학습 곡선 | 상대적으로 가파름 | 상대적으로 완만 (Spark SQL 등) |
지연(Latency)
가장 체감이 큰 차이다. Flink는 이벤트가 도착하면 즉시 처리하므로 밀리초 단위의 지연이 가능하다. Spark의 마이크로배치는 아무리 짧게 설정해도 배치 간격만큼의 지연이 발생하며, 실질적으로 수백 밀리초에서 수 초 수준이다.
실시간 이상 탐지, 실시간 추천, 금융 거래 모니터링처럼 밀리초 단위 응답이 필요한 경우에는 Flink가 압도적으로 유리하다.
상태 관리
Flink의 상태 관리는 앞서 다룬 것처럼 프레임워크 수준에서 깊이 통합되어 있다. Keyed State, Operator State, 다양한 State Backend, 증분 체크포인트 등이 처음부터 설계에 포함되어 있다.
Spark도 mapGroupsWithState나 flatMapGroupsWithState 같은 API로 상태 처리를 지원하지만, 상태 업데이트가 마이크로배치 간격에 묶여 있고, Flink만큼 세밀한 제어는 어렵다. 다만 Spark 3.5 이후 RocksDB 기반 상태 관리가 도입되면서 격차가 줄어드는 추세다.
적합한 유스케이스
Flink가 적합한 경우:
- 밀리초 단위의 초저지연 스트림 처리가 필요한 경우
- 복잡한 이벤트 처리(CEP): 패턴 탐지, 이상 징후 감지
- 대규모 상태 기반 스트림 처리: 실시간 집계, 실시간 조인
- 이벤트 시간(Event Time) 기반의 정교한 윈도우 처리가 필요한 경우
Spark가 적합한 경우:
- 대규모 배치 처리가 주 워크로드인 경우
- 배치와 스트림을 하나의 프레임워크로 통합하고 싶은 경우
- ML 파이프라인(MLlib)이나 인터랙티브 분석(Spark SQL)과 통합이 필요한 경우
- 조직 내 Spark 전문 인력과 생태계가 이미 구축된 경우
결국 "어느 것이 더 좋다"의 문제가 아니라, 워크로드의 특성에 맞는 도구를 선택하는 문제다. 실시간성과 상태 관리가 핵심이라면 Flink, 배치 중심의 통합 분석 플랫폼이 필요하다면 Spark가 자연스러운 선택이다.
정리
Flink의 상태 관리와 내결함성 메커니즘을 다시 정리하면 다음과 같다.
- State: 스트림 처리에서 과거 이벤트 정보를 보존하는 핵심 요소. Keyed State(키별 상태)와 Operator State(인스턴스별 상태)로 나뉜다.
- State Backend: 상태의 물리적 저장소. HashMapStateBackend(메모리, 빠름)와 EmbeddedRocksDBStateBackend(디스크, 대용량)를 선택할 수 있다.
- Checkpoint: Chandy-Lamport 알고리즘 기반의 분산 스냅샷. Barrier Alignment을 통해 exactly-once 처리를 보장하며, Flink가 자동으로 관리한다.
- Savepoint: 사용자가 수동으로 생성하는 상태 스냅샷. 버전 업그레이드, 병렬도 변경, 마이그레이션 등에 활용된다.
- Flink vs. Spark: Flink는 스트림 우선, Spark는 배치 우선. 초저지연 스트림 처리에는 Flink, 대규모 배치 분석에는 Spark가 적합하다.
Flink가 "진정한 스트림 처리 엔진"이라 불리는 이유는 단순히 데이터를 빠르게 흘려보내서가 아니라, 상태를 안전하게 관리하면서도 장애에 강건한 처리를 보장하기 때문이다. 상태 관리와 체크포인트 메커니즘에 대한 이해는 Flink 애플리케이션을 운영 환경에서 안정적으로 운용하기 위한 필수 지식이다.