영상 스크롤 뷰에 적용할 수 있는 디자인 패턴 파헤치기

벼리·2025년 5월 11일
1

들어가며

Jetpack Media3에서 UI 재생, Media Session 등 다양한 기능을 제공하고 있지만, 그 외에도 네트워크나 메모리 관리 등의 부분은 개발자가 직접 해결해야하는 숙제가 남아있습니다.

특히 영상을 빈번하게 재생해야하는 스크롤 뷰에서는 이를 관리하지 않는다면, 성능에 부담을 줄 수 있기 때문에 유저 사용성이 저하될 수 있습니다.

그렇다면 어떻게 해야 무거운 미디어를 효율적으로 관리할 수 있을까요? 지금부터 그 방법을 소개하겠습니다

해당 글은 mp4 미디어를 기준으로 설명합니다.

사전 지식

다음 사전 지식을 알고 있다면 글을 이해하는데 더욱 도움이 됩니다

  • Jetpack Media3 라이브러리
  • 디자인 패턴

영상 로직에 사용되는 디자인 패턴 종류

1️⃣ 메모리 & 인스턴스 관리

📍 싱글톤 패턴(Singleton Pattern)

싱글톤 패턴이란? 클래스에 인스턴스가 하나만 있도록 하면서 이 인스턴스에 대한 전역 접근 지점을 제공하는 생성 디자인 패턴입니다.

ExoPlayer나 영상 캐시 관리처럼 어플리케이션 전역에서 하나의 인스턴스로 관리할 때 사용할 수 있습니다. 싱글톤으로 구현하면 영상 플레이어 객체의 중복 생성을 방지하여 메모리 누수를 방지하고 효율적으로 자원을 관리할 수 있습니다.

💡 안드로이드 hilt 라이브러리를 이용해 객체의 scope를 싱글톤으로 설정해서 인스턴스를 관리할 수도 있습니다.

📍 객체 풀 패턴(Object Pool Pattern)

객체 풀 패턴이란? 재사용 가능한 객체를 Pool에서 관리함으로써 객체의 생성 및 소멸에 따른 오버헤드를 최소화하는 패턴입니다.

스크롤 뷰에서는 하나의 UI 스레드에서 ExoPlayer 객체를 여러개 생성해야 합니다.

다만, ExoPlayer는 무거운 객체이기 때문에 빠른 스크롤 시 객체의 빈번한 생성/해제는 성능에 부담을 줄 수 있습니다. 유저가 빠르게 스크롤 한다면, 그 만큼 객체가 많이 생성되기 때문에 이를 효율적으로 관리해야 합니다.

그리고 ExoPlayer 객체를 효율적으로 관리하기 위한 수단으로 객체 풀 패턴을 적용할 수 있습니다.

그렇다면, 싱글톤 패턴객체 풀 패턴 을 적용한 코드를 한 번 살펴봅시다.

📍 코드로 적용하기

ExoPlayer의 객체 개수를 제한할 MAX_PLAYER_COUNT 와 객체를 관리할 playerPool 을 초기화합니다.

💡 MAX_PLAYER_COUNT : 객체의 최대 Pool의 크기입니다. 한 화면에 동시에 보이는 영상의 최대 개수 + 몇 개의 추가 버퍼로 정하면 좋습니다. 디자인과 로직 상황에 따라 자유롭게 설정하면 됩니다

이때, LinkedHashMap을 사용하면 가장 오래된 ExoPlayer 객체를 찾고 삭제할 수 있기 때문에 LinkedHashMap을 활용하여 Pool을 관리합니다.

object ExoPlayerPool {

		private const val MAX_PLAYER_COUNT = N
    private val playerPool = LinkedHashMap<Int, ExoPlayer>(MAX_PLAYER_COUNT, 0.75f)
    
    // ..		
} 

💡 Map 자료구조 살펴보기

순서 보장 여부정렬 여부특징
HashMap❌ (보장 안 됨)❌ 빠른 검색 속도(O(1)), 순서 보장 x
LinkedHashMap✅ (삽입 순서 유지)❌ 빠른 검색 속도(O(1)), 삽입된 순서 보장 및 오래된 요소 삭제 용이
TreeMap✅ (키 순서 유지)✅ (자동정렬)키로 정렬(O(log n))이 되어 자동 정렬이 가능, 성능은 다소 낮음

그 다음으로는 Pool을 이용해 객체를 재사용하는 로직입니다.

Pool에서 재사용 가능한 ExoPlayer가 있다면, 해당 ExoPlayer 객체를 이용해 뷰를 렌더링 합니다. 없다면 객체를 새로 생성하여 Pool에서 관리하도록 설정합니다.

이를 적용한 코드는 다음과 같습니다.

object ExoPlayerPool {

		private const val MAX_PLAYER_COUNT = N
    private val playerPool = LinkedHashMap<Int, ExoPlayer>(MAX_PLAYER_COUNT, 0.75f)
    
