Media3는 오디오를 어떻게 재생하는가?

easyhooon·2026년 7월 31일
post-thumbnail

서두

AndroidX Media3의 ExoPlayer는 URL 하나를 받아 재생하는 단순한 API처럼 보인다. 그러나 play() 호출과 스피커 출력 사이에서는 네트워크 로딩, container 분리, codec 선택, 디코딩, PCM 처리와 장치 출력이 차례로 일어난다. 이 글은 각 계층의 역할과 재생 장애를 관측하는 방법을 설명한다.

본론

Media3의 재생 파이프라인을 이해하려면 오디오 기본 용어부터 데이터가 이동하는 계층과 런타임 관측 방법까지 순서대로 살펴봐야 한다.

전체 파이프라인을 먼저 살펴보자

Media3의 오디오 재생은 명령을 전달하는 제어 계층과 실제 미디어 데이터를 처리하는 데이터 계층으로 나눌 수 있다.

아래 그림은 백그라운드 재생을 위해 session을 사용하고, 압축 오디오를 PCM으로 디코딩하는 일반적인 구성을 나타낸다.

Media3 전체 오디오 재생 파이프라인

MediaControllerMediaSession은 재생 명령과 상태를 전달한다. 오디오 byte를 다운로드하거나 디코딩하지는 않는다. 실제 미디어 데이터는 DataSource, Extractor, MediaCodec, AudioSink를 차례로 거친다.

따라서 “Media3가 어떤 codec을 사용하는가?”라는 질문에는 고정된 답이 없다. 실제 decoder는 콘텐츠 포맷, Android 버전, 제조사와 단말에 등록된 MediaCodec 구현에 따라 런타임에 결정된다.

MediaControllerMediaSession이 모든 재생에 필수인 것은 아니다. 화면이 떠 있는 동안만 재생하는 앱은 UI가 ExoPlayer를 직접 제어할 수도 있다. 백그라운드 재생, 알림, 잠금 화면과 외부 Bluetooth controller가 하나의 플레이어를 함께 제어해야 할 때 session 계층의 가치가 커진다.

또한 데이터 계층의 마지막이 언제나 MediaCodec → PCM → AudioTrack인 것은 아니다. 이 글은 가장 흔한 PCM 경로를 중심으로 설명하며, 단말과 포맷이 지원하면 audio offload나 passthrough가 선택될 수 있다.

디지털 오디오의 기본 단위

디지털 오디오는 시간에 따라 변하는 소리의 크기를 일정한 간격으로 측정한 숫자열이다. 재생 파이프라인을 이해하려면 압축 전 데이터인 Pulse-Code Modulation (PCM)과 압축된 codec 데이터를 구분해야 한다.

아날로그 파형이 압축 오디오와 PCM을 거쳐 출력되는 과정

서버나 파일에서 읽은 byte는 container 분석을 거쳐 압축 access unit으로 분리되고, decoder 입력으로 공급된다.

용어의미재생 파이프라인에서의 역할
sample특정 시점과 channel의 소리 크기를 나타내는 숫자PCM buffer를 이루는 최소 측정값
sample rate1초 동안 각 channel을 측정한 횟수44.1 kHz는 channel마다 초당 44,100번 측정
bit depthPCM sample 하나를 표현하는 bit 수16-bit는 sample 하나에 16 bit 사용
channel서로 독립된 오디오 신호 경로mono는 1개, stereo는 좌우 2개
PCMsample을 시간 순서대로 나열한 비압축 오디오 표현decoder 출력이자 AudioTrack 입력의 기본 형태
bitrate1초 분량의 저장 또는 전송에 필요한 bit 수128 kb/s처럼 네트워크와 파일 크기에 연결
access unitdecoder가 한 번에 처리할 수 있는 압축 오디오 조각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을 동일하게 복원한다.

protocol, container와 codec은 서로 다른 개념이다

미디어 URL을 분석할 때는 전송 방식, 포장 형식과 압축 방식을 나누어 봐야 한다.

계층질문예시
protocol데이터를 어떻게 전달하는가?HTTP Live Streaming (HLS), DASH, HTTP
container여러 track과 timestamp를 어떤 구조로 묶는가?MPEG-TS, fragmented MP4 (fMP4), MP4, Ogg
sample codecaudio sample을 어떻게 압축하는가?AAC, MP3, Opus, FLAC, Vorbis

