한 줄 요약: 스트리밍 플랫폼의 핵심은 RTMP 인제스트 → DAG 트랜스코딩 → 멀티 CDN HLS/DASH 배포의 3단 파이프라인으로 1억 시청자를 감당하고, Redis HyperLogLog·Sorted Set·Lua 스크립트로 실시간 지표를 제공하며, Kafka → Flink → TSDB 시청 분석 파이프라인으로 시청자 행동을 실시간 피드백하는 것이다.

실제 문제 — 넷플릭스·유튜브·트위치가 겪은 장애

넷플릭스 2012년 크리스마스 이브 장애

2012년 12월 24일, 넷플릭스 미국 서비스가 약 8시간 동안 중단됐습니다. 원인은 AWS US-East-1 리전의 ELB(Elastic Load Balancer) 장애였습니다. 당시 넷플릭스는 단일 리전 CDN 오리진에 의존하고 있었고, 오리진이 중단되자 CDN 캐시 미스가 연쇄적으로 오리진으로 향하면서 전체 스택이 마비됐습니다. 이 사건이 넷플릭스를 멀티 리전 Active-Active 아키텍처와 자체 CDN인 Open Connect로 이끈 결정적 계기가 됐습니다.

핵심 교훈: CDN 오리진은 단일 장애점이 되어서는 안 되며, 멀티 CDN + 오리진 폴백 전략이 필수입니다.

유튜브 2018년 10월 전 세계 동시 다발 장애

2018년 10월 유튜브는 약 1시간 30분 동안 전 세계적으로 서비스가 중단됐습니다. 구글은 공식 원인을 “내부 스토리지 시스템 이슈”라고만 밝혔지만, 후속 분석에 따르면 트랜스코딩 파이프라인의 메타데이터 스토리지 클러스터에서 발생한 일관성 오류가 전파된 것으로 추정됩니다. 업로드된 영상의 세그먼트 인덱스를 찾을 수 없어 재생 자체가 불가능했습니다.

핵심 교훈: 트랜스코딩 결과 메타데이터(세그먼트 인덱스, 매니페스트 파일)는 스토리지와 별도로 캐시해야 하며, 읽기 경로와 쓰기 경로를 완전히 분리해야 합니다.

트위치 2020년 아마존 프라임 데이 채팅 장애

2020년 아마존 프라임 데이 당일, 대형 스트리머들의 채팅창이 수분씩 멈추거나 메시지가 10분 뒤에 뭉텅이로 도착하는 현상이 발생했습니다. WebSocket 연결 수가 평소 대비 3배로 폭등하면서 채팅 서버의 Redis Pub/Sub 메시지 처리가 병목이 됐습니다. 개별 채널당 WebSocket 연결을 팬아웃하는 구조에서, 한 채널에 50만 명이 동시 접속하자 1개 메시지가 50만 개 소켓에 동시 전송되어야 하는 상황이 발생했습니다.

핵심 교훈: 채팅 팬아웃은 계층 구조(샤딩 + 구독 트리)로 분산해야 하며, 단일 채널 폭주를 대비한 rate limiting과 admission control이 필수입니다.

스포티파이 2022년 음악 스트리밍 지연 장애

2022년 스포티파이는 특정 지역에서 재생 버튼을 눌러도 5~15초의 긴 초기 버퍼링이 발생하는 장애를 경험했습니다. 원인은 CDN 캐시 히트율 급락이었습니다. 신규 릴리즈 앨범 트래픽이 특정 CDN 노드에 집중되면서 캐시 용량을 초과했고, 미스된 요청이 오리진 스토리지로 폭주했습니다. 오리진 스토리지 읽기 IOPS 한계에 도달하면서 응답 지연이 10배 이상 증가했습니다.

핵심 교훈: 신규 콘텐츠 릴리즈 시 CDN 프리워밍(pre-warming)과 오리진 rate limiting이 필요합니다. 인기 콘텐츠와 일반 콘텐츠를 별도 스토리지 티어로 분리하는 것이 효과적입니다.


설계 의사결정 로드맵

스트리밍 플랫폼을 설계할 때 면접관이 “왜 이 선택인가?”를 반드시 물어보는 5가지 핵심 결정입니다.

결정 1: 스트리밍 프로토콜 — HLS vs DASH vs WebRTC

후보 지연시간 CDN 캐시 호환성 언제 적합
HLS (Apple) 6~30초 (LL-HLS: 2초) 가능 (HTTP 세그먼트) iOS/Safari 필수 VOD, 라이브 스트리밍 범용
DASH (ISO 표준) 2~10초 가능 (HTTP 세그먼트) 브라우저·Android VOD, Android 앱 중심
WebRTC 50~500ms 불가 (P2P/SRTP) 브라우저 표준 1:1 화상통화, 인터랙티브 라이브
RTMP 1~3초 불가 (TCP 스트림) 레거시, 포트 1935 스트리머→인제스트 서버 전용

우리의 선택: DASH + HLS 이중 배포, 인터랙티브 라이브는 WebRTC

HLS와 DASH 모두 HTTP 기반 세그먼트 파일을 CDN이 캐시할 수 있습니다. 1억 명이 같은 세그먼트를 요청해도 CDN이 응답하므로 오리진 부하가 사실상 0에 수렴합니다. RTMP는 OBS·스트리밍 소프트웨어에서 인제스트 서버까지의 송출 구간에만 사용하고, 시청자 배포는 전부 HLS/DASH로 변환합니다. 화상 강의·라이브 커머스처럼 1초 미만 지연이 필요한 경우에만 WebRTC를 적용하며, 이 경우 CDN 캐시를 포기하고 선택적 릴레이 서버를 운영합니다.

결정 2: 트랜스코딩 — 온디맨드 vs 사전 인코딩 vs 적응형

후보 장점 단점 언제 적합
사전 인코딩 (Pre-encode) 재생 시 지연 없음, 스토리지 비용 예측 가능 업로드 즉시 공개 불가, 희귀 해상도 요청도 사전 인코딩 인기 VOD, 스트리밍 플랫폼
온디맨드 (Just-in-time) 실제 요청 해상도만 인코딩 첫 재생 지연, 동일 요청 중복 인코딩 위험 롱테일 콘텐츠, 저트래픽
적응형 (DAG 병렬) 부분 완료 즉시 공개, 실패 노드만 재시도 워커 조율 인프라 복잡 대규모 업로드, 빠른 공개

우리의 선택: DAG 기반 적응형 병렬 트랜스코딩 + 지능형 프리-인코딩

영상을 청크(Chunk) 단위로 분할해 360p, 720p, 1080p, 4K를 동시에 인코딩합니다. 360p가 완료되는 순간 시청 가능 상태로 공개하고, 이후 고화질 버전이 순차 추가됩니다. 조회수 기준 상위 20% 콘텐츠는 4K까지 사전 인코딩하고, 롱테일 80%는 실제 요청 해상도까지만 인코딩합니다.

결정 3: CDN 전략 — 멀티 CDN vs 자체 CDN vs 하이브리드

후보 장점 단점 언제 적합
단일 상업 CDN 운영 단순 장애 시 단일 실패점, 협상력 없음 초기 스타트업
멀티 상업 CDN 장애 격리, CDN 간 경쟁으로 비용 절감 일관성 관리, 비용 복잡 글로벌 중규모 서비스
자체 CDN (Netflix OCA 방식) 최저 비용, 완전 제어 ISP 협력 필요, 운영 인력 많음 넷플릭스급 트래픽
하이브리드 자체 CDN으로 80% 처리, 상업 CDN 버스트 처리 라우팅 로직 복잡 대규모 + 트래픽 변동 큰 경우

우리의 선택: 멀티 CDN (Akamai + Cloudflare) + 지능형 라우팅

지연시간 기반 DNS 라우팅으로 사용자를 최적 CDN으로 연결합니다. 어느 한 CDN의 에러율이 임계값(1%)을 초과하면 자동으로 다른 CDN으로 100% 트래픽을 전환합니다. 자체 CDN은 트래픽이 월 1Tbps를 초과할 때 검토합니다.

결정 4: 실시간 채팅 — WebSocket vs SSE vs 폴링

후보 방향 연결 유지 언제 적합
폴링 (HTTP) 단방향 (클라이언트 요청) 불필요 메시지 빈도 낮은 댓글형
SSE (Server-Sent Events) 서버→클라이언트 단방향 유지 알림, 뉴스피드
WebSocket 양방향 유지 실시간 채팅, 게임, 트레이딩

우리의 선택: WebSocket + Redis Pub/Sub 팬아웃

채팅은 초당 수천 건의 양방향 메시지 교환이 필요하므로 WebSocket이 필수입니다. 폴링은 100만 동시 사용자 기준 초당 100만 HTTP 요청이 발생하고, SSE는 서버에서 클라이언트로만 메시지를 보낼 수 있어 채팅에 부적합합니다. WebSocket 연결은 채팅 전용 서버 클러스터에서 관리하고, 서버 간 메시지 전달은 Redis Pub/Sub으로 처리합니다.

결정 5: 추천 파이프라인 — 배치 vs 실시간 vs 하이브리드

후보 신선도 계산 비용 언제 적합
배치 (Spark, 매일 새벽) 24시간 지연 낮음 초기, 트래픽 적은 서비스
실시간 (Flink, 초 단위) 수 초 이내 높음 트렌딩 콘텐츠, 최신 행동 반영
하이브리드 (람다 아키텍처) 배치 기반 + 실시간 보정 중간 대규모 추천 시스템 표준

우리의 선택: 하이브리드 람다 아키텍처

사용자의 장기 선호(collaborative filtering)는 매일 배치로 학습하고, 오늘 본 영상·현재 트렌딩은 실시간 Flink 파이프라인으로 반영합니다. 추천 결과는 사용자별로 Redis에 캐시해 서빙 지연을 10ms 이내로 유지합니다.


요구사항 분석 및 규모 추정

기능 요구사항

  • 영상 업로드, 트랜스코딩, 재생 (VOD + 라이브)
  • 적응형 비트레이트 스트리밍 (ABR): 네트워크 상황에 따라 화질 자동 조절
  • 실시간 채팅 및 반응(이모지) 기능
  • 실시간 시청자 수, 좋아요 수 표시
  • 시청 이력 기반 개인화 추천
  • 검색, 구독, 알림 기능

비기능 요구사항

  • 가용성: 99.99% (연간 다운타임 52분 이하)
  • 지연시간: 영상 초기 재생 3초 이내, 채팅 메시지 전달 500ms 이내
  • 확장성: 동시 시청 1억 명, 초당 업로드 1,000건
  • 내구성: 업로드된 영상 99.999999999% (11-nine) 보존

규모 추정

업로드 트래픽

  • 초당 신규 업로드: 500건 (유튜브 기준)
  • 평균 영상 크기 (원본): 500MB
  • 초당 업로드 데이터: 500 × 500MB = 250GB/s
  • 일간 스토리지 증가: 250GB × 86,400초 = 약 21PB/day

시청 트래픽

  • 동시 시청자: 1억 명
  • 평균 비트레이트: 4Mbps (1080p 기준)
  • 총 대역폭: 1억 × 4Mbps = 400Tbps
  • CDN이 99% 처리하면 오리진 부하: 4Tbps (여전히 대규모)

채팅 트래픽

  • 동시 채팅 참여자: 1,000만 명 (시청자의 10%)
  • 인당 초당 메시지: 0.1건 (10초에 1건)
  • 초당 총 메시지: 100만 건
  • 피크 시 채팅 서버: 메시지당 2KB → 초당 2GB 처리

캐시 요구사항

  • 인기 콘텐츠 상위 20%가 전체 조회의 80% 차지 (파레토 법칙)
  • 상위 1만 개 콘텐츠의 360p 세그먼트: 1만 × 500MB = 5TB → Redis Cluster 적재 가능
  • 시청자 수 카운팅: HyperLogLog 키당 12KB, 1만 개 라이브 스트림 = 120MB

고수준 아키텍처

