← 블로그 홈

YouTube 구조를 참고해 웨딩 라이브 송출 시스템 설계하기

YouTube 구조를 참고해 웨딩 라이브 송출 시스템 설계하기

YouTube 구조를 참고해 웨딩 라이브 송출 시스템 설계하기

저번 포스트에서 Django + Nginx-RTMP로 웨딩 라이브 스트리밍 시스템의 기초를 구현했습니다. 이번엔 한 걸음 더 나아가 “YouTube는 어떻게 동작하는가?” 를 분석하고, 그 구조를 웨딩 서비스에 맞게 현실적으로 가져오는 방법을 설계합니다. OBS 없이 스마트폰만으로 송출하는 구조까지 포함합니다.


YouTube Live Streaming 구조 분석

YouTube가 전 세계 수백만 명에게 라이브를 서빙할 수 있는 이유는 기술 자체가 특별해서가 아닙니다. 규모에 맞게 각 구간을 분리하고 최적화했기 때문입니다.

[송출자]
    ↓ RTMP / RTMPS
[인제스트 서버] — 전 세계 엣지, 가장 가까운 곳으로 연결
    ↓
[트랜스코더] — 1080p / 720p / 480p / 360p 동시 생성
    ↓
[패키저] — HLS / DASH 세그먼트(~2초) 생성
    ↓
[CDN] — 전 세계 PoP에서 시청자 근처로 서빙
    ↓
[시청자] — ABR로 네트워크 상황에 따라 자동 해상도 전환
구간 기술 지연
송출 → 인제스트 RTMP ~0.5초
인제스트 → 트랜스코딩 내부 처리 ~1초
트랜스코딩 → CDN HLS 세그먼트 ~2~4초
CDN → 시청자 HLS ABR ~5~10초
총 지연 (일반)   약 15~30초
총 지연 (저지연 모드) DASH chunked 약 3~7초

우리가 가져올 것, 버릴 것

YouTube의 구조는 수백만 명을 위한 설계입니다. 웨딩 라이브는 수십 ~ 수백 명이 대상입니다. 그대로 따라 만들면 오버엔지니어링입니다.

구성 요소 YouTube 웨딩 시스템 이유
인제스트 글로벌 엣지 서버 단일 Nginx-RTMP 결혼식은 단발성, 지역 행사
트랜스코딩 수천 코어 자체 처리 FFmpeg (서버 1대) 수백 명엔 충분
다중 해상도 5~6개 720p / 360p 2개 모바일 대역폭 배려
배포 Google CDN Cloudflare Stream 또는 직서빙 비용·복잡도 절감
실시간 채팅 WebSocket 전용 인프라 Django Channels 규모 충분
송출 도구 RTMP 모든 클라이언트 스마트폰 앱 (OBS 불필요) 현장 편의성

핵심은 “RTMP 인제스트 → FFmpeg 트랜스코딩 → HLS 배포” 파이프라인은 YouTube와 동일하게 가져오되, 규모만 현실에 맞게 줄이는 것입니다.


목표 아키텍처

[스마트폰 앱 - Larix Broadcaster or 커스텀 앱]
            ↓ RTMP
      [Nginx-RTMP 서버]
            ↓
      [FFmpeg 트랜스코더]
       ↓           ↓
  720p HLS      360p HLS    ← 다중 해상도 (YouTube 방식 차용)
       ↓           ↓
  [Django Static / Cloudflare CDN]
            ↓
      [시청자 브라우저]
       - hls.js ABR 재생    ← YouTube 방식 차용
       - WebSocket 축하 메시지

구현 단계

1단계: Nginx-RTMP + FFmpeg 다중 해상도 트랜스코딩

YouTube에서 가장 핵심적으로 차용할 부분입니다. 단일 스트림을 받아 720p와 360p를 동시에 생성합니다.

# /etc/nginx/nginx.conf

rtmp {
    server {
        listen 1935;
        chunk_size 4096;

        application live {
            live on;
            record off;

            # 수신 즉시 FFmpeg로 다중 해상도 트랜스코딩
            exec ffmpeg -i rtmp://localhost/live/$name
                # 720p
                -c:v libx264 -preset veryfast -b:v 2000k
                -maxrate 2000k -bufsize 4000k
                -vf scale=-2:720 -g 60 -c:a aac -b:a 128k -ac 2
                -f flv rtmp://localhost/hls720/$name

                # 360p
                -c:v libx264 -preset veryfast -b:v 600k
                -maxrate 600k -bufsize 1200k
                -vf scale=-2:360 -g 60 -c:a aac -b:a 96k -ac 2
                -f flv rtmp://localhost/hls360/$name;
        }

        # 720p HLS 생성
        application hls720 {
            live on;
            hls on;
            hls_path /var/www/hls/720;
            hls_fragment 2s;
            hls_playlist_length 10s;
            deny play all;
        }

        # 360p HLS 생성
        application hls360 {
            live on;
            hls on;
            hls_path /var/www/hls/360;
            hls_fragment 2s;
            hls_playlist_length 10s;
            deny play all;
        }
    }
}

