Streaming Semantics
exactly-once는 대부분 at-least-once와 멱등성으로 만든다
- Haram Lee
- 2026-07-27
- studies / Topics
데이터 파이프라인 이야기에는 늘 같은 어휘가 나온다. at-least-once, exactly-once, 멱등성, 워터마크. 이 글은 이 어휘들이 서로 어떻게 이어지는지 정리한다.
결론부터 말하면, 실무에서 말하는 exactly-once는 대부분 at-least-once 전달과 멱등한 쓰기의 조합이다.
세 가지 보장 수준
| 수준 | 뜻 | 비용 |
|---|---|---|
| at-most-once | 유실될 수 있지만 중복은 없다 (“보내고 잊기”) | 가장 싸다. 메트릭 정도에만 허용된다 |
| at-least-once | 유실은 없지만 중복될 수 있다 | 실무의 기본값 |
| exactly-once | 유실도 중복도 없다 | 어렵고 비싸다 |
핵심 직관은 이것이다. 중복은 재시도에서 나온다.
유실을 막으려면 성공을 확인할 때까지 재시도해야 한다. 그런데 “보냈지만 확인을 받지 못한” 애매한 순간이 반드시 존재한다. 네트워크가 끊겼다면 상대가 받았는지 알 수 없다. 그때 재시도하면 중복이 생긴다. at-least-once는 공짜로 따라오는 성질이 아니라, 유실을 막는 대가로 중복을 받아들인 결과다.
exactly-once가 어려운 이유
소비자는 두 가지를 저장해야 한다. 어디까지 읽었는지를 나타내는 offset과 처리 결과인 출력이다. 이 둘을 원자적으로 묶지 못하면 둘 중 하나가 먼저 기록되고, 그 사이에 죽는 경우가 생긴다.
출력 먼저, offset 나중 → 사이에 죽으면 재시작 후 같은 것을 또 처리한다 = 중복 (at-least-once)
offset 먼저, 출력 나중 → 사이에 죽으면 읽은 것으로 치고 건너뛴다 = 유실 (at-most-once)그래서 exactly-once의 구현은 결국 둘 중 하나다.
- offset과 출력을 한 트랜잭션으로 묶는다.
- 중복을 허용하되 무해하게 만든다.
at-least-once와 멱등성
멱등(idempotent)하다는 것은 같은 연산을 두 번 적용해도 결과가 같다는 뜻이다. sink가 멱등이면 중복 전달이 와도 최종 상태는 정확하다. at-least-once 전달에 멱등한 쓰기를 더하는 것이 실무의 표준 패턴이다.
| sink 쓰기 방식 | 멱등인가 |
|---|---|
append (INSERT) | 아니다. 중복 행이 생긴다 |
키 기반 MERGE, upsert | 그렇다. 같은 키가 두 번 와도 최종 상태가 같다 |
| 결정적인 경로에 파일 overwrite | 그렇다. 같은 파티션을 다시 돌려도 결과가 같다 |
카운터 증가 (+=) | 아니다. 두 번 더해진다 |
흔한 CDC 구성이 정확히 이 패턴이다. 변경 이벤트는 at-least-once로 흘려보내고, 웨어하우스에서 키 기반 MERGE로 최종 상태를 만든다. 중복 이벤트가 와도 같은 키의 같은 상태로 덮어쓸 뿐이다.
배치도 같은 원리다. 스케줄러가 태스크를 다시 실행해도 안전하려면 그 태스크의 쓰기가 멱등이어야 한다. 날짜 파티션을 통째로 overwrite하는 태스크는 몇 번을 다시 돌려도 결과가 같다. 재시도 가능한 시스템은 멱등한 쓰기의 집합이라는 말은 스트리밍만의 이야기가 아니다.
엔진이 제공하는 장치
- Flink 체크포인트: 파이프라인 전체의 상태와 offset을 주기적으로 일관된 스냅샷으로 저장한다. 장애가 나면 마지막 체크포인트로 되감고 그 이후를 다시 처리한다. 엔진 내부 상태는 이것으로 exactly-once가 되지만, sink까지의 end-to-end exactly-once는 sink가 트랜잭션이나 멱등성을 지원해야 완성된다.
- Spark Structured Streaming: 마이크로배치마다 offset 로그와 커밋 로그를 체크포인트 디렉터리에 기록한다. 재시작하면 커밋되지 않은 배치를 다시 실행한다. 역시 sink의 멱등성과 결합해야 end-to-end exactly-once가 된다.
- Kafka: producer 멱등성으로 중복 발행을 막고, 트랜잭션과
read_committed로 Kafka 안에서의 exactly-once를 만든다. 다만 Kafka 밖의 저장소로 나가는 순간 다시 sink의 문제가 된다.
Flink에서 checkpoint 모드와 sink의 delivery guarantee라는 두 설정이 각각 무엇을 정하는지는 Flink 시리즈 4편에서 따로 다룬다.
멱등성 키를 무엇으로 만들 것인가
멱등성을 만들려면 “같은 작업"을 알아볼 키가 필요하다. 이 키에 무엇을 넣느냐에 따라 시스템이 막아 주는 것과 허용하는 것이 달라진다.
예를 들어 웨어하우스에 MERGE를 실행하는 배치 태스크가 있다고 하자. 스케줄러의 재시도나 수동 재실행 때문에 같은 구간의 MERGE가 동시에 두 번 돌면 중복 적재가 생길 수 있다. BigQuery는 job id가 같은 job을 다시 만들 수 없으므로, job id를 결정적으로 만들면 동시 실행을 막을 수 있다. 태스크 이름과 처리 구간으로 job id를 만들면 같은 구간의 두 번째 실행은 충돌로 거부된다.
그런데 여기에 쿼리 설정의 해시까지 넣을지는 선택의 문제다.
- 넣으면 복구가 빠르다. 쿼리에 치명적인 결함이 있어서 고친 쿼리를 배포했다고 하자. 해시가 없다면 job id가 같아 충돌하므로, 이전 job이 끝나거나 정리될 때까지 고친 쿼리를 바로 돌리지 못한다. 쿼리를 바꿔 구간별로 백필할 때도 같은 문제가 생긴다. 해시를 넣으면 쿼리가 바뀐 순간 새 job id가 되어 즉시 다시 실행할 수 있다.
- 넣으면 중복 방지가 뚫린다. 같은 이유로, 설정이 바뀐 직후에는 이전 설정의 job과 새 설정의 job이 서로 다른 id를 가진다. 둘이 동시에 돌면 막을 방법이 없어 중복 적재가 생길 수 있다.
“막는다"와 “빨리 복구한다"가 같은 키에서 부딪히는 것이다. 정답이 하나로 정해져 있지는 않다. 한쪽을 고르고, 그 대가를 코드 주석에 적어 두는 것이 현실적인 답이다. 그래야 다음에 이 코드를 읽는 사람이 “왜 설정을 바꾼 직후에 중복이 생겼지?“를 다시 추적하지 않아도 된다.
워터마크: 늦은 이벤트를 언제까지 기다릴 것인가
이벤트 시간(일어난 시각)과 처리 시간(도착한 시각)은 다르다. 모바일 앱 이벤트는 기기가 오프라인이었다면 몇 시간 늦게 도착하기도 한다.
워터마크는 “이 시각 이전의 이벤트는 이제 다 왔다고 간주한다"는 선언이다. 이벤트 시간 기준으로 10분의 유예를 두는 식이다.
- 워터마크가 지나간 윈도우는 닫고 결과를 확정하며 상태를 정리한다.
- 그보다 늦게 온 이벤트는 버리거나 별도로 처리한다.
트레이드오프는 분명하다. 유예를 길게 잡으면 정확하지만 지연과 상태가 늘어난다. 짧게 잡으면 빠르지만 늦은 이벤트를 잃는다.
배치에도 같은 개념이 있다. “다음 날 새벽에 어제 파티션을 집계한다"는 하루짜리 워터마크인 셈이다.
파이프라인 리뷰 체크리스트
- 이 파이프라인의 전달 보장은 무엇인가? 대부분은 at-least-once다.
- sink 쓰기는 멱등인가? append라면 중복 대비책은 무엇인가?
- 재시작하면 어디서부터 다시 읽는가? 체크포인트와 offset은 어디에 있는가?
- 늦은 이벤트 정책은 무엇인가? 워터마크, 재집계, 무시 중 무엇인가?
- 백필과 재실행이 안전한가? 멱등성 키에 무엇이 들어 있고, 그 선택의 대가는 무엇인가?