Dart 언어 심화

Dart 언어에 대한 심화된 내용을 정리해봤습니다.읽는데 10분 정도 걸려요.

기초적인 지식이 필요하신 분들은 Dart 언어 기초 포스트를 읽어보시길 권장드립니다.

async / await

async, await 키워드는 비동기 프로그래밍시 빠지지 않는 키워드입니다.
여기서 .then() 메서드나 Dart 에서 Future<T> 도 포함해서 원리와 적절한 사용법까지 알아보고자 합니다.

비동기 프로그래밍
특정 코드가 완료되기 전, 다른 코드를 수행함으로서 코드의 실행 순서가 순차적(동기적)으로 실행되지 않는 프로그래밍 방식입니다.
보통 처리완료까지 시간이 많이 걸리는 네트워크 통신이나 파일 입출력 등에 많이 사용됩니다.

비동기 처리는 단일 스레드(Dart의 경우 단일 Isolate) 환경에서 이루어집니다.
따라서 비동기 처리를 한다고 해도, 비동기 함수가 컴퓨팅 자원 사용량이 많다면 해당 스레드(Isolate)는 일시 정지되는 현상이 발생할 수 있습니다.


이와 관련한 자세한 내용은 아래 Isolate 파트에서 다루겠습니다.

Future

Future 객체는 지금 당장은 처리되지 않았지만, 미래에 처리가 완료될 데이터를 알려주는 객체입니다.
예를 들어봅시다.

.dart
Future<String> futureStr() async {
  print('do something');
  return Future.delayed(Duration(seconds: 2), () => 'future string');
}

void main() {
  print('start');
  print(futureStr());
}

위의 futureStr() 함수는 호출 후 2초뒤에 'future string'을 반환하는 함수입니다.
실행결과는 어떻게 될까요?

start
do something
Instance of 'Future<String>'
(2초 후 종료)

답은 future 객체 입니다.
어찌보면 당연합니다. main 함수는 비동기 함수도 아닐 뿐더러 futureStr의 리턴값은 Future<String> 이라고 명시해두었으니 말이죠.

230917-211924

future는 Uncompleted, Completed 상태를 갖습니다.
비동기 함수 호출시에 future은 Uncompleted 상태를 갖습니다.
따라서 Instance of 'Future<String>'와 같은 결과를 출력한 것이죠.
하지만 함수가 성공적으로 종료되면 future은 비로소 Completed 상태를 갖습니다.
따라서 future string과 같은 결과를 출력하게 될 것입니다.

사용자 입장에서는 Future 객체는 중요하지 않습니다. 중요한 것은 Future 객체가 처리되고 나서 반환되는 데이터인거죠.
따라서, Future 객체의 처리(비동기 함수)를 원활히 하기위해 async, await 키워드를 사용하는 것입니다.

async

async 키워드는 이 함수가 비동기 처리를 수행하는 함수임을 명시하는 키워드 입니다.
다만 위의 예시에서 보듯, 반드시 Future<T> 객체 또는 void를 반환해야만 합니다.

그렇다면, main 함수를 async 키워드를 붙여 실행한다면 제대로된 결과가 나올까요?

.dart
Future<String> futureStr() async {
  print('do something');
  return Future.delayed(Duration(seconds: 2), () => 'future string');
}

void main() async {
  print('start');
  print(futureStr());
}

start
do something
Instance of 'Future<String>'
(2초 후 종료)

그렇습니다. 함수를 비동기 처리하는 함수로 선언한다고 해서 해결되지는 않습니다.
함수 내부에서 비동기 처리가 진행될 수 있다는 것을 암시할 뿐, 어디서 비동기 처리를 해야하는 지는 명시하지 않았기 때문이죠.

await

await 키워드는 해당 키워드가 명시된 비동기 처리(futureStr)가 완료될 때 까지 비동기 함수(main)의 처리를 멈추겠다는 의미입니다.
그렇기에 await 키워드는 반드시 async 키워드가 명시된 함수 내부에서만 사용할 수 있는 것입니다.

그렇다면 futureStr의 함수 앞에 await를 명시하여 비동기 함수가 끝날 때 까지 대기시킨다면 어떨까요?