    fun getPlayer(context: Context, uri: String): ExoPlayer {
        // Pool에서 재사용 가능한 ExoPlayer 찾기
        val reusablePlayerEntry = playerPool.entries.find { (key, player) ->
            val currentUri = player.currentMediaItem?.localConfiguration?.uri?.toString()
            currentUri != uri && player.isCommandAvailable(Player.COMMAND_SET_VIDEO_SURFACE)
        }
        
        val dataSourceFactory = DefaultDataSource.Factory(context)
				val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
						    .createMediaSource(MediaItem.fromUri(uri))

        val player = if (reusablePlayerEntry != null && playerPool.size >= MAX_PLAYER_COUNT) {
            // 재활용할 ExoPlayer 객체가 있다면 활용
            val existingPlayer = reusablePlayerEntry.value
            existingPlayer.apply {
		            stop() // 기존 재생 멈춤
            }
        } else {
            if (playerPool.size >= MAX_PLAYER_COUNT) {
                // 가장 오래된 객체 제거
                val oldestKey = playerPool.keys.first()
                val oldestPlayer = playerPool[oldestKey]
                oldestPlayer?.stop()
                oldestPlayer?.release()
                playerPool.remove(oldestKey)
            }

            val newPlayer = createExoPlayer(context) // 객체 생성

            val newKey = { uri를 이용해 유니크한 key 생성 }
            playerPool[newKey] = newPlayer
            newPlayer
        }
        
        ExoPlayerCache.cache(context, uri)

        return player.apply {
		        setMediaSource(mediaSource)
		        prepare()
        }
    }		
}     

이때, pause() 혹은 stop() 없이 바로 release()를 호출하면, player가 완전히 중단되지 않은 상태에서 리소스 해제가 이뤄질 수 있기 때문에, 내부적으로 예외가 발생하거나 상태 관리가 불안정해질 수 있습니다.

💡 ExoPlayer 메서드 정리

  • release: player가 더 이상 필요하지 않을 때 호출.
  • stop: 로드된 미디어 및 재생에 필요한 리소스 해제
  • isCommandAvailable(Player.COMMAND_SET_VIDEO_SURFACE): player가 setVideoSurface()를 호출할 수 있는지 여부를 체크

2️⃣ 네트워크 관리

📍 프록시 패턴(Proxy Pattern)

영상을 네트워크로부터 직접 로드하기 전에 프록시 객체가 요청을 먼저 가로채 캐시된 영상이 있는지 확인합니다. 캐시에 영상이 존재하면 네트워크 호출 없이 캐시된 데이터를 반환하고, 없다면 네트워크에서 영상을 로드합니다. 이로 인해 불필요한 네트워크 요청을 줄이고 데이터 사용량을 절약하며, 영상의 로딩 속도를 향상시킵니다.

📍 어댑터 패턴(Adapter Pattern)

다양한 영상 형식(DASH, HLS, MP4)과 네트워크 프로토콜을 처리하기 위한 호환성을 제공합니다. 각기 다른 인터페이스를 가진 영상 데이터 소스와 플레이어를 연결하여, 다양한 형식의 영상을 쉽게 지원하도록 유연성을 높입니다.

📍 코드로 적용하기

앞에서의 메모리 & 인스턴스 관리 방법과는 달리, 프록시 패턴과 어댑터 패턴은 media3에서 제공해주고 있습니다. 다음은 media3에서 제공하는 라이브러리들 입니다.

  • DataSource & DataSource.Factory
    • Media3는 네트워크/로컬 등 다양한 데이터 소스를 처리합니다.
    • DataSource 인터페이스는 데이터 읽기 API를 정의하고, DefaultHttpDataSource, FileDataSource 등은 이를 구현해 각기 다른 방식으로 데이터를 읽습니다.
  • MediaSource
    • 같은 MediaSource 인터페이스를 이용해 HLS, DASH, MP4 등 다양한 스트리밍 프로토콜을 다룰 수 있습니다.
  • Surface
    • Media3는 SurfaceHolder, SurfaceView, TextureView, SurfaceControl 등 Android의 여러 디스플레이 메커니즘을 추상화해서 처리합니다.

이렇듯, media3에서는 다양한 환경에서 유연하게 대응할 수 있도록 여러 라이브러리를 제공하고 있습니다.

프록시 패턴과 어댑터 패턴을 적용한 예시 코드는 다음과 같습니다.

// 방법 1. 캐시 x 일반 네트워크/파일 로드
val dataSourceFactory = DefaultDataSource.Factory(context)
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
    .createMediaSource(MediaItem.fromUri(uri))
    

// 방법 2. 캐시 o 
val cacheDataSourceFactory = CacheDataSource.Factory()
    .setCache(simpleCache)
    .setUpstreamDataSourceFactory(DefaultDataSource.Factory(context))

val mediaSource = ProgressiveMediaSource.Factory(cacheDataSourceFactory)
    .createMediaSource(MediaItem.fromUri(uri))

3️⃣ 파일 관리

📍 캐시 어사이드 패턴(Cache-Aside Pattern)