server {
    listen 80;

    location /hls {
        alias /var/www/hls;
        add_header Cache-Control no-cache;
        add_header Access-Control-Allow-Origin *;
        types {
            application/vnd.apple.mpegurl m3u8;
            video/mp2t ts;
        }
    }
}

이 설정으로 스마트폰에서 RTMP 송출을 시작하는 순간 /var/www/hls/720//var/www/hls/360/ 에 HLS 세그먼트가 자동 생성됩니다.


2단계: Master Playlist 생성 (ABR - YouTube 방식)

YouTube가 네트워크 상황에 따라 해상도를 자동으로 전환하는 ABR(Adaptive Bitrate) 을 구현합니다. 핵심은 두 해상도를 하나의 마스터 플레이리스트로 묶는 것입니다.

# wedding_live/views.py

from django.http import HttpResponse

def master_playlist(request, stream_key):
    """
    두 해상도를 묶는 HLS 마스터 플레이리스트를 동적으로 생성.
    hls.js가 이 파일을 읽고 네트워크 상황에 따라 자동 전환.
    """
    content = f"""#EXTM3U
#EXT-X-VERSION:3

#EXT-X-STREAM-INF:BANDWIDTH=2128000,RESOLUTION=1280x720
/hls/720/{stream_key}.m3u8

#EXT-X-STREAM-INF:BANDWIDTH=696000,RESOLUTION=640x360
/hls/360/{stream_key}.m3u8
"""
    return HttpResponse(content, content_type='application/vnd.apple.mpegurl')
# wedding_live/urls.py

from django.urls import path
from . import views

urlpatterns = [
    path('watch/<str:stream_key>/', views.watch, name='watch'),
    path('stream/<str:stream_key>/master.m3u8', views.master_playlist, name='master_playlist'),
]

3단계: 스마트폰 앱으로 송출 (OBS 대체)

OBS는 데스크탑 전용 소프트웨어입니다. 결혼식 현장에서는 스마트폰이 훨씬 현실적입니다.

옵션 A: Larix Broadcaster 앱 (즉시 사용 가능)

설치 후 Settings → Connections → New Connection에서 아래 정보만 입력합니다.

URL: rtmp://서버IP/live
Stream Name: wedding_abc123

무료이며 iOS / Android 모두 지원합니다. 결혼 당일 담당자 스마트폰에 미리 설치하고 QR코드로 설정값을 전달하면 됩니다.

옵션 B: 커스텀 앱 내 송출 기능 (Flutter)

웨딩 서비스 자체 앱이 있다면 송출 기능을 직접 내장할 수 있습니다.

# pubspec.yaml
dependencies:
  camera: ^0.10.5
  flutter_rtmp_publisher: ^0.1.0
// wedding_stream_page.dart

class WeddingStreamPage extends StatefulWidget {
  final String streamKey;
  const WeddingStreamPage({required this.streamKey});

  @override
  State<WeddingStreamPage> createState() => _WeddingStreamPageState();
}

class _WeddingStreamPageState extends State<WeddingStreamPage> {
  late RtmpPublisher _publisher;
  bool _isStreaming = false;

  @override
  void initState() {
    super.initState();
    _publisher = RtmpPublisher(
      url: 'rtmp://서버IP/live',
      streamKey: widget.streamKey,
      videoConfig: VideoConfig(
        width: 1280,
        height: 720,
        fps: 30,
        bitrate: 2000 * 1024,
      ),
      audioConfig: AudioConfig(
        bitrate: 128 * 1024,
        sampleRate: 44100,
      ),
    );
  }

  void _toggleStream() async {
    if (_isStreaming) {
      await _publisher.stop();
    } else {
      await _publisher.start();
    }
    setState(() => _isStreaming = !_isStreaming);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          CameraPreview(_publisher.cameraController),
          Positioned(
            bottom: 40,
            child: ElevatedButton(
              onPressed: _toggleStream,
              style: ElevatedButton.styleFrom(
                backgroundColor: _isStreaming ? Colors.red : Colors.pink,
              ),
              child: Text(_isStreaming ? '송출 중지' : '결혼식 라이브 시작'),
            ),
          ),
        ],
      ),
    );
  }
}

