Toss의 Apache Flink + RocksDB 튜닝 — 광고 집계를 일주일로 늘린 방법
광고가 같은 사람에게 너무 많이 노출되면 불쾌하다. 이를 막는 게 Frequency Capping이다. "이 광고는 하루에 3번까지만 보여준다"는 규칙이 있다면, 실시간으로 노출 횟수를 세어야 한다.
토스는 이 집계를 1분에서 7일까지의 슬라이딩 윈도우로 처리한다. 그것도 4번의 Redis 조회를 1번으로 줄이면서. 그 과정에서 Flink와 RocksDB를 어떻게 튜닝했는지 정리한다.
기존 구조와 문제
기존엔 구간별로 다른 파이프라인을 썼다.
단기 (1분~1시간) → Flink 실시간 집계
장기 (1일~7일) → Airflow 배치 (75회/일 실행)
서빙 → Head + Mid + Tail 3개 Redis 조회 합산목표는 1분~7일 슬라이딩 집계를 단일 Flink 스트림으로 통합하고, 서빙 시 Redis 조회를 1번으로 줄이는 것이었다. Event Time 기반으로 재처리 시에도 정합성을 보장해야 했다.
앱 분리 결정
구간별로 병목 패턴이 완전히 다르다. 하나의 앱으로 통합하면 서로 다른 병목이 충돌한다. 3개 앱으로 분리했다.
| 앱 | 구간 | 주요 병목 |
|---|---|---|
| minutes | 1분~30분 | Write 집약적 |
| hours | ~12시간 | CPU 포화 |
| days | 7일 | State 규모 |
비유하면 짧은 거리용 소형차, 장거리용 대형 트럭, 화물용 컨테이너를 따로 쓰는 것과 같다. 같은 엔진으로 세 용도를 다 커버하려면 어느 것도 제대로 못 한다.
초기 적재 정합성 — 재처리의 함정
새 파이프라인을 켤 때 과거 데이터를 채워야 한다(Backfill). 문제는 채우는 도중에 만료 타이머가 발화하면 아직 다 쌓이지 않은 집계에서 감소가 먼저 일어난다는 것이다. 값이 음수가 되는 사태가 생긴다.
2단계로 나눠서 해결했다.
- Backfill: 과거 데이터를 채운다. 이 단계에서는 Redis 쓰기를 하지 않는다.
- Catch-up: Backfill 완료 후, 실시간 스트림을 따라잡으며 Redis에 쓰기 시작한다.
Redis 쓰기: eventTime 기준 (watermark 아님)
withIdleness: 60초
timerState TTL: 슬라이딩 윈도우 만료보다 충분히 길게[💡 잠깐! 이 용어는?] Watermark: Flink에서 "이 시각 이전의 이벤트는 모두 도착했다"를 선언하는 마커. Event Time 기반 윈도우의 완료 시점을 결정한다.
RocksDB 튜닝
Flink의 상태 백엔드로 RocksDB를 쓴다. 각 앱마다 병목이 달랐고, 튜닝도 각각 달랐다.
minutes 앱 — Write Stall 해결
Write Buffer가 가득 차면 Flink가 멈추는 Write Stall이 발생했다.
# 변경 전
managed-memory: 500MB
write-buffer-ratio: 0.25
# 변경 후
managed-memory: 1200MB
write-buffer-ratio: 0.50결과: Block Cache Hit Rate가 6264%에서 99100%로 올라갔다.
hours 앱 — FilterBlock Cache Miss
async-profiler로 프로파일링하니 CPU의 96.2%가 FilterBlock 디스크 읽기에 쏠렸다.
[💡 잠깐! 이 용어는?] FilterBlock: RocksDB의 Bloom Filter 인덱스. "이 키가 이 SST 파일에 있는가?"를 디스크 읽기 없이 판단한다. 캐시 미스 시 디스크 I/O가 폭증한다.
partitioned-index-filters: true # 캐시 미스당 read를 ~2,750배 감소
managed-memory: 1GB → 3GB (단계적)
target-file-size-base: 64MB → 256MB # SST 파일 수 180개 → 40개partitioned-index-filters를 켜면 전체 필터 블록 대신 필요한 파티션만 읽는다. 캐시 미스 비용을 대폭 줄인다.
days 앱 — 규모 자체가 병목
State가 커서 SST 파일이 기하급수적으로 늘어났다.
target-file-size-base: 256MB # SST 파일 수 75% 감소
managed-memory: 12GB # Block Cache 대부분 수용7일치 집계 State의 live SST 파일 크기가 68GB, Savepoint는 220~230GB였다. 파일 수를 줄이는 것만으로 Compaction 부하가 크게 줄었다.
공통 최적화 3가지
1. Direct I/O 활성화
K8s 환경에서 OS Page Cache를 우회한다. 메모리 사용량을 Flink가 직접 제어할 수 있다. 단, Cache Miss 비용이 커지므로 partitioned-index-filters가 필수다.
2. 직렬화 방식 변경: Kryo → POJO
CPU 점유율: 20% → 12%
Block Cache 수용량: 2.2배 향상
Changelog I/O: 60% 감소Kryo는 범용이지만 느리다. POJO 직렬화는 타입 정보를 미리 알기 때문에 훨씬 빠르다.
3. State 키 구조 단순화
// 변경 전: 중첩 맵
MapState<String, Map<String, Map<String, Any>>>
// 변경 후: 플랫 구조
MapState<String, AdsFrequencyStats>중첩 맵은 직렬화/역직렬화 비용이 크다. 플랫하게 바꾸면 Block Cache도 더 효율적으로 쓴다.
Flink Changelog (DSTL)
Checkpoint가 전체 State를 저장하면 크기가 220~230GB였다. 변경분만 저장하는 DSTL(Distributed State Transfer Log)로 바꿨다.
전체 Checkpoint: 220~230GB
Incremental Checkpoint (DSTL): ~70MB70MB면 네트워크 부하도, 스토리지도 완전히 다른 차원이다.
minutes: materialization 5분 주기
hours·days: materialization 10분 주기
Savepoint: NATIVE 대신 CANONICAL 사용NATIVE Savepoint는 ID 갱신 교착 문제가 있어서 CANONICAL로 바꿨다.
결과
| 지표 | 변경 전 | 변경 후 |
|---|---|---|
| Airflow DAG 실행 | 75회/일 | 25회/일 |
| Redis 조회 | 최대 4회 | 1회 |
| Cache Hit Rate (minutes) | 62~64% | 99~100% |
| CPU 점유 (직렬화) | 20% | 12% |
| SST 파일 수 (hours) | ~180개 | ~40개 |
| Checkpoint 크기 | ~230GB | ~70MB |
Head·Mid·Tail 세 계층을 합산하던 서빙이 단일 Redis 조회로 단순해졌다.
마무리
RocksDB 튜닝은 "메모리를 더 주면 된다"는 단순한 접근이 아니었다. 각 앱의 병목 패턴을 먼저 파악하고, 그에 맞는 설정을 찾는 과정이었다. minutes는 Write 버퍼, hours는 FilterBlock 캐시, days는 파일 수가 각각 핵심이었다.
Flink + RocksDB로 대규모 상태를 다룬다면 async-profiler로 프로파일링부터 시작하는 걸 권장한다. 어디서 CPU를 쓰는지 보이면 튜닝 방향이 명확해진다.
참고:
- Apache Flink + RocksDB 튜닝: https://toss.tech/article/flink-realtime-frequency-capping
관심 있을 만한 포스트
Anthropic Managed Agents — AI 에이전트 인프라를 플랫폼에 넘기다
오케스트레이션, 세션 상태, 샌드박스를 직접 구축하지 않아도 되는 Anthropic 관리형 에이전트 플랫폼의 구조와 트레이드오프를 분석한다.
Claude Code + Figma MCP — UX 라이팅 리소스 50% 절감 실전기
수치화된 톤 스펙트럼과 Figma MCP 자동화로 반복 작업을 AI에게 넘기고 팀이 맥락과 사용자 경험에 집중하게 만든 과정을 정리한다.
Claude Code 프롬프트 재현성 — 암묵지를 제거하는 자동 튜닝 워크플로우
작성자와 평가자를 분리하고 서브에이전트로 반복 실행해 프롬프트 품질을 객관적으로 끌어올리는 실전 방법론을 정리한다.
Google Cloud 멀티 에이전트 — A2A와 MCP로 만드는 5가지 통합 패턴
에이전트 간 통신을 위한 A2A와 외부 도구 연결을 위한 MCP가 Google Cloud에서 어떻게 통합되는지, 5가지 패턴으로 정리한다.
mini-swe-agent — 100줄짜리 AI가 GitHub 이슈를 해결하는 방법
SWE-bench verified 74% 달성에도 핵심 코드가 100줄인 최소주의 AI 코딩 에이전트의 아키텍처와 동작 원리를 분석한다.
Agent Harness Engineering — AI 에이전트 성능을 결정하는 진짜 변수
모델보다 harness가 에이전트 성능을 더 크게 좌우한다는 사실을 Terminal Bench 결과와 함께 검증한다.
AWS DevOps Agent — MTTR 75% 감소를 만든 자율 인시던트 대응 에이전트
Amazon Bedrock AgentCore 기반의 AWS DevOps Agent가 인시던트를 자율 조사하는 방식과 MCP 확장, 94% 루트 코즈 정확도를 분석한다.
Cloudflare Agent Memory — 에이전트가 기억을 갖는 방법
Cloudflare가 공개한 관리형 Agent Memory 서비스의 수집·검색 파이프라인과 실제 API 사용법을 분석한다.