
이번 포스트는 AVFoundation 과 AVKit을 이용한 동영상 플레이어에 대해 알아보고자 한다. AVFoundation 과 AVkit은 iOS에서 미디어 처리를 다루는 두 가지 핵심 프레임워크인데,
AVFoundation
Work with audiovisual assets, control device cameras, process audio, and configure system audio interactions.
AVFoundation은 오디오와 비디오를 재생, 녹화, 편집하는 프레임워크이다. 미디어 에셋 관리, 재생 타이밍 제어, 오디오 세션 설정 등 세밀한 커스터마이징이 필요할 때 사용한다.
AVKit
Create user interfaces for media playback, complete with transport controls, chapter navigation, picture-in-picture support, and display of subtitles and closed captions.
▲ 그림 1. AVFoundation stack on iOS
AVKit은 AVFoundation 위에서 동작하는 프레임워크이다. AVPlayerViewController 같은 즉시 사용 가능한 플레이어 인터페이스를 제공해서, 빠르게 표준 미디어 재생 화면을 구현할 수 있다. 간단히 말하면 AVFoundation이 엔진이라면, AVKit은 그 위에 올라가는 UI 레이어라고 볼 수 있다.
AVFoundation 과 AVkit을 이용해 동영상 플레이어를 만든 프로젝트를 기준으로 설명하고자 한다. 해당 프로젝트에서 제공하는 기능은 다음과 같다.
다음 링크의 AVPlayer 브랜치에서 확인할 수 있다.
▲ 그림 2. AVFoundation & AVKit 이용한 동영상 플레이어
1. HLS 비디오 플레이어
2. 기본적인 재생 제어(재생/멈춤, Seek 기능, 5초 앞으로/뒤로)
3. SeekBar(전체 트랙, 버퍼링된 영역, 현재 재생 위치)
4. 화면 전환 시 전체화면 변경
기본적인 동영상 플레이어의 기능을 구현했고, 해당 프로젝트 내에서 AVFoundation 과 AVkit 에서 사용한 기능들에 대해 리뷰하겠다.
class AVURLAsset : AVAsset
An asset that represents media at a local or remote URL.
AVURLAsset은 URL을 통해 미디어 파일에 접근하는 클래스이다. 로컬 파일이나 원격 스트리밍 URL로부터 미디어의 메타데이터, 트랙 정보, 재생 시간 등을 비동기적으로 로드한다.
class AVPlayerItem : NSObject
An object that models the timing and presentation state of an asset during playback.
AVPlayerItem은 AVAsset을 재생 가능한 형태로 래핑한 객체이다. 재생 상태(buffering, ready, failed), 현재 재생 시간, 로드된 시간 범위 등 실시간 재생 정보를 제공한다. AVPlayer와 연결되어 실제 미디어 재생을 관리하며, 여러 AVPlayerItem을 순차적으로 재생하는 큐잉도 지원한다.
▲ 그림 3. AVAsset 구성
AVAsset은 비디오, 오디오, 자막과 같은 서로 다른 미디어 타입의 트랙들을 포함하고 있고, 위에서 설명한 AVURLAsset 혹은 AVComposition 등과 같이 AVAsset을 상속받아 구체적인 미디어 소스를 구현한다.
class StreamPlayerManager {
...
func loadStream(url: URL) {
cleanup()
currentState = .loading
let asset = AVURLAsset(url: url)
playerItem = AVPlayerItem(asset: asset)
player = AVPlayer(playerItem: playerItem)
// ABR 최적화 설정
if let player = player {
// 자동으로 stalling 최소화 (버퍼링 개선)
player.automaticallyWaitsToMinimizeStalling = true
}
setupObservers()
}
...
}
먼저 AVURLAsset으로 URL의 미디어 리소스에 접근하고, 이를 AVPlayerItem으로 래핑하여 재생 가능한 상태로 만든다. 마지막으로 AVPlayer에 playerItem을 연결하면 실제 재생 준비가 완료된다. 이 과정은 Asset → PlayerItem → Player 순서로 진행되며, 각 객체가 상위 레이어를 감싸는 구조이다.
class AVPlayerLayer : CALayer
An object that presents the visual contents of a player object.
AVPlayerLayer 는 AVPlayer가 재생하는 비디오를 화면에 렌더링하는 역할을 담당한다. 미디어 재생 자체는 AVPlayer가 담당하지만 AVPlayerLayer는 이 재생 중인 비디오를 렌더링하는 데 사용된다고 이해하면 된다. 해당 프로젝트 내에 Layer 를 설정하는 코드는 PlayerView.swift 에서 확인할 수 있다.
override class var layerClass: AnyClass {
AVPlayerLayer.self
}
UIView는 기본적으로 CALayer를 backing layer로 사용하기 때문에, layerClass를 override하면 다른 타입의 layer 사용 가능하다. 이렇게 하면 self.layer가 자동으로 AVPlayerLayer 인스턴스가 된다.
// self.layer를 AVPlayerLayer로 타입 캐스팅 -> 옵셔널로 반환하여 안전성 확보
var playerLayer: AVPlayerLayer? {
self.layer as? AVPlayerLayer
}
var player: AVPlayer? {
get { self.playerLayer?.player }
set { self.playerLayer?.player = newValue }
}
AVPlayerLayer의 player 프로퍼티에 대한 편리한 접근하고자 getter 를 세팅했고, ViewController 에서 player 를 편하게 초기화하기 위해 setter 를 설정했다.
// MARK: - Initialization
override init(frame: CGRect) {
super.init(frame: frame)
setupLayer()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupLayer()
}
// MARK: - Setup
private func setupLayer() {
backgroundColor = .black
playerLayer?.videoGravity = .resizeAspect
}
Storyboard/XIB 사용 시 init(coder:), 코드로 생성 시 init(frame:) 를 호출한다. 둘 다 setupLayer() 호출하여 플레이어에 대해 초기 설정을 적용할 수 있다.
class AVPlayer: NSObject
An object that provides the interface to control the player’s transport behavior.
AVPlayer 는 미디어 재생을 제어하는 핵심 컨트롤러 객체이다. play(), pause(), seek() 같은 재생 제어 메서드와 currentTime, rate(재생 속도) 등의 속성을 제공하며, AVPlayerItem의 상태를 모니터링하고 실제 재생을 관리한다.
AVPlayer 는 위의 loadStream(url) 에서 초기화했기 때문에 생략했고, 재생 제어 관련된 메소드들은 다음과 같이 설정했다. StreamPlayerManager 의 명시된 함수들을 통해 ViewController 에서 UI의 Action이 일어나는 부분에 해당 메소드들을 호출하게 되면 재생을 제어할 수 있다.class StreamPlayerManager {
...
// MARK: - Properties
private(set) var player: AVPlayer?
private var playerItem: AVPlayerItem?
...
// MARK: - Public Methods
...
func play() {
guard let playerItem = playerItem else { return }
player?.play()
if playerItem.isPlaybackLikelyToKeepUp {
currentState = .playing
} else {
currentState = .buffering
}
}
func pause() {
player?.pause()
currentState = .paused
}
func seek(to time: Double) {
let cmTime = CMTime(seconds: time, preferredTimescale: 600)
player?.seek(to: cmTime)
}
func seek(toPercent percent: Double) {
let time = duration * percent
seek(to: time)
}
func forward(seconds: Double = 10) {
guard let currentItem = player?.currentItem else { return }
let currentTime = currentItem.currentTime().seconds
let newTime = min(currentTime + seconds, duration)
seek(to: newTime)
}
func rewind(seconds: Double = 10) {
guard let currentItem = player?.currentItem else { return }
let currentTime = currentItem.currentTime().seconds
let newTime = max(currentTime - seconds, 0)
seek(to: newTime)
}
func setRate(_ rate: Float) {
player?.rate = rate
}
...
import Foundation
enum PlaybackState: Equatable {
case idle
case loading
case readyToPlay
case playing
case paused
case buffering
case ended
case failed(Error)
static func == (lhs: PlaybackState, rhs: PlaybackState) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle),
(.loading, .loading),
(.readyToPlay, .readyToPlay),
(.playing, .playing),
(.paused, .paused),
(.buffering, .buffering),
(.ended, .ended):
return true
case (.failed(let lhsError), .failed(let rhsError)):
return lhsError.localizedDescription == rhsError.localizedDescription
default:
return false
}
}
}
8 개의 상태를 정의해서 각 상태 별 표시해야 할 UI 는 다음과 같다.
▲ 그림 4. 상태 별 UI 표시
class StreamPlayerManager {
// MARK: - Properties
...
private(set) var currentState: PlaybackState = .idle {
didSet {
delegate?.playerStateDidChange(state: currentState)
}
}
...
// MARK: - Initialization
init() {
setupNotifications()
}
deinit {
cleanup()
NotificationCenter.default.removeObserver(self)
}
// MARK: - Private Methods
private func setupObservers() {
guard let player = player, let playerItem = playerItem else { return }
// Time Observer
timeObserverToken = player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
queue: .main
) { [weak self] time in
guard let self = self else { return }
self.delegate?.playerDidUpdateTime(
currentTime: time.seconds,
duration: self.duration
)
}
// Status Observer
statusObservation = playerItem.observe(\.status, options: [.new]) { [weak self] item, _ in
guard let self = self else { return }
switch item.status {
case .readyToPlay:
self.currentState = .readyToPlay
case .failed:
if let error = item.error {
self.currentState = .failed(error)
print("Failed to play: \(error.localizedDescription)")
}
case .unknown:
print("Loading...")
@unknown default:
break
}
}
// Buffer Observer - 버퍼 상태 및 UI 업데이트
bufferObservation = playerItem.observe(\.loadedTimeRanges, options: [.new]) { [weak self] item, _ in
guard let self = self else { return }
// 1. UI 업데이트 - 버퍼링 seekbar 표시
if let timeRange = item.loadedTimeRanges.first?.timeRangeValue {
let bufferedTime = timeRange.start.seconds + timeRange.duration.seconds
self.delegate?.playerDidUpdateBuffer(bufferedTime: bufferedTime)
}
// 2. 상태 관리 - 버퍼링 상태 체크
let isPlayingOrBuffering = self.currentState == .playing || self.currentState == .buffering
if !item.isPlaybackLikelyToKeepUp && isPlayingOrBuffering {
// 버퍼가 부족하면 buffering 상태로 전환
self.currentState = .buffering
} else if item.isPlaybackLikelyToKeepUp && self.currentState == .buffering {
// 버퍼링이 완료되면 playing 상태로 복귀 (재생 중이었다면)
if self.player?.rate != 0 {
self.currentState = .playing
}
}
}
}
private func setupNotifications() {
NotificationCenter.default.addObserver(
self,
selector: #selector(playerDidFinishPlaying),
name: .AVPlayerItemDidPlayToEndTime,
object: nil
)
}
@objc private func playerDidFinishPlaying() {
currentState = .ended
}
private func cleanup() {
if let token = timeObserverToken {
player?.removeTimeObserver(token)
timeObserverToken = nil
}
statusObservation?.invalidate()
statusObservation = nil
bufferObservation?.invalidate()
bufferObservation = nil
player?.pause()
player = nil
playerItem = nil
currentState = .idle
}
}
setupObservers() 에는 3 가지의 옵저버를 생성했는데, 각 옵저버의 역할은 다음과 같다.
1. Time Observer (재생 시간 관찰)
- 역할:
- 0.5초마다 현재 재생 시간을 delegate에 전달
- SeekBar의 progressBar 업데이트에 사용
2. Status Observer (재생 준비 상태 관찰)
- 역할:
- AVPlayerItem의 status 프로퍼티 변화 감지
- 재생 가능 여부, 에러 발생 등을 파악
- 처리하는 상태:
- .readyToPlay: 재생 준비 완료 → PlaybackState.readyToPlay
- .failed: 재생 실패 → PlaybackState.failed(error), 에러 로그 출력
- .unknown: 로딩 중 → 로그만 출력
3. Buffer Observer (버퍼링 상태 관찰)
- 역할:
- loadedTimeRanges 변화 감지
- 버퍼링된 시간 계산 및 UI 업데이트
- 버퍼 충분 여부에 따라 자동으로 상태 전환
- 처리 로직:
- UI 업데이트 (bufferedBar 표시)
- loadedTimeRanges에서 첫 번째 timeRange 추출
- start + duration으로 총 버퍼링된 시간 계산
- delegate로 전달하여 SeekBar의 bufferedBar 업데이트
- 상태 전환 로직
- 버퍼 부족 (!isPlaybackLikelyToKeepUp) + 재생 중 → buffering
- 버퍼 충분 (isPlaybackLikelyToKeepUp) + buffering 상태 + rate != 0 → playing
지금까지 AVPlayer 설정 및 재생 제어 그리고 상태 관리까지 알아보았다. 해당 프로젝트를 수행하면서 .m3u8 마스터 플레이리스트 URL을 사용했는데, 이는 HLS(HTTP Live Streaming) 프로토콜을 따르는 비디오 세그먼트들을 저장하고 있는 파일로, 이와 관련된 내용은 다음 포스트에서 자세하게 다루도록 하겠다.
AVPlayer는 Apple의 HLS 프로토콜을 네이티브로 지원하는데, 개발자는 세그먼트 다운로드, 플레이리스트 파싱, 디코딩 등의 복잡한 처리를 전혀 신경 쓸 필요가 없다. 또한 AVPlayer 는 자동으로 ABR(Adaptive Bitrate Streaming) 를 제공하는데, 이는 AVPlayer의 가장 강력한 기능 중 하나이다. ABR 은 간단하게 정의하자면 사용자의 네트워크 대역폭을 실시간으로 측정하고, 동적으로 그에 맞는 화질의 세그먼트를 자동으로 선택하여 끊김 없는 비디오 재생을 보장하는 기술이다.
AVPlayer가 자동으로 처리하는 것들은 다음과 같다.
1. 네트워크 대역폭 측정
- 실시간으로 다운로드 속도 모니터링
- 사용 가능한 대역폭 자동 계산
2. 화질 자동 선택
- HLS 마스터 플레이리스트의 여러 variant 중 최적 화질 선택
- 네트워크 상태에 따라 동적으로 화질 전환
- 고화질 ↔ 저화질 seamless 전환
3. 버퍼 관리
- 끊김 없는 재생을 위한 버퍼 사이즈 자동 조정
- isPlaybackLikelyToKeepUp 프로퍼티로 버퍼 상태 제공
4. 네트워크 변화 대응
- WiFi ↔ 셀룰러 전환 시 자동 적응
- 네트워크가 느려지면 즉시 낮은 화질로 전환
- 네트워크가 개선되면 점진적으로 화질 향상
위를 확인하기 위해 Simulator 에 해당 프로젝트를 실행시키고, 별도의 Network Link Conditioner 를 설치하여 Wifi -> LTE -> 3G 순으로 확인해본 결과 해당 네트워크의 대역폭에 따라 화질이 낮아지는 것을 확인했다.
▲ 그림 5. Network Link Conditioner
다음 게시글에서는 HLS 와 ABR 에 대해 학습한 내용들을 다루도록 하겠다.
[Apple Docs]