Flutter 릴리즈 모드 앱은 어떻게 디버깅하나요?

질문

Flutter 앱을 릴리즈 모드로 빌드한 후 발생하는 문제를 디버깅하는 방법에 대해 설명해주세요.

답변

Flutter 앱을 디버그 모드에서 릴리즈 모드로 전환하면 성능이 크게 향상되지만, 디버깅이 훨씬 어려워집니다. 릴리즈 모드에서는 디버그 심볼이 제거되고 코드가 최적화되기 때문입니다. 이러한 환경에서 문제를 디버깅하는 다양한 방법을 알아보겠습니다.

1. 릴리즈 모드와 디버그 모드의 차이점

우선 릴리즈 모드와 디버그 모드의 주요 차이점을 이해하는 것이 중요합니다:

특성 디버그 모드 릴리즈 모드
성능 느림 (디버깅 오버헤드) 빠름 (최적화됨)
코드 크기 작음 (최적화됨)
디버그 심볼 포함 제거됨
핫 리로드 지원 지원 안 함
어설션 활성화 비활성화
로깅 상세함 최소화됨

2. 사전 디버깅 준비

2.1 프로파일 모드 활용

릴리즈 모드로 바로 넘어가지 말고 프로파일 모드를 중간 단계로 활용하세요:

# 프로파일 모드로 실행
flutter run --profile

# 특정 기기에서 프로파일 모드로 실행
flutter run --profile -d <device_id>

프로파일 모드는 릴리즈 모드와 유사한 성능 특성을 가지지만, 성능 프로파일링이 가능합니다.

2.2 로깅 전략 구축

릴리즈 모드에서도 유용한 로깅 시스템을 구현하세요:

// logger.dart
enum LogLevel { debug, info, warning, error }

class Logger {
  static LogLevel currentLevel = LogLevel.info; // 릴리즈 모드에서의 기본 레벨

  static void setLogLevel(LogLevel level) {
    currentLevel = level;
  }

  static void debug(String message, [Object? error, StackTrace? stackTrace]) {
    _log(LogLevel.debug, message, error, stackTrace);
  }

  static void info(String message, [Object? error, StackTrace? stackTrace]) {
    _log(LogLevel.info, message, error, stackTrace);
  }

  static void warning(String message, [Object? error, StackTrace? stackTrace]) {
    _log(LogLevel.warning, message, error, stackTrace);
  }

  static void error(String message, [Object? error, StackTrace? stackTrace]) {
    _log(LogLevel.error, message, error, stackTrace);
  }

  static void _log(LogLevel level, String message, [Object? error, StackTrace? stackTrace]) {
    if (level.index < currentLevel.index) return;

    final now = DateTime.now();
    final timeString = '${now.hour}:${now.minute}:${now.second}.${now.millisecond}';
    final levelString = level.toString().split('.').last.toUpperCase();

    print('[$timeString][$levelString] $message');
    if (error != null) print('Error: $error');
    if (stackTrace != null) print('StackTrace: $stackTrace');
  }
}

2.3 출시 전 릴리즈 모드 테스트 자동화

CI/CD 파이프라인에 릴리즈 모드 테스트를 추가하세요:

# .github/workflows/release_test.yml 예시
name: Release Mode Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  release_test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: "3.10.0"
      - run: flutter pub get
      - run: flutter test
      - run: flutter build apk --release
      - name: Run integration tests in release mode
        run: flutter drive --driver=test_driver/integration_test_driver.dart --target=integration_test/app_test.dart --profile

3. 릴리즈 빌드에 디버그 정보 추가

3.1 Android에서 릴리즈 빌드에 디버그 심볼 유지

android/app/build.gradle 파일을 수정하여 릴리즈 빌드에도 디버그 정보를 포함할 수 있습니다:

