Dart 에러처리

이 문서 주변 탐색

주제 태그, 링크, 시리즈 흐름을 중심으로 옆으로 이동할 수 있습니다.

시리즈 흐름

이 문서는 아직 읽기 시리즈에 연결되지 않았습니다.

관련 문서

같이 읽을 만한 관련 문서가 아직 없습니다.

이 문서를 참조하는 문서

이 문서를 참조하는 다른 문서가 아직 없습니다.

1. 에러 처리의 중요성

에러 처리가 없으면?

void main() {
  // ❌ 에러 처리 없음 - 프로그램 크래시!
  int result = int.parse('abc');  // FormatException 발생
  print(result);  // 여기 도달 못함
  print('프로그램 계속...');  // 실행 안 됨
}

에러 처리가 있으면?

void main() {
  // ✅ 에러 처리 - 프로그램 계속 실행
  try {
    int result = int.parse('abc');
    print(result);
  } catch (e) {
    print('숫자 변환 실패: $e');
  }
  print('프로그램 계속...');  // 정상 실행!
}
  • 에러 처리는 프로그램의 안정성을 보장함

  • 사용자에게 친절한 에러 메시지를 제공할 수 있음

  • 문제 발생 시 로깅과 디버깅이 가능해짐

  • Flutter 앱이 크래시되면 사용자 경험이 크게 저하됨

2. Exception vs Error

두 가지 유형의 차이

구분

Exception

Error

성격

예상 가능한 문제

프로그래밍 실수

처리

잡아서 처리

잡지 말고 수정

예시

네트워크 실패, 잘못된 입력

null 참조, 범위 초과

Exception 예시

void main() {
  // FormatException - 예상 가능, 처리해야 함
  try {
    int.parse('hello');
  } on FormatException catch (e) {
    print('숫자가 아님: $e');
  }

  // IOException - 예상 가능, 처리해야 함
  try {
    // 파일 읽기 실패 가능
  } on IOException catch (e) {
    print('파일 읽기 실패: $e');
  }
}

Error 예시 (잡지 말 것!)

void main() {
  var list = [1, 2, 3];

  // RangeError - 프로그래밍 실수, 코드를 수정해야 함
  // print(list[10]);  // ❌ 에러! 인덱스 범위 초과

  // TypeError - 프로그래밍 실수
  // dynamic value = 'hello';
  // int number = value;  // ❌ 타입 불일치

  // StackOverflowError - 무한 재귀
  // void infinite() => infinite();  // ❌ 스택 오버플로우
}

내장 Exception 종류

// 자주 만나는 Exception들
FormatException       // 형식 오류 (숫자 파싱 실패 등)
IOException           // 입출력 오류
TimeoutException      // 시간 초과
HttpException         // HTTP 오류
StateError            // 잘못된 상태에서 호출
ArgumentError         // 잘못된 인자

// 자주 만나는 Error들 (잡지 말 것!)
RangeError            // 범위 초과
TypeError             // 타입 불일치
NullThrownError       // null 던짐
StackOverflowError    // 스택 오버플로우
OutOfMemoryError      // 메모리 부족
  • Exception: "이런 상황이 발생할 수 있어" → 처리 코드 작성

  • Error: "코드가 잘못됐어" → 코드 자체를 수정

  • 실무에서 Error를 catch하는 것은 안티패턴

  • Exception도 무분별하게 잡지 말고, 구체적인 타입을 지정할 것

3. try-catch-finally

기본 구조

void main() {
  try {
    // 에러가 발생할 수 있는 코드
    var result = riskyOperation();
    print('결과: $result');
  } catch (e) {
    // 에러 발생 시 실행
    print('에러 발생: $e');
  } finally {
    // 항상 실행 (에러 여부 상관없이)
    print('정리 작업');
  }
}

on 키워드로 특정 예외 처리

void main() {
  try {
    var value = int.parse('hello');
  } on FormatException {
    // FormatException만 처리
    print('숫자 형식이 아님');
  } on ArgumentError {
    // ArgumentError만 처리
    print('잘못된 인자임');
  }
}

catch로 예외 객체 받기

void main() {
  try {
    var value = int.parse('hello');
  } on FormatException catch (e) {
    // e: 예외 객체
    print('에러 메시지: ${e.message}');
  }
}

스택 트레이스 받기

void main() {
  try {
    throw Exception('문제 발생!');
  } catch (e, stackTrace) {
    // e: 예외 객체
    // stackTrace: 호출 스택 정보
    print('에러: $e');
    print('스택 트레이스:\n$stackTrace');
  }
}

여러 예외 타입 처리

