Toss의 Apache Flink + RocksDB 튜닝 — 광고 집계를 일주일로 늘린 방법

8 min read
Apache FlinkRocksDB실시간 처리광고데이터 엔지니어링
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개 앱으로 분리했다.

구간주요 병목
minutes1분~30분Write 집약적
hours~12시간CPU 포화
days7일State 규모

비유하면 짧은 거리용 소형차, 장거리용 대형 트럭, 화물용 컨테이너를 따로 쓰는 것과 같다. 같은 엔진으로 세 용도를 다 커버하려면 어느 것도 제대로 못 한다.


초기 적재 정합성 — 재처리의 함정

새 파이프라인을 켤 때 과거 데이터를 채워야 한다(Backfill). 문제는 채우는 도중에 만료 타이머가 발화하면 아직 다 쌓이지 않은 집계에서 감소가 먼저 일어난다는 것이다. 값이 음수가 되는 사태가 생긴다.

2단계로 나눠서 해결했다.

  1. Backfill: 과거 데이터를 채운다. 이 단계에서는 Redis 쓰기를 하지 않는다.
  2. 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이 발생했다.

minutes 앱 RocksDB 설정
# 변경 전
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가 폭증한다.

hours 앱 RocksDB 설정
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 파일이 기하급수적으로 늘어났다.

days 앱 RocksDB 설정
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 키 구조 단순화

State 키 구조 변경
// 변경 전: 중첩 맵
MapState<String, Map<String, Map<String, Any>>>
 
// 변경 후: 플랫 구조
MapState<String, AdsFrequencyStats>

중첩 맵은 직렬화/역직렬화 비용이 크다. 플랫하게 바꾸면 Block Cache도 더 효율적으로 쓴다.


Checkpoint가 전체 State를 저장하면 크기가 220~230GB였다. 변경분만 저장하는 DSTL(Distributed State Transfer Log)로 바꿨다.

Checkpoint 크기 비교
전체 Checkpoint: 220~230GB
Incremental Checkpoint (DSTL): ~70MB

70MB면 네트워크 부하도, 스토리지도 완전히 다른 차원이다.

DSTL 설정
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를 쓰는지 보이면 튜닝 방향이 명확해진다.


참고:

관심 있을 만한 포스트

Anthropic Managed Agents — AI 에이전트 인프라를 플랫폼에 넘기다

오케스트레이션, 세션 상태, 샌드박스를 직접 구축하지 않아도 되는 Anthropic 관리형 에이전트 플랫폼의 구조와 트레이드오프를 분석한다.

AnthropicAI 에이전트

Claude Code + Figma MCP — UX 라이팅 리소스 50% 절감 실전기

수치화된 톤 스펙트럼과 Figma MCP 자동화로 반복 작업을 AI에게 넘기고 팀이 맥락과 사용자 경험에 집중하게 만든 과정을 정리한다.

Claude CodeFigma MCP

Claude Code 프롬프트 재현성 — 암묵지를 제거하는 자동 튜닝 워크플로우

작성자와 평가자를 분리하고 서브에이전트로 반복 실행해 프롬프트 품질을 객관적으로 끌어올리는 실전 방법론을 정리한다.

Claude Code프롬프트 엔지니어링

Google Cloud 멀티 에이전트 — A2A와 MCP로 만드는 5가지 통합 패턴

에이전트 간 통신을 위한 A2A와 외부 도구 연결을 위한 MCP가 Google Cloud에서 어떻게 통합되는지, 5가지 패턴으로 정리한다.

멀티 에이전트A2A

mini-swe-agent — 100줄짜리 AI가 GitHub 이슈를 해결하는 방법

SWE-bench verified 74% 달성에도 핵심 코드가 100줄인 최소주의 AI 코딩 에이전트의 아키텍처와 동작 원리를 분석한다.

AI 에이전트SWE-bench

Agent Harness Engineering — AI 에이전트 성능을 결정하는 진짜 변수

모델보다 harness가 에이전트 성능을 더 크게 좌우한다는 사실을 Terminal Bench 결과와 함께 검증한다.

AI AgentHarness

AWS DevOps Agent — MTTR 75% 감소를 만든 자율 인시던트 대응 에이전트

Amazon Bedrock AgentCore 기반의 AWS DevOps Agent가 인시던트를 자율 조사하는 방식과 MCP 확장, 94% 루트 코즈 정확도를 분석한다.

AWSDevOps

Cloudflare Agent Memory — 에이전트가 기억을 갖는 방법

Cloudflare가 공개한 관리형 Agent Memory 서비스의 수집·검색 파이프라인과 실제 API 사용법을 분석한다.

CloudflareAI Agent