
사내 서비스 중 하나는 네이티브 앱 안에 WebView를 띄워 React 기반 웹을 올린 WebView 기반 하이브리드 앱이다.
어느 날 기획에서 요구사항이 하나 들어왔다.
"명상 콘텐츠 재생을 위한 음악 플레이어 기능 추가해주세요."
다만, 다른 콘텐츠들과 다르게 명상 콘텐츠이므로 백그라운드 재생을 지원해야 합니다.
단순해 보였다. 프론트에서 이미 video.js 라이브러리 기반으로 다른 운동 따라하기, 건강 콘텐츠들에 대한 플레이어를 구현해둔 상황이었고, 명상 콘텐츠를 위한 플레이어도 프론트단에서 다 처리하면 되지 않을까?
결론부터 말하자면, 그렇게 하는 것이 불가능했다.
https://issues.chromium.org/issues/40611412

Chrome 브라우저에서는 MediaSession API를 통해 잠금화면과 알림바에서 미디어를 제어할 수 있다.
// Chrome 브라우저에서는 정상 동작
navigator.mediaSession.metadata = new MediaMetadata({
title: '힐링 BGM',
artist: 'My App',
});
navigator.mediaSession.setActionHandler('play', () => { /* ... */ });
navigator.mediaSession.setActionHandler('pause', () => { /* ... */ });
문제는 Android의 WebView는 Chrome이 아니라는 것이다.
WebView는 WebKit 기반으로 동작하며, navigator.mediaSession을 호출해도 시스템에 아무것도 전달되지 않는다. 프론트 개발자가 MediaSession을 아무리 정성껏 세팅해도, 백그라운드 진입시 잠금화면과 알림바에는 아무것도 나타나지 않는다.
즉, WebView 안에서 시스템 미디어 컨트롤을 제어하는 건 불가능하다.
왜 지원하지 않는지 궁금해서 Chromium 이슈를 찾아봤다. Google 측 담당자의 공식 답변은 이렇다.
Chrome이 직접 사용하는 플랫폼에서만 API를 활성화하기 때문이다. WebView에서는 이 API에 대한 기본 처리(default handling)가 존재하지 않는다. WebView를 사용하는 앱이라면 내부 메시징(JS Bridge 등)으로 이미 구현 가능하다고 보기 때문에, 높은 우선순위로 보지 않는다.
요약하면 구글의 공식 스탠스는 "JS Bridge로 알아서 해" 다. 2019년에 올라온 이슈가 2026년 현재도 여전히 열려 있는 것과 구글의 스탠스를 미루어 짐작건대, Android WebView의 MediaSession API 지원은 당분간 계획에 없을 것으로 보인다.
슬픈 사실은 iOS의 WKWebView는 MediaSession을 지원하기 때문에, 너무나도 잘 동작한다는 것...! ㅠㅠ
덕분에 별다른 네이티브 구현 없이도 WebView 내 플레이어만 만들어도 미디어 알림을 통한 백그라운드 재생, 잠금화면 알림 표시 등을 모두 지원할 수 있다.
처음엔 명상 콘텐츠를 위한 음악 플레이어 feature 전체를 네이티브로 마이그레이션하는 방안도 고려했다. 하지만 크게 두 가지 이유로 이 방법을 선택하지 않았다.
그래서 선택한 방식이 역할 분리다.

