🛡️

Dart 예외 처리

try-catch-finally, on 타입별 처리, rethrow

Dart는 복구 가능한 Exception과 복구 불가능한 Error를 구분합니다. try-catch-finally로 예외를 처리하고, on 절로 타입별 분기가 가능합니다. rethrow를 사용하면 원래 스택 트레이스를 유지하면서 상위로 전파할 수 있고, 비동기 코드에서도 try-catch나 Future.catchError()로 동일하게 처리합니다.

Exception vs Error

Dart는 Exception(복구 가능한 오류)과 Error(프로그래밍/시스템 오류, 복구 불가)를 구분합니다.

Exception (복구 가능)

FormatException, StateError, TypeError, ArgumentError, RangeError, TimeoutException

Error (복구 불가)

AssertionError, NoSuchMethodError, StackOverflowError, OutOfMemoryError

기본 try-catch-finally

try 블록에서 실행하고, catch에서 예외를 처리하며, finally는 항상 실행됩니다.

try { int result = 12 ~/ 0; print('결과: \$result'); } catch (e) { print('예외 발생: \$e'); } finally { print('finally 블록 실행'); }

on 절로 타입별 처리

on 타입 catch (e)으로 예외 종류별로 분기할 수 있습니다. 마지막 catch (e, s)는 나머지 모든 예외를 잡으며 스택 트레이스도 받습니다.

try { dynamic value = 'not a number'; int number = int.parse(value); } on FormatException catch (e) { print('숫자로 변환할 수 없음: \$e'); } on TypeError catch (e) { print('타입 오류 발생: \$e'); } catch (e, s) { print('기타 예외: \$e'); print('스택 트레이스: \$s'); }

rethrow — 원래 스택 트레이스 보존

catch에서 로깅 후 rethrow로 상위에 전파합니다. throw e와 달리 원래 스택 트레이스가 유지됩니다.

