설계 사례: 데이터 파이프라인
메시지 큐, 지표 모니터링, 광고 클릭 집계, 실시간 순위표처럼 끊임없이 들어오는 이벤트를 모으고 세는 시스템이다.
이 노트의 사례는 끝없이 들어오는 사건을 받아 쌓고, 세고, 순위를 매긴다. 공통된 질문은 둘이다. 사건을 잃거나 두 번 세지 않으려면 어디서 무엇을 기록해야 하나, 그리고 늦게 오는 사건을 언제까지 기다리나. 사례마다 요구와 규모를 직접 어림하고, 핵심 설계, 어려운 점과 선택지, 실제 사례 순서로 본다. 배치와 스트림, 창과 워터마크의 일반론은 배치와 스트림 처리 노트에 있다.
분산 메시지 큐
요구와 규모
여러 서비스가 메시지를 넣고, 여러 서비스가 각자 속도로 꺼내 간다. 순서가 중요한 메시지는 순서를 지키고, 장애가 나도 잃지 않으며, 지난 메시지를 다시 읽을 수 있어야 한다. 초당 100만 건, 한 건 1KB, 7일 보관이라고 하자.
- 유입: 100만 × 1KB = 초당 1GB. 7일이면 1GB × 604,800초 ≈ 605TB, 세 벌 복제하면 약 1.8PB의 디스크다.
- 병렬성: 파티션 하나를 한 컨슈머가 초당 10MB 처리한다고 보면, 1GB/s를 따라가려면 파티션이 적어도 100개 있어야 한다. 파티션 수가 곧 읽기 병렬성의 상한이다.
핵심 설계
전통적인 큐는 꺼내 간 메시지를 지운다. 로그 기반 큐는 반대로 지우지 않는 추가 전용 로그(append-only log)에 쓰고, 읽는 쪽이 "어디까지 읽었나"(오프셋)만 기억한다. 토픽 하나를 여러 파티션으로 나눠 브로커들에 흩고, 프로듀서는 메시지 키를 해시해 파티션을 고른다. 같은 키(예: 같은 주문 ID)는 늘 같은 파티션에 가므로 키 단위 순서가 지켜진다. 파티션 사이의 순서는 보장하지 않는다.
컨슈머 그룹이 읽기를 나눈다. 같은 그룹 안의 컨슈머들은 파티션을 나눠 맡고(파티션 하나에 컨슈머 하나), 다른 그룹은 같은 메시지를 처음부터 따로 읽는다. 그래서 하나의 토픽이 "작업 큐"이면서 "여러 구독자에게 알리는 발행·구독"이기도 하다. 컨슈머가 죽거나 늘면 그룹이 파티션을 다시 나눈다(리밸런스). 메시지는 읽혀도 지우지 않고 보관 기간이 지나면 지우므로, 버그를 고친 뒤 오프셋을 되돌려 다시 처리할 수 있다.
내구성은 복제로 얻는다. 파티션마다 리더 하나와 팔로워 몇이 있고, 리더를 잘 따라오는 복제본 집합(ISR) 안에서만 새 리더를 뽑는다. 프로듀서가 acks=all로 보내면 ISR 모두가 받은 뒤에 성공을 돌려받고, min.insync.replicas로 최소 몇 벌이 받아야 하는지 정한다. 복제와 리더 선출의 원리는 복제·샤딩·합의 노트에 있다.
어려운 점과 선택지
- 전달 보장: 오프셋을 처리 전에 커밋하면 죽었을 때 그 메시지를 건너뛴다(최대 한 번). 처리 후에 커밋하면 죽었을 때 다시 처리한다(최소 한 번). 대부분은 최소 한 번을 고르고 중복을 받는 쪽에서 막는다.
- 프로듀서 쪽 중복: 응답만 잃어버려 다시 보내면 같은 메시지가 두 번 쌓인다. 프로듀서가 자기 ID와 파티션별 순번을 붙이면 브로커가 이미 쓴 순번을 알아보고 버린다.
- 정확히 한 번: "읽고 → 처리하고 → 다른 토픽에 쓰고 → 오프셋 커밋"을 하나의 트랜잭션으로 묶으면, 큐 안에서 끝나는 처리는 정확히 한 번 효과를 낸다. 결과가 바깥 DB로 나가면 그 DB 쓰기도 멱등하게 만들어야 한다.
- 파티션 수: 많으면 병렬성은 늘지만 브로커의 파일·메모리와 리밸런스 비용이 는다. 키 단위 순서 때문에 나중에 파티션 수를 바꾸면 키가 다른 파티션으로 옮겨 순서가 깨질 수 있어 처음에 넉넉히 정한다.
프로듀서가 메시지 m1을 보낸다. 브로커는 파티션 끝(오프셋 41)에 덧붙인다. 위 줄은 멱등성을 끈 경우, 아래 줄은 켠 경우다.
실제 사례
Apache Kafka가 이 설계의 표준이다. 공식 문서의 설계 장은 파티션 로그, 운영체제 페이지 캐시와 복사 없는 전송(zero-copy)으로 디스크를 순차로 쓰고 읽는 이유, 전달 보장의 세 단계를 설명한다. 멱등 프로듀서와 트랜잭션은 Kafka 0.11에서 KIP-98로 들어왔고, Confluent의 Exactly-once Semantics is Possible: Here's How Apache Kafka Does it가 그 동작을 풀어 쓴다. 트랜잭션으로 쓴 메시지는 컨슈머가 read_committed로 읽어야 커밋되지 않은 결과를 건너뛴다.
지표 모니터링
요구와 규모
서버와 서비스의 숫자(요청 수, 지연, 오류율, CPU)를 모아 그래프로 보고, 문제가 생기면 사람을 부른다. 서버 1만 대가 각각 시계열 1,000개를 내고, 15초마다 수집한다고 하자.
- 활성 시계열 1천만 개, 수집은 1천만 ÷ 15초 ≈ 초당 67만 개 값.
- 값 하나를 타임스탬프 8바이트와 값 8바이트로 그대로 쓰면 하루 67만 × 86,400 × 16B ≈ 920GB다. 시계열 전용 압축으로 값 하나를 1.4바이트 안팎으로 줄이면 하루 약 80GB로, 열 배 넘게 준다.
- 1년 보관은 원본으로 하면 수십 TB다. 15초 원본을 5분 평균으로 바꾸면 점이 20분의 1, 1시간 평균이면 240분의 1이 된다.
핵심 설계
시계열 하나는 지표 이름과 라벨(http_requests_total{service="cart", code="500"})로 정해지고, 값은 시각과 숫자의 쌍이다. 수집 방식은 둘이다. pull은 수집기가 대상의 /metrics 주소를 주기적으로 긁어 온다. 수집기가 대상 목록을 서비스 탐색으로 알고 있으니 "응답이 없다" 자체가 장애 신호가 되고, 대상이 수집기를 몰라도 된다. push는 대상이 수집기에 보낸다. 방화벽 너머나 몇 초 만에 끝나는 배치 작업처럼 긁어 갈 수 없는 곳에 맞다.
저장은 시계열 DB에 한다. 쓰기는 거의 다 "지금"에 덧붙이기이고, 읽기는 최근 몇 시간 범위를 시계열 여러 개에 걸쳐 모으는 일이다. 그래서 최근 값은 메모리에 모았다가 몇 시간 단위 블록으로 압축해 내리고, 시각은 "차이의 차이"로, 값은 이전 값과의 XOR로 저장해 비슷한 값이 이어질 때 몇 비트로 줄인다. 시계열 DB의 내부는 시계열·그래프·벡터 DB 노트에 있다.
어려운 점과 선택지
- 라벨 폭발: 라벨에 사용자 ID나 요청 ID를 넣으면 시계열 수가 사용자 수만큼 불어나 색인과 메모리가 터진다. 라벨에는 값의 종류가 적은 것만 넣고, 개별 요청은 로그나 트레이스로 본다.
- 무엇에 알림을 거나: CPU 80% 같은 원인에 알림을 걸면 밤마다 울리지만 사용자는 멀쩡할 수 있다. 사용자가 겪는 증상(지연, 오류율)에 걸고, 일시적 튐은 "5분 동안 계속되면"처럼 기간 조건으로 거른다. 알림 관리자는 같은 알림을 묶고 중복을 지우고 담당자에게 보낸다.
- 모니터링의 모니터링: 수집기가 죽으면 "아무 알림도 없음"이 "아무 문제도 없음"처럼 보인다. 수집기 둘을 같은 대상에 붙이고, 바깥에서 "알림 시스템이 살아 있다"는 신호를 늘 받게 한다.
- 다운샘플: 평균만 남기면 순간 튐이 사라진다. 평균과 함께 최댓값과 개수, 지연 분포는 히스토그램 버킷을 남겨 나중에도 백분위를 다시 계산할 수 있게 한다.
실제 사례
Prometheus는 pull 방식 수집기와 시계열 DB, 규칙 평가를 한 프로그램에 담고, 알림 전달은 Alertmanager로 나눴다. FAQ는 pull을 고른 이유와 짧게 사는 작업에 푸시 게이트웨이를 쓰라는 안내를, 저장소 문서는 2시간 블록과 기본 보관 15일을 설명한다. 압축 방식은 Facebook의 VLDB 2015 논문 Gorilla가 원형이다. 타임스탬프의 차이의 차이와 값의 XOR로 값 하나를 평균 1.37바이트로 줄였다. 무엇을 잴지는 Google SRE 책의 Monitoring Distributed Systems 장이 지연·트래픽·오류·포화의 "네 가지 황금 신호"로 정리했다. 수집 규모가 커지면 Uber의 M3처럼 수집 단계에서 미리 집계하고 해상도별로 따로 보관하는 분산 저장소를 둔다.
광고 클릭 집계
요구와 규모
광고마다 분 단위 클릭 수를 세어 광고주 보고서와 예산 차감, 과금에 쓴다. 돈이 걸리므로 잃어도, 두 번 세도 안 되고, 몇 분 안에 숫자가 보여야 한다. 하루 클릭 3억 건, 집행 중인 광고 50만 개라고 하자.
- 평균 3억 ÷ 86,400 ≈ 초당 3,500건, 저녁에 다섯 배면 초당 1만 7천 건.
- 집계 결과는 분마다 "클릭이 있었던 광고" 수만큼의 행이다. 많아야 분당 50만 행으로, 원본보다 훨씬 작다. 원본은 대사와 재처리를 위해 따로 오래 둔다.
핵심 설계
클릭 로그를 메시지 큐에 넣고, 스트림 처리기가 광고 ID로 나눠 고정 창(tumbling window, 1분)마다 센다. 중요한 선택은 "어느 시각으로 창을 나누나"다. 처리기에 닿은 시각으로 나누면 쉽지만, 휴대폰이 잠시 오프라인이었다 몰아 보낸 클릭이 엉뚱한 분에 들어간다. 과금은 클릭이 일어난 시각(사건 시간)으로 해야 하므로, 늦게 오는 사건을 얼마나 기다릴지 정하는 워터마크가 필요하다.
처리기는 상태(창마다 센 수)와 큐 오프셋을 함께 주기적으로 스냅샷(체크포인트)한다. 죽으면 마지막 체크포인트로 돌아가 그 오프셋부터 다시 읽으니, 상태 안에서는 정확히 한 번 센 것과 같다. 결과를 바깥으로 내보낼 때는 결과마다 (광고 ID, 분)으로 정해지는 키를 붙여 덮어쓰기(upsert)를 하거나, 체크포인트에 맞춰 커밋하는 트랜잭션 출력으로 중복을 막는다.
어려운 점과 선택지
- 기다림과 신선도: 허용 지연을 길게 잡으면 늦은 클릭을 많이 받지만 결과가 늦게 나온다. 짧게 잡고 늦은 사건을 따로 모아 나중에 보정 결과를 내는 쪽이 흔하다. 첫 결과는 빠르게, 정확한 결과는 나중에 덮어쓰는 셈이다.
- 중복 클릭: 같은 클릭이 재시도로 두 번 오기도 하고, 봇이 같은 광고를 일부러 여러 번 누르기도 한다. 앞쪽은 클릭 ID로 중복을 지우고, 뒤쪽은 부정 클릭 판정이라는 별개의 문제로 다룬다.
- 뜨거운 광고: 큰 캠페인 하나가 전체 클릭의 상당 부분을 차지하면 그 광고 ID를 맡은 작업자 하나가 밀린다. 광고 ID 뒤에 작은 무작위 수를 붙여 여러 작업자에게 나눠 센 뒤 한 번 더 합친다.
- 대사(reconciliation): 스트림 결과가 틀리지 않았는지 확인할 기준이 필요하다. 원본 로그를 하루에 한 번 배치로 다시 세어 스트림 결과와 비교하고, 차이가 나면 배치 결과로 고친다. 돈이 걸린 숫자에는 늘 두 번째 계산이 따라붙는다.
실제 사례
창과 워터마크, 트리거라는 말은 Google의 VLDB 2015 논문 The Dataflow Model이 정리했고, Apache Flink의 창 문서가 같은 개념을 구현한다. Google은 광고 클릭과 검색 질의를 실시간으로 이어 붙이되 한 클릭을 정확히 한 번만 쓰는 시스템을 Photon 논문(SIGMOD 2013)으로 공개했다. 이미 처리한 클릭 ID를 여러 데이터센터에 복제된 저장소에 기록해 중복을 막는다. Uber는 Real-Time Exactly-Once Ad Event Processing에서 Flink로 1분 고정 창을 세고, Kafka 트랜잭션을 Flink 체크포인트에 맞춰 커밋하며, 결과 행마다 붙인 UUID를 아래 단계가 멱등 키와 덮어쓰기 키로 쓰는 구조를 설명한다.
실시간 순위표
요구와 규모
게임 시즌 동안 점수 순위를 보여 준다. 경기가 끝나면 바로 내 점수와 순위가 바뀌어야 하고, 상위 10명과 "내 주변 순위"를 자주 본다. 시즌 참가자 1,200만 명, 하루 활성 300만 명이 하루 여덟 경기를 한다고 하자.
- 점수 갱신: 300만 × 8 = 하루 2,400만 건, ÷ 86,400 ≈ 초당 280건, 저녁에 열 배면 초당 2,800건.
- 메모리: 참가자 한 명에 ID와 점수, 자료구조 오버헤드를 합쳐 100바이트로 잡으면 1,200만 × 100B = 1.2GB. 서버 한 대의 메모리에 넉넉히 들어간다.
핵심 설계
관계형 DB로 "내 순위"를 구하려면 나보다 점수가 높은 행을 세야 한다. 점수에 색인이 있어도 상위권이 아니면 수백만 행을 세는 일이라 초당 수천 번 하기 어렵다. 그래서 순위표는 대개 정렬 집합(sorted set)을 쓴다. 원소마다 점수를 들고 늘 점수 순으로 정렬된 집합이고, 넣기와 점수 바꾸기, 순위 찾기가 모두 O(log N)이다. 내부는 원소에서 점수를 찾는 해시 표와, 점수 순서를 지키는 스킵 리스트의 조합이다. 스킵 리스트의 각 층이 아래 층에서 몇 칸을 건넜는지 기억하고 있어서, 위에서 내려오며 건넌 칸 수를 더하면 순위가 된다.
DB가 원본을 맡는다. 경기 결과는 먼저 DB에 기록하고, 순위표는 그 결과로 정렬 집합을 고친다. 순위표 서버가 죽으면 DB에서 다시 만들 수 있으니, 순위표는 "빠르게 답하는 파생 데이터"로 둔다. 시즌이 바뀌면 키를 새로 만들어(lb:2026-10) 옛 시즌은 그대로 남긴다.
어려운 점과 선택지
- 같은 점수: 동점자의 순서를 정해야 한다. 정렬 집합은 점수가 같으면 원소 이름 순이라, "먼저 도달한 사람이 위"를 원하면 점수에 도달 시각을 섞은 값을 점수로 쓴다(예: 점수 × 10¹⁰ + (큰 수 − 시각)).
- 한 대를 넘을 때: 수억 명 규모면 나눠야 한다. 점수 구간으로 나누면 내 순위 = 나보다 높은 구간의 인원 + 내 구간 안의 순위라 계산이 쉽지만, 점수가 오르면 샤드를 옮겨야 하고 상위 구간 샤드에 갱신이 몰린다. 사용자 해시로 나누면 부하는 고르지만 상위 k는 샤드마다 상위 k를 모아 합쳐야 하고, 한 사람의 정확한 순위는 모든 샤드에 "나보다 높은 사람 수"를 물어야 한다.
- 정확한 순위가 꼭 필요한가: 상위 1,000명 밖에서는 "상위 3%"처럼 근사한 백분위만 보여 줘도 된다. 점수 분포 히스토그램만 있으면 한 번의 조회로 답할 수 있어 샤딩 문제가 크게 줄어든다.
실제 사례
Redis 정렬 집합 문서는 순위표를 대표 용도로 들고, 스킵 리스트와 해시 표로 구현되어 넣기와 순위 질의가 O(log N)이라고 설명한다. AWS의 Building a real-time gaming leaderboard with Amazon ElastiCache for Redis는 같은 명령으로 상위 목록, 내 순위, 내 주변 순위를 구하는 예를 보여 준다. Redis 자체의 구조는 키-값 노트에 있다.
- Apache Kafka Documentation — Apache Software Foundation
- Prometheus Overview — Prometheus
- Prometheus FAQ — Prometheus
- Storage — Prometheus
- Gorilla: A Fast, Scalable, In-Memory Time Series Database — VLDB 2015
- Monitoring Distributed Systems — Google SRE Book
- M3: Uber's Open Source, Large-scale Metrics Platform — Uber
- The Dataflow Model — Google Research (VLDB 2015)
- Photon: Fault-tolerant and Scalable Joining of Continuous Data Streams — Google Research
- Real-Time Exactly-Once Ad Event Processing with Apache Flink, Kafka, and Pinot — Uber
- Building a real-time gaming leaderboard with Amazon ElastiCache for Redis — AWS