Develop/Kotlin

Jetpack Compose 환경에서 ExoPlayer 최적화

JunJangE 2025. 8. 15. 12:29

Jetpack Compose로 동영상 UI를 만들기 시작했을 때는 단순히 화면에 영상만 잘 나오면 된다고 생각했다. 그러나 구현을 조금씩 구체화할수록, 예상하지 못했던 문제들이 하나둘씩 고개를 들기 시작했다.
특히 요즘 유행하는 세로형 숏폼 콘텐츠 앱을 만들다 보면 이 어려움을 누구나 실감할 것이다. 사용자는 화면을 끊김 없이 스크롤하고 싶어 하고, 개발자는 각 화면마다 ExoPlayer를 안정적으로 관리해야 한다. 동시에 메모리 누수 없이 플레이어를 재활용하는 과제까지 따라붙는다.
결국 세 가지 조건이 충돌한다.
스크롤은 매끄러워야 하고, 영상은 자연스럽게 재생돼야 하며, 앱은 절대 크래시가 나면 안 된다. 문제는 이 세 가지가 서로 얽혀 있다는 점이다. 하나를 잡으면 다른 하나가 삐걱거리는 식으로 개발자를 끊임없이 시험한다.
“이 복잡한 퍼즐을 어떻게 풀어야 할까?”
이번 글은 Jetpack Compose와 ExoPlayer 경험이 있는 개발자에게 특히 유용하다.
단순한 구현 방법이나 코드 스니펫을 넘어, 컴퓨터 과학 원리와 소프트웨어 설계 지식을 실제 프로젝트에 적용해 문제를 해결하는 과정을 중심으로 다룬다.
메모리 관리, 객체 재사용, 리소스 최적화 같은 주제를 CS적 관점에서 접근하며, 실제 성능 개선과 안정적인 사용자 경험을 동시에 달성한 전략을 공유하고자 한다.

ExoPlayer 구현은 쉬웠다. 진짜 문제는 그다음이었다.

프로젝트는 Jetpack Compose 기반으로 구성되어 있었고, 이 안에서 .m3u8 형식의 HLS 스트리밍 영상을 재생해야 했다.
Media3의 ExoPlayer는 안드로이드에서 가장 널리 사용되는 미디어 플레이어이며, Compose 환경에서도 비교적 수월하게 적용할 수 있다는 점에서 자연스러운 선택이었다. 실제로 첫 구현까지는 큰 어려움 없이 진행됐다.
하지만 문제는 그 이후였다.
실제 화면 구성을 얹고, 사용자 상호작용이 개입되기 시작하면서 예상치 못한 여러 가지 복잡한 이슈들이 모습을 드러냈다.

페이지마다 ExoPlayer를 생성했을 때 드러난 문제

우리는 세로로 스크롤되는 VerticalPager 안에 동영상을 배치했다.
페이지 단위로 콘텐츠가 구성되어 있었기에, 처음에는 각 페이지마다 ExoPlayer 인스턴스를 별도로 생성하는 방식이 가장 자연스러워 보였다.
그러나 곧 한계가 드러났다.
스크롤이 발생할 때마다 새로운 인스턴스를 만들고 초기화하다 보니, 영상은 매번 끊기고 페이지 전환 시 렌더링도 매끄럽지 않았다. 더 큰 문제는 메모리 사용량이 예상보다 빠르게 늘어났다는 점이었다.
결국 이 구조는 ‘돌아가긴 하지만’, 성능과 사용자 경험 모두에 뚜렷한 불편을 남겼다.

1. AndroidView와 Recomposition에서 리소스가 정리되지 않는 문제

Compose에서 'PlayerView'와 같은 기존 Android View를 사용하려면 'AndroidView'를 활용하게 된다.
그런데 Compose의 Recomposition 과정에서 AndroidView 내부 뷰나 연결된 리소스가 예상대로 해제되지 않는 경우가 종종 발생한다.
예를 들어, 화면 전환이나 상태 변경으로 구성 요소가 다시 그려질 때, 기존 ExoPlayer 인스턴스나 PlayerView가 메모리에 남아 있는 사례가 발견됐다.
이 구조를 그대로 유지하면, 화면을 몇 번 스크롤한 뒤 메모리 사용량이 급격히 늘어나고, GC(Garbage Collection)가 개입하지 않으면 OOM(Out of Memory) 상황으로 이어질 수 있다.

2. 실제로 OOM까지 이어졌다.

