본문 바로가기

좌충우돌 1인 창업 일지

1인 창업 일지 #21 - Flutter 메트로놈 앱 백그라운드 재생 구현

반응형

서론

Flutter로 개발한 메트로놈 앱이 거의 완벽하다고 생각했습니다. 하지만 사용하다 보니 미세하게 박자가 안 맞는다던지, Android에서 갑자기 사운드 클리핑이 발생한다던지 하는 문제들이 하나둘 발견되기 시작했습니다.

 

무엇보다 가장 큰 문제는 백그라운드에서 재생이 안 된다는 것이었죠. 메트로놈 앱인데 다른 앱을 사용하면서 연습할 수 없다니, 이건 반드시 해결해야 할 문제였습니다.

 

"이번이 진짜 마지막 수정이다"라는 마음으로 시작한 작업이었지만, Android와 iOS 각 플랫폼의 특성 때문에 예상보다 훨씬 복잡한 여정이 되었습니다. 이 글은 그 과정에서 마주한 기술적 도전과 해결책을 정리한 기록입니다.


Android 편

발생한 문제들

1. 갑작스럽게 발생한 사운드 클리핑/박자 오류 (최근 이슈)

메트로놈 앱을 출시하고 몇 주 간 아무 문제가 없었는데, 최근에 확인해보니 갑자기 두 가지 증상이 나타났습니다.

  • 사운드 클리핑이 발생하거나
  • 클리핑은 없지만 박자가 불규칙하게 재생되는 현상

 

가장 혼란스러웠던 점

  • Android 15 실기기에서 문제 발생
  • Android 14 기기에서는 정상 작동
  • 에뮬레이터에서도 문제를 재현할 수 없음
  • 같은 Android 15라도 제조사/모델별로 다른 증상

 

이렇게 기기별로 다르게 동작하니 원인 파악이 정말 어려웠습니다. 결국 AudioAttributes 설정을 하나씩 테스트해보기 시작했고, 기존 설정에 문제가 있음을 발견했습니다.

// 🚫 문제가 있던 기존 코드
AudioAttributes.Builder()
    .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)  // 시스템 알림으로 분류됨
    .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)

 

원인 분석

  • Android OS 업데이트로 오디오 정책이 변경된 것으로 추정
  • USAGE_ASSISTANCE_SONIFICATION이 시스템 알림음으로 분류되어 제약이 생김

Android 15와 AudioAttributes의 관계: Android의 AudioAttributes는 오디오 시스템에 "왜 재생하는지(usage)", "무엇을 재생하는지(content type)"를 알려주는 중요한 정보입니다. Android 15에서는 오디오 포커스를 요청하려면 앱이 최상위 앱이거나 포그라운드 서비스를 실행 중이어야 한다는 새로운 제약이 추가되었습니다.

 

특히 Android 15는 자동차 오디오(AAOS)를 위한 시스템 강제 오디오 페이드 기능을 도입하면서 오디오 정책이 더 엄격해졌고, 이러한 변화가 일반 Android 기기에도 부분적으로 영향을 미친 것으로 보입니다. USAGE_ASSISTANCE_SONIFICATION은 본래 시스템 알림음을 위한 것인데, 메트로놈처럼 지속적으로 재생되는 앱에는 적합하지 않았던 것이죠.

 

2. 백그라운드 재생이 안 되는 문제

백그라운드 재생 기능을 추가하려고 했더니 화면이 꺼지면 메트로놈이 멈추는 문제가 발생했습니다.

 

원인

  • Android의 AudioFocus 정책이 앱을 차단
  • CPU가 절전 모드로 들어가면서 타이머 정확도 저하

 

3. 빠른 BPM에서 소리가 끊기는 현상

BPM 180 이상에서 비트 소리가 뭉개지거나 일부가 들리지 않는 문제가 발생했습니다.

  • SoundPool의 스트림 관리 부실
  • 오디오 버퍼 오버플로우

 

4. 볼륨 조절이 안 되는 문제

클리핑 문제를 해결하기 위해 FLAG_AUDIBILITY_ENFORCED를 사용했더니, 이번엔 시스템 볼륨 버튼이 작동하지 않는 새로운 문제가 생겼습니다.

 

해결 방법

1. 이중 AudioAttributes 전략

재생용과 포커스 제어용 AudioAttributes를 분리하여 사용하는 창의적인 해결법을 적용했습니다.