스트리밍 플랫폼을 물 공급망으로 비유하면 이해가 쉽습니다. 스트리머(수원지)가 원수를 RTMP로 정수장(인제스트 서버)에 보냅니다. 정수장은 원수를 다양한 용도(360p·1080p·4K)에 맞게 정수·분류합니다. 정수된 물은 전국 배수지(CDN 엣지)에 미리 채워놓고, 시청자(수도꼭지)는 가장 가까운 배수지에서 자신의 파이프 굵기(네트워크 대역폭)에 맞는 물을 받습니다.

graph LR
  S["스트리머/업로더"] --> I["인제스트 서버"]
  I --> T["트랜스코딩 워커"]
  T --> O["오리진 스토리지"]
  O --> C["CDN 엣지"]
  C --> V["시청자 1억명"]
  V --> CH["채팅 서버"]

전체 흐름은 두 경로로 나뉩니다. 쓰기 경로(업로드·인제스트·트랜스코딩)는 낮은 빈도지만 높은 처리량이 필요하고, 읽기 경로(CDN 배포·재생)는 극도로 높은 빈도와 낮은 지연시간이 필요합니다. 두 경로를 완전히 분리하는 것이 설계의 핵심입니다.


핵심 컴포넌트 상세 설계

1. 비디오 업로드·트랜스코딩 파이프라인

영상 업로드를 공장 생산 라인으로 생각하세요. 원자재(원본 영상)가 입고되면 컨베이어 벨트(Kafka)가 여러 가공 라인(트랜스코딩 워커)으로 분배하고, 각 라인이 동시에 다른 규격(해상도)으로 제품을 완성합니다. 가장 빠른 라인(360p)이 완료되는 즉시 출하가 시작됩니다.

업로드 처리 흐름

  1. 클라이언트가 영상 파일을 청크(5MB) 단위로 분할해 멀티파트 업로드
  2. API 서버가 S3 멀티파트 업로드 URL을 발급하고 클라이언트가 직접 S3에 업로드 (API 서버 대역폭 절약)
  3. S3 업로드 완료 이벤트가 Kafka video.uploaded 토픽에 발행
  4. 트랜스코딩 오케스트레이터가 이벤트 소비 후 DAG 작업 계획 수립
  5. 각 해상도 워커가 FFmpeg으로 병렬 인코딩
  6. 완료된 세그먼트를 CDN 오리진 스토리지에 저장
  7. HLS/DASH 매니페스트(.m3u8, .mpd) 생성 후 메타데이터 DB에 등록

트랜스코딩 워커 Java 구현

@Component
public class TranscodingWorker {

    private static final Map<String, TranscodeProfile> PROFILES = Map.of(
        "360p",  new TranscodeProfile(640,  360,  500_000,  "libx264", "aac"),
        "720p",  new TranscodeProfile(1280, 720,  2_500_000, "libx264", "aac"),
        "1080p", new TranscodeProfile(1920, 1080, 5_000_000, "libx264", "aac"),
        "4k",    new TranscodeProfile(3840, 2160, 15_000_000,"libx265", "aac")
    );

    // DAG 작업 단위: 원본 청크 하나를 특정 해상도로 변환
    public TranscodeResult transcode(TranscodeJob job) {
        TranscodeProfile profile = PROFILES.get(job.getResolution());

        // FFmpeg 명령어 구성: 세그먼트 길이 6초, HLS 출력
        List<String> cmd = List.of(
            "ffmpeg", "-i", job.getInputPath(),
            "-vf",   "scale=" + profile.width() + ":" + profile.height(),
            "-c:v",  profile.videoCodec(),
            "-b:v",  profile.videoBitrate() + "",
            "-c:a",  profile.audioCodec(),
            "-hls_time", "6",
            "-hls_playlist_type", "vod",
            "-hls_segment_filename", job.getOutputDir() + "/seg_%03d.ts",
            job.getOutputDir() + "/index.m3u8"
        );

        try {
            Process process = new ProcessBuilder(cmd)
                .redirectErrorStream(true)
                .start();

            int exitCode = process.waitFor();
            if (exitCode != 0) {
                throw new TranscodeException("FFmpeg failed: " + job.getVideoId()
                    + " resolution=" + job.getResolution());
            }

            // 완료 즉시 CDN 오리진에 업로드하고 가용 상태로 전환
            String cdnPath = uploadToCdnOrigin(job.getOutputDir(), job.getVideoId(),
                job.getResolution());
            markResolutionAvailable(job.getVideoId(), job.getResolution(), cdnPath);

            return TranscodeResult.success(job.getVideoId(), job.getResolution(), cdnPath);

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new TranscodeException("Interrupted: " + job.getVideoId());
        }
    }

    private void markResolutionAvailable(String videoId, String resolution, String path) {
        // 360p 완료 시점에 영상을 "시청 가능" 상태로 전환
        // 이후 720p, 1080p, 4K 순서로 추가됨
        videoMetadataRepository.addAvailableResolution(videoId, resolution, path);
        if ("360p".equals(resolution)) {
            videoMetadataRepository.setStatus(videoId, VideoStatus.AVAILABLE);
            eventPublisher.publish("video.available", new VideoAvailableEvent(videoId));
        }
    }
}

설명할 부분이 있습니다. hls_time 6은 세그먼트 길이를 6초로 설정합니다. 너무 짧으면(2초) 세그먼트 파일 수가 늘어 CDN 메타데이터 오버헤드가 증가하고, 너무 길면(10초) ABR 전환 반응이 느려집니다. 6초는 넷플릭스와 유튜브의 표준값입니다. libx265(HEVC)는 4K에 사용하는데, 동일 화질에서 H.264 대비 파일 크기를 40% 줄입니다. 다만 인코딩 시간이 3배 길어지므로 4K 전용으로 한정합니다.


2. CDN + Adaptive Bitrate (ABR) 전략

ABR을 자동 변속 자동차로 비유하면 명확합니다. 고속도로(광대역 네트워크)에서는 6단(4K), 시내 정체(LTE)에서는 3단(720p), 골목길(약한 WiFi)에서는 1단(360p)으로 자동 전환합니다. 운전자(시청자)는 기어를 신경 쓸 필요 없습니다.

ABR 동작 원리

HLS/DASH 매니페스트 파일에는 각 해상도의 세그먼트 URL이 정의됩니다. 클라이언트 플레이어는 다음 세그먼트를 다운로드하는 속도(throughput)와 현재 버퍼 잔량을 보고 다음 세그먼트의 화질을 결정합니다.

# HLS 마스터 플레이리스트 (master.m3u8)
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=640x360
https://cdn.example.com/videos/abc123/360p/index.m3u8

#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720
https://cdn.example.com/videos/abc123/720p/index.m3u8

#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080
https://cdn.example.com/videos/abc123/1080p/index.m3u8

클라이언트는 먼저 이 마스터 플레이리스트를 요청합니다. 현재 네트워크 처리량이 3Mbps이면 2.5Mbps 스트림(720p)을 선택하고, 이후 처리량이 6Mbps로 올라가면 5Mbps 스트림(1080p)으로 업그레이드합니다.

CDN 캐시 계층 설계

@Service
public class CdnRoutingService {

    // CDN 상태 모니터링 기반 동적 라우팅
    public String selectCdn(String userId, String videoId, String segmentPath) {
        List<CdnProvider> healthyCdns = cdnHealthMonitor.getHealthyCdns();

        if (healthyCdns.isEmpty()) {
            // 모든 CDN 장애 시 오리진 직접 서빙 (긴급 폴백)
            return originStorageUrl(videoId, segmentPath);
        }

        // 사용자 위치 기반 최적 CDN 선택 (레이턴시 우선)
        String userRegion = geoIpService.getRegion(userId);
        return healthyCdns.stream()
            .min(Comparator.comparingDouble(
                cdn -> cdn.getLatencyMs(userRegion)))
            .map(cdn -> cdn.buildUrl(videoId, segmentPath))
            .orElse(originStorageUrl(videoId, segmentPath));
    }

    // CDN 장애 감지: 에러율 > 1% 시 해당 CDN 제외
    @Scheduled(fixedDelay = 10_000)
    public void healthCheck() {
        for (CdnProvider cdn : allCdns) {
            double errorRate = metricsClient.getErrorRate(cdn.getName(), Duration.ofMinutes(1));
            if (errorRate > 0.01) {
                log.warn("CDN {} error rate {}%, marking unhealthy", cdn.getName(), errorRate * 100);
                cdnHealthMonitor.markUnhealthy(cdn.getName());
            } else {
                cdnHealthMonitor.markHealthy(cdn.getName());
            }
        }
    }
}

CDN 캐시 무효화 시 분산 락

콘텐츠를 수정(자막 오류 수정, 불법 콘텐츠 제거)할 때 CDN 캐시를 무효화해야 합니다. 동시에 수백 개의 API 서버가 같은 콘텐츠의 purge를 요청하면 CDN 벤더 API rate limit에 걸립니다. 분산 락으로 단 하나의 서버만 purge를 실행하게 합니다.

@Service
public class CdnInvalidationService {

    private final RedisTemplate<String, String> redis;

    public void invalidate(String videoId) {
        String lockKey = "cdn:purge:lock:" + videoId;
        String lockValue = UUID.randomUUID().toString();

        // SET NX PX: 락이 없을 때만 설정, 30초 TTL
        Boolean acquired = redis.opsForValue()
            .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(30));

        if (Boolean.TRUE.equals(acquired)) {
            try {
                // 이 서버만 실제 CDN purge 실행
                cdnClient.purge(videoId);
                log.info("CDN purge executed for videoId={}", videoId);
            } finally {
                // Lua 스크립트로 내가 건 락만 해제 (타인의 락 해제 방지)
                releaseLock(lockKey, lockValue);
            }
        } else {
            // 다른 서버가 이미 purge 중 → 스킵
            log.debug("CDN purge already in progress for videoId={}, skipping", videoId);
        }
    }

    private void releaseLock(String lockKey, String lockValue) {
        String luaScript =
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "  return redis.call('del', KEYS[1]) " +
            "else return 0 end";
        redis.execute(
            new DefaultRedisScript<>(luaScript, Long.class),
            List.of(lockKey),
            lockValue
        );
    }
}

Lua 스크립트로 GET과 DEL을 원자적으로 처리하는 이유가 있습니다. get(key) == myValue이면 del(key)를 실행하는 로직을 일반 코드로 작성하면, GET과 DEL 사이에 다른 프로세스가 락을 재획득할 수 있습니다. Lua 스크립트는 Redis에서 원자적으로 실행되므로 이 경쟁 조건을 원천 차단합니다.


3. 실시간 채팅 — WebSocket + Redis Pub/Sub

채팅 서버를 우체국으로 비유하면 이해하기 쉽습니다. 시청자A가 채팅 서버1에 편지(메시지)를 보내면, 서버1은 중앙 우편 분류소(Redis Pub/Sub)에 전달합니다. 분류소는 같은 채널을 구독한 모든 우체국(채팅 서버2, 서버3)에 복사본을 전달하고, 각 우체국이 자신에게 연결된 시청자들에게 배달합니다.

WebSocket 서버 구현

@ServerEndpoint("/ws/chat/{channelId}")
@Component
public class ChatWebSocketServer {

    // 채널별 연결된 세션 목록 (서버 로컬)
    private static final ConcurrentHashMap<String, Set<Session>> channelSessions
        = new ConcurrentHashMap<>();

    private final RedisTemplate<String, String> redis;
    private final ChatRateLimiter rateLimiter;

    @OnOpen
    public void onOpen(Session session, @PathParam("channelId") String channelId) {
        channelSessions.computeIfAbsent(channelId,
            k -> ConcurrentHashMap.newKeySet()).add(session);

        // 이 서버가 해당 채널을 처음 구독하는 경우 Redis Pub/Sub 구독 시작
        subscribeIfNeeded(channelId);
    }

