Flutter의 예외처리 - try/catch와 result Type

햄식·2024년 8월 12일
0

Flutter든 네이티브든, 앱을 실행하다보면 예측하지 못한 일들이 발생한다. 사용자가 잘못된 입력을 하거나, 서버가 죽거나, 개발자의 실수로 코드가 꼬여 앱이 충돌한다던가,, 최악의 상황에는 앱이 뻗기도 한다.

이는 스토어에 배포되어 수천만, 수억 명이 쓰는 세계적 규모의 훌륭한 앱들에서도 간혹 발생한다. 그래서 개발자는 예측하지 못한 상황에 앱이 잘못되지 않도록 오류에 대한 “예외처리”를 해야 한다.

Flutter로 앱을 만들때도 당연히 예외처리를 해야 한다. 여기서는 근본 try/catch 구문과, sealed class와 pattern matching을 사용하여 보다 명시적인 처리가 가능한 Result type을 알아볼 것이다.

1. try/catch로 예외처리 하기

다음 코드를 보자.

// http get 요청을 통해 외부로부터 값을 받아옴
final something = await getSomething();
print(something);

getSomething 메서드는 http get요청을 수행하여 외부로부터 값을 받아와 리턴하는 비동기 메서드다. 값을 받아온 후 print 할 것이다.
getSomething 메서드 내부 구현을 살펴보자.

Future<Something> getSomething() async {
  try {
    final response = await dio.get('https://나는주소.com');
    switch (response.statusCode) {
      case 200:
        return Something.fromJson(response.data);
      default:
      	throw Exception('Failed to load something ${response.statusMessage}');
    }
  } on Exception catch (_) {
  	rethrow;
  }
}

Flutter dio 패키지로 간단한 get요청을 수행한다. 요청이 성공한다면 Something 객체가 return 될것이고, 실패할 경우 exception을 throw한다.

getSomething 메서드가 exception을 throw한단다... 그러므로 메서드 바깥에서도 try/catch로 감싸주어 exception을 처리해주어야 한다.

try {
	final something = await getSomething();
	print(something);
} catch (e) {
	// TODO: 알람을 띄우던지 등의 처리
}

바로 여기서 try/catch의 귀차니즘이 발생한다. 우리는 우리가 사용하는 메서드가 Exception을 throw하는지 바로 알 수 없다. 결국 문서를 살펴보거나 직접 내부 구현을 들여다 본 이후에야 try/catch를 사용할지 말지 정할 수 있다. 이는 명시적이지 않다! 더 세련된 방법이 없을까?


2. Sealed Class를 사용하여 응답 결과를 명시적으로 표현하기

Kotlin과 Swift에서는 각각 나름의 “Result” type을 사용한다. Kotlin의 경우는 sealed class이고, swift의 경우는 enums with associated values이다.

그리고..! Dart 3부터 sealed class가 생겨서, Result Type을 사용할 수 있게 됐다:

/// Base Result class
/// [S] 성공(Success)했을 시 반환하는 값의 타입을 의미
/// [E] [Exception] 타입이거나 그것의 서브클래스를 의미
sealed class Result<S, E extends Exception> {
  const Result();
}

final class Success<S, E extends Exception> extends Result<S, E> {
  final S value;
  const Success(this.value);
}

final class Failure<S, E extends Exception> extends Result<S, E> {
  final E exception;
  const Failure(this.exception);
}

이제 이전의 예제를 sealed class를 사용하여 바꾸어보자.

// 1. return type을 변경한다.
Future<Result<Something, Exception>> getSomething() async {
  try {
    final response = await dio.get('https://나는주소.com');
    switch (response.statusCode) {
      case 200:
      	// 2. 원하는 값을 Success로 감싸 리턴한다.
        return Success(Something.fromJson(response.data));
      default:
      	// 3. 원하는 exception을 Failure로 감싸 리턴한다.
      	return Failure(Exception('Failed to load something ${response.statusMessage}'));
    }
  } on Exception catch (e) {
  	// 4. 역시 Failure를 리턴한다.
	return Failure(e);
  }
}

