iBetter Books
수정

예외는 반드시 온다

완벽한 코드는 없습니다. 네트워크가 끊길 수 있고, 파일이 없을 수 있고, 사용자가 엉뚱한 값을 입력할 수 있습니다. 프로그램이 예상치 못한 상황을 만났을 때 터지는 것이 "예외(Exception)"입니다.

예외를 처리하지 않으면 프로그램이 강제 종료됩니다. 예외를 잘 처리하면 사용자에게 의미 있는 메시지를 보여주고, 앱이 계속 실행될 수 있습니다.

이번 챕터에서는 예외를 잡고, 구분하고, 만들고, 비동기 환경에서 처리하는 방법을 배웁니다.

try-catch-finally 기본 문법

void main() {
  try {
    // 예외가 발생할 수 있는 코드
    int result = 10 ~/ 0; // 정수 나누기 0 → 예외 발생
    print(result);
  } catch (e) {
    // 예외가 발생했을 때
    print('오류 발생: $e');
  } finally {
    // 예외 여부와 관계없이 항상 실행
    print('항상 실행됨');
  }
}

catch (e)e는 예외 객체입니다. e.toString()으로 메시지를 볼 수 있습니다.

스택 트레이스(Stack Trace)도 함께 받을 수 있습니다. 스택 트레이스는 에러가 발생한 위치와 호출 경로를 보여줍니다.

try {
  riskyOperation();
} catch (e, stackTrace) {
  print('오류: $e');
  print('위치: $stackTrace'); // 에러가 어디서 발생했는지
}

on 절로 특정 예외만 잡기

모든 예외를 같은 방식으로 처리할 필요는 없습니다. on 키워드로 예외 타입별로 다르게 처리할 수 있습니다.

void parseAndDivide(String numberStr, int divisor) {
  try {
    int number = int.parse(numberStr); // 파싱 실패 시 FormatException
    int result = number ~/ divisor;    // 0 나누기 시 UnsupportedError
    print('결과: $result');
  } on FormatException catch (e) {
    // FormatException만 처리
    print('숫자 형식이 잘못되었습니다: ${e.message}');
  } on UnsupportedError {
    // 예외 객체가 필요 없으면 catch 생략
    print('0으로 나눌 수 없습니다');
  } catch (e) {
    // 위에서 잡지 못한 나머지 예외
    print('알 수 없는 오류: $e');
  } finally {
    print('파싱 시도 완료');
  }
}

void main() {
  parseAndDivide('100', 5);    // 결과: 20
  parseAndDivide('abc', 5);    // 숫자 형식이 잘못되었습니다
  parseAndDivide('100', 0);    // 0으로 나눌 수 없습니다
}

on을 여러 개 나열하면 순서대로 타입을 확인합니다. 더 구체적인 타입을 먼저, 일반적인 타입을 나중에 씁니다.

커스텀 예외 클래스 만들기

Dart의 예외는 Exception 인터페이스를 구현하면 됩니다.

// 커스텀 예외 클래스
class NetworkException implements Exception {
  final int statusCode;
  final String message;

  NetworkException({required this.statusCode, required this.message});

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

class AuthException implements Exception {
  final String message;

  AuthException(this.message);

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

class ValidationException implements Exception {
  final String field;
  final String message;