android {
    // ...
    buildTypes {
        release {
            // 기존 설정 유지
            signingConfig signingConfigs.release
            minifyEnabled true
            shrinkResources true

            // 디버그 정보 유지 추가
            debuggable false  // true로 설정하면 더 많은 디버그 정보 유지되지만 보안 위험
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

proguard-rules.pro 파일에 다음 내용을 추가:

# 스택트레이스 보존
-keepattributes SourceFile, LineNumberTable

# 예외 관련 클래스 보존
-keep public class * extends java.lang.Exception

3.2 iOS에서 릴리즈 빌드에 디버그 심볼 유지

Xcode에서 Runner 프로젝트 설정:

  1. Build Settings 탭 선택
  2. Strip Debug Symbols During CopyNo로 설정
  3. Deployment PostprocessingNo로 설정
  4. Generate Debug SymbolsYes로 설정

4. 오류 추적 및 크래시 리포팅 통합

4.1 Firebase Crashlytics 설정

# pubspec.yaml
dependencies:
  firebase_core: ^2.13.1
  firebase_crashlytics: ^3.3.2

애플리케이션 초기화:

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  // 디버그 모드가 아닌 경우에만 Crashlytics 활성화
  if (!kDebugMode) {
    // Crashlytics에 오류 전달
    FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;

    // 플랫폼 예외 캡처
    PlatformDispatcher.instance.onError = (error, stack) {
      FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
      return true;
    };
  }

  runApp(MyApp());
}

4.2 Sentry 설정

# pubspec.yaml
dependencies:
  sentry_flutter: ^7.9.0

애플리케이션 초기화:

import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';

Future<void> main() async {
  await SentryFlutter.init(
    (options) {
      options.dsn = 'YOUR_SENTRY_DSN';
      options.tracesSampleRate = 1.0; // 성능 모니터링
    },
    appRunner: () => runApp(MyApp()),
  );
}

커스텀 이벤트 및 예외 로깅:

try {
  // 위험한 작업
  final result = await riskyOperation();
  // ...
} catch (exception, stackTrace) {
  await Sentry.captureException(
    exception,
    stackTrace: stackTrace,
  );

  // 사용자에게 오류 표시
  showErrorDialog('작업을 완료할 수 없습니다. 나중에 다시 시도해주세요.');
}

5. 사용자 피드백 및 원격 진단

5.1 인앱 피드백 메커니즘

// feedback_service.dart
class FeedbackService {
  static Future<void> submitFeedback({
    required String email,
    required String feedback,
    required BuildContext context,
    String? screenshot,
    Map<String, dynamic>? deviceInfo,
    Map<String, dynamic>? appInfo,
  }) async {
    // 기기 정보 수집
    final deviceData = deviceInfo ?? await _collectDeviceInfo();

    // 앱 정보 수집
    final appData = appInfo ?? await _collectAppInfo();

    // 피드백 데이터 구성
    final feedbackData = {
      'email': email,
      'feedback': feedback,
      'device_info': deviceData,
      'app_info': appData,
      'timestamp': DateTime.now().toIso8601String(),
      'screenshot': screenshot,
    };

    // 서버로 피드백 전송
    await _submitToServer(feedbackData);

    // 성공 메시지 표시
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('피드백을 주셔서 감사합니다!')),
    );
  }

  static Future<Map<String, dynamic>> _collectDeviceInfo() async {
    // 기기 정보 수집 로직
    return {
      'platform': Platform.operatingSystem,
      'version': Platform.operatingSystemVersion,
      'device_model': 'Unknown', // device_info_plus 패키지로 수집 가능
    };
  }

  static Future<Map<String, dynamic>> _collectAppInfo() async {
    // 앱 정보 수집 로직
    final packageInfo = await PackageInfo.fromPlatform();
    return {
      'version': packageInfo.version,
      'build_number': packageInfo.buildNumber,
    };
  }

  static Future<void> _submitToServer(Map<String, dynamic> data) async {
    // 서버로 데이터 전송 로직
    final response = await http.post(
      Uri.parse('https://your-api.com/feedback'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode(data),
    );

    if (response.statusCode != 200) {
      throw Exception('Failed to submit feedback');
    }
  }
}

5.2 원격 구성

Firebase Remote Config를 사용하여 원격으로 디버그 모드를 활성화할 수 있습니다:

import 'package:firebase_remote_config/firebase_remote_config.dart';

class RemoteConfigService {
  final FirebaseRemoteConfig _remoteConfig;

  RemoteConfigService._() : _remoteConfig = FirebaseRemoteConfig.instance;

  static Future<RemoteConfigService> create() async {
    final service = RemoteConfigService._();
    await service._initialize();
    return service;
  }

  Future<void> _initialize() async {
    await _remoteConfig.setConfigSettings(RemoteConfigSettings(
      fetchTimeout: const Duration(minutes: 1),
      minimumFetchInterval: const Duration(hours: 1),
    ));

    await _remoteConfig.setDefaults({
      'enable_verbose_logging': false,
      'log_level': 'info',
      'enable_crash_reporting': true,
    });

    await _remoteConfig.fetchAndActivate();
  }

  bool get isVerboseLoggingEnabled => _remoteConfig.getBool('enable_verbose_logging');

  String get logLevel => _remoteConfig.getString('log_level');

  bool get isCrashReportingEnabled => _remoteConfig.getBool('enable_crash_reporting');
}

6. 릴리즈 모드에서 성능 모니터링

6.1 사용자 정의 성능 추적

import 'package:firebase_performance/firebase_performance.dart';

class PerformanceMonitor {
  // 단일 작업 추적
  static Future<T> trackTask<T>({
    required String name,
    required Future<T> Function() task,
  }) async {
    final trace = FirebasePerformance.instance.newTrace(name);
    await trace.start();

    try {
      final result = await task();
      await trace.stop();
      return result;
    } catch (e) {
      await trace.stop();
      rethrow;
    }
  }

  // HTTP 요청 추적
  static Future<http.Response> trackHttpRequest(
    http.Client client,
    Uri url, {
    Map<String, String>? headers,
    Object? body,
    Encoding? encoding,
    String? method = 'GET',
  }) async {
    final metric = FirebasePerformance.instance.newHttpMetric(
      url.toString(),
      method == 'GET' ? HttpMethod.Get : HttpMethod.Post,
    );

    await metric.start();

    late final http.Response response;
    try {
      if (method == 'GET') {
        response = await client.get(url, headers: headers);
      } else if (method == 'POST') {
        response = await client.post(
          url,
          headers: headers,
          body: body,
          encoding: encoding,
        );
      } else {
        throw ArgumentError('Unsupported HTTP method: $method');
      }

      metric.httpResponseCode = response.statusCode;
      metric.responseContentType = response.headers['content-type'];
      metric.requestPayloadSize = body.toString().length;
      metric.responsePayloadSize = response.bodyBytes.length;

      await metric.stop();
      return response;
    } catch (e) {
      await metric.stop();
      rethrow;
    }
  }
}

6.2 Firebase Performance Monitoring

# pubspec.yaml
dependencies:
  firebase_performance: ^0.9.2+2

애플리케이션 초기화:

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_performance/firebase_performance.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  // Firebase Performance Monitoring 설정
  FirebasePerformance firebasePerformance = FirebasePerformance.instance;
  firebasePerformance.setPerformanceCollectionEnabled(true);

  runApp(MyApp());
}

