배경: iOS 지원 과정에서 생긴 예상치 못한 부작용
메트로놈 앱은 처음에 안드로이드용으로 개발되어 안정적으로 동작하고 있었습니다. 기존 안드로이드 버전은 SoundPool의 rate 파라미터를 사용해 하나의 사운드 파일을 다른 피치로 재생하여 액센트 효과를 구현했습니다.
// 기존 안드로이드 구현 (잘 동작함)
soundPool?.play(
currentSoundId,
volume, volume, 1, 0,
if (isAccent) accentPitch else 1.0f // rate로 피치 조절
)
문제의 시작: iOS 호환성 요구사항
iOS 지원을 위해 플랫폼별 차이점을 분석하던 중, iOS에서는 실시간 피치 변경이 안드로이드만큼 매끄럽지 않다는 것을 발견했습니다. iOS의 AudioServicesPlaySystemSound()는 피치 조절 기능을 제공하지 않아, 액센트 사운드를 별도의 WAV 파일로 제작하기로 결정했습니다.
새로운 아키텍처: 별도 액센트 파일
- 기존: click.mp3 (rate 조절로 피치 변경)
- 신규: click.wav + click_accent.wav (별도 파일)
이 변경으로 iOS에서는 문제가 해결되었지만, 크로스 플랫폼 일관성을 위해 안드로이드에서도 동일한 방식으로 변경하게 되었습니다.
디버깅 로그 삽입: 개발 과정의 자연스러운 선택
새로운 액센트 파일 시스템을 안드로이드에 적용하면서, 다음과 같은 우려사항들이 있었습니다.
- 파일이 제대로 로딩되는가?
- 올바른 액센트 파일이 선택되는가?
- Flutter와 네이티브 간 통신이 정상인가?
이런 불확실성 때문에 검증용 로그들을 추가하게 되었습니다.
// 액센트 파일 시스템 검증을 위한 로그들
fun loadSample(fileName: String) {
Log.d(TAG, "Loading sample: $fileName") // 파일 로딩 확인
val newSoundId = soundIds[wavFileName]
if (newSoundId != null) {
Log.d(TAG, "Updated current sound to: $wavFileName (ID: $currentSoundId)")
val accentSoundId = soundIds[accentFileName]
if (accentSoundId != null) {
Log.d(TAG, "Updated accent sound to: $accentFileName") // 액센트 파일 확인
}
}
}
private fun playBeat() {
val soundId = if (isAccent && currentAccentSoundId != 0) {
Log.d(TAG, "Playing accent sound (ID: $currentAccentSoundId)") // 액센트 재생 확인
currentAccentSoundId
} else {
Log.d(TAG, "Playing normal sound (ID: $currentSoundId)") // 일반 재생 확인
currentSoundId
}
}
개발 환경 vs 실제 사용 환경의 차이
개발 중에는 문제가 보이지 않았던 이유
- 짧은 테스트 시간: 몇 분간의 테스트로는 누적된 오버헤드를 체감하기 어려움
- 느린 박자 위주 테스트: 4분음표나 8분음표 중심으로 테스트하여 문제를 놓침
- 개발자 기기: 상대적으로 높은 성능의 디바이스에서 테스트
- 디버그 환경: 이미 로깅이 많은 환경에서 추가 로그의 영향을 구분하기 어려움
- 테스트 광고: 개발환경에서는 테스트 광고만 사용하여 실제 광고 로직에서 발생하는 스레드 경합 상황을 체험할 수 없었음
실제 사용자 환경에서 드러난 문제
// 16분음표 BPM 180에서의 실제 부하
초당 박자 수: 12회
매 박자당 로그: 최소 3개 (accent 확인 + normal 확인 + Flutter 통신)
총 로그 횟수: 초당 36번의 I/O 작업
사용자들이 실제로 연습에 사용하는 빠른 템포에서 문제가 드러났습니다.
- 16분음표 연습
- 긴 시간 연속 사용
- 다양한 성능의 기기들
성능 저하의 메커니즘
로그 출력의 실제 비용
Log.d(TAG, "Playing accent sound (ID: $currentAccentSoundId)")
이 한 줄이 실제로 수행하는 작업들
- 문자열 보간: $currentAccentSoundId 값을 문자열로 변환
- 메모리 할당: 새로운 문자열 객체 생성
- 시스템 콜: 안드로이드 로그 시스템 호출
- I/O 작업: logcat 버퍼에 쓰기
- 가비지 컬렉션: 임시 문자열 객체들의 정리
각 단계에서 0.1~1ms의 지연이 발생하며, 빠른 박자에서는 이것이 누적되어 타이밍 문제를 일으킵니다.
실시간 오디오의 타이밍 요구사항
메트로놈에서 허용 가능한 타이밍 오차
- 인간의 인지 한계: ±20ms 이내
- 음악적 정확도: ±10ms 이내
- 프로페셔널 수준: ±5ms 이내
16분음표 BPM 180에서 박자 간격이 83ms인 상황에서, 1-2ms의 지연도 전체 타이밍에 영향을 미칩니다.
해결 과정: 선택적 로그 제거
문제 진단
프로파일링을 통해 발견한 사실들
- CPU 사용률 자체는 정상
- 메모리 사용량도 문제없음
- 로그 I/O가 주요 병목 지점
해결 전략
1단계: 핫패스 로그 완전 제거
// 매 박자마다 실행되는 로그들 제거
val soundId = if (isAccent && currentAccentSoundId != 0) {
currentAccentSoundId // 로그 제거
} else {
currentSoundId // 로그 제거
}
2단계: 설정 변경 시 로그 제거
// BPM이나 사운드 변경 시 로그 제거 (실시간 변경 가능)
fun updateBpm(newBpm: Int) {
bpm = newBpm
// Log.d(TAG, "Updated BPM to: $bpm") // 제거
}
3단계: 초기화 로그만 유지
// 앱 시작 시에만 실행되는 중요한 로그는 유지
Log.d(TAG, "MetronomeEngine initialized")
Log.e(TAG, "Failed to load sound: $soundFile")
결과와 교훈
성능 개선 결과
- 로그 오버헤드: 초당 36회 → 0회
- 타이밍 정확도: 현저한 개선
- 사용자 만족도: 16분음표 연습 시 버벅거림 해결
얻은 교훈
1. 크로스 플랫폼 개발의 함정
한 플랫폼에서 잘 동작하던 것이 다른 플랫폼 지원 과정에서 의도치 않게 성능 저하를 일으킬 수 있음. 플랫폼별 최적화가 전체 성능에 미치는 영향 고려 필요.
2. 디버깅 로그의 라이프사이클 관리
개발 과정에서 추가된 검증용 로그는 검증 완료 후 즉시 제거. 특히 반복 실행되는 코드 경로에서는 더욱 주의.
3. 실시간 애플리케이션의 특수성
일반적인 앱과 달리 1ms의 지연도 사용자 경험에 직접 영향. 개발 환경과 실제 사용 환경의 차이 고려.
4. 성능 테스트의 중요성
다양한 템포와 사용 시간으로 테스트. 실제 사용 패턴을 반영한 테스트 시나리오 구성.
결론
이번 사례는 소프트웨어 개발에서 작은 변경이 예상치 못한 부작용을 일으킬 수 있음을 보여줍니다. iOS 지원이라는 선한 목적으로 시작된 개선이 안드로이드 성능 저하로 이어진 것은, 크로스 플랫폼 개발에서 각 플랫폼의 특성을 고려한 세심한 접근이 필요함을 시사합니다.
또한 디버깅을 위한 로그는 개발 도구일 뿐, 프로덕션 환경에서는 성능에 미치는 영향을 항상 고려해야 한다는 점을 다시 한번 확인할 수 있었습니다.
'앱 개발' 카테고리의 다른 글
| 구글 플레이스토어 vs 앱스토어: 실제 출시 경험을 통해 본 플랫폼 비교 (0) | 2025.09.16 |
|---|---|
| iOS 앱스토어 출시 완벽 가이드: 인앱결제부터 심사까지 (2) (0) | 2025.09.11 |
| iOS 앱스토어 출시 완벽 가이드: 인앱결제부터 심사까지 (1) (0) | 2025.09.11 |
| iOS 앱 리젝트 해결기: App Tracking Transparency (ATT) 구현 필수! (0) | 2025.09.03 |