HLS 동영상 스트리밍: AVPlayer와 ExoPlayer 완전 구현

모바일

동영상 스트리밍HLSAVPlayerExoPlayerDRM

이 글은 누구를 위한 것인가

  • OTT, 교육, 라이브 스트리밍 앱을 구현하려는 팀
  • HLS 스트리밍과 DRM 보호를 적용하려는 개발자
  • 오프라인 다운로드 기능을 구현하려는 팀

들어가며

HLS(HTTP Live Streaming)는 Apple이 개발한 적응형 비트레이트 스트리밍 프로토콜로, RFC 8216으로 표준화돼 있고 Apple의 스트리밍 자료에 구현 지침이 모여 있다. 네트워크 상황에 따라 화질을 자동으로 조정하고, DRM으로 콘텐츠를 보호한다. iOS는 AVPlayer, Android는 ExoPlayer(Media3)가 표준이다.

이 글은 bluefoxdev.kr의 HLS 동영상 스트리밍 구현 가이드 를 참고하여 작성했습니다.


1. HLS 스트리밍 아키텍처

[HLS 구조]
  Master Playlist (.m3u8)
  ├── 1080p Stream → 세그먼트들 (.ts/.fmp4)
  ├── 720p Stream
  ├── 480p Stream
  └── 오디오 스트림

[ABR (Adaptive Bitrate)]
  네트워크 대역폭 측정
  → 적절한 화질 자동 선택
  → 버퍼링 없이 전환

[DRM]
  iOS: FairPlay Streaming (FPS)
  Android: Widevine
  Web: PlayReady
  크로스 플랫폼: MPEG-DASH + Common Encryption

[CDN 구성]
  원본: S3 + CloudFront
  세그먼트 캐싱: CDN 엣지
  지역별 배포: 지연 시간 최소화
  
[오프라인 다운로드]
  iOS: AVAssetDownloadTask
  Android: ExoPlayer DownloadManager
  암호화: 기기별 키로 로컬 암호화

2. AVPlayer 구현

import AVKit
import SwiftUI
import Combine

class VideoPlayerViewModel: ObservableObject {
    @Published var isPlaying = false
    @Published var currentTime: Double = 0
    @Published var duration: Double = 0
    @Published var isLoading = true
    @Published var selectedQuality: VideoQuality = .auto
    
    var player: AVPlayer?
    private var timeObserver: Any?
    private var cancellables = Set<AnyCancellable>()
    
    enum VideoQuality: String, CaseIterable {
        case auto = "자동"
        case hd1080 = "1080p"
        case hd720 = "720p"
        case sd480 = "480p"
    }
    
    func setupPlayer(urlString: String) {
        guard let url = URL(string: urlString) else { return }
        
        // HLS 에셋
        let asset = AVURLAsset(url: url)
        let playerItem = AVPlayerItem(asset: asset)
        
        // 버퍼 설정
        playerItem.preferredForwardBufferDuration = 10 // 10초 선버퍼
        
        player = AVPlayer(playerItem: playerItem)
        
        // 로딩 상태 관찰
        playerItem.publisher(for: \.status)
            .receive(on: DispatchQueue.main)
            .sink { [weak self] status in
                self?.isLoading = status != .readyToPlay
            }
            .store(in: &cancellables)
        
        // 재생 시간 관찰
        timeObserver = player?.addPeriodicTimeObserver(
            forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
            queue: .main
        ) { [weak self] time in
            self?.currentTime = time.seconds
            self?.duration = self?.player?.currentItem?.duration.seconds ?? 0
        }
        
        // 끝 감지
        NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: playerItem)
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                self?.isPlaying = false
                self?.player?.seek(to: .zero)
            }
            .store(in: &cancellables)
    }
    
    func togglePlayPause() {
        if isPlaying { player?.pause() } else { player?.play() }
        isPlaying.toggle()
    }
    
    func seek(to seconds: Double) {
        let time = CMTime(seconds: seconds, preferredTimescale: 600)
        player?.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
    }
    
    // 오프라인 다운로드
    func downloadForOffline(urlString: String, assetTitle: String) async throws {
        guard let url = URL(string: urlString) else { return }
        let asset = AVURLAsset(url: url)
        
        let downloadTask = AVAssetDownloadURLSession.shared.makeAssetDownloadTask(
            asset: asset,
            assetTitle: assetTitle,
            assetArtworkData: nil,
            options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000]
        )
        
        downloadTask?.resume()
    }
    
    deinit {
        if let observer = timeObserver { player?.removeTimeObserver(observer) }
    }
}

// SwiftUI 비디오 플레이어 뷰
struct CustomVideoPlayer: View {
    @StateObject private var viewModel = VideoPlayerViewModel()
    let url: String
    
    var body: some View {
        ZStack {
            VideoPlayer(player: viewModel.player)
                .ignoresSafeArea()
            
            if viewModel.isLoading {
                ProgressView().tint(.white)
            }
            
            // 커스텀 컨트롤 오버레이
            VStack {
                Spacer()
                HStack {
                    Button(action: viewModel.togglePlayPause) {
                        Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill")
                            .foregroundColor(.white).font(.title)
                    }
                    Slider(
                        value: Binding(get: { viewModel.currentTime }, set: viewModel.seek),
                        in: 0...max(viewModel.duration, 1)
                    )
                    Text(formatTime(viewModel.currentTime))
                        .foregroundColor(.white).font(.caption)
                }
                .padding()
                .background(.black.opacity(0.4))
            }
        }
        .onAppear { viewModel.setupPlayer(urlString: url) }
    }
    
    private func formatTime(_ seconds: Double) -> String {
        let m = Int(seconds) / 60
        let s = Int(seconds) % 60
        return String(format: "%d:%02d", m, s)
    }
}

마무리

HLS의 핵심은 ABR(Adaptive Bitrate)이다. 네트워크 대역폭에 따라 화질이 자동으로 전환되어 버퍼링을 최소화한다. DRM 보호는 FairPlay(iOS)/Widevine(Android) 각각 구현이 필요하며, AVAssetDownloadTask로 오프라인 다운로드를 지원하면 비행기 모드에서도 재생이 가능하다.