7. 릴리즈 모드 특화 디버깅 기법

7.1 조건부 로깅

릴리즈 모드에서만 나타나는 문제를 디버깅하기 위한 조건부 로깅:

import 'package:flutter/foundation.dart';

void conditionalLog(String message, {bool evenInRelease = false}) {
  if (kDebugMode || evenInRelease) {
    print('[${DateTime.now()}] $message');

    // 릴리즈 모드에서는 로그를 파일이나 원격 서버에 저장할 수도 있음
    if (!kDebugMode && evenInRelease) {
      _saveLogToFile(message);
    }
  }
}

void _saveLogToFile(String message) {
  // 로그를 파일에 저장하는 로직
}

7.2 비밀 개발자 메뉴

개발자 전용 디버그 메뉴를 구현하여 릴리즈 빌드에서도 문제 진단:

class DeveloperMenu extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('개발자 메뉴')),
      body: ListView(
        children: [
          ListTile(
            title: Text('로그 레벨 설정'),
            subtitle: Text('현재: ${Logger.currentLevel.toString().split('.').last}'),
            onTap: () => _showLogLevelDialog(context),
          ),
          ListTile(
            title: Text('네트워크 요청 로깅'),
            subtitle: Text('모든 API 호출 기록'),
            onTap: () => _toggleNetworkLogging(context),
          ),
          ListTile(
            title: Text('UI 렌더링 디버깅'),
            onTap: () => _togglePerformanceOverlay(context),
          ),
          ListTile(
            title: Text('로그 내보내기'),
            onTap: () => _exportLogs(context),
          ),
          ListTile(
            title: Text('메모리 사용량'),
            onTap: () => _showMemoryUsage(context),
          ),
        ],
      ),
    );
  }

  void _showLogLevelDialog(BuildContext context) {
    // 로그 레벨 선택 다이얼로그
  }

  void _toggleNetworkLogging(BuildContext context) {
    // 네트워크 요청 로깅 설정
  }

  void _togglePerformanceOverlay(BuildContext context) {
    // 성능 오버레이 표시
  }

  void _exportLogs(BuildContext context) {
    // 로그 내보내기
  }

  void _showMemoryUsage(BuildContext context) {
    // 메모리 사용량 표시
  }
}

