Dart 비동기 프로그래밍: Futures

Dart 비동기 프로그래밍: Futures

Dart가 비동기 프로그래밍을 위해 제공하는 가장 기초적인 API 중 하나가 바로 Future 타입의 객체인 future예요. Dart의 future는 다른 언어의 future나 promise API와 상당히 비슷해요. 이 글에서는 Dart future의 개념과 Future API 사용법, 그리고 future의 상태에 따라 Flutter UI를 비동기로 갱신해 주는 FutureBuilder 위젯까지 다룰게요.

출처: Dart asynchronous programming: Futures

본문

많은 Dart 비동기 API가 future를 반환해요. Dart가 비동기 프로그래밍을 위해 제공하는 가장 기초적인 API 중 하나가 Future 타입의 객체인 future인데, 대체로 다른 언어의 future나 promise API와 매우 유사해요.

이 글은 Dart future의 개념과 Future API 사용법을 다룹니다. 또한 future의 상태에 따라 Flutter UI를 비동기로 갱신하는 데 도움을 주는 FutureBuilder 위젯도 소개할게요.

async-await 같은 Dart 언어 기능 덕분에 Future API를 직접 쓸 일은 없을 수도 있어요. 하지만 Dart 코드를 다루다 보면 future를 만나게 될 가능성은 거의 확실하죠. 그리고 future를 직접 만들거나 Future API를 쓰는 코드를 읽어야 할 때도 있을 거예요.

이 글은 Flutter in Focus 비디오 시리즈 Asynchronous Programming in Dart를 바탕으로 한 두 번째 글이에요. 첫 번째 글인 Isolates and event loops는 백그라운드 작업을 지원하는 Dart의 기초를 다뤘어요. 보고 듣는 것으로 배우는 걸 더 좋아한다면, 이 글의 모든 내용이 다음 비디오에 다 들어 있어요.

새 탭에서 YouTube 재생: "Dart Futures - Flutter in Focus"

future는 데이터를 담는 작은 선물 상자라고 생각하면 돼요. 누군가 당신에게 닫혀 있는 선물 상자를 건네줘요. 잠시 후 상자가 열리면서 그 안에는 값 아니면 오류가 들어 있죠.

그래서 future는 다음의 3가지 상태 중 하나가 될 수 있어요.

  • Uncompleted(미완료): 선물 상자가 닫혀 있어요.
  • Completed with a value(값과 함께 완료): 상자가 열렸고, 선물(데이터)이 준비됐어요.
  • Completed with an error(오류와 함께 완료): 상자가 열렸지만, 뭔가 잘못됐어요.

앞으로 보게 될 코드의 대부분은 이 세 가지 상태를 어떻게 처리할지에 관한 거예요. future를 받으면, 상자가 열릴 때까지 뭘 할지, 값과 함께 열리면 뭘 할지, 오류가 있으면 뭘 할지 결정해야 하죠. 이 1-2-3 패턴을 아주 자주 보게 될 거예요.

Future의 3가지 상태

아래 그림의 이벤트 루프(event loop)를 기억하실지 모르겠어요. 이벤트 루프에 대한 글에서 본 그대로예요. future에 대해 알아두면 좋은 점은, future가 사실은 이벤트 루프를 더 쉽게 쓰도록 만들어진 API라는 거예요.

Dart 이벤트 루프는 이벤트를 한 번에 하나씩 처리해요. 당신이 작성한 Dart 코드는 단일 스레드로 실행돼요. 앱이 실행되는 내내 그 하나의 작은 스레드가 이벤트 큐에서 이벤트를 집어 들고 처리하면서 계속 돌아가고 있죠.

다운로드 버튼을 위한 코드가 있다고 해볼게요(아래에서는 RaisedButton으로 구현). 사용자가 탭하면 버튼이 사진을 다운로드하기 시작해요.

RaisedButton(
  onPressed: () {
    final myFuture = http.get('https://my.image.url');
    myFuture.then((resp) {
      setImage(resp);
    });
  },
  child: Text('Click me!'),
)

먼저 탭 이벤트가 발생해요. 이벤트 루프가 이벤트를 받아서 탭 핸들러를 호출해요(RaisedButton 생성자의 onPressed 파라미터로 설정한 함수). 핸들러는 http 라이브러리로 요청을 만들고(http.get()), 그 결과로 future(myFuture)를 받아요.

