플러터(Flutter)는 다트(Dart) 언어를 사용합니다. 대부분의 플러터 입문자는 별도의 책으로 다트를 공부하지 않고 플러터 서적에서 1개 장 분량으로 얕게 배웁니다. 플러터로 앱을 원활히 개발하려면 다트를 탄탄하게 아는 것이 중요합니다. 그래서 이 책은 또 다른 자료를 찾아보지 않아도 될 정도로 깊이 있게 다트를 다룹니다.
1장에서 다트 입문하기, 2장에서 객체지향 프로그래밍, 3장에서 비동기 프로그래밍, 4장은 다트 3.0 신규 문법을 학습합니다.
다트 비동기 프로그래밍 ❷
다트 언어는 동기/비동기 프로그래밍을 지원합니다. 동기는 요청하고 나서 응답이 올 때까지 더는 코드를 진행하지 못하고 기다렸다가 응답을 받으면 그제서야 다음 코드를 진행합니다. 반면에 비동기는 요청하고 나서 응답을 받지 않았는데도 대기하지 않고 다음 코드를 진행합니다. 언제든 응답이 오면 즉시 응답을 처리하게 됩니다. 다트를 사용해 동기/비동기 프로그램을 하는 방법을 알아봅니다.
1. 동기 vs. 비동기 프로그래밍
지금까지 다트 언어를 배우면서 작성한 코드는 모두 동기 방식을 사용했습니다. 함수를 실행하면 다음 코드가 실행되기 전에 해당 함수의 결괏값이 먼저 반환됩니다. 하지만 비동기 프로그래밍은 요청한 결과를 기다리지 않으며 응답 순서 또한 요청한 순서와 다를 수 있습니다. 그렇기 때문에 컴퓨터 자원을 낭비하지 않고 더욱 효율적으로 코드를 실행할 수 있습니다. 예를 들어 데이터베이스에서 게시판 글을 가져오는 작업이나, 복잡한 미적분 계산이나 이미지 인코딩 등 시간이 걸리는 작업을 동기로 실행하면 앱이 매우 느려질 수 있습니다. 그렇기 때문에 이런 작업은 비동기로 처리해야 합니다. 다음 그림은 동기 방식과 비동기 방식에서 요청과 응답을 보여줍니다.
▼ 동기 vs. 비동기
2. Future
Future 클래스는 ‘미래’라는 단어의 의미대로 미래에 받아올 값을 뜻합니다. List나 Set처럼 제네릭으로 어떤 미래의 값을 받아올지를 정할 수 있습니다.
Future<String> name; // 미래에 받을 String값
Future<int> number; // 미래에 받을 int값
Future<bool> isOpened; // 미래에 받을 boolean값
비동기 프로그래밍은 서버 요청과 같이 오래 걸리는 작업을 기다린 후 값을 받아와야 하기 때문에 미래값을 표현하는 Future 클래스가 필요합니다. 특정 기간 동안 아무것도 하지 않고 기다리는 Future.delayed( )를 사용해서 실습하겠습니다.
void main() {
addNumbers(1, 1);
}
void addNumbers(int number1, int number2){
print('$number1 + $number2 계산 시작!');
// ➊ Future.delayed()를 사용하면 일정 시간 후에 콜백 함수를 실행할 수 있음
Future.delayed(Duration(seconds: 3), (){
print('$number1 + $number2 = ${number1 + number2}');
});
print('$number1 + $number2 코드 실행 끝');
}
▼ 실행 결과
❶ 첫 번째 매개변수에 대기할 기간을 입력하고 두 번째 매개변수에 대기 후 실행할 콜백 함수를 입력하면 됩니다. addNumbers( ) 함수는 print( ) 함수를 실행하고 Future.delayed( )를 통해 3초간 대기합니다. 그다음 마지막 print( ) 함수를 실행하고 함수를 마칩니다. ❷, ❹, ❸ 순서대로 출력이 이뤄져야 한다고 생각할 수 있습니다. 하지만 Future.delayed( )는 비동기 연산이기 때문에 CPU가 3초간 대기해야 한다는 메시지를 받으면 리소스를 허비하지 않고 다음 코드를 바로 실행합니다. 그래서 작성한 코드의 순서와 다르게 ❷, ❸, ❹ 순서대로 값이 출력됩니다. 결과적으로 CPU가 아무것도 하지 않으며 낭비할 뻔한 시간(여기서는 3초) 동안 다른 작업을 할 수 있어 더 효율적으로 CPU 리소스를 사용했습니다.
3. async와 await
Future 사용법은 배웠습니다. 그런데 코드가 작성된 순서대로 실행되지 않는다면 개발자 입장에서 헷갈릴 수 있습니다. 이때 async와 await 키워드를 사용하면 비동기 프로그래밍을 유지하면서도 코드 가독성을 유지할 수 있습니다.
void main() {
addNumbers(1, 1);
}
// async 키워드는 함수 매개변수 정의와 바디 사이에 입력합니다.
void addNumbers(int number1, int number2) async {
print('$number1 + $number2 계산 시작!');
// await는 대기하고 싶은 비동기 함수 앞에 입력합니다.
await Future.delayed(Duration(seconds: 3), (){
print('$number1 + $number2 = ${number1 + number2}');
});
print('$number1 + $number2 코드 실행 끝');
}
▼ 실행 결과
예제와 같이 함수를 async로 지정해주고 나서 대기하고 싶은 비동기 함수를 실행할 때 await 키워드를 사용하면 코드는 작성한 순서대로 실행됩니다. 이렇게 되면 비동기 프로그래밍이 아니라 동기 프로그래밍이 아니냐고 생각할 수 있습니다. 합리적인 의심이지만 async와 await 키워드를 사용하면 비동기 프로그래밍 특징을 그대로 유지하며 코드가 작성된 순서대로 프로그램을 실행합니다. addNumbers( ) 함수를 두 번 실행하는 다음 예제를 보면 쉽게 이해할 수 있습니다.
void main() {
addNumbers(1, 1);
addNumbers(2, 2);
}
// async 키워드는 함수 매개변수 정의와 바디 사이에 입력합니다.
void addNumbers(int number1, int number2) async {
print('$number1 + $number2 계산 시작!');
// await는 대기하고 싶은 비동기 함수 앞에 입력합니다.
await Future.delayed(Duration(seconds: 3), (){
print('$number1 + $number2 = ${number1 + number2}');
});
print('$number1 + $number2 코드 실행 끝');
}
▼ 실행 결과
상당히 혼란스러운 출력 결과일 수 있습니다. 하지만 지금까지 배운 지식을 복습하면 쉽게 이해할 수 있습니다. addNumbers( ) 함수는 두 번 실행되었습니다. 그러니 출력 결과를 함수별로 나눠서 보면 각 addNumbers( ) 함수의 실행 결과가 예상한 코드 순서대로 시작되었습니다. 그런데 addNumbers(1, 1)가 끝나기 전에 addNumbers(2, 2)가 실행되었습니다. 그 이유는 addNumbers( ) 함수가 비동기 프로그래밍으로 실행되었기 때문입니다. addNumbers(1, 1)의 Future.delayed( )가 실행되며 3초를 기다려야 할 때 CPU의 리소스가 낭비되지 않고 바로 다음 실행할 코드인 addNumbers(2, 2)를 실행한 겁니다.
이로써 addNumbers( ) 함수는 비동기로 실행된다는 걸 증명했습니다. 만약 addNumbers(1, 1)과 addNumbers(2, 2)가 순차적으로 실행되길 원한다면 다음과 같이 async와 await 키워드를 추가해주면 됩니다.
void main() async {
await addNumbers(1, 1);
await addNumbers(2, 2);
}
// async 키워드는 함수 매개변수 정의와 바디 사이에 입력합니다.
Future<void> addNumbers(int number1, int number2) async {
print('$number1 + $number2 계산 시작!');
// await는 대기하고 싶은 비동기 함수 앞에 입력합니다.
await Future.delayed(Duration(seconds: 3), (){
print('$number1 + $number2 = ${number1 + number2}');
});
print('$number1 + $number2 코드 실행 끝');
}
▼ 실행 결과
main( ) 함수에 async 키워드를 적용하고 addNumbers(1,1)과 addNumbers(2, 2)에 await 키워드를 적용했기 때문에 코드는 작성한 순서대로 실행되었습니다.
3.1 결괏값 반환받기
async와 await 키워드를 사용한 함수에서도 결괏값을 받아낼 수 있습니다. 이때 앞서 배운 Future 클래스를 사용합니다. 입력된 두 숫자를 더한 결괏값을 반환하는 addNumbers( ) 함수의 코드를 수정해 자세히 알아보겠습니다.
void main() async {
final result = await addNumbers(1, 1);
print('결괏값 $result'); // 일반 함수와 동일하게 반환값을 받을 수 있음
final result2 = await addNumbers(2, 2);
print('결괏값 $result2');
}
Future<int> addNumbers(int number1, int number2) async {
print('$number1 + $number2 계산 시작!');
await Future.delayed(Duration(seconds: 3), (){
print('$number1 + $number2 = ${number1 + number2}');
});
print('$number1 + $number2 코드 실행 끝');
return number1 + number2;
}
위와 같이 await 키워드를 적용해도 일반 함수처럼 변수에 반환값을 저장하고 활용할 수 있습니다.
4. Stream
Future는 반환값을 딱 한 번 받아내는 비동기 프로그래밍에 사용합니다. 지속적으로 값을 반환 받을 때는 Stream을 사용합니다. Stream은 한 번 리슨listen하면 Stream에 주입되는 모든 값들을 지속적으로 받아옵니다.
▼ Future vs. Stream 선택하기
❶ Future.wait( ) 함수는 하나의 Future로 구성된 리스트를 매개변수로 입력받습니다. Future.wait( )에 입력된 비동기 함수들은 모두 동시에 실행되며 응답값 요청을 보낸 순서대로 저장해둡니다(호출한 순서대로 응답값을 받지는 않습니다).
4.1 스트림 기본 사용법
스트림(Stream)을 사용하려면 플러터에서 기본으로 제공하는 dart:async 패키지를 불러와야 합니다. 그다음 dart:async 패키지에서 제공하는 StreamController를 listen( )해야 값을 지속적으로 반환받을 수 있습니다.
import 'dart:async';
void main() {
final controller = StreamController(); // StreamController 선언
final stream = controller.stream; // Stream 가져오기
// Stream에 listen() 함수를 실행하면 값이 주입될 때마다 콜백 함수를 실행할 수 있습니다.
final streamListener1 = stream.listen((val) {
print(val);
});
// Stream에 값을 주입할 때는 sink.add() 함수를 실행하면 됩니다.
controller.sink.add(1);
controller.sink.add(2);
controller.sink.add(3);
controller.sink.add(4);
}
▼ 실행 결과
4.2 브로드캐스트 스트림
스트림은 단 한 번만 listen( )을 실행할 수 있습니다. 하지만 때때로 하나의 스트림을 생성하고 여러 번 listen( ) 함수를 실행하고 싶을 때가 있습니다. 이럴 때 브로드캐스트 스트림(Broadcast Stream)을 사용하면 스트림을 여러 번 listen( )하도록 변환할 수 있습니다.
import 'dart:async';
void main() {
final controller = StreamController();
// 여러 번 리슨할 수 있는 Broadcaste Stream 객체 생성
final stream = controller.stream.asBroadcastStream();
// 첫 listen() 함수
final streamListener1 = stream.listen((val) {
print('listening 1');
print(val);
});
// 두 번째 listen() 함수
final streamListener2 = stream.listen((val) {
print('listening 2');
print(val);
});
// add()를 실행할 때마다 listen()하는 모든 콜백 함수에 값이 주입됩니다.
controller.sink.add(1);
controller.sink.add(2);
controller.sink.add(3);
}
▼ 실행 결과
4.3 함수로 스트림 반환하기
StreamController를 직접 사용하지 않고도 직접 스트림을 반환하는 함수를 작성할 수도 있습니다. Future를 반환하는 함수는 async로 함수를 선언하고 return 키워드로 값을 반환하면 됩니다. 스트림을 반환하는 함수는 async*로 함수를 선언하고 yield 키워드로 값을 반환해주면 됩니다.
import 'dart:async';
// Stream을 반환하는 함수는 async*로 선언합니다.
Stream<String> calculate(int number) async* {
for (int i = 0; i < 5; i++) {
// StreamController의 add()처럼 yield 키워드를 이용해서 값 반환
yield 'i = $i';
await Future.delayed(Duration(seconds: 1));
}
}
void playStream() {
// StreamController와 마찬가지로 listen() 함수로 콜백 함수 입력
calculate(1).listen((val) {
print(val);
});
}
void main() {
playStream();
}
▼ 실행 결과
학습 마무리
이번 장에서는 비동기 프로그래밍을 배웠습니다. 함수를 한 번 실행할 때마다 값을 한 번 반환하는 Future와 지속적으로 값을 받아볼 수 있는 Stream을 모두 알아봤습니다. 비동기 프로그래밍을 사용하면 CPU를 비효율적으로 사용하는 순간을 피할 수 있습니다. 그렇기 때문에 다트 언어는 기본적으로 비동기 프로그래밍을 사용하니 꼭 이번 장을 반복 숙달 후 다음으로 넘어가길 바랍니다.
핵심 요약
- 비동기 프로그래밍을 이용하면 오랜 기간 CPU의 리소스가 막히는 상황을 방지할 수 있습 니다.
- async 키워드를 사용하면 비동기 함수를 정의할 수 있습니다.
- await 키워드를 사용하면 비동기 함수를 논리적 순서대로 실행할 수 있습니다.
- Future는 비동기 응답을 한 번만 받을 때 사용하는 클래스입니다.
- Stream은 지속적으로 리슨하여 비동기 응답을 받을 때 사용하는 클래스입니다.
여기까지 3장 비동기 프로그래밍였습니다. 다트 언어 마스터하기는 4장은 다트 3.0 신규 문법으로 이어집니다.
다트 입문하기
- 1편 보기: https://bit.ly/47NAtjC
- 2편 보기: https://bit.ly/3Sfl8SX
- 3편 보기: https://bitly.ws/3biHw
객체지향 프로그래밍
- 1편 보기: https://bitly.ws/3cmYi
- 2편 보기: https://bitly.ws/3ctA9
비동기 프로그래밍
- 더 보기: https://bit.ly/49bgMDJ
다트 3.0 신규 문법
- 더 보기: https://bit.ly/3unqMuB
최지호(코드팩토리)
임페리얼 칼리지 런던을 졸업하고 계리 컨설팅 회사 밀리만(Milliman) 한국 지사에서 소프트웨어 엔지니어로 일했습니다. 현재 주식회사 코드팩토리를 창업하여 개발을 하면서 초보자뿐만 아니라 현직 개발자에게도 유용한 개발 강의를 제작합니다. 밀리의서재 플러터 전환 차세대 프로젝트를 리드했습니다.