Futures와 오류 처리

Futures와 오류 처리 (Futures and error handling)

비동기 코드를 작성할 때 오류와 예외를 처리하는 데 대해 알고 싶었던 모든 것... 그리고 그 이상을 다룰게요.

출처: Futures and error handling

본문

Dart 언어에는 비동기 지원이 내장되어 있어서 비동기 Dart 코드를 읽고 쓰기가 훨씬 쉬워졌어요. 그런데도 일부 코드 — 특히 오래된 코드 — 는 여전히 then(), catchError(), whenComplete() 같은 Future 메서드를 쓸 수 있어요.

이 페이지는 그런 Future 메서드를 사용할 때 흔히 빠지는 함정을 피하는 데 도움을 줄 거예요.

경고 코드가 언어의 비동기 지원(async, await, 그리고 try-catch를 이용한 오류 처리)을 이용한다면 이 페이지는 필요 없어요. 자세한 내용은 비동기 프로그래밍 튜토리얼을 참고하세요.

Future API와 콜백 (The Future API and callbacks)

Future API를 사용하는 함수들은 Future를 완료시키는 값(또는 오류)을 처리하는 콜백을 등록해요. 예를 들어:

myFunc().then(processValue).catchError(handleError);

등록된 콜백은 다음 규칙에 따라 발화(fire)돼요: 값과 함께 완료되는 Future에 대해 then()의 콜백이 호출되고, 오류와 함께 완료되는 Future에 대해 catchError()의 콜백이 호출돼요.

위 예시에서 myFunc()의 Future가 값과 함께 완료되면 then()의 콜백이 발화해요. then() 안에서 새 오류가 만들어지지 않으면 catchError()의 콜백은 발화하지 않아요. 반대로 myFunc()가 오류와 함께 완료되면 then()의 콜백은 발화하지 않고 catchError()의 콜백이 발화해요.

then()을 catchError()와 함께 사용하는 예시 (Examples of using then() with catchError())

연쇄적인 then()catchError() 호출은 Future를 다룰 때 흔한 패턴이며, 대략 try-catch 블록과 같다고 볼 수 있어요.

다음 몇 섹션이 이 패턴의 예시를 보여 줄게요.

catchError()를 포괄적인 오류 핸들러로 사용하기

다음 예시는 then()의 콜백 안에서 예외를 던지는 상황을 다루며, 오류 핸들러로서 catchError()의 다용성을 보여 줘요:

myFunc()
    .then((value) {
      doSomethingWith(value);
      ...
      throw Exception('Some arbitrary error');
    })
    .catchError(handleError);

myFunc()의 Future가 값과 함께 완료되면 then()의 콜백이 발화해요. then()의 콜백 안의 코드가 (위 예시처럼) throw하면 then()의 Future는 오류와 함께 완료돼요. 그 오류는 catchError()가 처리해요.

myFunc()의 Future가 오류와 함께 완료되면 then()의 Future도 그 오류와 함께 완료돼요. 그 오류 역시 catchError()가 처리해요.

오류가 myFunc() 안에서 발생했든 then() 안에서 발생했든, catchError()는 성공적으로 처리해요.

then() 안에서의 오류 처리

더 세밀한 오류 처리를 위해, then() 안에 두 번째(onError) 콜백을 등록해 오류와 함께 완료되는 Future를 처리할 수 있어요. 다음은 then()의 시그니처예요:

Future<R> then<R>(FutureOr<R> Function(T value) onValue, {Function? onError});

선택적 onError 콜백은 then()으로 전달된 오류와 then() 안에서 발생한 오류를 구분하고 싶을 때만 등록해요:

asyncErrorFunction()
    .then(
      successCallback,
      onError: (e) {
        handleError(e); // Original error.
        anotherAsyncErrorFunction(); // Oops, new error.
      },
    )
    .catchError(handleError); // Error from within then() handled.

위 예시에서 asyncErrorFunction()의 Future의 오류는 onError 콜백으로 처리돼요. anotherAsyncErrorFunction()then()의 Future를 오류와 함께 완료시키고, 이 오류는 catchError()가 처리해요.

일반적으로 두 가지 서로 다른 오류 처리 전략을 구현하는 것은 권장하지 않아요. then() 안에서 오류를 잡을 타당한 이유가 있을 때만 두 번째 콜백을 등록하세요.

긴 체인의 중간에 있는 오류

연속된 then() 호출이 있고, 체인의 어느 부분에서든 발생한 오류를 catchError()로 잡는 것은 흔한 일이에요:

