상태 기반 스트림 처리

상태 기반 스트림 처리 (Stateful Stream Processing)

이벤트 하나만 보고 처리하는 연산이 있는 반면, 여러 이벤트에 걸쳐 정보를 기억해야 하는 연산도 있어요. 윈도우 연산자가 대표적인데, 이런 연산을 상태 기반(stateful) 연산이라고 불러요. 예를 들어 특정 이벤트 패턴을 찾는 애플리케이션은 지금까지 마주친 이벤트의 순서를 상태로 저장하고, 분당·시간당·일간 집계를 하는 연산은 아직 확정되지 않은 집계값을 상태로 보관해요. 이 상태를 Flink의 장애 복구 메커니즘에 통합해 주는 것이 바로 체크포인트와 세이브포인트예요.

출처: Apache Flink 공식 문서 - Stateful Stream Processing

상태란 무엇인가?

많은 데이터플로우 연산은 이벤트 하나를 개별적으로만 보지만(예: 이벤트 파서), 어떤 연산은 여러 이벤트에 걸쳐 정보를 기억해야 해요(예: 윈도우 연산자). 그런 연산을 **상태 기반(stateful)**이라고 불러요. 상태 기반 연산의 예는 이렇게 다양해요.

  • 애플리케이션이 특정 이벤트 패턴을 찾을 때 — 상태가 지금까지 마주친 이벤트의 시퀀스를 저장해요.
  • 분·시·일 단위로 이벤트를 집계할 때 — 상태가 아직 확정되지 않은 집계값을 들고 있어요.
  • 데이터 포인트 스트림으로 머신러닝 모델을 학습시킬 때 — 상태가 모델 파라미터의 현재 버전을 보관해요.
  • 과거 데이터를 다뤄야 할 때 — 상태가 과거에 발생한 이벤트에 효율적으로 접근하게 해줘요.

Flink는 이 상태를 체크포인트세이브포인트로 내결함성 있게 만들어요. 그리고 상태에 대한 지식은 Flink 애플리케이션의 **재스케일링(rescaling)**에도 쓰여요. Flink가 상태를 병렬 인스턴스 간에 재분배해 주기 때문이에요. 상태를 다룰 때는 상태가 어디에 어떻게 저장되는지를 정하는 **상태 백엔드(state backend)**에 대해서도 함께 보는 게 좋아요.

키 상태 (Keyed State)

키 상태는 임베디드 키/값 저장소로 생각하면 돼요. 상태는 상태 저장 연산자가 읽는 스트림과 함께 엄격하게 파티셔닝되어 분산돼요. 그래서 처음부터 키 스트림에서만, 그것도 현재 이벤트의 키에 연결된 값에만 접근할 수 있죠. 스트림과 상태의 키를 정렬해 두면 모든 상태 갱신이 로컬 연산이 되어서 트랜잭션 오버헤드 없이도 일관성을 보장받을 수 있어요. 이 정렬 덕분에 Flink가 상태를 재분배하거나 스트림 파티셔닝을 조정하는 것도 투명하게 이뤄져요.

키 상태는 다시 **키 그룹(Key Group)**이라는 단위로 조직돼요. 키 그룹은 Flink가 키 상태를 재분배할 수 있는 최소 단위인데, 정의된 최대 병렬도와 정확히 같은 수만큼 존재해요. 실행 중에는 키 연산자의 각 병렬 인스턴스가 하나 이상의 키 그룹에 속한 키들을 다루게 되죠.

상태 영속성 (State Persistence)

Flink는 **스트림 재생(stream replay)**과 체크포인트의 조합으로 내결함성을 구현해요. 체크포인트는 각 입력 스트림의 특정 지점과 그 시점의 각 연산자 상태를 함께 표시해 두는 것이에요. 스트리밍 데이터플로우는 연산자 상태를 복원하고 체크포인트 지점부터 레코드를 재생함으로써 일관성(정확히 한 번의 처리 시맨틱스)을 유지하면서 재개할 수 있어요.

체크포인트 주기는 실행 중 내결함성 오버헤드와 복구 시간(재생해야 할 레코드 수) 사이의 트레이드오프를 조절하는 수단이에요. 이 내결함성 메커니즘은 분산 스트리밍 데이터플로우의 스냅샷을 계속해서 그려나가요. 상태가 작은 스트리밍 애플리케이션에서는 이 스냅샷이 아주 가벼워서 성능에 큰 영향을 주지 않고 자주 찍을 수 있어요. 스트리밍 애플리케이션의 상태는 보통 분산 파일 시스템 같은 설정 가능한 위치에 저장돼요.

