Capacitor에서 배경으로 오디오를 재생하려면 네이티브 구성 요소 두 가지가 필요합니다: audio iOS의 background mode와 Android의 mediaPlayback foreground service입니다. 미디어 세션을 통해 잠금 화면 컨트롤을 추가하면 사용자가 전화를 잠그거나 다른 앱으로 전환해도 음악, 팟캐스트, 명상 앱이 계속 재생됩니다. 이 가이드는 Capacitor 8에서 두 가지 설정 방법을 보여줍니다. @capgo/capacitor-native-audio 네이티브 재생을 위해 @capgo/capacitor-media-session 시스템 컨트롤을 위해.
백그라운드에서 오디오가 멈추는 이유
Capacitor 앱은 WebView를 감싸는 네이티브 셸이므로 각 플랫폼의 라이프사이클 규칙을 따릅니다:
| iOS | Android | |
|---|---|---|
| 백그라운드에서 일어나는 일 | 수 초 안에 앱이 일시정지됩니다 | Activity가 일시정지되고, WebView 타이머가 스로틀링되며, 프로세스가 종료될 수 있습니다 |
| 오디오를 유지하는 요소 | UIBackgroundModes 포함 audio, 오디오 세션 카테고리 playback |
타입의 포그라운드 서비스 mediaPlayback 알림과 함께 |
| 무음 스위치 | playback 카테고리가 무시합니다, ambient 지킵니다 |
해당 없음 |
| 잠금 화면 컨트롤 | MPNowPlayingInfoCenter 및 MPRemoteCommandCenter |
MediaSession 미디어 스타일 알림과 함께 |
| 스토어 규칙 | 사용자가 기대하는 가청 콘텐츠를 재생해야 합니다 (가이드라인 2.5.4) | 포그라운드 서비스 유형은 실제 사용과 일치해야 하며, Play Console에 선언해야 합니다 |
두 플랫폼 모두 기본적으로 백그라운드 오디오를 제공하지 않으며, 한쪽만 수정하는 것이 'iPhone에서는 작동하지만 Android에서는 멈춘다'는 버그 리포트의 가장 흔한 원인입니다.
재생 방식 선택하기
좋은 아키텍처가 두 가지 있습니다:
- 네이티브 재생 사용
@capgo/capacitor-native-audio: iOS에서는 AVFoundation, Android에서는 Media3 ExoPlayer가 오디오를 디코딩합니다. 음악 플레이어, 오디오북, 명상 앱, WebView가 스로틀링될 수 있는 모든 경우에 적합합니다. 로컬 파일, 원격 URL, HLS 스트림, 페이드, 재생 속도, Now Playing 메타데이터를 지원합니다. - HTML
<audio>및@capgo/capacitor-media-session: 기존 웹 플레이어를 유지하고 플러그인이 OS에 메타데이터와 컨트롤을 게시하도록 합니다. Android에서는 세션 재생 중에도 포그라운드 서비스를 실행합니다. 이미 웹 플레이어가 있거나 웹과 네이티브에 단일 code 경로를 필요로 할 때 가장 적합합니다.
@capgo/capacitor-native-audio |
<audio> + 미디어 세션 |
|
|---|---|---|
| 재생 엔진 | 네이티브 | WebView |
| 웹 지원 | 예 (HTML 오디오 폴백) | 지원 |
| 잠금 화면 메타데이터 | showNotification + notificationMetadata |
setMetadata |
| Android 포그라운드 서비스 | 개발자가 시작합니다 | 재생 또는 일시정지 중 플러그인이 시작합니다 |
| 리모컨 이벤트 | playbackState 리스너 |
setActionHandler |
| 정밀한 저지연 SFX | 지원 | 제한적 |
iOS 백그라운드 오디오 설정
열기 ios/App/App/Info.plist 그리고 백그라운드 모드를 추가하세요. Xcode의 Signing & Capabilities > Background Modes에서 “Audio, AirPlay, and Picture in Picture”를 체크하면 동일한 키가 작성됩니다:
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
오디오 세션 카테고리도 중요합니다. @capgo/capacitor-native-audio 자동으로 설정해 줍니다: showNotification: true 을(를) 사용합니다 .playback 기본 모드로, 다른 앱(예: Spotify)을 중단시키고 내 앱을 Now Playing 앱으로 만듭니다. showNotification: false 을(를) 사용합니다 .playback 포함 mixWithOthers은(는) 사운드 이펙트에 적합하지만 잠금 화면 컨트롤이 표시되지 않습니다. 트랙별로가 아니라 앱별로 하나를 선택하세요.
을(를) 통해 재생하는 경우 <audio>WebKit은 미디어가 시작되면 재생 세션을 활성화합니다. 처음에는 사용자 제스처로 재생을 시작해야 하며, 그렇지 않으면 iOS가 이를 차단합니다.
2026년 4월부터 필수 사항이므로, App Store Connect에 업로드하기 전에 Xcode 26 이상으로 빌드하세요. Capgo 빌드 Windows나 Linux에서 작업한다면 클라우드에서 처리할 수 있습니다.
Android 백그라운드 오디오 설정
에 권한 추가 android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
FOREGROUND_SERVICE_MEDIA_PLAYBACK 는 Android 14(API 34) 이상을 타겟으로 하는 앱에 필수이며, Capacitor 8은 API 36을 타겟으로 합니다. 이 권한이 없으면 서비스를 시작할 때 예외가 발생합니다. SecurityException. 게시할 때 Play Console에서 포그라운드 서비스 유형을 선언하고 사유를 설명하도록 요구합니다. “사용자가 시작한 미디어 재생”이 허용되는 용도입니다.
POST_NOTIFICATIONS 는 Android 13+에서 런타임 권한입니다. 미디어 세션 알림은 알림 권한에서 예외라 알림 창에 계속 표시되지만, 서비스가 게시하는 다른 알림에는 이 권한이 필요합니다.
Android 12+에서는 앱이 백그라운드에 있을 때 포그라운드 서비스 시작을 차단합니다. 액티비티가 표시된 상태에서 사용자가 재생을 누를 때 시작하세요.
옵션 A: 미디어 세션 플러그인으로 서비스 실행
@capgo/capacitor-media-session 은(는) 자체 매니페스트에 mediaPlayback 서비스를 선언하고 재생 상태를 다음으로 설정할 때 시작합니다 playing 또는 paused. 상태가 다시 none 로 돌아가면 서비스를 중지합니다. 앱 세션 전체 동안 서비스를 실행하려면 플러그인 설정을 capacitor.config.ts:
plugins: {
MediaSession: {
foregroundService: 'always',
},
},
로 설정하세요. 재생 중에만 시작하는 기본 설정이 대부분의 앱에 적합합니다.
옵션 B: 직접 포그라운드 서비스 시작
@capgo/capacitor-native-audio 재생, 포커스 및 미디어 알림을 처리하지만 포그라운드 서비스를 생성하지는 않습니다. 미디어 세션 플러그인 없이 사용할 경우 Android 프로젝트에 작은 서비스와 로컬 플러그인을 추가해야 합니다.
android/app/src/main/java/com/example/app/PlaybackService.java:
package com.example.app;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.Service;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.IBinder;
import androidx.core.app.NotificationCompat;
public class PlaybackService extends Service {
private static final String CHANNEL_ID = "playback";
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW);
getSystemService(NotificationManager.class).createNotificationChannel(channel);
}
Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Playing audio")
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true)
.build();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
} else {
startForeground(1, notification);
}
return START_NOT_STICKY;
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
}
PlaybackServicePlugin.java 같은 패키지에:
package com.example.app;
import android.content.Intent;
import androidx.core.content.ContextCompat;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
@CapacitorPlugin(name = "PlaybackService")
public class PlaybackServicePlugin extends Plugin {
@PluginMethod
public void start(PluginCall call) {
ContextCompat.startForegroundService(getContext(), new Intent(getContext(), PlaybackService.class));
call.resolve();
}
@PluginMethod
public void stop(PluginCall call) {
getContext().stopService(new Intent(getContext(), PlaybackService.class));
call.resolve();
}
}
다음에 등록하세요: MainActivity.java 첫 번째 제출이 승인되기 super.onCreate, 그리고 매니페스트에 서비스를 선언하세요:
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(PlaybackServicePlugin.class);
super.onCreate(savedInstanceState);
}
}
<service
android:name=".PlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
TypeScript에서:
import { Capacitor, registerPlugin } from '@capacitor/core';
interface PlaybackServicePlugin {
start(): Promise<void>;
stop(): Promise<void>;
}
export const PlaybackService = registerPlugin<PlaybackServicePlugin>('PlaybackService');
export const isAndroid = Capacitor.getPlatform() === 'android';
@capgo/capacitor-native-audio로 네이티브 오디오 재생
bun add @capgo/capacitor-native-audio
bunx cap sync
시작할 때 한 번 설정한 다음, 미리 로드하고 재생하세요:
import { NativeAudio } from '@capgo/capacitor-native-audio';
import { PlaybackService, isAndroid } from './playback-service';
await NativeAudio.configure({
focus: true, // request audio focus, pause others
background: true, // keep playing in the background
backgroundPlayback: true, // Android: skip auto pause on background
showNotification: true, // lock screen and notification controls
});
export async function playEpisode(id: string, url: string, title: string, artwork: string) {
await NativeAudio.preload({
assetId: id,
assetPath: url,
isUrl: true,
notificationMetadata: {
title,
artist: 'My Podcast',
artworkUrl: artwork,
},
});
if (isAndroid) await PlaybackService.start();
await NativeAudio.play({ assetId: id });
}
export async function stopEpisode(id: string) {
await NativeAudio.stop({ assetId: id });
await NativeAudio.unload({ assetId: id });
if (isAndroid) await PlaybackService.stop();
}
assetPath 웹 에셋 기준 상대 경로(audio/intro.mp3), file:// URL, https:// URL 또는 HLS .m3u8 스트림을 받습니다. 번들 에셋이 아닌 경우, 다음을 설정하세요. isUrl: true. 오디오 호스트에 인증이 필요하면 다음을 전달하세요 headers.
자동으로 다음 곡으로 넘어가는 플레이리스트 만들기
이 complete 화면이 잠겨 있어도 네이티브 플레이어가 계속 실행되므로 트랙이 끝나면 이벤트가 발생합니다:
const queue = [
{ id: 'ep1', url: 'https://cdn.example.com/ep1.mp3', title: 'Episode 1' },
{ id: 'ep2', url: 'https://cdn.example.com/ep2.mp3', title: 'Episode 2' },
];
let index = 0;
await NativeAudio.addListener('complete', async ({ assetId }) => {
await NativeAudio.unload({ assetId });
index += 1;
if (index < queue.length) {
const next = queue[index];
await NativeAudio.preload({
assetId: next.id,
assetPath: next.url,
isUrl: true,
notificationMetadata: { title: next.title, artist: 'My Podcast' },
});
await NativeAudio.play({ assetId: next.id });
} else if (isAndroid) {
await PlaybackService.stop();
}
});
트랙 사이에도 포그라운드 서비스를 계속 실행하세요. 트랙 하나 후 서비스를 중지했다가 백그라운드에서 다시 시작하면 Android 12+에서 다음 오류가 발생합니다 ForegroundServiceStartNotAllowedException.
잠금 화면과 UI 상태를 동기화하세요
사용자는 잠금 화면, 이어폰, 알림에서 일시정지를 누릅니다. 다음을 수신하세요 playbackState 네이티브 플레이어를 단일 진실 공급원으로 처리하세요:
await NativeAudio.addListener('playbackState', (event) => {
// event.state: 'playing' | 'paused' | 'stopped'
// event.reason: 'play', 'pause', 'remotePlay', 'complete', ...
store.setPlaying(event.isPlaying);
if (event.currentTime !== undefined) store.setPosition(event.currentTime);
});
await NativeAudio.addListener('currentTime', ({ currentTime }) => {
store.setPosition(currentTime); // every 100 ms while playing
});
앱이 포그라운드로 돌아올 때도 상태를 새로고침하세요. 사용 예: isPlaying 및 getCurrentTime상태를 새로고침하세요. 이벤트가 발생하는 동안 WebView가 일시정지되었을 수 있기 때문입니다.
미디어 세션 컨트롤이 포함된 HTML audio 요소를 사용하세요
이미 웹 플레이어가 있다면, 그대로 유지하면서 플러그인을 추가하세요:
bun add @capgo/capacitor-media-session
bunx cap sync
import { MediaSession } from '@capgo/capacitor-media-session';
const audio = new Audio();
audio.preload = 'auto';
export async function play(track: { url: string; title: string; artist: string; cover: string }) {
audio.src = track.url;
await audio.play();
await MediaSession.setMetadata({
title: track.title,
artist: track.artist,
artwork: [{ src: track.cover, sizes: '512x512', type: 'image/jpeg' }],
});
await MediaSession.setPlaybackState({ playbackState: 'playing' });
}
await MediaSession.setActionHandler({ action: 'play' }, async () => {
await audio.play();
await MediaSession.setPlaybackState({ playbackState: 'playing' });
});
await MediaSession.setActionHandler({ action: 'pause' }, async () => {
audio.pause();
await MediaSession.setPlaybackState({ playbackState: 'paused' });
});
await MediaSession.setActionHandler({ action: 'seekto' }, (details) => {
if (details.seekTime != null) audio.currentTime = details.seekTime;
});
await MediaSession.setActionHandler({ action: 'nexttrack' }, () => playNext());
audio.addEventListener('timeupdate', () => {
MediaSession.setPositionState({
duration: audio.duration || 0,
position: audio.currentTime,
playbackRate: audio.playbackRate,
});
});
호출하세요 setPositionState 프로덕션 환경에서는 초당 최대 1~2회로 제한하세요. 잠금 화면은 업데이트 사이를 보간하며, 브리지에 너무 많은 timeupdate 이벤트를 보내면 배터리가 낭비됩니다.
중단 및 오디오 경로 변경 처리
전화 통화, Siri, 알람 및 기타 앱이 재생을 방해합니다. 네이티브 오디오는 중단 시 일시 정지되고, iOS에서는 시스템이 재개하라고 할 때 다시 재생됩니다. 다음 두 가지 경우에도 주의가 필요합니다:
- 헤드폰 분리: 사용자는 재생이 일시 정지되기를 기대합니다. Android에서는 다음에 대한 receiver를 등록하세요
AudioManager.ACTION_AUDIO_BECOMING_NOISY서비스에서 플레이어가 이미 일시 정지하지 않는 경우. iOS에서는 route 변경을 수신하려면@capgo/capacitor-audio-session:
import { AudioSession, RouteChangeReasons } from '@capgo/capacitor-audio-session';
await AudioSession.addListener('routeChanged', (reason) => {
if (reason === RouteChangeReasons.OLD_DEVICE_UNAVAILABLE) {
pausePlayback();
}
});
await AudioSession.addListener('interruption', (type) => {
// 'began' or 'ended'
});
- Ducking vs. 일시 정지: 내비게이션 안내음은 오디오 볼륨을 낮춥니다(Ducking). 음성 앱은 볼륨이 낮아진 음성을 따라가기 어렵기 때문에 일시 정지하는 것이 좋습니다.
백그라운드 재생 테스트
시뮬레이터는 백그라운드 동작을 정확히 반영하지 않습니다. 실제 기기에서 테스트하세요:
- 재생을 시작하고 화면을 잠근 뒤 2분간 기다리세요. 오디오가 계속 재생되어야 합니다.
- 잠금 화면의 재생, 일시정지, 다음, 탐색 컨트롤을 사용한 뒤 잠금을 해제하고 UI가 일치하는지 확인하세요.
- 재생한 뒤 다른 오디오 재생 앱으로 전환했다가 다시 돌아오세요.
- 재생 중 전화 통화나 FaceTime 오디오를 시작하세요.
- Android에서는 배터리 절약 모드를 켜고 Samsung 또는 Xiaomi 기기에서 테스트하세요. 일부 OEM은 백그라운드 앱을 강하게 종료합니다. 포그라운드 서비스는 살아남지만 일반 백그라운드 태스크는 그렇지 않습니다.
- Android 13 이상에서는 알림 권한을 거부하고 재생이 여전히 작동하는지 확인하세요.
- 화면을 끈 상태로 플레이리스트가 여러 트랙을 연속 재생하도록 두세요.
adb shell dumpsys activity services | grep -A3 PlaybackService 서비스가 포그라운드에서 실행 중인지 표시합니다.
문제 해결
iOS에서 약 30초 후 오디오가 중단됩니다: UIBackgroundModes 없음 audio, 또는 세션 카테고리가 ambient 또는 soloAmbient. 확인하세요 Info.plist 소스가 아닌 빌드된 앱에서
Android에서 몇 분 후 오디오가 멈춤: 포그라운드 서비스가 없거나 서비스 유형이 일치하지 않음 mediaPlayback. 또한 OEM 배터리 최적화 설정도 확인하세요.
SecurityException: Starting FGS with type mediaPlayback ... requires permissions: 추가 FOREGROUND_SERVICE_MEDIA_PLAYBACK.
ForegroundServiceStartNotAllowedException: 앱이 백그라운드 상태일 때 서비스를 시작했습니다. 사용자가 재생 버튼을 탭할 때 시작하세요.
iOS에서 잠금 화면 컨트롤 불가: showNotification 값이 false(mixable session)이거나 메타데이터가 설정되지 않음. non-mixable session을 소유한 앱만 Now Playing 앱이 됩니다.
Android에서 알림이 두 개 표시됨: 활성화함 showNotification native audio에서 활성화하고 자체 서비스 알림도 게시했습니다. 서비스 알림은 최소화하거나 media session 플러그인의 서비스만 사용하세요.
다른 음악 앱이 내 앱과 겹쳐 계속 재생됨: mixable session으로 설정했습니다. 설정하세요 focus: true 및 showNotification: true.
플레이어 변경 사항을 더 빠르게 배포하세요
플레이어의 큐 로직, UI, 메타데이터, 분석은 모두 JavaScript에 있습니다. 위의 네이티브 설정이 스토어 빌드에 포함되면, Capgo Live Update 사용하기 새로운 리뷰 사이클 없이 해당 code에 수정 사항을 푸시할 수 있습니다. 네이티브 오디오 문서 및 미디어 세션 문서 모든 옵션을 다루며, 앱에서 오디오 녹음도 한다면 다음을 참고하세요: Capacitor 오디오 레코더 플러그인.