② AdFlow - Mini OTT Ad Platform 구현 결과와 설계 변화

2026. 9. 13. 20:28ㆍPROJECT/Toy Project

② AdFlow - Mini OTT Ad Platform 구현 결과와 설계 변화

GitHub Repository
https://github.com/00700lsm/ad-flow

지난 글에서는 AdFlow라는 Mini OTT Ad Platform을 어떤 방향으로 구현할 것인지 전체적인 구조를 설계했다.

이전 글에서 잡았던 프로젝트의 흐름은 다음과 같았다.

Product
실제로 사용할 수 있는 광고 플랫폼 구현

↓

Problem
사용자 증가와 이벤트 증가로 문제 발생

↓

Experiment
문제를 재현하고 측정

↓

Engineering
필요한 기술로 문제 해결

↓

Result
개선된 시스템이 실제 서비스에서도 동작

중요한 것은 처음부터 Redis, Kafka, k6 같은 기술을 모두 넣는 것이 아니었다.

먼저 실제로 동작하는 제품을 만들고,
문제가 발생했을 때 그 문제를 측정한 뒤 필요한 기술을 선택하는 것이 목표였다.

실제로 Phase 1부터 Phase 7까지 구현해보면서 초기 설계와 달라진 부분이 꽤 많았다.

처음 예상했던 Redis와 Kafka는 아직 들어가지 않았고,
Next.js 대신 정적 HTML을 사용했으며,
실시간 Dashboard 역시 SSE가 아닌 Polling으로 구현했다.

처음 설계만 놓고 보면 빠진 기술이 많다.

하지만 프로젝트를 진행하면서 오히려 한 가지 기준은 더 명확해졌다.

기술을 사용하기 위해 문제를 만드는 것이 아니라
문제가 기술을 요구할 때 도입한다.

이번 글에서는 AdFlow의 현재 구현 상태와,
초기 설계에서 어떤 부분이 달라졌는지 정리해보려고 한다.


1. 현재 구현 범위

현재 AdFlow에서는 기본적인 광고 플랫폼 흐름을 직접 실행할 수 있다.

Campaign Console
        ↓
광고 캠페인 생성
        ↓
OTT Player
        ↓
사용자 / 콘텐츠 조건에 따른 광고 선택
        ↓
Impression / Click
        ↓
Dashboard

구현된 주요 기능은 다음과 같다.

  • Campaign CRUD
  • Creative 등록
  • 사용자 / 콘텐츠 기반 광고 타겟팅
  • Priority 기반 광고 선택
  • Frequency Cap
  • Budget Control
  • Impression / Click Event
  • 이벤트 중복 방지
  • Campaign Dashboard
  • Traffic Simulator

따라서 처음 프로젝트를 시작하면서 잡았던 가장 작은 목표였던

Campaign Console에서 광고를 하나 생성하고 OTT Player를 실행했을 때 내가 만든 광고가 실제로 재생되게 만든다.

는 달성했다.

단순한 REST API 모음이 아니라,

광고 생성
    ↓
광고 선택
    ↓
광고 노출
    ↓
이벤트 발생
    ↓
성과 확인

까지 하나의 흐름으로 연결되어 있다.

현재 구현 코드는 GitHub에서 확인할 수 있다.

https://github.com/00700lsm/ad-flow


2. 처음 설계와 실제 구현은 꽤 달라졌다

첫 번째 글에서 예상했던 기술 스택은 다음과 같았다.

Backend
Java 21
Spring Boot
Spring Data JPA

Data
PostgreSQL
Redis
Kafka

Frontend
Next.js
React

Observability / Test
k6
Prometheus
Grafana

Infra
Docker Compose

현재 구현은 훨씬 단순하다.

Java 21
Spring Boot
Spring Data JPA
PostgreSQL
Docker Compose
Static HTML / JavaScript

아직 사용하지 않은 기술은 다음과 같다.

Redis
Kafka
Next.js
React
k6
Prometheus
Grafana
SSE / WebSocket

처음에는 이 정도 차이가 생길 것이라고 생각하지 못했다.

하지만 구현을 진행하면서

"설계에 적어놨으니까 Redis를 사용한다."

라는 방식이 오히려 프로젝트의 처음 방향과 맞지 않는다고 판단했다.

