Skip to content

예외 처리

소프트웨어 개발에서 오류는 피할 수 없는 부분입니다. Dart는 예외(Exception)를 사용하여 프로그램 실행 중 발생하는 오류를 처리합니다. 이 장에서는 Dart의 예외 처리 메커니즘, 내장 예외 타입, 사용자 정의 예외 생성 및 효과적인 예외 처리 전략에 대해 알아보겠습니다.

예외는 프로그램 실행 중 발생하는 비정상적인 상황이나 오류입니다. 예외가 발생하면 프로그램의 정상적인 흐름이 중단되고, 해당 예외를 처리하는 코드로 제어가 이동합니다.

Dart에서는 모든 예외가 Exception 또는 Error 클래스의 하위 타입입니다:

  • Exception: 프로그램이 복구할 수 있는 오류 상황을 나타냅니다.
  • Error: 프로그래밍 오류나 시스템 오류와 같이 일반적으로 복구할 수 없는 심각한 문제를 나타냅니다.

Dart는 다양한 내장 예외 타입을 제공합니다:

// 포맷 예외
FormatException('잘못된 형식의 입력입니다.');
// 상태 예외
StateError('객체가 잘못된 상태입니다.');
// 타입 오류
TypeError(); // 예: 잘못된 타입 캐스팅
// 인수 오류
ArgumentError('잘못된 인수가 제공되었습니다.');
ArgumentError.notNull('필수 매개변수가 null입니다.');
ArgumentError.value(42, 'age', '0보다 커야 합니다.');
// 범위 오류
RangeError('인덱스가 범위를 벗어났습니다.');
RangeError.index(10, [1, 2, 3], 'index', '인덱스가 범위를 벗어났습니다.', 3);
RangeError.range(42, 0, 10, 'value', '값이 허용 범위를 벗어났습니다.');
// 동시성 예외
ConcurrentModificationError('반복 중 컬렉션이 수정되었습니다.');
// 타임아웃 예외
TimeoutException('작업이 시간 초과되었습니다.', Duration(seconds: 5));
// 어설션 오류
AssertionError('조건이 false입니다.');
// 형식 오류
TypeError();
// 캐스트 오류 (다운캐스팅 실패)
CastError();
// 널 참조 오류
NoSuchMethodError.withInvocation(null, Invocation.method(Symbol('toString'), []));
// 스택 오버플로우
StackOverflowError();
// 외부 오류
OutOfMemoryError();

기본적인 예외 처리 구문은 다음과 같습니다:

try {
// 예외가 발생할 수 있는 코드
int result = 12 ~/ 0; // 0으로 나누기 시도
print('결과: $result'); // 이 코드는 실행되지 않음
} catch (e) {
// 모든 예외 처리
print('예외 발생: $e');
} finally {
// 예외 발생 여부와 관계없이 항상 실행
print('finally 블록 실행');
}
// 출력:
// 예외 발생: IntegerDivisionByZeroException
// finally 블록 실행

여러 종류의 예외를 다르게 처리할 수 있습니다:

try {
// 예외가 발생할 수 있는 코드
dynamic value = 'not a number';
int number = int.parse(value);
print('숫자: $number');
} on FormatException catch (e) {
// FormatException 처리
print('숫자로 변환할 수 없음: $e');
} on TypeError catch (e) {
// TypeError 처리
print('타입 오류 발생: $e');
} catch (e, s) {
// 기타 모든 예외 처리, 스택 트레이스 포함
print('기타 예외 발생: $e');
print('스택 트레이스: $s');
}

예외를 잡은 후 처리하고 다시 상위 호출자에게 전파할 수 있습니다:

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');
}
}

특정 상황에 맞는 예외를 직접 정의할 수 있습니다:

// 사용자 정의 예외 클래스 정의
class InsufficientBalanceException implements Exception {
final double balance;
final double withdrawal;
InsufficientBalanceException(this.balance, this.withdrawal);
@override
String toString() {
return '잔액 부족: 현재 잔액 $balance, 출금 요청액 $withdrawal';
}
}
// 사용자 정의 예외 사용
class BankAccount {
double balance = 0;
final String owner;
BankAccount(this.owner, [this.balance = 0]);
void deposit(double amount) {
if (amount <= 0) {
throw ArgumentError('입금액은 0보다 커야 합니다.');
}
balance += amount;
}
void withdraw(double amount) {
if (amount <= 0) {
throw ArgumentError('출금액은 0보다 커야 합니다.');
}
if (amount > balance) {
throw InsufficientBalanceException(balance, amount);
}
balance -= amount;
}
}
// 사용 예시
void main() {
var account = BankAccount('홍길동', 1000);
try {
account.withdraw(1500);
} on InsufficientBalanceException catch (e) {
print('출금 실패: $e');
// 출금 실패: 잔액 부족: 현재 잔액 1000.0, 출금 요청액 1500.0
} on ArgumentError catch (e) {
print('인수 오류: $e');
} catch (e) {
print('기타 예외: $e');
}
}

비동기 함수에서도 동기 코드와 마찬가지로 try-catch를 사용할 수 있습니다:

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('데이터 처리 완료');
}
}
void main() async {
await processData();
// 출력:
// 데이터 처리 중 오류 발생: Exception: 데이터를 가져올 수 없습니다.
// 데이터 처리 완료
}

Future의 메서드 체인을 사용할 때는 catchError를 사용할 수 있습니다:

Future<String> fetchData() {
return Future.delayed(Duration(seconds: 1))
.then((_) => throw Exception('네트워크 오류'));
}
void main() {
fetchData()
.then((data) => print('데이터: $data'))
.catchError((e) => print('오류 발생: $e'))
.whenComplete(() => print('작업 완료'));
// 출력:
// 오류 발생: Exception: 네트워크 오류
// 작업 완료
}

catchError에서 특정 예외만 처리할 수 있습니다:

Future<void> processTask() async {
return Future.delayed(Duration(seconds: 1))
.then((_) => throw TimeoutException('시간 초과', Duration(seconds: 1)))
.then((_) => print('작업 완료'));
}
void main() {
processTask()
.catchError(
(e) => print('타임아웃 발생: $e'),
test: (e) => e is TimeoutException,
)
.catchError(
(e) => print('기타 오류: $e'),
)
.whenComplete(() => print('모든 작업 완료'));
// 출력:
// 타임아웃 발생: TimeoutException: 시간 초과
// 모든 작업 완료
}
Stream<int> countStream(int to) async* {
for (int i = 1; i <= to; i++) {
if (i == 4) {
throw Exception('4는 불길한 숫자입니다!');
}
yield i;
}
}
Future<void> readStream() async {
try {
await for (var number in countStream(5)) {
print('숫자: $number');
}
print('스트림 읽기 완료');
} catch (e) {
print('스트림 처리 중 오류 발생: $e');
}
}
// 출력:
// 숫자: 1
// 숫자: 2
// 숫자: 3
// 스트림 처리 중 오류 발생: Exception: 4는 불길한 숫자입니다!
Stream<int> countStream(int to) async* {
for (int i = 1; i <= to; i++) {
await Future.delayed(Duration(milliseconds: 500));
if (i == 4) {
throw Exception('4는 불길한 숫자입니다!');
}
yield i;
}
}
void main() {
countStream(5).listen(
(data) => print('숫자: $data'),
onError: (e) => print('오류 발생: $e'),
onDone: () => print('스트림 완료'),
cancelOnError: false, // 오류 발생 시 구독 유지 (기본값은 true)
);
}
// 출력:
// 숫자: 1
// 숫자: 2
// 숫자: 3
// 오류 발생: Exception: 4는 불길한 숫자입니다!
// 스트림 완료
Stream<int> generateNumbers() async* {
for (int i = 1; i <= 5; i++) {
if (i == 3) throw Exception('3에서 오류 발생');
yield i;
}
}
void main() {
generateNumbers()
.handleError((error) => print('처리된 오류: $error'))
.listen(
(data) => print('데이터: $data'),
onDone: () => print('완료'),
);
}
// 출력:
// 데이터: 1
// 데이터: 2
// 처리된 오류: Exception: 3에서 오류 발생
// 완료

Zone은 실행 컨텍스트를 제공하여 전역적으로 오류 처리를 할 수 있게 해줍니다. 특히 비동기 코드에서 캐치되지 않은 예외를 처리하는 데 유용합니다.