HLS는 codec이 아니다. HLS playlist가 MPEG-TS segment를 가리킬 수 있고, 그 안에 AAC 오디오가 들어갈 수 있다. 같은 HLS라도 fMP4 container와 다른 codec 조합을 사용할 수 있다.

protocol, container와 sample codec의 관계

“HLS를 사용하므로 AAC다”라고 단정하면 문제를 잘못된 계층에서 찾을 수 있다. 실제 codec은 manifest와 track format을 관측해서 확인해야 한다.

여기서 manifest는 AndroidManifest.xml이 아니라 adaptive streaming의 track 구성과 media segment 위치를 기술하는 문서다. HLS에서는 .m3u8 playlist가 이 역할을 한다. multivariant playlist(과거의 master playlist)는 선택 가능한 variant를 나열하고, media playlist는 segment URL과 재생 순서를 기술한다.

muxer와 demuxer는 container를 묶고 분리한다

Multiplexer (muxer)는 여러 track과 timestamp를 하나의 container 구조로 묶는다. Demultiplexer (demuxer)는 container를 읽고 각 track의 압축 sample과 시간 정보를 분리한다.

Muxer가 track을 묶고 Demuxer가 분리하는 과정

Muxer는 PCM을 AAC로 압축하지 않는다. Demuxer도 AAC를 PCM으로 복원하지 않는다. 압축과 복원은 encoder와 decoder의 역할이다.

Media3 재생 파이프라인에서 Extractor는 demuxer에 가까운 역할을 한다. Extractor는 container를 분석하고 압축 access unit과 timestamp를 분리한다.

remux와 transcode도 구분해야 한다

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 규격과 codec 구현체를 구분하자

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 구현체다.

Codec 규격과 hardware, software decoder 구현체의 관계

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는 하나의 codec이 아니다

FFmpeg는 미디어를 읽고 변환하고 인코딩·디코딩하는 명령줄 도구와 native library를 제공하는 오픈 소스 프로젝트다.

FFmpeg 자체는 codec이 아니며 내부에 여러 container 처리와 codec 구현을 포함한다. Media3는 기본적으로 Android platform의 MediaCodec을 사용하고, platform 지원이 부족한 포맷에는 별도의 software decoder extension을 검토할 수 있다.

Media3의 제어 계층은 재생 상태를 한곳에 모은다

MediaControllerMediaSession을 사용하면 화면, 알림, 잠금 화면과 Bluetooth 장치가 같은 Player를 제어할 수 있다. 특히 MediaSessionService에 플레이어를 두는 백그라운드 재생 구조에서는 Activity의 생명주기와 실제 재생을 분리하는 경계가 된다.

MediaController와 MediaSession의 재생 제어 구조

MediaControllerplay(), 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를 직접 소유하는 더 단순한 구조도 가능하다.

일반적인 PCM 경로는 일곱 단계를 거친다

ExoPlayer가 네트워크 오디오를 PCM으로 디코딩해 재생하는 데이터 경로를 단계별로 살펴보자.

1. MediaSource가 재생 구조를 만든다

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을 명시하는 편이 안전하다.

2. DataSource가 실제 byte를 읽는다

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 오류다.

3. Extractor가 container를 분리한다

Extractor는 받은 byte에서 track, timestamp, codec 정보와 압축 access unit을 찾는다.

MPEG-TS segment
  → audio track
  → AAC codec 정보
  → timestamp
  → AAC access unit

UnrecognizedInputFormatException이 발생했다면 decoder보다 container, 응답 본문, URI 또는 MIME type을 먼저 확인해야 한다.

4. TrackSelector가 재생할 track을 고른다

TrackSelectorMediaSource가 제공한 track과 Renderer capability를 비교한다. codec profile, channel 수, sample rate, bitrate, 언어와 단말 decoder 지원 여부가 선택에 영향을 준다.

여러 bitrate를 제공하는 adaptive stream에서는 네트워크 추정치와 buffer 상태에 따라 다음 segment의 track이 바뀔 수 있다. 단일 audio track에서는 선택 과정이 줄어든다.

5. Renderer와 MediaCodec이 압축 audio를 PCM으로 바꾼다

기본 오디오 경로에서는 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으로 바꾸는 이 단계가 우회될 수 있다.

