Dart의 Future과 Stream

Dart의 비동기 작업에서 가장 중요한 두 클래스를 배워봅시다.
Posted on 2023-05-30 by GKSRUDTN99
Flutter Dart

앱 개발에서 비동기 작업이란..

저는 앱 개발에서 비동기 처리가 중요한 이유는,

앱이 해야 하는 일들에 우선순위를 부여할 수 있기 때문이라고 생각합니다.


회사에서, 직장 상사가 지시한 일들과,

동료가 도와달라고 요청한 일들이 있다고 가정 해보겠습니다.

이 같은 상황이 있다면, 직장 상사가 지시한 일들은 주어지는 즉시 처리하는 것이 바람직합니다.

따라서 동료가 도와달라고 요청한 일들은, 상사가 지시한 일들이 모두 처리되어 남는 시간에, 짬짬이 처리하는 것이 좋습니다.

일들의 우선순위를 구분하지 않고, 일이 들어오는 대로 처리한다면, 동료를 도와주는 일에 밀려 직장 상사가 지시한 일의 처리가 늦어지는 경우가 생길 수 있습니다.


앱에 있어서, 직장 상사가 지시한 일들은 UI 처리와 같다고 볼 수 있습니다.

앱은, 언제든지 사용자와의 상호작용에 반응할 수 있어야 합니다.

따라서 UI 처리가 우선순위에서 밀려, 앱이 멈춰있는 것처럼 보이는 상황은 바람직하지 않습니다.

네트워킹이나 파일 저장과 같이 오래 걸리는 작업은, UI 처리가 끝나고, 남는 시간에 처리해야 합니다.


앞서 배웠던 async와 await 그리고 Future와 같은 것들은 위와 같은 우선순위를 코드상으로 구현하기 위한 도구라고 볼 수 있습니다.



Future

Dart에서 동기적으로 실행되는 함수의 경우, 그 함수가 실행되면 함수의 코드들이 전부 실행되고, 결과나 에러를 반환할 때까지 기다립니다.

void main() {
  returnThree();

  // 이 부분에 있는 코드들은 returnThree 함수가
  // 완전히 실행된 뒤에 실행됩니다.
}

int returnThree() {
  // 길고 복잡한 코드들...
  return 3;
}

하지만 비동기적으로 실행되는 함수의 경우, 함수 내에 있는 코드들의 실행을 기다리지 않고,

다음 코드로 넘어갈 수 있도록 하기 위해, 함수가 호출되자마자,

Future 클래스의 객체를 반환합니다.

void main() {
  returnThree();

  // 이 부분에 있는 코드들은 returnThree 함수가
  // 완전히 실행되지 않아도, 실행됩니다.
}

Future<int> returnThree() async {
  // 길고 복잡한 코드들...
  return 3;
}


그럼, 그냥 intFuture<int>는 어떤 차이가 있는지 알아봅시다.

int형의 변수에 담긴 값은, 메모리에 그 변수가 어떤 값을 가졌는지 저장되어 있음이 보장됩니다.

따라서 별도의 처리 없이, 변수를 자유롭게 사용하는 것이 가능합니다.


반면에 Future<int>타입 변수의 경우에는, 그 변수가 어떤 값을 가졌는지 저장되어 있을 수도 있고, 없을 수도 있습니다.

다시 말해 int형 변수와 달리, 추가로 '상태'라는 속성을 가집니다.

Future<int>와 같은 Future 타입의 객체(변수)는, 'uncompleted'또는 'completed'라는 두 가지 상태를 가질 수 있습니다.


좀 더 알기 쉽게 비동기적 함수 실행의 흐름을 살펴보면,

  1. main()함수가 returnThree()라는 비동기 함수를 호출
  2. returnThree()함수는 호출되자마자 Future<int>타입 객체를 만들고, 그 객체의 상태값을 'uncompleted'라고 표시한 뒤, 추가적인 코드 실행 없이 main()함수로 방금 만든 객체를 반환해 줍니다.
  3. 그럼 main()함수와 returnThree()함수가 각자의 코드를 실행하다가, returnThree()함수의 실행이 완료되면, 아까 반환해 준 Future<int>객체의 상태값을 'completed'라고 바꾸고, 결괏값(3)도 함께 넣어줍니다.