  ValidationException({required this.field, required this.message});

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

커스텀 예외를 사용하면 오류 상황을 정확하게 표현하고, 호출하는 쪽에서 타입별로 다르게 처리할 수 있습니다.

Future<String> login(String email, String password) async {
  if (email.isEmpty) {
    throw ValidationException(field: 'email', message: '이메일을 입력해주세요');
  }
  
  if (!email.contains('@')) {
    throw ValidationException(field: 'email', message: '이메일 형식이 아닙니다');
  }
  
  // 시뮬레이션: 잘못된 비밀번호
  if (password != 'correct') {
    throw AuthException('비밀번호가 틀렸습니다');
  }
  
  return 'token_xyz';
}

void main() async {
  try {
    var token = await login('[email protected]', 'wrong');
    print('로그인 성공: $token');
  } on ValidationException catch (e) {
    print('입력 오류 — ${e.field}: ${e.message}');
  } on AuthException catch (e) {
    print('인증 실패: $e');
  } on NetworkException catch (e) {
    print('네트워크 오류 (${e.statusCode}): ${e.message}');
  } catch (e) {
    print('예상치 못한 오류: $e');
  }
}

비동기 에러 처리: try-catch + await

async/await를 사용하면 비동기 코드에서도 익숙한 try-catch를 그대로 쓸 수 있습니다.

Future<Map<String, dynamic>> fetchUserProfile(int userId) async {
  await Future.delayed(Duration(milliseconds: 500));
  
  if (userId <= 0) {
    throw ArgumentError('유효하지 않은 사용자 ID: $userId');
  }
  
  if (userId == 999) {
    throw NetworkException(statusCode: 404, message: '사용자를 찾을 수 없습니다');
  }
  
  return {'id': userId, 'name': '홍길동', 'email': '[email protected]'};
}

Future<void> showProfile(int userId) async {
  try {
    print('사용자 정보 로딩...');
    var profile = await fetchUserProfile(userId);
    print('이름: ${profile['name']}');
    print('이메일: ${profile['email']}');
  } on ArgumentError catch (e) {
    print('잘못된 요청: $e');
  } on NetworkException catch (e) {
    print('서버 오류 (${e.statusCode}): ${e.message}');
  } catch (e, stack) {
    print('오류: $e');
    // 프로덕션에서는 로그 서비스로 전송
  }
}

void main() async {
  await showProfile(1);    // 정상
  print('---');
  await showProfile(-1);   // ArgumentError
  print('---');
  await showProfile(999);  // NetworkException
}

try 블록 안의 await가 에러와 함께 완료된 Future를 기다리면, 그 에러가 catch로 전달됩니다. 동기 코드와 완전히 같은 방식으로 처리됩니다.

Future.catchError vs try-catch

then() 체이닝 방식에서는 .catchError()로 에러를 처리했습니다. async/await로 작성하면 try-catch가 더 자연스럽습니다. 두 방식은 동등합니다.

// then/catchError 방식
fetchUserProfile(1)
  .then((profile) => print(profile['name']))
  .catchError(
    (e) => print('NetworkException: $e'),
    test: (e) => e is NetworkException,
  )
  .catchError(
    (e) => print('기타 오류: $e'),
  );

// async/await + try-catch 방식 (권장)
Future<void> loadProfile() async {
  try {
    var profile = await fetchUserProfile(1);
    print(profile['name']);
  } on NetworkException catch (e) {
    print('NetworkException: $e');
  } catch (e) {
    print('기타 오류: $e');
  }
}

async/await + try-catch를 권장합니다. 코드가 더 읽기 쉽고, 스택 트레이스도 더 명확합니다.

Stream 에러 처리

Stream에서 에러가 발생할 때 처리하는 방법입니다.

await for와 try-catch.

Stream<int> riskyStream() async* {
  yield 1;
  yield 2;
  throw Exception('스트림 중간 에러!');
  yield 3; // 이 줄은 실행되지 않음
}

void main() async {
  try {
    await for (var value in riskyStream()) {
      print(value);
    }
  } catch (e) {
    print('Stream 에러: $e');
  }
}

listen의 onError 콜백.

riskyStream().listen(
  (data) => print(data),
  onError: (e) => print('에러: $e'),
  onDone: () => print('완료'),
  cancelOnError: false, // true면 에러 발생 시 자동으로 구독 취소
);

handleError 변환.

Stream 파이프라인에서 에러를 걸러낼 수 있습니다.

riskyStream()
  .handleError(
    (e) => print('처리된 에러: $e'),
    test: (e) => e is Exception,
  )
  .listen(
    (data) => print(data),
    onDone: () => print('완료'),
  );

rethrow로 예외 다시 던지기

예외를 잡았다가 처리한 후, 상위 호출자에게 다시 전달하고 싶을 때 rethrow를 사용합니다.

Future<String> fetchData(String url) async {
  try {
    await Future.delayed(Duration(milliseconds: 300));
    throw NetworkException(statusCode: 503, message: '서비스 일시 중단');
  } catch (e) {
    // 로그 기록
    print('[로그] 에러 발생: $e');
    
    // 에러를 상위로 전달 (rethrow는 원래 스택 트레이스 유지)
    rethrow;
  }
}

void main() async {
  try {
    await fetchData('https://api.example.com');
  } on NetworkException catch (e) {
    print('사용자에게 보여줄 메시지: 잠시 후 다시 시도해주세요');
  }
}

throw e; 대신 rethrow를 쓰면 원래 스택 트레이스가 유지됩니다. 디버깅할 때 에러가 어디서 처음 발생했는지 정확하게 알 수 있습니다.

에러 처리 계층화

실제 앱에서는 에러 처리를 여러 계층으로 나눕니다.

// 데이터 계층: 기술적 예외를 도메인 예외로 변환
class UserRepository {
  Future<Map<String, dynamic>> getUser(int id) async {
    try {
      // HTTP 요청 (생략)
      await Future.delayed(Duration(milliseconds: 200));
      if (id == 0) throw Exception('서버 오류');
      return {'id': id, 'name': '홍길동'};
    } catch (e) {
      // 기술적 예외 → 도메인 예외 변환
      throw NetworkException(statusCode: 500, message: '사용자 정보를 불러올 수 없습니다');
    }
  }
}

// 서비스 계층: 비즈니스 로직 검증
class UserService {
  final UserRepository _repository;
  UserService(this._repository);
  