이 상황이 반복되면서 일부 디바이스에서는 앱이 강제 종료되거나, 메모리 부족으로 인해 재생 중 오류가 발생하는 현상이 나타났다.
특히 단말 성능이 낮거나, 장시간 여러 영상을 탐색한 경우 문제는 더 빈번하게 발생했다.
이는 단순한 버벅임이나 끊김을 넘어, 영상 재생이라는 핵심 기능 자체를 위협하는 심각한 이슈였다.

하나의 ExoPlayer를 공유하는 구조로 전환

이 문제를 해결하기 위해, ExoPlayer 인스턴스를 싱글톤 형태로 관리하는 구조를 도입했다.
Jetpack Compose 환경에 맞춰 공유 가능한 뷰 모델이나 상태 관리 구조와 연결하고, 'PlayerView'는 필요에 따라 특정 영상과 연결하거나 해제하는 방식으로 전환했다.
이 구조로 전환하면서 다음과 같은 이점을 얻을 수 있었다.

- 동시에 재생 가능한 영상 수를 자연스럽게 제한할 수 있었다.
- 매번 새로운 플레이어를 생성하고 해제하는 과정에서 발생하던 GC 오버헤드가 크게 줄었다.
- 불필요한 Recomposition에 의한 리소스 누수도 함께 해결되었다.
결과적으로, 메모리 사용량이 안정화되었고, 장시간 사용하거나 스크롤이 잦은 환경에서도 영상 재생 품질을 유지할 수 있었다.
하지만 하나의 플레이어만 사용하는 구조가 항상 최선은 아니었다.
영상 플레이어를 어떻게 관리할지에 대한 근본적인 고민이 필요했고, 우리는 이후 ‘단일 플레이어 구조’와 ‘다중 플레이어 구조’ 각각의 장단점을 비교 분석하는 과정에 들어갔다.

단일 vs 다중 ExoPlayer, 어떤 구조가 좋을까?

ExoPlayer 최적화를 고민할 때 가장 먼저 마주한 질문은 “모든 영상에 하나의 플레이어를 쓸 것인가, 아니면 영상마다 별도의 플레이어를 만들 것인가?”였다.
각 방식에는 분명한 장단점이 존재했다.

하나의 ExoPlayer를 재사용하는 경우

메모리를 절약하며 효율적으로 리소스를 관리하는 전략이었다.
장점
- 메모리 사용량이 크게 줄었다.
- 스크롤이 부드럽고 가벼워졌다.
- 플레이어 생성 및 초기화에 드는 시간과 리소스가 감소했다.
단점
- 영상 간 전환 시 플레이어 상태를 다시 맞춰야 하므로 약간의 딜레이가 발생할 수 있었다.
- 재생 위치, 볼륨 등 세부 상태를 별도로 관리해야 한다.

각 영상마다 ExoPlayer를 생성하는 경우

각 영상에 독립적인 플레이어를 할당하는 방식으로, 즉시 전환에 유리하다.
장점
- 각 영상이 독립적으로 재생되며 서로 간섭하지 않는다.
- 영상 전환 시 별도의 초기화 없이 즉시 재생할 수 있다.
단점
- 플레이어 수가 많아질수록 메모리 사용량이 급증한다.
- CPU 사용량도 증가해 앱 성능 저하 위험이 커진다.

3개의 ExoPlayer를 돌려 쓰는 ‘트리플’ 전략

우리는 앞서 살펴본 두 방식의 장점을 적절히 결합해, ‘3개의 ExoPlayer 인스턴스를 미리 생성하고 순환 재사용하는’ 전략을 선택했다.

val exoPlayerPair = remember {
    Triple(
        ExoPlayer.Builder(context).build(),
        ExoPlayer.Builder(context).build(),
        ExoPlayer.Builder(context).build()
    )
}

Compose의 'remember'를 활용해 최초 한 번만 인스턴스를 생성하고, 이후에는 계속 재사용하도록 구현했다.
각 플레이어는 다음과 같이 역할을 분담했다.

- 이전 화면 영상 담당
- 현재 화면 영상 담당
- 다음 화면 영상 담당
현재 페이지 인덱스에 따라 적절한 플레이어를 선택하여 사용하고, 나머지 플레이어는 일시 정지시켜 리소스를 효율적으로 관리했다.

VerticalPager(
    modifier = Modifier.fillMaxSize(),
    state = pagerState
) { page ->

    val exoPlayer = when (page % 3) {
        0 -> exoPlayerPair.first
        1 -> exoPlayerPair.second
        else -> exoPlayerPair.third
    }

    Box(modifier = Modifier.fillMaxSize()) {
        VideoPlayer(
            exoPlayer = exoPlayer,
            uri = videoUri[page]
        )
        // 추가 UI 구성 요소
    }
}

