플러터의 다양한 에러 상황 다루기 - Flutter Error Handling

앱을 만들때는 정상적인 시나리오를 벗어난 사용 혹은 상황에 따라 다양한 에러를 만날 경우가 많다. 예를 들면 어떤 목록을 불러와 화면에 리스트에 뿌린다고 했을 때, 제일먼저 인터넷이 연결 안된 상황, 혹은 불러올 데이터가 하나도 없는 상황등이다. 그렇것을 예외라고 할 수 있다. Flutter 에서 만날 수 있는 다양한 에러를 다뤄보자. 

에러를 인식하는 방법

가장 기본적인 방법으로는 assert()함수를 이용해 특정 조건을 검사하는 것이다.  someCondition 이 false일 때를 검사해 내는 경우를 예를 들어보자.

assert( )

assert(someCondition, "someCondition is false, Something wrong!");

이런 방법은 디버깅 목적이기 때문에 프로덕션빌드에서는 assert 를 꺼두어야 한다.

try-catch

네트워크에 쿼리요청을 보내거나 데이터베이스에서 데이터를 로드하는 경우와 같은 비동기 루틴에서는 다양한 네트워크 상황 때문에 unhandled exception 이 발생하기 쉽상이다. 이런 비동기적인 에러(Asynchronous Errors)는 try-catch 블록이나 Future API를 사용해 다룰 수 있다.

try {
  var result = await NetworkRequestOperation();
  // Use the result
} catch (e) {
  // Handle the asynchronous error
}

try {...} 블록에서 발생하는 어떤 예외든 catch(e) {...} 블록이 잡아내 처리할 수 있게 된다. 

이 기법은 비동기 루틴에서 독립적으로 작동할 수 있기 때문에 메인 루틴을 벗어나 제어하기 어렵게 만들 수 있다. 이때는 Flutter가 제공하는 FutureStream 매커니즘을 활용해 에러를 좀 더 효과적으로 다룰 수 있다.

Future

이것은 어떤 단일 값의 변수나 객체가 즉시 사용되지 않는 다는 것을 표현하기 때문에 어떤 비동기 특정 시점에서 종료가 될 것이다. 이런 경우에는 .catchError() 함수를 통해서 감지하고 미래의 에러에 대처할 수 있게 된다.

Stream

Stream 은 Collection의 하나로 지속적으로 데이터를 방출하는 비동기 이벤트이다. .listen() 함수를 통해 구독하는 루틴이 이를 사용할 수 있다. 만일 에러가 발생하면 .onError()함수를 통해 하나의 Stream 에서 에러를 잡아 처리할 수 있게 된다. 

Stream Example .dart
Future<int> fetchUserData() async {
  // Process...
  // Error! -> Jump to catchError()
  // Process...
  return true; // If it is successful, respond something
}

void main() {
  // Handling errors in a Future
  fetchUserData()
      .then((value) => print('User data: $value'))
      .catchError((error) => print('Error fetching user data: $error'));

  // Handling errors in a Stream
  Stream<int>.periodic(Duration(seconds: 1), (count) => count)
      .map((count) {
        // Process...
        // Error! -> Jump to listen()
        // Process...
        return count;
      })
      .listen(
        (data) => print('Stream data: $data'),
        onError: (error) => print('Error in stream: $error'),
      );
}

 

에러를 다루는 전역적인 Widget 사용하기 

여러가지 처리되지 않은 예외적인 에러들을 잡아 처리할 위젯을 만들고 거기에 에러를 넘겨주어 사용자에게 좋은 UX를 줄 수 있다.

Flutter 프레임워크에서는 어떤 예외적 에러들이 발생할 때 FlutterError.onError 메서드가 호출된다. 이것을 커스텀하여 특정 에러 처리루틴을 제공할 수 있다. 

Error Handling Widget .dart
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'My App',
      home: ErrorHandlerWidget( // POINT: Wrap a widget
        child: MyHomePage(),
      ),
    );
  }
}

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Main Page')),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            // Process...
            // If Error
            // Process...
            Navigator.push(
              context,
              MaterialPageRoute(builder: (context) => SecondPage()),
            );
          },
          child: Text('Trigger Error'),
        ),
      ),
    );
  }
}

class SecondPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Second Page')),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            // Process...
            // If Error
            // Process...
          },
          child: Text('Trigger Another Error'),
        ),
      ),
    );
  }
}

class ErrorHandlerWidget extends StatefulWidget {
  final Widget child;

  ErrorHandlerWidget({required this.child});

  @override
  _ErrorHandlerWidgetState createState() => _ErrorHandlerWidgetState();
}

class _ErrorHandlerWidgetState extends State<ErrorHandlerWidget> {
  // Error handling logic
  void onError(FlutterErrorDetails errorDetails) {
    // Add your error handling logic here, 
    // ex., logging, reporting to a server, etc.
    print('Caught error: ${errorDetails.exception}');
  }

  @override
  Widget build(BuildContext context) {
    return ErrorWidgetBuilder(
      builder: (context, errorDetails) {
        // Display a user-friendly error UI
        return Scaffold(
          appBar: AppBar(title: Text('Error')),
          body: Center(
            child: Text('Something went wrong. Please try again later.'),
          ),
        );
      },
      onError: onError,
      child: widget.child,
    );
  }
}

class ErrorWidgetBuilder extends StatefulWidget {
  final Widget Function(BuildContext, FlutterErrorDetails) builder;
  final void Function(FlutterErrorDetails) onError;
  final Widget child;

  ErrorWidgetBuilder({
    required this.builder,
    required this.onError,
    required this.child,
  });

  @override
  _ErrorWidgetBuilderState createState() => _ErrorWidgetBuilderState();
}

class _ErrorWidgetBuilderState extends State<ErrorWidgetBuilder> {
  @override
  void initState() {
    super.initState();
    // Set up global error handling
    FlutterError.onError = widget.onError;
  }

  @override
  Widget build(BuildContext context) {
    return widget.child;
  }
}

이 간단한 예에서는 ErrorHandlerWidget 이 앱의 시작 지점 위젯을 감싸면서 앱 동작 중 발생하는 에러를 잡아낼 수 있게 된다. 만일 위의 코드처럼 클릭 이벤트 처리도중 에러가 발생하면 외부의 에러 로직을 실행하며 ErrorWidgetBuilder 에서 정의된 UI를 사용자에게 보여줌으로써 좀 더 사용자 친화적인 처리가된다. 이 부분에 팝업 다이어로그처럼 에러를 띄워도 좋다. 에러 로직에는 에러를 보고할 각종 트래킹 혹은 에러리포트 도구를 호출해 NewRelic처럼 로깅 전용 서비스에 전달해 줄 수 있다.

 

Network Connection 다루기

인터넷이 없는 지역이나 모바일 네트워크가 불가능한 곳이라면 인터넷 연결성 문제가 우리의 앱에도 발생할 수 있다. 

No Internet 

간단하게 검사할 수 있는 방법으로 Connectvity 패키지를 이용해 정상 처리전 연결성을 검사하는 것이다. 플러터 코어패키지의 Connectivity는 더이상 진행되지 않으니, 다음 서드파티 패키지를 추가한다.

먼저 pubspec.yaml 의존성 목록에 추가하고 사용해 보자.

var connectivityResult = await (Connectivity().checkConnectivity());

ConnectivityResult는 enum 타입으로 다음과 같은 값을 처리할 수 있다.

  • wifi: Connected to a WiFi network.
  • mobile: Connected to a mobile network.
  • none: Not connected to any network.

none 값을 검사해 No Internet 상황을 다룰 수 있다.

final connectivityResult = await Connectivity().checkConnectivity();

if (connectivityResult == ConnectivityResult.none) {
  // Show UI like SnackBar or showDialog
  // "You are not connected. No Internet."
} else {
  // Normal Netwrok Requests ongoing...
}

비동기 루틴과  함께 다음과 같이 사용할 수 있을 것이다. 

Network Connection Check .dart
class MyHomePage extends StatelessWidget {
Future<void> checkInternetConnection(BuildContext context) async {
  final connectivityResult = await Connectivity().checkConnectivity();
  if (connectivityResult == ConnectivityResult.none) {
    // Display a "No Internet Connection" message
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('No Internet Connection'),
        content: Text('Please check your internet connection and try again.'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text('OK'),
          ),
        ],
      ),
    );
  } else {
    // Perform the network request
    // ...
  }
}

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: Text('Network Error Handling')),
    body: Center(
      child: ElevatedButton(
        onPressed: () => checkInternetConnection(context),
        child: Text('Check Internet Connection'),
      ),
    ),
  );
}

 