void main() {
  try {
    // 여러 종류의 에러가 발생할 수 있는 코드
    processData();
  } on FormatException catch (e) {
    // 형식 에러
    print('형식 오류: $e');
  } on IOException catch (e) {
    // 입출력 에러
    print('I/O 오류: $e');
  } on Exception catch (e) {
    // 그 외 모든 Exception
    print('기타 예외: $e');
  } catch (e) {
    // Exception이 아닌 것까지 포함 (권장하지 않음)
    print('알 수 없는 에러: $e');
  }
}

finally 활용

void main() {
  var file = openFile('data.txt');

  try {
    var content = file.readAll();
    processContent(content);
  } catch (e) {
    print('에러: $e');
  } finally {
    // 에러가 발생해도 파일은 반드시 닫아야 함
    file.close();
    print('파일 닫힘');
  }
}
  • on예외 타입 지정, catch예외 객체 받기

  • on Type catch (e)로 둘을 조합할 수 있음

  • catch (e, s)스택 트레이스도 받을 수 있음

  • finally리소스 정리(파일, 연결 닫기)에 필수임

  • 순서: 구체적인 예외 → 일반적인 예외 (위에서 아래로)

4. throw와 rethrow

throw - 예외 던지기

void validateAge(int age) {
  if (age < 0) {
    throw ArgumentError('나이는 음수일 수 없음: $age');
  }
  if (age > 150) {
    throw ArgumentError('나이가 너무 큼: $age');
  }
}

void main() {
  try {
    validateAge(-5);
  } on ArgumentError catch (e) {
    print('유효성 검사 실패: $e');
  }
}

아무거나 던질 수 있음 (권장하지 않음)

void main() {
  // 문자열도 던질 수 있음 (❌ 권장하지 않음)
  // throw '에러야!';

  // 숫자도 던질 수 있음 (❌ 권장하지 않음)
  // throw 404;

  // ✅ Exception이나 Error를 상속한 객체를 던질 것
  throw FormatException('잘못된 형식');
}

rethrow - 예외 다시 던지기

void processData() {
  try {
    // 데이터 처리
    riskyOperation();
  } catch (e) {
    // 로깅만 하고
    print('에러 발생, 로깅: $e');

    // 상위로 다시 던짐
    rethrow;
  }
}

void main() {
  try {
    processData();
  } catch (e) {
    // 여기서 최종 처리
    print('최종 에러 처리: $e');
  }
}

throw vs rethrow

void example() {
  try {
    throw Exception('원본 에러');
  } catch (e) {
    // throw e;   // ❌ 스택 트레이스가 여기서 새로 시작
    // throw Exception('새 에러');  // ❌ 완전히 다른 에러

    rethrow;      // ✅ 원본 스택 트레이스 유지
  }
}
  • throw는 새로운 예외를 던짐

  • rethrow는 잡은 예외를 원본 스택 트레이스 그대로 다시 던짐

  • 로깅 후 상위로 전파할 때는 항상 rethrow를 사용할 것

  • throw e는 스택 트레이스가 손실되므로 피해야 함

5. 커스텀 Exception

기본 커스텀 Exception

// Exception 인터페이스 구현
class ValidationException implements Exception {
  final String message;

  ValidationException(this.message);

  @override
  String toString() => 'ValidationException: $message';
}

void validateEmail(String email) {
  if (!email.contains('@')) {
    throw ValidationException('유효하지 않은 이메일: $email');
  }
}

void main() {
  try {
    validateEmail('invalid-email');
  } on ValidationException catch (e) {
    print(e);  // ValidationException: 유효하지 않은 이메일: invalid-email
  }
}

에러 코드가 있는 Exception

class ApiException implements Exception {
  final int statusCode;
  final String message;
  final String? errorCode;

  ApiException({
    required this.statusCode,
    required this.message,
    this.errorCode,
  });

  @override
  String toString() => 'ApiException($statusCode): $message';

  // 상태 코드별 팩토리 생성자
  factory ApiException.notFound([String? message]) {
    return ApiException(
      statusCode: 404,
      message: message ?? '리소스를 찾을 수 없음',
      errorCode: 'NOT_FOUND',
    );
  }

  factory ApiException.unauthorized([String? message]) {
    return ApiException(
      statusCode: 401,
      message: message ?? '인증이 필요함',
      errorCode: 'UNAUTHORIZED',
    );
  }

  factory ApiException.serverError([String? message]) {
    return ApiException(
      statusCode: 500,
      message: message ?? '서버 오류가 발생함',
      errorCode: 'INTERNAL_ERROR',
    );
  }
}