이제 getSomething 메서드의 return type은 다음과 같다.
  1. 문제 없이 동작을 마쳤을 경우 Something을 감싼 Success를 리턴한다.
  2. 문제가 생겼을 경우 Exception을 감싼 Failure를 리턴한다.


...

이제 getSomething 메서드 호출부를 어떻게 바꿀 수 있는지 보자.

final result = await getSomething();
final value = switch (result) {
	Success(value: final something) => something.toString(),
    Failure(exception: final exception) => '뭔가 잘못됐어요 $exception',
};
print(value);

switch문으로 분기처리하고 있는데, 생김새가 낯설다.

이것은 Dart3부터 사용가능한 pattern matching이다.
이 switch 문에서는 Result type의 모든 경우를 포함시켜야 한다. 아니면 에러가 발생한다.

Failure를 빼면 다음과 같은 에러가 발생한다


3. Result type의 장점과 단점

지금까지의 내용은 이렇다.

  1. Result type을 사용하면 메서드의 success, failure type을 명시적으로 선언할 수 있다.
  2. 메서드의 호출부에서 switch를 사용한 pattern matching으로 모든 케이스를 명시적으로 처리할 수 있다.(= 해야 한다)

이는 코드를 더욱 견고하게 만들고 오류를 줄이는데 큰 도움을 준다.

그렇다면, 항상 Result type을 사용하는 게 좋을까?
다음 코드를 보자.

Future<int> function1() { ... }
Future<int> function2(int value) { ... }
Future<int> function3(int value) { ... }

Future<int> complexAsyncWork() async {
  try {
    // first async call
    final result1 = await function1();
    // second async call
    final result2 = await function2(result1);
    // third async call (implicit)
    return function3(result2);
  } catch (e) {
    // TODO: 위의 메소드에서 발생하는 exception 캐치
  }
}

비동기 메서드가 총 세 개이다. 이전 메소드로 받은 값이 바로 다음 메서드의 인자로 들어가는 것을 볼 수 있다. try/catch 문으로 예외를 처리했다.

그럼 이 코드를 다시 Result를 사용하여 바꾸어보자.

Future<Result<int, Exception>> function1() { ... }
Future<Result<int, Exception>> function2(int value) { ... }
Future<Result<int, Exception>> function3(int value) { ... }

Future<Result<int, Exception>> complexAsyncWork() async {
  // first async call
  final result1 = await function1();
  if (result1
    case Success(value: final value1)) {
      // second async call
      final result2 = await function2(value1);
      return switch (result2) {
        // third async call
        Success(value: final value2) => await function3(value2),
        Failure(exception: final _) => result2, // error
      };
    } else {
      return result1; // error
    }
}

훨씬 보기 어려워졌다.

세 개의 Result 객체가 가지는 케이스를 모두 명시적으로 다뤄야하기 때문이다.

반면에 try/catch를 사용하는 경우에는 에러를 return하지 않고 throw하기 때문에, 비동기 메서드를 몇개를 연속으로 쓰던 catch로 한 번에 처리가 가능하다.


4. 결론

  • try/catch는 근본이긴 한데, 명시적인 느낌이 들지 않는다. 메서드의 모든 비정상적인 동작을 그냥 exception 하나로 퉁치는 느낌이다.(이게 장점이 되기도 한다.)
  • 그래서 Result를 사용해본다. 확실히 명시적이다. 개발자가 원하는대로 케이스를 나누어 타입을 지정하여 리턴시킬 수 있다. 그러나 여러 개의 비동기 메서드가 얽혀있을 경우에는 비효율적이다. 타입 안정성에 과몰입하는 느낌이 드는 경우도 있다.

결국, 각각의 장단점을 잘 파악하여 적재적소에 활용하는 것이 결론이 되겠다. 근데 만약 Result type에 대한 한계를 극복해보고 싶다면, 함수형 프로래밍을 익히고 fpdart와 같은 패키지의 사용을 고려해보는 것을 추천한다:


5. 참고

0개의 댓글