[AVFoundation] 버퍼링 없는 숏폼 경험을 위한 프리로드 플레이어 아키텍쳐 설계하기
최근 진행 중인 사이드 프로젝트 ‘풀’에서 숏폼 재생 기능 개발을 담당하게 되었습니다. 숏폼 서비스의 핵심은 빠른 컨텐츠 소비인데, 초기 구현에서 재생 딜레이 문제를 발견했고 프리로드 아키텍처로 해결한 경험을 공유합니다.
🎯 문제 인식
‘풀’은 숏폼 재생 기능을 메인으로 하는 서비스입니다. 팀 구성은 PM 1명, 디자인 2명, iOS 2명, 백엔드 3명, Android 2명으로 구성되어 있고, 저는 iOS 개발자로 숏폼 재생 기능을 담당하게 되었습니다.
숏폼의 핵심 UX
숏폼은 빠른 컨텐츠 소비가 핵심입니다. 사용자는 스크롤로 다음 영상을 빠르게 넘겨가며 보게 되는데, 이때 즉각적인 재생이 중요합니다.
초기 구현의 한계
초기 구현은 스크롤 시 해당 영상의 URL을 AVPlayerItem으로 만들어 AVPlayer에 교체하여 재생하는 방식이었습니다. 하지만 이 방식은 AVPlayer의 생명주기에 따라 다음 과정을 거치기 때문에 스크롤이 끝난 뒤 재생까지 딜레이가 존재했습니다:
- 메타데이터 로드
- 버퍼링 시작
- 버퍼 채움
- 재생 준비 완료
- 재생
숏폼의 특성상 즉각적인 재생이 중요하다고 판단했고, 이러한 딜레이를 개선하고자 했습니다. 목표는 일반적인 네트워크 환경에서 버퍼링이 1초 이상 발생하지 않는 것으로 설정했습니다.
💡 해결 과정: 프리로드 아키텍처
HLS 기반 재생의 특성
문제를 해결하기 위해 먼저 HLS(HTTP Live Streaming) 기반 재생의 특성을 파악했습니다. 핵심 인사이트는 HLS 기반 재생은 AVPlayerItem을 교체한 순간부터 버퍼링이 시작된다는 것이었습니다.
이는 단일 AVPlayer만으로는 미리 로드할 수 없다는 것을 의미했습니다.
AVPlayer 풀(Pool) 방식 도입
해결책으로 AVPlayer 인스턴스를 여러 개 만들어서 풀(Pool)로 관리하는 방식을 선택했습니다. 기본값으로는 다음, 이전 영상을 프리로드하도록 설정하여 3개의 AVPlayer 인스턴스를 사용했습니다.
프리로드 로직 구현
프리로드할 인덱스 범위를 계산하고, 해당 플레이어들을 미리 준비하는 로직을 구현했습니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
// 프리로드할 인덱스 범위 계산 (이전, 현재, 다음)
func calculatePreloadRange(around index: Int) -> Set<Int> {
return Set([
max(0, index - 1),
index,
min(playlist.count - 1, index + 1)
])
}
func preloadVideos(around index: Int) {
let indices = calculatePreloadRange(around: index)
preparePlayers(indices: indices)
}
PlayerPool에서는 중복 준비를 방지하여 버퍼를 보존하는 로직을 추가했습니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func preparePlayer(at index: Int, with url: URL) {
let actualIndex = index % poolSize
// 이미 같은 playlist index가 준비되어 있으면 스킵(버퍼 보존)
guard preparedIndexs[actualIndex] != index else {
return
}
let playerItem = AVPlayerItem(url: url)
playerItem.preferredForwardBufferDuration = 3.0
player.replaceCurrentItem(with: playerItem)
preparedIndexs[actualIndex] = index
}
⚖️ 메모리 최적화
트레이드오프 고려
프리로드를 통한 사용자 경험도 중요하지만, 다음 요소들을 고려했을 때 인스턴스의 개수를 무한정 늘릴 수 없었습니다:
- 데이터 사용량
- 메모리 사용량
- 하드웨어 제약사항 (기기 성능에 따라 동시에 가용할 수 있는
AVPlayer개수가 제한됨)
메모리 사용량 측정
AVPlayer 인스턴스 하나당 메모리 사용량을 파악하기 위해 HLS 영상을 재생하는 데모 앱을 만들어보았습니다. 측정 결과, 인스턴스 하나당 약 20MB 정도 사용하는 것으로 파악했고, 다음/이전 영상을 위해 필요한 최소한의 개수인 3개를 운용하기엔 하드웨어 성능이 충분히 받쳐줄 수 있겠다고 판단했습니다.
🔄 스크롤-플레이어 동기화
모듈러 연산자 활용
스크롤하면 영상 목록을 가지는 배열에서 인덱스가 증가하면서 다음 영상을 가리키게 되는데, 모듈러 연산자(%)를 통해 인덱스와 그에 맞는 AVPlayer를 참조하는 방식을 사용했습니다:
1
2
3
4
5
6
func getPlayer(at index: Int) -> AVPlayer {
guard let player = playerPool[safe: index % poolSize] else {
return AVPlayer()
}
return player
}
이 방식을 통해 3개의 플레이어로 무한한 영상 목록을 순환하며 재생할 수 있었습니다.
빠른 스크롤 처리 의사결정
빠르게 스크롤하는 경우엔 프리로드 로직이 무효화되는 문제가 있었습니다. 상용 서비스들이 어떻게 해결하는지 확인해본 결과, 많은 서비스에서 쓰로틀(throttle)을 통해 너무 빠른 스크롤 자체를 막는 방식을 사용하고 있었습니다.
하지만 저는 사용자 입장에서 버퍼링보다 스크롤이 마음대로 안 되는 경험이 더 크리티컬한 Bad 경험이라고 판단해서 따로 쓰로틀을 걸지 않았습니다.
🧪 테스트 가능한 구조 설계
설계 동기
가장 어려웠던 것은 잘 동작함을 보장하는 것이었습니다. 서비스의 핵심 기능이고, 플레이어를 여러 개 사용하다 보니 상태 관리의 복잡성 때문에 이 코드가 잘 동작할 거라는 것을 보장해야 했습니다.
그래서 테스트 코드가 필요하다고 판단했고, 테스트 가능한 구조를 위해 플레이어와 관련된 상태와 비즈니스 로직을 분리했습니다.
PlayerService - PlayerPool 구조
관심사를 분리하여 두 개의 레이어로 설계했습니다:
PlayerService: 재생 로직 및 현재 재생 상태 관리 PlayerPool: 플레이어 인스턴스 관리 및 프리로드
1
2
3
4
5
6
7
8
9
10
11
12
13
@MainActor
@Observable
final class PlayerService {
private let playerPool: PlayerPoolProtocol
private(set) var playlist: [Shorts] = []
private(set) var currentPlayIndex: Int = 0
var currentPlayer: AVPlayer {
playerPool.getPlayer(at: currentPlayIndex)
}
// 재생 관련 로직...
}
PlayerService는 재생 관련 로직과 현재 재생 상태에 대해서만 관심이 있고, PlayerPool 내부 구조에 대해서는 몰라도 됩니다. 이런 설계 덕분에 PlayerService의 비즈니스 로직이 직관적이었습니다. 만약 둘이 혼재되어 있었다면 저조차도 코드를 이해하기 어려웠을 것입니다.
AVPlayer Mock 구현
실제 영상을 재생해서 테스트할 수 없으니, AVPlayer를 목킹해서 실제 AVPlayer처럼 상태가 바뀌는 Mock을 만들어 테스트했습니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
func 플레이어_전환_시_현재_플레이어가_초기화된다() async throws {
let (service, mockPool) = createServiceWithPlaylist(count: 3)
try await service.playFirst()
try await wait(1)
try await service.playNext()
try await wait(1)
let player1 = mockPool.getMockPlayer(at: 1)
#expect(player1.currentItem?.status == .readyToPlay)
}
✨ 결과 및 학습
달성한 결과
실기기로 테스트해본 결과 버퍼링 시간이 줄어든 게 체감되었습니다. 우리 팀은 한 달에 한 번 데모데이를 통해 팀원 전체가 모여 결과물을 확인하는 시간이 있는데, 부드럽게 재생되는 것을 보고 다들 신기해했고 뿌듯한 경험이었습니다.
핵심 학습
이 프로젝트를 통해 다음을 배울 수 있었습니다:
AVFoundation 생명주기 이해: HLS 스트리밍의 버퍼링 메커니즘과
AVPlayer의 생명주기를 깊이 있게 이해할 수 있었습니다.테스트 주도 설계의 실천: 예전엔 테스트 가능한 구조로 설계는 하더라도 실제로 테스트를 진행한 적은 적었는데, 이번엔 테스트가 필요하다고 생각해서 이러한 구조로 설계했고, 실제로 테스트까지 진행해서 데모데이에서도 안정적으로 동작하는 플레이어를 만들 수 있었습니다.
관심사 분리의 효과:
PlayerService와PlayerPool을 분리한 것이 코드 이해도와 유지보수성을 크게 향상시켰습니다.UX 우선 의사결정: 기술적 제약보다 사용자 경험을 우선시하는 의사결정의 중요성을 배웠습니다.
🔮 향후 계획
현재는 체감으로만 개선을 확인했지만, 앞으로 다음을 진행할 예정입니다:
정량적 지표 측정: 스크롤 완료 시점과 영상 재생 시작 시점의 차이를 계산하는 함수를 만들어 실제 개선 수치를 측정하고 싶습니다. 측정 후 이 글에 결과를 추가할 계획입니다.
코드 리팩토링: 기능 구현에 집중하다 보니 코드가 지저분한 부분이 있습니다. 가독성 좋게 개선하여 다른 사람이 봐도 이해하기 쉽게 만들고 싶습니다.
데모 앱 공개: 프로젝트가 private이라 전체 코드를 보여줄 수 없는데, 데모 앱 프로젝트를 만들어서 공개할 예정입니다.