// 사용
void main() {
  try {
    throw ApiException.notFound('사용자를 찾을 수 없음');
  } on ApiException catch (e) {
    switch (e.statusCode) {
      case 401:
        print('로그인 필요');
        break;
      case 404:
        print('찾을 수 없음: ${e.message}');
        break;
      default:
        print('에러: ${e.message}');
    }
  }
}

계층적 Exception

// 기본 앱 예외
abstract class AppException implements Exception {
  final String message;
  final String? code;
  final dynamic originalError;

  AppException(this.message, {this.code, this.originalError});

  @override
  String toString() => '$runtimeType: $message';
}

// 네트워크 관련 예외
class NetworkException extends AppException {
  NetworkException(super.message, {super.code, super.originalError});
}

// 인증 관련 예외
class AuthException extends AppException {
  AuthException(super.message, {super.code, super.originalError});
}

// 유효성 검사 예외
class ValidationException extends AppException {
  final Map<String, String>? fieldErrors;

  ValidationException(
    super.message, {
    super.code,
    this.fieldErrors,
  });
}

// 사용
void main() {
  try {
    throw AuthException('토큰이 만료됨', code: 'TOKEN_EXPIRED');
  } on AuthException catch (e) {
    print('인증 에러: ${e.message}');
    // 로그인 화면으로 이동
  } on NetworkException catch (e) {
    print('네트워크 에러: ${e.message}');
    // 재시도 버튼 표시
  } on AppException catch (e) {
    print('앱 에러: ${e.message}');
    // 일반 에러 처리
  }
}
  • implements Exception으로 Exception 인터페이스를 구현함

  • toString()을 오버라이드하면 디버깅이 쉬워짐

  • 계층적 Exception을 만들면 공통 처리와 개별 처리를 분리할 수 있음

  • 팩토리 생성자로 자주 쓰는 예외를 편리하게 만들 수 있음

6. assert (디버깅용)

assert 기본

void processAge(int age) {
  // 디버그 모드에서만 체크 (릴리즈에서는 무시됨)
  assert(age >= 0, '나이는 음수일 수 없음');
  assert(age < 150, '나이가 너무 큼');

  print('나이 처리: $age');
}

void main() {
  processAge(25);   // OK
  processAge(-5);   // 디버그 모드: AssertionError
                    // 릴리즈 모드: 정상 실행
}

assert vs throw

class User {
  final String name;
  final int age;

  User(this.name, this.age) {
    // assert: 개발자 실수 방지 (디버그용)
    assert(name.isNotEmpty, 'name은 비어있으면 안 됨');

    // throw: 실제 유효성 검사 (프로덕션에서도 동작)
    if (age < 0) {
      throw ArgumentError('age는 음수일 수 없음');
    }
  }
}

구분

assert

throw

동작 환경

디버그 모드만

항상

목적

개발 중 실수 발견

실제 에러 처리

성능 영향

릴리즈에서 없음

항상 있음

assert 활용 예시

class Rectangle {
  final double width;
  final double height;

  Rectangle(this.width, this.height)
      : assert(width > 0, 'width는 양수여야 함'),
        assert(height > 0, 'height는 양수여야 함');
}

// 초기화 리스트에서 assert 사용
class Circle {
  final double radius;

  Circle(this.radius) : assert(radius > 0);

  double get area {
    assert(radius > 0);  // 메서드 내에서도 사용 가능
    return 3.14159 * radius * radius;
  }
}
  • assert디버그 모드에서만 동작함 (flutter run vs flutter run --release)

  • 개발 중 실수를 빨리 발견하기 위한 도구임

  • 사용자 입력 검증에는 throw를 사용할 것

  • 내부 로직의 불변 조건(invariant) 체크에 적합함

7. Null Safety와 에러 방지

Nullable 타입 안전하게 다루기

void main() {
  String? name;  // nullable

  // ❌ 위험: null이면 런타임 에러
  // print(name.length);

  // ✅ 방법 1: null 체크
  if (name != null) {
    print(name.length);  // 안전
  }

  // ✅ 방법 2: ?. (null-aware 접근)
  print(name?.length);  // null이면 null 반환

  // ✅ 방법 3: ?? (기본값)
  print(name ?? '기본값');

  // ✅ 방법 4: ! (null이 아님을 확신할 때만)
  name = 'Hello';
  print(name!.length);  // 5
}

late 키워드 주의점

class UserProfile {
  late String name;  // 나중에 초기화

  void loadProfile() {
    // API에서 로드
    name = 'John';
  }

  void printName() {
    // ⚠️ loadProfile() 호출 전에 접근하면 LateInitializationError!
    print(name);
  }
}