프로그램에 장애(머신·네트워크·소프트웨어 실패)가 나면 Flink는 분산 스트리밍 데이터플로우를 멈추고, 연산자를 재시작해서 가장 최근의 성공한 체크포인트로 되돌려요. 입력 스트림은 상태 스냅샷 시점으로 리셋되고, 재시작된 병렬 데이터플로우의 일부로 처리되는 모든 레코드는 이전에 체크포인트된 상태에 영향을 주지 않도록 보장돼요.

기본적으로 체크포인트는 비활성화되어 있어요. 활성화·설정 방법은 Checkpointing 문서에서 확인할 수 있어요.

이 메커니즘이 완전한 보장을 실현하려면 데이터 스트림 소스(메시지 큐나 브로커 같은)가 정의된 최근 지점으로 스트림을 되감을 수 있어야 해요. Apache Kafka가 그런 능력을 갖고 있고 Flink의 Kafka 커넥터가 이를 활용해요.

체크포인팅

Flink 내결함성 메커니즘의 핵심은 분산 데이터 스트림과 연산자 상태에 대한 일관된 스냅샷을 찍는 일이에요. 이 스냅샷들이 실패 시 되돌아갈 수 있는 일관된 체크포인트 역할을 하죠. 이 스냅샷을 찍는 메커니즘은 분산 스냅샷의 표준 알고리즘인 Chandy-Lamport 알고리즘에서 영감을 받아 Flink의 실행 모델에 맞게 다듬어진 것이에요.

체크포인팅과 관련된 모든 것은 비동기적으로 처리될 수 있다는 점을 기억해 두면 좋아요. 체크포인트 배리어는 락스텝으로 이동하지 않고, 연산들은 자기 상태를 비동기적으로 스냅샷할 수 있어요. Flink 1.11부터는 체크포인트를 정렬(alignment)을 써서 찍을 수도, 안 쓰고 찍을 수도 있어요.

배리어 (Barriers)

Flink의 분산 스냅샷의 핵심 요소는 **스트림 배리어(stream barrier)**예요. 배리어는 데이터 스트림에 주입되어 레코드와 함께 스트림의 일부처럼 흘러요. 배리어는 레코드를 절대 추월하지 않고 순서대로 정확히 그 사이를 흐르죠. 배리어는 데이터 스트림의 레코드를 '현재 스냅샷에 들어가는 레코드'와 '다음 스냅샷에 들어가는 레코드'로 구분해요. 각 배리어는 자기 앞에 밀어 넣은 레코드들을 담당하는 스냅샷의 ID를 운반해요. 배리어는 스트림의 흐름을 끊지 않아서 아주 가볍고, 서로 다른 스냅샷의 여러 배리어가 동시에 스트림에 존재할 수 있어요.

스트림 배리어는 스트림 소스에서 병렬 데이터플로우에 주입돼요. 스냅샷 n의 배리어가 주입된 지점(Sn이라고 부를게요)은 소스 스트림에서 그 스냅샷이 커버하는 데이터의 끝 지점이에요. 예를 들어 Apache Kafka에서는 그 위치가 파티션의 마지막 레코드 오프셋이죠. 이 위치 Sn체크포인트 코디네이터(Flink의 JobManager)에게 보고돼요.

배리어는 아래로 흘러 내려가요. 중간 연산자가 모든 입력 스트림에서 스냅샷 n의 배리어를 받으면 모든 출력 스트림으로 스냅샷 n의 배리어를 내보내요. 싱크 연산자(스트리밍 DAG의 끝)가 모든 입력 스트림에서 배리어 n을 받으면 스냅샷 n을 체크포인트 코디네이터에 확인(acknowledge)해요. 모든 싱크가 스냅샷을 확인하면 그 스냅샷은 완료된 것으로 간주돼요.

연산자 상태 스냅샷

연산자에 어떤 형태의 상태라도 있으면 그 상태도 반드시 스냅샷에 포함돼야 해요. 연산자는 모든 입력 스트림에서 스냅샷 배리어를 받은 시점에, 출력 스트림으로 배리어를 내보내기 전에 상태를 스냅샷해요. 이 시점에는 배리어 이전 레코드들의 상태 갱신이 모두 반영되어 있고, 배리어 이후 레코드에 의존하는 갱신은 아직 적용되지 않았어요. 스냅샷 상태는 클 수 있어서 설정 가능한 상태 백엔드에 저장돼요. 기본값은 JobManager 메모리지만, 운영 환경에서는 분산 신뢰 저장소(HDFS 같은)를 설정하는 게 권장돼요.

결과 스냅샷에는 다음이 들어 있어요.

  • 각 병렬 스트림 데이터 소스에 대해, 스냅샷이 시작된 시점의 스트림 오프셋/위치
  • 각 연산자에 대해, 스냅샷의 일부로 저장된 상태를 가리키는 포인터

