버벅이는 메트로놈, Isolate로 심폐소생한 이야기
안녕하세요. 최근 메트로놈 앱을 출시한 개발자입니다. 오늘은 제가 프로덕션에 앱을 출시한 후 겪었던 성능 문제와, Flutter의 Isolate를 사용해 이 문제를 해결한 경험을 공유하고자 합니다.
문제의 발단
개발과 테스트 단계에서는 모든 것이 정상적으로 작동했습니다. 박자는 정확했고, 애니메이션은 부드러웠습니다. 하지만 프로덕션 빌드를 출시한 후 심각한 문제를 발견했습니다.
릴리즈 모드에서 메트로놈 소리가 주기적으로 끊기고 지연되는 현상이 발생한 것입니다. 디버그 모드에서는 정상 작동하던 핵심 기능이 실제 환경에서는 제대로 동작하지 않았습니다.
원인 분석
Flutter는 싱글 스레드 이벤트 루프 모델로 동작합니다. 메인 스레드가 다음과 같은 모든 작업을 처리합니다.
- UI 렌더링 및 애니메이션
- 사용자 입력 이벤트 처리
- 네트워크 통신
- 메트로놈 타이머 로직
- 광고 SDK 로직
문제는 명확했습니다. 메트로놈은 밀리초 단위의 정확도가 요구되는 기능입니다. 그런데 이 시간 민감한 로직이 무거운 광고 로딩 작업과 같은 스레드에서 실행되고 있었습니다. 메인 스레드가 광고 SDK 작업으로 점유되면, 메트로놈의 다음 비트 타이밍을 놓치게 됩니다. 이것이 소리가 끊기고 지연되는 현상의 원인이었습니다.
해결책, Isolate를 통한 스레드 분리
Flutter의 Isolate는 독립된 메모리 공간을 가진 별도의 실행 컨텍스트입니다. 메인 스레드와 메모리를 공유하지 않아 동기화 문제에서 자유롭고, CPU 집약적인 작업을 처리하기에 적합합니다.
메트로놈의 타이머 로직을 별도의 Isolate로 분리하는 전략을 수립했습니다.
- 메인 스레드: UI와 사용자 입력만 처리
- 별도 Isolate: 메트로놈 타이머와 사운드 재생만 담당
- SendPort/ReceivePort를 통한 스레드 간 통신
구현 예시
Isolate 진입점 함수
void metronomeIsolateEntry(SendPort mainSendPort) {
final isolateReceivePort = ReceivePort();
mainSendPort.send(isolateReceivePort.sendPort);
Timer? metronomeTimer;
isolateReceivePort.listen((message) {
final command = message['command'];
switch (command) {
case 'start':
final int bpm = message['bpm'];
final interval = Duration(milliseconds: (60000 / bpm).toInt());
metronomeTimer?.cancel();
metronomeTimer = Timer.periodic(interval, (_) {
// 사운드 재생 신호 전송
mainSendPort.send({'event': 'tick'});
});
break;
case 'stop':
metronomeTimer?.cancel();
metronomeTimer = null;
break;
}
});
}
Dart메인 스레드에서의 Isolate 관리
class MetronomeService {
Isolate? _isolate;
SendPort? _isolateSendPort;
final _receivePort = ReceivePort();
Future<void> init() async {
_isolate = await Isolate.spawn(metronomeIsolateEntry, _receivePort.sendPort);
_receivePort.listen((message) {
if (message is SendPort) {
_isolateSendPort = message;
} else if (message['event'] == 'tick') {
// 메인 스레드에서 실제 사운드 재생
playSound();
}
});
}
void start(int bpm) {
_isolateSendPort?.send({'command': 'start', 'bpm': bpm});
}
void stop() {
_isolateSendPort?.send({'command': 'stop'});
}
void dispose() {
stop(); // 중요: 타이머를 먼저 정리
_isolate?.kill();
_receivePort.close();
}
}
Dart주의사항
Isolate 사용 시 주의해야 할 기술적 제약사항이 있습니다.
- Timer 정리 필수: Isolate를 종료하기 전에 반드시 모든 Timer를 cancel해야 합니다. 그렇지 않으면 VM 전체의 타이머가 멈출 수 있는 버그가 있습니다.
- 오디오 라이브러리 제약: audioplayers 같은 플랫폼 채널 의존 라이브러리는 Isolate에서 직접 사용할 수 없습니다. 타이머만 Isolate에서 돌리고 실제 사운드 재생은 메인 스레드에서 처리해야 합니다.
결과
Isolate 적용 후 다음과 같은 개선이 있었습니다.
- 정확성: 광고 로딩이나 복잡한 UI 작업 중에도 메트로놈 박자가 정확하게 유지됨
- 반응성: 메인 스레드 부하가 줄어 전체적인 앱 반응성 향상
- 안정성: 로직 분리로 각 컴포넌트의 독립성 확보
적용 가능한 다른 시나리오
이러한 스레드 분리 전략은 메트로놈뿐만 아니라 다음과 같은 상황에서도 유용합니다.
- 실시간 데이터 처리: 센서 데이터나 스트림 처리
- 복잡한 연산: 이미지 프로세싱, 대용량 데이터 파싱
- 백그라운드 작업: 파일 처리, 데이터베이스 동기화
- 게임 로직: 물리 엔진, AI 연산 등
메인 스레드와 경합할 수 있는 모든 무거운 작업은 Isolate로 분리하여 앱의 성능과 사용자 경험을 개선할 수 있습니다.
결론
성능 문제를 단순히 최적화로 해결하려 하기보다, 근본적인 스레드 모델의 한계를 이해하고 아키텍처 수준에서 접근한 것이 핵심이었습니다. Flutter 앱에서 시간 민감한 작업이나 CPU 집약적인 작업을 처리해야 한다면, Isolate를 통한 스레드 분리를 고려해보시기 바랍니다.