void main() {
  var profile = UserProfile();
  // profile.printName();  // ❌ LateInitializationError
  profile.loadProfile();
  profile.printName();     // ✅ John
}

안전한 타입 캐스팅

void main() {
  dynamic value = 'hello';

  // ❌ 위험: 타입이 다르면 TypeError
  // int number = value as int;

  // ✅ 안전한 캐스팅
  if (value is int) {
    int number = value;  // 자동 캐스팅
    print(number);
  }

  // ✅ 또는 null 반환
  int? maybeNumber = value is int ? value : null;
}
  • Dart의 Null Safety는 컴파일 타임에 null 에러를 방지

  • !정말 확신할 때만 사용할 것. 남용하면 런타임 에러의 원인이 됨

  • late반드시 초기화될 것이 보장될 때만 사용할 것

  • is 체크 후에는 자동으로 타입 캐스팅됨 (smart cast)

8. 비동기 에러 처리

async/await에서 try-catch

Future<String> fetchData() async {
  await Future.delayed(Duration(seconds: 1));
  throw Exception('네트워크 오류');
}

Future<void> main() async {
  try {
    var data = await fetchData();
    print('데이터: $data');
  } catch (e) {
    print('에러 발생: $e');
  }
}

Future.then()에서 에러 처리

void main() {
  fetchData()
      .then((data) => print('데이터: $data'))
      .catchError((e) => print('에러: $e'))
      .whenComplete(() => print('완료'));
}

여러 Future의 에러 처리

Future<void> main() async {
  // Future.wait: 하나라도 실패하면 전체 실패
  try {
    var results = await Future.wait([
      fetchUser(),
      fetchPosts(),
      fetchComments(),
    ]);
  } catch (e) {
    print('하나 이상 실패: $e');
  }

  // 개별 에러 처리가 필요하면
  var results = await Future.wait([
    fetchUser().catchError((e) => null),
    fetchPosts().catchError((e) => []),
    fetchComments().catchError((e) => []),
  ]);
}

Stream 에러 처리

void main() {
  final stream = Stream.periodic(
    Duration(seconds: 1),
    (count) {
      if (count == 3) throw Exception('3번째에서 에러!');
      return count;
    },
  ).take(5);

  stream.listen(
    (data) => print('데이터: $data'),
    onError: (error) => print('에러: $error'),
    onDone: () => print('완료'),
    cancelOnError: false,  // 에러 발생해도 계속 구독
  );
}

Zone으로 전역 에러 처리

import 'dart:async';

void main() {
  runZonedGuarded(
    () {
      // 이 안에서 발생하는 모든 비동기 에러 캐치
      Timer(Duration(seconds: 1), () {
        throw Exception('타이머에서 에러!');
      });
    },
    (error, stackTrace) {
      print('Zone에서 캐치: $error');
      print('스택: $stackTrace');
    },
  );
}
  • async/awaittry/catch 조합이 가장 깔끔

  • Future.catchError의 타입 지정: test: (e) => e is SpecificException

  • Stream의 cancelOnError: false로 에러 발생해도 계속 구독할 수 있음

  • runZonedGuarded전역 비동기 에러를 캐치할 수 있음

9. 실전 에러 처리 패턴

패턴 1: Result 타입 (Either 패턴)

// 성공 또는 실패를 명시적으로 표현
sealed class Result<T> {
  const Result();
}

class Success<T> extends Result<T> {
  final T data;
  const Success(this.data);
}

class Failure<T> extends Result<T> {
  final String message;
  final Exception? exception;
  const Failure(this.message, [this.exception]);
}

// 사용
Future<Result<User>> fetchUser(String id) async {
  try {
    final response = await api.getUser(id);
    return Success(User.fromJson(response));
  } on NetworkException catch (e) {
    return Failure('네트워크 오류', e);
  } on ApiException catch (e) {
    return Failure(e.message, e);
  } catch (e) {
    return Failure('알 수 없는 오류');
  }
}

void main() async {
  final result = await fetchUser('123');

  switch (result) {
    case Success(data: var user):
      print('사용자: ${user.name}');
    case Failure(message: var msg):
      print('에러: $msg');
  }
}

패턴 2: 에러 경계 (Error Boundary)

class ErrorBoundary {
  static Future<T?> guard<T>(
    Future<T> Function() action, {
    void Function(Object error, StackTrace stack)? onError,
  }) async {
    try {
      return await action();
    } catch (e, s) {
      onError?.call(e, s);
      return null;
    }
  }

  static T? guardSync<T>(
    T Function() action, {
    void Function(Object error, StackTrace stack)? onError,
  }) {
    try {
      return action();
    } catch (e, s) {
      onError?.call(e, s);
      return null;
    }
  }
}

