Monday, November 26, 2018

안드로이드 MediaPlayer의 치명적 오류: finalized without being released 해결법

서론: 왜 MediaPlayer는 까다로운 친구인가?

안드로이드 애플리케이션 개발에서 오디오나 비디오를 재생하는 기능은 매우 흔하고 강력한 사용자 경험을 제공하는 핵심 요소입니다. 간단한 효과음부터 배경음악, 사용자가 선택한 동영상 스트리밍에 이르기까지 미디어 재생은 앱의 매력을 한층 더 끌어올립니다. 안드로이드 프레임워크는 이러한 미디어 재생 기능을 손쉽게 구현할 수 있도록 android.media.MediaPlayer 라는 강력한 클래스를 제공합니다.

하지만 MediaPlayer는 그 강력함만큼이나 다루기 까다로운 면모를 지니고 있습니다. 특히 리소스 관리와 생명주기(Lifecycle) 측면에서 개발자의 세심한 주의를 요구합니다. 많은 안드로이드 개발자들이 한 번쯤은 미디어 재생 중에 앱이 갑자기 중단되거나, 로그캣(Logcat)에 알 수 없는 오류 메시지가 출력되는 경험을 해보셨을 겁니다. 그중에서도 가장 악명 높고, 처음 접했을 때 원인을 파악하기 어려운 오류가 바로 MediaPlayer finalized without being released 입니다.

이 오류 메시지는 직역하면 'MediaPlayer가 해제(release)되지 않은 채로 종료(finalize)되었다'는 의미입니다. 이는 개발자가 명시적으로 자원을 반납하라는 신호를 보내지 않았는데, 시스템이 임의로 MediaPlayer 객체를 메모리에서 회수하면서 그와 연결된 네이티브 리소스들이 불안정한 상태로 남았음을 경고하는 것입니다. 이 현상은 종종 예고 없이 발생하여 오디오 재생이 갑자기 멈추는 결과로 이어지며, 사용자는 앱의 불안정성을 느끼게 됩니다. 왜 이런 일이 발생하는 것일까요? 단순히 코드를 몇 줄 추가하는 것만으로 해결될 수 있는 문제일까요? 이 글에서는 MediaPlayer finalized without being released 오류의 근본적인 원인을 자바 가비지 컬렉션(Garbage Collection)과 JNI(Java Native Interface)의 상호작용을 통해 깊이 파헤치고, 명확한 해결책과 더 나아가 안정적인 미디어 재생을 위한 MediaPlayer 관리 모범 사례에 대해 총망라하여 설명하고자 합니다. 이 글을 끝까지 읽으신다면, 더 이상 MediaPlayer의 변덕에 시달리지 않고 미디어 재생 기능을 자신 있게 구현할 수 있게 될 것입니다.

문제의 핵심: 'MediaPlayer finalized without being released' 오류의 실체

이 오류를 이해하기 위해서는 먼저 MediaPlayer가 순수한 자바(Java)나 코틀린(Kotlin) 코드로만 이루어진 객체가 아니라는 사실을 알아야 합니다. MediaPlayer는 안드로이드 시스템의 하위 레벨에 존재하는 네이티브(Native, C/C++) 미디어 프레임워크를 감싸고 있는 래퍼(Wrapper) 클래스입니다. 즉, 우리가 mediaPlayer.start() 같은 메소드를 호출하면, 이 호출은 JNI를 통해 실제 오디오 디코딩, 하드웨어 가속, 오디오 장치 제어 등을 담당하는 네이티브 코드로 전달됩니다.

자바 가비지 컬렉션(GC)과 JNI의 위태로운 만남

자바/코틀린 개발 환경의 가장 큰 장점 중 하나는 가비지 컬렉터(Garbage Collector, GC)의 존재입니다. 개발자가 `new` 키워드로 생성한 객체들의 메모리 해제를 직접 신경 쓰지 않아도, GC가 더 이상 참조되지 않는 객체(Unreachable objects)를 알아서 찾아내 메모리에서 제거해 줍니다. 이는 메모리 누수(Memory leak)의 위험을 크게 줄여주는 편리한 기능입니다.