    @OnMessage
    public void onMessage(String message, Session session,
                          @PathParam("channelId") String channelId) {
        String userId = getUserId(session);

        // Rate Limiting: 슬라이딩 윈도우 Lua 스크립트 (초당 3건 제한)
        if (!rateLimiter.allow(userId, channelId)) {
            session.getAsyncRemote().sendText(
                "{\"type\":\"rate_limit\",\"msg\":\"메시지가 너무 빠릅니다\"}");
            return;
        }

        ChatMessage chatMsg = ChatMessage.builder()
            .channelId(channelId)
            .userId(userId)
            .content(message)
            .timestamp(Instant.now().toEpochMilli())
            .build();

        // Redis Pub/Sub으로 모든 채팅 서버에 브로드캐스트
        redis.convertAndSend("chat:" + channelId, objectMapper.writeValueAsString(chatMsg));
    }

    // Redis Pub/Sub 수신 → 이 서버에 연결된 시청자들에게 팬아웃
    public void onRedisMessage(String channelId, String messageJson) {
        Set<Session> sessions = channelSessions.getOrDefault(channelId, Set.of());
        for (Session s : sessions) {
            if (s.isOpen()) {
                s.getAsyncRemote().sendText(messageJson);
            }
        }
    }

    @OnClose
    public void onClose(Session session, @PathParam("channelId") String channelId) {
        Set<Session> sessions = channelSessions.get(channelId);
        if (sessions != null) {
            sessions.remove(session);
            if (sessions.isEmpty()) {
                channelSessions.remove(channelId);
                // 이 서버의 마지막 구독자가 떠났으면 Redis 구독 해제
                unsubscribe(channelId);
            }
        }
    }
}

채팅 Rate Limiting — Sliding Window Lua 스크립트

Redis의 Sorted Set을 이용한 슬라이딩 윈도우 알고리즘입니다. Fixed Window 방식은 윈도우 경계에서 2배 트래픽 버스트를 허용하는 문제가 있는데, Sliding Window는 이 문제를 완벽하게 해결합니다.

@Component
public class ChatRateLimiter {

    private final RedisTemplate<String, String> redis;

    // 슬라이딩 윈도우: 1초 안에 최대 3건 허용
    private static final String SLIDING_WINDOW_LUA =
        "local key = KEYS[1] " +
        "local now = tonumber(ARGV[1]) " +
        "local window = tonumber(ARGV[2]) " +
        "local limit = tonumber(ARGV[3]) " +
        // 만료된 타임스탬프 제거 (윈도우 이전 항목)
        "redis.call('ZREMRANGEBYSCORE', key, 0, now - window) " +
        // 현재 윈도우 내 요청 수 확인
        "local count = redis.call('ZCARD', key) " +
        "if count < limit then " +
        // 허용: 현재 타임스탬프를 Sorted Set에 추가
        "  redis.call('ZADD', key, now, now) " +
        "  redis.call('EXPIRE', key, 2) " +
        "  return 1 " +
        "else return 0 end";

    public boolean allow(String userId, String channelId) {
        String key = "ratelimit:chat:" + channelId + ":" + userId;
        long nowMs = System.currentTimeMillis();
        long windowMs = 1000L; // 1초 윈도우
        int limit = 3;         // 초당 3건 제한

        Long result = redis.execute(
            new DefaultRedisScript<>(SLIDING_WINDOW_LUA, Long.class),
            List.of(key),
            String.valueOf(nowMs),
            String.valueOf(windowMs),
            String.valueOf(limit)
        );

        return Long.valueOf(1L).equals(result);
    }
}

Sorted Set을 사용하는 이유: ZADD로 타임스탬프를 score와 member로 동시에 저장하고, ZREMRANGEBYSCORE로 윈도우 밖의 항목을 O(log N)으로 제거합니다. ZCARD로 현재 카운트를 O(1)로 확인합니다. 전체 연산을 Lua로 감싸 원자성을 보장합니다.


4. 실시간 시청자 수 — Redis HyperLogLog

HyperLogLog를 여론조사로 비유하면 이해하기 쉽습니다. 전체 유권자(시청자)를 한 명씩 세는 대신, 표본 조사(해시 비트 패턴)로 전체 수를 추정합니다. 정확도는 99.19%(오차율 0.81%)이지만, 메모리는 고작 12KB만 씁니다.

왜 HyperLogLog인가?

라이브 스트림에 100만 명이 시청 중일 때 정확한 고유 시청자 수를 세려면:

  • Set: 각 사용자 ID(8바이트) × 100만 = 8MB (채널당)
  • HyperLogLog: 고정 12KB (채널 수 무관)

10,000개 라이브 채널이 동시에 진행 중이라면:

  • Set 방식: 10,000 × 8MB = 80GB
  • HyperLogLog: 10,000 × 12KB = 120MB

시청자 수 표시에 0.81% 오차는 전혀 문제되지 않습니다. 100만 명이 시청 중일 때 “999,190명”이 아니라 “100만”으로 표시하기 때문입니다.

@Service
public class ViewerCountService {

    private final RedisTemplate<String, String> redis;

    // 시청자 입장: PFADD로 고유 시청자 추가
    public void recordViewer(String channelId, String userId) {
        String key = "viewers:hll:" + channelId;
        // PFADD: 새로운 원소면 1 반환, 이미 있으면 0 반환
        redis.opsForHyperLogLog().add(key, userId);
        // TTL 설정: 스트림 종료 후 1시간 뒤 자동 삭제
        redis.expire(key, Duration.ofHours(1));
    }

    // 시청자 수 조회: PFCOUNT로 추정값 반환
    public long getViewerCount(String channelId) {
        String key = "viewers:hll:" + channelId;
        Long count = redis.opsForHyperLogLog().size(key);
        return count != null ? count : 0L;
    }

    // 여러 채널의 총 고유 시청자 수 (채널 간 중복 제거)
    // PFMERGE로 여러 HyperLogLog를 병합한 뒤 PFCOUNT
    public long getTotalUniqueViewers(List<String> channelIds) {
        String mergedKey = "viewers:hll:merged:" + UUID.randomUUID();
        String[] keys = channelIds.stream()
            .map(id -> "viewers:hll:" + id)
            .toArray(String[]::new);

        redis.opsForHyperLogLog().union(mergedKey, keys);
        redis.expire(mergedKey, Duration.ofSeconds(60));
        Long count = redis.opsForHyperLogLog().size(mergedKey);
        return count != null ? count : 0L;
    }
}

5. 실시간 인기 콘텐츠 랭킹 — Redis Sorted Set

Redis Sorted Set을 악보 순위표로 비유하면 명확합니다. 각 곡(콘텐츠)에 점수(조회수·좋아요·시청시간)가 붙어 있고, ZADD 한 번으로 점수 갱신과 순위 재정렬이 동시에 일어납니다. 수백만 건의 조회수를 정확히 유지하면서 언제든 1위부터 100위까지 O(log N)으로 뽑아낼 수 있습니다.

@Service
public class ContentRankingService {

    private final RedisTemplate<String, String> redis;

    // 조회수 증가 + 순위 반영: ZINCRBY를 Lua로 원자 처리
    private static final String INCREMENT_AND_RANK_LUA =
        "local score = redis.call('ZINCRBY', KEYS[1], ARGV[1], ARGV[2]) " +
        // 상위 100개만 유지 (메모리 절약): 100위 밖 항목 제거
        "redis.call('ZREMRANGEBYRANK', KEYS[1], 0, -101) " +
        "return score";

    public double incrementViewCount(String rankingKey, String videoId, double delta) {
        // rankingKey 예: "ranking:trending:1h", "ranking:trending:24h"
        Object result = redis.execute(
            new DefaultRedisScript<>(INCREMENT_AND_RANK_LUA, Double.class),
            List.of(rankingKey),
            String.valueOf(delta),
            videoId
        );
        return result instanceof Double ? (Double) result : 0.0;
    }

    // 상위 N개 콘텐츠 조회: ZREVRANGE (높은 점수 순)
    public List<RankedContent> getTopContent(String rankingKey, int topN) {
        // ZREVRANGE: 높은 점수(조회수)부터 내림차순으로 N개 반환
        Set<ZSetOperations.TypedTuple<String>> tuples =
            redis.opsForZSet().reverseRangeWithScores(rankingKey, 0, topN - 1);

        if (tuples == null) return List.of();

        return tuples.stream()
            .map(t -> new RankedContent(t.getValue(), t.getScore().longValue()))
            .collect(Collectors.toList());
    }

    // 멀티 시간 윈도우 랭킹: 1시간·24시간·7일 별도 관리
    public void recordView(String videoId) {
        long now = System.currentTimeMillis();
        // 각 윈도우 키에 조회수 증가
        incrementViewCount("ranking:trending:1h",  videoId, 1.0);
        incrementViewCount("ranking:trending:24h", videoId, 1.0);
        incrementViewCount("ranking:trending:7d",  videoId, 1.0);

        // 시간 감쇠(Time Decay): 최신 조회에 높은 가중치 부여
        // 현재 시각 기반 소수점 가중치로 최신성 반영
        double decayedScore = 1.0 + (now % 1_000_000) / 1_000_000.0;
        incrementViewCount("ranking:trending:realtime", videoId, decayedScore);
    }
}

세션 관리 — SET NX PX + Lua 해제

스트리밍 세션은 사용자가 재생을 시작할 때 생성하고, 재생을 멈추거나 세션이 만료되면 제거합니다. 동시에 같은 계정으로 여러 디바이스에서 재생을 시도할 때 제어가 필요합니다.

@Service
public class StreamingSessionService {

    private final RedisTemplate<String, String> redis;

    // 스트리밍 세션 생성: 계정당 최대 동시 재생 N개 제한
    public SessionResult createSession(String userId, String videoId, String deviceId) {
        String sessionKey = "session:streaming:" + userId + ":" + videoId;
        String sessionToken = UUID.randomUUID().toString();

        // SET NX PX: 세션이 없을 때만 생성, 4시간 TTL
        // NX = Not eXists, PX = milliseconds
        Boolean created = redis.opsForValue()
            .setIfAbsent(sessionKey, sessionToken + ":" + deviceId,
                Duration.ofHours(4));

        if (Boolean.TRUE.equals(created)) {
            return SessionResult.success(sessionToken);
        }

        // 이미 다른 디바이스에서 재생 중
        String existing = redis.opsForValue().get(sessionKey);
        String existingDevice = existing != null ? existing.split(":")[1] : "unknown";
        return SessionResult.conflict(
            "이미 다른 디바이스(" + existingDevice + ")에서 재생 중입니다");
    }

    // 세션 갱신: 재생 중인 경우 TTL 연장 (하트비트)
    public boolean refreshSession(String userId, String videoId, String sessionToken) {
        String sessionKey = "session:streaming:" + userId + ":" + videoId;
        String luaScript =
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "  redis.call('expire', KEYS[1], 14400) " + // 4시간
            "  return 1 " +
            "else return 0 end";

        Long result = redis.execute(
            new DefaultRedisScript<>(luaScript, Long.class),
            List.of(sessionKey),
            sessionToken
        );
        return Long.valueOf(1L).equals(result);
    }
}

시청 분석 파이프라인을 항공 관제 시스템으로 비유하세요. 항공기(시청자)가 이착륙할 때마다 레이더(Kafka)가 위치를 기록하고, 관제 컴퓨터(Flink)가 실시간으로 패턴을 분석해 혼잡을 예측합니다. 통계(TSDB)는 오늘의 모든 비행 기록을 초 단위로 보관합니다.

Kafka Producer 설정 — 쓰로틀링 완전 해부

Kafka Producer의 핵심 설정값을 잘못 이해하면 데이터 유실이나 OOM이 발생합니다.

@Configuration
public class KafkaProducerConfig {

