Apache Flink — 스트림을 엔진으로 돌린다 · 스트림 조인 · 이론
스트림 조인 — 무엇을 얼마나 오래 기억하는가
한 줄 요약
스트림 조인은 어느 쪽 행을 얼마나 오래 기억하느냐로 갈린다. 시간 조건이 없는 일반 조인은 양쪽을 영원히 쥐고, 구간 조인은 시간 범위가 지나면 버리며, 이벤트 시간 temporal join 은 오른쪽의 버전만 쥐고 왼쪽 행을 "그 시각의 버전" 하나와 붙인다.
왜 이게 필요했나
배치에서 조인은 쉽다. 두 표가 이미 다 있으니 한쪽으로 해시 표를 만들고 다른 쪽을 훑으면 끝난다. 스트림에서는 두 표가 끝나지 않는다. 주문이 들어온 순간 그 주문의 배송은 아직 없고, 사용자 정보는 한참 전에 왔을 수도, 한참 뒤에 올 수도 있다. 그래서 엔진은 "지금 온 행과 짝이 될 수 있는 반대편 행" 을 어딘가에 쌓아 두어야 하고, 그 더미가 곧 상태다.
문제는 언제 버리느냐다. 짝이 영영 안 올지도 모르는 행을 무한정 쥐면 상태가 끝없이 자란다. 그렇다고 아무 때나 버리면 뒤늦게 온 짝을 놓친다. Flink SQL 이 조인을 여러 종류로 나눠 둔 이유가 이것이다 — 쿼리가 시간에 대해 무엇을 약속하느냐에 따라 엔진이 안전하게 버릴 수 있는 것이 달라진다.
어떻게 동작하나
일반 조인(regular join) 은 가장 자유롭다. 공식 문서의 표현대로 한쪽에 새 행이 오면 반대편의 과거와 미래의 모든 행과 맞춰 본다. 그래서 오후에 가입한 사용자의 오전 주문도 붙는다 — 시간을 보지 않기 때문이다. 대가도 문서에 그대로 적혀 있다. 양쪽 입력을 영원히 상태에 둬야 한다. 상태 TTL 로 줄일 수는 있지만 그러면 결과가 틀릴 수 있다. 실행 계획을 JSON 으로 뽑아 보면(COMPILE PLAN) 조인 노드에 leftState·rightState 두 상태가 TTL 0 ms(지우지 않음)와 함께 적혀 있다.
구간 조인(interval join) 은 등호 조건 하나와 양쪽 시간을 묶는 범위를 요구한다. s.ship_time BETWEEN o.order_time AND o.order_time + INTERVAL '2' HOUR 가 그 예다. 입력은 시간 속성이 있는 append-only 표여야 한다. 시간 속성은 거의 단조 증가하므로, 워터마크가 (주문 시각 + 2시간)을 지나면 그 주문과 짝이 될 배송은 더 오지 않는다고 확정하고 상태에서 지운다. 실측으로 확인한 경계도 있다. BETWEEN 은 양끝을 포함해 지연 0초와 정확히 7200초인 배송은 붙고 7201초는 안 붙는다. LEFT JOIN 으로 바꾸고 시간 조건을 ON 에 두면, 창이 닫힐 때까지 짝을 못 찾은 주문이 null 과 함께 한 번 나온다. 결과는 끝까지 append-only 다.
이벤트 시간 temporal join 은 왼쪽(주문) 행 하나를 오른쪽 버전 테이블의 "그 시각에 유효했던 버전" 하나와 붙인다. 문법은 SQL:2011 의 FOR SYSTEM_TIME AS OF o.order_time 이다. 버전 테이블이 되려면 기본 키와 이벤트 시간 속성이 있어야 한다. 환율 파일처럼 append-only 인 원천에는 기본 키를 걸 수 없는데, 문서는 여기서 요령을 알려 준다. 통화별로 ROW_NUMBER() OVER (PARTITION BY currency ORDER BY update_time DESC) = 1 인 중복 제거 뷰를 만들면, 옵티마이저가 currency 를 기본 키로 추론해 버전 뷰로 쓴다.
CREATE TEMPORARY VIEW rates_v ASSELECT currency, rate, update_time FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY currency ORDER BY update_time DESC) AS rn FROM rates)WHERE rn = 1;SELECT o.order_id, r.rateFROM orders o JOIN rates_v FOR SYSTEM_TIME AS OF o.order_time AS r ON o.currency = r.currency;실측 결과, 주문은 update_time <= order_time 인 버전 중 가장 늦은 것과 붙었다(환율이 주문과 같은 초에 바뀌면 새 환율). 첫 환율보다 이른 주문은 붙을 버전이 없어 안쪽 조인에서 빠졌다. 문서의 말대로 이 조인은 양쪽 워터마크가 방아쇠이고, 오른쪽이 나중에 바뀌어도 이미 낸 결과를 고치지 않는다. 오래된 버전은 더 필요 없어지면 상태에서 지워진다. 계획 JSON 을 보면 temporal join 노드와 구간 조인 노드에는 TTL 이 붙은 state 항목이 아예 없다 — 이 둘은 TTL 이 아니라 시간으로 상태를 정리한다.
같은 버전 뷰를 FOR SYSTEM_TIME AS OF 없이 붙이면 그냥 일반 조인이다. 환율이 바뀔 때마다 지난 주문까지 새 환율로 다시 계산되어 철회(-U)와 갱신(+U)이 쏟아지고, 최종 결과는 마지막 환율로 환산한 값이 된다.
| 조인 | 기억하는 것 | 결과를 고치나 | 상태를 지우는 근거 |
| --- | --- | --- | --- |
| 일반 | 양쪽 전부 | 고친다(갱신 입력이면) | TTL 뿐(정확성을 잃을 수 있음) |
| 구간 | 시간 범위 안의 행 | 안 고친다 | 워터마크가 범위 끝을 지남 |
| 이벤트 시간 temporal | 오른쪽의 필요한 버전 | 안 고친다 | 워터마크가 지나 필요 없어진 버전 |
현장에서 만나는 모습
가장 흔한 사고는 "주문에 상품 정보를 붙였을 뿐인데 상태가 몇 달째 자란다" 다. 원인은 거의 늘 일반 조인이다. 상품 표는 작아도 주문 쪽이 영원히 쌓인다. 차원 정보가 그 시점의 값이면 되는 경우라면 temporal join 이 맞는 도구다.
두 번째는 매출 재계산 사고다. 환율이나 가격표를 일반 조인으로 붙여 놓으면, 가격이 바뀐 순간 지난 주문의 금액이 조용히 바뀐다. 대시보드의 어제 매출이 오늘 달라져 있다는 신고가 오면 조인 종류부터 본다. 이 실습에서 같은 USD 주문을 두 방식으로 환산하면 합계가 실제로 달라진다.
세 번째는 구간 조인의 경계다. "2시간 안에 배송" 을 < 로 쓰느냐 BETWEEN 으로 쓰느냐에 따라 정확히 2시간에 나간 배송이 갈린다. 운영 지표의 정의서와 SQL 의 부등호를 한 번은 맞춰 봐야 한다.
다음 실습에서 할 것
네 원천을 정의하고, 일반 조인으로 등급별 주문을 모은다. 구간 조인으로 2시간 안의 배송을 붙이고, 바깥 구간 조인으로 제때 배송되지 않은 주문을 찾는다. 환율을 버전 뷰로 만들어 temporal join 으로 주문 시각의 환율을 붙이고, 같은 뷰를 일반 조인으로 붙였을 때 합계가 어떻게 달라지는지 본다. 세 조인의 실행 계획을 JSON 으로 뽑아 상태 항목을 비교하고, 숫자를 보고서로 정리한다.