포그라운드에서는 프론트(video.js)가 그대로 재생을 담당하고, 앱이 백그라운드로 전환되는 시점에 현재 재생 중인 음원 URL과 재생 위치를 브릿지를 통해 네이티브로 전달한다. 백그라운드 전환 시 네이티브의 Media3 ExoPlayer가 전달받은 재생 시간부터 이어받아 재생을 계속한다.
이때 필요한 WebView 브릿지는 총 6개로, Web → App 3개 / App → Web 3개로 구성된다.
Web → App
| 브릿지 | 역할 | 호출 타이밍 | 전달 데이터 |
|---|---|---|---|
startAudioSession | 미디어 세션 시작 및 알림 표시 | 오디오 최초 재생 시 (1회) | title, thumbnailUrl, audioUrl |
updateAudioPlaybackState | 재생 상태 동기화 | play/pause 이벤트 발생 시마다, 백그라운드 전환 감지 시, 프론트 플레이어 seekbar를 통한 재생 시간 변경 시 | isPlaying(true/false), currentTime, duration |
endAudioSession | 미디어 세션 종료 및 알림 제거 | 오디오 종료 또는 플레이어 페이지 이탈 시 | - |
App → Web
| 브릿지 | 역할 | 호출 타이밍 | 전달 데이터 |
|---|---|---|---|
audioPlaybackCommand | 알림바 버튼 탭을 프론트 플레이어에 전달 | 알림바 재생/일시정지 버튼 탭 시 | isPlaying(true/false) |
audioSeekCommand | 알림바 seekbar 위치 변경을 프론트 플레이어에 전달 | 알림바 seekbar에서 재생 위치 변경 시 | currentTime |
audioResumePosition | 포그라운드 복귀 시 재생 위치 및 상태 복원 | 백그라운드에서 포그라운드 복귀 시 | currentTime, isPlaying(true/false) |
네이티브에서 MediaSession을 직접 다루면 Android 환경에서도 잠금화면 컨트롤, 알림바 미디어 컨트롤이 모두 정상 동작한다.
// MediaSession 설정
mediaSession = MediaSession.Builder(context, player)
.setCallback(object : MediaSession.Callback {
override fun onPlay(session: MediaSession, controller: MediaSession.ControllerInfo, ...) {
player.play()
}
override fun onPause(...) {
player.pause()
}
// ...
})
.build()
백그라운드 재생을 위한 별도의 네이티브 플레이어를 추가 구현했다고 해서 끝이 아니었다. 크게 두 가지 이슈가 더 있었다.
앱을 백그라운드로 전환하면 음악이 잠깐 멈췄다가 다시 재생되는 현상이 있었다.
원인은 WebView의 라이프사이클이 Activity와 함께 흘러가기 때문이었다. Activity.onPause() → WebView.onPause() 순서로 호출되면서, 프론트에서 재생 중이던 오디오가 일시정지됐다.
백그라운드에서는 네이티브 ExoPlayer가 재생을 담당하고, 포그라운드로 돌아오면 다시 프론트가 재생을 이어받는다. 문제는 이 전환 과정에서 싱크가 어긋나는 것이었다.
예를 들어 백그라운드 구간에서 알림바 seekbar로 탐색(Seek)이 발생하거나 네트워크 버퍼링이 생기면, 포그라운드로 복귀했을 때 프론트의 progress bar가 실제 재생 위치와 달라져 있었다.
프론트(WebView)에서 visibilitychange 이벤트로 백그라운드 전환 시점을 감지하고, updateAudioPlaybackState 브릿지로 현재 재생 위치를 네이티브에 전달한다. 네이티브의 ExoPlayer가 해당 위치부터 재생을 이어받는다.
document.addEventListener('visibilitychange', () => {
if (document.hidden && sessionStarted) {
Android.postMessage(JSON.stringify({
type: 'request',
handlerName: 'updateAudioPlaybackState',
requestId: crypto.randomUUID(),
data: {
isPlaying: true,
currentTime: video.currentTime,
duration: video.duration || 0
}
}));
}
});
백그라운드 전환 시점에 WebView 오디오가 정지되는 타이밍과 네이티브 ExoPlayer가 재생을 시작하는 그 사이의 간격이 이 끊김의 원인이었다.
브릿지가 비동기로 동작하고, 프론트/네이티브 각각 별개의 플레이어를 사용하는 이상 이 간격을 완전히 없앨 수는 없지만, 두 가지 보완 전략으로 간격을 최대한 줄여보았다.
보완 1. ExoPlayer 낙관적 사전 준비
startAudioSession 시점에 미리 ExoPlayer를 prepare() 해둔다. 백그라운드 전환 시 prepare()를 새로 기다릴 필요 없이 seekTo() + play()만 호출하면 되므로 재생 시작 지연이 크게 줄어든다.
// 세션 시작 시점에 ExoPlayer 미리 준비 (소리 출력 안 함, 대기 상태)
private fun prepareExoPlayer(audioUrl: String) {
exoPlayer?.apply {
setMediaItem(MediaItem.fromUri(audioUrl))
prepare()
}
}
// 백그라운드 전환 시 (Activity.onStop)
fun startNativePlayback() {
if (isExoPlayerPrepared && exoPlayer != null) {
exoPlayer?.seekTo(seekPositionMs)
exoPlayer?.playWhenReady = true
// ...
}
}
보완 2. 전환 지연 시간 보정
visibilitychange → updateAudioPlaybackState 브릿지 호출 → onStop() 순서로 진행되는데, onStop()이 실행될 때는 마지막으로 보고된 currentTime 시점보다 수백 ms가 지나 있다. raw currentTime을 그대로 seekTo()에 넘기면 그 차이만큼 이전 구간이 반복 재생된다.
예시)
재생 시간 1분 31초에서 visibilitychange 발생 → currentTime = 1분 31초 기록
ExoPlayer seekTo 호출 시점까지 700ms 경과
seekTo(1분 31초) → 1분 31초부터 다시 재생 (0.7초 구간 반복)
이를 해결하기 위해 마지막 보고 시점 이후 경과한 시간을 더해 추정 위치를 계산한다.
/**
* 웹 모드에서 현재 재생 위치를 추정한다.
*
* 마지막으로 updateAudioPlaybackState가 호출된 시점(positionSnapshotUptimeMs)으로부터
* 경과한 시간을 currentTimeMs에 더해 실제 재생 위치에 가까운 값을 반환한다.
*
* 재생 중이 아니거나 duration이 아직 확정되지 않은 경우,
* 보정 없이 마지막으로 보고된 currentTimeMs를 그대로 반환한다.
*/
private fun estimatedCurrentPositionMs(): Long {
if (isPlaying && positionSnapshotUptimeMs > 0 && durationMs > 0) {
val elapsed = SystemClock.uptimeMillis() - positionSnapshotUptimeMs
return (currentTimeMs + elapsed).coerceAtMost(durationMs)
}
return currentTimeMs
}
positionSnapshotUptimeMs: 마지막 updateAudioPlaybackState 호출 시점의 SystemClock.uptimeMillis()elapsed: 그 이후 경과한 시간currentTimeMs 반환싱크가 어긋나는 케이스는 두 가지다.
케이스 1. 알림바 seekbar에서 사용자가 재생 위치를 변경했을 때
네이티브가 audioSeekCommand 브릿지로 변경된 위치를 프론트에 전달하고, 프론트가 해당 위치로 seek한다.
case 'audioSeekCommand':
video.currentTime = msg.data.currentTime;
break;
케이스 2. 프론트 플레이어 seekbar에서 사용자가 재생 위치를 변경했을 때
프론트에서 seek 이벤트가 발생하면 updateAudioPlaybackState 브릿지로 변경된 위치를 네이티브에 전달하고, 네이티브가 MediaSession 알림의 progress를 갱신한다.
video.addEventListener('seeked', () => {
if (sessionStarted) {
Android.postMessage(JSON.stringify({
type: 'request',
handlerName: 'updateAudioPlaybackState',
requestId: crypto.randomUUID(),
data: {
isPlaying: !video.paused,
currentTime: video.currentTime,
duration: video.duration || 0
}
}));
}
});
어느 방향이든 한쪽이 변경되면 브릿지를 통해 반대쪽에 전달하는 방식으로 싱크를 맞췄다.
위 방식들의 적용으로 대부분의 문제는 해결됐지만, 완벽하지 않은 부분이 하나 있다.
프론트에서 progress bar를 드래그해 재생 위치를 변경하면, 미디어 알림에 즉각 반영되지 않는다.
이에 대한 근본적인 이유는 두 가지다.
JS Bridge는 비동기로 동작한다.
프론트에서 seek 이벤트가 발생하면 브릿지를 통해 네이티브로 전달되고, 네이티브가 ExoPlayer의 위치를 업데이트한 뒤 MediaSession에 반영되기까지 약간의 지연이 생긴다.
눈에 띄게 어색한 수준은 아니지만, 알림바의 progress가 살짝 늦게 싱크가 맞춰지는 게 체감된다.
두 플레이어는 본질적으로 독립적이다.
WebView 안의 video.js와 네이티브 MediaSession은 서로 다른 레이어에서 동작하기 때문에, 상태를 완벽하게 동기화하는 건 구조적으로 불가능하다.
이 지연 자체를 없애려면 결국 재생 자체를 처음부터 네이티브에서 통일하여 관리하는 수밖에 없다.
돌아보면, 처음에 음악 플레이어를 프론트에서 구현하기로 합의했던 건 딱히 네이티브에서 해야 할 이유를 찾지 못했기 때문이었다. 이제는 어떤 요구사항이 있을 경우 네이티브에서 구현해야 할지 알게 되었다...!
백그라운드 재생과 미디어 알림이 요구사항에 포함된다면, Android 앱에선 처음부터 네이티브에서 구현하는 것을 권장한다.
WebView 기반 하이브리드 앱에서 JS Bridge로 역할을 분리하는 방식도 동작은 하지만, 결국 두 플레이어 사이의 상태를 계속 맞춰줘야 하는 부담이 따른다. 요구사항이 복잡해질수록 그 부담은 커진다.
정리하면 이렇다.
Android WebView는 MediaSession API를 지원하지 않는다.
잠금화면·알림바 등의 미디어 컨트롤은 네이티브에서만 구현 가능하다.
WebView의 라이프사이클을 주의해야 한다.
Activity가 백그라운드로 전환되면 WebView도 함께 pause되면서 프론트에서 재생 중이던 audio가 정지된다.
두 플레이어의 상태를 완벽히 동기화하는 건 구조적으로 불가능하다.
JS Bridge의 비동기 특성상 항상 지연이 존재한다.
사실 위에서 채택한 해결 방식은 문제 없이 잘 돌아가는 것처럼 보이게 만든 어떤 흑마법(?)이라고도 할 수 있을 것 같다.
콘텐츠 재생을 위한 음악 플레이어가 앱의 핵심 기능은 아닌, 부가적인 기능이기 때문에 가능한 솔루션
음악 플레이어가 앱의 메인 기능이며, 포그라운드 재생과 백그라운드 재생간의 지연 싱크를 허락하지 않는다면... 반복해서 말하지만 네이티브로 구현하는 것이 정신 건강에 좋을 것이다.