문제는 바로 여기서 발생합니다. GC는 JVM(자바 가상 머신)의 힙(Heap) 메모리 영역만 관리합니다. 즉, GC의 관심사는 오로지 자바/코틀린 객체들뿐입니다. MediaPlayer의 경우, 우리가 코드에서 다루는 것은 자바 힙에 생성된 `MediaPlayer` 객체이지만, 이 객체는 JNI를 통해 네이티브 힙에 할당된 막대한 양의 리소스(예: 디코더, 메모리 버퍼, 하드웨어 제어권 등)에 대한 '포인터' 또는 '핸들' 역할을 합니다.

만약 어떤 이유로든 자바 `MediaPlayer` 객체에 대한 참조가 모두 사라지면, GC는 이 객체를 '쓰레기'로 간주하고 수거 대상으로 삼습니다. 그리고 언젠가 GC가 동작하는 시점에 이 객체의 메모리를 회수합니다. 메모리가 회수되기 직전, `finalize()` 메소드가 호출될 수 있습니다. MediaPlayer의 `finalize()` 메소드는 네이티브 레이어에 "이제 모든 것을 정리하라"는 최후의 신호를 보내도록 구현되어 있습니다. 이것이 바로 로그캣에 `MediaPlayer finalized without being released` 라는 메시지가 찍히는 이유입니다. 개발자가 `release()`를 명시적으로 호출하여 질서정연하게 자원을 반납한 것이 아니라, GC에 의해 강제로, 예기치 않은 시점에 자원이 회수되었다는 '경고'인 셈입니다.

로컬 변수의 배신: 잠재적 오류의 씨앗

그렇다면 어떤 경우에 MediaPlayer 객체에 대한 참조가 사라질까요? 가장 흔한 원인은 MediaPlayer로컬 변수(Local variable)로 선언하고 사용하는 것입니다. 다음의 안티패턴(Anti-pattern) 코드를 살펴보겠습니다.


// 안티패턴: 절대 이렇게 사용하지 마세요!
fun playSoundEffect() {
    // 로컬 변수로 MediaPlayer 생성
    val mp = MediaPlayer.create(context, R.raw.sound_effect)
    mp.setOnCompletionListener { player ->
        // 이 리스너는 player가 GC되기 전에 호출된다는 보장이 없다.
        player.release()
    }
    mp.start()
    // playSoundEffect() 메소드가 종료되는 순간,
    // 로컬 변수 'mp'에 대한 참조는 사라진다.
}

위 코드에서 `mp`는 `playSoundEffect()` 메소드 내에서만 유효한 로컬 변수입니다. 이 메소드의 실행이 끝나는 순간, `mp`라는 참조는 스택(Stack)에서 사라집니다. 이제 자바 힙에 외롭게 남겨진 `MediaPlayer` 인스턴스를 가리키는 참조는 어디에도 없게 됩니다. 비록 `mp.start()`를 호출해서 소리가 재생되기 시작했더라도, GC의 눈에는 이 `MediaPlayer` 인스턴스가 즉시 수거 가능한 쓰레기로 보일 수 있습니다.

GC는 언제, 어떤 주기로 동작할지 예측할 수 없습니다. 메소드 호출 직후에 동작할 수도 있고, 몇 초 뒤에 동작할 수도 있습니다. 만약 GC가 소리가 재생되는 도중에 이 객체를 수거해 간다면, 네이티브 리소스가 예기치 않게 정리되면서 소리는 갑자기 뚝 끊기게 됩니다. 이것이 바로 많은 개발자들이 겪는 '소리가 나다가 갑자기 멈추는' 현상의 실체입니다. setOnCompletionListener 안에서 `release()`를 호출하는 것은 해결책이 될 수 없습니다. 왜냐하면 소리가 다 재생되기도 전에 GC가 먼저 객체를 수거해 갈 수 있기 때문입니다.