when (pagerState.currentPage % 3) {
    0 -> {
        exoPlayerPair.first.play()
        exoPlayerPair.second.pause()
        exoPlayerPair.third.pause()
    }

    1 -> {
        exoPlayerPair.first.pause()
        exoPlayerPair.second.play()
        exoPlayerPair.third.pause()
    }

    2 -> {
        exoPlayerPair.first.pause()
        exoPlayerPair.second.pause()
        exoPlayerPair.third.play()
    }
}

또한 Compose의 'DisposableEffect'를 활용해 화면에서 사라질 때 플레이어 리소스를 깔끔하게 해제했다.
영상 URI가 변경되면 'setMediaSource()'로 새 영상을 설정하고, 미리 'prepare()'를 호출하여 다음 재생을 준비했다. 덕분에 영상 전환이 빠르고 부드럽게 이루어질 수 있었다.

Compose의 DisposableEffect 활용

'DisposableEffect'는 Composable의 생명 주기에 맞춰 리소스를 관리하는 데 핵심적인 역할을 했다.

1. Composable이 사라질 때 플레이어 인스턴스 해제

'exoPlayerTriple'과 같이 전역적으로 관리되는 플레이어 인스턴스는, 해당 Composable이 화면에서 완전히 사라질 때 'release()'되어야 한다.
이를 통해 불필요한 리소스 누수를 방지할 수 있었다.

DisposableEffect(Unit) {
    onDispose {
        exoPlayerPair.first.release()
        exoPlayerPair.second.release()
        exoPlayerPair.third.release()
    }
}

2. URI 변경 시 미디어 소스 및 플레이어 상태 관리

개별 플레이어가 새로운 영상을 재생해야 할 때, 'DisposableEffect'의 'key'를 'uri'로 설정하면, 'uri'가 변경될 때마다 플레이어를 새로 설정하고, Composable이 재구성되거나 사라질 때 플레이어 상태를 깔끔하게 정리할 수 있다.

DisposableEffect(uri) {
    val dataSourceFactory: DataSource.Factory = DefaultHttpDataSource.Factory()
    val source = HlsMediaSource.Factory(dataSourceFactory)
        .createMediaSource(MediaItem.fromUri(uri))
    exoPlayer.apply {
        setMediaSource(source)
        prepare()
        playWhenReady = true
        videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
        repeatMode = Player.REPEAT_MODE_ONE
    }

    onDispose {
        exoPlayer.stop()
        exoPlayer.clearMediaItems()
    }
}

이 접근 덕분에 메모리 누수 걱정 없이, 부드러운 스크롤과 안정적인 영상 재생을 동시에 달성할 수 있었다. 실제 프로파일러 결과에서도 CPU와 메모리 사용량이 눈에 띄게 개선되었다.

- CPU 사용량: 평균 42%에서 18%로 감소, 약 57% 개선
- 메모리 사용량: 다중 인스턴스 사용 시보다 현저히 감소
무엇보다 사용자 경험에서 큰 차이를 만들었다.
스크롤을 아무리 빠르게 넘겨도 영상이 끊기거나 버벅이는 현상이 없었고, 재생은 매우 부드럽게 이루어졌다.

최적화 전/ 후

마무리하며

ExoPlayer는 미디어 재생을 위한 강력한 도구이지만, 그 잠재력을 온전히 활용하고 안정적인 서비스를 제공하기 위해서는 깊이 있는 소프트웨어 설계 지식이 필요하다는 점을 이번 경험을 통해 다시 한번 깨달았다.
특히 Jetpack Compose와 같은 현대적인 선언형 UI 프레임워크 환경에서는, UI의 생명 주기와 리소스 관리가 더욱 복잡해진다. 따라서 검증된 설계 패턴과 원칙을 이해하고 적용하는 것이 필수적이다.
예를 들어, OOM 문제를 해결하기 위해 싱글톤 패턴을 활용해 플레이어 인스턴스를 관리하거나, 객체 풀(Object Pool) 개념을 적용해 3개의 ExoPlayer를 순환 재사용한 전략이 이에 해당한다.
이처럼 기본적인 컴퓨터 과학 지식과 소프트웨어 공학 원칙을 바탕으로 문제를 접근하는 것이 매우 중요했다.
이번 최적화 과정을 통해 배운 가장 중요한 교훈은, 아무리 강력한 도구라도 올바른 설계와 지속적인 개선이 없으면 오히려 독이 될 수 있다는 점이다.
단순한 기능 구현을 넘어, 성능과 사용자 만족도 사이의 균형을 유지하기 위해 지속적인 모니터링과 점진적 최적화가 반드시 필요함을 다시 한번 강조하고 싶다.