7.3 문제 재현을 위한 상태 덤프

class StateDumpService {
  static Future<void> dumpAppState() async {
    final appState = {
      'timestamp': DateTime.now().toIso8601String(),
      'memory_usage': await _getMemoryUsage(),
      'global_state': _captureGlobalState(),
      // 기타 앱 상태 정보
    };

    // 상태 정보를 파일로 저장하거나 서버로 전송
    final jsonString = jsonEncode(appState);
    await _saveToFile(jsonString);

    print('App state dumped successfully');
  }

  static Future<Map<String, dynamic>> _getMemoryUsage() async {
    // 메모리 사용량 정보 수집
    return {
      'total_allocated': 'Unknown', // 플랫폼 채널을 통해 얻을 수 있음
    };
  }

  static Map<String, dynamic> _captureGlobalState() {
    // 앱의 글로벌 상태 (Bloc, Provider 등) 수집
    return {};
  }

  static Future<void> _saveToFile(String content) async {
    // 파일로 저장하는 로직
  }
}

결론

릴리즈 모드에서 Flutter 앱을 디버깅하는 것은 어려운 작업이지만, 적절한 준비와 도구를 갖추면 효과적으로 문제를 진단하고 해결할 수 있습니다. 주요 전략은 다음과 같습니다:

  1. 사전 준비: 릴리즈 모드 테스트 자동화, 로깅 전략 구축
  2. 크래시 리포팅: Firebase Crashlytics 또는 Sentry 같은 도구 통합
  3. 성능 모니터링: 사용자 경험에 영향을 미치는 성능 문제 추적
  4. 원격 구성: 필요할 때 더 상세한 로깅을 활성화할 수 있는 원격 스위치
  5. 조건부 로깅: 릴리즈 모드에서도 필요한 정보 기록
  6. 사용자 피드백: 사용자로부터 문제 상황에 대한 상세 정보 수집

이러한 방법들을 조합하여 사용하면 릴리즈 모드에서도 효과적인 디버깅이 가능해져 사용자들에게 더 안정적인 앱을 제공할 수 있습니다.

results matching ""

    No results matching ""