import 'dart:async';
void main() {
// 사용자 정의 Zone 생성
runZonedGuarded(
() {
// 이 영역 내에서 실행되는 모든 코드의 예외를 처리
print('Zone 내에서 코드 실행 시작');
// 동기 예외
// throw Exception('동기 오류');
// 비동기 예외
Future.delayed(Duration(seconds: 1), () {
throw Exception('비동기 오류');
});
// 타이머 내 예외
Timer(Duration(seconds: 2), () {
throw Exception('타이머 내 오류');
});
},
(error, stack) {
// 모든 예외를 여기서 처리
print('Zone에서 오류 캐치: $error');
print('스택 트레이스: $stack');
},
);
print('main 함수의 끝 (Zone은 계속 실행됨)');
}
// 출력:
// Zone 내에서 코드 실행 시작
// main 함수의 끝 (Zone은 계속 실행됨)
// Zone에서 오류 캐치: Exception: 비동기 오류
// 스택 트레이스: ...
// Zone에서 오류 캐치: Exception: 타이머 내 오류
// 스택 트레이스: ...

Flutter 앱에서는 FlutterError.onError를 통해 전역 에러 핸들러를 설정할 수 있습니다:

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
void main() {
// UI 렌더링 중 발생하는 오류 처리
FlutterError.onError = (FlutterErrorDetails details) {
if (kReleaseMode) {
// 릴리즈 모드에서는 오류 로깅 서비스로 보내기
Zone.current.handleUncaughtError(details.exception, details.stack!);
} else {
// 개발 모드에서는 콘솔에 출력
FlutterError.dumpErrorToConsole(details);
}
};
// 앱 실행을 Zone으로 감싸서 모든 비동기 오류 처리
runZonedGuarded(
() {
runApp(MyApp());
},
(error, stackTrace) {
// 여기서 오류 로깅, 분석 서비스로 보내기 등 처리
print('예기치 않은 오류: $error');
print('스택 트레이스: $stackTrace');
},
);
}

Flutter 위젯에서는 ErrorWidget을 사용하여 예외 발생 시 UI를 관리할 수 있습니다:

void main() {
// 개발 시에만 사용자 정의 에러 위젯 설정
if (kDebugMode) {
ErrorWidget.builder = (FlutterErrorDetails details) {
return Container(
padding: EdgeInsets.all(16),
alignment: Alignment.center,
color: Colors.red.withOpacity(0.3),
child: Text(
'위젯 빌드 오류: ${details.exception}',
style: TextStyle(color: Colors.white),
),
);
};
}
runApp(MyApp());
}

3. FutureBuilder와 StreamBuilder에서의 예외 처리

Section titled “3. FutureBuilder와 StreamBuilder에서의 예외 처리”

Flutter의 FutureBuilderStreamBuilder는 위젯에서 비동기 데이터 처리를 쉽게 하고, 오류 상태도 처리할 수 있게 해줍니다:

// FutureBuilder 사용 예
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}');
} else {
return Text('데이터 없음');
}
},
)
// StreamBuilder 사용 예
StreamBuilder<int>(
stream: countStream(5),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Text('스트림 오류: ${snapshot.error}');
} else if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
} else if (snapshot.hasData) {
return Text('현재 값: ${snapshot.data}');
} else {
return Text('데이터 없음');
}
},
)

1. 예외는 예외적인 상황에만 사용하기