Redis가 없어도 현재 요구사항을 만족한다면 우선 PostgreSQL로 해결한다.

Kafka가 없어도 이벤트 처리 경계를 만들 수 있다면 우선 단순한 구조로 시작한다.

그리고 실제 부하를 발생시켰을 때 문제가 확인되면 그때 기술을 비교한다.

결과적으로 초기 설계의 기술 스택은 많이 바뀌었지만 프로젝트 진행 방식은 오히려 처음 의도에 가까워졌다.


3. Phase 1 - 실제로 동작하는 제품 MVP

첫 번째 Phase에서는 성능이나 분산 시스템을 생각하지 않았다.

우선 다음 흐름을 완성하는 것이 목표였다.

Campaign 생성
    ↓
Creative 등록
    ↓
OTT Player 실행
    ↓
Ad Serving API 호출
    ↓
광고 선택
    ↓
광고 재생
    ↓
Impression / Click
    ↓
Dashboard 확인

Campaign Console에서 다음 정보를 입력할 수 있다.

Campaign
- name
- budget
- startAt
- endAt
- priority
- targetAgeMin
- targetAgeMax
- targetCategory
- frequencyCap

Player에서는 사용자와 콘텐츠 정보를 기반으로 광고 요청을 발생시킨다.

User
28세
선호 장르: SPORTS

↓

Content
축구 콘텐츠

↓

GET /ads

↓

타겟 조건을 만족하는 광고 검색

↓

Priority가 가장 높은 광고 반환

광고가 반환되면 실제 Creative를 화면에서 확인할 수 있다.

이 단계까지 구현하면서 최소한의 광고 플랫폼 형태가 만들어졌다.


4. Frequency Cap - Redis 없이 먼저 해결해보기

Frequency Cap은 동일한 사용자에게 같은 광고가 지나치게 반복 노출되는 것을 막기 위한 기능이다.

예를 들어

User A
Campaign 1
Daily Frequency Cap = 2

라면 다음과 같은 결과가 나와야 한다.

1번째 요청 → Campaign 1
2번째 요청 → Campaign 1
3번째 요청 → 다른 Campaign

처음 설계에서는 Redis를 사용하는 구조를 예상했다.

GET frequency

if frequency < cap
    INCR frequency

하지만 이런 구조는 동시에 여러 요청이 들어왔을 때 문제가 생길 수 있다.

현재 Count = 2
Cap = 3

Request A → Count 조회 = 2
Request B → Count 조회 = 2

A → 허용
B → 허용

최종 노출 = 4

처음에는 Redis Atomic Operation이나 Lua Script를 후보로 생각했다.

하지만 현재 규모에서는 Redis를 추가하기 전에 PostgreSQL을 이용한 원자적 증가 방식으로 문제를 해결했다.

즉,

Redis를 사용하면 해결할 수 있다.

가 아니라

현재 구조에서 가장 작은 변경으로 정합성을 보장할 수 있는가?

를 먼저 본 것이다.

현재 테스트에서는 동시에 광고 요청이 들어와도 설정된 Frequency Cap을 초과하지 않는다.

Redis와 비교하는 작업은 이후 성능 Experiment에서 진행할 예정이다.


5. Budget Control - Overspending 방지

광고 시스템에서 Budget 역시 동시성 문제가 발생할 수 있는 부분이다.

예를 들어 캠페인의 남은 광고 노출 가능 횟수가 100번이라고 가정한다.

남은 광고 = 100
동시 요청 = 1,000

잘못 구현하면 다음과 같은 상황이 발생할 수 있다.

예상

100회 노출
Budget = 0

실제

103회 노출
Budget < 0

광고 시스템에서는 실제 비용과 연결되는 값이기 때문에 이런 Overspending을 허용하기 어렵다.

AdFlow에서는 Frequency Cap과 마찬가지로 PostgreSQL에서 원자적으로 예산을 차감하도록 구현했다.

예산이 모두 소진되면 Campaign은

ACTIVE
    ↓
BUDGET_EXHAUSTED

상태로 변경되고 이후 광고 선택 대상에서 제외된다.

처음에는 다음 네 가지 방식을 비교할 예정이었다.

Pessimistic Lock
Optimistic Lock
Redis Atomic
Redis Lua Script

