Day 5: Redis 캐싱으로 밀리초 단위 광고 서빙 구현하기

오늘 한 일 요약

오늘은 실시간 광고 시스템에서 가장 중요한 “빠른 응답속도”를 달성하기 위해 Redis 캐싱 레이어를 구축했습니다. 결과적으로 ClickHouse 대비 72배 빠른 1.48ms의 응답속도를 달성했습니다!


왜 이렇게 복잡한 시스템이 필요한가요?

실제 광고 입찰은 “눈 깜빡일 시간”에 일어납니다

앱에서 광고가 뜨는 과정을 상상해보세요:

  1. 사용자가 앱을 열면 (0ms)
  2. 광고 서버에 “지금 보여줄 광고 추천해줘!” 요청 (10ms)
  3. 서버가 “이 사용자는 미국, 배너 광고 CTR 5%, 입찰가 $2” 계산 (??ms)
  4. 광고를 화면에 표시 (50ms)

문제: 만약 3번이 100ms 걸리면? → 사용자는 이미 떠났습니다.

실제 광고 입찰(RTB)은 100ms 이내에 모든 게 끝나야 합니다. 그래서 데이터 조회를 극도로 빠르게 만들어야 하는 거죠.


🏗️ 우리 시스템의 전체 구조 (쉽게 설명)

우리는 총 5개의 프로그램을 동시에 실행하고 있습니다. 각각이 서로 다른 역할을 합니다.

[데이터 생성기] → [Kafka] → [Spark Streaming] → [Redis + ClickHouse] → [FastAPI]
    (터미널1)      (저장소)      (터미널2)            (데이터베이스)        (API서버)

1️⃣ 데이터 생성기 (터미널 1)

역할: 가짜 광고 이벤트를 계속 만들어냅니다.

# ad_log_generator.py가 하는 일
매초마다:
    "미국 사용자가 배너 광고를 클릭했어요!" (JSON)
    "한국 사용자가 리워드 비디오를 봤어요!" (JSON)
     Kafka로 전송

왜 필요한가요? 실제 서비스라면 진짜 사용자 데이터가 들어오겠지만, 우리는 포트폴리오니까 “시뮬레이션”이 필요합니다.

실행 명령어:

python data-generator/ad_log_generator.py
# 출력: ✅ Sent 1,000 events | Rate: 83 events/sec

2️⃣ Kafka (백그라운드)

쉽게 말하면: “택배 보관함”입니다.

상황 비유:

  • 데이터 생성기 = 택배 기사 (광고 이벤트를 계속 배달)
  • Kafka = 택배 보관함 (ad-events라는 이름의 보관함)
  • Spark Streaming = 택배를 꺼내가는 고객

왜 Kafka를 쓰나요? 데이터 생성기가 초당 83개 이벤트를 만드는데, Spark가 잠깐 멈추면? → Kafka가 데이터를 “보관”해둡니다. 안 잃어버려요!

실제 동작:

# Kafka에 들어있는 데이터 확인
docker exec adtech-kafka kafka-console-consumer \
  --topic ad-events --from-beginning --max-messages 3

# 출력:
{"event_id": "abc123", "country": "US", "event_type": "click", ...}
{"event_id": "def456", "country": "KR", "event_type": "impression", ...}
{"event_id": "ghi789", "country": "JP", "event_type": "conversion", ...}

3️⃣ Spark Streaming (터미널 2)

쉽게 말하면: “실시간 계산기”입니다.

하는 일:

# spark_processor_with_redis.py가 하는 일

1. Kafka에서 이벤트 꺼내기 (스트리밍)
2. 5분마다 집계:
   - "지난 5분간 미국 배너 광고 몇 번 봤지?"
   - "그 중 몇 번 클릭했지?"
   - CTR = (클릭 / 노출) × 100% 계산
3. 결과를 Redis와 ClickHouse에 동시 저장

