원문 캡처 · timeplus.com
원문 캡처 · timeplus.com

실시간 데이터에서 연속된 이벤트를 하나의 구간으로 묶으려면 구간의 경계를 정하는 규칙과 이벤트 사이의 상태를 기억하는 방법이 필요하다. Timeplus는 스트리밍 SQL에서 상태 변화, 비활동 시간, 명시적 시작·종료 이벤트, 구간 종료 조건에 따라 이를 처리하는 네 가지 방법을 소개했다.

차량이 A → A → B → B → A 순서로 지역을 보고한다면, A를 두 번 방문한 것을 포함해 세 구간으로 나눠야 한다. 이를 ‘갭과 아일랜드(gaps and islands)’ 문제라고 한다. 이벤트가 여러 번의 삽입 작업에 나뉘어 들어오면 앞선 이벤트를 기억해 다음 경계를 찾아야 한다.

Big Data Boutique의 글은 ClickHouse 증분 구체화 뷰로 이 문제를 다룬다. 새로 삽입된 블록을 처리하는 뷰가 삽입 사이의 상태를 유지하도록, 저장된 현재 상태와 새 배치를 결합해 전환을 감지하고 열린 구간과 완료된 구간을 별도 테이블에 관리한다. 이 방식에서는 각 엔터티의 이벤트가 같은 샤드와 복제본으로 전달돼야 한다.

Timeplus의 구체화 뷰는 연속 스트리밍 쿼리를 실행하고, 엔진이 이벤트가 들어올 때 중간 상태를 관리한다. 스트리밍 SQL 연산자로 경계 규칙을 표현하고 구체화 뷰에 결과를 저장한다. 아래 네 방법은 같은 이벤트 스트림을 사용해 경계 규칙에 따라 구간과 결과가 나오는 시점이 어떻게 달라지는지 비교한다.

  • 상태 변화: lag()와 PARTITION BY로 전환을 감지한다.
  • 비활동 시간: session()으로 활동을 묶는다.
  • 시작·종료 이벤트: 명시적 조건으로 세션을 정의한다.
  • 세션 종료와 결과 시점: EMIT AFTER SESSION CLOSE로 이벤트 시간 범위와 실제 경과 시간을 제한한다.

상태 변화와 비활동 시간으로 구간 나누기

상태가 바뀌는 지점으로 구간을 나눌 때는 각 이벤트를 바로 앞 이벤트와 비교한다. Timeplus는 배치 쿼리와 스트리밍 쿼리에서 모두 lag()와 PARTITION BY를 지원한다. A에서 B로 바뀌는 시각 t에 A 구간을 닫고 종료 상태를 B로 기록한 뒤, t부터 열린 B 구간을 시작하는 방식이다.

완료된 구간을 만드는 구체화 뷰 entity_state_segments는 상태가 바뀔 때마다 닫힌 구간을 출력한다. PARTITION BY를 적용한 lag()를 두 단계로 사용한다. 안쪽 단계가 전환 시점을 찾고, 바깥 단계가 이웃한 전환 시점을 연결해 구간을 만든다. 열린 구간은 키를 기준으로 값을 갱신할 수 있는 스트림에 저장하며, 구체화 뷰가 전환 때마다 엔터티의 현재 상태를 갱신한다. 두 결과를 UNION ALL로 합치면 열린 구간과 닫힌 구간을 함께 볼 수 있다.

Timeplus는 스트리밍 lag()가 삽입 배치를 넘어 동작한다고 설명한다. PARTITION BY를 지정하면 연산자가 각 키의 이전 행을 보유하고 체크포인트로 저장해 재시작 뒤에도 이어서 처리한다. 따라서 외부 상태 테이블이나 배열 조작, 커서 순회가 필요하지 않다는 설명이다. 글에서는 같은 작업을 한 번에 삽입한 블록과 이벤트를 하나씩 삽입한 경우로 실행했으며, 상태가 삽입 블록 경계와 무관해 결과가 바이트 단위로 같았다고 밝혔다.