    @Bean
    public ProducerFactory<String, String> viewEventProducerFactory() {
        Map<String, Object> configs = new HashMap<>();
        configs.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-cluster:9092");

        // ===== 처리량 vs 지연시간 트레이드오프 =====

        // buffer.memory: Producer가 브로커로 보내기 전 버퍼링하는 총 메모리 (기본 32MB)
        // 너무 작으면: 초당 100만 이벤트 환경에서 버퍼 가득 찰 때 max.block.ms만큼 블록킹
        // 너무 크면: Producer JVM 힙 압박, GC 빈번
        // 시청 로그처럼 대량·저중요 이벤트에는 128MB로 넉넉하게 설정
        configs.put(ProducerConfig.BUFFER_MEMORY_CONFIG, 134_217_728L); // 128MB

        // max.block.ms: 버퍼 가득 찰 때 send() 호출이 블록되는 최대 시간 (기본 60초)
        // 너무 짧으면: 버퍼 가득 찰 때 즉시 TimeoutException, 이벤트 유실
        // 너무 길면: 요청 스레드가 오래 잠겨 다른 처리가 지연
        // 시청 로그는 유실보다 지연이 낫지 않으므로 5초로 설정
        configs.put(ProducerConfig.MAX_BLOCK_MS_CONFIG, 5_000L);

        // linger.ms: 배치가 꽉 차지 않아도 이 시간 후 강제 전송 (기본 0)
        // 0이면: 매 이벤트마다 즉시 전송 → 네트워크 요청 수 폭증
        // 20ms면: 20ms 동안 모인 이벤트를 한 번에 전송 → throughput 10배 향상
        // 실시간성이 덜 중요한 시청 로그에는 20ms 충분
        configs.put(ProducerConfig.LINGER_MS_CONFIG, 20L);

        // batch.size: 단일 배치의 최대 크기 (기본 16KB)
        // linger.ms와 함께 작동: 배치가 꽉 차거나 linger.ms가 지나면 전송
        // 시청 로그 이벤트 평균 200바이트 → 64KB면 ~320개 묶음 전송
        configs.put(ProducerConfig.BATCH_SIZE_CONFIG, 65_536); // 64KB

        // acks: 브로커 확인 수준 (0=확인없음, 1=리더만, all=모든 ISR)
        // acks=0: 최고 처리량, 브로커 장애 시 유실 가능 (지표성 로그에 적합)
        // acks=1: 리더 확인, 리더 장애 시 일부 유실 가능
        // acks=all: 완전 내구성, 처리량 30~50% 감소
        // 시청 로그는 일부 유실 허용 → acks=1, 결제 이벤트는 acks=all
        configs.put(ProducerConfig.ACKS_CONFIG, "1");

        // compression.type: 배치 압축 (none/gzip/snappy/lz4/zstd)
        // lz4: 압축비 2~3배, 압축 CPU 비용 낮음 → 처리량·스토리지 모두 개선
        configs.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4");

        // retries: 일시적 오류 재시도 횟수 (기본 2147483647)
        // MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION=1과 함께 사용해야 순서 보장
        configs.put(ProducerConfig.RETRIES_CONFIG, 3);
        configs.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 1);

        return new DefaultKafkaProducerFactory<>(configs);
    }
}

Kafka Consumer 설정 — 쓰로틀링 완전 해부

@Configuration
public class KafkaConsumerConfig {

    @Bean
    public ConsumerFactory<String, String> viewEventConsumerFactory() {
        Map<String, Object> configs = new HashMap<>();
        configs.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-cluster:9092");
        configs.put(ConsumerConfig.GROUP_ID_CONFIG, "view-analytics-processor");

        // max.poll.records: poll() 한 번에 최대로 가져오는 레코드 수 (기본 500)
        // 너무 크면: 처리 시간이 길어져 max.poll.interval.ms 초과 → rebalance 트리거
        // 너무 작으면: poll() 호출 빈도 증가, 브로커 부하
        // Flink 처리량 기준 1초에 10,000건 처리 가능 → 500건씩 poll이 적절
        configs.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 500);

        // max.poll.interval.ms: poll() 호출 간격의 최대 허용 시간 (기본 300초)
        // Consumer가 이 시간 안에 poll()을 하지 않으면 그룹에서 제외 → rebalance
        // 배치 처리 로직이 DB 조회를 포함한다면 30초도 부족할 수 있음
        // 시청 로그는 가벼운 집계이므로 30초로 설정
        configs.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 30_000);

        // fetch.min.bytes: 브로커가 응답하기 위한 최소 데이터 크기 (기본 1바이트)
        // 1바이트면: 데이터가 조금만 있어도 즉시 응답 → 불필요한 네트워크 왕복 증가
        // 64KB면: 적어도 64KB 모일 때까지 대기 후 응답 → 브로커 부하 감소
        // 낮은 트래픽에서도 fetch.max.wait.ms와 함께 배치 효율 향상
        configs.put(ConsumerConfig.FETCH_MIN_BYTES_CONFIG, 65_536); // 64KB

        // fetch.max.wait.ms: fetch.min.bytes가 안 채워져도 이 시간 후 강제 응답 (기본 500ms)
        // fetch.min.bytes=64KB이지만 트래픽이 적어 채워지지 않으면 500ms 후 응답
        // 시청 분석은 1초 이내 집계이므로 500ms가 적절
        configs.put(ConsumerConfig.FETCH_MAX_WAIT_MS_CONFIG, 500);

        // auto.offset.reset: 초기 오프셋 없을 때 동작 (earliest/latest)
        // latest: 컨슈머 시작 시 최신 메시지부터 (이전 누락 허용)
        // earliest: 처음부터 모두 처리 (중복 처리 위험 있으나 누락 없음)
        configs.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "latest");

        // enable.auto.commit: 오프셋 자동 커밋 여부
        // true면: 처리 전 커밋 가능 → 장애 시 유실
        // false면: 수동 커밋으로 정확히 한 번 처리 보장
        configs.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);

        return new DefaultKafkaConsumerFactory<>(configs);
    }
}

Consumer Group Rebalance 문제와 CooperativeStickyAssignor

Consumer Group에 새 인스턴스가 추가되거나 제거될 때 파티션 재배분이 일어납니다. 기본 RangeAssignorRoundRobinAssignor는 재배분 시 모든 Consumer가 동시에 파티션 소비를 중단(Stop-the-World)합니다. 파티션 1,000개, Consumer 100개 환경에서는 재배분 시 수십 초간 처리가 멈춥니다.

@Configuration
public class KafkaConsumerRebalanceConfig {

    @Bean
    public ConsumerFactory<String, String> resilientConsumerFactory() {
        Map<String, Object> configs = new HashMap<>();

        // CooperativeStickyAssignor: 점진적 재배분 (Incremental Rebalancing)
        // 기존 파티션 할당을 최대한 유지하면서 필요한 파티션만 이동
        // 재배분 시에도 대부분 Consumer는 계속 처리 가능
        configs.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
            List.of(CooperativeStickyAssignor.class));

        // session.timeout.ms: 브로커가 Consumer를 죽은 것으로 판단하는 시간 (기본 45초)
        // 너무 짧으면: GC pause로 인한 일시 응답 없음에도 제외 → 불필요한 rebalance
        // 너무 길면: 실제 Consumer 장애를 늦게 감지
        configs.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 45_000);

        // heartbeat.interval.ms: 하트비트 전송 간격 (session.timeout.ms의 1/3 권장)
        configs.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, 15_000);

        return new DefaultKafkaConsumerFactory<>(configs);
    }
}

Consumer Lag 모니터링 + Auto-scaling

Consumer Lag은 Kafka 파티션의 최신 오프셋과 Consumer가 소비한 오프셋의 차이입니다. Lag이 증가한다는 것은 Consumer가 Producer 속도를 따라가지 못한다는 신호입니다.

@Service
public class ConsumerLagMonitor {

    private final AdminClient kafkaAdminClient;
    private final K8sScalingService k8sScalingService;

    // Consumer Lag 수집 (30초마다)
    @Scheduled(fixedDelay = 30_000)
    public void checkAndAutoScale() {
        try {
            // 그룹별 오프셋 정보 조회
            Map<String, ListConsumerGroupOffsetsResult> groupOffsets =
                kafkaAdminClient.listConsumerGroupOffsets(
                    Map.of("view-analytics-processor",
                        new ListConsumerGroupOffsetsSpec()));

            long totalLag = calculateTotalLag(groupOffsets);

            // 메트릭 발행 (Prometheus)
            metricsRegistry.gauge("kafka.consumer.lag.total",
                Tags.of("group", "view-analytics-processor"), totalLag);

            // Auto-scaling 결정
            if (totalLag > 1_000_000) {
                // Lag 100만 건 초과 시 Consumer 인스턴스 2배 증가
                int currentReplicas = k8sScalingService.getReplicas("view-analytics-processor");
                int targetReplicas = Math.min(currentReplicas * 2, 50); // 최대 50개
                log.warn("Consumer lag {} exceeds threshold, scaling from {} to {}",
                    totalLag, currentReplicas, targetReplicas);
                k8sScalingService.scale("view-analytics-processor", targetReplicas);

            } else if (totalLag < 10_000 && getConsumerCount() > minReplicas) {
                // Lag 1만 건 미만으로 안정화되면 스케일 다운
                k8sScalingService.scaleDown("view-analytics-processor");
            }

        } catch (Exception e) {
            log.error("Consumer lag monitoring failed", e);
        }
    }
}

Broker 쓰로틀링 설정

Kafka Broker의 쿼터 설정은 특정 클라이언트가 클러스터 전체를 독점하지 못하도록 제어합니다.

# Producer 쓰로틀링: 클라이언트당 초당 50MB로 제한
# 쓰로틀되면 ProduceRequest에 throttle_time_ms 필드로 대기 시간 전달
kafka-configs.sh --bootstrap-server kafka:9092 \
  --alter --add-config 'producer_byte_rate=52428800' \
  --entity-type clients --entity-default

# Consumer 쓰로틀링: 클라이언트당 초당 100MB로 제한
kafka-configs.sh --bootstrap-server kafka:9092 \
  --alter --add-config 'consumer_byte_rate=104857600' \
  --entity-type clients --entity-default

# 특정 클라이언트 우선 처리 (예: 실시간 알림 Consumer)
kafka-configs.sh --bootstrap-server kafka:9092 \
  --alter --add-config 'consumer_byte_rate=524288000' \
  --entity-type clients --entity-name "realtime-notification-consumer"
# server.properties: I/O 스레드 수 조정
# num.io.threads: 디스크 I/O 처리 스레드 수 (기본 8)
# 디스크가 NVMe SSD인 경우 코어 수 × 2까지 늘려도 효과적
# 과도한 증가는 Context switching 오버헤드로 역효과
num.io.threads=16

# num.network.threads: 네트워크 처리 스레드 수 (기본 3)
# 고트래픽 환경에서 3이면 네트워크 처리가 병목
num.network.threads=8

# quota.producer.default: 기본 Producer 쓰로틀 (bytes/sec)
quota.producer.default=52428800

# quota.consumer.default: 기본 Consumer 쓰로틀 (bytes/sec)
quota.consumer.default=104857600

7. 추천 엔진 — Collaborative Filtering + 실시간 시그널

추천 엔진을 음반 가게 직원으로 비유하세요. 오랜 단골(배치 CF 모델)은 고객의 취향을 잘 알지만, 오늘 새로 들어온 앨범(실시간 트렌딩)은 모릅니다. 하이브리드 추천은 “당신이 좋아하는 장르의 신보가 오늘 나왔어요”를 말해주는 것입니다.

@Service
public class HybridRecommendationService {

    private final BatchRecommendationStore batchStore;   // 매일 갱신되는 CF 결과
    private final RealTimeSignalStore realtimeStore;     // Kafka에서 수집되는 실시간 시그널