영상을 로컬 파일 시스템에 저장하고 요청 시 캐시에서 먼저 데이터를 조회합니다. 캐시에 데이터가 없으면 파일이나 네트워크에서 영상을 로드한 후 캐시에 저장하여, 다음 요청부터는 캐시에서 빠르게 로드합니다. 이를 통해 영상의 로딩 시간을 단축시키고 앱의 전반적인 성능을 향상시킵니다.

📍 싱글톤 패턴(Singleton Pattern)

파일 시스템에서 영상 데이터를 관리할 때도 싱글톤 패턴이 자주 활용됩니다. 하나의 파일 관리 인스턴스를 통해 모든 파일 접근을 효율적으로 관리하며, 파일 입출력 과정에서 발생할 수 있는 중복 접근과 성능 저하를 방지합니다.

📍 코드로 적용하기

앞에서의 프록시 패턴과는 달리, 캐시 어사이드 패턴에서는 어플리케이션이 명시적으로 데이터를 캐시에 저장할 수 있습니다.

이를 통해 개발자가 원하는 특정 데이터만 캐싱하여 효율적으로 데이터를 관리할 수 있습니다.

object ExoPlayerCache {

		// 캐시 객체 초기화 선행 필요

		fun cache(context: Context, url: String) {
		    val cacheWriter = CacheWriter( /*...*/ )
		    CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
		        cacheWriter.cache()
		    }
		}

}

object ExoPlayerPool { 

		fun getPlayer(context: Context, uri: String): ExoPlayer {
				val cacheDataSourceFactory = ExoPlayerCache.getCacheDataSourceFactory(context)
        val mediaSource = ProgressiveMediaSource.Factory(cacheDataSourceFactory)
            .createMediaSource(mediaItem)
            
				// mediaSource를 ExoPlayer 객체에 설정 
				
				ExoPlayerCache.cache(context, uri)
		}

}

4️⃣ 재생 상태 관리

📍 상태 패턴(State Pattern)

ExoPlayer 객체의 상태(재생, 일시정지, 로딩 중, 오류 상태 등)를 각각 별도의 객체로 관리하는 방식입니다. 상태 전환과 각 상태에 따른 동작을 체계적으로 정의하여 코드 복잡성을 줄이고 유지보수와 확장성을 높일 수 있습니다.

📍 코드로 적용하기

다음은 각 상태에 따라 play와 pause 로직을 정의한 예제 코드입니다.

interface PlayerState {
    fun play(player: VideoPlayer)
    fun pause(player: VideoPlayer)
}

class PlayingState : PlayerState {
    override fun play(player: VideoPlayer) { /* 이미 재생 중 */ }
    override fun pause(player: VideoPlayer) {
        player.exoPlayer.pause()
        player.state = PausedState()
    }
}

class PausedState : PlayerState {
    override fun play(player: VideoPlayer) {
        player.exoPlayer.play()
        player.state = PlayingState()
    }
    override fun pause(player: VideoPlayer) { /* 이미 정지됨 */ }
}

class VideoPlayer(val exoPlayer: ExoPlayer) {
    var state: PlayerState = PausedState()

    fun play() = state.play(this)
    fun pause() = state.pause(this)
}

5️⃣ 영상 설정 관리

📍 데코레이터 패턴(Decorator Pattern)

영상에 자막, 워터마크, 영상 필터 효과와 같은 추가 기능을 동적으로 적용하는 방식입니다. 기본 영상 플레이어 객체를 변경하지 않고도 새로운 기능을 쉽게 추가하거나 제거할 수 있어 확장성이 뛰어납니다.

📍 코드로 적용하기

다음은 데코레이터 패턴을 표현한 간단한 수도코드 입니다.

interface VideoComponent {
    fun play()
}

class BasicVideoPlayer : VideoComponent {
    override fun play() { println("기본 영상 재생") }
}

class SubtitleDecorator(private val component: VideoComponent) : VideoComponent {
    override fun play() {
        component.play()
        println("자막 추가됨")
    }
}

// 사용 예시
val playerWithSubtitle = SubtitleDecorator(BasicVideoPlayer())
playerWithSubtitle.play()

마치며

영상 재생 로직은 네트워크, 메모리, 상태 관리 등 다양한 측면에서 고려해야 할 요소들이 많습니다. 이번 글에서 소개한 디자인 패턴들은 Media3 기반의 영상 플레이어를 더욱 효율적이고 안정적으로 구현할 수 있도록 도와주는 도구일 뿐입니다. 무엇보다 중요한 것은 애플리케이션의 특성과 상황에 맞게 적절한 패턴을 선택하고 유연하게 적용하는 것입니다. 패턴은 만능 해법이 아니라, 문제 해결을 위한 하나의 수단이라는 점을 잊지 않으셨으면 합니다.

참고

Design Patterns in Android Application Development: Use Cases and Examples

Exploring Creational Design Patterns in Android Development

Customization  |  Android media  |  Android Developers

Object Pool Design Pattern - GeeksforGeeks

4 Popular Cache Patterns - Awesome Software Engineer

프록시 패턴

profile
코딩일기

0개의 댓글