명확한 해결책: 멤버 변수로의 전환

이 문제의 해결책은 의외로 간단하며, 그 원리를 이해하면 매우 명확합니다. MediaPlayer 객체가 GC에 의해 임의로 수거되지 않도록, 우리가 원하는 시점까지 객체에 대한 강력한 참조(Strong reference)를 유지해주면 됩니다. 가장 일반적이고 효과적인 방법은 MediaPlayer 인스턴스를 로컬 변수가 아닌 **멤버 변수(Member variable)** 또는 **인스턴스 변수(Instance variable)**로 선언하는 것입니다.

잘못된 코드 (Before)

먼저 문제가 발생하는 코드의 전형적인 예시입니다. 버튼을 클릭하면 `playSound()` 메소드가 호출되어 로컬로 `MediaPlayer`를 생성하고 재생합니다.


import android.media.MediaPlayer
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.example.app.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.playButton.setOnClickListener {
            playSound() // 버튼 클릭 시 사운드 재생
        }
    }

    // 안티패턴: MediaPlayer를 로컬 변수로 사용
    private fun playSound() {
        try {
            // 이 mp 객체는 playSound() 함수가 끝나면 GC 대상이 될 수 있다.
            val mp = MediaPlayer().apply {
                setDataSource("https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3")
                setOnPreparedListener { player ->
                    player.start()
                }
                prepareAsync() // 비동기 준비
            }
            // 소리가 시작되자마자 함수는 종료되고, mp는 버려질 위험에 처한다.
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

    // 액티비티가 소멸될 때 아무런 처리도 하지 않는다.
    override fun onDestroy() {
        super.onDestroy()
    }
}

올바른 코드 (After)

이제 `MediaPlayer`를 `Activity`의 멤버 변수로 변경하고, `Activity`의 생명주기에 맞춰 관리하는 올바른 코드를 보겠습니다.


import android.media.MediaPlayer
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.example.app.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    
    // 해결책: MediaPlayer를 액티비티의 멤버 변수로 선언
    // nullable 타입으로 선언하여 사용 전후를 명확히 관리한다.
    private var mediaPlayer: MediaPlayer? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.playButton.setOnClickListener {
            // 기존 플레이어가 있다면 중지하고 새로 시작
            stopAndReleasePlayer()
            playSound()
        }
    }

    private fun playSound() {
        try {
            // 멤버 변수 mediaPlayer에 인스턴스를 할당한다.
            mediaPlayer = MediaPlayer().apply {
                setDataSource("https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3")
                
                // 에러 발생 시 처리
                setOnErrorListener { mp, what, extra ->
                    // 에러 로그 출력, 플레이어 리셋 및 해제
                    Log.e("MediaPlayerError", "what: $what, extra: $extra")
                    stopAndReleasePlayer()
                    true // 에러를 처리했음을 시스템에 알림
                }
                
                // 준비가 완료되면 재생 시작
                setOnPreparedListener { player ->
                    player.start()
                }
                
                // 재생이 완료되면 리소스 해제
                setOnCompletionListener {
                    stopAndReleasePlayer()
                }

                prepareAsync() // 비동기 준비
            }
        } catch (e: Exception) {
            e.printStackTrace()
            stopAndReleasePlayer() // 초기화 중 예외 발생 시에도 리소스 해제
        }
    }
    
    // 플레이어를 중지하고 리소스를 해제하는 헬퍼 함수
    private fun stopAndReleasePlayer() {
        mediaPlayer?.let {
            if (it.isPlaying) {
                it.stop()
            }
            it.release() // 가장 중요한 부분! 명시적 리소스 해제
        }
        mediaPlayer = null // 참조를 완전히 제거
    }

    // 화면이 보이지 않을 때 일시정지 (선택적이지만 좋은 습관)
    override fun onPause() {
        super.onPause()
        mediaPlayer?.let {
            if (it.isPlaying) {
                it.pause()
            }
        }
    }
    
    // 화면으로 돌아왔을 때 다시 재생 (선택적)
    override fun onResume() {
        super.onResume()
        mediaPlayer?.let {
            if (!it.isPlaying) {
                it.start()
            }
        }
    }

    // 액티비티가 완전히 소멸될 때 반드시 리소스를 해제해야 한다.
    override fun onDestroy() {
        super.onDestroy()
        stopAndReleasePlayer()
    }
}