Future<String> one() => Future.value('from one');
Future<String> two() => Future.error('error from two');
Future<String> three() => Future.value('from three');
Future<String> four() => Future.value('from four');

void main() {
  one() // Future completes with "from one".
      .then((_) => two()) // Future completes with two()'s error.
      .then((_) => three()) // Future completes with two()'s error.
      .then((_) => four()) // Future completes with two()'s error.
      .then((value) => value.length) // Future completes with two()'s error.
      .catchError((e) {
        print('Got error: $e'); // Finally, callback fires.
        return 42; // Future completes with 42.
      })
      .then((value) {
        print('The value is $value');
      });
}

// Output of this program:
//   Got error: error from two
//   The value is 42

위 코드에서 one()의 Future는 값과 함께 완료되지만 two()의 Future는 오류와 함께 완료돼요. 오류와 함께 완료되는 Future에 대해 then()이 호출되면 then()의 콜백은 발화하지 않아요. 대신 then()의 Future는 receiver(수신자)의 오류와 함께 완료돼요. 우리 예시에서 이는 two()가 호출된 뒤, 이후의 모든 then()이 반환하는 Future가 two()의 오류로 완료된다는 뜻이에요. 그 오류는 마침내 catchError() 안에서 처리돼요.

특정 오류 처리하기

특정 오류를 잡고 싶다면? 또는 하나 이상의 오류를 잡고 싶다면?

catchError()는 선택적 명명 인자 test를 받는데, 이걸로 발생한 오류의 종류를 검사할 수 있어요.

Future<T> catchError(Function onError, {bool Function(Object error)? test});

handleAuthResponse(params)를 생각해 보세요. 이 함수는 제공된 params를 바탕으로 사용자를 인증하고, 사용자를 적절한 URL로 리다이렉트해요. 복잡한 워크플로 때문에 handleAuthResponse()는 다양한 오류와 예외를 만들어 낼 수 있고, 각각 다르게 처리해야 해요. test로 그렇게 하는 방법을 볼게요:

void main() {
  handleAuthResponse(const {'username': 'dash', 'age': 3})
      .then((_) => ...)
      .catchError(handleFormatException, test: (e) => e is FormatException)
      .catchError(
        handleAuthorizationException,
        test: (e) => e is AuthorizationException,
      );
}

whenComplete()를 이용한 비동기 try-catch-finally

then().catchError()가 try-catch를 닮았다면, whenComplete()는 'finally'에 해당해요. whenComplete() 안에 등록된 콜백은 whenComplete()의 receiver가 값으로 완료되든 오류로 완료되든 그 완료 시점에 호출돼요:

final server = connectToServer();
server
    .post(myUrl, fields: const {'name': 'Dash', 'profession': 'mascot'})
    .then(handleResponse)
    .catchError(handleError)
    .whenComplete(server.close);

server.post()가 유효한 응답을 만들든 오류를 만들든 상관없이 server.close를 호출하고 싶어요. whenComplete() 안에 넣어 둠으로써 그렇게 되도록 보장할 수 있어요.

whenComplete()가 반환하는 Future 완료시키기

whenComplete() 안에서 오류가 발생하지 않으면, 그 Future는 whenComplete()가 호출된 Future와 같은 방식으로 완료돼요. 이건 예시를 통해서 이해하는 게 제일 쉬워요.

아래 코드에서 then()의 Future는 오류로 완료되므로, whenComplete()의 Future도 그 오류로 완료돼요.

void main() {
  asyncErrorFunction()
      // Future completes with an error:
      .then((_) => print("Won't reach here"))
      // Future completes with the same error:
      .whenComplete(() => print('Reaches here'))
      // Future completes with the same error:
      .then((_) => print("Won't reach here"))
      // Error is handled here:
      .catchError(handleError);
}

아래 코드에서 then()의 Future는 오류로 완료되고, 그 오류는 이제 catchError()가 처리해요. catchError()의 Future가 someObject로 완료되므로, whenComplete()의 Future도 그 같은 객체로 완료돼요.

void main() {
  asyncErrorFunction()
      // Future completes with an error:
      .then((_) => ...)
      .catchError((e) {
        handleError(e);
        printErrorMessage();
        return someObject; // Future completes with someObject
      })
      .whenComplete(() => print('Done!')); // Future completes with someObject
}

whenComplete() 안에서 발생한 오류

whenComplete()의 콜백이 오류를 throw하면, whenComplete()의 Future는 그 오류로 완료돼요:

void main() {
  asyncErrorFunction()
      // Future completes with a value:
      .catchError(handleError)
      // Future completes with an error:
      .whenComplete(() => throw Exception('New error'))
      // Error is handled:
      .catchError(handleError);
}

