관리 메뉴

나만을 위한 블로그

[Flutter] Future, async / await를 통한 비동기 처리 방법 본문

Flutter

[Flutter] Future, async / await를 통한 비동기 처리 방법

참깨빵위에참깨빵 2024. 7. 24. 23:53
728x90
반응형

안드로이드에선 주로 레트로핏을 사용해 통신하는 경우가 많은데, 플러터에선 http라는 패키지를 통해 네트워크 통신을 하는 경우도 있는 듯하다. 물론 dio, 플러터의 retrofit 등 여러가지를 쓰는 경우가 많겠으나 네트워크 통신을 하려면 이런 라이브러리보다 async, await, Future라는 키워드를 먼저 알아야 한다.

아래는 플러터 비동기 프로그래밍을 다루는 공식문서다.

 

https://dart.dev/libraries/async/async-await

 

Asynchronous programming: futures, async, await

Learn about and practice writing asynchronous code in DartPad!

dart.dev

(중략)...이 튜토리얼은 다음을 다룬다

- async, await를 쓰는 방법, 시기
- async, await 사용이 실행 순서에 주는 영향
- try-catch를 써서 비동기 호출 오류를 처리하는 법

 

비동기 프로그래밍을 구현하면 다른 연산이 완료되길 기다리는 동안 다른 작업을 할 수 있다.

플러터에서 비동기 계산은 일반적으로 Future 형태의 결과를 제공하고, 결과가 여러 파트로 구성됐다면 스트림으로 제공한다.

언제 도착할 지 모르는 비동기 계산의 결과를 받아서 처리하기 위해 필요한 키워드가 async, await다.

 

두 키워드를 보기 전에 먼저 Future가 뭔지 확인한다.

Future는 비동기 연산의 결과를 나타내고 uncompleted(값이 생성되기 전의 미래 상태), completed의 2가지 상태를 가진다.

 

  • uncompleted : 비동기 함수를 호출하면 완료되지 않은 미래를 리턴한다. 이 Future는 비동기 연산이 끝나거나 오류 발생을 기다린다
  • completed : 비동기 연산이 성공하면 Future가 value로 완료된다. 그렇지 않고 어떤 이유로든 실패하면 error와 함께 완료된다

 

Future의 뒤에는 제네릭 타입을 붙여서 Future<T> 형태로 쓸 수 있다. Future<String>은 문자열 값을 생성하는 것이다. 만약 값을 만들지 않는다면 Future<void>라고 쓸 수 있다.

아래는 Future를 사용하는 간단한 예시다.

 

Future<void> fetchUserOrder() {
  // 이 함수가 다른 서비스, DB에서 유저 정보를 가져온다고 가정하라
  return Future.delayed(const Duration(seconds: 2), () => print('Large Latte'));
}

void main() {
  fetchUserOrder();
  print('Fetching user order...');
}

 

위 코드를 실행하면 2초 대기하는 delayed()의 영향 때문에 메인 함수의 print()가 먼저 완료되고 2초 뒤에 "Large Latte"가 출력된다.

다음은 에러로 완료되는 Future의 예시 코드다.

 

Future<void> fetchUserOrder() {
  return Future.delayed(
    const Duration(seconds: 2),
    () => throw Exception('Logout failed: user ID is invalid'),
  );
}

void main() {
  fetchUserOrder();
  print('Fetching user order...');
}

 

일부러 유저 ID가 유효하지 않다는 예외를 일으킨다. 마찬가지로 메인 함수의 print()가 먼저 완료되고 이후 에러가 표시된다.

 

이제 async / await를 확인한다. 두 키워드는 비동기 함수를 정의하고 비동기 함수의 결과를 사용하기 위한 키워드다.

이걸 사용하려면 기억해야 할 2가지 기본 지침이 있다.

 

  • 비동기 함수를 정의하려면 함수 본문 앞(중괄호 앞)에 async를 추가한다
  • await는 비동기 함수에서만 쓸 수 있다

 

함수의 중괄호 부분을 함수 본문이라고 한다. 이 중괄호에서 여는 중괄호 앞에 async를 붙이면 그 함수는 비동기 함수가 된다.

 

void main() async { ··· }

 

함수가 어떤 값을 리턴해야 한다면 타입을 Future<T>로 설정한다. 값을 리턴하지 않으면 Future<void>로 설정한다.

 

Future<void> main() async { ··· }

 

이제 await를 써서 비동기 함수가 완료되기를 기다리면 된다.

 

print(await createOrderMessage());

 