이제 myFuture라는 작은 상자를 가진 셈이에요. 처음에는 닫혀 있죠. 열릴 때 실행할 콜백을 등록하려면 then()을 사용해요.

선물 상자를 받았으니 기다립니다. 그 사이 다른 이벤트가 들어오고 사용자가 뭔가 조작할 수도 있어요. 이벤트 루프가 계속 돌아가는 동안 당신의 작은 상자는 그냥 거기 앉아 있죠.

결국 이미지 데이터가 다운로드되고, http 라이브러리가 "좋아! 여기 이 future가 있어." 하면서 데이터를 상자에 넣고 뚜껑을 열어요. 그러면 당신의 콜백이 실행되죠. then()에 건네준 그 짧은 코드가 실행돼서 이미지를 표시해요.

그 과정 내내 당신의 코드는 이벤트 루프를 직접 건드릴 필요가 없었어요. 다른 일이 진행 중이든, 다른 이벤트가 들어오든 상관없었죠. http 라이브러리에서 future를 받아서, future가 완료되면 뭘 할지만 말해 주면 됐어요.

실제 코드에서는 오류도 처리해야 해요. 그 방법은 조금 뒤에 보여드릴게요.

Future API를 좀 더 자세히 살펴볼게요. 그중 일부는 방금 사용하는 걸 봤죠.

자, 첫 번째 질문: Future 인스턴스는 어떻게 얻나요? 대부분의 경우 future를 직접 만들지는 않아요. 흔한 비동기 프로그래밍 작업에는 이미 future를 생성해 주는 라이브러리가 있기 때문이죠.

예를 들어 네트워크 통신은 future를 반환해요.

final myFuture = http.get('http://example.com');

공유 환경설정(shared preferences)에 접근하는 것도 future를 반환해요.

final myFuture = SharedPreferences.getInstance();

하지만 Future 생성자를 사용해 future를 만들 수도 있어요.

Future 생성자

가장 단순한 생성자는 Future()예요. 함수를 받아서 그 함수의 반환 타입과 일치하는 future를 반환하죠. 나중에 함수가 비동기로 실행되고, future는 함수의 반환 값으로 완료돼요. Future() 사용 예시를 볼게요.

void main() {
  final myFuture = Future(() {
    return 12;
  });
}

비동기 부분을 명확히 보기 위해 print 문을 몇 개 추가해 볼게요.

void main() {
  final myFuture = Future(() {
    print('Creating the future.'); // 두 번째로 출력돼요.
    return 12;
  });
  print('Done with main().'); // 첫 번째로 출력돼요.
}

이 코드를 DartPad(dartpad.dev)에서 실행하면, Future() 생성자에 준 함수보다 main 함수 전체가 먼저 끝나요. Future() 생성자는 처음에 미완료 future만 반환하기 때문이죠. 생성자가 "여기 상자 있어. 일단 들고 있어. 나중에 네 함수를 실행해서 데이터를 넣어줄게."라고 말하는 셈이에요. 위 코드의 출력은 다음과 같아요.

Done with main().
Creating the future.

또 다른 생성자 Future.value()는 future 값을 이미 알고 있을 때 유용해요. 캐싱을 사용하는 서비스를 만들 때 특히 그렇죠. 때로는 이미 필요한 값을 갖고 있으므로 바로 넣을 수 있어요.

final myFuture = Future.value(12);

Future.value() 생성자에는 오류와 함께 완료되는 대응 생성자가 있어요. 이름은 Future.error()인데, 동작은 기본적으로 같되 오류 객체와 선택적 stacktrace를 받아요.

final myFuture = Future.error(ArgumentError.notNull('input'));

아마 가장 편리한 생성자는 Future.delayed()일 거예요. Future()처럼 동작하지만, 함수를 실행하고 future를 완료하기 전에 지정된 시간만큼 기다린다는 점이 달라요.

Future.delayed()를 쓰는 한 가지 경우는 테스트용 mock 네트워크 서비스를 만들 때예요. 로딩 스피너가 올바르게 표시되는지 확인해야 한다면, 지연 future가 좋은 친구가 돼줘요.

final myFuture = Future.delayed(
  const Duration(seconds: 5),
  () => 12,
);

Futures 사용하기