    // 최종 추천 결과 생성: 배치 추천 + 실시간 보정
    public List<VideoRecommendation> getRecommendations(String userId, int limit) {

        // 1단계: 배치 CF 기반 후보군 (100개)
        List<String> batchCandidates = batchStore.getCandidates(userId, 100);

        // 2단계: 실시간 시그널 기반 보정 점수
        Map<String, Double> realtimeBoosts = realtimeStore.getRealtimeBoosts(
            userId,
            Duration.ofHours(1)  // 최근 1시간 행동 반영
        );

        // 3단계: 점수 합산 및 재랭킹
        return batchCandidates.stream()
            .map(videoId -> {
                double batchScore = batchStore.getScore(userId, videoId);
                double realtimeBoost = realtimeBoosts.getOrDefault(videoId, 0.0);
                // 가중합: 배치 70% + 실시간 30%
                double finalScore = batchScore * 0.7 + realtimeBoost * 0.3;
                return new VideoRecommendation(videoId, finalScore);
            })
            .sorted(Comparator.comparingDouble(VideoRecommendation::score).reversed())
            .limit(limit)
            .collect(Collectors.toList());
    }
}

극한 시나리오 3가지

시나리오 1: 슈퍼볼 동시 중계 — 1억 명이 같은 순간 시청

상황: 슈퍼볼 하프타임 쇼 시작과 동시에 1억 명이 버퍼링 없이 시청을 시작해야 합니다. 이 순간 CDN으로 흐르는 데이터는 1억 × 4Mbps = 400Tbps입니다.

문제: 하프타임 쇼는 예측 가능한 이벤트입니다. 하지만 1억 명이 동시에 재생 버튼을 누르면 CDN 엣지 노드의 세그먼트 캐시 워밍이 되기 전에 오리진으로 대규모 캐시 미스가 발생합니다.

graph LR
  P["사전 프리워밍"] --> E["CDN 엣지 1000+"]
  E --> V["1억 시청자"]
  V --> AB["ABR 화질 자동전환"]
  AB --> B["버퍼 안정화"]

대응 전략:

  • CDN 프리워밍: 이벤트 시작 30분 전부터 예상 인기 세그먼트를 모든 CDN 엣지에 능동적으로 배포합니다. 이벤트 시작과 동시에 세그먼트가 캐시에 이미 존재합니다.
  • Admission Control: 동시 접속 요청이 CDN 엣지 용량의 90%를 초과하면 신규 접속자에게 대기 페이지를 보여줍니다. 무한 동시 접속 허용보다 짧은 대기가 사용자 경험이 낫습니다.
  • ABR 초기 화질 강제 다운그레이드: 피크 접속 시간대에 모든 클라이언트의 초기 화질을 720p로 강제합니다. 버퍼가 안정화되면 자동으로 화질을 올립니다.
  • Origin Shield: 오리진 바로 앞에 Origin Shield 레이어를 두어 캐시 미스가 오리진으로 직접 도달하지 않게 합니다. 오리진 부하를 99% 감소시킵니다.

숫자: Akamai 기준 CDN 엣지 노드 수는 전 세계 4,000개 이상. 각 노드가 25만 명씩 처리하면 노드당 1Tbps. 현재 상용 서버 기준 노드당 100Gbps NIC 기준으로는 수십 대의 서버가 필요합니다.


시나리오 2: 트위치 유명 스트리머 채팅 폭주 — 채널당 100만 채팅 메시지/분

상황: 스트리머가 서프라이즈 발표를 하자 채팅창에 초당 17,000개의 메시지가 쏟아집니다. 이 채널에 연결된 채팅 서버 1대가 처리해야 할 Redis Pub/Sub 팬아웃은 초당 17,000 메시지 × 50만 수신자 = 85억 번의 전송입니다.

graph LR
  C["채팅 입력"] --> R["Rate Limiter"]
  R --> S["샤딩 라우터"]
  S --> W1["채팅서버1"]
  S --> W2["채팅서버2"]
  W1 --> U["시청자 팬아웃"]

문제: 단일 채팅 서버가 50만 WebSocket에 동시 쓰기를 시도하면 소켓 버퍼 부족과 CPU 포화로 서버가 다운됩니다.

대응 전략:

  • 채팅 샤딩: 50만 시청자를 1,000개 채팅 서버에 분산. 서버당 500명, 초당 17,000 × 500/500,000 = 17개 메시지 전송. 완전히 관리 가능한 수준으로 낮아집니다.
  • 메시지 드롭 + 집계 표시: 초당 17,000개를 전부 보여주면 채팅이 읽을 수 없는 속도로 흐릅니다. 실제 전달은 초당 50개로 throttle하고 “분당 100만 채팅”을 별도 카운터로 표시합니다.
  • 구독 트리: 채팅 서버 → 중간 팬아웃 서버 → 클라이언트의 2계층 구조로 브로드캐스트 부하를 분산합니다.
  • Redis Pub/Sub 대신 Kafka로 전환: 채널당 트래픽이 초당 1만 건을 초과하면 Redis Pub/Sub보다 Kafka 파티션 기반 처리가 더 안정적입니다. Kafka는 메시지를 디스크에 저장하므로 일시적인 Consumer 장애를 lag으로 흡수할 수 있습니다.

시나리오 3: 넷플릭스 신작 릴리즈 — 0시 동시 접속 폭탄

상황: 인기 드라마 시즌 피날레가 0시에 공개됩니다. 전 세계 팬 5,000만 명이 0시 정각에 재생을 시도합니다.

문제: 공개 직전까지 이 에피소드는 CDN에 없습니다. 공개 순간 5,000만 개의 캐시 미스가 오리진 스토리지로 향하면, 오리진은 5초 만에 과부하로 다운됩니다.

graph LR
  T["T-30분 프리워밍"] --> E["글로벌 CDN"]
  E --> AC["Admission Control"]
  AC --> P["Priority Queue"]
  P --> V["시청자"]

대응 전략:

  • 0시 전 프리워밍: 공개 전 30분 동안 CDN 엣지에 에피소드 전체를 배포합니다. 공개 시점에 모든 CDN이 완전히 채워진 상태입니다. 프리워밍은 내부 봇이 CDN을 직접 히트하는 방식으로 수행합니다.
  • Soft Launch + Gradual Rollout: 0시에 전체 공개 대신, 가장 먼저 요청한 10만 명에게만 공개 후 매 분마다 확대합니다. CDN 워밍이 완료되는 속도와 공개 속도를 맞춥니다.
  • 오리진 Rate Limit: 오리진 스토리지 앞에 초당 최대 1,000건의 캐시 미스만 허용하는 rate limiter를 배치합니다. 나머지 요청은 CDN에서 503 대신 Retry-After 헤더로 재시도를 유도합니다.
  • Priority Queue for Early Access: 유료 구독자에게 먼저 접근권을 부여하고, 일반 구독자는 Priority Queue에서 대기합니다. Redis ZADD로 구독 등급별 우선순위를 부여하고 ZPOPMIN으로 순서대로 처리합니다.

실무 실수 Top 5

실수 1: RTMP로 시청자에게 직접 스트리밍

가장 흔한 초기 설계 실수입니다. “RTMP가 지연시간이 짧다”는 이유로 시청자에게 직접 RTMP 스트림을 제공하면, 서버가 1억 개의 TCP 연결을 장기 유지해야 합니다. CDN 캐시 불가, 방화벽 차단(포트 1935), 모든 트래픽이 오리진 직접 히트. RTMP는 스트리머에서 인제스트 서버까지의 단거리 구간에만 사용하고, 시청자 배포는 반드시 HLS/DASH로 변환해야 합니다.

실수 2: 세그먼트 크기 무시

HLS/DASH 세그먼트 크기를 너무 작게(1~2초) 설정하면 세그먼트 파일 수가 폭발적으로 늘어납니다. 2시간 영상을 1초 세그먼트로 나누면 7,200개 파일이 생기고, 이를 CDN에서 관리하는 메타데이터 오버헤드가 재생보다 비쌉니다. 반대로 너무 크면(30초) ABR 전환 시 긴 시간 동안 낮은 화질이 유지됩니다. 6초가 산업 표준입니다.

실수 3: Redis Pub/Sub에만 의존한 채팅 팬아웃

Redis Pub/Sub은 메시지를 디스크에 저장하지 않습니다. Consumer가 잠깐 연결이 끊기면 그동안의 메시지를 영구 유실합니다. 채팅은 메시지 유실이 직접적인 사용자 경험 저하로 이어지므로, Redis Pub/Sub을 실시간 팬아웃에 사용하되 메시지 영속성은 별도 저장소(MySQL, DynamoDB)에 보관해야 합니다. 또는 트래픽이 일정 수준 이상이면 Kafka로 전환합니다.

실수 4: 트랜스코딩 완료 전 CDN 프리워밍 미설정

고화질 트랜스코딩이 완료되는 시점에 CDN에 자동으로 배포하지 않으면, 첫 번째 시청자가 오리진에서 세그먼트를 직접 가져오는 “Cold Start” 문제가 발생합니다. 트랜스코딩 파이프라인이 완료 이벤트를 발행하고, 이를 구독한 CDN 워밍 서비스가 즉시 프리워밍을 시작하는 이벤트 드리븐 구조가 필요합니다.

실수 5: 시청 이벤트를 동기적으로 DB에 직접 저장

1억 명이 시청 중일 때 초당 수백만 건의 “시청 5초 경과” 하트비트가 발생합니다. 이를 MySQL에 직접 INSERT하면 DB가 즉시 다운됩니다. 시청 이벤트는 반드시 Kafka로 비동기 버퍼링하고, Flink나 Spark Streaming으로 집계한 뒤 TSDB(InfluxDB, TimescaleDB)나 데이터 웨어하우스(BigQuery, Redshift)에 배치로 저장해야 합니다.


Phase 1 → 4 진화 로드맵

Phase 1 — MVP (월 $5,000)

대상: 동시 시청 1만 명, 일 업로드 100건

컴포넌트 기술 비용/월
업로드 서버 EC2 c5.xlarge × 2 $300
트랜스코딩 워커 EC2 c5.2xlarge × 4 $1,200
오리진 스토리지 S3 Standard (10TB) $230
CDN Cloudflare Stream $1,000
DB RDS MySQL t3.medium $60
Redis ElastiCache t3.medium $50
채팅 서버 EC2 t3.xlarge × 2 $240
Kafka MSK kafka.t3.small × 3 $200
합계   $3,280

한계: 단일 리전, 단일 CDN, 트랜스코딩 큐 백압력 없음

Phase 2 — 스케일 업 (월 $50,000)

대상: 동시 시청 10만 명, 일 업로드 1,000건

  • 트랜스코딩 GPU 워커 도입 (nvidia GPU EC2): 인코딩 속도 10배 향상
  • 멀티 CDN 전환 (Akamai + Cloudflare): CDN 장애 격리
  • Redis Cluster 전환: 단일 Redis 노드 한계 극복
  • Kafka 파티션 증가: 병렬 소비자 확장
  • 읽기 전용 DB 레플리카 추가: 시청 메타데이터 조회 분산
  • CDN 원가: 10Gbps 기준 월 $15,000 수준

Phase 3 — 글로벌 확장 (월 $200,000)

대상: 동시 시청 100만 명, 다국가 서비스

  • 멀티 리전 배포 (AP-NE, EU-WEST, US-EAST): 지역별 오리진 분산
  • Origin Shield 레이어 추가: 오리진 캐시 히트율 99%+
  • Edge Lambda/CloudFront Functions: 엣지에서 매니페스트 동적 생성
  • Kafka Cross-Region Replication (MirrorMaker 2): 리전 간 이벤트 동기화
  • A/B Testing 인프라: 추천 알고리즘 실험
  • 전용 트랜스코딩 팜: 자체 FFmpeg 클러스터

Phase 4 — 넷플릭스/유튜브급 (월 $2,000,000+)

대상: 동시 시청 1억 명, 일 업로드 50만 건

  • 자체 CDN (Open Connect 방식): 상위 ISP에 OCA 장비 배치, 트래픽 비용 80% 절감
  • 자체 코덱 최적화: VP9, AV1 도입으로 전송 용량 30~40% 절감
  • 분산 트랜스코딩 팜: 전용 하드웨어 가속 인코더(ASIC) 운영
  • 추천 ML 플랫폼: 수백 개 피처의 딥러닝 모델 실시간 서빙
  • 전 세계 ANYCAST: 사용자 가장 가까운 노드로 자동 라우팅
  • 이 단계에서 CDN 비용이 전체 인프라 비용의 70%를 차지