왜 “5분 윈도우”인가요? 실시간이라고 해서 매 1초마다 계산하면 CPU가 폭발합니다. 5분 정도가 “적당히 실시간 + 효율적”입니다.

실행 명령어:

docker exec spark-master /opt/spark/bin/spark-submit \
  --master "local[2]" \
  --packages org.apache.spark:spark-sql-kafka-0-10_2.12:3.5.0 \
  /opt/spark-apps/spark_processor_with_redis.py

# 출력:
✅ Batch 1: Redis에 105개 메트릭 저장됨
✅ Batch 1: ClickHouse에 105개 메트릭 저장됨

실제 계산 결과 예시:

+-------+--------------+-----------+------+-------+-------+
|country|ad_format     |impressions|clicks|ctr    |ecpm   |
+-------+--------------+-----------+------+-------+-------+
|US     |rewarded_video|358        |153   |42.74% |$26.27 |
|KR     |banner        |397        |24    |6.05%  |$1.71  |
+-------+--------------+-----------+------+-------+-------+

4️⃣ Redis + ClickHouse (저장소)

쉽게 말하면: 각각 “메모”와 “일기장”입니다.

Redis (메모판)

  • 특징: 초고속이지만 메모리에만 저장 (휘발성)
  • 용도: 최근 5분 데이터만 저장 (TTL 1시간 후 자동 삭제)
  • 속도: 1.48ms ⚡

비유: “미국 리워드 비디오 CTR이 얼마였지?” → 메모판에서 바로 확인 (1초도 안 걸림)

ClickHouse (일기장)

  • 특징: 느리지만 디스크에 영구 저장
  • 용도: 몇 달치 데이터 분석용
  • 속도: 107ms 🐢

비유: “지난달 전체 광고 수익이 얼마였지?” → 일기장 뒤져서 계산 (좀 걸림)

Redis 데이터 확인:

# Redis CLI 접속
docker exec -it adtech-redis redis-cli

# 저장된 키 확인
127.0.0.1:6379> KEYS metrics:*
1) "metrics:US:rewarded_video:5m"
2) "metrics:KR:banner:5m"
...21개

# 실제 데이터 조회
127.0.0.1:6379> GET metrics:US:rewarded_video:5m
{
  "impressions": 358,
  "clicks": 153,
  "ctr": 42.74,
  "ecpm": 26.27
}

5️⃣ FastAPI (API 서버)

쉽게 말하면: “고객 상담 창구”입니다.

하는 일:

# fastapi_metrics_api.py가 하는 일

고객: "미국 리워드 비디오 성과 알려줘!"
API: Redis에서 즉시 조회  1.48ms 만에 응답

왜 FastAPI를 쓰나요?

  • Python으로 빠르게 만들 수 있음
  • 자동으로 API 문서(/docs) 만들어줌
  • 비동기 처리 지원 (빠름)

API 엔드포인트:

# 1. 특정 국가/광고포맷 조회
curl http://localhost:8000/metrics/US/rewarded_video
# 응답: {"ctr": 42.74, "response_time_ms": 7.36}

# 2. 전체 메트릭 조회
curl http://localhost:8000/metrics/all
# 응답: 21개 국가/포맷 조합 데이터

# 3. 성능 벤치마크
curl http://localhost:8000/benchmark/US/rewarded_video
# 응답:
{
  "redis": {"response_time_ms": 1.48},
  "clickhouse": {"response_time_ms": 107.13},
  "comparison": {"redis_faster_by": "72.4x"}
}

🔥 핵심 성과: 72배 빠른 응답속도!

Before (ClickHouse만 사용)

사용자 요청 → ClickHouse 쿼리 (107ms) → 응답
  • 문제: 107ms는 광고 입찰에 너무 느림
  • 결과: 실시간 서비스 불가능

After (Redis 캐싱 추가)

사용자 요청 → Redis 조회 (1.48ms) → 응답
  • 개선: 72배 빠름!
  • 결과: 실시간 광고 입찰 가능