왜 이것이 해결책이 되는가?

`MediaPlayer`를 멤버 변수로 선언하면, `mediaPlayer` 인스턴스는 `MainActivity` 인스턴스의 일부가 됩니다. 따라서 `MainActivity`가 메모리에 살아있는 동안에는 `mediaPlayer`에 대한 참조가 항상 유지됩니다. GC는 `MainActivity` 인스턴스가 소멸되기 전까지는 `mediaPlayer` 객체를 절대로 '쓰레기'로 판단하지 않습니다.

이제 `MediaPlayer`의 생명은 `MainActivity`의 생명주기와 동기화됩니다. `MainActivity`가 `onDestroy()` 될 때, 우리는 `stopAndReleasePlayer()` 메소드를 통해 명시적으로 `mediaPlayer.release()`를 호출하여 네이티브 리소스를 안전하게 해제할 수 있습니다. 이렇게 함으로써 `finalized without being released` 경고는 더 이상 발생하지 않으며, 오디오 재생은 안정적으로 유지됩니다.

단순한 해결을 넘어: MediaPlayer 전문가를 위한 모범 사례

멤버 변수로 전환하는 것은 문제 해결의 시작일 뿐입니다. 안정적이고 성능 좋은 미디어 플레이어를 만들기 위해서는 `MediaPlayer`의 동작 방식을 더 깊이 이해하고 몇 가지 중요한 원칙을 준수해야 합니다.

1. 생명주기(Lifecycle)와의 동기화: 가장 중요한 원칙

위의 올바른 코드 예시에서 보았듯이, `MediaPlayer`의 제어는 반드시 그것을 소유한 컴포넌트(Activity, Fragment, Service 등)의 생명주기와 긴밀하게 연결되어야 합니다.

  • onCreate() / onViewCreated(): UI 초기화 등 준비 작업을 수행합니다. 플레이어 인스턴스 자체는 사용자가 재생을 요청할 때 생성하는 것이 좋습니다.
  • onResume(): 사용자가 화면과 상호작용하기 시작하는 시점입니다. `onPause()`에서 일시정지했다면, 여기서 다시 `start()`를 호출하여 재생을 재개할 수 있습니다.
  • onPause(): 다른 앱이 전면에 나타나거나 홈 버튼을 누르는 등, 액티비티가 포커스를 잃는 시점입니다. 소리를 계속 재생할 필요가 없다면 `mediaPlayer.pause()`를 호출하여 시스템 리소스를 잠시 반환하는 것이 좋습니다. 전화가 걸려오는 등의 상황을 대비한 필수적인 처리입니다.
  • onDestroy(): 액티비티가 완전히 소멸되는 시점입니다. 이 단계에서 `mediaPlayer.release()`를 호출하여 모든 리소스를 완전히 해제하는 것은 매우 중요합니다. 이를 누락하면 심각한 메모리 누수로 이어질 수 있습니다.

2. 상태 머신(State Machine) 완벽 이해하기

MediaPlayer는 내부적으로 복잡한 상태 머신으로 동작합니다. 특정 상태에서만 호출할 수 있는 메소드들이 정해져 있으며, 잘못된 상태에서 메소드를 호출하면 `IllegalStateException`이 발생합니다. 각 상태의 흐름을 이해하는 것은 버그를 예방하는 데 필수적입니다.