핵심 메트릭

스트리밍 플랫폼의 건강도를 판단하는 지표들입니다. 면접에서 “어떤 메트릭을 모니터링할 것인가?”라는 질문이 자주 나옵니다.

시청 품질 메트릭 (QoE: Quality of Experience)

메트릭 정의 목표값 경보 임계값
TTFF (Time to First Frame) 재생 버튼 → 첫 프레임 표시까지 시간 < 3초 > 5초
Buffering Ratio 전체 재생 시간 중 버퍼링 비율 < 0.5% > 2%
ABR Bitrate Average 시청 중 평균 비트레이트 > 2.5Mbps < 1Mbps
Error Rate 재생 오류 (4xx/5xx) 비율 < 0.1% > 1%
CDN Cache Hit Rate CDN에서 직접 응답한 비율 > 95% < 90%

인프라 메트릭

메트릭 정의 목표값
Transcoding Queue Depth 대기 중인 트랜스코딩 작업 수 < 1,000
Transcoding Latency P95 업로드 → 시청 가능까지 P95 < 10분
Kafka Consumer Lag 분석 파이프라인 처리 지연 < 10만 건
CDN Bandwidth Utilization CDN 엣지 대역폭 사용률 < 70%
Origin Requests/sec CDN 캐시 미스로 오리진 도달 수 < 전체의 5%

채팅 메트릭

메트릭 정의 목표값
Message Delivery Latency P99 메시지 전송 → 수신 P99 < 500ms
WebSocket Connection Success Rate 연결 성공률 > 99.9%
Chat Error Rate 메시지 전송 실패율 < 0.01%

실제 장애 사례 및 사후 분석

사례 1: 트랜스코딩 워커 OOM (Out of Memory)

현상: 특정 시간대에 업로드된 영상의 30%가 트랜스코딩 실패

원인: 모바일에서 촬영한 세로 4K 영상의 경우 메모리 요구량이 가로 4K의 2배. 워커 메모리 설정이 가로 영상 기준으로만 산정됐습니다.

해결: 트랜스코딩 전 영상 메타데이터 분석 후 동적으로 워커 메모리 할당. 4K 세로 영상은 고메모리 워커 풀로 라우팅.

재발 방지: 해상도·코덱·HDR 여부에 따른 워커 분류 라우팅 테이블 도입. 워커 OOM 발생 시 해당 작업을 죽이지 않고 고메모리 큐에 재투입.

사례 2: Redis Pub/Sub 구독 유실로 채팅 침묵

현상: 특정 채널의 채팅이 갑자기 동작을 멈추는 현상. 메시지는 Redis에 도달하지만 시청자 화면에는 아무것도 표시되지 않음.

원인: Redis Sentinel 페일오버 이벤트 발생 시 Redis 클라이언트가 새 마스터에 자동 재연결했지만, Pub/Sub 구독은 자동으로 재구독되지 않음.

해결: Redis 재연결 이벤트 후 모든 활성 채널 구독을 자동으로 재등록하는 재구독 로직 추가. 30초마다 현재 구독 상태를 검증하는 헬스체크 루프 도입.

사례 3: HyperLogLog 시청자 수 급락

현상: 라이브 스트림 시청자 수가 실제보다 30% 낮게 표시

원인: Redis Cluster 리샤딩(rebalancing) 중 일부 HyperLogLog 키가 새 슬롯으로 이동하면서 임시로 접근 불가 상태. 이 기간에 PFADD 실패로 시청자가 누락.

해결: PFADD 실패 시 실패 목록을 로컬 메모리에 큐잉하고 재시도. Redis 리샤딩 전 HyperLogLog 키를 별도 Redis 인스턴스로 임시 이관하는 절차 수립.


확장 포인트

360도/VR 스트리밍 지원

일반 스트리밍과 달리 VR 콘텐츠는 현재 시청 방향에 따라 고화질 세그먼트를 선택적으로 전송하는 타일 기반 스트리밍(Tile-based Streaming)이 필요합니다. 360도 구면을 여러 타일로 나누고, 현재 시청 방향의 타일만 4K로, 나머지는 저화질로 전송합니다. 전체 대역폭을 일반 스트리밍 수준으로 유지하면서 시청 방향은 4K 품질로 제공할 수 있습니다.

AI 기반 실시간 자막 생성

트랜스코딩 파이프라인에 ASR(Automatic Speech Recognition) 단계를 추가합니다. Whisper나 Google Speech-to-Text API로 오디오 트랙에서 텍스트를 추출하고, 타임스탬프와 함께 WebVTT 형식으로 저장합니다. 실시간 스트림의 경우 오디오 청크를 30초 단위로 ASR API에 전송하고, 결과를 HLS/DASH 자막 트랙으로 동적 삽입합니다.

다중 오디오 트랙 지원

넷플릭스는 같은 영상에 20개 언어의 오디오 트랙을 제공합니다. HLS/DASH 매니페스트에 오디오 트랙 목록을 포함하고, 클라이언트가 재생 중 오디오 트랙을 전환할 수 있습니다. 각 언어 오디오 트랙을 비디오 세그먼트와 분리해 저장하면, 새로운 언어 더빙을 추가할 때 비디오 재인코딩 없이 오디오만 추가됩니다.

클립 생성 및 하이라이트 자동 추출

라이브 스트리밍에서 시청자가 “클립” 버튼을 누르면 최근 30초 세그먼트를 즉시 클리핑합니다. 인기 구간(채팅 폭증, 좋아요 급증)을 자동으로 하이라이트로 추출하는 ML 파이프라인을 Flink 위에 구축합니다. Kafka에서 실시간으로 감정 지수가 치솟는 타임스탬프를 기록하고, 스트림 종료 후 해당 구간을 자동으로 클립합니다.


면접 포인트 — Q&A 5개

Q1. HLS와 DASH의 차이는 무엇이고, 어떤 상황에서 어떤 것을 선택하나요? **A**: HLS(HTTP Live Streaming)는 Apple이 개발한 프로토콜로, `.m3u8` 매니페스트와 `.ts` 세그먼트 파일을 사용합니다. Safari와 iOS는 DASH를 지원하지 않으므로 iOS 지원이 필수라면 HLS가 필요합니다. DASH(Dynamic Adaptive Streaming over HTTP)는 ISO 표준으로, 코덱에 종속되지 않아 VP9, AV1 등 최신 코덱을 자유롭게 사용할 수 있습니다. 브라우저와 Android를 중심으로 하는 플랫폼에서는 DASH가 더 유연합니다. 실제 대형 서비스(넷플릭스, 유튜브)는 두 가지를 모두 지원합니다. 트랜스코딩 파이프라인에서 동일한 세그먼트를 DASH와 HLS 두 형식의 매니페스트로 각각 생성하거나, fMP4(fragmented MP4)를 사용해 HLS와 DASH 양쪽에서 동일한 세그먼트 파일을 공유합니다(CMAF 표준). 이렇게 하면 CDN 스토리지 중복을 없애면서 두 프로토콜을 동시에 지원할 수 있습니다. **핵심 포인트**: iOS/Safari가 없으면 DASH만으로 충분하지만, 실제 서비스에서 iOS는 무시할 수 없으므로 대부분 DASH + HLS 이중 지원을 선택합니다.
Q2. Redis HyperLogLog를 시청자 수 카운팅에 사용할 때 0.81% 오차가 허용 가능한 이유는 무엇인가요? **A**: 스트리밍 플랫폼에서 시청자 수는 정확한 값보다 규모를 파악하는 용도로 사용됩니다. "12,345,678명"을 정확히 세는 것보다 "약 1,200만 명"으로 표시하는 것이 서비스 요구사항을 충족합니다. 메모리 관점에서의 차이가 결정적입니다. 정확한 고유 카운팅을 위해 Set을 쓰면 사용자 ID(8바이트) × 1,000만 명 = 80MB가 한 채널에 필요합니다. 동시 라이브 채널이 10,000개라면 800GB의 Redis 메모리가 시청자 수 카운팅에만 쓰입니다. HyperLogLog는 채널 수에 무관하게 채널당 고정 12KB이므로 10,000채널 = 120MB입니다. 오차 0.81%가 문제가 되는 경우는 광고 과금 기준으로 쓰일 때입니다. "광고 노출 10,000,000회"가 실제로는 9,919,000회일 수 있습니다. 이런 경우에는 HyperLogLog 대신 정확한 카운팅(Kafka 이벤트 집계 → DB)이 필요합니다. **설계 시 용도에 따라 정확도와 메모리를 트레이드오프해야 합니다.**
Q3. Kafka Consumer Rebalance가 서비스에 미치는 영향과 CooperativeStickyAssignor가 어떻게 해결하는지 설명해주세요. **A**: Kafka Consumer Group에 인스턴스가 추가되거나 제거될 때 파티션 재배분(Rebalance)이 발생합니다. 기본 `RangeAssignor` 방식은 **Eager Rebalance**를 사용하는데, 재배분 시작과 함께 모든 Consumer가 파티션 소비를 **즉시 중단**하고, 새 할당이 완료된 후에야 재개합니다. 이를 Stop-the-World Rebalance라고 합니다. Kafka 파티션이 1,000개이고 Consumer가 100개인 환경에서 오토스케일링으로 인스턴스가 하나 추가되면: 1. 모든 100개 Consumer가 소비 중단 2. 101개로 재배분 (각 Consumer 9~10개 파티션) 3. 모든 Consumer 재개 이 과정에서 수십 초간 메시지 처리가 중단됩니다. 초당 100만 건의 시청 로그가 쌓이는 환경에서 30초 중단은 3,000만 건의 Lag을 발생시킵니다. `CooperativeStickyAssignor`는 **Incremental Rebalance**를 구현합니다. 재배분 시 이동이 필요한 파티션만 선택적으로 이관합니다. 기존 Consumer는 이동이 필요 없는 파티션은 계속 소비하고, 이관 대상 파티션만 잠시 중단 후 새 Consumer에게 이관합니다. 재배분 중에도 대부분 Consumer는 계속 동작합니다. 서비스 중단 없는 롤링 배포, 오토스케일링에 필수적입니다.
Q4. 트랜스코딩 파이프라인에서 부분 실패를 어떻게 처리하나요? **A**: DAG 기반 병렬 트랜스코딩에서 부분 실패는 두 가지 시나리오가 있습니다. **시나리오 A — 특정 해상도만 실패**: 1080p 인코딩 중 워커가 OOM으로 다운됐습니다. 360p, 720p는 이미 완료 상태입니다. 해결: 1080p 작업만 상태를 `FAILED`로 표시하고 Dead Letter Queue(DLQ)에 넣습니다. 스케줄러가 주기적으로 DLQ를 폴링해 고메모리 워커 풀에 재투입합니다. 시청자는 720p까지 즉시 시청 가능합니다. **시나리오 B — 영상 자체가 손상**: 업로드 중 네트워크 중단으로 원본 파일이 불완전합니다. 해결: 트랜스코딩 전 원본 파일의 CRC 체크섬을 검증합니다. 실패 시 업로더에게 재업로드를 요청하는 이벤트를 발행합니다. **멱등성 보장**: 트랜스코딩 작업은 동일 job ID로 재실행해도 동일 결과가 나와야 합니다. 출력 경로에 videoId + resolution + version을 포함시켜, 재실행 시 이전 출력을 덮어쓰되 중간 상태 파일이 남지 않도록 합니다. 완성된 경우에만 CDN 오리진에 업로드하고 메타데이터를 갱신합니다. **재시도 정책**: 즉시 재시도 1회 → 1분 후 재시도 → 10분 후 재시도 → DLQ로 이동. 지수 백오프와 지터를 적용해 일시적 리소스 부족 시 재시도 폭풍을 방지합니다.
Q5. 1억 명 동시 시청을 위한 채팅 서버 아키텍처를 설명해주세요. **A**: 채팅은 시청 스트리밍과 별도 경로로 처리합니다. 채팅의 병목은 대규모 채널에서의 **팬아웃(Fan-out)** 문제입니다. 50만 명이 시청 중인 채널에서 1개 메시지를 보내면 50만 개의 WebSocket에 전달해야 합니다. **1단계 — 채팅 서버 샤딩**: 채널 ID를 해시해 특정 채팅 서버 그룹에 할당합니다. 인기 채널 시청자는 여러 채팅 서버에 분산 연결되고, 각 채팅 서버는 자신에게 연결된 수천 명의 시청자에게만 팬아웃합니다. **2단계 — Redis Pub/Sub 브로드캐스트**: 채팅 서버 A에 연결된 사용자가 메시지를 보내면, A는 Redis `chat:{channelId}` 채널에 게시합니다. 같은 채널의 다른 채팅 서버(B, C, D)는 이 채널을 구독 중이므로 즉시 수신하고 자신의 WebSocket 연결에 팬아웃합니다. **3단계 — 유명 채널 최적화**: 50만 시청자 채널은 채팅 서버가 수백 개 필요합니다. Redis Pub/Sub의 메시지 양이 너무 많아지면 Kafka로 전환합니다. Kafka 파티션을 채팅 서버 수만큼 생성하고, 각 채팅 서버가 자신의 파티션만 소비합니다. **Rate Limiting**: 채널당 초당 메시지 수를 제한합니다(예: 인기 채널 초당 5,000건). 초과 시 메시지를 드롭하고 UI에 "채팅 속도가 너무 빠릅니다" 알림을 표시합니다. 모든 메시지를 전달하는 것보다 안정적인 서비스가 중요합니다.