하지만 아직 같은 조건에서 성능 비교를 진행하지 않았기 때문에 특정 방식이 더 좋다고 결론내리지는 않았다.

현재 단계에서 확인한 것은 하나다.

동시 요청 상황에서도 Budget을 초과하여 광고가 선택되지 않도록 제한할 수 있다.

성능 비교는 별도의 Experiment로 남겨두었다.


6. Kafka 대신 이벤트 처리 경계부터 만들었다

첫 번째 설계에서는 광고 이벤트 파이프라인을 다음과 같이 예상했다.

OTT Player
    ↓
Event API
    ↓
Kafka
    ↓
Consumer
    ↓
Aggregation
    ↓
PostgreSQL

그런데 구현 단계에서 바로 Kafka를 추가하지 않았다.

현재 구조는 다음과 같다.

OTT Player
    ↓
Event API
    ↓
Application Queue
    ↓
Worker
    ↓
PostgreSQL

핵심은 Kafka 자체가 아니라

HTTP 요청 처리와 이벤트 처리를 분리할 수 있는가?

였다.

Event API는 이벤트를 접수하고,
실제 저장 작업은 별도의 Worker가 처리한다.

이를 통해 API와 이벤트 처리의 책임을 우선 분리했다.

다만 현재 구조에는 명확한 한계가 있다.

Application 내부 Queue이기 때문에 프로세스가 종료되면 처리되지 않은 이벤트가 유실될 수 있다.

Event API
    ↓
Application Queue
    ↓
PROCESS CRASH

→ Queue에 남아있던 이벤트 유실 가능

이 문제는 아직 해결하지 않았다.

오히려 이 지점이 Kafka 도입 여부를 판단할 수 있는 실제 근거가 된다.

향후

  • 이벤트 유실
  • 재처리
  • Consumer 장애
  • 대량 이벤트 처리

등을 실험하면서 Kafka가 필요한 상황을 직접 확인할 예정이다.


7. 이벤트 중복은 DB UNIQUE로 먼저 막았다

이벤트 기반 시스템에서는 동일한 이벤트가 여러 번 전달될 수 있다.

예를 들어 같은 eventId가 세 번 들어오는 상황이다.

eventId = abc

IMPRESSION
IMPRESSION
IMPRESSION

실제 유효한 이벤트는 하나이므로 최종 집계 역시 한 번만 증가해야 한다.

입력 이벤트 = 3

실제 저장 = 1

Impression 증가 = 1

초기 설계에서는 Kafka의 At-least-once Delivery를 전제로

At-least-once
+
Idempotent Consumer
+
eventId Deduplication

구조를 생각했다.

현재는 Kafka가 없기 때문에 더 단순하게 처리했다.

ad_events.eventId에 UNIQUE 제약을 적용하여 동일한 이벤트가 여러 번 저장되지 않도록 했다.

즉 현재 단계에서 필요한 것은 복잡한 Consumer 구조가 아니라

같은 eventId가 여러 번 들어와도 최종 데이터가 한 번만 반영된다.

라는 결과였다.

Kafka를 도입하게 된다면 이 구조를 Idempotent Consumer까지 확장할 수 있다.


8. 실시간 Dashboard도 우선 Polling으로 구현했다

초기 설계에서는 Dashboard를 실시간으로 만들 계획이었다.

Kafka Consumer
    ↓
SSE / WebSocket
    ↓
Dashboard

표시하려던 정보도 상당히 많았다.

Impression / sec
Click / sec
CTR
Ad Request / sec
Campaign Budget
Kafka Lag
p99
Error Rate

하지만 현재 Dashboard는 3초 Polling 방식이다.

Browser
    ↓

3초마다 API 요청

    ↓

GET /dashboard/summary

현재 확인할 수 있는 정보는 다음과 같다.

  • Impression
  • Click
  • CTR
  • 사용 Budget
  • 잔여 Budget
  • Campaign 상태

SSE나 WebSocket을 사용하지 않은 이유는 단순하다.

현재 기능에서는 3초 Polling으로도 사용자가 결과를 확인하는 데 문제가 없기 때문이다.

향후 초당 이벤트 흐름이나 시스템 지표를 실시간으로 표현해야 한다면 SSE를 다시 검토할 수 있다.


9. Traffic Simulator 역시 먼저 시연 가능한 수준으로 만들었다