4단계: Django Channels - 실시간 축하 메시지

WebSocket Consumer는 이전 포스트와 동일한 구조입니다. 여기서는 이모지 반응 기능을 추가합니다. 시청자가 이모지를 누르면 모든 시청자 화면에 이모지가 떠오르는 YouTube의 Super Thanks와 유사한 기능입니다.

# wedding_live/consumers.py

import json
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from .models import WeddingEvent, Congratulation

class WeddingConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.stream_key = self.scope['url_route']['kwargs']['stream_key']
        self.room_group_name = f'wedding_{self.stream_key}'

        await self.channel_layer.group_add(self.room_group_name, self.channel_name)
        await self.accept()
        await self._broadcast_viewer_count(delta=1)

    async def disconnect(self, close_code):
        await self.channel_layer.group_discard(self.room_group_name, self.channel_name)
        await self._broadcast_viewer_count(delta=-1)

    async def receive(self, text_data):
        data = json.loads(text_data)

        if data['type'] == 'congratulation':
            congrat = await self._save_congratulation(data)
            await self.channel_layer.group_send(self.room_group_name, {
                'type': 'congratulation_message',
                **congrat,
            })

        elif data['type'] == 'emoji_reaction':
            # DB 저장 없이 즉시 브로드캐스트 (YouTube Super Thanks 방식)
            await self.channel_layer.group_send(self.room_group_name, {
                'type': 'emoji_burst',
                'emoji': data['emoji'],
            })

    async def congratulation_message(self, event):
        await self.send(text_data=json.dumps({'type': 'congratulation', **event}))

    async def emoji_burst(self, event):
        await self.send(text_data=json.dumps({'type': 'emoji_burst', 'emoji': event['emoji']}))

    async def viewer_count_update(self, event):
        await self.send(text_data=json.dumps({'type': 'viewer_count', 'count': event['count']}))

    @database_sync_to_async
    def _save_congratulation(self, data):
        event = WeddingEvent.objects.get(stream_key=self.stream_key)
        c = Congratulation.objects.create(
            event=event,
            name=data.get('name', '익명'),
            message=data['message'],
            emoji=data.get('emoji', '💍'),
        )
        return {
            'name': c.name,
            'message': c.message,
            'emoji': c.emoji,
            'created_at': c.created_at.strftime('%H:%M'),
        }

    async def _broadcast_viewer_count(self, delta):
        count = await self._update_viewer_count(delta)
        await self.channel_layer.group_send(self.room_group_name, {
            'type': 'viewer_count_update',
            'count': count,
        })

    @database_sync_to_async
    def _update_viewer_count(self, delta):
        from django.db.models import F
        WeddingEvent.objects.filter(stream_key=self.stream_key).update(
            viewer_count=F('viewer_count') + delta
        )
        return WeddingEvent.objects.get(stream_key=self.stream_key).viewer_count