이 설계의 한계와 대안

시니어 엔지니어와 주니어 엔지니어의 차이는 “이렇게 만들면 됩니다”에서 끝나느냐, “이 설계가 어디서 무너지는가”까지 말할 수 있느냐입니다. 모든 설계에는 전제 조건이 있고, 전제가 무너지는 순간을 미리 알고 있어야 합니다.

CDN 장애 시 — “CDN이 살아있다”는 전제가 무너질 때

멀티 CDN을 쓰더라도 특정 지역에서 두 CDN이 동시에 장애를 내는 시나리오는 실제로 발생합니다. Cloudflare 2021년 6월 장애는 전 세계 19개 도시에서 동시에 인터넷 트래픽이 중단됐고, 여러 CDN 사업자가 동시에 영향을 받았습니다.

graph LR
  U["사용자"] --> DNS["DNS 지연시간 라우팅"]
  DNS --> CDN1["Akamai"]
  DNS --> CDN2["Cloudflare"]
  CDN1 -- "장애" --> OS["Origin Shield"]
  CDN2 -- "장애" --> OS
  OS --> ORI["오리진 스토리지"]

단계별 폴백 전략:

  1. DNS 기반 전환: 에러율 임계값(1%) 초과 시 해당 CDN의 DNS 가중치를 0으로 낮춥니다. TTL을 30초로 짧게 설정해 전환 속도를 높입니다. 단, DNS TTL이 짧으면 DNS 쿼리 빈도가 올라가 DNS 서버 부하가 증가하는 부작용이 있습니다.

  2. Origin Shield 패턴: 오리진 앞에 Origin Shield(중간 캐시 계층)를 두면, CDN 캐시 미스가 오리진으로 직접 도달하지 않습니다. Akamai가 장애를 내도 Cloudflare의 캐시 미스는 Origin Shield를 히트하고, Origin Shield가 오리진 부하를 흡수합니다. 오리진 직접 요청을 99% 이상 차단할 수 있습니다.

  3. 클라이언트 측 폴백: 플레이어가 CDN 에러를 감지하면 자동으로 다른 CDN URL로 재시도합니다. HLS 매니페스트에 CDN별 세그먼트 URL을 순서대로 나열하고, 플레이어가 첫 번째 URL이 실패하면 두 번째를 시도하는 방식입니다.

남은 한계: Origin Shield 자체도 단일 장애점이 될 수 있습니다. 넷플릭스 수준에서는 멀티 리전 Origin Shield를 운영합니다. 스타트업 단계에서는 이 복잡성은 오버엔지니어링입니다.


트랜스코딩 큐 폭주 — “큐가 소화할 수 있다”는 전제가 무너질 때

인기 유튜버가 동시에 영상을 업로드하거나, 라이브 이벤트 종료 후 다수 스트리머가 VOD를 동시에 저장하면 트랜스코딩 큐가 폭증합니다. 큐 깊이가 늘어날수록 업로드 후 시청 가능 시간이 늘어납니다. “10분 안에 시청 가능”이라고 약속한 SLA가 깨집니다.

graph LR
  UP["신규 업로드"] --> PQ["우선순위 큐"]
  PQ --> FL["Fast-Lane\n인기 크리에이터"]
  PQ --> SL["Standard-Lane\n일반 업로드"]
  FL --> W1["고성능 워커"]
  SL --> W2["일반 워커"]

우선순위 큐 + 인기 콘텐츠 Fast-Lane:

  • 구독자 수, 평균 조회수, 채널 등급을 기반으로 업로드에 우선순위를 부여합니다. Redis Sorted Set으로 점수 기반 우선순위 큐를 구현하고, ZPOPMAX로 항상 가장 중요한 작업을 먼저 처리합니다.
  • 신규 업로드 Throttling: 큐 깊이가 임계값(예: 5,000건)을 초과하면 신규 업로드를 수락하되 즉시 처리를 보류합니다. 업로더에게 “영상이 업로드됐습니다. 현재 트래픽이 많아 처리에 30분 정도 걸릴 수 있습니다”라는 메시지를 보여줍니다.
  • 360p 우선 인코딩: 큐가 폭주할 때는 모든 해상도를 포기하고 360p 단일 해상도만 먼저 처리합니다. 시청 가능 시간을 최소화하고 나머지 해상도는 큐가 안정화된 후 처리합니다.

남은 한계: 우선순위 큐는 저우선순위 작업의 기아(Starvation) 문제를 야기합니다. 신규 크리에이터의 첫 영상이 영원히 처리되지 않는 최악의 시나리오를 막으려면 대기 시간 기반 우선순위 상향(Aging) 로직이 필요합니다.


WebSocket 서버 장애 시 채팅 유실 — Redis Pub/Sub의 구조적 한계

Redis Pub/Sub은 at-most-once 전달 보장입니다. 메시지를 구독자에게 한 번만 전달하려고 시도하며, 구독자가 없거나 연결이 끊겼으면 메시지는 영구 유실됩니다. 디스크에 저장하지 않으므로 재전송이 불가능합니다.

WebSocket 서버가 재시작되는 순간 Redis 구독이 끊기고, 재구독 전까지 모든 채팅 메시지가 그 서버의 시청자에게 전달되지 않습니다.

방식 전달 보장 메시지 저장 재전송 가능 처리량
Redis Pub/Sub at-most-once 없음 불가 매우 높음
Kafka Streams at-least-once 디스크 가능 높음
Kafka + 수동 커밋 exactly-once 디스크 가능 중간

Kafka Streams 대안:

트래픽이 초당 1만 건을 초과하거나 채팅 유실이 비즈니스적으로 치명적인 경우(유료 이벤트, 커머스 라이브) Kafka Streams로 전환합니다. 채널 ID를 파티션 키로 사용해 같은 채널의 메시지가 항상 같은 파티션으로 라우팅됩니다. Consumer(채팅 서버)가 장애 후 재시작하면 마지막 커밋 오프셋 이후 메시지를 재처리할 수 있습니다.

단, Kafka는 Redis Pub/Sub보다 지연시간이 높습니다(Redis: 1ms 이하 vs Kafka: 5~20ms). 500ms SLA가 있는 채팅에서는 여전히 허용 범위이지만, 트레이드오프를 인지해야 합니다.


ABR 알고리즘 오판 — “플레이어가 올바른 화질을 선택한다”는 전제가 무너질 때

ABR 알고리즘은 다운로드 처리량을 기반으로 다음 세그먼트 화질을 선택합니다. 이 접근법에는 두 가지 구조적 오판 시나리오가 있습니다.

시나리오 1 — 네트워크는 좋은데 저화질 고착:

WiFi 신호가 강하지만 공유기 혼잡으로 순간 처리량이 낮은 경우, ABR이 이를 “나쁜 네트워크”로 판단해 360p로 다운그레이드합니다. 이후 처리량이 회복돼도 버퍼가 충분히 차기 전까지는 업그레이드를 지연합니다. 실제 네트워크 품질보다 낮은 화질이 수분간 지속됩니다.

시나리오 2 — 네트워크가 나쁜데 고화질 선택 후 버퍼링:

ABR이 이전 세그먼트 다운로드 속도를 기반으로 낙관적으로 1080p를 선택했지만, 다음 세그먼트 다운로드 중 네트워크가 갑자기 나빠지면 버퍼가 고갈되어 재생이 멈춥니다. “버퍼링 없는 시청”이 목표인데, 화질 욕심이 오히려 버퍼링을 유발합니다.

개선 전략:

  • 보수적 업그레이드, 적극적 다운그레이드: 처리량이 상위 레이어 비트레이트의 1.5배 이상 안정적으로 유지될 때만 업그레이드합니다. 다운그레이드는 버퍼 잔량이 10초 이하로 떨어지는 즉시 실행합니다.
  • 버퍼 기반 ABR: Netflix의 BOLA(Buffer Occupancy based Lyapunov Algorithm)처럼 처리량 대신 버퍼 잔량을 1차 신호로 사용합니다. 버퍼가 충분하면 화질을 올리고, 버퍼가 고갈 위험에 처하면 즉시 낮춥니다.

DRM 지연 — “라이선스 서버가 항상 응답한다”는 전제가 무너질 때

유료 콘텐츠나 저작권 보호 콘텐츠는 DRM(Digital Rights Management) 라이선스 서버에서 복호화 키를 발급받아야 재생이 시작됩니다. 라이선스 서버 장애 = 재생 불가입니다.

graph LR
  P["플레이어"] --> LS["라이선스 서버"]
  LS -- "장애" --> X["재생 불가"]
  P --> CL["캐시된 라이선스"]
  CL -- "유효기간 내" --> OK["즉시 재생"]

캐시된 라이선스 전략:

Widevine, FairPlay는 라이선스에 유효기간(License Duration)을 설정할 수 있습니다. 예를 들어 구독 중인 사용자의 라이선스를 24시간 유효하게 설정하면, 라이선스 서버가 24시간 동안 다운돼도 이미 캐시된 라이선스로 재생이 가능합니다.

단, 라이선스 캐시는 보안 취약점이 됩니다. 구독을 취소한 사용자의 라이선스가 만료 전에 무효화되지 않으면 취소 후에도 재생이 가능합니다. 유효기간을 짧게(1~4시간) 설정할수록 보안이 강화되지만 라이선스 서버 장애 내성이 낮아집니다. 서비스 성격에 따라 트레이드오프를 결정해야 합니다.


동시성과 락 — 분산 환경에서 “동시에” 일어나면

분산 시스템의 버그 중 상당수는 “이 두 가지가 동시에 일어날 리 없다”는 암묵적 가정에서 비롯됩니다. 서버가 1대일 때는 동시에 일어날 리 없던 일들이 서버 100대 환경에서는 초당 수십 번씩 발생합니다.

CDN 캐시 Purge 동시 요청 — 단일 Purge 보장

콘텐츠 삭제 요청이 들어오면 100개의 API 서버가 동시에 CDN Purge API를 호출할 수 있습니다. CDN 벤더 API는 rate limit이 있어(예: Akamai는 분당 300건), 100개 서버가 동시에 호출하면 대부분이 429(Too Many Requests)를 받고 실패합니다.

앞서 소개한 Redis 분산 락(SET NX PX) 패턴이 이를 해결합니다. 핵심은 Lua 스크립트로 GET과 DEL을 원자적으로 처리해 락 해제 시 경쟁 조건을 방지하는 것입니다. 30초 TTL은 락 보유 서버가 크래시해도 다른 서버가 30초 후 락을 재획득할 수 있게 합니다.