6. AudioSink가 데이터를 출력 형식에 맞게 처리한다

기본 구현인 DefaultAudioSink는 volume, playback speed, channel mapping과 audio processor를 적용한다. 필요한 경우 PCM sample format과 sample rate를 출력 조건에 맞게 변환하고, AudioTrack에 데이터를 non-blocking 방식으로 기록한다. 출력 장치 변경 등에 따른 재구성과 재생 위치 보정도 이 계층의 책임이다.

Audio offload는 압축 오디오 처리를 단말 내부의 저전력 전용 장치에 맡기는 경로다. 장시간 재생의 전력 소모를 줄일 수 있지만 단말과 포맷에 따라 지원 여부가 다르다.

Passthrough는 압축 오디오를 PCM으로 바꾸지 않고 외부 출력 장치에 전달하는 경로다. 외부 장치가 최종 디코딩을 담당한다.

Offload는 단말 내부에서 처리하고, passthrough는 외부 장치가 처리한다.

출력 경로압축 오디오를 처리하는 곳사용하는 이유
일반 PCMMediaCodec decoder가 PCM으로 복원audio processor, 배속과 여러 출력 장치를 폭넓게 지원
Audio offload단말 내부의 저전력 전용 장치화면을 끈 장시간 재생에서 CPU 사용과 전력 소모를 줄임
PassthroughHDMI로 연결한 TV나 AV receiver 같은 외부 장치외부 장치가 지원하는 압축 bitstream을 그대로 전달

7. AudioTrack이 Android 오디오 시스템에 데이터를 전달한다

일반적인 PCM 경로에서 AudioTrack은 PCM buffer를 Android audio framework에 기록한다. 이후 system mixer, device routing, Hardware Abstraction Layer (HAL)와 장치 driver를 거쳐 스피커나 이어폰으로 출력된다. offload나 passthrough가 활성화되면 AudioTrack에 전달되는 데이터 형식과 하위 처리 경로가 달라질 수 있다.

재생 명령부터 AudioTrack 출력까지의 시퀀스

원본 콘텐츠 codec과 Bluetooth 전송 codec은 별개다. Bluetooth Classic A2DP 출력에서는 일반적으로 원본 audio가 PCM으로 decode된 뒤, Bluetooth 출력 경로가 두 장치가 함께 지원하는 SBC·AAC·LDAC 등의 codec으로 다시 encode해 전송한다. 따라서 Bluetooth 설정에 AAC가 표시되어도 원본 콘텐츠 codec이 AAC라는 뜻은 아니다.

buffering과 audio underrun은 발생 지점이 다르다

재생 중 끊김을 분석하려면 상위 미디어 buffer와 출력 직전의 audio buffer를 구분해야 한다.

Rebuffer와 audio underrun이 발생하는 계층

Rebuffer 또는 re-buffering은 재생이 시작된 뒤 확보해 둔 media buffer가 부족해 플레이어가 다시 데이터를 기다리는 현상이다. 첫 소리가 나오기 전 데이터를 모으는 initial buffering과 구분되며, 재생 중 STATE_READY에서 STATE_BUFFERING으로 돌아가는 형태로 흔히 관측된다. 다만 모든 끊김이 반드시 이 상태 전이로 나타나는 것은 아니다. 네트워크 지연, segment 요청 실패나 sample queue 고갈이 원인일 수 있다.

Audio underrunAudioTrack이 소비하는 속도를 audio data 공급이 따라가지 못해 출력 buffer가 비는 현상이다. 일반적인 PCM 경로에서는 PCM 공급 부족을 뜻한다. STATE_READY를 유지한 채 짧은 무음, 끊김, 버벅임이나 클릭·팝 잡음이 발생할 수 있다.

구분audio underrunrebuffer
발생 위치AudioSinkAudioTrack 출력 buffernetwork와 sample queue를 포함한 상위 buffer
Player 상태STATE_READY에서도 발생 가능주로 STATE_BUFFERING으로 전환
체감 증상순간적인 무음, 끊김, 잡음재생 중지 후 buffer 확보 시 재개
주요 관측onAudioUnderrunSTATE_BUFFERING, PlaybackStats rebuffer