Traffic Simulator의 초기 목표는 꽤 컸다.

동시 사용자 1,000

↓

Ad Request / sec
Impression / sec

↓

Campaign 분배 %

↓

Budget 감소

↓

Frequency Cap

↓

Kafka Event 증가

↓

Dashboard 실시간 변화

하지만 현재 Simulator는 전문적인 부하 테스트 도구라기보다

다양한 사용자 요청을 발생시켜 광고 시스템의 동작을 확인하는 기능

에 가깝다.

설정할 수 있는 값은 다음과 같다.

사용자 수
연령
콘텐츠 장르
Click Rate

Simulator를 실행하면 실제 Ad Serving API를 호출하고

Ad Request
    ↓
광고 선택
    ↓
Impression
    ↓
일부 Click

과정을 수행한다.

이를 통해 다음 기능이 실제로 연결되어 있는지 확인할 수 있다.

  • 광고 타겟팅
  • Frequency Cap
  • Budget 감소
  • Impression / Click
  • Dashboard 집계

다만 현재 Simulator는 지속적인 부하를 발생시키는 도구가 아니다.

100 RPS, 500 RPS, 1,000 RPS와 같은 성능 측정은 이후 k6 같은 별도의 부하 테스트 도구를 통해 진행할 예정이다.


10. Phase 1 ~ 7 구현 결과

현재까지의 구현을 정리하면 다음과 같다.

Phase 목표 현재 구현
Phase 1 제품 MVP 완료
Phase 2 Frequency Cap PostgreSQL 원자 연산
Phase 3 Budget Control PostgreSQL 원자 차감
Phase 4 Event Pipeline Application Queue + Worker
Phase 5 Event Deduplication eventId UNIQUE
Phase 6 Dashboard 3초 Polling
Phase 7 Traffic Simulator HTTP 기반 Simulator

처음 계획했던 기술과 비교하면 상당히 단순해졌다.

Redis → 아직 없음
Kafka → 아직 없음
SSE → 아직 없음
k6 → 아직 없음
Prometheus → 아직 없음
Grafana → 아직 없음
Next.js → 정적 HTML로 대체

그런데 이 상태를 단순히

아직 Redis와 Kafka를 구현하지 못했다.

라고 보기는 어렵다고 생각한다.

이 프로젝트를 시작할 때 세운 기준 자체가

필요한 문제가 발생했을 때 기술을 도입한다.

였기 때문이다.


11. 기술을 덜 사용했지만 프로젝트 방향은 오히려 명확해졌다

토이 프로젝트를 만들다 보면 사용해보고 싶은 기술이 먼저 생기는 경우가 많다.

Kafka 써보고 싶다.
Redis 써보고 싶다.
Kubernetes 써보고 싶다.

그러면 프로젝트 구조가 자연스럽게 기술 중심으로 변한다.

Kafka를 사용해야 하니까 이벤트를 만든다.

Redis를 사용해야 하니까 캐시를 만든다.

Grafana를 사용해야 하니까 메트릭을 만든다.

AdFlow에서는 반대로 진행해보려고 했다.

제품을 만든다.

↓

문제를 찾는다.

↓

문제를 재현한다.

↓

측정한다.

↓

해결 방법을 비교한다.

↓

필요하면 기술을 도입한다.

그래서 Redis나 Kafka가 없는 현재 상태가 오히려 중요하다.

앞으로 성능 테스트를 했는데 PostgreSQL만으로 충분하다면 Redis를 넣을 이유가 없다.

반대로 DB 조회가 명확한 병목으로 나타난다면 그때 Redis를 도입하고 전후 차이를 측정하면 된다.

Kafka 역시 이벤트 유실이나 처리량 문제가 확인된다면 도입 이유가 생긴다.

기술 이름보다

왜 이 기술이 필요했는가?

를 설명할 수 있는 프로젝트를 만드는 것이 목표다.


12. 현재 시스템 구조

현재 구조를 단순화하면 다음과 같다.

┌───────────────────────────┐
│          Browser          │
│                           │
│ Console                   │
│ Player                    │
│ Dashboard                 │
│ Traffic Simulator         │
└─────────────┬─────────────┘
              │
              ▼
