
AndroidX Media3의 ExoPlayer는 URL 하나를 받아 재생하는 단순한 API처럼 보인다. 그러나 play() 호출과 스피커 출력 사이에서는 네트워크 로딩, container 분리, codec 선택, 디코딩, PCM 처리와 장치 출력이 차례로 일어난다. 이 글은 각 계층의 역할과 재생 장애를 관측하는 방법을 설명한다.
Media3의 재생 파이프라인을 이해하려면 오디오 기본 용어부터 데이터가 이동하는 계층과 런타임 관측 방법까지 순서대로 살펴봐야 한다.
Media3의 오디오 재생은 명령을 전달하는 제어 계층과 실제 미디어 데이터를 처리하는 데이터 계층으로 나눌 수 있다.
아래 그림은 백그라운드 재생을 위해 session을 사용하고, 압축 오디오를 PCM으로 디코딩하는 일반적인 구성을 나타낸다.

MediaController와 MediaSession은 재생 명령과 상태를 전달한다. 오디오 byte를 다운로드하거나 디코딩하지는 않는다. 실제 미디어 데이터는 DataSource, Extractor, MediaCodec, AudioSink를 차례로 거친다.
따라서 “Media3가 어떤 codec을 사용하는가?”라는 질문에는 고정된 답이 없다. 실제 decoder는 콘텐츠 포맷, Android 버전, 제조사와 단말에 등록된
MediaCodec구현에 따라 런타임에 결정된다.
MediaController와 MediaSession이 모든 재생에 필수인 것은 아니다. 화면이 떠 있는 동안만 재생하는 앱은 UI가 ExoPlayer를 직접 제어할 수도 있다. 백그라운드 재생, 알림, 잠금 화면과 외부 Bluetooth controller가 하나의 플레이어를 함께 제어해야 할 때 session 계층의 가치가 커진다.
또한 데이터 계층의 마지막이 언제나 MediaCodec → PCM → AudioTrack인 것은 아니다. 이 글은 가장 흔한 PCM 경로를 중심으로 설명하며, 단말과 포맷이 지원하면 audio offload나 passthrough가 선택될 수 있다.
디지털 오디오는 시간에 따라 변하는 소리의 크기를 일정한 간격으로 측정한 숫자열이다. 재생 파이프라인을 이해하려면 압축 전 데이터인 Pulse-Code Modulation (PCM)과 압축된 codec 데이터를 구분해야 한다.

서버나 파일에서 읽은 byte는 container 분석을 거쳐 압축 access unit으로 분리되고, decoder 입력으로 공급된다.
| 용어 | 의미 | 재생 파이프라인에서의 역할 |
|---|---|---|
| sample | 특정 시점과 channel의 소리 크기를 나타내는 숫자 | PCM buffer를 이루는 최소 측정값 |
| sample rate | 1초 동안 각 channel을 측정한 횟수 | 44.1 kHz는 channel마다 초당 44,100번 측정 |
| bit depth | PCM sample 하나를 표현하는 bit 수 | 16-bit는 sample 하나에 16 bit 사용 |
| channel | 서로 독립된 오디오 신호 경로 | mono는 1개, stereo는 좌우 2개 |
| PCM | sample을 시간 순서대로 나열한 비압축 오디오 표현 | decoder 출력이자 AudioTrack 입력의 기본 형태 |
| bitrate | 1초 분량의 저장 또는 전송에 필요한 bit 수 | 128 kb/s처럼 네트워크와 파일 크기에 연결 |
| access unit | decoder가 한 번에 처리할 수 있는 압축 오디오 조각 | Extractor가 container에서 분리해 decoder에 공급 |
| buffer | 생산자와 소비자의 속도 차이를 흡수하는 메모리 영역 | network queue, decoder buffer, PCM buffer |
sample rate와 bitrate는 다른 값이다. sample rate는 소리를 얼마나 자주 측정했는지를 나타낸다. bitrate는 인코딩된 데이터가 초당 몇 bit인지 나타낸다.
예를 들어 44.1 kHz, 16-bit, stereo PCM의 데이터율은 다음과 같다.
44,100 samples/s × 16 bit/sample × 2 channels
= 1,411,200 bit/s
= 약 1.41 Mb/s 또는 176.4 KB/s
AAC와 MP3는 일부 정보를 제거해 데이터율을 낮추는 손실 압축 codec이다. FLAC은 정보를 버리지 않는 무손실 압축 codec이며, 디코딩 후 원본 PCM을 동일하게 복원한다.
미디어 URL을 분석할 때는 전송 방식, 포장 형식과 압축 방식을 나누어 봐야 한다.
| 계층 | 질문 | 예시 |
|---|---|---|
| protocol | 데이터를 어떻게 전달하는가? | HTTP Live Streaming (HLS), DASH, HTTP |
| container | 여러 track과 timestamp를 어떤 구조로 묶는가? | MPEG-TS, fragmented MP4 (fMP4), MP4, Ogg |
| sample codec | audio sample을 어떻게 압축하는가? | AAC, MP3, Opus, FLAC, Vorbis |
HLS는 codec이 아니다. HLS playlist가 MPEG-TS segment를 가리킬 수 있고, 그 안에 AAC 오디오가 들어갈 수 있다. 같은 HLS라도 fMP4 container와 다른 codec 조합을 사용할 수 있다.