남은 한계: Redis 자체가 장애나면 분산 락도 작동하지 않습니다. 넷플릭스 수준에서는 Redis 대신 ZooKeeper나 etcd를 분산 락 전용으로 운영합니다. 하지만 CDN Purge 중복 실행은 최악의 경우 “같은 파일을 두 번 삭제”로, 멱등적 작업입니다. 락 실패 시 CDN API rate limit 오류만 발생하는 수준이므로 Redis 단일 인스턴스 락으로 충분한 경우가 많습니다.


동시접속 카운터 정확도 — Redis INCR vs HyperLogLog

시청자 수 카운팅에는 두 가지 요구사항이 공존합니다.

요구사항 적합한 방식 특징
“지금 몇 명이 보고 있나요?” (실시간 표시) HyperLogLog 12KB 고정, 0.81% 오차, 중복 제거
“이 영상 총 조회수” (광고 과금 기준) Kafka 집계 → DB 정확, 영속성 있음, 지연 있음
“동시 접속자 수 급락 감지” (알럿용) Redis INCR/DECR 입장/퇴장 시 정확한 증감

HyperLogLog는 동일 사용자의 재입장을 중복 제거하는 데 강점이 있지만, 실시간 “지금 이 순간 접속 중인 수”를 세는 용도로는 맞지 않습니다. HyperLogLog는 집합의 카디널리티(고유 원소 수)를 추정하는 자료구조이기 때문에, 사용자가 퇴장해도 HyperLogLog에서 제거할 수 없습니다.

실시간 동시접속 카운터는 Redis INCR(입장) + DECR(퇴장)이 더 정확합니다. 다만 서버 크래시 시 DECR 없이 연결이 끊기면 카운터가 과도하게 높아지는 “좀비 카운터” 문제가 생깁니다. 이를 막으려면 하트비트 기반 TTL 갱신과 만료된 세션 정리 루틴이 필요합니다.


라이브 스트림 동시 시작 — 트랜스코딩 리소스 경합

대형 이벤트(예: 게임 출시, 연예인 데뷔)가 있으면 수백 명의 스트리머가 동시에 라이브를 시작합니다. 각 스트림은 360p~1080p 4개 해상도를 동시에 인코딩해야 합니다. 트랜스코딩 워커 클러스터는 순식간에 포화됩니다.

graph LR
  LS["라이브 스트림\n동시 시작 100개"] --> AC["Admission Control"]
  AC -- "워커 여유 있음" --> TW["트랜스코딩 워커"]
  AC -- "워커 포화" --> DG["해상도 다운그레이드\n360p 전용"]
  TW --> CDN["CDN 배포"]

Admission Control:

신규 라이브 스트림 인제스트 요청 시 현재 워커 가용률을 확인합니다. 워커 사용률이 80% 이상이면 신규 스트림을 “360p 전용 모드”로 시작합니다. 워커 여유가 생기면 고화질 해상도를 추가합니다. 스트리머는 “현재 서버 부하로 인해 720p 이상은 잠시 후 제공됩니다”라는 알림을 받습니다.

전적으로 거부하는 것보다 저화질로 일단 시작하는 것이 사용자 경험에 훨씬 낫습니다.


오버엔지니어링 경고 — “진짜 필요한가?”를 먼저 물어보세요

가장 비싼 아키텍처는 필요 없는 아키텍처입니다. 1,000명을 위한 서비스에 1억 명용 시스템을 구축하면 운영 복잡도와 비용으로 서비스가 먼저 죽습니다.

규모별 적정 아키텍처

동시 시청자 적정 아키텍처 핵심 포인트
~100명 단일 서버 HLS 정적 파일 서빙 S3 + CloudFront 하나면 충분. Kafka, Redis Cluster 불필요
~10,000명 CDN + 기본 트랜스코딩 단일 CDN, FFmpeg 워커 2~3대, Redis 단일 노드
~100만명 멀티 CDN + 트랜스코딩 큐 이 글의 Phase 2~3 수준
1억명+ 멀티 CDN + Adaptive + 자체 CDN 검토 이 글의 전체 설계

동시 시청자 100명 수준에서 Redis Cluster, Kafka, Flink, 분산 락, HyperLogLog를 도입하면 운영 엔지니어만 5명이 필요한 시스템이 됩니다. nginx로 HLS 파일을 서빙하는 단일 서버가 비용·운영 모두 압도적으로 효율적입니다.


“라이브 스트리밍이 정말 필요한가?”

플랫폼의 90% 이상 시청은 VOD(녹화된 영상)입니다. 유튜브조차 라이브 스트리밍은 전체 시청의 5% 미만입니다. 라이브 스트리밍은 다음 이유로 VOD보다 훨씬 복잡합니다.

  • RTMP 인제스트 서버 운영 필요
  • 세그먼트를 실시간으로 생성하고 CDN에 배포하는 파이프라인
  • 지연시간 최소화를 위한 LL-HLS 또는 WebRTC 추가 구현
  • 라이브 채팅의 팬아웃 문제 (위 설계의 가장 복잡한 부분)
  • 스트리머 이탈 시 자동 VOD 전환 처리

“라이브 방송 필요” → “실시간 댓글 달기면 충분하지 않나?”를 먼저 검토하세요. 라이브 방송 대신 예약된 시간에 VOD를 공개하고, 댓글창을 실시간으로 활성화하는 방식만으로 사용자 경험의 80%를 달성할 수 있습니다. 이 경우 WebSocket 채팅만 구현하면 되고 RTMP 인프라는 필요 없습니다.


“자체 CDN vs 클라우드 CDN” — 트래픽이 기준

넷플릭스가 자체 CDN(Open Connect)을 구축한 이유는 트래픽이 너무 많아서 상업 CDN 비용이 감당 불가능했기 때문입니다. 현재 넷플릭스는 전 세계 인터넷 트래픽의 약 15%를 차지하며, 월 수 엑사바이트를 전송합니다.

클라우드 CDN 요금(CloudFront 기준 아시아 리전): 약 $0.12/GB

월 트래픽 CDN 비용 판단
100TB $12,000 클라우드 CDN으로 충분
1PB $120,000 클라우드 CDN으로 충분
10PB $1,200,000 자체 CDN 검토 시작점
100PB+ $12,000,000 자체 CDN이 장기적으로 저렴

월 트래픽 1PB 이하라면 CloudFront 또는 Cloudflare가 자체 CDN보다 압도적으로 저렴합니다. 자체 CDN은 ISP와의 피어링 협상, 전 세계 PoP 서버 운영, 24/7 네트워크 엔지니어링 팀이 필요합니다. 이 고정 비용이 클라우드 CDN 변동 비용보다 저렴해지는 시점은 극소수 기업에만 해당됩니다.


Kafka 심층 — 토픽 설계부터 Consumer Lag 연쇄까지

Kafka를 “빠른 메시지 큐”로만 이해하면 반드시 운영 사고가 납니다. Kafka는 토픽 설계, 파티션 수, Consumer 그룹 구성이 전체 파이프라인 처리량과 지연시간을 결정합니다.

토픽 분리 전략 — 시청 로그 vs 채팅 vs 하트비트

하나의 토픽에 모든 이벤트를 몰아넣는 것은 가장 흔한 Kafka 설계 실수입니다. 이벤트 유형이 다르면 처리 우선순위, 보존 기간, 처리량 요구사항이 모두 다릅니다.

graph LR
  VE["시청 이벤트"] --> T1["view.events\nacks=1, lz4, 7일 보존"]
  CE["채팅 메시지"] --> T2["chat.messages\nacks=all, 24시간 보존"]
  HB["하트비트"] --> T3["stream.heartbeat\nacks=0, 1시간 보존"]
  AL["광고 노출"] --> T4["ad.impressions\nacks=all, 30일 보존"]
토픽 acks 설정 보존 기간 이유
view.events 1 (리더만) 7일 일부 유실 허용, 집계로 보정 가능
chat.messages all (모든 ISR) 24시간 유실 시 채팅 히스토리 복원 불가
stream.heartbeat 0 (확인 없음) 1시간 초당 수백만 건, 유실해도 통계 오차 미미
ad.impressions all 30일 과금 기준, 절대 유실 불가

하트비트는 10초마다 발생하는 “나 아직 보고 있어요” 신호입니다. 1억 명 × 0.1회/초 = 초당 1,000만 건입니다. 이를 acks=all로 처리하면 Kafka 브로커 부하가 폭발합니다. acks=0으로 설정해 유실을 허용하고, 하트비트 기반 시청 시간 계산은 “5% 유실 가정 보정치”를 적용합니다.


실시간 시청자 수, 트렌딩 집계에는 두 가지 선택지가 있습니다.

  Flink Kafka Streams
배포 방식 별도 클러스터 Kafka Consumer에 내장
상태 관리 RocksDB 기반, 대규모 상태 처리 강점 Kafka 내부 토픽, 소규모 상태 적합
지연시간 100ms~수초 (윈도우 크기 의존) 수십ms (레코드 단위 처리 가능)
처리량 초당 수천만 건 초당 수백만 건
운영 복잡도 높음 (별도 클러스터, JobManager) 낮음 (Kafka만 있으면 됨)
언제 적합 복잡한 이벤트 처리, ML 피처 계산 단순 집계, 필터링, 조인

실무 권장: 트렌딩 집계(5분 윈도우 조회수 합산)는 Kafka Streams로 충분합니다. Kafka Consumer 코드 안에서 RocksDB 상태 저장소를 사용해 구현합니다. Flink는 ML 피처 계산, 복잡한 CEP(Complex Event Processing), 분 단위를 넘는 긴 윈도우 집계처럼 Kafka Streams의 한계를 넘을 때 도입합니다.

운영 판단 기준: Flink 클러스터 운영에는 전담 엔지니어가 필요합니다. “Flink가 필요한가”보다 “Kafka Streams로 불가능한가”를 먼저 물어보세요.


Consumer Lag → 추천 지연 → 사용자 경험 저하 연쇄

Consumer Lag은 단순한 인프라 메트릭이 아닙니다. 비즈니스 사용자 경험까지 직결되는 연쇄 효과가 있습니다.

graph LR
  UW["사용자 시청"] --> KP["Kafka Producer\n시청 이벤트 발행"]
  KP --> LAG["Consumer Lag 증가"]
  LAG --> FD["Flink 처리 지연"]
  FD --> RD["추천 모델\n입력 데이터 오래됨"]
  RD --> SR["추천 결과\n수 시간 전 취향 반영"]
  SR --> UX["사용자: 방금 본 영상\n또 추천됨"]

Consumer Lag이 10만 건 쌓이면 Flink의 시청 이벤트 처리가 지연됩니다. 시청 이벤트가 추천 모델에 반영되지 않으면, 사용자가 방금 본 영상이 계속 추천되거나 현재 트렌딩 콘텐츠가 추천 목록에 나타나지 않습니다.

Lag 경보 → 자동 스케일링 연결이 필수:

앞서 소개한 ConsumerLagMonitor가 Lag 100만 건 초과 시 Consumer를 2배 증가시키는 이유가 바로 이 연쇄 효과 때문입니다. 단순히 “처리가 느린 것”이 아니라 추천 품질 저하, 광고 노출 집계 지연, 실시간 시청자 수 부정확으로 이어집니다.

Lag이 줄지 않는다면: Consumer 수를 늘려도 Lag이 줄지 않으면 Kafka 파티션 수가 병목입니다. Consumer 수는 파티션 수를 초과할 수 없습니다. 파티션 수를 늘리려면 토픽을 재생성해야 하므로, 초기 설계 시 예상 Consumer 수의 2배로 파티션을 여유 있게 설정해야 합니다.


함께 읽으면 좋은 글

댓글

이 글이 도움이 됐다면?

같은 카테고리의 다른 글도 확인해보세요

더 많은 글 보기 →