// ✅ 실제 재생용 (SoundPool에 사용)
val audioAttributes = AudioAttributes.Builder()
    .setUsage(AudioAttributes.USAGE_MEDIA)  // 미디어로 분류 → 백그라운드 허용
    .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
    .setFlags(AudioAttributes.FLAG_AUDIBILITY_ENFORCED)  // 클리핑 방지
    .build()

// ✅ 오디오 포커스 제어용 (AudioManager에 사용)
AudioAttributes.Builder()
    .setUsage(AudioAttributes.USAGE_GAME)  // 게임으로 분류 → 높은 우선순위
    .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
    .build()

핵심 아이디어: SoundPool은 USAGE_MEDIA로 백그라운드 제약을 회피하고, AudioFocus는 USAGE_GAME으로 높은 우선순위를 확보

 

2. 실시간 시스템 볼륨 반영

FLAG_AUDIBILITY_ENFORCED 사용으로 인한 볼륨 제어 문제를 해결하기 위해, 매 비트마다 시스템 볼륨을 체크하여 수동으로 적용했습니다.

// ✅ 시스템 볼륨을 실시간으로 반영
val maxVolume = audioManager?.getStreamMaxVolume(AudioManager.STREAM_MUSIC) ?: 15
val currentVolume = audioManager?.getStreamVolume(AudioManager.STREAM_MUSIC) ?: maxVolume

// 로그 스케일 적용 (사람 귀에 더 자연스러운 볼륨 변화)
val systemVolumeRatio = if (currentVolume == 0) {
    0.0f
} else {
    (currentVolume.toFloat() / maxVolume.toFloat()).pow(2.0f)
}

val finalVolume = appVolume * systemVolumeRatio

 

3. CPU Wake Lock으로 정확한 타이밍 보장

// ✅ CPU가 절전 모드로 들어가지 않도록 보장
private fun acquireWakeLock() {
    wakeLock = powerManager.newWakeLock(
        PowerManager.PARTIAL_WAKE_LOCK,
        "Metronome:MetronomePlayback"
    )
    wakeLock?.acquire()
}

 

4. 고우선순위 스레드 스케줄링

schedulerThread = HandlerThread("MetronomeScheduler",
    Process.THREAD_PRIORITY_URGENT_AUDIO - 1)

 

5. SoundPool 스트림 관리 최적화

// ✅ 스트림 정지 대신 추적만 수행 (자연스러운 감쇠)
if (activeStreams.size > 10) {
    val oldestStreams = activeStreams.take(5)
    activeStreams.removeAll(oldestStreams.toSet())
}

iOS 편

발생한 문제들

1. 백그라운드에서 타이머가 멈추는 문제

Android와 마찬가지로 iOS에서도 백그라운드 전환 시 메트로놈이 멈췄습니다.

// 🚫 백그라운드에서 작동 안 함
Timer.scheduledTimer(withTimeInterval: interval) { _ in
    playBeat()  // 백그라운드에서 실행되지 않음
}

 

2. 앱 최초 실행 시 BPM 설정 화면 프리징

앱을 처음 실행한 후 BPM 설정 모달을 열면 3초 정도 화면이 멈추는 현상이 발생했습니다.

 

원인 분석

  • BPM 모달창에 숫자 입력 TextField가 있음
  • 모달이 열리면서 TextField가 자동으로 포커스를 받음
  • 키보드 초기화 + AVAudioEngine 초기화가 동시에 일어나면서 렉 발생

 

3. 강박(Accent) 비트 타이밍 오류

첫 번째 비트에 강박이 들어가야 하는데 두 번째 비트에 들어가는 버그가 있었습니다.

 

해결 방법

1. Core Audio로 아키텍처 전환

타이머 기반 방식을 완전히 버리고 Core Audio의 실시간 렌더링으로 전환했습니다.

// ✅ Core Audio 실시간 렌더링
let audioSourceNode = AVAudioSourceNode { _, _, frameCount, audioBufferList in
    return self.renderBeat(frameCount: frameCount, audioBufferList: audioBufferList)
}

 

왜 Core Audio인가?

  • iOS는 백그라운드에서 타이머 실행을 제한
  • Core Audio는 시스템 레벨에서 정확한 타이밍 보장
  • 하드웨어 가속으로 배터리 효율성도 개선

 

2. 백그라운드 오디오 세션 구성

private func setupAudioSession() {
    do {
        try audioSession.setCategory(.playback, mode: .default, options: [])
        try audioSession.setActive(true)
    } catch {
        print("Audio session setup failed: \(error)")
    }
}

 

3. BPM 모달 자동 포커스 해제