5단계: 프론트엔드 - ABR 재생 + 이모지 버스트

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>{{ event.groom }} ♥ {{ event.bride }} 결혼식 라이브</title>
    <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body { font-family: 'Noto Sans KR', sans-serif; background: #fdf6f0; }
        .container { max-width: 1000px; margin: 0 auto; padding: 20px; }
        h1 { text-align: center; color: #8b4f6e; padding: 20px 0; font-size: 22px; }

        /* 플레이어 */
        .player-wrap { position: relative; background: #000; border-radius: 12px; overflow: hidden; }
        #video { width: 100%; display: block; max-height: 500px; }
        .badge {
            position: absolute; top: 12px; padding: 4px 12px;
            border-radius: 20px; font-size: 13px; font-weight: bold;
        }
        .live-badge { left: 12px; background: #e74c3c; color: #fff; }
        .viewer-badge { right: 12px; background: rgba(0,0,0,0.55); color: #fff; }

        /* 이모지 버스트 레이어 */
        #emoji-layer {
            position: absolute; inset: 0;
            pointer-events: none; overflow: hidden;
        }
        .floating-emoji {
            position: absolute; bottom: 0; font-size: 28px;
            animation: floatUp 2.5s ease-out forwards;
        }
        @keyframes floatUp {
            0%   { transform: translateY(0) scale(1); opacity: 1; }
            100% { transform: translateY(-300px) scale(1.4); opacity: 0; }
        }

        /* 하단 패널 */
        .panel { display: flex; gap: 16px; margin-top: 20px; }
        .chat-box {
            flex: 1; background: #fff; border-radius: 12px;
            padding: 16px; height: 340px; overflow-y: auto;
            box-shadow: 0 2px 8px rgba(0,0,0,0.07);
        }
        .msg { padding: 10px 0; border-bottom: 1px solid #f5e8f0; }
        .msg .meta { font-weight: bold; color: #8b4f6e; font-size: 14px; }
        .msg .text { color: #444; font-size: 14px; margin-top: 3px; }
        .msg .time { color: #bbb; font-size: 11px; margin-top: 2px; }

        .input-box {
            width: 280px; background: #fff; border-radius: 12px;
            padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.07);
            display: flex; flex-direction: column; gap: 10px;
        }
        .input-box h3 { color: #8b4f6e; font-size: 15px; }
        input, textarea {
            width: 100%; border: 1px solid #e0c8d8; border-radius: 8px;
            padding: 8px 12px; font-size: 13px; font-family: inherit;
        }
        textarea { height: 70px; resize: none; }

        /* 이모지 선택 */
        .emoji-row { display: flex; gap: 6px; flex-wrap: wrap; }
        .emoji-btn {
            font-size: 20px; background: none;
            border: 2px solid transparent; border-radius: 8px;
            padding: 2px 5px; cursor: pointer;
        }
        .emoji-btn.selected { border-color: #8b4f6e; background: #fdf0f6; }

        /* 즉각 이모지 반응 버튼 */
        .reaction-row { display: flex; gap: 8px; }
        .reaction-btn {
            flex: 1; font-size: 22px; background: #fdf0f6;
            border: none; border-radius: 10px;
            padding: 6px; cursor: pointer; transition: transform 0.1s;
        }
        .reaction-btn:active { transform: scale(1.3); }

        /* 전송 버튼 */
        .send-btn {
            background: #8b4f6e; color: #fff; border: none;
            border-radius: 8px; padding: 10px; font-size: 14px; cursor: pointer;
        }
        .send-btn:hover { background: #7a3d5d; }
    </style>
</head>
<body>
<div class="container">
    <h1>💍 {{ event.groom }} & {{ event.bride }} 결혼식</h1>

    <div class="player-wrap">
        <video id="video" controls playsinline autoplay muted></video>
        <span class="badge live-badge">🔴 LIVE</span>
        <span class="badge viewer-badge" id="viewer-count">👁 0명</span>
        <div id="emoji-layer"></div>
    </div>

    <div class="panel">
        <div class="chat-box" id="chat-box">
            {% for msg in recent_messages %}
            <div class="msg">
                <div class="meta">{{ msg.emoji }} {{ msg.name }}</div>
                <div class="text">{{ msg.message }}</div>
                <div class="time">{{ msg.created_at|time:"H:i" }}</div>
            </div>
            {% endfor %}
        </div>

        <div class="input-box">
            <h3>💌 축하 메시지</h3>
            <input type="text" id="name-input" placeholder="이름 (익명 가능)" maxlength="20">

            <div class="emoji-row" id="emoji-row">
                {% for e in "💍 🎊 🌸 💐 🥂 ❤️ 🎉 🕊️"|split:" " %}
                <button class="emoji-btn" data-emoji="{{ e }}">{{ e }}</button>
                {% endfor %}
            </div>

            <textarea id="msg-input" placeholder="두 분의 앞날을 축복합니다 ✨" maxlength="100"></textarea>
            <button class="send-btn" id="send-btn">축하 보내기 💍</button>

            <div style="border-top: 1px solid #f0e0ea; padding-top: 10px;">
                <div style="font-size: 13px; color: #999; margin-bottom: 8px;">즉각 반응</div>
                <div class="reaction-row">
                    <button class="reaction-btn" data-emoji="🎊">🎊</button>
                    <button class="reaction-btn" data-emoji="❤️">❤️</button>
                    <button class="reaction-btn" data-emoji="👏">👏</button>
                    <button class="reaction-btn" data-emoji="😭">😭</button>
                </div>
            </div>
        </div>
    </div>
</div>

<script>
// ─── HLS ABR 재생 (YouTube 방식) ───────────────────────────
const video = document.getElementById('video');
const masterUrl = "{% url 'master_playlist' event.stream_key %}";

if (Hls.isSupported()) {
    const hls = new Hls({ lowLatencyMode: true });
    hls.loadSource(masterUrl);
    hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
    video.src = masterUrl;
}

// ─── WebSocket 연결 ────────────────────────────────────────
const ws = new WebSocket(`ws://${location.host}/ws/wedding/{{ event.stream_key }}/`);
const chatBox = document.getElementById('chat-box');

ws.onmessage = ({ data }) => {
    const msg = JSON.parse(data);

    if (msg.type === 'congratulation') {
        const div = document.createElement('div');
        div.className = 'msg';
        div.innerHTML = `
            <div class="meta">${msg.emoji} ${msg.name}</div>
            <div class="text">${msg.message}</div>
            <div class="time">${msg.created_at}</div>
        `;
        chatBox.prepend(div);
    }

    if (msg.type === 'viewer_count') {
        document.getElementById('viewer-count').textContent = `👁 ${msg.count}명`;
    }

    if (msg.type === 'emoji_burst') {
        spawnEmoji(msg.emoji);
    }
};

// ─── 이모지 버스트 애니메이션 ──────────────────────────────
function spawnEmoji(emoji) {
    const layer = document.getElementById('emoji-layer');
    const el = document.createElement('span');
    el.className = 'floating-emoji';
    el.textContent = emoji;
    el.style.left = (10 + Math.random() * 80) + '%';
    layer.appendChild(el);
    setTimeout(() => el.remove(), 2500);
}

// ─── 이모지 선택 ───────────────────────────────────────────
let selectedEmoji = '💍';
document.querySelectorAll('.emoji-btn').forEach(btn => {
    btn.addEventListener('click', () => {
        document.querySelectorAll('.emoji-btn').forEach(b => b.classList.remove('selected'));
        btn.classList.add('selected');
        selectedEmoji = btn.dataset.emoji;
    });
});
document.querySelector('.emoji-btn').classList.add('selected');

// ─── 축하 메시지 전송 ──────────────────────────────────────
document.getElementById('send-btn').addEventListener('click', () => {
    const name = document.getElementById('name-input').value.trim() || '익명';
    const message = document.getElementById('msg-input').value.trim();
    if (!message) return;

    ws.send(JSON.stringify({ type: 'congratulation', name, message, emoji: selectedEmoji }));
    document.getElementById('msg-input').value = '';
});

// ─── 즉각 이모지 반응 ──────────────────────────────────────
document.querySelectorAll('.reaction-btn').forEach(btn => {
    btn.addEventListener('click', () => {
        ws.send(JSON.stringify({ type: 'emoji_reaction', emoji: btn.dataset.emoji }));
    });
});
</script>
</body>
</html>

전체 흐름 정리

결혼 당일 현장
─────────────────────────────────────────────────────
1. 담당자 스마트폰 → Larix Broadcaster 실행
   → rtmp://서버IP/live/wedding_abc123 으로 RTMP 송출

서버 (자동 처리)
─────────────────────────────────────────────────────
2. Nginx-RTMP 수신
3. FFmpeg 트랜스코딩 → 720p + 360p HLS 동시 생성
4. Django가 마스터 플레이리스트(master.m3u8)로 두 해상도 묶음

하객 시청 (링크 접속)
─────────────────────────────────────────────────────
5. hls.js가 마스터 플레이리스트 읽고 ABR 재생 시작
   → Wi-Fi 환경이면 720p, LTE 약하면 자동으로 360p 전환
6. WebSocket 연결 → 실시간 축하 메시지 + 이모지 반응

YouTube와 최종 비교

항목 YouTube 우리 웨딩 시스템
송출 OBS, 모바일 앱 스마트폰 앱 (Larix / 커스텀)
인제스트 글로벌 엣지 Nginx-RTMP 단일 서버
트랜스코딩 자체 수천 코어 FFmpeg (서버 1대)
다중 해상도 5~6개 720p + 360p
배포 Google CDN 직서빙 or Cloudflare
ABR 전환 자체 플레이어 hls.js
실시간 채팅 전용 인프라 Django Channels + Redis
이모지 반응 Super Thanks WebSocket emoji burst
목표 동시 시청 수백만 명 수십 ~ 수백 명

YouTube의 구조를 그대로 모방하되, 규모를 현실에 맞게 줄인 것이 이번 설계의 핵심입니다. 기술 선택의 방향은 같고, 인프라 규모만 다릅니다.


마무리

“YouTube는 어떻게 동작하는가”를 이해하면 라이브 스트리밍의 각 구간이 왜 그렇게 설계됐는지 보입니다. RTMP 인제스트, FFmpeg 트랜스코딩, HLS ABR 배포는 YouTube가 발명한 개념이 아닙니다. 오픈소스 기술들을 조합한 것이고, 우리도 같은 조합을 사용할 수 있습니다.

결혼식은 일생에 한 번입니다. 오지 못한 소중한 분들도 실시간으로 함께하고 이모지로 축하를 전할 수 있는 경험, 생각보다 가까운 기술로 만들 수 있습니다. 💍


구현 중 막히는 부분은 댓글로 남겨주세요!

이 글을 공유해보세요!