Network 에러 전용 위젯 만들기

생성자에서 함수를 매개변수로 전달하는 NetworkErrorDialog 위젯을 만들어 보자. 

NetworkErrorDialog .dart
lass NetworkErrorDialog extends StatelessWidget {
  const NetworkErrorDialog({Key? key, this.onPressed}) : super(key: key);
  final Function()? onPressed;
  
  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(10.0)),
      content: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        mainAxisSize: MainAxisSize.min,
        children: [
          const Text( 
            "Check your connection and try again.",
            style: TextStyle(fontSize: 12),
            textAlign: TextAlign.center,
          ),
          const SizedBox(height: 16),
          ElevatedButton(
            child: const Text("Try Again"),
            onPressed: onPressed,
          )
        ],
      ),
    );
  }
}

이제, builder 속성에 다음과 같이 전달할 수 있다. 

...
child: ElevatedButton(
  child: const Text("Check Connection"),
  onPressed: () async {
    final connectivityResult = await Connectivity().checkConnectivity();
    if (connectivityResult == ConnectivityResult.none) { 
      showDialog(
        barrierDismissible: false,
        context: context,
        builder: (_) => const NetworkErrorDialog(),
      );
    } else {
      // Network Requests... 
    }
  },
),

그럼 함수를 매개변수로 전달해 보자. 

...
    if (connectivityResult == ConnectivityResult.none) { 
      showDialog(
        barrierDismissible: false,
        context: context,
        builder: (_) => const NetworkErrorDialog(
            onPressed: () async {
              // POINT: Try Again, Retry and Refresh Action..
            },
        ),
      );
    } else {
      // Network Requests... 
    }
...

네트워크 연결 상태 변화에 따른 이벤트 감시 - Listen

Connectivity의 onConnectivityChanged 프로퍼티를 사용하면 네트워크 상태 변경을 감시할 수 있다. 이 프로퍼티는  Stream 타입으로 Stream의 listen 메서드에서 상태가 변경될 때 함수를 전달하는 것이다. listen 의 매개변수로 ConnectivityResult 타입을 가질 수 있도록 하고, listen의 내부 구현 블록에 상태 변화 로직을 구성한다. 지속적으로 결과를 갱신할 것이다.

이벤트를 구독하는 방식이므로 더 이상 사용하지 않을 경우에는 위젯의 dispose() 생명 주기 내에서 구독을 취소하도록 한다. 

Subscription and Listen .dart
  ConnectivityResult? _connectivityResult;
  late StreamSubscription _connectivitySubscription;
 
  @override
  initState() {
    super.initState();
 
    _connectivitySubscription = 
      Connectivity().onConnectivityChanged.listen((
          ConnectivityResult result
      ) {
        print('Current connectivity status: $result');
        _connectivityResult = result;
      },
    );
  }
 
  @override
  dispose() {
    super.dispose();
 
    _connectivitySubscription.cancel();
  }

 

Riverpod과 함께 연결상태를 위한 Notifier 만들기

상태관리 패키지로 많이 사용되는 Riverpod 과 함께 다뤄보자. 

알림자(Notifier)의 정의

먼저 네트워크 연결 상태들을 정의할 enum 열거형을 사용하자. 

enum ConnectivityStatus { NotDetermined, isConnected, isDisonnected }

class ConnectivityStatusNotifier extends Notifier<ConnectivityStatus> {}

ConnectivityResult  변화에 따른 청취(Listen)를 위해 ConnectivityStatusNotifier를 작성해 준다.

ConnectivityStatus .dart
  ConnectivityStatus? lastResult;
  ConnectivityStatus? newState;

  ConnectivityStatusNotifier() : super(ConnectivityStatus.isConnected) {
    if (state == ConnectivityStatus.isConnected) {
      lastResult = ConnectivityStatus.isConnected;
    } else {
      lastResult = ConnectivityStatus.isDisonnected;
    }
    lastResult = ConnectivityStatus.NotDetermined;
    Connectivity().onConnectivityChanged.listen((ConnectivityResult result) {
      switch (result) {
        case ConnectivityResult.mobile:
        case ConnectivityResult.wifi:
          newState = ConnectivityStatus.isConnected;
          break;
        case ConnectivityResult.none:
          newState = ConnectivityStatus.isDisonnected;
          break;
      }
      if (newState != lastResult) {
        state = newState!;
        lastResult = newState;
      }
    });
  }

