iOS 메트로놈 개발 삽질기, 안드로이드 개발자의 48시간 사투

Hyunjun By Hyunjun 2025년 08월 18일

프로젝트 배경

Flutter로 개발한 Metronome 앱에서 안드로이드는 완벽하게 동작하는데, iOS에서만 온갖 문제가 발생하는 상황. iOS 네이티브와 사투를 벌인 48시간의 기록.

초기 상황

  • 안드로이드: SoundPool로 완벽 구현, 배포까지 완료
  • iOS: 소리도 안나고, 나와도 크래시, 나와도 느림
  • 목표: 안드로이드와 동일한 성능과 안정성

1단계: iOS 기본 구현 (처음부터 막힘)

문제 1: 소리가 아예 안남

// 처음 시도 - 당연히 안됨
func playSound() {
    // 어떻게 소리를 내지...?
}
Swift

해결: Bundle에서 Flutter assets 찾는 방법 학습

// Flutter assets 접근 방법 발견
guard let appFrameworkPath = Bundle.main.path(
    forResource: "Frameworks/App", 
    ofType: "framework"
),
let appBundle = Bundle(path: appFrameworkPath) else { 
    return 
}

let assetPath = "flutter_assets/assets/\(fileName)"
let url = appBundle.url(forResource: assetPath, withExtension: nil)
Swift

문제 2: .mp3에서 .wav로 전환

처음엔 안드로이드와 동일하게 .mp3 사용했는데…

  • 문제: iOS에서 .mp3 로딩이 불안정
  • 해결: 모든 오디오 파일을 .wav로 변환
// pubspec.yaml도 수정
final Map<String, String> soundFiles = {
  'click': 'sounds/click.wav',  // .mp3 → .wav
  'wood': 'sounds/wood.wav',
  'bell': 'sounds/bell.wav',
  'cat': 'sounds/cat.wav',
};
Dart

2단계: Accent 사운드 구현 (볼륨 vs 피치 대결)

초기 접근: 볼륨으로 악센트 구분

피치로 악센트를 주는 것이 이상하게 IOS는 구현하기가 까다로워 볼륨으로 접근 시도.

let volume = isAccent ? accentVolume : normalVolume
Swift

볼륨으로 악센트를 주기엔 악센트 느낌이 안나서 실패

AVAudioUnitTimePitch 시도 (실패)

// 피치 조절 시도 - 복잡하고 불안정
private var timePitchUnit: AVAudioUnitTimePitch
timePitchUnit.pitch = isAccent ? 1200 : 0  // cents 단위
Swift

문제: 음질 깨짐, 복잡한 구조, 여전히 크래시

최종 해결: _accent.wav 파일 분리

아이디어: 볼륨/피치 조절 대신 아예 별도 파일 사용

// 직접 로직으로 Pitch가 다른 음을 .wav 파일로 export 함.
let accentFileName = fileName.replacingOccurrences(of: ".wav", with: "_accent.wav")

// 예: click.wav + click_accent.wav
//     cat.wav + cat_accent.wav
Swift

결과: 완벽한 음질, 사용자가 원하는 정확한 피치 차이


3단계: AVAudioEngine 크래시 지옥

첫 번째 구현: AVAudioEngine + AVAudioPlayerNode

private let audioEngine = AVAudioEngine()
private var playerNodes: [AVAudioPlayerNode] = []
private var audioBuffers: [String: AVAudioPCMBuffer] = [:]

// 크래시의 주범
playerNode.scheduleBuffer(buffer, at: nil, options: [], completionHandler: nil)
Swift

크래시 로그:

Runner.debug.dylib  MetronomeEngine.playSoundSafely(volume:pitch:) + 1872 (MetronomeEngine.swift:533)
Plaintext

시도한 해결책들:

  1. 안전성 체크 추가 – 여전히 크래시
  2. 버퍼 풀링 – 여전히 크래시
  3. 메모리 관리 개선 – 여전히 크래시
  4. 스레드 안전성 – 여전히 크래시

4단계: AVAudioPlayer 시도 (성능 지옥)

풀링 방식 구현

private var audioPlayerPool: [AVAudioPlayer] = []
private let poolSize = 6  // 겹치는 소리 방지용

// 각 플레이어 생성에 400ms씩...
for i in 0..<poolSize {
    let player = try AVAudioPlayer(contentsOf: url)
    player.prepareToPlay()
    audioPlayerPool.append(player)
}
Swift

성능 문제:

  • 초기화: 4개 사운드 × 6개 풀 × 400ms = 10초 로딩
  • 사운드 변경: 매번 2.4초 대기

단일 플레이어 시도

private var audioPlayer: AVAudioPlayer?

// 매번 새로 생성
audioPlayer = try AVAudioPlayer(contentsOf: audioURL)
Swift

여전한 문제: accent 소리를 위해 임시 플레이어 생성 시 렉


5단계: SystemSound 발견 (빛과 그림자)

첫 번째 SystemSound 구현

private var systemSoundID: SystemSoundID = 0

// 드디어 빠른 재생!
AudioServicesPlaySystemSound(systemSoundID)
Swift
  • 장점: 즉시 로딩, 즉시 재생
  • 단점: 빠른 연속 재생에서 소리 씹힘

연속 재생 문제

SystemSound는 이전 소리가 끝나기 전에 새 소리 재생 시 씹힘


6단계: 최종 해결책 – SystemSound 멀티 인스턴스

핵심 아이디어: 풀링으로 씹힘 방지

// 여러 개의 동일한 SystemSound 생성
private var systemSoundIDs: [SystemSoundID] = []
private var accentSoundIDs: [SystemSoundID] = []
private var currentSoundIndex = 0
private let soundPoolSize = 4

// 로딩: 같은 소리를 4번 등록
for i in 0..<soundPoolSize {
    var soundID: SystemSoundID = 0
    AudioServicesCreateSystemSoundID(audioURL as CFURL, &soundID)
    systemSoundIDs.append(soundID)
}

// 재생: 순환하며 사용
let soundToPlay = soundsToUse[currentSoundIndex]
AudioServicesPlaySystemSound(soundToPlay)
currentSoundIndex = (currentSoundIndex + 1) % soundsToUse.count
Swift

최종 결과

Before vs After

항목Before (AVAudioEngine)After (SystemSound 풀)

로딩 시간10초+즉시
재생 지연수백ms거의 없음
크래시빈번전혀 없음
고속 재생불가능완벽
메모리 사용높음최소

성능 테스트 통과

  • 180+ BPM: 문제없음
  • 6연음: 소리 씹힘 없음
  • meow 사운드: 완벽 재생
  • 사운드 변경: 즉시 적용

핵심 교훈

1. “간단한 게 최고다”

복잡한 AVAudioEngine보다 단순한 SystemSound가 더 안정적

2. “성능 vs 기능 트레이드오프”

  • SystemSound: 볼륨 조절 불가, 하지만 빠르고 안정적
  • 해결: _accent.wav 파일로 우회

3. “플랫폼별 특성 이해”

  • Android: SoundPool이 최적
  • iOS: SystemSound가 최적
  • 같은 Flutter 앱이라도 네이티브는 다르게 구현

결론

48시간의 사투 끝에: 안드로이드만큼 완벽하게 동작하는 iOS 메트로놈 완성!

때로는 가장 단순한 API가 가장 강력한 솔루션이 될 수 있다는 것을 배웠다. iOS 오디오 개발에서는 적절한 API 선택이 성패를 좌우한다.

핵심: 복잡하게 갈 필요 없이, SystemSound + 멀티 인스턴스 풀링이면 충분!

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

목차