await를 붙이지 않으면 비동기 함수가 비동기적으로 연산되지 않고 즉시 완료돼 버리니 주의한다.

 

대충 확인했으니 이제 실제론 어떻게 사용할 수 있는지 예시를 확인한다. 플러터 쿡북 중 인터넷에서 데이터 받아오기라는 이름의 문서가 있어서 이걸 기준으로 확인한다.

 

https://docs.flutter.dev/cookbook/networking/fetch-data

 

Fetch data from the internet

How to fetch data over the internet using the http package.

docs.flutter.dev

 

먼저 안드로이드, 아이폰 에뮬레이터에 네트워크 권한 처리를 해 둔다. 그리고 http 패키지를 pubspec에 명시하고 의존성을 적용한다.

그리고 jsonplaceholder라는 도메인에서 앨범 정보를 받아오는 url에 GET 요청을 날리는 함수를 정의한다.

 

import 'package:http/http.dart' as http;

Future<http.Response> fetchAlbum() {
  return http.get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1'));
}

 

http.dart 패키지에 http라는 별칭을 붙였기 때문에 get()을 호출할 때 앞에 http를 붙이는 걸 볼 수 있다.

앞서 Future가 어떤 값을 리턴한다면 <T>를 붙인다고 했기 때문에, 위 함수에서 Future는 http 패키지의 Response 클래스 형태와 같은 값을 리턴할 거라고 추측할 수 있다.

 

그리고 JSON 안의 데이터를 파싱하기 위한 모델을 만든다. 패턴 매칭을 써서 JSON에서 필요한 값들을 뽑아오는 팩토리 함수도 같이 만든다.

 

class Album {
  final int userId;
  final int id;
  final String title;

  const Album({
    required this.userId,
    required this.id,
    required this.title,
  });

  factory Album.fromJson(Map<String, dynamic> json) {
    return switch (json) {
      {
        'userId': int userId,
        'id': int id,
        'title': String title,
      } =>
        Album(
          userId: userId,
          id: id,
          title: title,
        ),
      _ => throw const FormatException('Failed to load album.'),
    };
  }
}

 

이제 네트워크 통신 결과가 담긴 http.response를 Album 형태로 변환해야 한다. fetchAlbum()을 수정한다

 

import 'dart:convert';
import 'album.dart';
import 'package:http/http.dart' as http;

Future<Album> fetchAlbum() async {
  final response = await http
      .get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1'));

  if (response.statusCode == 200) {
    // 200 응답을 받으면 JSON 파싱
    return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
  } else {
    // 200 응답이 아니면 예외를 던진다
    throw Exception('Failed to load album');
  }
}

 

여기서 Uri.parse()에 넣은 경로를 실행하면 어떤 값을 받는지 확인한다.

 

{
    "userId": 1,
    "id": 1,
    "title": "quidem molestiae enim"
}

 

JSON은 key, value로 이뤄진 Map과 같은 형태를 갖고 있다. 그래서 Album에 만든 fromJson()은 switch문을 써서 JSON 안의 key 이름들을 나열하고 그 값을 앱에서 어떤 자료형의 값으로 쓸 것인지 정의한 후, Album의 생성자를 호출해 파싱한 값들이 담긴 인스턴스를 만들어낸다.

 

외부에서 데이터를 가져왔다면 화면에 뿌릴 일만 남았다. 공식문서에선 FutureBuilder 위젯을 사용하라고 설명하니 이것을 사용해서 가져온 데이터를 표시한다.

이 때 2가지 파라미터를 반드시 넘겨야 한다.

 

  • fetchAlbum()에서 리턴된 Future
  • 로딩, 성공, 에러 같은 Future 상태에 따라 렌더링할 내용을 알려주는 빌더 함수

 

fetchAlbum()은 널이 아닌 값만 리턴할 수 있어서 400, 500 대의 에러가 발생해도 예외를 던져야 한다. 그래야 왜 데이터가 표시되지 않는지 알 수 있고, 사용자에겐 적절한 오류 메시지의 팝업 등을 띄워서 안내할 수 있다.

또한 공식문서에 갑자기 snapshot이란 단어가 튀어나온다. 이 객체를 통해 hasData, hasError 함수를 호출하는데 이 스냅샷이란 게 무엇인가?

 

https://stackoverflow.com/a/67049934

 

What is a snapshot in Flutter?

