가상 면접 사례로 배우는 대규모 시스템 설계 기초 2편 정리 (6장 광고 클릭 이벤트 집계)
— 18 min read
『가상 면접 사례로 배우는 대규모 시스템 설계 기초 2』 6장을 스터디에서 같이 보며 정리한 내용이다. 책의 흐름을 따라가되, 이해를 돕기 위해 예시는 직접 만들어 붙였다.
한 줄 요약
페이스북·구글 규모의 광고 클릭 이벤트를 실시간으로 모아 → 1분 단위로 세고 → 정확하게 저장해서 과금과 입찰(RTB)에 쓸 수 있게 만드는 시스템을 설계하는 장이다.
5장(지표 모니터링)과 구조는 비슷하지만 결정적인 차이가 하나 있다. 이 숫자는 곧 돈이다. 모니터링 그래프는 몇 건 빠져도 괜찮지만, 광고 클릭 수가 틀리면 광고주에게 돈을 잘못 청구하게 된다. 그래서 이 장의 절반은 "어떻게 하면 정확히 한 번만 셀까"에 대한 이야기다.
1단계: 요구사항
기능 요구사항
- 특정 광고의 최근 M분 동안 클릭 수 집계
- 매분 가장 많이 클릭된 상위 100개 광고 집계
- 위 두 집계를 IP,
user_id, 국가 등으로 필터링
비기능 요구사항
- 정확성: 과금과 RTB에 쓰이므로 집계 결과가 정확해야 한다
- 지연되거나 중복된 이벤트를 제대로 처리해야 한다
- 안정성: 일부 장애가 생겨도 시스템은 버텨야 한다
- 지연 시간: 전체 처리 지연은 길어도 수 분 이내
개략적 추정
| 항목 | 값 |
|---|---|
| 일간 광고 클릭 | 10억 건 (DAU 10억 × 1인당 하루 1클릭 가정) |
| 광고 수 | 약 200만 개 |
| 평균 QPS | 10억 ÷ 10⁵초 ≈ 10,000 |
| 최대 QPS | 평균의 5배 ≈ 50,000 |
| 일간 저장 공간 | 클릭 1건 0.1KB × 10억 = 100GB (월 3TB) |
2단계: 개략적 설계
질의 API
클라이언트(대시보드)가 쓰는 API는 두 개면 충분하다.
| API | 하는 일 |
|---|---|
GET /v1/ads/{ad_id}/aggregated_count | 특정 광고의 기간 별 클릭 수 (from, to, filter) |
GET /v1/ads/popular_ads | 최근 M분 동안 가장 많이 클릭된 N개 광고 (count, window, filter) |
데이터 모델: 원시 데이터 vs 집계 데이터
원시 데이터는 클릭 한 건이 한 줄이다.
| ad_id | click_timestamp | user_id | ip | country |
|---|---|---|---|---|
| ad001 | 2026-10-06 00:00:01 | user1 | 207.148.22.22 | USA |
| ad001 | 2026-10-06 00:00:02 | user1 | 207.148.22.22 | USA |
| ad002 | 2026-10-06 00:00:02 | user2 | 209.153.56.11 | USA |
집계 데이터는 광고별로 1분마다 한 줄이다.
| ad_id | click_minute | count |
|---|---|---|
| ad001 | 202610060000 | 5 |
| ad001 | 202610060001 | 7 |
| 항목 | 원시 데이터만 | 집계 데이터만 |
|---|---|---|
| 장점 | 원본 그대로라 필터링·재계산 가능 | 용량이 작고 질의가 빠름 |
| 단점 | 저장 공간이 크고 질의가 느림 | 파생 데이터라 손실이 있음 (10건 → 1건) |
책의 결론은 둘 다 저장이다.
- 원시 데이터는 백업이다. 버그가 나면 이걸로 다시 계산한다. 오래된 건 냉동 저장소로 옮긴다.
- 집계 데이터는 질의용이다. 대시보드는 이것만 읽는다.
필터링은 미리 정해 둔 필터마다 filter_id를 붙여 (ad_id, click_minute, filter_id, count)로 집계해 두는 스타 스키마 방식으로 처리한다. 예를 들어 filter_id=0012가 "국가=USA"라면, 질의할 때 그 줄만 읽으면 된다.
데이터베이스 선택
- 쓰기가 압도적으로 많다 (평균 10,000 QPS, 최대 50,000 QPS)
- 읽기는 대시보드와 상위 N개 질의 정도
그래서 쓰기에 강하고 수평 확장이 쉬운 카산드라 같은 NoSQL이 맞다. 원시 데이터는 ORC, Parquet, AVRO 같은 칼럼형 포맷으로 S3 같은 저렴한 저장소에 넣는 방법도 있다.
전체 흐름
로그 감시자 → 메시지 큐 ─┬→ 데이터 집계 서비스 → 메시지 큐 → DB 기록 프로세스 → 집계 결과 DB → 질의 서비스 └→ DB 기록 프로세스 → 원시 데이터 DB메시지 큐가 두 개라는 점이 눈에 띈다.
- 첫 번째 큐: 클릭 이벤트를 받아 생산자와 소비자를 분리한다.
- 두 번째 큐: 집계 결과를 받는다. 집계 서비스가 DB에 바로 쓰지 않고 큐에 넣는 이유는 정확히 한 번(exactly-once) 처리를 보장하기 위해서다 (3단계에서 자세히).
집계 서비스: 맵리듀스 DAG
집계 서비스는 작은 노드들을 방향성 비순환 그래프(DAG)로 연결해 만든다.
┌→ 집계 노드 1 (ad_id % 3 == 0) ─┐맵 노드 ───┼→ 집계 노드 2 (ad_id % 3 == 1) ─┼→ 리듀스 노드 → 상위 100개 └→ 집계 노드 3 (ad_id % 3 == 2) ─┘| 노드 | 하는 일 | 예시 |
|---|---|---|
| 맵 | 이벤트를 읽어 걸러내고 변환해서 담당 노드로 보낸다 | ad_id % 3 값으로 라우팅 |
| 집계 | 메모리에서 1분 동안 광고별 클릭 수를 센다 | ad001: 5, ad004: 3 |
| 리듀스 | 여러 집계 노드의 결과를 합쳐 최종 결과를 만든다 | 노드별 상위 3개 × 3 → 전체 상위 3개 |
리듀스 단계 예시: 집계 노드 세 개가 각자 상위 3개를 보내면 리듀스 노드는 9개 중에서 상위 3개만 다시 고르면 된다. 전체 200만 개를 한 노드에서 정렬할 필요가 없다.
3단계: 상세 설계
스트리밍 vs 일괄 처리
이 시스템은 두 가지를 다 쓴다.
| 구분 | 스트리밍 처리 | 일괄 처리 |
|---|---|---|
| 응답성 | 빠름 (초~분) | 느림 (분~시간) |
| 입력 | 끝없이 들어오는 이벤트 | 유한한 크기의 데이터 |
| 쓰임 | 실시간 집계 | 재계산, 대사(reconciliation) |
- 람다 아키텍처: 스트리밍 경로와 일괄 처리 경로를 둘 다 두는 방식. 코드도 두 벌을 관리해야 한다.
- 카파 아키텍처: 하나의 스트림 처리 엔진으로 실시간 처리와 재처리를 모두 한다. 이 장의 설계는 이쪽에 가깝다.
재계산: 집계 서비스에 버그가 있어서 지난 3시간 결과가 틀렸다면, 원시 데이터를 다시 읽어 별도의 집계 서비스로 재계산한다. 실시간 처리에 영향이 가지 않게 따로 돌린다.
시간: 이벤트 시각 vs 처리 시각
| 기준 | 뜻 | 장점 | 단점 |
|---|---|---|---|
| 이벤트 시각 | 클라이언트에서 클릭이 일어난 시각 | 실제 클릭 시각이라 집계가 정확함 | 클라이언트 시계를 믿어야 하고, 늦게 오는 이벤트를 처리해야 함 |
| 처리 시각 | 집계 서버가 이벤트를 처리한 시각 | 서버 시각이라 믿을 수 있음 | 네트워크 지연 때문에 결과가 부정확할 수 있음 |
정확성이 중요하니 이벤트 시각을 쓴다. 그럼 늦게 도착하는 이벤트는 어떻게 할까?
워터마크: 집계 윈도를 조금 더 열어 둔다. 예를 들어 10:00~10:01 윈도를 10:01:00에 바로 닫지 않고 15초 더 기다렸다가 닫는다. 10:00:58에 일어난 클릭이 10:01:10에 도착해도 제 윈도에 들어간다.
- 워터마크가 길면 → 더 정확하지만 지연이 늘어난다
- 워터마크가 짧으면 → 빠르지만 놓치는 이벤트가 생긴다
그래도 놓친 이벤트는 하루 끝의 대사 작업으로 바로잡는다.
집계 윈도
| 윈도 | 모양 | 쓰임 |
|---|---|---|
| 텀블링 윈도 (고정 윈도) | 10:00 | 1분마다 광고별 클릭 수 |
| 슬라이딩 윈도 | 일정 간격으로 미끄러지며 겹친다 | 최근 M분 동안 상위 N개 광고 |
전달 보장: 정확히 한 번
카프카가 제공하는 선택지는 세 가지다.
- 최대 한 번: 잃어버릴 수 있다
- 최소 한 번: 중복이 생길 수 있다
- 정확히 한 번: 잃지도 않고 중복도 없다
1% 중복만 생겨도 하루 수백만 달러 차이가 날 수 있으니 정확히 한 번이 필요하다.
중복은 어디서 생길까?
- 클라이언트: 같은 클릭을 여러 번 보낸다 (악의적이면 광고 사기 탐지의 영역이고, 이 장의 범위 밖)
- 서버 장애: 집계 서비스가 결과를 두 번째 큐에 보낸 뒤, 오프셋을 커밋하기 전에 죽는다 → 재시작하면 같은 이벤트를 또 처리한다
해결책은 다음과 같다.
- 오프셋을 HDFS나 S3 같은 외부 저장소에 기록한다.
- 결과를 큐에 보내기, 오프셋 저장, 소비 확인(ack) 세 가지를 하나의 분산 트랜잭션으로 묶는다. 하나라도 실패하면 전부 롤백한다.
두 번째 메시지 큐가 필요한 이유가 여기 있다. "결과 전송"과 "오프셋 저장"을 원자적으로 묶을 수 있어야 하기 때문이다.
시스템 규모 확장
세 부분을 각각 늘린다.
① 메시지 큐
- 생산자: 인스턴스 수에 제한이 없어 쉽게 늘린다.
- 소비자: 소비자 그룹에 노드를 추가하면 리밸런싱이 일어나 몇 분 걸릴 수 있다 → 사용량이 적은 시간에 한다.
- 브로커:
ad_id를 파티션 키로 써서 같은 광고 의 이벤트가 같은 파티션에 모이게 한다. 파티션 수는 나중에 바꾸면 같은 광고가 다른 파티션으로 갈 수 있으니 미리 넉넉하게 잡는다. 필요하면 지역·사업 유형별로 토픽을 나눈다.
② 집계 서비스
- 노드 안에서
ad_id별로 스레드를 나눠 처리하거나 - 아파치 하둡 YARN 같은 자원 관리자로 노드 자체를 늘린다.
③ 데이터베이스
카산드라는 안정 해시와 가상 노드로 수평 확장을 기본 지원한다. 노드를 추가하면 데이터가 자동으로 재분배된다.
핫스팟 문제
인기 광고 하나(예: 대형 브랜드의 신제품 광고)에 클릭이 몰리면 그 ad_id를 맡은 집계 노드만 과부하가 걸린다.
해결: 해당 집계 노드가 자원 관리자에게 자원을 더 요청한다. 추가 노드 두 개를 받으면 이벤트를 셋으로 나눠 각자 집계하고, 결과를 다시 원래 노드에 모아 합친다.
책에서는 더 정교한 방법으로 전역-지역 집계(Global-Local Aggregation)와 분할 고유 집계(Split Distinct Aggregation)도 언급한다.
결함 내성
집계는 메모리에서 하기 때문에 노드가 죽으면 집계 중이던 결과가 사라진다. 카프카에서 처음부터 다시 읽으면 되지만 오래 걸린다.
해결: 주기적으로 스냅숏을 저장한다. 스냅숏에는 업스트림 오프셋과 현재까지의 집계 상태(예: 최근 1분 상위 N개)가 들어 있다. 노드가 죽으면 새 노드가 마지막 스냅숏을 불러오고, 그 이후 이벤트만 카프카에서 다시 읽는다.
데이터 모니터링 및 정확성
지속적 모니터링
- 지연 시간: 각 단계별 타임스탬프를 기록해 지연 구간을 찾는다
- 메시지 큐 크기: 카프카라면 레코드 처리 지연(records-lag) 지표를 본다
- 집계 노드 자원: CPU, 디스크, JVM 등
대사(reconciliation)
은행이 하루 장부를 맞춰 보는 것처럼, 매일 끝에 일괄 처리 작업으로 원시 데이터를 이벤트 시각 기준으로 정렬해 다시 집계하고, 실시간 집계 결과와 비교한다. 워터마크 때문에 놓친 이벤트가 여기서 잡힌다. 더 높은 정확도가 필요하면 윈도를 더 작게 잡아 시간별로 비교할 수도 있다.
대안적 설계안
책은 면접관이 기대하지 않더라도 다른 선택지를 알고 있으면 좋다며 다음 구조도 소개한다.
로그 모니터 → 메시지 큐 → 위험성 통제 엔진 ─┬→ 하이브 → 일래스틱서치 ← 데이터 과학자 └→ 클릭하우스 → 고객 대상 애널리틱스광고 클릭 데이터를 하이브에 저장하고, 빠른 질의를 위해 일래스틱서치 계층을 얹는다. 집계는 클릭하우스나 드루이드 같은 OLAP 데이터베이스가 맡는다.
4단계: 마무리
이 장에서 가져갈 다섯 가지.
- 원시 데이터 + 집계 데이터 둘 다 저장: 원시는 백업과 재계산용, 집계는 빠른 질의용.
- 맵리듀스 DAG로 집계: 맵 → 집계 → 리듀스로 나눠 수평 확장한다.
- 이벤트 시각 + 워터마크: 정확성을 위해 이벤트 시각을 쓰고, 늦게 오는 이벤트는 워터마크와 대사로 처리한다.
- 정확히 한 번 처리: 결과 전송과 오프셋 저장을 분산 트랜잭션으로 묶는다.
- 확장과 결함 내성:
ad_id기준 파티셔닝, 핫스팟엔 자원 추가, 장애 복구엔 스냅숏.
카프카, 아파치 플링크, 아파치 스파크 같은 업계 표준 솔루션에 대한 사전 지식이나 경험이 있다면 이해하고 설계하기 훨씬 쉬운 장이다.
스터디에서 나눈 질문
- 워터마크를 15초로 잡을지 1분으로 잡을지, 무엇을 기준으로 정해야 할까?
- 정확히 한 번 처리를 위해 분산 트랜잭션을 쓰면 처리량은 얼마나 손해를 볼까? 멱등한 쓰기(같은 키로 덮어쓰기)로 대신할 수는 없을까?
- 5장의 지표 모니터링 시스템과 같은 점과 다른 점은?
- 같은 점: 수집 → 카프카 → 집계 → 시계열/NoSQL 저장이라는 큰 뼈대
- 다른 점: 6장은 돈이 걸려 있어서 정확성(정확히 한 번, 이벤트 시각, 대사)이 훨씬 중요하다