자, 그럼 main()함수 쪽에서는 이 Future<int> 객체를 어떻게 사용하는지 살펴봅시다.

위에서도 말했듯이, int형 변수와는 달리, 값이 있을지 없을지 알 수 없기 때문에, 이렇게 사용해야 합니다.

나중에 Future<int>에 값이 들어오면(completed 상태가 되면), 그 값을 가지고 이 코드들(closure)을 실행시켜 줘

위와 같은 느낌으로 코드들을 등록하는 함수가, then 함수입니다.

void main() {
  Future<int> future = returnThree();
  future.then((value) {
    print(value);
  });
}

Future<int> returnThree() async {
  // 길고 복잡한 코드들...
  return 3;
}

위 코드를 보면, returnThree()함수를 호출해서, Future<int> 타입의 객체를 리턴받아 future에 저장하고,

future에 then 함수를 통해 value를 출력하는 코드를 등록한 것을 볼 수 있습니다.

이처럼 Future타입의 객체는 일반적인 DataType과 달리, 바로 사용할 수 있는 것이 아니라, 코드 블럭을 등록하고 실행하는 과정이 필요합니다.



Stream

Future의 개념을 어느 정도 이해했다면, Stream은 어렵지 않습니다.

Future가 값을 한 번만 받으면 'completed'상태가 된다면, Stream은 값을 여러 번 받은 뒤에 'completed'될 수 있는 형태입니다.

사용법도 Future와 비슷하게, forEach 등을 통해 Stream에 값이 들어오면, 실행할 코드들을 등록하는 방식으로 동작합니다.

우선, Stream을 사용하는 비동기 함수를 작성하는 방법을 알아보겠습니다.

Stream<int> returnNumbers() async* {
  for(int i = 1; i <= 100; i++) {
    yield i;
  }
}

우선 async* 키워드를 통해 이 함수가 Stream 타입의 객체를 반환하는 함수임을 표시합니다.

async* 표시된 함수 안에서 yield 키워드를 통해 Stream 객체에 값을 집어넣을 수 있습니다.

main()함수에서 받은 값을 사용하는 방식은 Future와 달리 forEach를 사용합니다.

void main() {
  Stream<int> stream = returnNumbers();
  stream.forEach((value) {
    print(value);
  });
}

Stream<int> returnNumbers() async* {
  for(int i = 1; i <= 100; i++) {
    yield i;
  }
}



추가

사실, FutureStream의 사용법은 이 글에서 설명한 내용 외에 훨씬 더 많은 내용이 있습니다.

예로, 이 글에서는 FutureStream에 값이 들어왔을 때 처리하는 thenforEach를 활용한 코드들만 예시로 보여드렸지만,

예를 들어 Stream의 경우, 들어온 값을 다른 타입으로 바꾸는 map과 같은 다양한 함수들이 매우 많습니다.

그러한 연산자들을 하나씩 열거하며 설명하기엔 내용이 너무 많아서, 이 글에 다 담지는 못했지만, 연산자는 공식 문서나 인터넷에서 하나씩 살펴보는 것이 더 좋은 공부가 될 것으로 생각합니다.

Future, async, await 가이드 문서

Stream 가이드 문서

Future 클래스 API

Stream 클래스 API


추가로, 저는 이 부분을 공부하면서 Rx 패러다임과 굉장히 유사하다고 느꼈습니다.

Future은 Rx의 Single에 대응되고, Stream은 Rx의 Observable과 대응되는 개념이라고 생각합니다.

현재 많은 언어에서 Rx Extension이 개발되고, 많은 기업에서 사용하고 있는 만큼, 이 부분에 관한 공부도 함께 하면 좋을 것 같습니다.

ReactiveX 공식 홈페이지