┌───────────────────────────┐
│        Spring Boot        │
│                           │
│ Campaign API              │
│ Ad Serving API            │
│ Event API                 │
│ Dashboard API             │
│ Simulation API            │
└───────┬───────────┬───────┘
        │           │
        │           ▼
        │     Application Queue
        │           │
        │           ▼
        │         Worker
        │           │
        └─────┬─────┘
              ▼
        PostgreSQL

처음 설계와 비교하면 훨씬 단순하다.

하지만 현재 제품에서 필요한 기능을 확인하기에는 충분하다.

이후 Experiment를 통해 병목이 확인되면 구조가 다시 변경될 수 있다.


13. 아직 해결하지 않은 문제

현재 Phase 1 ~ 7은 시연 가능한 제품 범위를 기준으로 종료했다.

하지만 기술적으로 해결하지 않은 문제는 분명히 남아 있다.

13.1 Ad Serving DB 조회

현재 광고 선택 과정은 PostgreSQL에 상당 부분 의존한다.

트래픽이 증가했을 때 DB 조회가 병목이 되는지는 아직 측정하지 않았다.

이 결과에 따라 Redis 도입 여부가 결정될 수 있다.

13.2 Event 유실

Application Queue에 들어간 이벤트는 프로세스가 종료되면 유실될 수 있다.

Event API
    ↓
Queue
    ↓
Crash

→ Event Lost

Durable Message Broker가 필요한 대표적인 지점이다.

13.3 Event Reprocessing

현재 구조에는 실패한 이벤트를 다시 처리하는 명확한 구조가 없다.

Kafka 같은 메시지 브로커를 도입하게 된다면

Retry
DLQ
Replay
Consumer Offset

같은 문제도 함께 다룰 수 있다.

13.4 Reconciliation

현재 Dashboard 집계와 Source of Truth 데이터를 비교하여 정합성을 보정하는 Reconciliation Batch는 없다.

광고 데이터가 실제 정산과 연결된다고 가정한다면 중요한 영역이다.

13.5 Performance Metrics

아직 다음 지표를 실제 부하 환경에서 측정하지 않았다.

RPS
p50
p95
p99
Error Rate
DB Connection
CPU

따라서 현재 구조가 어느 수준까지 버틸 수 있는지도 모른다.

이 부분이 다음 단계의 핵심이다.


14. 다음 단계 - Experiment

Phase 1부터 Phase 7까지는 제품을 만드는 과정이었다.

이제부터는 처음 설계했던

Product
    ↓
Problem
    ↓
Experiment
    ↓
Engineering
    ↓
Result

에서 Experiment 영역으로 넘어간다.

우선 다음 실험부터 진행하려고 한다.

Experiment 1. PostgreSQL 기반 Ad Serving

부하를 단계적으로 증가시킨다.

100 RPS
500 RPS
1,000 RPS
3,000 RPS

그리고 다음 지표를 측정한다.

RPS
p50
p95
p99
Error Rate
DB Connection
CPU

만약 PostgreSQL 조회가 병목으로 확인된다면 Redis 도입 전후를 비교한다.

PostgreSQL

VS

Redis

중요한 것은 Redis를 사용했다는 것이 아니다.

동일한 조건에서 Redis를 도입했을 때 실제로 얼마나 개선되었는가?

를 확인하는 것이다.

Experiment 2. Budget 동시성

남은 광고 노출 = 100
동시 요청 = 1,000

조건에서 Overspending 여부를 확인한다.

이후 필요하다면 다음 방식을 같은 조건에서 비교할 수 있다.

Pessimistic Lock
Optimistic Lock
Redis Atomic
Redis Lua Script

Experiment 3. Frequency Cap 동시성

Frequency Cap = 3
동시 요청 = N

상황에서 실제 노출이 3회를 초과하지 않는지 확인한다.

현재 PostgreSQL 방식과 Redis 방식의 처리량 차이도 비교할 수 있다.

Experiment 4. Event Pipeline

현재 Application Queue 구조에 장애를 발생시켜 이벤트 유실을 재현한다.

Event 발생
    ↓
Queue
    ↓
Application 종료
    ↓
Event Lost

문제가 확인되면 Kafka 같은 Durable Message Broker를 도입하고

재처리
Consumer 장애
이벤트 중복
처리량

을 비교한다.


15. AI와 함께 개발하면서 추가한 규칙

