새벽 2시, 운영 중인 핀테크 앱의 DAU가 급격히 떨어지기 시작했습니다. CS 채널에는 "앱을 켜자마자 꺼져요"라는 문의가 빗발치는데, 서버 로그는 깨끗했습니다. 클라이언트 사이드, 특히 특정 안드로이드 기기에서 발생하는 네이티브 레이어의 크래시(Native Layer Crash)는 일반적인 API 에러 로그로는 추적할 수 없습니다. 단순히 try-catch로 모든 예외를 잡았다고 생각했지만, 비동기(Async) 영역이나 플랫폼 채널(Platform Channel)에서 발생하는 치명적인 오류는 여전히 사각지대였습니다. 이 글에서는 Firebase Crashlytics를 통해 '보이지 않는 에러'를 시각화하고, 난독화된 스택 트레이스를 복구하는 과정을 엔지니어 관점에서 기술합니다.
현실적인 문제: try-catch의 한계와 비동기 누락
Flutter 3.16 버전으로 마이그레이션 하던 중, iOS 17 베타 사용자들에게서 간헐적인 튕김 현상이 보고되었습니다. 당시 우리는 모든 API 호출부에 try-catch를 적용하고, 전역 에러 핸들러로 Sentry를 사용 중이었습니다. 하지만 Sentry 무료 플랜의 한계와 더불어, 네이티브(Swift/Kotlin) 쪽에서 발생한 메모리 누수나 써드파티 SDK 충돌은 Dart 레벨의 에러 핸들러까지 도달하지 못하고 프로세스를 바로 종료시켜 버렸습니다.
사용자 화면: 앱 실행 -> 스플래시 화면 -> 즉시 종료 (Force Close)
개발자 로그:
Lost connection to device. (가장 절망적인 메시지)
특히 프로덕션 배포 빌드(Release Mode)는 성능 최적화를 위해 코드가 난독화(Obfuscation)되고 심볼이 제거(Stripping)됩니다. 따라서 운 좋게 로그를 확보했더라도, a.b.c()와 같은 형태의 의미 없는 문자열만 보게 됩니다. 이것이 바로 우리가 Firebase와 같은 강력한 크래시 리포팅 툴을 도입해야 하는 이유입니다.
실패 사례: 단순 FlutterError.onError의 함정
처음에는 공식 문서의 가장 윗줄만 읽고 FlutterError.onError만 재정의했습니다. 이렇게 하면 위젯 빌드 타임에 발생하는 동기적인 에러는 잘 잡힙니다. 하지만 Future나 Stream 내부에서 발생하는 비동기 에러, 혹은 MethodChannel을 통해 네이티브 코드에서 발생한 예외는 이 핸들러를 우회하여 앱을 터뜨렸습니다. "왜 로그가 안 남지?"라며 3일을 허비한 후에야 PlatformDispatcher와 runZonedGuarded(구 방식)의 차이를 명확히 이해하게 되었습니다.
해결책: 3중 방어막(Triple-Layer Defense) 아키텍처
모든 종류의 에러를 놓치지 않기 위해서는 Flutter 프레임워크 에러, 비동기 Dart 에러, 그리고 네이티브 플랫폼 에러를 각각 처리하는 3중 방어막을 구성해야 합니다. Flutter 3.3 이후 권장되는 PlatformDispatcher를 활용한 최신 초기화 패턴입니다.
// main.dart
import 'dart:ui'; // PlatformDispatcher 사용을 위해 필요
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
import 'firebase_options.dart'; // flutterfire configure로 생성된 파일
void main() async {
// 1. 엔진 초기화 보장
WidgetsFlutterBinding.ensureInitialized();
// 2. Firebase 초기화 (플랫폼별 설정 자동 로드)
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// 3. 치명적이지 않은 Flutter 프레임워크 에러 포착 (레이아웃 오버플로우 등)
FlutterError.onError = (errorDetails) {
FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails);
};
// 4. 비동기 에러 및 그 외 처리되지 않은 예외 포착 (PlatformDispatcher)
// 이전의 runZonedGuarded를 대체하는 현대적인 방식입니다.
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true; // 에러가 처리되었음을 명시
};
runApp(const MyApp());
}
위 코드에서 fatal: true 옵션은 매우 중요합니다. 이 플래그를 통해 Firebase 콘솔에서 해당 에러를 "치명적(Crashes)"으로 분류할지, 단순 "비치명적(Non-fatals)" 이벤트로 분류할지 결정합니다. 앱이 종료되는 수준의 에러라면 반드시 fatal: true를 설정하여 대시보드에서 우선순위를 높여야 합니다.
심층 분석: 난독화 해제와 dSYM 업로드
코드를 완벽히 적용하고 배포했는데, Crashlytics 대시보드에 (Missing) 혹은 hidden이라고 표시된다면 이는 심볼 파일이 업로드되지 않았기 때문입니다. 특히 iOS의 경우 이 과정이 자동화되지 않아 수동 설정이 필요한 경우가 많습니다.
android/app/build.gradle에 firebaseCrashlytics { mappingFileUploadEnabled true }가 설정되어 있다면 빌드 시 자동으로 매핑 파일이 업로드됩니다.
iOS의 경우, dSYM(디버그 심볼) 파일이 필수적입니다. Xcode Build Settings에서 Debug Information Format이 DWARF with dSYM File로 설정되어 있는지 확인해야 합니다. 만약 CI/CD(GitHub Actions 등)를 사용 중이라면, 빌드 후 생성된 dSYM 파일을 Firebase로 업로드하는 스크립트를 파이프라인에 추가해야만 정확한 스택 트레이스를 볼 수 있습니다.
강제 크래시 테스트 검증
설정이 올바르게 되었는지 확인하기 위해, 배포 전 반드시 강제 크래시를 발생시켜 봐야 합니다. 단순히 Dart 예외를 던지는 것뿐만 아니라, 네이티브 단의 크래시도 테스트해야 합니다.
// 테스트용 버튼에 연결하여 사용
TextButton(
onPressed: () {
// Dart 레벨의 에러 테스트
throw Exception('Firebase Crashlytics 테스트용 예외입니다.');
// 네이티브 크래시 테스트 (주석 해제 후 사용)
// FirebaseCrashlytics.instance.crash();
},
child: const Text("강제 크래시 유발"),
);
FirebaseCrashlytics.instance.crash()는 앱을 즉시 강제 종료시킵니다. 이 데이터는 실시간으로 반영되지 않을 수 있으며, 보통 앱을 재실행했을 때 이전 세션의 크래시 리포트가 서버로 전송됩니다. 대시보드 반영까지 최대 5분의 지연이 발생할 수 있음을 인지해야 합니다.
| 구분 | 기존 방식 (Local Logging) | Firebase Crashlytics 도입 후 |
|---|---|---|
| 에러 감지 범위 | 동기 Dart 코드 한정 | Dart(동기/비동기) + Native(Java/Obj-C) |
| 운영 대응 속도 | 사용자 제보 의존 (평균 2일) | 실시간 알림 및 분석 (평균 1시간) |
| 데이터 품질 | 단편적 스크린샷 | OS 버전, 기기 모델, 배터리 상태, 로그 컨텍스트 |
도입 결과, 원인을 알 수 없었던 "특정 삼성 기기에서의 간헐적 종료" 이슈가 사실은 특정 Android API 레벨에서 ImagePicker 라이브러리가 메모리 부족으로 강제 종료되는 현상임을 밝혀냈습니다. Crashlytics가 제공한 "User non-fatal" 로그와 "Memory Free" 상태 로그가 결정적인 단서가 되었습니다.
주의사항 및 엣지 케이스
Firebase Crashlytics를 사용할 때 주의해야 할 몇 가지 기술적 제약 사항이 있습니다.
- 개발 모드에서의 동작: 기본적으로 Crashlytics는 디버그 모드(`kDebugMode`)에서도 동작하지만, 개발 중 발생하는 수많은 에러가 콘솔을 오염시킬 수 있습니다. 따라서
if (!kDebugMode)조건을 걸어 프로덕션 환경에서만 리포팅하도록 설정하는 것이 일반적입니다. - iOS의 Isolate 오류: Dart의
Isolate(별도 스레드)에서 발생한 에러는 메인 스레드의PlatformDispatcher가 잡지 못할 수 있습니다. 별도의 Isolate를 생성하여 무거운 작업을 처리한다면, 해당 Isolate 내부에도 에러 리스너를 별도로 부착해야 합니다. - 개인정보 보호: 로그에 사용자 이메일이나 전화번호 같은 PII(개인 식별 정보)를 포함시키지 마십시오.
setCustomKey를 사용할 때는 익명화된 User ID만 사용하는 것이 규정 준수 측면에서 안전합니다.
FirebaseCrashlytics.instance.setCustomKey('last_action', 'payment_button_clicked')와 같이 사용자의 마지막 행동을 키-값 쌍으로 남겨두세요. 디버깅 시간이 획기적으로 줄어듭니다.
결론
앱의 안정성은 기능의 다양성보다 중요합니다. 사용자는 버그가 많은 화려한 앱보다, 기능이 적더라도 죽지 않는 앱을 신뢰합니다. Firebase Console을 통한 체계적인 모니터링 시스템 구축은 선택이 아닌 필수입니다. 지금 바로 여러분의 main.dart를 점검하고, 보이지 않는 곳에서 발생하고 있을지 모를 에러들을 수면 위로 끌어올리시길 바랍니다. 품질 관리는 문화이며, Crashlytics는 그 문화를 지탱하는 가장 강력한 도구입니다.
Post a Comment