상태 (State) 설명 전환 가능한 주요 메소드
Idle new MediaPlayer()로 객체 생성 직후 또는 reset() 호출 후의 상태. setDataSource()
Initialized setDataSource() 호출 후의 상태. 미디어 소스는 설정되었지만 재생 준비는 안 됨. prepare(), prepareAsync()
Preparing prepareAsync() 호출 후, 시스템이 미디어를 비동기적으로 준비하는 중간 상태. (내부 상태, 직접 제어 불가)
Prepared 미디어 로딩과 버퍼링이 완료되어 재생할 준비가 된 상태. prepare()가 성공적으로 반환되거나, OnPreparedListener가 호출된 시점. start(), seekTo(), setVolume()
Started start() 호출 후, 미디어가 활발하게 재생 중인 상태. pause(), stop(), seekTo()
Paused pause() 호출 후, 재생이 일시 중지된 상태. start() (재개), stop()
Stopped stop() 호출 후, 재생이 중지된 상태. 네이티브 리소스는 일부 유지되지만 다시 재생하려면 prepare() 또는 prepareAsync()부터 다시 시작해야 함. prepare(), prepareAsync(), release()
PlaybackCompleted 미디어의 끝까지 재생이 완료된 상태. isLooping()이 false일 때 도달. 이 상태에서도 start()를 호출하면 처음부터 다시 재생됨. start(), stop(), seekTo(0)
Error 지원하지 않는 포맷, 네트워크 오류 등 각종 오류 발생 시 진입하는 상태. OnErrorListener가 호출됨. 복구를 위해 reset()을 호출하여 Idle 상태로 돌아가야 함. reset()
End release() 호출 후의 최종 상태. 모든 리소스가 해제되었으며, 이 객체는 더 이상 사용할 수 없음. (없음)

3. 비동기 준비: ANR을 피하는 현명한 선택 `prepareAsync()`

MediaPlayer를 준비시키는 방법에는 `prepare()`와 `prepareAsync()` 두 가지가 있습니다.

  • prepare() (동기): 이 메소드는 미디어 데이터를 로드하고 디코딩을 준비하는 모든 작업이 끝날 때까지 현재 스레드(보통 UI 스레드)를 차단(Block)합니다. 만약 네트워크 스트림이나 용량이 큰 파일을 로드한다면 수 초간 멈춤이 발생할 수 있고, 이는 ANR(Application Not Responding) 오류의 주된 원인이 됩니다.
  • prepareAsync() (비동기): 이 메소드는 즉시 반환되고, 미디어 준비 작업을 백그라운드 스레드에서 수행합니다. 준비가 완료되면 OnPreparedListener의 `onPrepared()` 콜백 메소드를 호출해 줍니다. 따라서 UI 스레드를 차단하지 않아 부드러운 사용자 경험을 제공할 수 있습니다. 특별한 이유가 없다면 항상 `prepareAsync()`를 사용하는 것이 좋습니다.

4. 리소스 해제는 필수! `release()`를 잊지 마세요

몇 번을 강조해도 지나치지 않습니다. `MediaPlayer` 사용이 끝났을 때는 반드시 `release()`를 호출해야 합니다. `release()`는 `MediaPlayer`가 점유하고 있던 모든 메모리, 디코더, 하드웨어 자원을 시스템에 완전히 반납하는 역할을 합니다. 만약 이를 잊는다면 액티비티를 나갔다 들어올 때마다 새로운 `MediaPlayer` 객체와 네이티브 리소스가 계속 쌓이면서 메모리 누수가 발생하고, 결국 앱이 비정상적으로 종료될 수 있습니다. `onDestroy()`에서 `release()`를 호출하는 것을 습관화하고, 더 이상 플레이어가 필요 없는 모든 경로(예: 에러 발생, 재생 완료)에서 리소스를 해제하는 로직을 추가하는 것이 견고한 코드를 만드는 지름길입니다.

5. 견고한 앱을 위한 오류 처리: `OnErrorListener`

