[iOS] AVPlayer 의 Custom ResourceLoader

이상진·2025년 12월 14일

HLS, DASH 학습 기록

목록 보기
3/4
post-thumbnail

개요

AVPlayer 에서 자동으로 처리하는 ABR 기능을 직접 구현해보고 싶었다. 그러면 사용자의 네트워크 환경을 계속 관찰하고 있다가 기존에 사용하던 환경에서 변경이 되면, 그 당시 네트워크의 대역폭을 고려한 스트림을 다시 가져와서 Media Playlist 를 가져오고, 해당 파일 내에 그 다음으로 받아야 하는 비디오 세그먼트를 가져와야 된다고 생각했다.

그래서 지금까지의 현황은 모든 비디오 세그먼트들을 다운로드하고 하나의 파일로 만들어서 AVPlayer가 재생하는 것까지 구현되어 있으니, 그 다음으로는 비디오 세그먼트들을 하나씩 AVPlayer 에 넣고 실행시켜주는 방식으로 구현하는 것을 목표로 설정했다. (첫문단에서 언급한 ABR 을 사용하기 위해 비디오 세그먼트 하나씩 AVPlayer에 넣는 것이 전제 조건이라 생각했음)

하나의 main.mp4 를 바이트 범위에 따라 받아오는 로직까지는 모두 구현을 완료했으나 결국 AVPlayer 에서 실행이 되지 않고 CoreMediaErrorDomain 이 발생하여 재생이 되지 않는 문제에 직면하였다. 그래서 이번 포스트에서는 문제를 직면하기까지 일련의 과정들을 작성하고 그에 따른 시행착오와 회고를 남기려고 한다.


Recap

지난 게시글에서 언급한대로 Apple 에서 제공한 스트리밍 URL 의 미디어 플레이리스트는 다음과 같다.

#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-VERSION:7
#EXT-X-MEDIA-SEQUENCE:1
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-MAP:URI="main.mp4",BYTERANGE="719@0"
#EXTINF:6.00000,
#EXT-X-BYTERANGE:1508000@719
main.mp4
#EXTINF:6.00000,
#EXT-X-BYTERANGE:1510244@1508719
main.mp4
...
(생략)
#EXTINF:6.00000,
#EXT-X-BYTERANGE:1504803@148977509
main.mp4
#EXT-X-ENDLIST

해당 비디오의 메타데이터를 포함한 init_segment 와 6초씩 실행되는 100개의 segment 들이 존재한다. 즉, 1 개의 .mp4 파일을 바이트 범위에 따라 100 개의 세그먼트들을 받아와야 한다. 이러한 특징은 뒤에서 다시 서술할 예정이라 우선 비디오의 인풋이 어떤 형태인지 간단하게 언급만 하고 넘어가겠다.


AVAssetResourceLoader?

이전 포스트에서 다루었던 AVPlayer 에서 AVAssetResourceLoader를 사용하는 기본적인 흐름은 다음 사진과 같다. 이는 AVURLAsset 내부의 "리소스 로딩 시스템" 으로, URL 요청을 처리하는 내부 시스템이다. AVPlayer 에서 스트리밍이 담겨져 있는 URL 에 데이터를 받아올 때, AVAssetResourceLoaderURLSession 을 통하여 데이터를 받아오게 된다.

▲ 그림 1. 일반적인 AVPlayer 재생

출처: 네이버 DEVIEW 2021

