18 KiB
예외 처리
소프트웨어 개발에서 오류는 피할 수 없는 부분입니다. Dart는 예외(Exception)를 사용하여 프로그램 실행 중 발생하는 오류를 처리합니다. 이 장에서는 Dart의 예외 처리 메커니즘, 내장 예외 타입, 사용자 정의 예외 생성 및 효과적인 예외 처리 전략에 대해 알아보겠습니다.
예외의 개념
예외는 프로그램 실행 중 발생하는 비정상적인 상황이나 오류입니다. 예외가 발생하면 프로그램의 정상적인 흐름이 중단되고, 해당 예외를 처리하는 코드로 제어가 이동합니다.
예외와 오류의 차이
Dart에서는 모든 예외가 Exception
또는 Error
클래스의 하위 타입입니다:
- Exception: 프로그램이 복구할 수 있는 오류 상황을 나타냅니다.
- Error: 프로그래밍 오류나 시스템 오류와 같이 일반적으로 복구할 수 없는 심각한 문제를 나타냅니다.
내장 예외 타입
Dart는 다양한 내장 예외 타입을 제공합니다:
Exception 하위 타입
// 포맷 예외
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));
Error 하위 타입
// 어설션 오류
AssertionError('조건이 false입니다.');
// 형식 오류
TypeError();
// 캐스트 오류 (다운캐스팅 실패)
CastError();
// 널 참조 오류
NoSuchMethodError.withInvocation(null, Invocation.method(Symbol('toString'), []));
// 스택 오버플로우
StackOverflowError();
// 외부 오류
OutOfMemoryError();
예외 처리 기본
1. try-catch-finally
기본적인 예외 처리 구문은 다음과 같습니다:
try {
// 예외가 발생할 수 있는 코드
int result = 12 ~/ 0; // 0으로 나누기 시도
print('결과: $result'); // 이 코드는 실행되지 않음
} catch (e) {
// 모든 예외 처리
print('예외 발생: $e');
} finally {
// 예외 발생 여부와 관계없이 항상 실행
print('finally 블록 실행');
}
// 출력:
// 예외 발생: IntegerDivisionByZeroException
// finally 블록 실행
2. 특정 예외 타입 잡기
여러 종류의 예외를 다르게 처리할 수 있습니다:
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');
}
3. 예외 다시 던지기(rethrow)
예외를 잡은 후 처리하고 다시 상위 호출자에게 전파할 수 있습니다:
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');
}
}
비동기 코드에서의 예외 처리
1. async-await와 try-catch
비동기 함수에서도 동기 코드와 마찬가지로 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: 데이터를 가져올 수 없습니다.
// 데이터 처리 완료
}
2. Future의 catchError
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: 네트워크 오류
// 작업 완료
}
3. 특정 예외만 처리하기
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)에서의 예외 처리
1. try-catch와 await for
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는 불길한 숫자입니다!
2. onError 리스너
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는 불길한 숫자입니다!
// 스트림 완료
3. handleError 메서드
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)을 사용한 예외 처리
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에서의 예외 처리
1. Flutter 앱의 전역 에러 핸들러
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');
},
);
}
2. 위젯에서의 예외 처리
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에서의 예외 처리
Flutter의 FutureBuilder
와 StreamBuilder
는 위젯에서 비동기 데이터 처리를 쉽게 하고, 오류 상태도 처리할 수 있게 해줍니다:
// 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. 예외는 예외적인 상황에만 사용하기
// 나쁜 예: 일반적인 흐름 제어에 예외 사용
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;
}
2. 적절한 예외 타입 사용하기
// 나쁜 예: 일반 예외 사용
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', '나이는 음수일 수 없습니다.');
}
// 처리 로직...
}
3. 모든 예외 처리하기
// 나쁜 예: 특정 예외만 처리
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. 예외 래핑 및 컨텍스트 추가하기
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;
}
}
5. 리소스 해제 보장하기
Future<void> processFile(String path) async {
File file;
try {
file = File(path);
final content = await file.readAsString();
// 콘텐츠 처리...
} catch (e) {
print('파일 처리 오류: $e');
rethrow;
} finally {
// 리소스 정리 (파일 닫기 등)
print('파일 처리 완료');
}
}
6. 예외 처리 중앙화하기
// 중앙 에러 핸들러 정의
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의 오류 처리 메서드를 활용할 수 있습니다.
모범 사례를 따르면 더 안정적이고 유지 관리가 쉬운 코드를 작성할 수 있습니다:
- 예외는 진짜 예외적인 상황에만 사용하세요.
- 적절한 예외 타입을 사용하여 문제를 명확하게 전달하세요.
- 발생할 수 있는 모든 예외를 처리하세요.
- 필요한 경우 예외를 래핑하여 컨텍스트를 추가하세요.
- finally 블록을 사용하여 리소스 해제를 보장하세요.
- 일관된 예외 처리를 위해 중앙화된 접근 방식을 사용하세요.
다음 장에서는 Dart의 Extension과 Mixin에 대해 알아보겠습니다.