// 사용
void main() async {
  final user = await ErrorBoundary.guard(
    () => fetchUser('123'),
    onError: (e, s) => print('에러 로깅: $e'),
  );

  if (user != null) {
    print('사용자: ${user.name}');
  } else {
    print('사용자를 불러올 수 없음');
  }
}

패턴 3: 재시도 로직

Future<T> retry<T>(
  Future<T> Function() fn, {
  int maxAttempts = 3,
  Duration delay = const Duration(seconds: 1),
  bool Function(Exception)? retryIf,
}) async {
  int attempts = 0;

  while (true) {
    try {
      attempts++;
      return await fn();
    } on Exception catch (e) {
      // 재시도 조건 확인
      if (retryIf != null && !retryIf(e)) {
        rethrow;
      }

      if (attempts >= maxAttempts) {
        rethrow;
      }

      print('시도 $attempts 실패, $delay 후 재시도...');
      await Future.delayed(delay);

      // 지수 백오프
      delay *= 2;
    }
  }
}

// 사용
void main() async {
  try {
    final data = await retry(
      () => fetchData(),
      maxAttempts: 3,
      retryIf: (e) => e is NetworkException,  // 네트워크 에러만 재시도
    );
    print('성공: $data');
  } catch (e) {
    print('최종 실패: $e');
  }
}

패턴 4: 에러 로깅 서비스

class ErrorLogger {
  static final ErrorLogger _instance = ErrorLogger._internal();
  factory ErrorLogger() => _instance;
  ErrorLogger._internal();

  void log(
    Object error,
    StackTrace? stackTrace, {
    String? context,
    Map<String, dynamic>? extra,
  }) {
    // 콘솔 출력
    print('=== ERROR ===');
    print('Context: $context');
    print('Error: $error');
    print('Extra: $extra');
    if (stackTrace != null) {
      print('Stack:\n$stackTrace');
    }

    // 프로덕션에서는 Sentry, Crashlytics 등으로 전송
    // Sentry.captureException(error, stackTrace: stackTrace);
  }
}

// 사용
void main() async {
  try {
    await fetchData();
  } catch (e, s) {
    ErrorLogger().log(
      e,
      s,
      context: 'fetchData 호출',
      extra: {'userId': '123'},
    );
  }
}

10. Flutter에서의 에러 처리

전역 에러 핸들러

void main() {
  // Flutter 프레임워크 에러 처리
  FlutterError.onError = (FlutterErrorDetails details) {
    FlutterError.presentError(details);
    // 에러 로깅 서비스로 전송
    ErrorLogger().log(
      details.exception,
      details.stack,
      context: 'FlutterError',
    );
  };

  // 비동기 에러 처리
  PlatformDispatcher.instance.onError = (error, stack) {
    ErrorLogger().log(error, stack, context: 'PlatformDispatcher');
    return true;  // 에러 처리됨
  };

  runApp(MyApp());
}

ErrorWidget 커스터마이징

void main() {
  // 에러 위젯 커스터마이징 (빨간 에러 화면 대신)
  ErrorWidget.builder = (FlutterErrorDetails details) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.error_outline, size: 64, color: Colors.red),
            SizedBox(height: 16),
            Text('문제가 발생함'),
            TextButton(
              onPressed: () {/* 재시도 */},
              child: Text('다시 시도'),
            ),
          ],
        ),
      ),
    );
  };

  runApp(MyApp());
}

FutureBuilder/StreamBuilder 에러 처리

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return FutureBuilder<User>(
      future: fetchUser(),
      builder: (context, snapshot) {
        // 에러 상태
        if (snapshot.hasError) {
          return Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text('에러: ${snapshot.error}'),
                ElevatedButton(
                  onPressed: () {
                    // setState로 재시도
                  },
                  child: Text('다시 시도'),
                ),
              ],
            ),
          );
        }

        // 로딩 상태
        if (!snapshot.hasData) {
          return Center(child: CircularProgressIndicator());
        }

        // 성공 상태
        return Text('사용자: ${snapshot.data!.name}');
      },
    );
  }
}

🤖

본 글은 AI의 도움을 받아 작성되었습니다.

읽기 시리즈

현재 위치를 확인하고, 흐름을 따라 바로 다음 읽기로 이어갈 수 있습니다.

Reading Series

Dart
0편

현재 읽는 문서

Dart 에러처리

Dart 언어에 대해서 알아보기

이전 문서

시리즈의 시작 문서입니다.

다음 문서

시리즈의 마지막 문서입니다.