🧠 왜 터미널을 여러 개 써야 하나요?

답: 각 프로그램이 “계속 실행되어야” 하기 때문입니다.

터미널 1: 데이터 생성기

python ad_log_generator.py
# 이 프로그램은 "무한 루프"로 계속 데이터를 만듭니다
# Ctrl+C 누르기 전까지 멈추지 않음

터미널 2: Spark Streaming

spark-submit spark_processor_with_redis.py
# 이 프로그램도 "무한 루프"로 계속 Kafka를 읽습니다
# Ctrl+C 누르기 전까지 멈추지 않음

터미널 3: API 테스트

curl http://localhost:8000/metrics/US/banner
# 이건 "일회성" 명령어
# 실행하면 결과 받고 끝

비유:

  • 터미널 1, 2 = 공장에서 계속 일하는 기계 (멈추면 안 됨)
  • 터미널 3 = 손님이 주문하는 카운터 (필요할 때만 사용)

📊 실제 데이터 흐름 (타임라인)

00:00초

데이터 생성기: "미국 사용자가 배너 광고 봤어요!" → Kafka

00:01초

데이터 생성기: 83개 이벤트 더 생성 → Kafka

00:05초 (5분 윈도우 완료)

Spark: "자, 지난 5분 계산해볼까?"
       - 미국 배너: 노출 1,234회, 클릭 45회
       - CTR = 45/1234 = 3.64%
       → Redis 저장 (TTL 1시간)
       → ClickHouse 저장 (영구)

00:06초

고객: "미국 배너 CTR 알려줘!" (API 요청)
FastAPI: Redis에서 조회 (1.48ms) → "3.64%입니다!" 응답

🎯 주요 기술 용어 정리

Kafka

  • 무엇: 분산 메시지 큐 (택배 보관함)
  • 왜 씀: 데이터 유실 방지, 비동기 처리
  • 실무 사용: 카카오톡 메시지, 넷플릭스 시청 기록

Spark Streaming

  • 무엇: 실시간 대용량 데이터 처리 엔진
  • 왜 씀: 초당 수천~수만 건 이벤트 집계
  • 실무 사용: 우버 실시간 요금 계산, 쿠팡 재고 추적

Redis

  • 무엇: 인메모리 키-값 저장소 (초고속 캐시)
  • 왜 씀: 밀리초 단위 응답 필요할 때
  • 실무 사용: 쿠팡 장바구니, 배민 실시간 주문

ClickHouse

  • 무엇: 컬럼 기반 분석용 DB (빅데이터 저장)
  • 왜 씀: 수억 건 데이터 집계 쿼리
  • 실무 사용: 야놀자 예약 분석, 당근마켓 조회수 통계

FastAPI

  • 무엇: Python 웹 프레임워크
  • 왜 씀: 빠르게 REST API 만들기
  • 실무 사용: ML 모델 서빙, 내부 백오피스 API

💡 AdTech에서 이 시스템이 중요한 이유

실제 광고 입찰 시나리오

1. 사용자가 유튜브 앱 실행 (0ms)
2. "광고 보여줘!" 요청 전송 (5ms)
3. 광고 입찰 시작:
   ┌─────────────────────────────┐
   │ 1000개 광고주가 동시 입찰   │ ← 우리 시스템이 여기서 작동!
   │ - 각자 "얼마 줄까?" 계산    │
   │ - 과거 CTR 데이터 참고      │ ← Redis에서 1.48ms에 조회
   │ - 최고가가 낙찰             │
   └─────────────────────────────┘
4. 낙찰된 광고 표시 (50ms)

만약 Redis 없이 ClickHouse만 쓴다면?

3번 단계에서 107ms 소요
→ 총 시간: 5 + 107 + 50 = 162ms
→ 광고 입찰 제한시간 100ms 초과!
→ 입찰 실패, 수익 0원

Redis 있으면?

3번 단계에서 1.48ms 소요
→ 총 시간: 5 + 1.48 + 50 = 56.48ms
→ 여유있게 입찰 성공!
→ 광고 수익 발생 💰