// ✅ bpm_display.dart - 키보드 초기화 지연 방지
TextField(
    autofocus: false,  // 핵심: 자동 포커스 비활성화
    controller: controller,
    keyboardType: TextInputType.number,
)

 

4. Info.plist 백그라운드 권한 설정

<key>UIBackgroundModes</key>
<array>
    <string>audio</string>
</array>

특별한 해결책들

Android의 창의적인 볼륨 제어

FLAG_AUDIBILITY_ENFORCED는 클리핑을 방지하지만 시스템 볼륨을 무시한다는 딜레마가 있었습니다. 이를 해결하기 위해 매 비트마다 시스템 볼륨을 체크하여 수동으로 반영하는 방식을 구현했습니다.

// 매 비트마다 실행
val systemVolume = audioManager?.getStreamVolume(AudioManager.STREAM_MUSIC)
val finalVolume = appVolume * (systemVolume / maxVolume).pow(2.0f)  // 로그 스케일

 

정밀 타이밍 구현

Android: 나노초 단위 정확도

// 1ms 미만은 스핀 대기로 정확도 확보
if (timeUntilNext > SPIN_THRESHOLD_NS) {
    Thread.sleep(sleepTime / 1_000_000L, (sleepTime % 1_000_000L).toInt())
}
while (System.nanoTime() < nextBeatTimeNanos) {
    // Busy wait for precise timing
}

 

iOS: 샘플 단위 정밀도

  • 44.1kHz 기준 샘플 단위 정밀도 (±0.02ms)
  • Core Audio 하드웨어 동기화로 지터 최소화

 

셔플 모드 구현 (1-e&-a 패턴)

// subdivision=4일 때 스윙 느낌 구현
when (positionInMainBeat) {
    0 -> (beatIntervalNanos * 1.33).toLong()   // 1st: 긴 음표
    1 -> (beatIntervalNanos * 0.67).toLong()   // e: 짧은 음표  
    2 -> (beatIntervalNanos * 1.33).toLong()   // &: 긴 음표
    else -> (beatIntervalNanos * 0.67).toLong() // a: 짧은 음표
}

배운 점

1. 플랫폼별 네이티브 특성 이해의 중요성

Flutter의 크로스 플랫폼 추상화는 훌륭하지만, 실시간 오디오처럼 정밀한 제어가 필요한 경우에는 각 플랫폼의 네이티브 API를 직접 다뤄야 합니다.

 

2. Android AudioAttributes의 복잡성

단순히 하나의 설정으로 모든 것을 해결하려 하지 말고, 용도에 따라 다른 설정을 사용하는 유연한 접근이 필요합니다.

 

3. iOS Core Audio의 강력함

처음엔 복잡해 보였지만, 한번 제대로 구현하니 타이머 기반보다 훨씬 안정적이고 정확한 결과를 얻을 수 있었습니다.

 

4. 창의적인 문제 해결

FLAG_AUDIBILITY_ENFORCED로 인한 볼륨 제어 불가 문제를 실시간 시스템 볼륨 모니터링으로 해결한 것처럼, 때로는 우회적인 접근이 효과적일 수 있습니다.


결론

"이번이 진짜 마지막"이라고 생각했던 수정 작업은 예상보다 훨씬 깊고 복잡한 여정이 되었습니다. 하지만 그 과정에서 Android와 iOS의 오디오 시스템을 깊이 이해하게 되었고, 결과적으로 더 완성도 높은 앱을 만들 수 있었습니다.

 

특히 Android에서 FLAG_AUDIBILITY_ENFORCED로 인한 볼륨 제어 불가 문제를 실시간 시스템 볼륨 모니터링과 로그 스케일링으로 해결한 것은 클리핑 방지와 사용자 경험을 동시에 만족시키는 창의적인 접근법이었다고 자부합니다.

 

메트로놈 앱처럼 정밀한 타이밍과 안정적인 백그라운드 재생이 필요한 앱을 만들 때는, 크로스 플랫폼의 편의성과 네이티브의 성능 사이에서 적절한 균형을 찾는 것이 중요합니다. 때로는 플랫폼별로 완전히 다른 접근 방식을 취해야 할 수도 있지만, 그것이 최고의 사용자 경험을 제공하는 길이라면 망설이지 말아야 합니다.

 

이제 정말로 "완벽한" 메트로놈 앱이 되었을까요? 아마 또 다른 문제를 발견하게 될지도 모릅니다. 하지만 적어도 백그라운드에서도 정확한 박자를 유지하며, 사용자가 원하는 대로 볼륨을 조절할 수 있는 메트로놈이 되었다는 점에서 만족스럽습니다.

반응형