void processFile(String filename) { try { var file = File(filename); var contents = file.readAsStringSync(); } catch (e) { print('파일 처리 중 오류: \$e'); rethrow; // 원래 스택 트레이스 유지 } } void main() { try { processFile('존재하지_않는_파일.txt'); } catch (e) { print('메인에서 오류 처리: \$e'); } }

커스텀 Exception 클래스

implements Exception으로 도메인별 예외를 정의하면 의미 있는 에러 메시지와 컨텍스트를 전달할 수 있습니다.

class InsufficientBalanceException implements Exception { final double balance; final double withdrawal; InsufficientBalanceException(this.balance, this.withdrawal); @override String toString() { return '잔액 부족: 현재 \$balance, 출금 요청 \$withdrawal'; } } // 사용 try { account.withdraw(1500); } on InsufficientBalanceException catch (e) { print('출금 실패: \$e'); } on ArgumentError catch (e) { print('인수 오류: \$e'); }

비동기 예외 — async/await

async 함수에서는 동기 코드와 동일하게 try-catch-finally를 사용합니다.

Future<String> fetchData() async { await Future.delayed(Duration(seconds: 1)); throw Exception('데이터를 가져올 수 없습니다.'); } Future<void> processData() async { try { String data = await fetchData(); print('데이터: \$data'); } catch (e) { print('오류 발생: \$e'); } finally { print('데이터 처리 완료'); } }

비동기 예외 — Future 체인

Future 체인에서는 .catchError().whenComplete()를 사용합니다. test: 파라미터로 특정 타입만 선택적으로 처리할 수도 있습니다.

fetchData() .then((data) => print('데이터: \$data')) .catchError((e) => print('오류 발생: \$e')) .whenComplete(() => print('작업 완료')); // 특정 타입만 선택 처리 processTask() .catchError( (e) => print('타임아웃: \$e'), test: (e) => e is TimeoutException, ) .catchError((e) => print('기타 오류: \$e'));

Stream 예외 처리

Stream은 3가지 방식으로 예외를 처리할 수 있습니다.

1. await for + try-catch

try { await for (var number in countStream(5)) { print('숫자: \$number'); } } catch (e) { print('스트림 오류: \$e'); }

2. listen의 onError 콜백

countStream(5).listen( (data) => print('숫자: \$data'), onError: (e) => print('오류: \$e'), onDone: () => print('완료'), cancelOnError: false, // false면 오류 후에도 계속 );

3. handleError 메서드

generateNumbers() .handleError((error) => print('처리: \$error')) .listen((data) => print('데이터: \$data'));

Zone — 글로벌 에러 핸들링

runZonedGuarded는 Zone 내의 모든 비동기 에러를 잡아냅니다. Flutter 앱의 글로벌 에러 핸들러로 활용됩니다.

import 'dart:async'; runZonedGuarded( () { Future.delayed(Duration(seconds: 1), () { throw Exception('비동기 오류'); }); }, (error, stack) { print('Zone에서 캐치: \$error'); }, );

Flutter 에러 처리 패턴

Flutter 앱에서는 FlutterError.onErrorrunZonedGuarded를 조합하여 모든 예외를 포착합니다.

void main() { // Flutter 프레임워크 에러 FlutterError.onError = (details) { if (kReleaseMode) { Zone.current.handleUncaughtError( details.exception, details.stack!); } else { FlutterError.dumpErrorToConsole(details); } }; // 그 외 모든 비동기 에러 runZonedGuarded( () => runApp(MyApp()), (error, stackTrace) { print('예기치 않은 오류: \$error'); }, ); }

FutureBuilder / StreamBuilder 에러 처리

위젯에서 비동기 데이터를 표시할 때 snapshot.hasError로 에러 상태를 체크합니다.

FutureBuilder<String>( future: fetchData(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return CircularProgressIndicator(); } else if (snapshot.hasError) { return Text('오류: \${snapshot.error}'); } else if (snapshot.hasData) { return Text('데이터: \${snapshot.data}'); } return Text('데이터 없음'); }, )

Best Practices

  • 구체적인 예외 타입 사용 — catch-all 대신 on FormatException, on HttpException 등으로 명확하게 분기
  • finally로 리소스 정리 — 파일, DB 연결 등을 finally에서 반드시 해제
  • 예외 래핑으로 컨텍스트 전달 — 상위로 전파할 때 원인(cause)을 포함한 커스텀 예외로 감싸기
  • 예외는 예외적 상황에만 — 흐름 제어 용도로 사용하지 말고 -1, null 등 반환값 활용
  • 중앙화된 에러 핸들러 — ErrorHandler.guard() 패턴으로 로깅과 에러 처리를 통일

중앙화된 에러 핸들러 패턴

class ErrorHandler { static void logError(Object error, StackTrace stackTrace) { print('ERROR: \$error'); print('STACK: \$stackTrace'); } static Future<T> guard<T>(Future<T> Function() fn) async { try { return await fn(); } catch (error, stackTrace) { logError(error, stackTrace); rethrow; } } } // 사용 await ErrorHandler.guard(() async { final data = await fetchData(); return data; });

구현 순서

1

try-catch-finally — try에서 실행, catch에서 처리, finally에서 리소스 정리 (파일 닫기, 연결 해제 등)

2

on 타입별 처리 — on FormatException catch (e), on HttpException catch (e)로 예외 종류별 분기

3

rethrow — catch에서 로깅 후 rethrow로 상위에 전파, throw e와 달리 원래 스택 트레이스 유지

4

비동기 예외 — async/await에서는 try-catch 그대로 사용, Future 체인에서는 .catchError() 활용

장점

  • on 절로 예외 타입별 세밀한 처리 가능
  • rethrow로 원래 스택 트레이스를 보존하면서 전파

단점

  • 과도한 try-catch는 코드 가독성을 떨어뜨림

사용 사례

API 호출 실패 시 사용자에게 에러 메시지 표시 파일/DB 작업에서 finally로 리소스 해제 보장

참고 자료