.dart
Future<String> futureStr() async {
  print('do something');
  // return Future.delayed(Duration(seconds: 2), () => 'future string');
  await Future.delayed(Duration(seconds: 2));
  return 'future string';
}


void main() async {
  print('start');
  print(await futureStr());
}

start
do something
(2초 대기)
future string
(즉시 종료)

비로소 원하던 결과가 나왔습니다.


Isolate

Isolate는 Dart에서 스레드를 부르는 용어라고 생각하시면 됩니다.

230917-221122

Isolate는 스레드와 마찬가지로 멀티 코어 CPU의 장점을 살리는 프로그래밍 기법으로 별도의 이벤트 루프를 갖는 실행 흐름을 만들어 병렬적인 처리를 가능케 합니다.
하지만, 스레드와 용어에서의 차이점을 두는 이유가 있습니다.

보통 스레드라 하면 메모리 영역을 공유한다고 배웠습니다. 하지만 그 때문에 race-condition이 발생하는 문제점을 고려하여 프로그래밍을 했어야 합니다.

하지만, Isolate는 스레드와 다르게 메모리 영역 또한 공유하지 않습니다.(물론 코드 영역은 공유합니다)
그렇기에 mutex, lock 등을 고려할 필요가 없는 편의성이 있습니다.

하지만 왜 Isolate를 알아야 하고, 사용해야만 할까요?

Isolate vs Async

아래의 영상을 확인해봅시다.

Main isolate부분은 json 데이터 파싱과 화면 빌드를 하나의 isolate에서 구동한 결과입니다.
영상에서 보시는 바와 같듯 십몇만줄의 json 파싱은 단일 isolate 환경에서 구동하기에는 다소 무리가 있습니다.
아무리 async로 실행 결과를 뒤로 미룬다 할지라도 실제 데이터 파싱하는 동작과 화면을 그리는 동작 모두 동일한 isolate에서 처리되기 때문에 성능상 이슈가 생길 수 밖에 없습니다.

하지만 Worker isolate부분은 json 데이터 파싱을 별도의 worker isolate에서 구동한 결과입니다.
차이는 명확합니다. 화면에 그리는 동작과 파싱 동작이 별도의 isolate에서 처리되기 때문에 화면을 그리는 부분에서 랙이 걸리지 않습니다.

이렇듯 단일 isolate에서 동작이 버벅일 정도로 무거운 기능을 수행해야 한다면, 해당 기능은 별도의 isolate로 빼서 처리하는 것이 사용자에게 더 나은 경험을 제공하게 됩니다.

해당 영상에서 구동하는 코드는 Arkhive repo에서 확인하실 수 있습니다.


function

callback function

콜백 함수는 함수의 인자로 넘겨주어 넘겨받은 함수 내부에서 실행 가능한 함수를 의미합니다.
예시를 살펴봅시다.

.dart
void main() {
  int value = 0;

  print(valueModifire(value: value, modifire: add1)); // 1
  print(valueModifire(value: value, modifire: sub1)); // -1
}

int add1(int value) => value + 1;
int sub1(int value) => value - 1;

int valueModifire({
  required int value,
  required Function(int) modifire,
}) {
  return modifire(value);
}

위 코드에서는 valueModifire의 인자로서 add1, sub1을 전달했습니다.
이 때, 이 두 함수를 callback function라고 부르고, 이 함수는 valueModifire 내부에서 modifire의 형태로 호출됩니다.

보통은 이런식으로 동일한 함수 내부에서 서로 다른 기능을 수행해야 할 때 콜백 함수로서 전달하는 경우가 많습니다.

.dart
void main() async {
  int value = 0;

  print(await valueModifire(value: value, modifire: add1)); // await 1 second -> 1
  print(await valueModifire(value: value, modifire: sub1)); // await 1 second -> -1
}

int add1(int value) => value + 1;
int sub1(int value) => value - 1;

Future<int> valueModifire({
  required int value,
  required Function(int) modifire,
}) async {
  await Future.delayed(Duration(seconds: 1)); // something actions
  return modifire(value);
}

또는, 특정 함수의 동작이 끝난 후 수행해야 할 기능이 있을 때 많이 사용되곤 합니다.