들어가기 앞서
동기와 비동기에 대해 잘 모른다면 아래의 글을 먼저 읽고 오시는걸 추천드립니다!
개론
비동기 프로그래밍은 다른 작업이 끝나기를 기다리는 것이 아니라 그 동안에 다른 작업을 수행하게 해준다.
보통 아래의 작업들을 비동기로 수행하곤 한다.
- 네트워크를 통해 데이터 가져오기
- 데이터베이스에 데이터 쓰기
- 파일에서 데이터 읽어오기
이런 작업들은 읽어오거나 가져온 결과를 Future, 만약 여러개의 부분으로 나누어져 있는 결과라면 Stream으로 가져온다.
만약에 한 함수 내부에서 비동기 작업이 수행된다고 하면, 내부의 명령어만 비동기이면 되는게 아니라 해당 함수도 비동기 함수여야 한다.▼
그렇다면 비동기 함수는 일반적인 함수와 어떤 차이가 있을까?
비동기 함수들은 Future클래스를 결과로 반환하거나 Stream 클래스를 결과로 반환하곤 한다.
위의 비동기 결과들과 상호작용하기 위해 async와 await 키워드를 사용할 수 있다.
Future 클래스
Future 클래스란?
그렇다면 비동기 함수가 반환한다는 Future 클래스는 무엇일까?
Future 클래스는 이름의 뜻 그대로 미래에 비동기 작업이 끝나면 값을 받아오는 클래스이다.
Future는 비동기 작업의 결과를 미완성(uncompleted)과 완성(completed)의 2개의 상태로 표현한다.
미완성(uncompleted)
비동기 함수를 호출하면, uncompleted Future를 리턴한다.
Future는 함수의 비동기 작업이 끝나거나 에러를 던지는 것을 기다린다.
완성(completed)
비동기 작업이 성공하면, Future는 completed Future로 값을 완성하게 된다.
만약 작업이 실패하여 값이 제대로 나오지 않았다면 에러로 값을 완성하게 된다.
왜 uncompleted Future라는 상태를 반환할까?
아래의 그림과 같은 함수가 있다고 해보자.
여기서의 비동기 함수는 3초 뒤에 네트워크에서 고라니 사진을 반환하고, show 함수는 비동기 함수로부터 이미지를 받아서 이미지를 보여준다.▼
위의 두 코드를 실행시키게 되면, 아래의 시간선과 같이 실행이 된다.
가장 큰 문제는 비동기 함수가 이미지를 반환하기도 전에 show 함수가 실행이 된다는 것이다.▼
위의 코드는 이미 잘못된 코드긴 하지만, image가 null을 참조하지 않게끔 하기 위해 비동기 함수는 아무것도 반환하지 않는게 아니라 uncompleted Future를 반환하여 그 빈자리를 채워준다.
그리고 그 빈자리를 나중에 비동기 함수의 실행을 통해 completed Future로 바꿈으로써 오류를 막아준다.▼
하지만 위에서도 언급했지만 예시 코드는 이미 잘못된 코드이다.
왜 잘못됐을까? 이제부터 알아보자.
Future를 이용한 잘못된 비동기 프로그램
위에 언급된 예시와 유사한 다른 코드를 들고 왔다.
아래의 코드는 2초 뒤에 ‘고라니’ 라는 문자열을 반환하는 `findGorani` 함수와 `findGorani` 함수로부터 문자열을 받아서 ‘드디어 제가 $gorani 를 잡았습니다!’ 를 반환하는 `catchGroani`로 이루어져있다.▼
String catchGorani() {
var gorani = findGorani();
return '드디어 제가 $gorani 를 잡았습니다!';
}
Future<String> findGorani() => Future.delayed(
const Duration(seconds: 2),
() => '고라니',
);
void main() {
print(catchGorani());
}
위의 코드를 실행시키면 어떻게 될까?
‘드디어 제가 고라니 를 잡았습니다!’ 라는 문자열이 출력될까?
정답은 제대로 출력되지 않는다이다.
출력의 결과는 아래와 같다.▼
이렇게 나오는 이유는 uncompleted Future가 완성되기도 전에 출력을 했기 때문이다.
위의 코드의 시간선을 그려보면 아까 보았던 시간선과 비슷한 시간선이 나온다.▼
async & await
async와 await 키워드는 Future를 사용하기 위해 선언적(declarative)으로 사용된다.
async
async 키워드를 통해 비동기 함수임을 선언할 수 있다.
Dart에서는 타입 추론을 지원하여 굳이 async 키워드를 작성하지 않아도 비동기 함수임을 알 수 있지만, 그래도 비동기 함수임을 명시해주는 것이 좋다.
async 키워드는 아래와 같이 함수의 body(몸체)부분 전에 명시해주면 된다.▼
Future asyncFunction() async {}
await
await 키워드를 통해 비동기 함수가 값을 완성하는 것을 기다릴 수 있다.
await 키워드는 async가 명시된 함수 내에서 사용이 가능하다.
Dart에서 타입 추론을 지원하여 async 키워드가 없어도 알아차릴 수 있다고 했지만, await를 사용할 때는 추론이 제대로 되지 않는 것인지 async가 명시된 함수에서만 사용이 가능하다.
await 키워드를 통해 위에서 봤던 코드를 수정할 수 있다.▼
Future<String> catchGorani() async {
var gorani = await findGorani();
return '드디어 제가 $gorani 를 잡았습니다!';
}
Future<String> findGorani() => Future.delayed(
const Duration(seconds: 2),
() => '고라니',
);
void main() async {
print(await catchGorani());
}
`catchGorani`와 `main`에 async 키워드를 붙여 비동기 함수로 만들어주었다.
비동기문에서 completed Future을 받기 위해서 await 키워드가 catchGorani와 findGorani 앞에 붙었다.
위와 같이 수정하고 나면 아래의 제대로 된 결과를 받을 수 있다.▼
Stream 클래스
Stream이란?
Stream은 쉽게 말하면 데이터나 이벤트가 들어오는 통로다.
Stream과 Future의 차이점은 Future가 단일 이벤트라면 Stream은 일련의 비동기 이벤트이다.
네트워킹을 하다보면 이런 저런 이유로 데이터가 들어오는 타이밍을 제대로 잴 수 없을 때가 많다.
와이파이 신호가 약하다거나, 데이터를 보내는 쪽에서 장애가 생겼다거나 하는 이유로 데이터가 어쩔 때는 빠르게, 어쩔 때는 느리게 들어온다. ▼
동기적인 코드를 짜면 행동의 순서가 굉장히 중요해지는데, 어느 순서에 들어오는 지를 모르면 코드를 제대로 짤 수 없게 된다.
이런 얘기는 동기와 비동기 개론에서 더 보기로 하자.
결국에는 이런 일을 막기 위해 비동기적인 코드를 작성하게 되고, 이를 처리할 때 데이터 생산자와 데이터 소비자의 두 개의 주체로 나누어서 처리를 하게 된다.
여기서 추가로 구독이라는 개념이 들어가게 되는데, 데이터 소비자는 데이터 생산자를 구독하여 이벤트를 감지한다.
그리고 이 구독과 변경점 알림 사이에 데이터가 이동하는 파이프가 Stream이 된다. ▼
그러나 stream을 생성한다고 생산자와 소비자가 한 번에 생기고 연결이 되는 것은 아니다.
Stream은 연결을 해줘야 하는데 listen이 이를 도와준다.
그래서 stream은
- Stream 생성 (생산자 설정)
- Listen을 통해 연결
- 이벤트 처리 (소비자 설정)
의 3단계로 이뤄진다고 할 수 있다.
그럼 Future와 어떤 부분이 다른건지?
비동기적 처리를 하는 것을 보면 Future랑 크게 다를게 없어 보인다.
Stream에는 구독과 변경점 알림의 구조가 있다는 것은 알겠지만, 어쨌거나 비동기적 데이터 처리라는 점에서는 Future와 큰 차이가 없어 보인다.
둘 다 완성되지 않은 데이터를 들고 있다가 데이터가 들어오면 완성해주고 알려주는 정도로만 생각된다.
하지만 둘의 결정적인 차이점은 ‘데이터를 연속적으로 받을 수 있는지’이다.
Future의 경우
Future는 비동기적으로 나중에 완성된 데이터를 가져다가 사용할 수 있다. ▼
하지만 그 이후에 다른 데이터가 들어오는 것에 대해서는 처리할 수 없다.
Stream의 경우
Stream은 Future와 다르게, 이후에 들어오는 일련의 데이터를 모두 처리할 수 있다.
단일 비동기 작업만 처리하는 Future과는 다르게 Stream에서는 지속적인 데이터 교체와 처리가 가능해진다.
그래서 이름이 Stream이라고 생각하면도 쉽다.
데이터의 흐름, 즉 Data Stream을 처리할 수 있는 타입이다.
async* & yield
Future에서는 `async` 키워드를 사용했다면, Stream에서는 `async*` 키워드를 사용한다.
예제
하나의 예시를 보자.
아래는 `Future`를 반환하는 비동기 함수 `sumStream`이다. ▼
Future<int> sumStream(Stream<int> stream) async {
var sum = 0;
await for (var value in stream) {
sum += value;
}
return sum;
}
해당 함수는 `Stream stream` 을 매개변수로 받고, `stream` 이 주는 값을 받아 `sum`에 합한다.
stream이 주는 데이터라는 것을 제외하면 일반적인 함수랑 다를 것이 없는데 일반적인 함수랑 다른 부분은,
stream이 제공하는 데이터(이벤트)가 끝나면 함수는 다음 데이터가 들어오거나 stream이 끝날 때 까지 정지된다는 것이다. ▼
이는 `await for 루프` 때문이기도 한다.
당연한 이야기겠지만, `await for 루프`를 사용하려면 `async` 키워드를 함수에 붙여야 한다.
그러면 `sumStream`이 제공받은 `stream`인 `countStream`을 한 번 보자. ▼
Stream<int> countStream(int to) async* {
for (int i = 1; i <= to; i++) {
yield i;
}
}
countStream(10).listen((event) {
// something
});
`countStream`은 `to`로 들어온 수 까지 이벤트를 생성한다.
그런데 어떻게 이벤트를 생성하는지가 의문일 것인데, 바로 `yield` 키워드가 그 역할을 한다.
yield 키워드를 통해 `countStream`에 연결된 `listener`에게 데이터를 전달한다.
또한 이렇게 `yields`를 통해 연속적으로 비동기적 이벤트를 생성할 때, 즉 stream을 사용할 때는 `async`가 아닌 `async*` 키워드를 사용한다.
Future와 전체적인 사용 느낌은 비슷하지만 이렇게 키워드에서 차이가 난다.
그러면 countStream과 sumStream 함수는 아래와 같이 사용이 가능해진다. ▼
main() async {
var stream = countStream(10);
var sum = await sumStream(stream);
print(sum); // 55
}
마치며
이번에는 Dart에서 비동기 작업을 어떻게 처리하는지 간단하게 알아보았다.
Dart에서는 이렇게 Future와 Stream으로 비동기 작업을 처리할 수 있고, Future와 Stream의 결정적인 차이점은 ‘연속적이냐 불연속적이냐’ 였다.
찍먹이라기엔 상당히 내용이 길었지만 Future와 Stream에 대한 공식 문서가 한가득 있어서 여기있는 글을 봤다고 다 본 게 아니다.
아마 전체 내용의 한 10% 정도 보지 않았나 싶다.
아무튼간에 뒤의 내용들은 비동기에 대해 대괄적으로 이해를 한 후에 보면 쉽게 익히지 않을까 싶다.
'Develop > Flutter' 카테고리의 다른 글
[Flutter] Dart의 Single Quote와 Double Quote (0) | 2024.01.31 |
---|---|
[Flutter] 파이어베이스 이메일 인증에서 생긴 문제 (0) | 2024.01.23 |
[Flutter][Error] M1 맥 Flutter CocoaPod 설치 오류 (0) | 2024.01.21 |
[Flutter] 동기와 비동기 개론 (0) | 2024.01.20 |
[Flutter] 위젯을 메소드로 쪼개는 것이 왜 안좋은가? (0) | 2024.01.18 |