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

모바일

동영상 스트리밍HLSAVPlayerExoPlayerDRM

이 글은 누구를 위한 것인가

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

들어가며

HLS(HTTP Live Streaming)는 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로 오프라인 다운로드를 지원하면 비행기 모드에서도 재생이 가능하다.