  Future<Map<String, dynamic>> getUserProfile(int id) async {
    if (id <= 0) {
      throw ValidationException(field: 'id', message: '유효한 ID를 입력하세요');
    }
    
    return await _repository.getUser(id);
  }
}

// UI 계층: 사용자에게 메시지 표시
Future<void> showUserScreen(int userId) async {
  var service = UserService(UserRepository());
  
  try {
    var profile = await service.getUserProfile(userId);
    print('화면 표시: ${profile['name']}');
  } on ValidationException catch (e) {
    print('입력 오류: ${e.message}');
  } on NetworkException catch (e) {
    print('네트워크 오류: ${e.message}\n잠시 후 다시 시도해주세요.');
  }
}

void main() async {
  await showUserScreen(1);  // 정상
  await showUserScreen(-1); // 검증 오류
  await showUserScreen(0);  // 네트워크 오류
}

각 계층이 자신의 책임에 맞는 예외를 처리하고, 필요하면 변환하거나 상위로 전달합니다.

정리

에러 처리는 코드의 완성도를 결정합니다.

  • try-catch-finally로 예외를 잡고 정리합니다.
  • on ExceptionType으로 예외 타입별로 다르게 처리합니다.
  • implements Exception으로 커스텀 예외 클래스를 만듭니다.
  • async/awaittry-catch를 함께 쓰면 비동기 예외도 동기처럼 처리됩니다.
  • rethrow로 스택 트레이스를 유지하며 예외를 다시 던집니다.
  • Stream 에러는 try-catch 또는 onError, handleError로 처리합니다.

이제 PART 08의 여정이 끝났습니다. 비동기 프로그래밍의 기초인 Future부터, async/await로 읽기 쉬운 코드 작성, Stream으로 흐르는 데이터 처리, Isolate로 무거운 작업 분리, 그리고 예외 처리까지 모두 배웠습니다. 이 다섯 가지 도구를 자유롭게 다룰 수 있다면 Flutter 앱 개발의 핵심 역량을 갖춘 것입니다.

다음 PART에서는 Dart 3에서 새롭게 추가된 패턴 매칭과 레코드를 살펴보겠습니다.