커머스 광고 플랫폼 설계 — 초당 10만 광고 요청을 50ms 안에 입찰하는 법
한 줄 요약: 커머스 광고 플랫폼의 핵심은 후보 선별 → 입찰 → 랭킹 3단계 서빙 파이프라인으로 50ms 내 응답하고, Redis 원자 차감으로 예산 초과를 막으며, 스트리밍 클릭 집계로 광고주에게 실시간 가시성을 제공하는 것이다.
실제 문제: 광고 플랫폼이 돈을 잃는 세 가지 방법
네이버 쇼핑 광고 예산 초과 사건: 2019년, 국내 한 광고 대행사는 특정 키워드 CPC 캠페인에 일 예산 100만 원을 설정했습니다. 그런데 실제 청구액은 187만 원이었습니다. 피크 시간대 수백 개의 광고 서버가 각자 “예산 여유 있음”을 판단하고 독립적으로 입찰을 진행했기 때문입니다. 예산 차감이 배치로 30초마다 정산되는 구조여서, 그 사이 다른 서버들이 예산이 남은 줄 알고 계속 낙찰을 받았습니다.
쿠팡 CPC 클릭 사기 사례: 경쟁사가 봇을 이용해 특정 키워드의 1위 광고를 하루에 3,000번 클릭했습니다. 광고주는 클릭당 500원 × 3,000번 = 150만 원을 지출했지만 실제 구매 전환은 0건이었습니다. 단순 IP 차단만으로는 분산 프록시를 쓰는 봇을 막지 못합니다.
지마켓 키워드 광고 랭킹 역전: 품질이 낮은 광고가 높은 입찰가만으로 상위를 차지하자 클릭률(CTR)이 전체적으로 하락했습니다. 사용자가 광고 클릭을 기피하기 시작했고, 이는 플랫폼 광고 수익 감소로 이어졌습니다. 순수 최고가 입찰 방식은 단기 수익은 높지만 광고 생태계를 망가뜨립니다.
광고 플랫폼이 해결해야 할 핵심 문제:
- 응답 속도: 페이지 로딩을 기다리는 사용자에게 50ms 안에 최적 광고를 선별해야 함
- 예산 정확도: 수천 개 광고 서버가 동시에 동작해도 광고주 예산을 1원도 초과하지 않아야 함
- 클릭 사기: 경쟁사 봇 클릭, 자사 어뷰징 클릭을 실시간으로 탐지하고 환불해야 함
- 랭킹 공정성: 입찰가뿐 아니라 광고 품질을 반영해 사용자 경험과 플랫폼 수익을 동시에 지켜야 함
- 리포팅 실시간성: 광고주가 캠페인 성과를 실시간으로 모니터링하고 예산을 조정할 수 있어야 함
설계 의사결정 로드맵
결정 1: 과금 모델 — CPC vs CPM vs CPA
| 후보 | 장점 | 단점 | 언제 적합 |
|---|---|---|---|
| CPC (클릭당 과금) | 클릭 가치 명확, 광고주 ROI 직관적 | 클릭 사기 위험, 노출 가치 미반영 | 검색 광고, 키워드 광고 |
| CPM (노출 1000회당 과금) | 브랜드 인지도 측정 용이 | 클릭·전환 보장 없음 | 디스플레이 배너, 브랜드 캠페인 |
| CPA (전환당 과금) | 광고주 리스크 최소 | 전환 어트리뷰션 복잡, 플랫폼 리스크 | 성과형 마케팅, 어필리에이트 |
우리의 선택: CPC 기반 + CPM 혼합 (상품에 따라 분리)
커머스 검색/키워드 광고는 CPC, 메인 배너·기획전은 CPM으로 운영한다. CPA는 어트리뷰션 윈도우(클릭 후 7일 이내 구매 등) 논쟁이 끊이지 않아 광고주 분쟁 리스크가 높다. CPC는 클릭 사기 방어 장치를 갖추면 광고주 신뢰도가 가장 높고, 플랫폼도 클릭 시점에 수익을 즉시 확정할 수 있다.
결정 2: 광고 랭킹 — 입찰가 vs Quality Score vs 혼합
| 후보 | 장점 | 단점 | 언제 적합 |
|---|---|---|---|
| 최고가 입찰 (First Price) | 구현 단순 | 광고 품질 무관, 사용자 경험 하락 | 초기 MVP |
| Quality Score 기반 | 사용자 경험 보호 | 점수 계산 비용, 신규 광고 불리 | 검색 품질이 중요한 성숙 플랫폼 |
| eCPM = 입찰가 × Quality Score | 수익+품질 동시 최적화 | 점수 조작 인센티브 | 구글/네이버 표준 |
우리의 선택: eCPM = bid × predicted_CTR × quality_score
구글의 Generalized Second Price(GSP) 경매 방식을 채용한다. 랭킹 점수는 입찰가 × 예측 CTR × 광고 품질 점수로 계산하고, 실제 청구액은 2위 입찰가 + 1원으로 설정한다. 이 구조는 광고주가 진정한 가치를 입찰하도록 유도하고(dominant strategy: 자신의 최대 가치를 솔직히 입찰하는 것이 최선), 품질 낮은 광고가 높은 가격으로 밀어붙이는 것을 방어한다.
결정 3: 예산 소진 제어 — 실시간 차감 vs 배치 정산
| 후보 | 장점 | 단점 | 언제 적합 |
|---|---|---|---|
| DB 직접 차감 | 정확도 최고 | TPS 한계, 락 경합 | 소규모 광고 캠페인 수 |
| 배치 정산 (30초~1분) | DB 부하 낮음 | 예산 초과 구간 발생 | 느슨한 예산 제어 허용 시 |
| Redis 원자 차감 + DB 후기록 | 초과 없음 + 고속 | Redis 장애 시 예산 소실 위험 | 피크 TPS 높은 대규모 플랫폼 |
우리의 선택: Redis 원자 차감 + 주기적 DB 동기화
낙찰 시 Redis에서 원자적으로 예산을 차감하고, 10초마다 DB에 소진액을 반영한다. Redis 장애 시 예산 차감을 일시 중단하고 보수적으로 입찰 정지한다. 30초 배치 정산은 네이버 광고 사건처럼 피크 시 187% 초과를 허용한다.
결정 4: 클릭 사기 탐지 — 룰 vs ML
| 후보 | 장점 | 단점 | 언제 적합 |
|---|---|---|---|
| 룰 기반 (IP/UA 패턴) | 즉각 적용, 설명 가능 | 우회 쉬움, 신규 패턴 대응 느림 | MVP, 단순 봇 차단 |
| ML (이상 탐지, GBM) | 복잡한 패턴 학습 | 모델 학습 시간, 오탐 위험 | 정교한 봇, 분산 클릭 팜 |
| 룰 + ML 앙상블 | 즉시 대응 + 고정밀 탐지 | 운영 복잡도 | 대규모 광고 플랫폼 표준 |
우리의 선택: 룰 기반 실시간 1차 필터 + ML 기반 사후 정산
클릭 발생 즉시 룰(동일 IP 5분 3회, 동일 디바이스 핑거프린트)로 1차 차단한다. ML은 24시간 클릭 패턴을 분석해 의심 클릭을 사후 식별하고 광고주에게 자동 환불한다. 실시간 ML 추론은 레이턴시 예산을 초과하기 때문에 사후 처리로 분리한다.
결정 5: 리포팅 파이프라인 — 실시간 vs 준실시간 vs 배치
| 후보 | 장점 | 단점 | 언제 적합 |
|---|---|---|---|
| 완전 실시간 (Flink) | 초 단위 가시성 | 인프라 비용 급증, 복잡도 | 금융, 게임처럼 실시간 필수 |
| 준실시간 (Kafka + 집계, 1~5분 지연) | 비용-성능 균형 | 일부 집계 지연 | 광고 대시보드 표준 |
| 배치 (Spark, 1시간 단위) | 비용 최저, 구현 단순 | 광고주 대응 느림 | 정산·청구 레포트 |
우리의 선택: 준실시간 (5분 지연) 대시보드 + 배치 공식 정산
광고주가 실시간으로 보는 대시보드는 Kafka Consumer가 1분 단위로 집계해 Redis에 쓰는 준실시간으로 제공한다. 과금 기준이 되는 공식 정산 수치는 Spark 배치로 1시간마다 확정한다. 두 숫자의 오차를 광고주에게 미리 안내해 분쟁을 예방한다.
1. 요구사항 분석 및 규모 추정
기능 요구사항
- 광고 캠페인 관리: 광고주가 키워드, 타겟, 예산, 입찰가를 설정하고 광고 소재를 업로드
- 광고 서빙: 사용자 검색/조회 요청에 50ms 내에 최적 광고 선별 및 반환
- 실시간 예산 관리: 일 예산/월 예산 도달 시 즉시 입찰 중단, 1원도 초과 없음
- 클릭/노출 트래킹: 클릭·노출 이벤트를 손실 없이 수집, 중복 집계 방지
- 클릭 사기 탐지: 비정상 클릭 자동 탐지 및 광고주 자동 환불
- 리포팅 대시보드: 광고주에게 노출·클릭·전환·지출액 실시간 가시성 제공
- 품질 점수 관리: 광고 CTR, 랜딩페이지 품질, 사용자 반응으로 Quality Score 자동 산출
비기능 요구사항
- 응답 속도: 광고 서빙 p99 < 50ms (사용자 경험 최우선)
- 정확성: 예산 초과 0건, 클릭 집계 오차율 < 0.01%
- 확장성: 초당 100,000 광고 요청(QPS) 처리
- 내결함성: 광고 서비스 SLA 99.99% (연간 다운타임 52분 이하)
- 보안: 클릭 사기 탐지율 > 99%, 오탐율 < 0.1%
규모 추정
| 항목 | 수치 |
|---|---|
| 일 활성 사용자 | 2,000만 명 |
| 피크 광고 요청 QPS | 100,000 req/s |
| 일 광고 노출 | 20억 회 |
| 일 클릭 수 | 4,000만 회 (CTR 2%) |
| 광고 캠페인 수 | 50만 개 |
| 클릭 이벤트 저장 | 4,000만 × 1KB = 40GB/일 |
| 입찰 레이턴시 목표 | p50 < 20ms, p99 < 50ms |
| 광고 인덱스 크기 | 활성 광고 500만 개, ~5GB RAM |
2. 고수준 아키텍처
비유: 커머스 광고 플랫폼은 실시간 경매장입니다. 손님(사용자)이 검색어를 입력하는 순간, 경매사(광고 서빙 엔진)가 0.05초 안에 수십만 광고주 중 낙찰자를 결정하고, 낙찰금(클릭 비용)을 금고(Redis 예산)에서 즉시 차감하며, 경매 기록(클릭 로그)은 파이프라인을 통해 광고주 리포트로 변환됩니다.
graph LR
A[사용자 검색] --> B[광고 서빙 엔진]
B --> C[후보 선별기]
C --> D[입찰·랭킹]
D --> E[예산 관리]
E --> F[광고 반환]
B --> G[클릭 트래커]
G --> H[사기 탐지]
H --> I[리포팅 파이프]
| 컴포넌트 | 역할 |
|---|---|
| 광고 서빙 엔진 | 타겟 매칭 → 후보 선별 → 입찰 → 랭킹 → 50ms 내 반환 |
| 후보 선별기 | 역색인(키워드) + 타겟 필터로 500만 → 수백 개 압축 |
| 입찰·랭킹 모듈 | eCPM = bid × pCTR × quality로 최종 순위 결정 |
| 예산 관리 서비스 | Redis 원자 차감, 예산 소진 시 실시간 입찰 정지 |
| 클릭 트래커 | 클릭 이벤트 수집, 중복 제거, Kafka 발행 |
| 사기 탐지 엔진 | 룰 실시간 1차 + ML 사후 정밀 분석 |
| 리포팅 파이프라인 | Kafka → 준실시간 집계 → 광고주 대시보드 |
광고 서빙 전체 흐름:
graph LR
A[검색 서비스] -->|컨텍스트 전달| B[서빙 엔진]
B -->|키워드 역색인| C[후보 500개]
C -->|eCPM 계산| D[랭킹 TOP 3]
D -->|예산 잔액 확인| E[Redis 차감]
E -->|낙찰 광고 반환| A
3. 핵심 컴포넌트 상세 설계
각 컴포넌트 동작 원리
| 컴포넌트 | 핵심 역할 | 내부 동작 흐름 |
|---|---|---|
| 서빙 엔진 | 50ms 내 최적 광고 결정 | 역색인 조회 → 타겟 필터 → eCPM 정렬 → 예산 확인 → 반환 |
| 예산 관리 | 예산 초과 완전 차단 | Redis DECRBY 원자 실행 → 잔액 0 이하 시 입찰 즉시 차단 |
| 클릭 트래커 | 손실 없는 클릭 수집 | 클릭 ID 발급 → Redis 중복 체크 → Kafka 발행 → DB 비동기 기록 |
| 사기 탐지 | 비정상 클릭 식별 및 환불 | 룰 실시간 필터 → Kafka 소비 → ML 배치 분석 → 의심 클릭 환불 |
| 리포팅 파이프 | 광고주 실시간 가시성 | Kafka Consumer → Redis 집계 → 1분 Flush → 대시보드 API |
3-1. 광고 서빙 엔진 (후보 선별 → 입찰 → 랭킹)
비유: 광고 서빙은 소믈리에가 손님의 메뉴를 보자마자 수천 병의 와인 중 3병을 즉시 추천하는 과정입니다. 전부 테이스팅하면 10분이 걸리니, 먼저 포도 품종(키워드)으로 후보를 추리고, 가격대(예산)와 평점(Quality Score)으로 최종 선택합니다.
광고 서빙의 핵심 성능 병목은 “500만 개 광고 중 관련 광고를 얼마나 빠르게 추려내는가”입니다. 전체를 순회하면 수백 ms가 걸리므로, 역색인(Inverted Index)으로 키워드에 매핑된 광고 ID만 즉시 가져옵니다.
eCPM 공식은 단순해 보이지만, predictedCTR을 실시간으로 계산하는 것이 핵심입니다. 광고의 과거 CTR과 현재 컨텍스트(검색어, 사용자 세그먼트, 시간대)를 결합한 로지스틱 회귀 모델을 메모리에 상주시킵니다.
@Service
public class AdServingEngine {
// 역색인: keyword -> List<AdCandidate> (메모리 상주, 5분 TTL 갱신)
private final AdIndex adIndex;
private final BudgetService budgetService;
private final CtrPredictor ctrPredictor;
public List<Ad> serve(AdRequest request) {
// 1단계: 역색인으로 후보 선별 (500만 → 수백 개, ~5ms)
List<AdCandidate> candidates = adIndex.lookup(
request.getKeyword(),
request.getUserProfile(),
request.getPlacement()
);
// 2단계: 예산 소진된 광고주 필터링 (Redis 비트맵 조회, ~1ms)
candidates = budgetService.filterExhausted(candidates);
// 3단계: eCPM 계산 및 정렬 (~10ms for 수백 candidates)
List<RankedAd> ranked = candidates.stream()
.map(c -> {
double pCTR = ctrPredictor.predict(c, request); // 모델 추론
double eCPM = c.getBidPrice() * pCTR * c.getQualityScore();
return new RankedAd(c, eCPM, pCTR);
})
.sorted(Comparator.comparingDouble(RankedAd::getEcpm).reversed())
.limit(50) // Top-50만 과금 확인으로 넘김
.toList();
// 4단계: GSP 가격 계산 (2위 입찰가 + 1원)
return assignGspPrice(ranked).stream()
.limit(request.getSlots()) // 광고 슬롯 수만큼 반환
.map(RankedAd::toAd)
.toList();
}
// Generalized Second Price: 낙찰자는 2위 입찰가 + 최소 단위만 납부
private List<RankedAd> assignGspPrice(List<RankedAd> ranked) {
for (int i = 0; i < ranked.size() - 1; i++) {
long chargePrice = ranked.get(i + 1).getAd().getBidPrice() + 1;
ranked.get(i).setChargePrice(chargePrice);
}
if (!ranked.isEmpty()) {
ranked.get(ranked.size() - 1).setChargePrice(
ranked.get(ranked.size() - 1).getAd().getMinBid()
);
}
return ranked;
}
}
3-2. 실시간 예산 관리 (Redis 원자 차감)
비유: 예산 관리는 카드 한도와 같습니다. 카드사 서버가 전 세계 어디서든 동시에 결제 요청이 와도 한도를 1원도 초과하지 않는 것처럼, Redis 원자 연산은 수천 개 광고 서버가 동시에 차감을 시도해도 절대 초과를 허용하지 않습니다.
예산 초과의 원인은 항상 “읽기 → 판단 → 차감”의 세 단계 사이에 다른 요청이 끼어드는 것입니다. Redis의 Lua 스크립트는 세 단계를 단일 원자 연산으로 묶어 끼어들기를 원천 차단합니다.
광고 서버가 수십 대이면 각 서버마다 매 낙찰마다 Redis를 호출하는 것은 레이턴시 병목이 됩니다. 이를 해결하기 위해 각 서버는 Redis에서 일정 예산 블록(예: 1만 원 단위)을 미리 할당받아 로컬에서 소진합니다. 블록이 소진되면 다시 Redis에서 할당받습니다.
@Service
public class BudgetService {
private static final String BUDGET_DEDUCT_SCRIPT = """
local budget_key = KEYS[1]
local amount = tonumber(ARGV[1])
local current = tonumber(redis.call('GET', budget_key))
if current == nil or current < amount then
return 0 -- 예산 부족
end
redis.call('DECRBY', budget_key, amount)
return 1 -- 차감 성공
""";
// 로컬 예산 블록 캐시 (서버당 1만 원 단위 선점)
private final ConcurrentHashMap<Long, AtomicLong> localBudgets = new ConcurrentHashMap<>();
private static final long BUDGET_BLOCK_SIZE = 10_000L; // 1만 원 블록
public boolean deductBudget(long campaignId, long clickPrice) {
AtomicLong local = localBudgets.computeIfAbsent(
campaignId, id -> new AtomicLong(0)
);
// 로컬 블록에서 먼저 차감 시도 (Redis 호출 없음, ~0.1ms)
long remaining = local.addAndGet(-clickPrice);
if (remaining >= 0) {
return true; // 로컬 블록 충분
}
// 블록 소진: Redis에서 새 블록 할당
local.addAndGet(clickPrice); // 롤백
return allocateBlockFromRedis(campaignId, clickPrice);
}
private boolean allocateBlockFromRedis(long campaignId, long clickPrice) {
String budgetKey = "campaign:budget:" + campaignId;
long allocAmount = Math.max(BUDGET_BLOCK_SIZE, clickPrice);
Long result = redisTemplate.execute(
new DefaultRedisScript<>(BUDGET_DEDUCT_SCRIPT, Long.class),
List.of(budgetKey),
String.valueOf(allocAmount)
);
if (result != null && result == 1) {
localBudgets.get(campaignId).addAndGet(allocAmount);
return deductBudget(campaignId, clickPrice); // 재시도
}
return false; // 예산 소진
}
// 예산 소진 캠페인 비트맵 (광고 서빙 전 빠른 필터)
public List<AdCandidate> filterExhausted(List<AdCandidate> candidates) {
return candidates.stream()
.filter(c -> !isExhausted(c.getCampaignId()))
.toList();
}
private boolean isExhausted(long campaignId) {
// Redis GETBIT: O(1), 초고속 예산 소진 여부 확인
return Boolean.TRUE.equals(
redisTemplate.opsForValue().getBit("budget:exhausted", campaignId)
);
}
}
3-3. 클릭/전환 트래킹
비유: 클릭 트래킹은 마라톤 체크포인트 스탬프와 같습니다. 선수(사용자)가 특정 지점(광고 클릭)을 통과할 때 타임스탬프를 찍고, 같은 선수가 동일 지점을 다시 통과하면 기록을 무시합니다. 클릭 ID가 바로 선수의 고유 번호입니다.
광고 클릭 시 직접 광고주 사이트로 이동하지 않고 반드시 트래킹 서버를 경유합니다. 이 순간에 클릭을 기록하고 중복을 체크한 뒤 리디렉션합니다. 302 리디렉션은 사용자가 느끼지 못할 수준(<5ms)으로 빠릅니다.
@RestController
@RequestMapping("/click")
public class ClickTracker {
// 클릭 ID는 광고 서빙 시 서버가 미리 생성해 광고 URL에 포함
// ex) https://ad.shop.com/click?cid=abc123&aid=4567&kw=운동화
@GetMapping
public ResponseEntity<Void> trackClick(
@RequestParam String cid, // 클릭 ID (UUID)
@RequestParam Long aid, // 광고 ID
@RequestParam String kw, // 키워드
HttpServletRequest req) {
// 1. 중복 클릭 체크: Redis SET NX, TTL 5분
// 동일 클릭 ID가 5분 내 재도달하면 무시 (새로고침, 봇 반복 클릭)
Boolean isNew = redisTemplate.opsForValue()
.setIfAbsent("click:dedup:" + cid, "1", Duration.ofMinutes(5));
if (Boolean.FALSE.equals(isNew)) {
// 중복 클릭: 광고주 과금 없이 랜딩 페이지만 이동
return redirect(aid);
}
// 2. 클릭 이벤트 Kafka 발행 (비동기, 손실 없음)
ClickEvent event = ClickEvent.builder()
.clickId(cid)
.adId(aid)
.keyword(kw)
.ipAddress(getClientIp(req))
.userAgent(req.getHeader("User-Agent"))
.deviceFingerprint(extractFingerprint(req))
.timestamp(Instant.now())
.build();
kafkaTemplate.send("ad-clicks", cid, event);
// 3. 즉시 리디렉션 (사용자는 딜레이 없음)
return redirect(aid);
}
private ResponseEntity<Void> redirect(Long adId) {
String landingUrl = adIndex.getLandingUrl(adId);
return ResponseEntity.status(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, landingUrl)
.build();
}
}
Kafka Consumer가 클릭 이벤트를 소비해 예산 차감, 리포팅 집계, 사기 탐지를 비동기 처리합니다.
@KafkaListener(topics = "ad-clicks", groupId = "click-processor")
public void processClick(ClickEvent event) {
// 1. 예산 실시간 차감
Ad ad = adRepository.findById(event.getAdId());
budgetService.deductBudget(ad.getCampaignId(), ad.getChargePrice());
// 2. 리포팅 집계 (Redis HINCRBY: 원자 카운터)
String reportKey = "report:" + ad.getCampaignId() + ":" + today();
redisTemplate.opsForHash().increment(reportKey, "clicks", 1);
redisTemplate.opsForHash().increment(reportKey, "spend", ad.getChargePrice());
// 3. 사기 탐지 큐 발행
fraudDetector.enqueue(event);
// 4. DB 영구 기록 (배치, 클릭 정산 기준)
clickRepository.save(ClickRecord.from(event, ad));
}
3-4. 클릭 사기 탐지 엔진
비유: 클릭 사기 탐지는 카지노의 딜러와 감시 카메라 조합입니다. 딜러(룰 엔진)는 테이블 위에서 명백한 부정 행위를 즉시 제지하고, 천장 카메라(ML 모델)는 며칠치 영상을 분석해 교묘한 패턴을 잡아냅니다.
룰 기반 탐지는 명백한 패턴에 즉각 반응하고, ML은 분산 클릭 팜처럼 개별로는 정상적으로 보이지만 전체 패턴에서 이상한 케이스를 잡습니다.
@Component
public class FraudDetector {
// 룰 1: 동일 IP 5분 내 동일 광고 3회 이상
// 룰 2: 동일 디바이스 핑거프린트 1시간 내 10회 이상
// 룰 3: 클릭 후 랜딩 페이지 체류 시간 2초 미만 (직접 이탈)
// 룰 4: 알려진 봇 User-Agent 패턴
// 룰 5: 데이터센터 IP 대역 (AWS/GCP/Cloudflare) 클릭
public FraudSignal evaluate(ClickEvent event) {
// 룰 1: IP 기반 빈도 체크
String ipKey = "fraud:ip:" + event.getIpAddress() + ":" + event.getAdId();
Long ipCount = redisTemplate.opsForValue().increment(ipKey);
redisTemplate.expire(ipKey, Duration.ofMinutes(5));
if (ipCount != null && ipCount > 3) {
return FraudSignal.suspicious("IP_RATE_LIMIT", ipCount);
}
// 룰 2: 디바이스 핑거프린트 빈도 체크
String fpKey = "fraud:fp:" + event.getDeviceFingerprint();
Long fpCount = redisTemplate.opsForValue().increment(fpKey);
redisTemplate.expire(fpKey, Duration.ofHours(1));
if (fpCount != null && fpCount > 10) {
return FraudSignal.suspicious("DEVICE_RATE_LIMIT", fpCount);
}
// 룰 4: 데이터센터 IP 차단 (CIDR 블록리스트)
if (datacenterIpRanges.contains(event.getIpAddress())) {
return FraudSignal.fraud("DATACENTER_IP");
}
return FraudSignal.clean();
}
// ML 사후 분석 (Kafka Consumer, 배치 처리)
// - XGBoost 모델: IP 클러스터링, 클릭 간격 분포, CTR 이상치
// - 의심 클릭 식별 → campaign_id 기준 집계 → 광고주 자동 환불
}
3-5. 리포팅/대시보드 파이프라인
비유: 리포팅 파이프라인은 선거 개표 방송과 같습니다. 개표 중에도 실시간 집계를 보여주고(준실시간 대시보드), 개표 완료 후 공식 결과를 발표합니다(배치 확정). 중간 수치는 변동 가능하고, 공식 수치는 불변입니다.
// 준실시간 집계: Kafka Consumer → Redis
@KafkaListener(topics = "ad-clicks", groupId = "realtime-aggregator")
public void aggregateRealtime(ClickEvent event) {
String hour = DateTimeFormatter.ofPattern("yyyyMMddHH")
.format(LocalDateTime.now());
// Redis Hash로 다차원 집계 (캠페인 × 시간)
Map<String, Long> increments = Map.of(
"clicks", 1L,
"spend", event.getChargePrice()
);
increments.forEach((metric, value) ->
redisTemplate.opsForHash().increment(
"realtime:" + event.getCampaignId() + ":" + hour,
metric, value
)
);
// TTL 25시간 (전일 데이터까지 보관)
redisTemplate.expire(
"realtime:" + event.getCampaignId() + ":" + hour,
Duration.ofHours(25)
);
}
// 대시보드 API: Redis에서 즉시 응답
@GetMapping("/campaigns/{id}/stats")
public CampaignStats getStats(@PathVariable Long id,
@RequestParam String period) {
List<String> hourKeys = generateHourKeys(period);
// 다수 시간대를 파이프라인으로 한 번에 조회
List<Object> results = redisTemplate.executePipelined(connection -> {
hourKeys.forEach(key ->
connection.hashCommands().hGetAll(
("realtime:" + id + ":" + key).getBytes()
)
);
return null;
});
return aggregateStats(results);
}
4. 극한 시나리오 3개
시나리오 1: 블랙프라이데이 — 초당 100만 클릭 폭탄
정상 피크의 10배가 갑자기 들어오는 상황입니다. 빅세일 이벤트 첫 1분, 2,000만 명이 동시에 쇼핑 앱을 켜고 검색을 시작합니다. 초당 100만 클릭이 클릭 트래커 서버로 쏟아집니다.
어디서 무너지는가
클릭 트래커가 Redis에 중복 체크를 요청하는 초당 100만 건의 SET NX 호출이 Redis 단일 노드에 집중됩니다. Redis는 초당 약 100만 명령을 처리할 수 있지만, 네트워크 왕복 레이턴시(RTT) 1ms × 100만 = 초당 1초 분량의 대기 큐가 쌓입니다. Redis가 응답 지연을 일으키면 클릭 트래커 스레드 풀이 블록되고 서버 전체가 타임아웃 폭풍에 빠집니다.
방어 메커니즘
1단계로 API Gateway에서 캠페인당 초당 클릭 수를 Rate Limiting합니다(Leaky Bucket, 캠페인당 1,000 clicks/s). 정상 사용자는 통과하고 비정상 폭발은 초기에 흡수합니다.
2단계로 클릭 트래커를 Redis Cluster(16 샤드)로 분산합니다. 클릭 ID를 해시해 샤드를 결정하므로 단일 노드 병목이 1/16로 줄어듭니다. 각 샤드는 독립적으로 초당 100만 명령을 처리할 수 있어 전체 처리량이 1,600만 ops/s로 확장됩니다.
3단계로 클릭 이벤트를 Kafka에 발행하는 것을 fire-and-forget으로 처리합니다. 트래커 서버는 Kafka 발행만 확인하고 즉시 302 리디렉션을 반환합니다. 예산 차감과 DB 기록은 Kafka Consumer가 비동기로 처리해 클릭 응답 경로에서 제거합니다.
graph LR
A[100만 클릭/초] --> B[API GW Rate Limit]
B --> C[클릭 트래커 클러스터]
C --> D[Redis Cluster 16샤드]
C --> E[Kafka 발행]
E --> F[비동기 소비]
결과: 정상 사용자는 레이턴시 변화 없이 통과하고, 클릭 처리 파이프라인이 Kafka 버퍼링으로 수평 확장 가능해집니다. Kafka는 초당 수천만 메시지를 버퍼링할 수 있어 Consumer가 뒤처져도 메시지는 손실되지 않습니다. 이 구조는 서빙 엔진과 집계 파이프라인을 완전히 분리해, 어느 한쪽이 느려져도 다른 쪽에 영향을 주지 않습니다.
시나리오 2: Redis 예산 서버 장애 — 예산 없이 입찰이 계속된다
피크 시간대에 Redis 예산 관리 노드가 다운됩니다. 50만 개 캠페인의 예산 잔액 정보가 사라집니다. 광고 서빙 엔진이 예산 확인을 하지 못하면 두 가지 최악이 공존합니다. 첫째, 예산이 소진된 광고가 계속 입찰해 광고주를 초과 청구합니다. 둘째, 예산이 남은 광고를 차단해 플랫폼 수익이 떨어집니다.
방어 메커니즘
Redis Sentinel으로 고가용성을 구성합니다. Master 1대 + Replica 2대 구조에서 Master 장애 시 Sentinel이 30초 내에 자동 Failover합니다. Replica는 비동기 복제이므로 최대 수 초치 데이터 손실이 발생할 수 있습니다.
Failover 30초 동안의 예산 데이터 공백을 메우는 것이 핵심입니다. 광고 서빙 엔진은 Redis 장애를 감지하면 즉시 보수적 모드로 전환합니다.
public boolean deductBudget(long campaignId, long price) {
try {
return tryRedisDeduct(campaignId, price);
} catch (RedisConnectionException e) {
// Redis 장애 시: 보수적 모드 — 예산 상위 10% 캠페인만 입찰 허용
// 최근 1시간 평균 지출 기준으로 고지출 캠페인 우선 서빙
log.warn("Redis budget unavailable, switching to conservative mode");
return fallbackBudgetCheck(campaignId, price);
}
}
private boolean fallbackBudgetCheck(long campaignId, long price) {
// DB에서 마지막으로 동기화된 예산 잔액 조회 (10초 지연)
// 잔액의 80% 이상 남은 캠페인만 허용 (초과 위험 최소화)
Optional<CampaignBudget> budget = budgetRepository
.findByIdWithLastSync(campaignId);
return budget
.map(b -> b.getRemaining() > b.getDailyBudget() * 0.2)
.orElse(false); // 정보 없으면 차단 (보수적)
}
Redis 복구 후에는 DB의 마지막 동기화 기준으로 예산 잔액을 재적재합니다. 장애 중 발생한 실제 클릭 비용은 Kafka Consumer가 이미 DB에 기록했으므로, 복구 시 DB 집계로 Redis 잔액을 정확히 보정합니다.
graph LR
A[Redis 장애 감지] --> B[보수적 모드 전환]
B --> C[DB 잔액 80%+ 기준]
C --> D[Failover 30초]
D --> E[Redis 복구]
E --> F[DB 기준 잔액 재적재]
결과: 장애 30초 동안 광고 서빙 품질이 약 20% 저하(고예산 캠페인 위주 서빙)되지만, 예산 초과 청구는 0건을 유지합니다. DB 잔액 기준의 보수적 판단으로 오버차지보다 오히려 언더서빙 쪽으로 설계하는 것이 광고주 신뢰 관점에서 항상 안전합니다. 복구 후 30초 이내 정상화를 목표로 합니다.
시나리오 3: 분산 클릭 팜 공격 — 100만 IP에서 조직적 클릭 사기
경쟁사가 주거용 프록시 네트워크(Residential Proxy)를 통해 전 세계 100만 IP에서 초당 1,000번씩 특정 광고를 클릭합니다. IP당 하루 클릭 수는 1~2회로 지극히 정상적으로 보입니다. 기존 IP 빈도 룰은 무력화됩니다. 광고주는 24시간 만에 월 예산을 소진하지만 전환은 0건입니다.
왜 기존 룰이 실패하는가
룰 기반 탐지의 기준은 “단일 IP의 높은 빈도”입니다. 분산 공격은 IP당 빈도를 1~2회로 낮추고, User-Agent를 실제 브라우저처럼 조작하며, 클릭 간격을 인간의 행동 패턴(3~30초 간격)으로 설정합니다. 개별 신호는 모두 정상이지만 전체 패턴이 비정상입니다.
ML 기반 탐지 메커니즘
클릭 이벤트의 다차원 피처를 추출해 이상 탐지 모델을 적용합니다.
// 클릭 이상 탐지 피처 (Kafka Consumer가 24시간 배치로 집계)
public FraudFeatures extractFeatures(List<ClickEvent> clicks, long campaignId) {
// 피처 1: 클릭 → 구매 전환율 (정상: 1~5%, 클릭 팜: 0%)
double conversionRate = conversionRepository
.getConversionRate(campaignId, Duration.ofHours(24));
// 피처 2: 랜딩 페이지 평균 체류 시간 (정상: 30초+, 봇: 2초 미만)
double avgDwellSeconds = clicks.stream()
.mapToDouble(ClickEvent::getDwellSeconds)
.average().orElse(0);
// 피처 3: 클릭 시간대 분포 엔트로피 (정상: 높음, 봇: 낮음 — 특정 시간 집중)
double timeEntropy = calculateShannonEntropy(
clicks.stream().map(e -> e.getTimestamp().getHour()).toList()
);
// 피처 4: AS Number 다양성 (정상: 다양, Proxy Farm: 특정 ASN 집중)
long uniqueAsn = clicks.stream()
.map(ClickEvent::getAsnNumber)
.distinct().count();
double asnDiversityRatio = (double) uniqueAsn / clicks.size();
// 피처 5: 마우스/터치 이벤트 없는 클릭 비율 (JS 비콘)
double noInteractionRatio = clicks.stream()
.filter(e -> !e.hasUserInteractionSignal())
.count() / (double) clicks.size();
return FraudFeatures.builder()
.conversionRate(conversionRate)
.avgDwellSeconds(avgDwellSeconds)
.timeEntropy(timeEntropy)
.asnDiversityRatio(asnDiversityRatio)
.noInteractionRatio(noInteractionRatio)
.build();
}
XGBoost 모델이 이 피처를 분석합니다. 전환율 0% + 체류시간 2초 미만 + 시간대 엔트로피 낮음의 조합은 99.7% 확률로 클릭 팜입니다. 모델 판정 임계값을 0.9로 설정해 오탐율을 0.1% 이하로 유지합니다.
graph LR
A[24시간 클릭 로그] --> B[피처 추출 5종]
B --> C[XGBoost 모델]
C --> D[신뢰도 0.9이상]
D --> E[클릭 무효화]
E --> F[광고주 자동 환불]
탐지된 클릭은 invalid_reason = CLICK_FARM으로 마킹하고, 해당 클릭에서 차감된 예산을 광고주에게 자동 환불합니다. 환불은 다음 날 정산 시 반영되며, 광고주 대시보드에 “사기 클릭 탐지 및 환불 완료” 알림을 표시합니다. 이 투명성이 광고주 신뢰를 유지하는 핵심입니다.
4-1. 이 설계의 한계와 대안
비유: 훌륭한 설계도도 “이 건물이 지진 7.0에서 무너지면?”을 먼저 물어야 합니다. 설계의 한계를 모르면 장애가 터진 후에야 그 경계선을 발견하게 됩니다.
시니어 개발자가 이 설계를 리뷰할 때 가장 먼저 던지는 질문 4가지를 정리했습니다.
한계 1: Redis 예산 원자 차감이 실패하면?
Redis Lua 스크립트는 강력하지만 Redis 자체가 죽으면 아무 의미가 없습니다. 장애 시나리오별 선택지는 두 가지입니다.
graph LR
A[Redis 장애] --> B{어떤 전략?}
B -->|Option A| C[DB Fallback]
B -->|Option B| D[Budget Block 선점]
C --> E[정확하지만 느림 10ms]
D --> F[빠르지만 초과 허용 구간 존재]
Option A — DB Fallback: Redis 장애를 감지하면 PostgreSQL에 직접 UPDATE campaigns SET spent = spent + ? WHERE id = ? AND budget - spent >= ?를 실행합니다. DB 행 수준 락으로 원자성을 보장하지만, TPS가 Redis의 1/100 수준으로 떨어집니다. 피크 시간대 DB가 이 부하를 감당하지 못하면 두 번째 장애가 연쇄됩니다.
Option B — Budget Block 선점(Pessimistic Block): 광고 서버 시작 시 Redis에서 일정 금액(예: 10만 원)을 미리 선점합니다. Redis가 죽어도 서버는 로컬 블록 소진까지 정상 작동합니다. 단, 서버가 비정상 종료되면 선점한 블록이 허공에 사라집니다. 이를 막으려면 블록 TTL을 60초로 설정하고, 서버가 살아있는 동안 주기적으로 갱신(heartbeat)해야 합니다.
실무 선택: 대형 플랫폼은 두 가지를 계층화합니다. 정상 시: Redis 원자 차감. Redis 장애 시: 로컬 블록 소진까지 허용 + 동시에 DB Fallback으로 차감. DB도 죽으면: 입찰 완전 중단(보수적 선택). 돈이 걸린 시스템에서 “모르면 차단”이 항상 안전합니다.
한계 2: 클릭 사기 ML이 오판하면? — FP vs FN 트레이드오프
ML 모델의 임계값(threshold)을 어디에 놓느냐는 단순한 기술 문제가 아니라 비즈니스 이해관계 충돌입니다.
| 오판 유형 | 발생 상황 | 피해자 | 비즈니스 결과 |
|---|---|---|---|
| FP (False Positive) | 정상 클릭을 사기로 판정 | 선의의 사용자, 광고주 | 광고주 클릭 수 감소 → “왜 내 광고 효과가 없냐”며 플랫폼 불신 |
| FN (False Negative) | 사기 클릭을 정상으로 통과 | 피해 광고주 | 광고주 예산 낭비 → “사기를 못 막냐”며 플랫폼 이탈 |
graph LR
A[임계값 높임 0.95] --> B[FP 감소]
A --> C[FN 증가]
D[임계값 낮춤 0.7] --> E[FN 감소]
D --> F[FP 증가]
B --> G[광고주 신뢰 상승]
C --> H[사기 피해 증가]
실무 접근: 임계값을 단일 숫자로 고정하지 않습니다. 클릭 금액이 클수록 임계값을 낮춰 더 공격적으로 차단하고, 소액 클릭은 임계값을 높여 FP를 줄입니다. 예: 클릭당 1,000원 이상 → 임계값 0.7, 100원 미만 → 임계값 0.9. 또한 사기 판정을 즉시 과금 취소로 연결하지 않고, 먼저 “검토 중” 상태로 보류 후 48시간 내 수동 확인 옵션을 광고주에게 제공하면 오탐 분쟁을 크게 줄일 수 있습니다.
한계 3: GSP 경매 vs Vickrey 경매 — 입찰자가 적을 때 무너진다
GSP(Generalized Second Price)는 구글이 대규모 광고 경매에서 검증한 방식이지만, 전제 조건이 있습니다. “입찰자가 충분히 많아야” 경매가 효율적으로 작동합니다.
입찰자 수가 적을 때 발생하는 문제:
- 입찰자 2명: 1위가 2위 입찰가 + 1원만 내므로 플랫폼 수익이 급감합니다.
- 입찰자 1명: 최소 입찰가만 내는 독점 상태가 됩니다.
- 담합 가능성: 입찰자들이 서로 교대로 낮게 입찰하면 GSP는 담합을 감지하지 못합니다.
Vickrey 경매와의 비교:
| 항목 | GSP | Vickrey (VCG) |
|---|---|---|
| 이론적 최적성 | 다수 슬롯에서 근사 최적 | Nash Equilibrium 보장 |
| 구현 복잡도 | 단순 | 복잡 (외부성 계산 필요) |
| 광고주 전략 | 솔직한 입찰이 항상 최선이 아님 | 솔직한 입찰이 dominant strategy |
| 슬롯 수 1개일 때 | GSP = Second Price Sealed Bid | 동일 |
실무 결론: 키워드별 평균 입찰자 수를 모니터링해야 합니다. 입찰자가 3명 미만인 키워드는 경매 대신 고정 가격 슬롯으로 전환하는 것이 플랫폼 수익 보호에 유리합니다. 이 경계를 모르면 경쟁이 적은 롱테일 키워드에서 수익률이 구조적으로 낮아집니다.
한계 4: 실시간 리포팅의 정확성 함정 — 중복 집계와 attribution window
준실시간 리포팅은 빠르지만 두 가지 구조적 오차를 내포합니다.
문제 1 — 중복 클릭 집계: 클릭 이벤트가 Kafka에 at-least-once로 발행되면, Consumer 재시작 시 동일 클릭이 두 번 집계될 수 있습니다. Redis HINCRBY가 원자적이라도, 같은 click_id가 두 번 들어오면 카운터가 2가 됩니다.
graph LR
A[클릭 이벤트] --> B[Kafka at-least-once]
B --> C[Consumer 재시작]
C --> D[동일 이벤트 재전달]
D --> E[Redis 카운터 중복 증가]
E --> F[대시보드 수치 부풀려짐]
방어책: Consumer가 집계 전에 Redis SET NX로 click_id를 체크합니다. SETNX processed:click:{click_id} 1 EX 86400(24시간 TTL). 이미 처리된 click_id는 건너뜁니다. 이 dedup 키의 TTL을 리포팅 집계 주기보다 길게 유지하는 것이 핵심입니다.
문제 2 — 지연 전환(Attribution Window): 사용자가 광고를 클릭하고 7일 후에 구매하는 경우, 클릭 시점 리포트에는 전환이 없는 것으로 보입니다. 광고주가 7일 안에 캠페인을 중단하면 전환 크레딧이 사라집니다. CPA 과금 모델에서는 이 attribution window가 청구 분쟁의 주요 원인입니다. 대시보드에 “전환 집계는 클릭 후 7일 기준이며 현재 집계 중인 수치는 변동될 수 있습니다”를 명시해야 합니다.
한계 5: CDN에서 광고 서빙 시 개인화 한계
광고 응답 속도를 높이려고 CDN에 캐싱하면, 개인화가 불가능해집니다. CDN은 동일 URL에 동일 응답을 반환합니다. 사용자 A와 B가 같은 검색어를 입력해도 완전히 다른 광고를 봐야 하는데, CDN은 이를 구분하지 못합니다.
선택지:
- CDN에 광고 껍데기(슬롯)만 캐싱하고 실제 광고 콘텐츠는 클라이언트 사이드 렌더링(CSR)으로 채웁니다. 페이지 로딩은 빠르지만 광고가 나중에 뜨는 레이아웃 시프트가 발생합니다.
- CDN을 우회하고 항상 오리진 서버에서 서빙합니다. 가장 정확하지만 CDN 효과가 없습니다.
- Edge Computing(Cloudflare Workers, Lambda@Edge)에서 사용자 컨텍스트를 읽어 개인화합니다. 속도와 개인화를 동시에 얻지만 Edge 스크립트 복잡도가 증가합니다.
실무에서는 디스플레이 배너(저개인화)는 CDN, 검색 키워드 광고(고개인화)는 오리진 서빙으로 분리합니다.
4-2. 동시성과 락 — “왜 락 없이도 안전한가”를 설명할 수 있어야 한다
비유: 동시성 문제는 은행 창구와 ATM의 차이입니다. 창구 직원이 잔액을 보고 출금 처리하는 사이 다른 창구가 같은 잔액을 보면 이중 출금이 발생합니다. ATM은 잔액 확인과 차감을 하나의 원자 트랜잭션으로 묶어 이를 방지합니다.
Redis Lua — 잔액 0원 이하 초과 차감 방지
현재 코드의 Lua 스크립트에는 한 가지 숨겨진 엣지 케이스가 있습니다.
-- 현재 스크립트: 잔액이 amount보다 작으면 차단
local current = tonumber(redis.call('GET', budget_key))
if current == nil or current < amount then
return 0
end
redis.call('DECRBY', budget_key, amount)
return 1
current == amount일 때(딱 0원 남은 상태) DECRBY를 실행하면 잔액이 0이 됩니다. 여기까지는 정상입니다. 그런데 Lua 스크립트 밖에서 DECRBY만 직접 호출하는 코드 경로(버그, 직접 운영 명령)가 있으면 잔액이 음수가 됩니다. 방어책으로 Lua 스크립트에 잔액 하한을 추가합니다.
local budget_key = KEYS[1]
local amount = tonumber(ARGV[1])
local current = tonumber(redis.call('GET', budget_key))
if current == nil or current < amount then
return 0
end
local new_val = redis.call('DECRBY', budget_key, amount)
if new_val < 0 then
-- 음수 방지: 롤백 (다른 경로가 동시에 차감한 경우)
redis.call('INCRBY', budget_key, amount)
return 0
end
return 1
이 패턴은 Redis 자체의 원자성을 활용하면서도 외부 오염으로부터 잔액을 보호합니다.
클릭 중복 제거 — SET NX vs Bloom Filter vs HyperLogLog
중복 제거 방식은 규모에 따라 최적 선택이 달라집니다.
| 방식 | 메모리 | 오탐율 | 언제 적합 |
|---|---|---|---|
| Redis SET NX | 클릭 ID당 ~50B | 0% (정확) | 일 클릭 수 < 1억 |
| Bloom Filter | 1억 항목 기준 ~120MB | 설정 가능 (0.1%) | 일 클릭 수 1억~10억 |
| HyperLogLog | 12KB 고정 | ~0.81% | 중복 수 추정만 필요할 때 |
일 클릭 4,000만 건 기준으로 SET NX 방식의 메모리를 계산하면 4,000만 × 50B = 2GB입니다. TTL 5분 기준으로 5분 내 클릭만 유지하면 4,000만 × (5/1440) × 50B = ~7MB로 급감합니다. 5분 안에 같은 클릭 ID가 재도달하는 경우만 막으면 충분하므로, SET NX + TTL 5분이 가장 현실적입니다.
Bloom Filter는 TTL이 없어서 오래된 항목을 제거하려면 주기적으로 필터 전체를 리셋해야 합니다. 이 리셋 순간에 중복이 통과하는 짧은 창이 생깁니다. Rolling Bloom Filter(두 필터 교대 사용)로 해결할 수 있지만 구현 복잡도가 올라갑니다.
경매 동시 입찰 — 왜 락 없이도 안전한가
경매 자체(eCPM 계산 → 랭킹)는 락이 필요 없습니다. 이유는 경매가 “읽기 전용 스냅샷”으로 동작하기 때문입니다.
graph LR
A[요청 A 입찰] --> C[광고 인덱스 스냅샷 읽기]
B[요청 B 입찰] --> C
C --> D[각자 독립 계산]
D --> E[각자 Redis 예산 차감 시도]
E --> F[Lua 스크립트가 순서 결정]
두 요청이 동시에 같은 인덱스 스냅샷을 읽어도 문제없습니다. 읽기는 부작용이 없습니다. 충돌은 “예산 차감” 단계에서만 발생하고, 이 단계는 Redis Lua가 단일 스레드로 직렬화합니다. 따라서 경매 계산에 락을 걸면 오히려 불필요한 직렬화로 레이턴시만 늘어납니다.
4-3. Kafka 파이프라인의 신뢰성 — “유실 vs 중복” 트레이드오프
비유: Kafka는 컨베이어 벨트입니다. 벨트가 빠르게 돌아가는 동안 상자(메시지)는 안전합니다. 그런데 벨트가 갑자기 멈추면 두 가지 선택입니다. 벨트를 되감아 상자를 재전달하거나(중복 위험), 멈춘 지점부터 새 상자만 올리거나(유실 위험). Kafka는 기본적으로 “재전달(at-least-once)”을 선택합니다.
클릭/노출 로그 파이프라인: at-least-once + 집계 시 dedup
Kafka의 기본 보장은 at-least-once delivery입니다. 메시지는 유실되지 않지만 중복으로 전달될 수 있습니다. 광고 과금 파이프라인에서 중복 처리는 광고주 과금 오류로 직결됩니다.
graph LR
A[클릭 이벤트 발생] --> B[Kafka 발행 성공]
B --> C[Consumer 처리 시작]
C --> D[DB 저장 성공]
D --> E[오프셋 커밋 실패]
E --> F[재시작 후 동일 메시지 재전달]
F --> G[중복 과금 위험]
방어 설계 — Idempotent Consumer 패턴:
@KafkaListener(topics = "ad-clicks", groupId = "click-processor")
public void processClick(ClickEvent event) {
// 1. 처리 여부 먼저 확인 (click_id가 PK → 중복 INSERT는 무시)
if (clickRepository.existsByClickId(event.getClickId())) {
return; // 이미 처리됨, 건너뜀
}
// 2. 처리 로직 (멱등성 보장)
budgetService.deductBudget(event.getCampaignId(), event.getChargePrice());
clickRepository.save(ClickRecord.from(event));
// 3. 오프셋 커밋은 처리 완료 후 자동 (auto.commit.interval 100ms)
}
click_id를 DB PK 또는 UNIQUE 인덱스로 설정하면 중복 INSERT가 DB 수준에서 무시됩니다. 이 패턴이 “exactly-once 처럼 동작하는 at-least-once”입니다. 진짜 exactly-once(Kafka Transactions)는 구현 복잡도가 3배 이상이고 레이턴시가 높아져 광고 클릭 처리에는 오버엔지니어링입니다.
Consumer Lag → 리포팅 지연 → 광고주 예산 초과 연쇄 체인
Kafka Consumer가 처리 속도를 따라가지 못하면 lag이 쌓입니다. 이것이 광고 시스템에서 단순한 성능 문제가 아닌 돈 문제로 변하는 연쇄 경로가 있습니다.
graph LR
A[Consumer Lag 증가] --> B[예산 차감 지연]
B --> C[Redis 잔액 과다 표시]
C --> D[소진 캠페인 계속 입찰]
D --> E[광고주 예산 초과 청구]
구체적으로: Consumer가 10분 뒤처지면 그 10분 동안 차감되지 않은 클릭 비용이 Redis 잔액에서 빠지지 않습니다. 광고 서빙 엔진은 “예산이 남았다”고 판단하고 계속 입찰을 허용합니다. Consumer가 따라잡을 때 한꺼번에 차감되면 잔액이 음수가 될 수 있습니다.
방어책:
- Consumer Lag 알람: lag > 50,000 메시지 시 즉시 알람 + Consumer 파티션 자동 증설.
- 예산 안전 마진: Redis 잔액이 일 예산의 5% 이하일 때 입찰을 선제 중단합니다. “95% 소진 시 중단”은 Consumer Lag로 인한 초과분을 흡수하는 버퍼입니다.
- 주기적 정합성 검증: 5분마다
Redis 잔액 + Kafka lag 예상 소비액 < 일 예산을 검증하고 불일치 시 알람을 발생시킵니다.
4-4. 오버엔지니어링 경고 — “경매 시스템이 정말 필요한가?”
비유: 동네 편의점 3곳에 납품하는 음료 회사가 NYSE 수준의 실시간 경매 시스템을 도입하는 것은 오버엔지니어링입니다. 규모에 맞는 도구를 써야 합니다.
광고 플랫폼 설계 면접에서 가장 흔한 실수는 “처음부터 구글 수준으로 설계”하는 것입니다. 규모별 적정 설계를 알고 있어야 진짜 시니어입니다.
| 광고주 수 | 적정 설계 | 왜? |
|---|---|---|
| 10명 | 스프레드시트 + 수동 노출 관리 | 엔지니어링 비용 > 광고 수익. 코드 한 줄도 불필요 |
| 100명 | 고정 가격 슬롯 + 단순 CPC | 경쟁 광고주가 적으면 경매 효율이 떨어짐. 슬롯 선점이 더 예측 가능 |
| 1,000명 | 단순 최고가 입찰 + 키워드 역색인 | Quality Score 계산 비용보다 입찰가 기반 단순 랭킹이 충분히 효과적 |
| 10,000명 | GSP 경매 + 예측 CTR | 경쟁이 충분해야 경매 효율이 발현. Quality Score 도입 시점 |
| 100,000명+ | 실시간 경매 + ML 랭킹 + 다채널 | 이 포스트에서 설계한 수준 |
graph LR
A[광고주 10명] --> B[수동 관리]
C[광고주 1K명] --> D[고정가 슬롯]
E[광고주 10K명] --> F[단순 경매]
G[광고주 100K명] --> H[ML 경매]
“경매 시스템이 정말 필요한가?” 체크리스트:
- 동일 키워드에 경쟁하는 광고주가 평균 5명 이상인가? → 아니라면 고정 가격이 더 효율적입니다.
- Quality Score를 계산할 CTR 히스토리 데이터가 충분한가? → 신규 플랫폼은 콜드스타트 문제로 Quality Score가 무의미합니다.
- 광고주가 실시간 입찰가를 조정할 인터페이스와 의지가 있는가? → 소규모 광고주는 고정 가격을 선호합니다.
- ML 모델을 운영할 엔지니어링 역량이 있는가? → 모델 없이 경매만 도입하면 품질 보정 없는 최고가 경매가 됩니다.
체크리스트에서 3개 이상 “아니오”이면 단순 고정 가격 슬롯부터 시작하는 것이 올바른 선택입니다.
5. 실무 실수 Top 5
| 순위 | 실수 | 결과 | 올바른 접근 |
|---|---|---|---|
| 1 | 배치 예산 정산 (30초 주기) | 피크 시 예산 187% 초과 청구, 광고주 이탈 | Redis 원자 차감 + 낙찰 즉시 예산 소진 |
| 2 | 단순 최고가 입찰 랭킹 | 저품질 광고 상위 점령 → CTR 하락 → 플랫폼 수익 감소 | eCPM = bid × pCTR × quality Score |
| 3 | IP 단순 빈도 룰만으로 클릭 사기 탐지 | 분산 프록시 봇 통과, 광고주 월 예산 하루 소진 | 다차원 피처 ML + 전환율/체류시간 검증 |
| 4 | 클릭 중복 체크를 DB 쿼리로 처리 | QPS 100K에서 DB 쿼리 100K/s → 커넥션 풀 고갈 | Redis SET NX (< 1ms) |
| 5 | 준실시간 집계와 공식 정산 수치를 동일하게 안내 | 광고주 대시보드와 청구서 수치 불일치 → 분쟁 | 대시보드는 “실시간 추정치” 레이블 + 공식 정산은 배치 확정 |
6. Phase 1→4 진화
| 단계 | 구성 | 월 비용 | 한계 QPS |
|---|---|---|---|
| Phase 1 (MVP) | 단일 서버 + PostgreSQL + Redis 1노드 | ~$300 | ~1,000 QPS |
| Phase 2 (스케일) | 광고 서버 × 5 + Redis Cluster + Kafka | ~$3,000 | ~10,000 QPS |
| Phase 3 (중규모) | 광고 서버 × 20 + Redis Cluster 16샤드 + Flink | ~$15,000 | ~50,000 QPS |
| Phase 4 (대규모) | 광고 서버 × 50 + 멀티 리전 + ML 파이프라인 | ~$80,000 | ~200,000 QPS |
Phase 1은 단일 노드로 시작해 서비스 가설을 검증합니다. Redis 예산 관리와 기본 클릭 중복 제거만으로도 초기 광고주 10곳은 안정적으로 운영됩니다. Phase 2에서 Kafka를 도입해 클릭 처리 파이프라인을 비동기로 분리하는 것이 가장 중요한 전환점입니다. 이 시점부터 서빙 레이턴시와 집계 처리량이 독립적으로 확장됩니다.
7. 핵심 메트릭
| 메트릭 | 정상 | 경고 | 장애 |
|---|---|---|---|
| 광고 서빙 p99 레이턴시 | < 50ms | 50~100ms | > 100ms |
| 예산 초과 건수 | 0건 | - | 1건 이상 즉시 알람 |
| 클릭 손실률 | < 0.01% | 0.01~0.1% | > 0.1% |
| 사기 클릭 탐지율 | > 99% | 95~99% | < 95% |
| 리포팅 지연 | < 5분 | 5~15분 | > 15분 |
| Redis 예산 명령 p99 | < 2ms | 2~10ms | > 10ms |
| Kafka 컨슈머 랙 | < 10,000 | 10K~100K | > 100K |
8. 실제 장애 사례
구글 광고 2012년 예산 버그: 구글 AdWords에서 다중 데이터센터 동기화 지연으로 일부 광고주가 일 예산의 200%까지 초과 청구되는 사고가 발생했습니다. 원인은 여러 데이터센터의 예산 캐시가 서로 다른 값을 가지고 독립적으로 입찰 결정을 내렸기 때문입니다. 해결책은 예산 차감의 단일 진실 저장소를 Redis로 통합하고, 각 데이터센터가 독립 판단하지 않고 중앙 예산 서비스를 경유하도록 설계 변경이었습니다.
페이스북 광고 2021년 과금 오류: iOS 14 ATT(앱 추적 투명성) 도입 후 전환 추적 데이터가 대폭 줄었습니다. 기존 ML 모델이 불완전한 신호로 광고 효과를 과대 추정하고, 더 높은 입찰가를 추천해 광고주 비용이 증가했습니다. 특히 CPA 기반 캠페인에서 전환 어트리뷰션 불확실성이 커져 실제보다 비싼 가격을 광고주에게 청구한 사례가 보고되었습니다. 데이터 인프라와 프라이버시 정책의 충돌이 과금 정확도에 직접 영향을 줄 수 있음을 보여주는 사례입니다.
쿠팡 광고 클릭 사기 대응: 쿠팡은 2022년 이후 광고 클릭 검증 시스템을 강화했습니다. 클릭 후 랜딩 페이지 JavaScript 비콘으로 실제 사용자 인터랙션(스크롤, 마우스 무브)을 수집하고, 인터랙션 없는 클릭을 자동 무효화하는 방식을 도입했습니다. 이 방식은 HeadlessChrome 봇을 포함한 고정밀 봇도 탐지합니다.
9. 확장 포인트
네이티브 광고 확장: 검색 광고 외에 상품 리스트 중간의 네이티브 광고, 상세페이지 하단의 연관 상품 광고로 확장합니다. 서빙 엔진에 placement 타입을 추가하고, 타입별 랭킹 가중치를 분리 관리합니다.
동적 입찰 최적화: 광고주가 목표 CPA를 설정하면 시스템이 자동으로 입찰가를 조정합니다. 과거 전환 데이터를 학습한 모델이 각 경매에서 전환 확률을 예측하고, 목표 CPA를 초과하지 않는 최대 입찰가를 자동 계산합니다.
리타겟팅 광고: 특정 상품을 조회하고 구매하지 않은 사용자에게 해당 상품 광고를 재노출합니다. 사용자 조회 이벤트를 Kafka로 수집하고, 3일 이내 미구매 사용자 세그먼트를 Redis Set으로 관리합니다.
오디언스 타겟팅 확장: DMP(Data Management Platform) 연동으로 외부 사용자 데이터를 광고 타겟팅에 활용합니다. GDPR/PIPA 준수를 위해 해시된 이메일(SHA-256) 기반 매칭으로 개인정보 직접 전송 없이 타겟팅합니다.
댓글