토스 기술 블로그에 올라온 글 하나가 눈에 걸렸다. 광고 Frequency Capping을 위해 Apache Flink 앱을 세 개로 분리했다는 이야기인데, 결제 앱이 왜 이 수준의 스트리밍 인프라를 만들고 있는지가 더 흥미롭다.

Frequency Capping, 왜 7일이 어려운가

Frequency capping은 단순한 개념이다. 같은 광고를 한 유저에게 과도하게 노출하지 않는 것. 구글이든 메타든 수십 년 전부터 해왔다. 문제는 시간 윈도우의 크기다.

1분 윈도우라면 인메모리 카운터로 충분하다. 1시간까지도 어찌저찌 된다. 그런데 7일? 수백만 유저의 광고 노출 이벤트를 168시간짜리 슬라이딩 윈도우로 집계하면서, 이벤트가 들어올 때마다 밀리초 단위로 "이 사람이 지난 일주일 동안 이 광고를 몇 번 봤는지" 응답해야 한다. 상태(state)가 폭발적으로 커지고, 메모리만으로는 감당이 안 된다.

왜 하나가 아니라 셋인가

토스 광고팀의 선택은 윈도우 크기별로 Flink 앱을 분리하는 것이었다. 짧은 윈도우(1분1시간), 중간 윈도우(1시간1일), 긴 윈도우(1일~7일). 이유는 단순하다 — 각 구간의 병목이 전혀 다르기 때문이다.

짧은 윈도우는 처리량 싸움이다. 이벤트 유입이 빠르지만 상태는 작다. Heap StateBackend면 충분하고, 체크포인트도 가볍다. 밀리초 단위 응답을 유지하면서 초당 수십만 이벤트를 삼키는 게 관건이다.

중간 윈도우부터 상태가 무거워진다. RocksDB StateBackend로 전환해서 디스크를 끌어들인다. 메모리 제약에서 벗어나는 대신 read/write 레이턴시와의 싸움이 시작된다. 블룸 필터 설정, 컴팩션 주기, block cache 사이즈 — 여기서부터 튜닝 지옥이 열린다.

긴 윈도우는 완전히 다른 게임이다. 7일치 상태는 수백 GB에 달할 수 있고, 체크포인트 자체가 병목이 된다. incremental checkpoint를 쓰면서도 interval과 timeout을 넉넉하게 잡아야 하며, state TTL과 이벤트 사전 집계를 결합해 상태 크기를 억제해야 한다. 하나의 앱으로 세 구간을 모두 처리하면 짧은 윈도우의 빠른 응답 요구와 긴 윈도우의 무거운 체크포인트가 서로 발목을 잡는다. 운영 복잡도를 감수하고라도 분리가 답이었다는 게 토스 팀의 결론이다.

RocksDB, 디폴트 설정으로는 안 된다

Flink에서 RocksDB를 상태 백엔드로 쓰는 건 흔한 패턴이지만, 프로덕션 튜닝은 문서만으로 배우기 어렵다.

block.cache-size 디폴트가 8MB다. 프로덕션에서 이 값은 거의 항상 부족하다. 토스 규모라면 256MB 이상은 잡았을 거다. 블룸 필터도 필수 — frequency capping은 "유저 ID + 광고 ID" 키로 조회하는 전형적인 point lookup 패턴이라, 10비트 블룸 필터만 달아도 불필요한 디스크 읽기를 90% 넘게 줄인다.

컴팩션은 LEVEL 방식을 유지하되 L0 파일 수 제한과 max-background-jobs를 올려서 write stall을 방지하는 게 포인트다. 이런 숫자들은 삽질 없이는 나오지 않는다. 기술 블로그의 가치가 여기에 있다.

슈퍼앱은 결국 광고 회사가 된다

토스는 더 이상 송금 앱이 아니다. MAU 2천만의 금융 슈퍼앱이고, 슈퍼앱의 수익 다각화에서 광고는 빠질 수 없는 축이다.

토스애즈가 가진 무기는 퍼스트파티 결제 데이터다. "이 유저가 지난 주에 카페에서 얼마를 썼는지" — 이 수준의 지출 데이터를 광고 타겟팅에 쓸 수 있는 플랫폼은 한국에서 토스와 카카오페이 정도뿐이다. 네이버도 구글도 이건 모른다. 다만 정밀한 타겟팅은 정밀한 노출 제어와 한 세트다. 완벽한 타겟을 찾아놓고 같은 광고를 열 번 보여주면 유저만 이탈한다.

네이버, 카카오 모두 자체 광고 인프라를 고도화하고 있지만, 스트리밍 파이프라인 수준의 기술 상세를 블로그로 공개한 건 토스가 먼저다. 핀테크에서 애드테크로의 확장이 마케팅 슬로건이 아니라 엔지니어링 현실임을 보여준 셈이다. Flink 세 벌을 굴리면서까지 7일 윈도우를 확보한 건, 이 사업을 오래 할 생각이라는 뜻이다.