다음 요인은 audio underrun을 만들 수 있다:

  • Decoder 처리 지연이나 제조사 codec 문제
  • 높은 CPU 부하, 긴 garbage collection 또는 thread scheduling 지연
  • 비용이 큰 audio processor와 sample rate 변환
  • 작은 output buffer
  • 출력 장치 변경에 따른 AudioTrack 재구성
  • 상위 sample queue 고갈이 출력 계층까지 전파된 경우

AnalyticsListener.onAudioUnderrun()은 원인을 직접 알려주지 않는다. 대신 output buffer 크기와 마지막 buffer 공급 이후 시간을 알려준다. 같은 시점의 load, decoder, garbage collection과 thread 로그를 함께 봐야 원인을 좁힐 수 있다.

seek는 position 값만 바꾸지 않는다

seekTo()가 호출되면 ExoPlayer는 timeline의 목표 위치를 media period와 segment 위치로 변환한다. 이동 거리와 이미 buffer된 범위에 따라 기존 sample queue를 재사용하거나 필요한 부분을 버리고, decoder를 flush한 뒤 목표 구간의 데이터를 준비한다.

Media3 seek 과정의 내부 시퀀스

Container index와 segment 경계 때문에 요청 시작점과 사용자가 듣는 정확한 sample 위치는 다를 수 있다. 일부 codec은 목표 위치를 디코딩하려면 앞선 sample이 필요하다. 목표 데이터가 memory buffer나 disk cache에 남아 있다면 새 network 요청 없이 seek가 끝날 수도 있다.

따라서 seek 지연을 분석할 때는 UI position뿐 아니라 segment 요청, sample queue 재구성, decoder flush와 onPositionDiscontinuity도 함께 확인해야 한다.

EventLogger로 실제 파이프라인을 관측한다

문서와 코드만으로는 운영 콘텐츠의 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 type
  • codecs: codec string과 profile 정보
  • channelCount: channel 수
  • sampleRate: PCM sample rate
  • audioDecoderInitialized: 실제 선택된 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 rate
  • onAudioDecoderInitialized: decoder 이름과 초기화 시간
  • onAudioUnderrun: output buffer 정보와 마지막 feed 이후 시간
  • onPlaybackStateChanged: BUFFERING, READY, ENDED
  • Load event callback: segment load 시간, byte와 오류

초기 재생 시간, rebuffer 횟수와 network byte를 집계하려면 PlaybackStatsListener도 사용할 수 있다. 로그에는 인증 token과 전체 media URL을 남기지 말아야 한다.

결론

Media3의 play()는 긴 재생 파이프라인의 시작점이다. session 구조에서는 MediaControllerMediaSession이 명령을 전달하고, DataSource는 byte를 읽으며, Extractor는 container를 분리한다. 일반적인 경로에서는 MediaCodec이 압축 sample을 PCM으로 복원하고, AudioSinkAudioTrack이 이를 실제 출력 장치로 전달한다. 단말과 출력 장치의 capability에 따라 offload나 passthrough가 이 경로의 일부를 대신할 수 있다.

처음에는 일반적인 PCM 경로부터 기억하면 된다.

URL → DataSource → Extractor → MediaCodec → PCM → AudioTrack → 스피커

재생 문제가 발생하면 증상과 가까운 계층부터 확인한다.

증상먼저 확인할 곳
재생이 시작되지 않음URL, 인증, 응답 본문과 container
특정 단말에서만 재생되지 않음실제 MediaCodec decoder, profile과 channel 수
로딩이 자주 발생함network 요청, media buffer와 LoadControl
순간적으로 소리가 끊김audio underrun, AudioSinkAudioTrack
실제 codec과 decoder가 궁금함EventLogger의 track과 decoder 로그

문제를 해결할 때는 protocol, container와 codec을 먼저 구분하자. 그다음 오류가 network, demux, decoder, PCM 처리와 장치 출력 중 어느 경계에서 발생했는지 확인한다. EventLogger, AnalyticsListenerPlaybackStatsListener를 함께 사용하면 추측 대신 런타임 값으로 판단할 수 있다.

다음 단계로 더 알아볼 것들

이 글에서 다루지 않은 심화 주제는 다음과 같다:

  • HLS track 선택, cache와 network 재시도
  • 출력 장치 변경, offload와 passthrough의 단말별 차이
  • DRM과 secure decoder
  • 재생 품질 지표와 단말별 codec 검증

참고 자료

profile
실력은 고통의 총합이다. Android Developer

0개의 댓글