이번 프로젝트에서는 제품 설계뿐만 아니라 개발 방식도 실험했다.

AI에게

"AdFlow 만들어줘."

라고 한 번에 요청하는 대신 하나의 작업을 작은 Task 단위로 나누었다.

기본 흐름은 다음과 같다.

Analysis
    ↓
Plan
    ↓
Human Gate
    ↓
Red
    ↓
Green
    ↓
Refactor
    ↓
Summary

특히 기술 선택은 AI가 임의로 결정하지 못하도록 했다.

예를 들어

Redis를 추가한다.
Kafka를 추가한다.
SSE를 추가한다.

와 같은 결정은 Human Gate를 거치도록 했다.

또한 Task가 끝날 때마다

  • Analysis
  • Plan
  • Test Result
  • Measurement
  • Summary
  • ADR

등의 결과를 파일로 남겼다.

대화 내용을 다음 작업의 기억으로 사용하는 것이 아니라,
저장소의 문서를 다음 작업의 Context로 사용하려고 한 것이다.

이 방식 덕분에

왜 Kafka를 넣지 않았는가?
왜 SSE 대신 Polling인가?
왜 PostgreSQL을 계속 사용했는가?

같은 결정도 기록으로 남길 수 있었다.

다만 AI 개발 Harness 역시 아직 완성된 것은 아니다.

현재 규칙 대부분은 문서와 프롬프트 기반이기 때문에
향후에는 일부 규칙을 CI나 스크립트로 실제 강제할 필요가 있다.


16. 이번 구현에서 얻은 것

처음 설계한 AdFlow에는 꽤 많은 기술이 들어 있었다.

Redis
Kafka
Next.js
k6
Prometheus
Grafana
SSE

현재 구현에는 대부분 없다.

처음 설계와 비교하면 구현률이 낮아 보일 수도 있다.

하지만 직접 구현하면서 더 중요하게 느낀 것은 기술의 개수가 아니었다.

프로젝트에서 설명할 수 있어야 하는 것은

무엇을 사용했는가?

보다

왜 사용했는가?

였다.

그래서 아직 Redis가 없는 것은 문제가 아니다.

Redis가 필요한 상황을 재현하지 않았는데 Redis가 들어가 있는 것이 오히려 설명하기 어렵다.

Kafka 역시 마찬가지다.

현재 Application Queue의 한계를 직접 확인하고,
이벤트 유실과 재처리 문제를 재현한 다음 Kafka를 도입한다면

Kafka를 사용했습니다.

에서 끝나는 것이 아니라

기존 구조에서는 프로세스 장애 시 이벤트가 유실되었고,
Kafka 도입 후 재처리가 가능한 구조로 변경했다.

라고 설명할 수 있다.

AdFlow의 다음 단계는 더 많은 기능을 추가하는 것이 아니다.

이제부터는 현재 구조에 실제 부하와 장애를 만들어보고,
지금까지 의도적으로 비워둔 기술들이 정말 필요한지 확인하는 단계다.


17. 현재 프로젝트 한 줄 정리

현재 AdFlow를 한 문장으로 정리하면 다음과 같다.

Campaign 생성부터 광고 선택, Frequency Cap, Budget Control, 이벤트 수집, Dashboard까지 이어지는 Mini OTT 광고 플랫폼을 먼저 구현하고, Redis와 Kafka 같은 분산 시스템 기술은 실제 병목과 장애를 측정한 이후 도입하는 방식으로 개발하고 있다.

프로젝트의 현재 소스와 문서는 아래 GitHub 저장소에 정리하고 있다.

GitHub

https://github.com/00700lsm/ad-flow

그리고 다음 글부터는 실제 Experiment 결과를 하나씩 정리해보려고 한다.

PostgreSQL Ad Serving은 몇 RPS까지 버티는가?

↓

병목은 어디에서 발생하는가?

↓

Redis가 정말 필요한가?

↓

도입했을 때 얼마나 개선되는가?

처음 설계에서 그렸던 시스템 구조를 그대로 만드는 것이 목표는 아니다.

실험 결과가 처음 설계를 증명할 수도 있고,
반대로 필요 없다는 결론을 낼 수도 있다.

그 과정 자체를 이 프로젝트의 다음 단계로 가져가려고 한다.

반응형