잠재적 문제: 오류 핸들러를 너무 늦게 등록하기

오류 핸들러가 Future가 완료되기 전에 설치되는 것이 중요해요. 이렇게 해야 Future가 오류로 완료됐는데 아직 오류 핸들러가 붙지 않아서 오류가 우연히 전파되는 상황을 피할 수 있어요. 다음 코드를 보세요:

void main() {
  Future<Object> future = asyncErrorFunction();

  // BAD: Too late to handle asyncErrorFunction() exception.
  Future.delayed(const Duration(milliseconds: 500), () {
    future.then(...).catchError(...);
  });
}

위 코드에서 catchError()asyncErrorFunction()이 호출된 지 0.5초 후에야 등록되므로, 오류는 처리되지 않은 채 남아요.

asyncErrorFunction()Future.delayed() 콜백 안에서 호출하면 문제가 사라져요:

void main() {
  Future.delayed(const Duration(milliseconds: 500), () {
    asyncErrorFunction()
        .then(...)
        .catchError(...); // We get here.
  });
}

잠재적 문제: 동기 오류와 비동기 오류를 실수로 섞기

Future를 반환하는 함수는 거의 항상 그 오류를 future에서 발생시켜야 해요. 그런 함수의 호출자가 여러 오류 처리 시나리오를 구현하게 하고 싶지 않으니, 동기 오류가 밖으로 새는 것을 막고 싶어요. 다음 코드를 보세요:

Future<int> parseAndRead(Map<String, dynamic> data) {
  final filename = obtainFilename(data); // Could throw.
  final file = File(filename);
  return file.readAsString().then((contents) {
    return parseFileData(contents); // Could throw.
  });
}

그 코드에서 두 함수가 동기적으로 throw할 수 있어요: obtainFilename()parseFileData(). parseFileData()then() 콜백 안에서 실행되므로 그 오류는 함수 밖으로 새지 않아요. 대신 then()의 Future가 parseFileData()의 오류로 완료되고, 그 오류는 결국 parseAndRead()의 Future를 완료시키며, catchError()로 성공적으로 처리될 수 있어요.

하지만 obtainFilename()then() 콜백 안에서 호출되지 않아요. 그 함수가 throw하면 동기 오류가 전파돼요:

void main() {
  parseAndRead(data).catchError((e) {
    print('Inside catchError');
    print(e);
    return -1;
  });
}

// Program Output:
//   Unhandled exception:
//   <error from obtainFilename>
//   ...

catchError()를 써도 그 오류는 잡히지 않으므로, parseAndRead()의 클라이언트는 이 오류를 위해 별도의 오류 처리 전략을 구현해야 해요.

해결책: Future.sync()로 코드 감싸기

함수에서 동기 오류가 실수로 던져지는 것을 막는 흔한 패턴은 함수 본문을 새 Future.sync() 콜백 안에 감싸는 거예요:

Future<int> parseAndRead(Map<String, dynamic> data) {
  return Future.sync(() {
    final filename = obtainFilename(data); // Could throw.
    final file = File(filename);
    return file.readAsString().then((contents) {
      return parseFileData(contents); // Could throw.
    });
  });
}

콜백이 Future가 아닌 값을 반환하면 Future.sync()의 Future는 그 값으로 완료돼요. 콜백이 (위 예시처럼) throw하면 Future는 오류로 완료돼요. 콜백 자체가 Future를 반환하면, 그 Future의 값 또는 오류가 Future.sync()의 Future를 완료시켜요.

Future.sync()로 코드를 감싸면 catchError()가 모든 오류를 처리할 수 있어요:

void main() {
  parseAndRead(data).catchError((e) {
    print('Inside catchError');
    print(e);
    return -1;
  });
}

// Program Output:
//   Inside catchError
//   <error from obtainFilename>

Future.sync()는 여러분의 코드를 잡히지 않은 예외에 대해 회복력 있게 만들어 줘요. 함수에 코드가 아주 많이 들어 있다면, 모르는 사이에 뭔가 위험한 것을 하고 있을 가능성이 있어요:

Future fragileFunc() {
  return Future.sync(() {
    final x = someFunc(); // Unexpectedly throws in some rare cases.
    var y = 10 / x; // x should not equal 0.
    ...
  });
}

Future.sync()는 발생할 수 있다고 아는 오류를 처리할 수 있게 해 줄 뿐 아니라, 함수 밖으로 오류가 실수로 새는 것도 막아 줘요.

더 알아보기

Futures에 대한 더 자세한 내용은 Future API 레퍼런스를 참고하세요.