이 방법은 상태 값의 변화가 구간 경계이고, 정확한 시작·종료 지점과 전후 상태가 필요할 때 적합하다. 다만 lag()는 도착 순서에 따라 처리되므로 배치에 걸쳐 이벤트가 크게 순서 없이 도착하면 전환을 잘못 셀 수 있다. 이벤트 시간 기준 처리가 필요하다면 세션 윈도우를 고려해야 한다.

세션 윈도우는 첫 이벤트가 도착하면 시작하고, 정해진 비활동 시간(idle gap)이 지나면 닫힌다. 불규칙하게 몰려오는 이벤트, 사용자 세션, 기기의 온라인 상태처럼 ‘얼마나 이벤트가 없으면 끊긴 것으로 볼지’가 경계인 경우에 쓸 수 있다. 같은 키에 속한 세션은 서로 겹치지 않는다.

예시의 아홉 이벤트를 각각 하트비트로 보면, e-1은 09:12부터 09:30까지 18분, e-2는 09:01부터 09:20까지 19분 동안 이벤트가 없다. 두 간격 모두 15분의 비활동 기준을 넘기므로 쿼리는 닫힌 세션 두 개를 출력한다. 이후 시작된 e-1의 두 번째 세션(09:30부터)과 e-2의 두 번째 세션(09:20부터)은 아직 열려 있다. 세션은 이벤트 시간 기준으로 닫히며, 같은 기기의 다음 이벤트가 워터마크를 window_end 너머로 밀어야 결과를 출력한다. window_end는 마지막 이벤트 시각에 비활동 시간을 더한 시점이다.

lag()는 각 구간의 시작·종료 상태를 출력하는 반면, session()은 구간별 집계를 출력한다. 전자는 도착 순서로 작동하고 후자는 이벤트 시간과 워터마크에 따라 닫힌다.

시작·종료 조건으로 업무 이벤트 처리하기

세션 윈도우는 시작과 종료 조건을 지정하는 기능도 제공한다. 예를 들어 모니터링 시스템에서 CPU가 기준치를 넘으면 경보를 시작하고 다시 기준치 아래로 내려가면 복구로 처리할 수 있다. 이때 구간은 비활동 시간이 아니라 업무 규칙으로 정해진다.

예시에서는 상태 A에 들어가는 이벤트를 시작으로, A에서 벗어나는 이벤트를 종료로 삼는다. state_id = 'A'가 처음 참이 될 때 세션을 열고, state_id != 'A'일 때 닫는다. 대괄호 표기는 시작·종료 이벤트를 세션에 포함할지 정한다. [는 시작 이벤트를 포함하고, )는 종료 이벤트를 제외하며, ]는 종료 이벤트를 포함한다. 따라서 [시작, 종료)는 시작 이벤트를 포함하고 종료 이벤트는 제외하는 일반적인 형태다.

같은 아홉 이벤트를 적용하면 e-1은 09:00에 A에 들어가 세션을 시작하고 09:12에 A에서 벗어나 종료한다. e-2는 A에 들어가지 않는다. 결과는 e-1의 경보 한 건이며 A 상태의 이벤트 세 개를 포함하고, 세션을 닫는 B 이벤트는 제외한다.

이 방식은 과속 구간([speed > 50, speed < 50)), 로그인부터 로그아웃까지의 사용자 세션([action = 'login', action = 'logout']), 경보 발생부터 복구까지의 과정([event = 'trigger', event = 'recovery'])처럼 업무 이벤트가 구간 경계를 정할 때 쓸 수 있다. 예시의 괄호 조건에 따라 종료 이벤트를 포함하거나 제외할 수 있다.

세션 종료 때 상태를 해제하기

세션 윈도우는 닫힐 때까지 각 윈도우의 상태를 유지한다. 분산 추적처럼 동시에 진행되는 키가 수백만 개이고 각 키에서 한 번만 결과를 내면 되는 작업에는 이 상태가 부담이 될 수 있다. Timeplus는 EMIT AFTER SESSION CLOSE 정책을 이런 경우에 사용하도록 제시한다. 집계를 출력한 뒤 해당 키를 삭제한다.

분산 추적에서 하나의 추적은 여러 스팬으로 이뤄지며, 대개 몇 초 안에 끝난다. 하지만 동시에 수백만 개의 추적이 진행될 수 있고 각 추적은 요약을 한 번만 출력하면 된다. Timeplus 설명에 따르면 윈도우 방식은 추적마다 상태를 유지하지만, 이 정책은 키가 닫히는 즉시 상태를 해제한다.