복구 (Recovery)

이 메커니즘 아래서 복구는 단순해요. 실패가 나면 Flink는 가장 최근에 완료된 체크포인트 k를 고르고, 분산 데이터플로우 전체를 재배포하면서 각 연산자에게 체크포인트 k의 일부로 스냅샷된 상태를 넘겨줘요. 소스는 스트림을 위치 Sk부터 읽도록 설정되는데, 예를 들어 Apache Kafka에서는 컨슈머가 오프셋 Sk부터 가져오도록 하는 식이에요.

정렬 없는(unalinged) 체크포인팅

체크포인팅은 정렬 없이 수행할 수도 있어요. 기본 아이디어는 인플라이트 데이터가 연산자 상태의 일부가 되는 한, 체크포인트가 모든 인플라이트 데이터를 추월할 수 있게 하는 거예요. 이 방식은 배리어가 싱크에 가능한 한 빨리 도착하도록 보장해서, 정렬 시간이 몇 시간에 달할 수 있는 느린 데이터 경로가 하나 이상 있는 애플리케이션에 특히 적합해요. 다만 상태 백엔드로의 I/O가 병목이라면 추가 I/O 부담 때문에 도움이 되지 않아요.

상태 백엔드 (State Backends)

키/값 인덱스가 저장되는 정확한 자료구조는 선택한 상태 백엔드에 따라 달라져요. 한 상태 백엔드는 데이터를 인메모리 해시맵에 저장하고, 다른 백엔드는 RocksDB를 키/값 저장소로 사용해요. 상태를 담는 자료구조를 정의하는 것 외에도, 상태 백엔드는 키/값 상태의 특정 시점 스냅샷을 찍어 그 스냅샷을 체크포인트의 일부로 저장하는 로직도 구현해요. 상태 백엔드는 애플리케이션 로직을 바꾸지 않고도 설정할 수 있어요.

세이브포인트 (Savepoints)

체크포인트를 사용하는 모든 프로그램은 세이브포인트에서 실행을 재개할 수 있어요. 세이브포인트는 프로그램과 Flink 클러스터를 업데이트하면서도 상태를 잃지 않게 해줘요. 세이브포인트는 사용자가 수동으로 트리거하는 체크포인트로, 프로그램의 스냅샷을 찍어 상태 백엔드로 내보내요. 체크포인트와 달리 사용자가 트리거하고, 더 새로운 체크포인트가 완료돼도 자동으로 만료되지 않는다는 점이 다르죠.

정확히 한 번 vs. 최소 한 번

정렬(alignment) 단계는 스트리밍 프로그램에 지연을 더할 수 있어요. 보통은 수 밀리초 수준이지만, 극단적인 경우 눈에 띄게 커질 때도 있어요. 모든 레코드에 대해 일관되게 초저지연(수 밀리초)을 요구하는 애플리케이션을 위해, Flink는 체크포인트 중에 스트림 정렬을 건너뛰는 스위치를 제공해요. 정렬을 건너뛰면 연산자는 스냅샷 n의 배리어가 도착한 뒤에도 모든 입력을 계속 처리해서, 스냅샷 n이 찍히기 전에 스냅샷 n+1에 속하는 요소까지 처리하게 돼요. 복구 시 이 레코드들은 중복으로 나타날 수 있는데, 체크포인트 n의 상태 스냅샷에도 포함되고 체크포인트 n 이후 데이터로도 재생되기 때문이에요.

정렬은 여러 선행(predecessor)을 가진 연산자(조인)와 여러 송신자(sender)를 가진 연산자(스트림 재파티셔닝/셔플 이후)에만 일어나요. 그래서 map(), flatMap(), filter() 같은 완전히 병렬인(embarrassingly parallel) 스트리밍 연산만 있는 데이터플로우는, at least once 모드에서도 사실상 exactly once 보장을 줘요.

배치 프로그램에서의 상태와 내결함성

Flink는 배치 프로그램을 BATCH ExecutionMode의 스트리밍 프로그램의 특수한 경우(스트림이 유한)로 실행해요. 그래서 위 개념들은 배치 프로그램에도 스트리밍과 같은 방식으로 적용되는데, 아주 약간의 예외가 있어요.

  • 배치 프로그램의 내결함성은 체크포인팅을 쓰지 않아요. 복구가 스트림을 완전히 재생하는 방식으로 일어나는데, 입력이 유한해서 가능한 거예요. 복구에 비용이 치우치는 대신, 체크포인트를 피하는 만큼 정상 처리 비용은 더 싸져요.
  • 배치 실행 모드의 상태 백엔드는 키/값 인덱스가 아니라 단순화된 인메모리/아웃 오브 코어 자료구조를 사용해요.

더 알아보기 (Learn more)