I've been using a Firebase database in my project. I've been following a tutorial, and when returning widgets to the future builder it says to use: if(snapshot.hasError) { // Cannot connect to

stackoverflow.com

스냅샷은 FutureBuilder에서 수신 중인 Future 또는 스트림의 결과다. 리턴되는 데이터와 상호작용해서 빌더에서 쓰기 전에 먼저 데이터에 접근해야 한다. FutureBuilder가 사용자에게 간접적으로 전달하는 이 데이터에 접근하려면 FutureBuilder에 요청해야 한다
스냅샷은 말하자면 내가 플러터에 사용하겠다고 말한 별명이기 때문에 먼저 스냅샷이라고 한다. 왜냐면 내 FutureBuilder는 이런 모양을 하고 있기 때문이다
FutureBuilder(
     future: someFutureFunction(),
     builder: (context, snapshot) { // 여기서 플러터에게 snapshot이란 단어를 쓰도록 지시했다
     if (snapshot.connectionState == ConnectionState.waiting)
         return Center(child: CircularProgressIndicator());
     else
         return Text(counter.toString());
}),

 

중간까지 읽었다면 내가 snapshot이란 단어를 쓴 적도 없는데 갑자기 이건 어디서 튀어나왔나 싶을 수 있다.

그러나 공식문서 맨 밑의 전체 코드를 보면 알 수 있다. 아래는 메인 함수와 Stateful 위젯을 정의한 코드들이다.

 

void main() => runApp(const MyApp());

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  late Future<Album> futureAlbum;

  @override
  void initState() {
    super.initState();
    futureAlbum = fetchAlbum();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: Scaffold(
        appBar: AppBar(
          title: const Text('인터넷에서 데이터 가져오기'),
        ),
        body: Center(
          child: FutureBuilder<Album>(
            future: futureAlbum,
            builder: (context, snapshot) {
              if (snapshot.hasData) {
                return Text("snapshot - title : ${snapshot.data!.title}");
              } else if (snapshot.hasError) {
                return Text('${snapshot.error}');
              }

              // By default, show a loading spinner.
              return const CircularProgressIndicator();
            },
          ),
        ),
      ),
    );
  }
}

 

Center의 builder 부분을 보면 context, snapshot이란 단어가 쓰인 게 보인다.

builder에 마우스를 올리면(맥은 cmd + J를 누르면) 문서가 작게 표시된다.

 

 

AsyncSnapshot이란 타입을 넘기라고 하는데 이것은 가장 최근에 이뤄진 비동기 계산과의 상호작용을 불변으로 표시하기 위해 존재하는 클래스다.

 

https://api.flutter.dev/flutter/widgets/AsyncSnapshot-class.html

 

AsyncSnapshot class - widgets library - Dart API

Immutable representation of the most recent interaction with an asynchronous computation. See also: Annotations Constructors AsyncSnapshot.nothing() Creates an AsyncSnapshot in ConnectionState.none with null data and error. const AsyncSnapshot.waiting() Cr

api.flutter.dev

 

이 클래스에 정의된 함수들을 보면 스냅샷에 널이 아닌 값이 포함됐는지 확인해서 bool 값을 리턴하는 hasData, 널이 아닌 에러가 포함된지 확인하는 hasError 등 여러 함수들이 있다. 스냅샷은 왜 필요한 건가?

 

앞서 비동기 처리를 통해 인터넷에서 데이터를 가져오는 코드를 작성했다. 그 비동기 처리의 결과는 Future<Album> 형태로 받아오고 있다. 이 때 언제 끝날지 모르는 Future 데이터에 접근해서 비동기 작업의 상태를 확인하고 성공했으면 결과값(데이터), 실패했으면 에러 상태를 확인해서 다른 처리를 하기 위해 AsyncSnapshot(=스냅샷)이 필요하다.

예시에선 이 클래스에 접근하기 위해 buildContext(context)와 AsyncSnapshot(snapshot)을 각각 다른 이름으로 간략화해서 사용할 뿐이다. 이를 통해 AsyncSnapshot에 정의된 hasData, hasError를 통해 데이터가 있는 경우와 에러만 있는 경우에 대해 각각 다른 화면을 표시할 수 있게 된다. snapshot이 아닌 asyncSnapshot 같은 다른 이름으로 바꿔도 전혀 문제없다.

 

이제 앱에 빌드하면 아래와 같은 화면이 표시된다.

 

 

반응형

'Flutter' 카테고리의 다른 글

[Dart] 반복문  (0) 2024.08.06
[Flutter] Stateless, Stateful 위젯의 생명주기  (0) 2024.08.05
[Dart] final vs const  (0) 2024.07.22
[Dart] 생성자(constructor) 알아보기  (0) 2024.07.21
[Flutter] Scaffold란?  (0) 2024.07.17
Comments