future가 어디서 오는지 알았으니 이제 어떻게 쓰는지 이야기해 볼게요. 앞서 말했듯이 future를 사용한다는 건 대체로 그 세 가지 상태, 즉 미완료, 값과 함께 완료, 오류와 함께 완료를 어떻게 처리할지 다루는 일이에요.

다음 코드는 Future.delayed()로 3초 후 값 100과 함께 완료되는 future를 만들어요.

void main() {
  Future.delayed(
    const Duration(seconds: 3),
    () => 100,
  );
  print('Waiting for a value...');
}

이 코드가 실행되면 main()은 위에서 아래로 실행되면서 future를 만들고 "Waiting for a value..."를 출력해요. 그 내내 future는 미완료 상태이죠. 3초가 지나기 전에는 완료되지 않아요.

완료된 값을 사용하려면 then()을 쓰면 돼요. then()은 각 future의 인스턴스 메서드로, future가 값과 함께 완료됐을 때 실행할 콜백을 등록하는 데 써요. future의 타입과 일치하는 단일 파라미터를 받는 함수를 넘겨주면 돼요. future가 값과 함께 완료되면 그 값과 함께 당신의 함수가 호출돼요.

void main() {
  Future.delayed(
    const Duration(seconds: 3),
    () => 100,
  ).then((value) {
    print('The value is $value.'); // 3초 후에 출력돼요.
  });
  print('Waiting for a value...'); // 먼저 출력돼요.
}

위 코드의 출력은 다음과 같아요.

Waiting for a value... *(콜백이 실행되기까지 3초 지남)*
The value is 100.

then()은 코드를 실행하는 것에 더해, 넘겨준 함수의 반환 값과 일치하는 자신만의 future를 반환해요. 그래서 비동기 호출을 여러 개 해야 한다면, 반환 타입이 서로 달라도 체인으로 이어 붙일 수 있어요.

_fetchNameForId(12)
    .then((name) => _fetchCountForName(name))
    .then((count) => print('The count is $count.'));

첫 번째 예시로 돌아가서, 그 초기의 future가 값으로 완료되지 않고 오류로 완료되면 어떻게 될까요? then() 메서드는 값을 기대하죠. 오류가 발생했을 때 실행할 다른 콜백을 등록할 방법이 필요해요.

그 답은 catchError()를 쓰는 거예요. then()과 비슷하게 동작하지만 값을 대신 오류를 받고, future가 오류와 함께 완료되면 실행돼요. then()과 마찬가지로 catchError() 메서드도 자신만의 future를 반환하므로, 서로를 기다리는 then()catchError() 메서드의 전체 체인을 구축할 수 있어요.

참고: async-await 언어 기능을 사용한다면 then()이나 catchError()를 호출할 필요가 없어요. 대신 완료된 값을 await하고, 오류를 처리할 때는 try-catch-finally를 사용하면 돼요. 자세한 내용은 Dart 언어 투어의 asynchrony 지원 섹션을 참고하세요.

catchError()로 future가 오류와 함께 완료되는 경우를 처리하는 예시를 볼게요.

void main() {
  Future.delayed(
    Duration(seconds: 3),
    () => throw 'Error!', // 오류와 함께 완료돼요.
  ).then((value) {
    print(value);
  }).catchError((err) {
    print('Caught $err'); // 오류를 처리해요.
  });
  print('Waiting for a value...');
}

catchError()에 테스트 함수를 넘겨서 콜백을 호출하기 전에 오류를 확인할 수도 있어요. 이렇게 하면 각각 다른 종류의 오류를 검사하는 여러 catchError() 함수를 가질 수 있어요. catchError()의 선택적 test 파라미터를 사용해 테스트 함수를 지정하는 예시를 볼게요.

void main() {
  Future.delayed(
    Duration(seconds: 3),
    () => throw 'Error!',
  ).then((value) {
    print(value);
  }).catchError((err) {
    print('Caught $err');
  }, test: (err) { // 선택적 test 파라미터.
    return err is String;
  });
  print('Waiting for a value...');
}

여기까지 읽었다면, future의 세 가지 상태가 코드 구조에 자주 반영된다는 걸 어렵지 않게 눈치챘을 거예요. 위 예시에는 세 개의 블록이 있어요. 첫 번째 블록은 미완료 future를 만들고, 그다음 future가 값과 함께 완료되면 호출할 함수가 있고, 마지막으로 future가 오류와 함께 완료되면 호출할 함수가 있어요.