“HLS를 사용하므로 AAC다”라고 단정하면 문제를 잘못된 계층에서 찾을 수 있다. 실제 codec은 manifest와 track format을 관측해서 확인해야 한다.
여기서 manifest는
AndroidManifest.xml이 아니라 adaptive streaming의 track 구성과 media segment 위치를 기술하는 문서다. HLS에서는.m3u8playlist가 이 역할을 한다. multivariant playlist(과거의 master playlist)는 선택 가능한 variant를 나열하고, media playlist는 segment URL과 재생 순서를 기술한다.
Multiplexer (muxer)는 여러 track과 timestamp를 하나의 container 구조로 묶는다. Demultiplexer (demuxer)는 container를 읽고 각 track의 압축 sample과 시간 정보를 분리한다.

Muxer는 PCM을 AAC로 압축하지 않는다. Demuxer도 AAC를 PCM으로 복원하지 않는다. 압축과 복원은 encoder와 decoder의 역할이다.
Media3 재생 파이프라인에서 Extractor는 demuxer에 가까운 역할을 한다. Extractor는 container를 분석하고 압축 access unit과 timestamp를 분리한다.
Remux는 codec을 유지한 채 container만 바꾸는 작업이다. Transcode는 원본을 디코딩하고 다른 codec이나 설정으로 다시 인코딩한다.
remux
MPEG-TS 안의 AAC → AAC sample 유지 → MP4에 다시 포장
transcode
AAC → PCM으로 decode → Opus로 encode
Remux는 sample을 다시 압축하지 않으므로 품질 손실과 연산량이 적다. Transcode는 bitrate, codec이나 sample rate를 바꿀 수 있지만 재인코딩 비용이 발생한다.
codec이라는 말의 어원은 coder-decoder지만, 미디어 개발에서는 두 가지 뜻으로 쓰인다. AAC, Opus와 FLAC 같은 sample format 규격을 가리키기도 하고, 그 규격을 실제로 인코딩하거나 디코딩하는 software·hardware 구현체를 가리키기도 한다. 문맥에 따라 둘을 구분해야 한다.
| codec | 압축 | 특징 |
|---|---|---|
| AAC | 손실 압축 | HLS와 MP4에서 널리 사용하며 profile 호환성을 확인해야 한다 |
| MP3 | 손실 압축 | 호환 범위가 넓지만 최신 codec보다 압축 효율이 낮을 수 있다 |
| Opus | 손실 압축 | 낮은 지연과 음성·음악 품질에 강점이 있다 |
| FLAC | 무손실 압축 | 디코딩 후 원본 PCM을 동일하게 복원한다 |
| Vorbis | 손실 압축 | Ogg container와 함께 사용하는 경우가 많다 |
Codec 규격 자체는 실행 코드가 아니다. 규격을 실제로 수행하는 software library나 hardware 회로가 codec 구현체다.