Section titled “1. 예외는 예외적인 상황에만 사용하기”
// 나쁜 예: 일반적인 흐름 제어에 예외 사용
int findIndex(List<int> list, int value) {
try {
for (int i = 0; i < list.length; i++) {
if (list[i] == value) {
throw i; // 찾은 인덱스를 예외로 던짐
}
}
return -1;
} catch (e) {
return e as int; // 예외에서 인덱스 추출
}
}
// 좋은 예: 직접 반환
int findIndex(List<int> list, int value) {
for (int i = 0; i < list.length; i++) {
if (list[i] == value) {
return i;
}
}
return -1;
}
// 나쁜 예: 일반 예외 사용
void processAge(dynamic age) {
if (age is! int) {
throw Exception('나이는 정수여야 합니다.');
}
if (age < 0) {
throw Exception('나이는 음수일 수 없습니다.');
}
// 처리 로직...
}
// 좋은 예: 구체적인 예외 사용
void processAge(dynamic age) {
if (age is! int) {
throw TypeError();
}
if (age < 0) {
throw ArgumentError.value(age, 'age', '나이는 음수일 수 없습니다.');
}
// 처리 로직...
}
// 나쁜 예: 특정 예외만 처리
Future<void> loadUserData() async {
try {
final data = await fetchUserFromNetwork();
saveToDatabase(data);
} on NetworkException catch (e) {
print('네트워크 오류: $e');
// 데이터베이스 오류는 처리되지 않음
}
}
// 좋은 예: 가능한 모든 예외 처리
Future<void> loadUserData() async {
try {
final data = await fetchUserFromNetwork();
saveToDatabase(data);
} on NetworkException catch (e) {
print('네트워크 오류: $e');
// 오프라인 데이터 사용
} on DatabaseException catch (e) {
print('데이터베이스 오류: $e');
// 임시 저장
} catch (e) {
print('예기치 않은 오류: $e');
// 기본 데이터 사용
}
}

4. 예외 래핑 및 컨텍스트 추가하기

Section titled “4. 예외 래핑 및 컨텍스트 추가하기”
Future<User> fetchUser(String userId) async {
try {
final response = await http.get(Uri.parse('https://api.example.com/users/$userId'));
if (response.statusCode == 200) {
return User.fromJson(jsonDecode(response.body));
} else {
throw HttpException('상태 코드: ${response.statusCode}');
}
} catch (e) {
// 원래 예외를 래핑하여 컨텍스트 추가
throw UserNotFoundException(
'ID가 $userId인 사용자를 찾을 수 없습니다.',
cause: e,
);
}
}
class UserNotFoundException implements Exception {
final String message;
final Object? cause;
UserNotFoundException(this.message, {this.cause});
@override
String toString() {
if (cause != null) {
return '$message (원인: $cause)';
}
return message;
}
}
Future<void> processFile(String path) async {
File file;
try {
file = File(path);
final content = await file.readAsString();
// 콘텐츠 처리...
} catch (e) {
print('파일 처리 오류: $e');
rethrow;
} finally {
// 리소스 정리 (파일 닫기 등)
print('파일 처리 완료');
}
}
// 중앙 에러 핸들러 정의
class ErrorHandler {
static void logError(Object error, StackTrace stackTrace) {
// 로그 파일에 기록
print('ERROR: $error');
print('STACK: $stackTrace');
// 분석 서비스로 전송
// _sendToAnalyticsService(error, stackTrace);
// 개발자에게 알림
if (!kReleaseMode) {
print('디버그 모드에서 오류 발생!');
}
}
static Future<T> guard<T>(Future<T> Function() function) async {
try {
return await function();
} catch (error, stackTrace) {
logError(error, stackTrace);
rethrow;
}
}
}
// 사용 예시
Future<void> fetchData() async {
await ErrorHandler.guard(() async {
// 비즈니스 로직...
if (Math.random() < 0.5) {
throw Exception('랜덤 오류');
}
return '데이터';
});
}

효과적인 예외 처리는 견고한 애플리케이션 개발의 핵심입니다. Dart는 try-catch-finally, 특정 예외 타입 잡기, 사용자 정의 예외 등 다양한 예외 처리 메커니즘을 제공합니다. 비동기 코드에서는 async-await와 함께 사용하거나 Future와 Stream의 오류 처리 메서드를 활용할 수 있습니다.

모범 사례를 따르면 더 안정적이고 유지 관리가 쉬운 코드를 작성할 수 있습니다:

  1. 예외는 진짜 예외적인 상황에만 사용하세요.
  2. 적절한 예외 타입을 사용하여 문제를 명확하게 전달하세요.
  3. 발생할 수 있는 모든 예외를 처리하세요.
  4. 필요한 경우 예외를 래핑하여 컨텍스트를 추가하세요.
  5. finally 블록을 사용하여 리소스 해제를 보장하세요.
  6. 일관된 예외 처리를 위해 중앙화된 접근 방식을 사용하세요.

다음 장에서는 Dart의 Extension과 Mixin에 대해 알아보겠습니다.