이 때 다음과 같이 AVAssetResourceLoaderDelegate 를 추가하게 되면 URL이 httphttps 로 시작하는 경우 URLSession 으로 처리가 되는데, 커스텀 스킴일 경우(ex. custom-hls://, myapp://) 해당 Delegate 가 요청을 가로채서 처리하게 된다.

▲ 그림 2. 커스텀 로더를 이용한 AVPlayer 재생

출처: 네이버 DEVIEW 2021

그럼 여기서 왜 커스텀 스킴을 사용하는지 의문이 드는데, 사례를 들자면 다음과 같다.

사례 1: 커스텀 암호화

// ex. "secure://encrypted-video-456"
// 서버에서 암호화된 데이터 다운로드
→ 복호화 키 가져오기 → 복호화 → AVPlayer에 평문 데이터 전달

사례 2: P2P 스트리밍

// ex. "p2p://torrent-hash-789"
여러 피어에서 청크 다운로드 → 조합 → AVPlayer에 전달

사례 3: 오프라인 재생

// ex. "offline://downloaded-video-012"
→ 로컬 파일 시스템에서 읽기 → AVPlayer에 전달

사례 4: 분석 & 통계

모든 요청을 가로채서 → 다운로드 속도 측정 → 얼마나 봤는지 추적 → 서버에 통계 전송
→ 데이터 그대로 AVPlayer에 전달

DelegateAVPlayer 와 실제 데이터 소스 사이의 중개자 역할로, http/https 스킴을 사용하지 않은 커스텀 스킴일 경우 Delegate 를 호출하기 위해 AVAssetResourceLoader 를 사용해야 한다. 본 포스트에서는 위에서 언급한 미디어 플레이리스트의 비디오 세그먼트를 읽어올 때

(기존)
https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/v5/main.mp4

(변경)
custom-hls://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/v5/main.mp4

기존 URL 의 스킴을 https:// -> custom-hls:// 로 변경하여 해당 URL은 Delegate 에서 처리하게 한 다음, 해당 Delegate 에서는 100 개의 비디오 세그먼트를 AVPlayer 가 자동으로 받아오는 것이 아닌, 수동으로 받아오게 하려고 한다.

이러한 이유는 기존 프로젝트에서는 AVPlayer 에 마스터 플레이리스트 URL 만 넣어주면 자동으로 모두 처리되었던 거를 직접 수동으로 구현해보면서 AVPlayer 의 역할에 대해 학습하기 위함이다.


시행착오

1. HLSDownloadManager

기존의 HLSDownloadManager 에서 두 함수가 추가되었다. 미디어 플레이리스트 파일 안에 있는 init segment 를 다운로드 하는 로직과 나머지 segment 들을 받아오는 로직 두 개를 추가했다.

/// 초기화 세그먼트 다운로드 (fMP4 헤더)
func downloadInitializationSegment() async throws -> Data? {
    guard let mediaPlaylist = currentMediaPlaylist,
          let initSegment = mediaPlaylist.initializationSegment else {
        return nil
    }

    guard let segmentURL = mediaPlaylist.absoluteURL(for: initSegment.uri) else {
        throw DownloadError.invalidURL
    }

    if let byteRange = initSegment.byteRange {
        return try await downloadSegment(url: segmentURL, byteRange: byteRange)
    } else {
        return try await downloadSegment(url: segmentURL)
    }
}

/// 특정 세그먼트 다운로드
func downloadSegment(at index: Int) async throws -> Data {
    guard let mediaPlaylist = currentMediaPlaylist else {
        throw DownloadError.noAvailableStream
    }

    guard let segment = mediaPlaylist.segment(at: index) else {
        throw DownloadError.invalidURL
    }

    guard let segmentURL = mediaPlaylist.absoluteURL(for: segment.uri) else {
        throw DownloadError.invalidURL
    }

    if let byteRange = segment.byteRange {
        return try await downloadSegment(url: segmentURL, byteRange: byteRange)
    } else {
        return try await downloadSegment(url: segmentURL)
    }
}

2. HLSResourceLoaderDelegate

Property

class HLSResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate {

    // MARK: - Properties

    private let downloadManager: HLSDownloadManager
    private let customScheme = "custom-hls"

    // 세그먼트 캐시 (이미 다운로드한 세그먼트 저장)
    private var segmentCache: [Int: Data] = [:]
    private var initSegmentData: Data?

    // 현재 Media Playlist
    private var mediaPlaylist: HLSMediaPlaylist?
    private var mediaPlaylistContent: String?

Private Method

  • handleLoadingRequest(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL)

AVPlayer 의 모든 리소스 요청을 받는 진입점이다. AVPlayer 가 custom-hls:// URL 을 요청하면 http/https 스키마가 아니므로 Delegate 에서 처리하도록 넘어오면서 해당 함수가 가장 먼저 호출된다. ContentInfo 와 Data 요청을 각각의 핸들러로 분기 처리하였다.

private func handleLoadingRequest(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL) async {
    do {
        // 1. Content Information Request 처리
        if let contentRequest = loadingRequest.contentInformationRequest {
            try await handleContentInfoRequest(contentRequest, url: url)
        }

        // 2. Data Request 처리
        if let dataRequest = loadingRequest.dataRequest {
            try await handleDataRequest(dataRequest, url: url)
        }

        loadingRequest.finishLoading()

    } catch {
        print("Request failed: \(error.localizedDescription)")
        loadingRequest.finishLoading(with: error)
    }
}
  • handleContentInfoRequest( _ contentRequest: AVAssetResourceLoadingContentInformationRequest, url: URL )
    파일의 메타데이터 정보를 제공한다. Content-TypeContent-Length(파일 전체 크기), Byte-Range 지원 여부를 설정한다.
private func handleContentInfoRequest(
    _ contentRequest: AVAssetResourceLoadingContentInformationRequest,
    url: URL
) async throws {
    guard let mediaPlaylist = mediaPlaylist else {
        throw NSError(domain: "HLSResourceLoader", code: -1)
    }

    let fileName = url.lastPathComponent

    // 플레이리스트 파일인 경우
    if fileName.hasSuffix(".m3u8") {
        contentRequest.contentType = "application/x-mpegURL"
        contentRequest.isByteRangeAccessSupported = false
        if let playlistContent = mediaPlaylistContent {
            contentRequest.contentLength = Int64(playlistContent.utf8.count)
        }
    } else {
        // fMP4 세그먼트인 경우 - 전체 파일 크기 계산
        contentRequest.contentType = "video/mp4"
        contentRequest.isByteRangeAccessSupported = true

        // 전체 파일 크기 = 초기화 세그먼트 + 모든 미디어 세그먼트
        var totalLength: Int64 = 0

        if let initSegment = mediaPlaylist.initializationSegment,
           let byteRange = initSegment.byteRange {
            totalLength += Int64(byteRange.offset + byteRange.length)
        }

        for segment in mediaPlaylist.segments {
            if let byteRange = segment.byteRange {
                let segmentEnd = byteRange.offset + byteRange.length
                totalLength = max(totalLength, Int64(segmentEnd))
            }
        }

        contentRequest.contentLength = totalLength
    }
}
  • handleDataRequest( _ dataRequest: AVAssetResourceLoadingDataRequest, url: URL )
    실제 데이터를 제공하는 부분이다. AVPlayer 가 요청한 바이트 범위(offset, length) 의 데이터를 반환하고, custom-hls:// 커스텀 스킴을 https:// 로 복원 후 다운로드하여 데이터를 제공한다.
private func handleDataRequest(
    _ dataRequest: AVAssetResourceLoadingDataRequest,
    url: URL
) async throws {
    // Custom scheme을 원래 scheme으로 복원
    let originalURL = restoreOriginalURL(url)

    // 요청된 바이트 범위로 세그먼트 데이터 가져오기
    let requestedOffset = Int(dataRequest.requestedOffset)
    let requestedLength = dataRequest.requestedLength
    let currentOffset = Int(dataRequest.currentOffset)

    // 바이트 범위로 어떤 세그먼트를 요청하는지 파악하고 세그먼트의 ByteRange offset 가져오기
    let (data, segmentByteRangeOffset) = try await fetchSegmentDataByRange(for: originalURL, offset: requestedOffset, length: requestedLength)

    // 세그먼트 데이터에서의 시작 위치 계산
    // data[0]은 파일의 segmentByteRangeOffset 위치에 해당
    // currentOffset부터 제공해야 하므로: currentOffset - segmentByteRangeOffset
    let dataStartOffset = currentOffset - segmentByteRangeOffset

    guard dataStartOffset >= 0 && dataStartOffset < data.count else {
        throw NSError(domain: "HLSResourceLoader", code: -4, userInfo: [
            NSLocalizedDescriptionKey: "Data offset out of bounds"
        ])
    }

    // 제공해야 할 데이터 길이 계산
    // requestedOffset부터 requestedLength만큼 요청했지만, currentOffset부터 제공
    let requestedEndOffset = requestedOffset + requestedLength
    let remainingLength = requestedEndOffset - currentOffset
    let endOffset = min(dataStartOffset + remainingLength, data.count)

    let subdata = data.subdata(in: dataStartOffset..<endOffset)
    dataRequest.respond(with: subdata)
}
  • fetchSegmentDataByRange(for url: URL, offset: Int, length: Int)
/// 바이트 범위로 세그먼트 데이터 가져오기
/// - Returns: (세그먼트 데이터, 세그먼트의 ByteRange offset)
private func fetchSegmentDataByRange(for url: URL, offset: Int, length: Int) async throws -> (Data, Int) {
    guard let mediaPlaylist = mediaPlaylist else {
        throw NSError(domain: "HLSResourceLoader", code: -1, userInfo: [
            NSLocalizedDescriptionKey: "Media playlist not set"
        ])
    }

    let fileName = url.lastPathComponent

    // 플레이리스트 파일 자체 요청 (.m3u8)
    if fileName.hasSuffix(".m3u8") {
        guard let playlistContent = mediaPlaylistContent else {
            throw NSError(domain: "HLSResourceLoader", code: -1, userInfo: [
                NSLocalizedDescriptionKey: "Media playlist content not set"
            ])
        }
        return (playlistContent.data(using: .utf8) ?? Data(), 0)
    }

    // 초기화 세그먼트 확인 (offset 0부터 시작)
    if let initSegment = mediaPlaylist.initializationSegment,
       fileName == initSegment.uri,
       let byteRange = initSegment.byteRange,
       offset >= byteRange.offset && offset < byteRange.offset + byteRange.length {

        if let cachedData = initSegmentData {
            return (cachedData, byteRange.offset)
        }

        let data = try await downloadManager.downloadInitializationSegment()
        guard let data = data else {
            throw NSError(domain: "HLSResourceLoader", code: -2)
        }
        initSegmentData = data
        return (data, byteRange.offset)
    }

    // 미디어 세그먼트 찾기 (바이트 범위로 매칭)
    for segment in mediaPlaylist.segments {
        if segment.uri == fileName,
           let byteRange = segment.byteRange,
           offset >= byteRange.offset && offset < byteRange.offset + byteRange.length {

            // 캐시 확인
            if let cachedData = segmentCache[segment.index] {
                return (cachedData, byteRange.offset)
            }

            // 다운로드
            let data = try await downloadManager.downloadSegment(at: segment.index)
            segmentCache[segment.index] = data
            return (data, byteRange.offset)
        }
    }

    throw NSError(domain: "HLSResourceLoader", code: -3, userInfo: [
        NSLocalizedDescriptionKey: "Segment not found for offset: \(offset)"
    ])
}

바이트 범위로 어떤 세그먼트인지 파악하고 다운로드하는 로직이다. Media Playlist의 Byte-Range 정보를 보고 해당 세그먼트를 찾는다. HLSDownloadManager 를 통해 실제 HTTP Range 요청으로 다운로드 한다.

  • restoreOriginalURL(_ url: URL)
    custom-hls:// -> https:// 로 복원
private func restoreOriginalURL(_ url: URL) -> URL {
    var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
    components?.scheme = "https"
    return components?.url ?? url
}
  • setMediaPlaylist(_ playlist: HLSMediaPlaylist, content: String)

Media Playlist 안에 있는 모든 비디오 세그먼트들의 main.mp4 파일 상대주소를 커스텀 스킴인 custom-hls:// 가 적용된 파일의 절대 주소로 모두 변경한다. 즉 custom-hls://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/v5/main.mp4 이런식으로 Media Playlist 파일 내 모든 상대 주소를 이렇게 변경한다.

/// Media Playlist 안 Video Segment URL 상대 주소를
/// 커스텀 스킴으로 적용된 절대 주소로 변경
func setMediaPlaylist(_ playlist: HLSMediaPlaylist, content: String) {
    self.mediaPlaylist = playlist

    // main.mp4를 custom-hls URL로 변경하여 우리가 직접 처리하도록 함
    var modifiedContent = content

    // baseURL에서 디렉토리 경로 추출
    let baseURL = playlist.baseURL.absoluteString.components(separatedBy: "/").dropLast().joined(separator: "/")

    if !baseURL.isEmpty {
        // https://...v5/main.mp4 -> custom-hls://...v5/main.mp4
        let httpsURL = "\(baseURL)/main.mp4"
        let customURL = httpsURL.replacingOccurrences(of: "https://", with: "custom-hls://")

        // 1. #EXT-X-MAP의 URI="main.mp4" -> URI="custom-hls://..."
        modifiedContent = modifiedContent.replacingOccurrences(
            of: "URI=\"main.mp4\"",
            with: "URI=\"\(customURL)\""
        )

        // 2. 세그먼트 URI main.mp4 -> custom-hls://...
        let lines = modifiedContent.components(separatedBy: .newlines)
        var modifiedLines: [String] = []

        for (index, line) in lines.enumerated() {
            if line == "main.mp4" {
                // 이전 줄이 #EXTINF: 또는 #EXT-X-BYTERANGE인 경우
                if index > 0 && (lines[index - 1].hasPrefix("#EXTINF:") || lines[index - 1].hasPrefix("#EXT-X-BYTERANGE:")) {
                    modifiedLines.append(customURL)
                } else {
                    modifiedLines.append(line)
                }
            } else {
                modifiedLines.append(line)
            }
        }

        modifiedContent = modifiedLines.joined(separator: "\n")
    }

    self.mediaPlaylistContent = modifiedContent
}
  • resourceLoader()
    AVPlayer의 리소스 요청 감지한다. AVPlayercustom-hls://을 요청할 때 자동 호출되고 모든 리소스 로딩의 진입점이다. true 를 반환 시 해당 Delegate 가 담당하여 처리하게 된다.
func resourceLoader(
    _ resourceLoader: AVAssetResourceLoader,
    shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
) -> Bool {
    guard let url = loadingRequest.request.url else {
        loadingRequest.finishLoading(with: NSError(domain: "HLSResourceLoader", code: -1))
        return false
    }

    // 비동기 처리
    Task {
        await handleLoadingRequest(loadingRequest, url: url)
    }

    return true
}

3. StreamPlayerManager

HLS 스트림을 수동으로 로드하고 AVPlayerCustom ResourceLoader 를 설정하였다. 우선 처음에 Master Playlist 를 https:// 로 직접 다운로드 및 파싱을 한 뒤, 사용할 스트림으로는 매직 넘버를 넣어 3Mbps 로 설정했고, Media Playlist 역시 위에서 스트림 대역폭을 선언한 것을 기반으로 로드하였다. setMediaPlaylist() 를 호출하면서 playlist content 의 세그먼트 URI 를 모두 커스텀 스킴으로 변경한 뒤 미디어 플레이리스트의 URL 마찬가지로 커스텀 스킴으로 변경한다. 이후 AVURLAssetResourceLoader 를 연결하여 플레이어를 설정하면 된다.

▲ 그림 3. loadCustomHLS() Flow

/// Custom HLS 스트림 로드 (AVAssetResourceLoader 방식)
func loadCustomHLS(masterURL: URL, bandwidth: Int = 3_000_000) async throws {
    cleanup()
    currentState = .loading

    // 1. HLSDownloadManager 생성
    let manager = HLSDownloadManager()
    self.downloadManager = manager

    // 2. Master Playlist 로드
    let masterPlaylist = try await manager.loadMasterPlaylist(from: masterURL)

    // 3. 스트림 선택
    let stream = try await manager.selectStream(bandwidth: bandwidth)

    // 4. Media Playlist 로드
    let (mediaPlaylist, playlistContent) = try await manager.loadMediaPlaylist(for: stream)

    // 5. ResourceLoaderDelegate 생성 및 설정
    let delegate = HLSResourceLoaderDelegate(downloadManager: manager)
    delegate.setMediaPlaylist(mediaPlaylist, content: playlistContent)
    self.resourceLoaderDelegate = delegate

    // 6. Media Playlist URL을 custom scheme으로 변환
    guard let mediaPlaylistURL = masterPlaylist.absoluteURL(for: stream.uri) else {
        throw NSError(domain: "StreamPlayerManager", code: -1, userInfo: [
            NSLocalizedDescriptionKey: "Failed to get media playlist URL"
        ])
    }

    var urlComponents = URLComponents(url: mediaPlaylistURL, resolvingAgainstBaseURL: false)
    urlComponents?.scheme = "custom-hls"

    guard let customURL = urlComponents?.url else {
        throw NSError(domain: "StreamPlayerManager", code: -2, userInfo: [
            NSLocalizedDescriptionKey: "Failed to convert media playlist URL to custom scheme"
        ])
    }

    // 7. AVURLAsset 생성 및 ResourceLoader 설정
    let asset = AVURLAsset(url: customURL)
    asset.resourceLoader.setDelegate(
        delegate,
        queue: DispatchQueue(label: "com.hlsplayer.resourceloader")
    )

    // 8. AVPlayerItem 및 AVPlayer 생성
    playerItem = AVPlayerItem(asset: asset)
    player = AVPlayer(playerItem: playerItem)

    // ABR 최적화 설정
    if let player = player {
        player.automaticallyWaitsToMinimizeStalling = true
    }

    setupObservers()
}

4. 총정리

위의 프로세스들을 정리하면 다음과 같다. Media Playlist URL -> Init Segment -> Video Segment 순으로 커스텀 스킴을 사용하는 URL일 경우 모두 Delegate 를 거치게 되고, AVPlayerhttp/https 만 처리가 가능하므로 커스텀 스킴을 다시 https:// 로 복원하여 AVPlayerItem 을 설정하였다.

▲ 그림 4. Delegate 호출 프로세스


에러 발생

비디오 플레이러를 실행한 결과 stateObserver 에서 .failed 로 처리되어 CoreMediaErrorDomain 의 에러가 발생하였다.

ErrorComment: custom url not redirect
ErrorDomain: CoreMediaErrorDomain
ErrorCode: -12881
URI: custom-hls://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/v5/main.mp4

에러가 발생한 원인은 다음과 같다.

1. Media Playlist 파싱
             ↓
2. "custom-hls://.../main.mp4" 발견
             ↓
3. AVPlayer 내부 검증:
   - 파일 확장자: .mp4 → 미디어 파일임
   - URL scheme: custom-hls://
             ↓
4. AVPlayer의 정책:
   - mp4 같은 미디어 파일의 scheme은 http/https
   - 또는 redirect로 http/https로 전환되어야 함
   - Range 요청이 HTTP 표준을 따라야 함
             ↓
5. AVPlayer에서 custom scheme으로 된 mp4를 처리 불가
             ↓
6. Error: "custom url not redirect"

custom-hls:// 로 접근한 리소스를 AVPlayer가 “MP4 파일”로 인식했고 Delegate로 넘어가면서 https:// 형태로 변경해서 데이터를 정상 제공했지만, 이미 AVPlayer에서 Delegate로 넘기기 전에 URL을 보고 MP4임을 인지했으므로 이는 HTTP 기반 redirect 가능한 URL이어야 하는데 custom scheme이라서 즉시 실패한 것이다.


약간의 꼼수 그리고 역시나 실패

setMediaPlaylist() 호출 후 수정된 미디어 플레이리스트를 통해 AVPlayer가 각 세그먼트를 독립적인 HLS media segment(.m4s)로 인식하도록 유도하고, 실제 데이터는 AVAssetResourceLoaderDelegate에서 byte-range 기반으로 직접 공급하는 구조를 기대했다. 즉, 플레이리스트 수준에서는 다중 세그먼트 HLS처럼 보이게 만들고, 내부 구현에서는 하나의 main.mp4 파일을 분할해 제공하는 방식으로 우회하려 했다.

func setMediaPlaylist(_ playlist: HLSMediaPlaylist, content: String) {
    var lines = content.components(separatedBy: .newlines)
    var output: [String] = []
    var segmentIndex = 0

    for line in lines {
        if line.contains("#EXT-X-MAP") {
            output.append(line)
            continue
        } else if line == "main.mp4" {
            output.append("custom-hls://seg-\(segmentIndex).m4s")
            segmentIndex += 1
        } else {
            output.append(line)
        }
    }

    self.mediaPlaylistContent = output.joined(separator: "\n")
}

func handleContentInfoRequest(...) {
	// 수정 필요
    ...
}


func handleDataRequest(...) {
	// 수정 필요
    ...
}

그러나 이 접근은 근본적으로 동작하지 않았다. 해당 스트림은 하나의 main.mp4 파일을 HTTP Range 요청으로 분할해 사용하는 single-file fMP4 HLS이다. 이 유형의 HLS 스트림은 미디어 플레이리스트를 파싱하는 초기 단계에서 이미 AVPlayer 내부적으로 single-file fMP4 HLS로 분류되며, 이 판단은 AVAssetResourceLoaderDelegate가 개입하기 이전에 완료된다. 따라서 미디어 플레이리스트의 URI를 수정하거나 확장자를 .m4s로 변경하더라도, AVPlayer는 이를 논리적인 HLS 세그먼트로 재해석하지 않는다. 결국 커스텀 스킴을 통한 리소스 로딩이나 세그먼트 단위 제어는 구조적으로 허용되지 않았고, 재생 과정은 CoreMediaErrorDomain -3 오류로 종료되었다.


그럼 다른 방법은?

  1. AVPlayer 가 리다이렉트를 통해 원본 서버에서 직접 비디오 세그먼트를 받아오도록 처리
    • 기존 비디오 세그먼트를 바이트 범위로 받아오는 로직 필요 없음 -> AVPlayer 가 알아서 처리하기 때문
  2. 커스텀 플레이어 제작
    • 기존 비디오 세그먼트를 바이트 범위로 받아오는 로직 그대로 사용 가능
    • AVPlayer 와 호환이 되지 않기 때문에, 비디오 프레임을 화면에 렌더링 하기 위한 레이어부터 비디오와 오디오 동기화, 개별 비디오/오디오 데이터 파싱 등 여러 컴포넌트들을 직접 구현해야 한다.

다음 두 가지 방법 중에서 1번 방법에 대한 코드는 다음과 같다. 크게 변경된 부분은 handleLoadingRequest() 이 부분에 리다이렉트 하는 로직을 넣은 것인데,

  • handleLoadingRequest(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL)
/// 로딩 요청 처리 - 302 리다이렉트로 AVPlayer가 직접 처리하도록 함
    private func handleLoadingRequest(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL) {
        let fileName = url.lastPathComponent

        // 플레이리스트 파일인 경우 직접 제공
        if fileName.hasSuffix(".m3u8") {
            if let playlistContent = mediaPlaylistContent,
               let data = playlistContent.data(using: .utf8) {

                if let contentRequest = loadingRequest.contentInformationRequest {
                    contentRequest.contentType = "application/x-mpegURL"
                    contentRequest.isByteRangeAccessSupported = false
                    contentRequest.contentLength = Int64(data.count)
                }

                if let dataRequest = loadingRequest.dataRequest {
                    dataRequest.respond(with: data)
                }

                loadingRequest.finishLoading()
            } else {
                loadingRequest.finishLoading(with: NSError(domain: "HLSResourceLoader", code: -1))
            }
        } else {
            // 비디오 세그먼트인 경우 원본 URL로 302 리다이렉트
            let originalURL = restoreOriginalURL(url)

            loadingRequest.redirect = URLRequest(url: originalURL)
            loadingRequest.response = HTTPURLResponse(
                url: originalURL,
                statusCode: 302,
                httpVersion: "HTTP/1.1",
                headerFields: ["Location": originalURL.absoluteString]
            )

            loadingRequest.finishLoading()
        }
    }

이를 실행시켜보면 재생이 잘 되는 것을 확인할 수 있다. 다만 이 경우 기존의 바이트 범위로 직접 세그먼트를 받아와서 AVPlayer 에 응답을 했었던 모든 로직들은 사용할 수 없기 때문에, 이렇게 되면 결국 어차피 AVPlayer 가 미디어 플레이리스트를 알아서 처리할텐데 굳이 Custom scheme 을 적용할 필요가 있나? 싶다.(다시 [iOS] AVFoundation, AVKit 이해 그리고 AVPlayer 다뤄보기 태초마을로 돌아가는게 아닌가..)

▲ 그림 4. 리다이렉트 방법 적용

지금까지 Custom scheme을 적용하고 CoreMediaErrorDomain 오류가 발생했던 코드는 다음 링크에서 확인할 수 있다.

그래도 이번 포스트를 통해 확실하게 배운 것은

  1. AVPlayer 는 http / https 스킴만 처리 가능
  2. Custom scheme 을 이용한 AVAssetResourceLoaderDelegate 처리

이 두가지를 알게 되었다!


Reference

[Apple Docs]

[Naver Deview]

profile
모바일 개발에 관하여 이것, 저것 다 합니다.

0개의 댓글