사용할 만한 메서드가 하나 더 있어요. 바로 whenComplete()예요. 값이든 오류든 future가 완료되면 어떤 함수를 실행하도록 하는 데 써요. try-catch-finallyfinally 블록과 비슷하죠. 모든 게 잘 진행되면 실행되는 코드, 오류를 위한 코드, 그리고 무슨 일이 있어도 실행되는 코드가 있는 셈이에요.

Flutter에서 futures 사용하기

이렇게 future를 만들고 그 값의 일부를 사용하는 법을 알아봤어요. 이제 Flutter에서 실제로 활용하는 방법을 이야기해 볼게요.

JSON 데이터를 반환하는 네트워크 서비스가 있고 그 데이터를 표시하고 싶다고 해볼게요. future를 만들고 완료나 오류를 확인하며 setState()를 호출하는 등 배선(wiring)을 전부 수동으로 처리하는 StatefulWidget을 만들 수도 있어요.

아니면 FutureBuilder를 쓸 수도 있어요. Flutter SDK에 포함된 위젯으로, future와 builder 함수를 넘겨주면 future가 완료될 때 자식 위젯을 자동으로 다시 빌드해요.

FutureBuilder 위젯은 builder 함수를 호출해서 동작해요. 이 함수는 컨텍스트와 future 현 상태의 스냅샷을 받죠.

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // FutureBuilder를 사용해요.
    return FutureBuilder<String>(
      future: _fetchNetworkData(),
      builder: (context, snapshot) {},
    );
  }
}

스냅샷을 확인해서 future가 오류로 완료됐는지 볼 수 있어요.

    return FutureBuilder<String>(
      future: _fetchNetworkData(5),
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          // Future가 오류와 함께 완료됐어요.
          return Text(
            'There was an error',
          );
        }
        throw UnimplementedError("Case not handled yet");
      },
    );

아니면 hasData 프로퍼티를 확인해서 값으로 완료됐는지 볼 수도 있어요.

    return FutureBuilder<String>(
      future: _fetchNetworkData(5),
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          // Future가 오류와 함께 완료됐어요.
          return Text(
            'There was an error',
          );
        } else if (snapshot.hasData) {
          // Future가 값과 함께 완료됐어요.
          return Text(
            json.decode(snapshot.data)['field'],
          );
        }
        throw UnimplementedError("Case not handled yet");
      },
    );

hasErrorhasData 둘 다 true가 아니라면 아직 기다리는 중이라는 뜻이므로, 그에 맞는 내용도 출력할 수 있어요.

    return FutureBuilder<String>(
      future: _fetchNetworkData(5),
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          // Future가 오류와 함께 완료됐어요.
          return Text(
            'There was an error',
          );
        } else if (snapshot.hasData) {
          // Future가 값과 함께 완료됐어요.
          return Text(
            json.decode(snapshot.data)['field'],
          );
        } else {
          // 미완료 상태예요.
          return Text(
            'No value yet!',
          );
        }
      },
    );

Flutter 코드에서도 그 세 가지 상태, 즉 미완료, 값과 함께 완료, 오류와 함께 완료가 계속 등장하는 걸 볼 수 있어요.

정리

이 글에서는 future가 무엇을 나타내는지, 그리고 FutureFutureBuilder API로 future를 만들고 완료된 값을 사용하는 방법을 다뤘어요.

future 사용에 대해 더 배우고 싶다면 — 실행 가능한 예시와 상호작용 연습으로 이해도를 확인할 수 있는 — futures, async, await에 관한 비동기 codelab을 확인해 보세요.

아니면 Asynchronous Programming in Dart 시리즈의 다음 비디오로 넘어가도 돼요. 스트림(streams)에 관한 내용인데, 값이나 오류를 제공할 수 있다는 점에서 future와 아주 비슷해요. 다만 future는 결과를 하나 주고 멈추는 반면, 스트림은 계속해서 흘러가죠.

새 탭에서 YouTube 재생: "Dart Streams - Flutter in Focus"

이 글이 바탕으로 한 비디오를 만든 Andrew Brogdon에게 큰 감사를 전합니다.

더 알아보기