[Flutter] 메트로놈 앱을 만들며 깨달은 것, 네이티브의 중요성, 메서드 채널(MethodChannel)
메트로놈 앱을 Flutter로 개발하면서 겪은 가장 큰 도전은 바로 정확한 박자였습니다. 메트로놈의 핵심은 정확한 타이밍인데, Flutter의 한계로 인해 첫 박이 밀리거나 박자가 미묘하게 어긋나는 문제가 발생했죠.
문제의 시작: “왜 첫 박이 밀릴까?”
처음엔 단순한 구현 문제라고 생각했습니다. 하지만 깊이 파고들수록 Flutter의 구조적 한계라는 것을 깨달았습니다.
발견한 주요 문제들
1.첫 박자가 항상 늦는 현상
- DateTime.now()에서 바로 시작하여 격자 정렬이 안 됨
- 오디오 세션 초기화에 걸리는 시간이 첫 박에 그대로 반영
- Keep-alive로 부분적 개선은 했지만 근본적 해결책은 아니었음
2.”지금 재생하기” 방식의 한계
- Dart의 Timer는 OS 스케줄링에 영향을 받아 정확한 시점을 보장하지 못함
- 오디오 타임라인과 분리되어 시간이 지날수록 드리프트 누적
//현재 방식: 타이머가 울리면 그때 재생
Timer.periodic(duration, (timer) {
player.play(source); // 이 시점에서 OS 스케줄링에 따라 지연 발생
});
Dart3.플레이어 풀 재사용 시 상태 관리
- 8개의 AudioPlayer를 순환하며 사용
- play() 호출 후 이전 상태가 완전히 정리되지 않는 경우 발생
- 상태 초기화 타이밍 문제로 간헐적 지연
4.스케줄링 정밀도의 부재
- 미리 예약하는 Lookahead 기능 없이 타이머 콜백 시점에 즉시 재생
- 시스템 시간 변경에 취약한 DateTime 기반 구현
Isolate로 해결하려 했지만…
이전에 프로덕션 배포 시 광고 로직과 메트로놈 로직이 Flutter의 메인 스레드에서 함께 동작하면서 렉이 발생하는 문제가 있었습니다. 이를 해결하기 위해 메트로놈 로직을 Isolate로 분리했었죠.
// 기존: 메인 스레드에서 모든 처리
void startMetronome() {
// 광고 로직과 함께 실행되어 렉 발생
Timer.periodic(duration, (timer) {
player.play(source);
});
}
// 개선: Isolate로 분리
Isolate.spawn(metronomeIsolate, sendPort);
DartIsolate 분리로 UI 렉은 해결됐지만, 정확한 타이밍 문제는 여전히 남아있었습니다. Isolate는 단지 스레드를 분리할 뿐, Flutter AudioPlayers의 구조적 한계는 그대로였던 것이죠.
Flutter AudioPlayers의 구조적 한계
조사를 하면서 알게 된 것은, 이건 제 코드의 문제가 아니라 Flutter AudioPlayers 자체의 한계라는 점이었습니다.
// Flutter에서는 불가능한 것들:
// 1. 미래 시점에 재생 예약
player.play(at: futureTime); // ❌ 이런 API가 없음
// 2. 오디오 컨텍스트 직접 제어
audioContext.currentTime; // ❌ 접근 불가
// 3. 하드웨어 타이머 사용
hardwareTimer.schedule(); // ❌ 네이티브만 가능
Dart네이티브가 답이었다
네이티브 레벨에서는 이런 것들이 가능합니다.
iOS의 경우
// AVAudioEngine으로 정확한 시점에 예약
audioEngine.scheduleBuffer(buffer, at: AVAudioTime(hostTime: futureTime))
SwiftAndroid의 경우
// AudioTrack의 타임스탬프 기반 정밀 스케줄링
audioTrack.setPlaybackPositionUpdateListener {
...
}
Kotlin이들은 하드웨어 타이머를 사용하고, 오디오 스레드에서 독립적으로 실행되며, 버퍼 언더런을 방지하는 Lookahead 기능을 제공합니다.
네이티브 모듈로 완벽한 해결
결국 메트로놈 로직을 네이티브 모듈로 완전히 분리하면서 두 마리 토끼를 모두 잡을 수 있었습니다.
- 성능 문제 해결: 네이티브 오디오 스레드에서 독립적으로 실행되어 광고나 UI와 완전히 분리
- 타이밍 정확도 해결: 하드웨어 타이머 기반으로 정확한 박자 구현
Flutter와 네이티브 간 통신: MethodChannel
Flutter에서 네이티브 기능을 사용하기 위해서는 MethodChannel을 통한 플랫폼 간 통신이 필요합니다. MethodChannel은 Flutter(Dart)와 네이티브 코드(iOS/Android) 사이의 다리 역할을 하는 통신 채널입니다.
// Flutter 측 구현
class NativeMetronome {
static const platform = MethodChannel('metronome/native');
Future<void> start(int bpm) async {
try {
// 네이티브 메서드 호출
await platform.invokeMethod('start', {
'bpm': bpm,
'volume': 1.0,
});
} on PlatformException catch (e) {
print("메트로놈 시작 실패: ${e.message}");
}
}
Future<void> stop() async {
await platform.invokeMethod('stop');
}
}
Dart// iOS 측 구현 (Swift)
class MetronomePlugin: NSObject, FlutterPlugin {
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "metronome/native",
binaryMessenger: registrar.messenger()
)
let instance = MetronomePlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "start":
guard let args = call.arguments as? [String: Any],
let bpm = args["bpm"] as? Int else {
result(FlutterError(code: "INVALID_ARGS", message: nil, details: nil))
return
}
startMetronome(bpm: bpm)
result(nil)
case "stop":
stopMetronome()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
}
Swift// Android 측 구현 (Kotlin)
class MetronomePlugin: FlutterPlugin, MethodCallHandler {
private lateinit var channel: MethodChannel
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(
binding.binaryMessenger,
"metronome/native"
)
channel.setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"start" -> {
val bpm = call.argument<Int>("bpm")
val volume = call.argument<Double>("volume")
if (bpm != null) {
startMetronome(bpm, volume ?: 1.0)
result.success(null)
} else {
result.error("INVALID_ARGS", "BPM is required", null)
}
}
"stop" -> {
stopMetronome()
result.success(null)
}
else -> {
result.notImplemented()
}
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}
KotlinMethodChannel의 동작 원리:
- 채널 생성: Flutter와 네이티브 양쪽에서 동일한 이름(
'metronome/native'
)으로 채널 생성 - 메서드 호출: Flutter에서
invokeMethod()
로 네이티브 함수 호출 - 파라미터 전달: Map 형태로 데이터를 직렬화하여 전송
- 네이티브 처리: 네이티브 코드에서 메서드명에 따라 분기 처리
- 결과 반환:
result
콜백을 통해 Flutter로 결과 전달
이제 광고가 로드되든, UI가 복잡한 애니메이션을 하든 메트로놈은 정확한 박자를 유지합니다.
Flutter 내에서의 응급처치 (임시 방편)
네이티브 구현 전까지 Flutter 내에서 최대한 개선해본 방법들
- 플레이어 풀 대신 매번 새 인스턴스 생성
- 모노토닉 시계 사용 (Stopwatch)
- WAV 파일 사용 (디코딩 지연 최소화)
- 볼륨 변경 최소화 (오디오 세션 재설정 방지)
하지만 이런 개선에도 불구하고 완벽한 메트로놈은 만들 수 없었습니다.
깨달은 것: 적재적소에 맞는 기술 선택
이번 경험을 통해 배운 가장 큰 교훈은 “모든 것을 Flutter로 해결하려 하지 말자“는 것입니다.
- UI와 비즈니스 로직: Flutter가 최고
- 정밀한 오디오 타이밍: 네이티브가 필수
크로스 플랫폼의 장점은 분명하지만, 각 플랫폼의 특성을 활용해야 할 때는 과감하게 네이티브로 들어가는 것이 정답입니다. 특히 메트로놈처럼 정확성이 생명인 앱에서는 더욱 그렇죠.
다음 스텝
현재 iOS는 AVAudioEngine, Android는 AudioTrack을 사용한 네이티브 모듈을 개발 중입니다. Flutter의 편리함과 네이티브의 정확성을 결합한 하이브리드 접근법이 정답이라고 생각합니다.
메트로놈 앱을 만들며 배운 것
“때로는 한 발 물러서서, 올바른 도구를 선택하는 것이 더 빠른 길이다.”
Flutter는 훌륭한 프레임워크지만, 모든 문제의 해답은 아닙니다. 그리고 그것을 인정하고 받아들이는 것이 더 나은 개발자가 되는 길이라고 생각합니다.
이미 앱을 출시한 상황이지만, 사용자들에게 더 나은 경험을 제공하기 위해 빠르게 네이티브 모듈을 적용한 패치 버전을 준비해서 업데이트할 예정입니다. 정확한 박자는 메트로놈의 기본 중의 기본이니까요!
결론적으로 메트로놈 메인 로직을 네이티브에서 구현한 결과, 메트로놈의 정확도가 현저히 향상되었으며, 프로덕션 빌드 시 광고 로직이 돌아갈 때 메트로놈이 버벅이던 현상까지 모두 해결되었습니다. Isolate로는 해결하지 못했던 근본적인 문제를 네이티브로 해결한 셈이죠.
이에 관해 플러터와 네이티브 간의 비교 분석 글은 다른 글에서 상세하게 다뤄 보겠습니다. 읽어 주셔서 감사합니다.