상태가 변경될 때 lastState 와 newState 를 갱신할 것이다.  

제공자(Provider)의 정의

// Final Global Variable which will expose the state.
// Should be outside of the class. 

final connectivityStatusProviders = NotifierProvider((ref) {
  return ConnectivityStatusNotifier();
});

 프로바이더의 목적은 노티파이어에 의해 노출된 상태 이벤트를 청취하는 것이다. 

ConsumerWidget을 상속한 소비자인 UI 위젯에서 사용하기

이제 사용자 화면에서 ref.watch 를 사용해 변화에 따른 ConnectivityStatus 의 상태를 비교할 수 있게 된다. 

Consumer UI .dart
class MainScreen extends ConsumerWidget {
  const MainScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    var connectivityStatusProvider = ref.watch(connectivityStatusProviders);
   
    return Scaffold(
        backgroundColor: Colors.black54,
        appBar: AppBar(
          title: const Text( 'Network Connectivity'),
        ),
        body: ...
                connectivityStatusProvider == ConnectivityStatus.isConnected
                    ? 'Connected to Internet'
                    : 'No Internet',
              ...
        ));
  }
}

물론 최 상단의 앱은 ProviderScope 로 감싸여 있어야 프로바이더로부터 값을 제대로 읽을 수 있게 된다. 주로 main()코드에서 구성하게 된다. 

빌더 루틴으로 사용해보기

class ConnectivityCheck extends StatelessWidget {
  final Widget child;

  ConnectivityCheck({@required this.child});

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<ConnectivityResult>(
        stream: Connectivity().onConnectivityChanged,
        builder: (context, snapshot) {
          if (!snapshot.hasData ||
              snapshot.data == ConnectivityResult.none) {
            return Center(
              child: Text("No Network"),
            );
          } else {
            return child;
          }
        });
  }
}

 

dart::io 패키지의 lookup 사용하기

또다른 방법으로 코어 패키지에 lookup을 사용해 접근 가능한 호스트가 넘어온다면 연결에 문제가 없으므로 이것으로 네트워크 연결 에러를 다룰 수 도 있다. 

  bool? _isConnectionSuccessful;
 
  Future<void> _tryConnection() async {
    try {
      final response = await InternetAddress.lookup('www.yoursite.com');
 
      setState(() {
        _isConnectionSuccessful = response.isNotEmpty;
      });
    } on SocketException catch (e) {
      setState(() {
        _isConnectionSuccessful = false;
      });
    }
  }

호스트를 연결하지 못하면 예외처리에서 플래그를 통해 상태를 결정할 수 있다.

서버가 반환하는 에러코드로 대응하기

서버에 연결 요청을 할 때 문제가 없다면 상태 코드로 200을 반환한다. 에러가 발생하면 페이지가 없는 경우에 404, 서버의 내부 오류의 경우 500등의 대표적인 에러코드로 서버는 앱에 응답(response)하게 된다. 

Server Status Code .dart
...
class MyHomePage extends StatelessWidget {
  Future<void> fetchData() async {
    try {
      // Perform the network request
      final response = await http.get(Uri.parse('https://example.com/api/get'));

      if (response.statusCode == 200) {
        // Handle successful response
        // Process...
      } else {
        // Handle server error(like 404,500...)
        // Show Error Dialog or SnapBar
      }
    } catch (e) {
      // Handle other errors like exceptions
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Server Error Handling')),
      body: Center(
        child: ElevatedButton(
          onPressed: () => fetchData(),
          child: Text('Fetch Data'),
        ),
      ),
    );
  }
}

시간 제한을 걸어 특정시간에 timeout하도록 하는 것도 좋다.

http.get('https://google.com').timeout(Duration(seconds: 5);

 

 

태그: 
youngdeok의 이미지

Language

Get in touch with us

"어떤 것을 완전히 알려거든 그것을 다른 이에게 가르쳐라."
- Tryon Edwards -