키별 세션은 이벤트 시간 범위가 MAXSPAN에 도달하거나, 세션 생성 뒤 실제 경과 시간이 TIMEOUT을 넘거나, end_col = true인 이벤트가 도착하면 닫힌다. 집계를 출력한 뒤 해시 테이블에서 키를 삭제한다. 예시에서는 각 entity_id를 키로 삼고, state_id = 'A'인 기간을 묶어 종료 이벤트를 session_end로 표시한다. include_session_end = false 설정에 따라 세션을 닫는 B 이벤트는 집계에서 제외된다. e-1의 A 이벤트 세 개에 대해 첫 번째와 마지막 A 이벤트의 시각과 개수를 출력한다.

Timeplus는 default_hash_table = 'hybrid' 설정으로 상태를 디스크에 넘길 수 있어 메모리 사용량이 현재 활성 키 수에 따라 달라진다고 설명한다. 수천만 개 엔터티가 각각 몇 분 동안 활성 상태인 작업에서 상태가 끝없이 쌓이지 않도록 하는 방법이라고 덧붙였다. 이 방식은 추적·스팬 집계, 대량의 단기 키, 키마다 생애 중 한 번만 결과를 내는 작업에 적합하다.

시나리오구간을 정하는 기준Timeplus 연산자
상태 체류 구간상태 값 변화lag() + PARTITION BY
기기 온라인 구간일정 시간 이상 비활동시간 간격 기준 session()
경보 발생부터 복구까지명시적인 시작·종료 조건조건을 지정한 session()
대량의 단기 추적이벤트 시간 범위, 실제 경과 시간 또는 종료 이벤트. 키가 닫히면 상태 해제EMIT AFTER SESSION CLOSE

스트리밍 엔진이 상태를 관리하는 방식

배치 쿼리에서는 기록을 정렬하고 lag()로 이전 행을 가져온 뒤 상태가 바뀐 지점을 찾으면 된다. 스트림에서는 특정 엔터티의 이전 행이 방금 도착한 블록에 있을 수도, 그보다 앞선 블록이나 며칠 전 데이터에 있을 수도 있다. 전체 이력을 한 번에 정렬해 두고 처리할 수 없으므로, 이전 행을 누가 보유하는지가 핵심이다.

글은 ClickHouse 같은 엔진에서는 사용자가 상태 테이블을 만들고 각 삽입 때 이를 다시 읽어야 하는 경우가 있다고 설명한다. Timeplus 스트리밍 쿼리는 장시간 실행되는 상태 보유 연산자로 이 상태 관리를 엔진 안에서 처리한다. PARTITION BY나 GROUP BY를 거친 키별 이전 행, 중간 집계, 윈도우 경계를 삽입 블록과 무관하게 유지한다. 상태는 체크포인트에 주기적으로 저장되며, 재시작 뒤 체크포인트부터 이어서 처리한다. 이벤트 시간과 워터마크를 사용하는 윈도우에서는 늦게 도착하거나 순서가 뒤바뀐 이벤트의 처리 규칙도 정해진다.

갭과 아일랜드 문제는 기기의 상태 유지 시간, 활동 세션, 워크플로 기간, 비활동 감지 등 여러 이벤트 처리 작업에 적용된다. 구간을 만들려면 이벤트 사이의 맥락을 유지하고 결과가 언제 완성됐는지 정하는 규칙이 필요하다. ClickHouse 증분 구체화 뷰는 들어오는 블록을 처리하지만, 구간이 블록 경계를 넘으면 저장 상태와 시드 조회, 엔터티별 일관된 라우팅이 필요하다는 것이 글의 설명이다. Timeplus는 이 맥락을 연속 쿼리 안에서 유지하지만, 개발자는 어떤 순서와 시간 의미를 적용할지 선택해야 한다.

예제는 오픈소스 스트리밍 SQL 엔진 Timeplus Proton으로 시도할 수 있다. 경계 규칙 하나를 정한 뒤 규칙을 바꿔 결과 구간이 어떻게 달라지는지 비교하는 방식이다.