Android의 MediaCodec은 AAC나 H.264 같은 codec 규격이 아니다. MediaCodec은 단말에 설치된 codec 구현체를 찾고 input·output buffer를 주고받는 platform API다.
val codec = MediaCodec.createDecoderByType("audio/mp4a-latm")
이 코드는 AAC decoder 규격을 구현하지 않는다. Android codec registry에서 audio/mp4a-latm을 처리할 수 있는 구현체를 선택한다.
같은 AAC stream도 단말에 따라 다른 decoder가 선택될 수 있다.
c2.android.aac.decoder
c2.vendor.example.decoder
이 이름은 Java나 Kotlin의 package name이 아니라 Android에 등록된 codec component 이름이다.
c2.android.aac.decoder를 나누어 보면c2는 Codec 2.0 인터페이스,android는 Android Open Source Project (AOSP)가 제공하는 기본 구현,aac는 처리할 codec,decoder는 디코딩 역할을 뜻한다. 따라서 이 component는 AOSP의 software AAC decoder이며 hardware decoder가 아니다.
c2.vendor.*처럼 제조사 식별자가 붙은 이름은 vendor 구현을 나타낸다. 다만 이름만으로 hardware 가속 여부를 단정할 수는 없다. Android 10(API 29) 이상에서는 MediaCodecInfo의 capability 정보와 실제 재생 로그를 함께 확인한다.
일반적인 PCM 경로에서 Media3의 MediaCodecAudioRenderer는 ExoPlayer와 MediaCodec 사이를 연결한다. Renderer는 선택된 압축 sample을 decoder input buffer에 넣고, 출력된 PCM을 AudioSink로 전달한다.
FFmpeg는 미디어를 읽고 변환하고 인코딩·디코딩하는 명령줄 도구와 native library를 제공하는 오픈 소스 프로젝트다.
FFmpeg 자체는 codec이 아니며 내부에 여러 container 처리와 codec 구현을 포함한다. Media3는 기본적으로 Android platform의 MediaCodec을 사용하고, platform 지원이 부족한 포맷에는 별도의 software decoder extension을 검토할 수 있다.
MediaController와 MediaSession을 사용하면 화면, 알림, 잠금 화면과 Bluetooth 장치가 같은 Player를 제어할 수 있다. 특히 MediaSessionService에 플레이어를 두는 백그라운드 재생 구조에서는 Activity의 생명주기와 실제 재생을 분리하는 경계가 된다.

MediaController는 play(), pause(), seekTo() 같은 표준 Player 명령을 세션에 전달한다. MediaSession은 명령을 실제 ExoPlayer에 위임하고 재생 상태와 metadata를 controller에 동기화한다.
controller.play()
controller.pause()
controller.seekBack()
controller.seekForward()
controller.seekTo(positionMs)
이 제어 계층은 codec이나 PCM을 직접 다루지 않는다. controller가 다루는 것은 명령, timeline, 현재 위치, 재생 상태와 media metadata다.
반대로 포그라운드 화면 하나가 짧게 재생하고 종료되는 구조라면 반드시 controller와 session을 추가할 필요는 없다. 이때는 화면이 ExoPlayer를 직접 소유하는 더 단순한 구조도 가능하다.
ExoPlayer가 네트워크 오디오를 PCM으로 디코딩해 재생하는 데이터 경로를 단계별로 살펴보자.
MediaItem을 받은 DefaultMediaSourceFactory는 Uniform Resource Identifier (URI)와 Multipurpose Internet Mail Extensions (MIME) type을 보고 적절한 MediaSource를 만든다.
val mediaItem = MediaItem.Builder()
.setUri(streamUrl)
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build()
player.setMediaItem(mediaItem)
player.prepare()
HLS라면 HlsMediaSource가 playlist를 해석하고 media segment를 관리한다. URL만으로 유형을 판단할 수 없는 endpoint라면 MIME type을 명시하는 편이 안전하다.
DataSource는 HTTP, file, asset과 content URI 같은 입력에서 byte를 읽는 경계다. HLS 재생에서는 하나의 콘텐츠에도 여러 HTTP 요청이 발생한다.
GET master.m3u8
→ GET audio-variant.m3u8
→ GET segment-0001.ts
→ GET segment-0002.ts
→ GET segment-0003.ts
인증 header, timeout과 network stack 설정은 이 계층에 속한다. HTTP 401, 403이나 5xx 오류는 codec 오류가 아니라 decoder에 byte가 도달하기 전의 network 또는 server 오류다.
Extractor는 받은 byte에서 track, timestamp, codec 정보와 압축 access unit을 찾는다.
MPEG-TS segment
→ audio track
→ AAC codec 정보
→ timestamp
→ AAC access unit
UnrecognizedInputFormatException이 발생했다면 decoder보다 container, 응답 본문, URI 또는 MIME type을 먼저 확인해야 한다.
TrackSelector는 MediaSource가 제공한 track과 Renderer capability를 비교한다. codec profile, channel 수, sample rate, bitrate, 언어와 단말 decoder 지원 여부가 선택에 영향을 준다.
여러 bitrate를 제공하는 adaptive stream에서는 네트워크 추정치와 buffer 상태에 따라 다음 segment의 track이 바뀔 수 있다. 단일 audio track에서는 선택 과정이 줄어든다.
기본 오디오 경로에서는 MediaCodecAudioRenderer가 선택된 압축 sample을 Android MediaCodec decoder에 공급한다.
압축 AAC access unit
→ MediaCodec input buffer
→ 단말 decoder
→ PCM output buffer
Decoder 초기화 실패가 특정 단말에서만 발생한다면 codec MIME type만 보지 말고 profile, channel 수, sample rate와 실제 decoder 이름도 확인해야 한다.
Offload나 passthrough가 선택되면 압축 sample을 앱의 MediaCodec에서 PCM으로 바꾸는 이 단계가 우회될 수 있다.
기본 구현인 DefaultAudioSink는 volume, playback speed, channel mapping과 audio processor를 적용한다. 필요한 경우 PCM sample format과 sample rate를 출력 조건에 맞게 변환하고, AudioTrack에 데이터를 non-blocking 방식으로 기록한다. 출력 장치 변경 등에 따른 재구성과 재생 위치 보정도 이 계층의 책임이다.
Audio offload는 압축 오디오 처리를 단말 내부의 저전력 전용 장치에 맡기는 경로다. 장시간 재생의 전력 소모를 줄일 수 있지만 단말과 포맷에 따라 지원 여부가 다르다.
Passthrough는 압축 오디오를 PCM으로 바꾸지 않고 외부 출력 장치에 전달하는 경로다. 외부 장치가 최종 디코딩을 담당한다.
Offload는 단말 내부에서 처리하고, passthrough는 외부 장치가 처리한다.
| 출력 경로 | 압축 오디오를 처리하는 곳 | 사용하는 이유 |
|---|---|---|
| 일반 PCM | MediaCodec decoder가 PCM으로 복원 | audio processor, 배속과 여러 출력 장치를 폭넓게 지원 |
| Audio offload | 단말 내부의 저전력 전용 장치 | 화면을 끈 장시간 재생에서 CPU 사용과 전력 소모를 줄임 |
| Passthrough | HDMI로 연결한 TV나 AV receiver 같은 외부 장치 | 외부 장치가 지원하는 압축 bitstream을 그대로 전달 |
일반적인 PCM 경로에서 AudioTrack은 PCM buffer를 Android audio framework에 기록한다. 이후 system mixer, device routing, Hardware Abstraction Layer (HAL)와 장치 driver를 거쳐 스피커나 이어폰으로 출력된다. offload나 passthrough가 활성화되면 AudioTrack에 전달되는 데이터 형식과 하위 처리 경로가 달라질 수 있다.