🔧 트러블슈팅: 겪었던 문제들

문제 1: Kafka 토픽이 없어요!

에러: UnknownTopicOrPartitionException

원인: 데이터 생성기를 안 돌렸음 해결: 생성기 먼저 실행 → Kafka 토픽 자동 생성

문제 2: Spark 메모리 부족

에러: Block broadcast_4 does not exist

원인: local[*] (모든 CPU 사용) 설정이 과함 해결: local[2] (2코어만 사용)로 변경


📈 성능 측정 결과

API 응답 속도

엔드포인트 응답 시간 데이터 개수
/metrics/US/rewarded_video 7.36ms 1개
/metrics/CA/banner 0.32ms 1개
/metrics/all 3.92ms 21개

Redis vs ClickHouse 비교

저장소 평균 응답 시간 용도
Redis 1.48ms 실시간 서빙
ClickHouse 107.13ms 분석 쿼리
속도 차이 72.4배 -

🎓 배운 점

1. Hot/Cold Data 분리 패턴

  • Hot Data (Redis): 최근 데이터, 자주 조회, 빠른 응답 필요
  • Cold Data (ClickHouse): 오래된 데이터, 가끔 조회, 복잡한 분석

2. Cache-Aside 패턴

# 전통적인 방식 (느림)
def get_metrics(country, format):
    return clickhouse.query(...)  # 107ms

# Cache-Aside 패턴 (빠름)
def get_metrics(country, format):
    data = redis.get(key)         # 1.48ms
    if data is None:
        data = clickhouse.query(...)
        redis.set(key, data, ttl=3600)
    return data

3. Dual Write 전략

# Spark에서 동시 저장
def save_metrics(batch):
    save_to_redis(batch)      # 실시간용
    save_to_clickhouse(batch)  # 분석용

🚀 다음 단계 (Day 6 예고)

Day 5에서 “빠른 조회”를 완성했습니다. 이제 다음 중 하나를 선택할 수 있어요:

Option 1: 실시간 알림 시스템

CTR이 갑자기 떨어지면?
→ Slack으로 "미국 배너 CTR 50% 하락!" 알림

Option 2: Fraud Detection (사기 탐지)

같은 사용자가 1초에 100번 클릭?
→ "이상 패턴 감지!" 경고

Option 3: A/B Testing

배너 A vs 배너 B 중 어떤 게 나을까?
→ 통계적으로 비교

💼 포트폴리오 어필 포인트

면접에서 이렇게 말할 수 있어요:

“Redis 캐싱 레이어를 구축해서 광고 메트릭 조회 속도를 72배 개선했습니다. ClickHouse는 107ms 걸리던 쿼리가 Redis는 1.48ms로, sub-10ms 응답을 달성했습니다. 이를 통해 실시간 RTB(Real-Time Bidding) 시스템의 100ms SLA를 만족시킬 수 있었습니다.”

기술 스택:

  • Kafka: 이벤트 스트리밍 (초당 83 events)
  • Spark Streaming: 실시간 집계 (5분 윈도우)
  • Redis: 인메모리 캐싱 (TTL 1시간)
  • ClickHouse: 분석용 데이터 웨어하우스
  • FastAPI: RESTful API 서버

📚 참고 자료

각 기술의 공식 문서

실무에서 이렇게 씁니다


🎉 마무리

오늘은 “빠르게 데이터를 조회하는 것”의 중요성을 직접 구현하며 배웠습니다.

AdTech 업계에서는 100ms가 매출에 직결됩니다. 우리가 만든 1.48ms 시스템은 실제 프로덕션에서도 충분히 쓸 수 있는 수준입니다!

다음 Day 6에서는 이 시스템을 더욱 고도화해봅시다! 🚀


프로젝트 저장소: [GitHub 링크] 작성일: 2025-12-18 소요 시간: Day 5 완료 (약 4시간)