미디어 재생은 실패할 수 있는 다양한 요소를 포함합니다. 네트워크 연결이 끊기거나, 파일 형식이 손상되었거나, 지원하지 않는 코덱을 사용하는 등 예외적인 상황은 언제든 발생할 수 있습니다. `setOnErrorListener`를 설정하면 이러한 오류를 감지하고 적절하게 대응할 수 있습니다.


mediaPlayer?.setOnErrorListener { mp, what, extra ->
    // 'what'과 'extra' 코드를 통해 오류의 원인을 구체적으로 파악할 수 있다.
    // e.g., MEDIA_ERROR_UNKNOWN, MEDIA_ERROR_SERVER_DIED
    Log.e("MyMediaPlayer", "Error occurred. what: $what, extra: $extra")
    
    // 오류가 발생했으므로 플레이어는 Error 상태에 빠진다.
    // 더 이상 사용할 수 없으므로 리소스를 정리하고 초기화해야 한다.
    mp.reset() // Idle 상태로 되돌린다.
    // 또는 release()로 완전히 해제할 수도 있다.
    // stopAndReleasePlayer() 와 같은 헬퍼 함수 호출

    // 사용자에게 오류가 발생했음을 알리는 Toast나 Snackbar를 보여준다.
    Toast.makeText(applicationContext, "재생 중 오류가 발생했습니다.", Toast.LENGTH_SHORT).show()
    
    // 리스너가 오류를 처리했으면 true, 처리하지 않았으면 false를 반환.
    // true를 반환하면 OnCompletionListener가 호출되지 않는다.
    true 
}

6. 백그라운드 재생의 꿈: 포그라운드 서비스(Foreground Service) 활용

만약 음악 플레이어 앱처럼 사용자가 다른 앱을 사용하거나 화면을 꺼도 음악이 계속 재생되어야 한다면, `Activity`나 `Fragment`에 `MediaPlayer`를 두는 것만으로는 부족합니다. `Activity`가 백그라운드로 전환되면 시스템에 의해 언제든지 종료될 수 있기 때문입니다.

이러한 요구사항을 위해서는 `Service`, 특히 안드로이드 8.0(Oreo) 이상부터는 **`Foreground Service`**를 사용해야 합니다. 포그라운드 서비스는 사용자에게 실행 중임을 알리는 지속적인 알림(Notification)을 표시하며, 시스템이 메모리가 부족하더라도 서비스를 함부로 종료하지 않도록 우선순위를 높여줍니다. `MediaPlayer`의 인스턴스와 재생 로직 전체를 서비스 내에서 관리하고, `Activity`는 서비스에 연결(bind)하여 재생/일시정지/정지 등의 명령을 전달하고 상태를 받아와 UI에 표시하는 역할만 담당하도록 설계해야 합니다. 이는 훨씬 복잡한 구조이지만, 진정한 의미의 백그라운드 오디오 앱을 만들기 위한 표준적인 방법입니다.

현대 안드로이드 개발의 대안: ExoPlayer

지금까지 `MediaPlayer`의 문제점과 올바른 사용법에 대해 자세히 알아보았습니다. `MediaPlayer`는 안드로이드 초창기부터 제공되어 온 기본적인 API이며, 간단한 효과음이나 짧은 오디오 클립 재생에는 여전히 유용합니다. 하지만 스트리밍, 적응형 비트레이트(DASH, HLS), 고급 캐싱, DRM 보호 콘텐츠 등 복잡하고 현대적인 미디어 재생 기능을 구현해야 한다면 `MediaPlayer`는 많은 한계를 드러냅니다.

Google에서는 이러한 한계를 극복하기 위해 오픈소스 라이브러리인 **`ExoPlayer`**를 개발하여 적극적으로 사용을 권장하고 있습니다. `ExoPlayer`는 YouTube, Google TV 등 Google의 여러 공식 앱에서 사용될 만큼 강력하고 안정적입니다.