원본 콘텐츠 codec과 Bluetooth 전송 codec은 별개다. Bluetooth Classic A2DP 출력에서는 일반적으로 원본 audio가 PCM으로 decode된 뒤, Bluetooth 출력 경로가 두 장치가 함께 지원하는 SBC·AAC·LDAC 등의 codec으로 다시 encode해 전송한다. 따라서 Bluetooth 설정에
AAC가 표시되어도 원본 콘텐츠 codec이 AAC라는 뜻은 아니다.
재생 중 끊김을 분석하려면 상위 미디어 buffer와 출력 직전의 audio buffer를 구분해야 한다.

Rebuffer 또는 re-buffering은 재생이 시작된 뒤 확보해 둔 media buffer가 부족해 플레이어가 다시 데이터를 기다리는 현상이다. 첫 소리가 나오기 전 데이터를 모으는 initial buffering과 구분되며, 재생 중 STATE_READY에서 STATE_BUFFERING으로 돌아가는 형태로 흔히 관측된다. 다만 모든 끊김이 반드시 이 상태 전이로 나타나는 것은 아니다. 네트워크 지연, segment 요청 실패나 sample queue 고갈이 원인일 수 있다.
Audio underrun은 AudioTrack이 소비하는 속도를 audio data 공급이 따라가지 못해 출력 buffer가 비는 현상이다. 일반적인 PCM 경로에서는 PCM 공급 부족을 뜻한다. STATE_READY를 유지한 채 짧은 무음, 끊김, 버벅임이나 클릭·팝 잡음이 발생할 수 있다.
| 구분 | audio underrun | rebuffer |
|---|---|---|
| 발생 위치 | AudioSink와 AudioTrack 출력 buffer | network와 sample queue를 포함한 상위 buffer |
| Player 상태 | STATE_READY에서도 발생 가능 | 주로 STATE_BUFFERING으로 전환 |
| 체감 증상 | 순간적인 무음, 끊김, 잡음 | 재생 중지 후 buffer 확보 시 재개 |
| 주요 관측 | onAudioUnderrun | STATE_BUFFERING, PlaybackStats rebuffer |
다음 요인은 audio underrun을 만들 수 있다:
AudioTrack 재구성AnalyticsListener.onAudioUnderrun()은 원인을 직접 알려주지 않는다. 대신 output buffer 크기와 마지막 buffer 공급 이후 시간을 알려준다. 같은 시점의 load, decoder, garbage collection과 thread 로그를 함께 봐야 원인을 좁힐 수 있다.
seekTo()가 호출되면 ExoPlayer는 timeline의 목표 위치를 media period와 segment 위치로 변환한다. 이동 거리와 이미 buffer된 범위에 따라 기존 sample queue를 재사용하거나 필요한 부분을 버리고, decoder를 flush한 뒤 목표 구간의 데이터를 준비한다.

