👋 들어가기 전
'드디어 이번 프로젝트에서 가장 심혈을 기울인 부분을 포스팅할 때가 됐다.
우리 Shook팀은 모바일 게임 스트리밍 서비스로 크게 방송 송출 / 방송 시청 파트로 나뉜다.
평소에 스트리밍 방송을 자주 신청하는 나로써.. 어느 하나 포기하고 싶지 않지만 현실적으로
3주 안에 해당 기능 개발에 모두 참가할 수는 없을 것 같아서 방송 시청쪽에서 사용자와 가장 밀접한
플레이어를 선택했다.
⚙️ 빠르게 플레이어 만들기
먼저 재생할 resource가 있다고 가정하고 바로 재생할 수 있게 먼저 세팅을 해보자.
애플에서는 비디오를 재생할 수 있게 크게 2가지 방법을 제공해주는데
첫번 째는 AVKit, 두번째는 AVPlayerLayer와 함께 AVPlayer를 사용하는 방법이다.
간단히 정리하면 AVKit은 애플에서 빠르게 사용할 수 있게 미리 만들어 놓은 Player이고
AVPlayerLayer + AVPlayer는 보통 커스텀 플레이어를 만들 때 사용한다.
자세한 내용은 아래 포스팅을 확인해보자.
여기서 우리는 당연히 커스텀 플레이러를 구현 해야하기 때문에 후자를 선택한다.
여기서 각 요소들이 플레이어를 구현하는데 어떤 기능을 제공하는 지 알아보자.
AVPlayerItem
AVPlayer가 사용하는 객체로 재생하는 동안 변하는 다양한 상태값들을 제공해준다.
사용할 Observer keyPath
- 재생 가능여부 (resource값을 잘 불러왔는 지)
- 전체 재생 길이 또는 Seek 가능 범위
- 버퍼링 상태
private enum BufferStateConstants: String {
case playbackBufferEmpty
case playbackLikelyToKeepUp
case playbackBufferFull
}
private func addObserverPlayerItem() {
playerItem?.addObserver(self,
forKeyPath: #keyPath(AVPlayerItem.status),
options: [.old, .new],
context: nil) // 동일한 객체를 여러 키 경로에서 관찰할 때 구분하기 위한 식별자
playerItem?.addObserver(self, forKeyPath: BufferStateConstants.playbackBufferEmpty.rawValue, options: .new, context: nil)
playerItem?.addObserver(self, forKeyPath: BufferStateConstants.playbackLikelyToKeepUp.rawValue, options: .new, context: nil)
playerItem?.addObserver(self, forKeyPath: BufferStateConstants.playbackBufferFull.rawValue, options: .new, context: nil)
}
AVPlayer
콘텐츠를 직접적으로 재생하고 제어하는 인터페이스
사용할 Observer keyPath
- 재생 상태
- 현재 재생 시간
private func addObserverPlayer() {
player.addObserver(self, forKeyPath: "timeControlStatus", context: nil)
let interval = CMTimeMakeWithSeconds(1, preferredTimescale: Int32(NSEC_PER_SEC))
timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] cmtTime in
guard let self else { return }
let floatSecond = CMTimeGetSeconds(cmtTime)
playerControlView.timeControlView.updateSlider(to: Float(floatSecond))
}
}
위에서 keypath를 통해 관찰할 값을 사용할 때는 2가지 방법이 있다.
첫번 째 방법은 아래와 같이 observe에 keypath를 넣어 제공되는 핸들러에서 처리하는 방법
현재는 이 방법을 추천하고 있다.
두번 째 방법은 addObserver로 값을 등록한 후 observeValue 함수 내에서 처리하는 방식이다.
원론적인 방법이라 이번 프로젝트에서는 두번 째 방법을 사용했다.
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
guard let keyPath = keyPath else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
return
}
if let playerItem = object as? AVPlayerItem {
handlePlayItemStatus(playerItem.status)
hanldePlayItemBufferString(keyPath)
} else if let player = object as? AVPlayer {
handlePlayerTimeControlStatus(player.timeControlStatus)
}
}
AVPlayerLayer
AVPlayer가 재생하는 내용을 단순히 시각적으로 제공해주는 인터페이스, 다시 말하자면 제어는 AVPlayer가 한다.
렌더링을 담당하고 gravity를 설정할 수 있다.
private lazy var playerLayer: AVPlayerLayer = {
let layer = AVPlayerLayer(player: player)
layer.videoGravity = .resizeAspectFill
return layer
}()
한번 결과 화면을 봐보자.
영상이 굉장히 잘 재생되고 있다. 애플이 정말 사용하기 쉽게 api를 만들어 놓은 것 같다.
영상 관련 처리가 생각보다 많이 두려웠는데 하고보니 별거 아니였다.
역시 이 부분을 자원해서 담당하길 잘한 것 같다.
▶️ HLS 프로토콜 경험하기
Shook 팀원은 수신측에서 사용하는 HLS와 송신측에서 사용하는 RTMP까지 이론을 다 같이 정리하며 학습했다.
라이브 스트리밍은 아래 과정으로 진행되는데 NCP에서 제공해주는 Live Station이 빨간 영역을 담당한다.
🛜 Live Station
여기서 내가 만든 Player는 HLS 규격으로 받게 되는데 우리가 원하는 이벤트 제어를 HLS에서 어떻게 처리하는 지 알아보자.
네이버 Live Station 콘솔 화면을 보면 HLS의 playlist 형태 중 하나인 .m3u8을 제공해주는 것을 볼 수 있다.
m3u8 파일을 한번 분석해보자.
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=5192000,CODECS="avc1.4D4028,mp4a.40.2",RESOLUTION=1920x1080
chunklist_1080p-16-9.m3u8
#EXTM3U
- 해당 플레이리스트가 .m3u8 파일을 사용하는 것을 의미한다.
#EXT-X-VERSION:3
- HLS 플레이리스트 버전을 나타낸다.
- 버전 3은 플레이리스트에서 사용하는 기능(예: 타임스탬프 정밀도, 지원되는 태그 등)을 정의한다.
#EXT-X-STREAM-INF
- 이 태그는 여러 스트림 중 하나에 대한 정보를 제공한다.
- BANDWIDTH=5192000: 스트림의 대역폭 요구사항(비트레이트)을 나타낸다.
- CODECS="avc1.4D4028,mp4a.40.2": 스트림에 사용된 코덱을 나타낸다.
- avc1.4D4028: H.264 비디오 코덱
- mp4a.40.2: AAC 오디오 코텍
- RESOLUTION=1920x1080: 비디오 스트림의 해상도는 1920x1080 (Full HD)이다.
chunklist_1080p-16-9.m3u8
- 이 스트림의 세부 정보를 포함하는 또 다른 .m3u8 파일이다
- 여기에는 실제 비디오 세그먼트 파일들이 나열된다.
chunklist도 한번 살펴보자.
개발자 도구의 네트워크 탭을 이용하면 확인할 수 있다
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-ALLOW-CACHE:NO
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:16
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-DATERANGE:ID="nmss-daterange",START-DATE="2024-12-01T09:32:52.546Z"
#EXT-X-PROGRAM-DATE-TIME:2024-12-01T09:33:24.589Z
#EXTINF:2.000000,
1080p-16-9_3892105036_1733045604589_32_0_16.ts?bitrate=438040&filetype=.ts
#EXTINF:2.000000,
1080p-16-9_1974383572_1733045606589_34_0_17.ts?bitrate=370642&filetype=.ts
#EXTINF:2.000000,
1080p-16-9_297687620_1733045608589_36_0_18.ts?bitrate=431930&filetype=.ts
#EXT-X-ALLOW-CACHE
- 클라이언트가 로컬 캐시를 사용할 수 없음
- 항상 서버에서 가져와야 한다
#EXT-X-TARGETDURATION
- 각 세그먼트의 최대 지속 시간을 초 단위로 정의한 값
- 버퍼링을 최소화하기 위해 플레이어가 예상해야 할 최대 세그먼트 길이
#EXT-X-MEDIA-SEQUENCE
- 현재 플레이리스트의 첫 번째 세그먼트의 순서를 정의
- 세그먼트의 일련번호를 나타내어 스트림의 연속성 보장
#EXT-X-DISCONTINUITY-SEQUENCE
- 스트림에서 불연속 구간(코덱 변경 및 광고 삽입 등)의 순서를 나타낸다.
- 0일 경우 불연속 구간이 없다는 뜻
#EXT-X-DATERANGE
- 특정 시간 범위를 정의하는 태그
- ID: 시간 범위 고유 식별자
- START-DATE: 시간 범위 시작 지점
#EXT-X-PROGRAM-DATE-TIME
- 플레이리스트의 특정 세그먼트가 미디어의 실시간 시간을 기준으로 시작되는 지점을 나타냄
#EXTINF
- 각 세그먼트의 지속시간과 URI 정보를 제고
- #EXTINF: <duration>,<URI>
- URI 해석 1080p-16-9_3892105036_1733045604589_32_0_16.ts?bitrate=438040&filetype=.ts
- 1080p-16-9_3892105036_1733045604589_32_0_16.ts(파일명)
- ?bitrate=438040(세그먼트 대역폭)
- filetype=.ts(세그먼트 파일 형식)
🎄SOOP playlist 파일 해석
이번에는 SOOP (전 아프리카 TV)의 playlist 파일을 분석해보자.
어라 ?? 라이브 스테이션으로 분석 했던 playlist 형식이랑 많이 다르다 ..?
PLAYLIST-TYPE을 보면 VOD로 오고 있다... 라이브 스트리밍인데 VOD ?? 이 플레이리스트는
바로 방송 시청 처음에 나오는 광고에 관한 플레이리스트다.
그렇다면 라이브 스트리밍에 대한 플레이리스트는 왜 안올까 ??
일단 다른 서비스도 살펴보자.
⚡️ 치지직 playlist 파일 해석
#EXTM3U
#EXT-X-VERSION:10
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-TARGETDURATION:10
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=3.003000
#EXT-X-PART-INF:PART-TARGET=1.000000
#EXT-X-MEDIA-SEQUENCE:2202
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-DATERANGE:ID="nmss-daterange",START-DATE="2024-12-01T08:56:17.834Z"
#EXT-X-MAP:URI="720p_0_0_0.m4s?type=hls&filetype=.m4s"
... #EXTINF 반복 ..
#EXTINF:2.950000,
720p_2667666833_1733049911717_6533_0_2212.m4v?type=hls&bitrate=420688&filetype=.m4v
#EXT-X-PART:DURATION=1.000000,URI="720p_2101915796_1733049914667_6536_0_2213_0.m4v?type=hls&filetype=.m4v",INDEPENDENT=YES
#EXT-X-PART:DURATION=1.000000,URI="720p_2101915796_1733049914667_6536_0_2213_1.m4v?type=hls&filetype=.m4v",INDEPENDENT=YES
#EXT-X-PRELOAD-HINT:TYPE=PART,URI="720p_2101915796_1733049914667_6536_0_2213_2.m4v?type=hls&filetype=.m4v"
#EXT-X-RENDITION-REPORT:URI="chunklist_480p.m3u8",LAST-MSN=2213,LAST-PART=1
#EXT-X-RENDITION-REPORT:URI="chunklist_360p.m3u8",LAST-MSN=2213,LAST-PART=1
#EXT-X-RENDITION-REPORT:URI="chunklist_144p.m3u8",LAST-MSN=2213,LAST-PART=1
#EXT-X-RENDITION-REPORT:URI="chunklist_1080p.m3u8",LAST-MSN=2213,LAST-PART=1
#EXT-X-INDEPENDENT-SEGMENTS
- 모든 세그먼트가 독립적으로 디코딩 가능함을 나타낸다.
#EXT-X-SERVER-CONTROL
- CAN-BLOCK-RELOAD=YES: 재생 목록을 갱신할 때 블로킹 가능.
- PART-HOLD-BACK=3.003000: Low Latency HLS 설정으로 서버가 클라이언트 재생 지연을 최소화하려 함
#EXT-X-PART-INF
- PART-TARGET=1.000000: 파트 세그먼트의 기본 길이가 1초.
#EXT-X-MAP
- 초기화 세그먼트 파일로, 각 미디어 세그먼트를 디코딩하기 위해 필요
- URI="720p_0_0_0.m4s?type=hls&filetype=.m4s": 초기화 파일 경로.
#EXT-X-RENDITION-REPORT
- 다른 품질의 재생 목록(렌디션) 상태를 보고
- URI="chunklist_480p.m3u8", chunklist_360p.m3u8, 다양한 해상도의 재생 목록
- LAST-MSN=2213: 마지막 시퀀스 번호
- LAST-PART=1: 마지막 파트 번호
라이브 스테이션보다 내용이 복잡하긴 하지만 전체적인 구조는 비슷한 것 같다.
😡 왜? SOOP은 playlist 파일이 안보이지?
네이버 치지직과 SOOP의 차이가 뭘까 ???
바로 그리디 컴퓨팅 적용 여부다.
SOOP은 고화질로 시청하기 위해 별도의 프로그램을 설치하는데 바로 이게 그리디 컴퓨팅을 쓰기 위한 프로그램이다.
그리디 컴퓨팅은 간단히 얘기하면 모든 컴퓨터들이 서로 연결되어 CPU(Central Processing Unit),
저장 공간, 데이터 등의 모든 가용 자원들을 공유하는 개념으로 시청자가 또 다른 송신지가 될 수 있다.
그리드 컴퓨팅은 playlist를 받는 형태가 아니라 별도의 재생 소스로 재생하는 기술이기 때문에
pliaylist가 네트워크 콘솔에서 나타나지 않은 것 같다.
🔄 가로모드 구현
스트리밍 서비스에 가로모드는 필수적인 요소다.
나는 한번도 회전에 따른 constraint를 업데이트 한 경험이 없었지만 이번 프로젝트에서 값진 경험을 했다.
Orientation 제한
기기 회전에 따른 Orientation 변경이 아닌 다음 버튼을 통해 컨트롤 해야하기 때문에
확장 상태에 따른 Orientation 제한이 필요하다.
supportedInterfaceOrientations 함수를 통해 해당 ViewController를 제한할 수 있다.
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
return output.isExpanded.value ? .landscapeLeft: .portrait
}
Orientation 업데이트에 따른 constraint 갱신
ViewController의 viewWillTransition 함수를 살펴보면 뷰의 사이즈가 변할 때 호출되는 메소드다.
Orientation이 바뀌는 것도 뷰의 사이즈가 바뀌는걸로 취급된다. 왜냐하면 width와 height이 바뀌기 때문에
그러면 이 시점에 알맞게 제약조건을 업데이트 하면 될 것 같다.
shrink: 축소 상태일 때 제약조건 , expand: 확대 상태일 때 제약조건 을 미리 선언하고 다음과 같이
상황에 따라 active와 deactive를 진행한다.
public override func viewWillTransition(to size: CGSize, with coordinator: any UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
chattingList.isHidden = output.isExpanded.value
infoView.isHidden = output.isExpanded.value
bottomGuideView.isHidden = output.isExpanded.value
if output.isExpanded.value {
NSLayoutConstraint.deactivate(shrinkConstraints)
NSLayoutConstraint.activate(expandConstraints)
} else {
NSLayoutConstraint.activate(shrinkConstraints)
NSLayoutConstraint.deactivate(expandConstraints)
}
}
결과를 한번 보자.
예정대로 왼쪽 방향으로 회전이 잘되는 것을 확인할 수 있다.
😁 소감
나의 가장 큰 취미인 스트리밍 플랫폼을 직접 만드는 경험을 과연 네이버 부스트 캠프가 아니면 가능할까??
특히 네이버 부스트 캠프에서 제공되는 NCP(네이버 클라우드 플랫폼) 크레딧을 통해 Live Station을 지원해줬기 때문에
서비스 기획이 가능했었던 것 같다. 또한 평소에는 그냥 시청하기만 했었는데 지금은 완벽히 이해는 하지 않았더라도
어떤 데이터를 주고 받고 데이터 내용에 뭐가 있는 지 알아갈 수 있는 너무 뜻 깊은 시간이었다.
다음 주가 마지막 주인데 열심히 준비한 만큼 발표도 마무리도 잘할 수 있도록 최선을 다하자!
'CS > LiveStreaming' 카테고리의 다른 글
라이브 스트리밍이란? (2) | 2024.11.06 |
---|---|
RTMP 와 HLS (1) | 2024.11.05 |