MediaPlayer vs ExoPlayer: 무엇을 선택해야 할까?

  • 유연성과 확장성: `ExoPlayer`는 매우 모듈화된 구조를 가지고 있어 개발자가 원하는 대로 커스터마이징하기 용이합니다. 예를 들어, 기본적으로 지원하지 않는 오디오 포맷이 있다면 직접 코덱을 구현하여 통합할 수 있습니다. 반면, `MediaPlayer`는 거의 모든 것이 내부에 감춰진 '블랙박스'와 같아서 확장성이 거의 없습니다.
  • 스트리밍 지원: `ExoPlayer`는 DASH, HLS, SmoothStreaming과 같은 현대적인 적응형 스트리밍 프로토콜을 완벽하게 지원합니다. 네트워크 상태에 따라 동적으로 화질을 변경하여 끊김 없는 재생을 제공합니다. `MediaPlayer`는 이러한 프로토콜 지원이 매우 제한적이고 불안정합니다.
  • 안정성 및 업데이트: `ExoPlayer`는 라이브러리로 제공되므로, 버그가 수정되거나 새로운 기능이 추가되면 앱 업데이트만으로 모든 사용자에게 즉시 적용할 수 있습니다. `MediaPlayer`는 OS의 일부이므로, 특정 기기나 OS 버전의 버그를 해결하려면 해당 제조사의 OS 업데이트를 기다려야만 하는 문제가 있습니다.
  • 학습 곡선: `MediaPlayer`는 API가 비교적 단순하여 초기 설정이 간단합니다. 반면, `ExoPlayer`는 `ExoPlayer.Builder`, `MediaItem`, `DataSource.Factory` 등 여러 컴포넌트를 직접 설정해야 하므로 초기 학습 곡선이 다소 가파를 수 있습니다.

결론적으로, 간단한 사운드 재생이나 레거시 프로젝트 유지보수가 아니라면, 새로운 프로젝트에서는 가급적 `ExoPlayer`를 사용하는 것이 장기적인 관점에서 훨씬 현명한 선택입니다. 하지만 `ExoPlayer` 역시 내부적으로는 시스템의 미디어 자원을 사용하므로, 이 글에서 다룬 생명주기 관리와 리소스 해제(`player.release()`)의 기본 원칙은 `ExoPlayer`를 사용할 때도 동일하게 중요하게 적용됩니다.

결론: 안정적인 미디어 재생을 향한 여정

MediaPlayer finalized without being released 오류는 단순히 코드 한두 줄의 실수가 아니라, 안드로이드의 리소스 관리와 객체 생명주기에 대한 이해 부족에서 비롯되는 구조적인 문제입니다. 이 오류의 원인이 GC와 JNI의 상호작용, 그리고 로컬 변수의 짧은 생명주기에 있음을 이해하는 것이 문제 해결의 첫걸음입니다.

핵심 해결책은 `MediaPlayer` 인스턴스를 액티비티나 서비스의 멤버 변수로 만들어 생명주기를 직접 관리하고, 사용이 끝난 후에는 반드시 `release()` 메소드를 명시적으로 호출하여 모든 자원을 해제하는 것입니다. 더 나아가 `prepareAsync()`를 통한 비동기 처리, 상태 머신에 대한 이해, 생명주기 콜백과의 연동, `OnErrorListener`를 통한 예외 처리 등을 모두 고려해야만 진정으로 안정적이고 사용자 친화적인 미디어 플레이어를 만들 수 있습니다.

안드로이드 미디어 재생의 세계는 깊고 복잡하지만, 그 기본 원칙을 충실히 따른다면 더 이상 예기치 않은 오류에 당황하지 않고 사용자를 즐겁게 하는 멋진 기능을 자신 있게 구현할 수 있을 것입니다. 그리고 다음 프로젝트를 시작할 기회가 있다면, `ExoPlayer`라는 더 강력한 도구를 사용하여 한 단계 더 높은 수준의 미디어 경험을 제공하는 것을 고려해 보시길 바랍니다.


0 개의 댓글:

Post a Comment