Container index와 segment 경계 때문에 요청 시작점과 사용자가 듣는 정확한 sample 위치는 다를 수 있다. 일부 codec은 목표 위치를 디코딩하려면 앞선 sample이 필요하다. 목표 데이터가 memory buffer나 disk cache에 남아 있다면 새 network 요청 없이 seek가 끝날 수도 있다.
따라서 seek 지연을 분석할 때는 UI position뿐 아니라 segment 요청, sample queue 재구성, decoder flush와 onPositionDiscontinuity도 함께 확인해야 한다.
문서와 코드만으로는 운영 콘텐츠의 codec과 단말 decoder를 확정할 수 없다. Media3의 EventLogger는 player state, 선택된 track과 decoder를 Logcat에 출력한다.
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.util.EventLogger
val eventLogger: AnalyticsListener = EventLogger()
player.addAnalyticsListener(eventLogger)
다음 값을 먼저 확인하자:
mimeType: sample MIME typecodecs: codec string과 profile 정보channelCount: channel 수sampleRate: PCM sample rateaudioDecoderInitialized: 실제 선택된 decoder 이름state: BUFFERING, READY, ENDED 상태 변화tracks
mimeType=audio/mp4a-latm
codecs=mp4a.40.2
channels=2
sample_rate=44100
audioDecoderInitialized
c2.android.aac.decoder
EventLogger는 Media3가 직접 제공하는 디버깅 도구다. Firebase Analytics와는 관련이 없다. 전체 이벤트를 Logcat에 기록하므로 Debug 환경에서 재생 세션을 분석할 때 사용하는 편이 적절하다.
운영 품질 지표가 필요하면 AnalyticsListener에서 필요한 callback만 수집한다:
onAudioInputFormatChanged: MIME type, codec, bitrate, channel 수와 sample rateonAudioDecoderInitialized: decoder 이름과 초기화 시간onAudioUnderrun: output buffer 정보와 마지막 feed 이후 시간onPlaybackStateChanged: BUFFERING, READY, ENDED초기 재생 시간, rebuffer 횟수와 network byte를 집계하려면 PlaybackStatsListener도 사용할 수 있다. 로그에는 인증 token과 전체 media URL을 남기지 말아야 한다.
Media3의 play()는 긴 재생 파이프라인의 시작점이다. session 구조에서는 MediaController와 MediaSession이 명령을 전달하고, DataSource는 byte를 읽으며, Extractor는 container를 분리한다. 일반적인 경로에서는 MediaCodec이 압축 sample을 PCM으로 복원하고, AudioSink와 AudioTrack이 이를 실제 출력 장치로 전달한다. 단말과 출력 장치의 capability에 따라 offload나 passthrough가 이 경로의 일부를 대신할 수 있다.
처음에는 일반적인 PCM 경로부터 기억하면 된다.
URL → DataSource → Extractor → MediaCodec → PCM → AudioTrack → 스피커
재생 문제가 발생하면 증상과 가까운 계층부터 확인한다.
| 증상 | 먼저 확인할 곳 |
|---|---|
| 재생이 시작되지 않음 | URL, 인증, 응답 본문과 container |
| 특정 단말에서만 재생되지 않음 | 실제 MediaCodec decoder, profile과 channel 수 |
| 로딩이 자주 발생함 | network 요청, media buffer와 LoadControl |
| 순간적으로 소리가 끊김 | audio underrun, AudioSink와 AudioTrack |
| 실제 codec과 decoder가 궁금함 | EventLogger의 track과 decoder 로그 |
문제를 해결할 때는 protocol, container와 codec을 먼저 구분하자. 그다음 오류가 network, demux, decoder, PCM 처리와 장치 출력 중 어느 경계에서 발생했는지 확인한다. EventLogger, AnalyticsListener와 PlaybackStatsListener를 함께 사용하면 추측 대신 런타임 값으로 판단할 수 있다.
이 글에서 다루지 않은 심화 주제는 다음과 같다: