비동기 프로그래밍의 배경
Dart에서 비동기 프로그래밍은 시간이 오래 걸리는 작업, 특히 I/O 작업을 수행할 때 애플리케이션의 성능을 최적화하는 중요한 기법이다. 비동기 처리를 통해 메인 쓰레드가 차단되지 않고 계속 실행되도록 보장할 수 있다. 이를 위해 Dart에서는 async
와 await
라는 두 가지 키워드를 제공한다. 이 키워드들은 비동기 코드를 동기식 코드처럼 간단하게 작성할 수 있도록 도와준다.
async 키워드의 역할
async
키워드는 함수 앞에 붙여서 해당 함수가 비동기 함수임을 선언하는 데 사용된다. 일반적으로 함수는 값을 반환하지만, async
키워드가 붙으면 해당 함수는 Future
를 반환하게 된다. Future
는 나중에 완료될 값의 약속을 나타낸다. async
키워드를 사용하면, 함수 내에서 비동기 작업을 포함한 여러 작업을 순차적으로 처리할 수 있다.
다음은 async
키워드를 사용한 간단한 예제이다:
Future<void> fetchData() async {
print('데이터 가져오는 중...');
await Future.delayed(Duration(seconds: 2)); // 2초 후에 완료
print('데이터 가져옴');
}
이 코드에서 fetchData
함수는 async
키워드로 선언되었으며, Future.delayed
로 2초 동안의 지연을 시뮬레이션한다. 함수는 await
를 사용하여 비동기 작업이 완료될 때까지 기다린다. 이를 통해 await
키워드는 비동기 함수 내부에서 동기식 코드처럼 작성되지만, 실제로는 비동기 작업을 처리한다.
await 키워드의 역할
await
는 async
함수 내에서 사용되는 키워드로, 비동기 작업이 완료될 때까지 기다린다. 일반적으로 Future
객체를 반환하는 함수 앞에 await
를 붙여서 그 함수가 반환하는 Future
의 완료를 기다린다. 이때, Dart는 다른 작업을 계속 수행하다가 해당 비동기 작업이 완료되면 이후의 코드를 실행하게 된다.
다음 예제를 보자:
Future<void> processData() async {
print('데이터 처리 시작');
var result = await fetchData();
print('처리된 데이터: $result');
}
이 코드에서 processData
함수는 await
를 사용하여 fetchData
함수의 완료를 기다리고, 완료되면 그 결과를 사용하여 데이터를 처리한다. 이 방식은 마치 동기식 코드처럼 보이지만, fetchData
는 실제로 비동기적으로 처리된다.
Future의 구조와 async, await의 상호작용
비동기 함수가 반환하는 Future
는 수학적으로도 이해할 수 있다. Future는 상태가 변할 수 있는 상태 머신으로 볼 수 있으며, 시간이 지나면서 그 상태가 "대기 중"에서 "완료"로 변경된다. 이를 수식으로 표현하면 다음과 같다.
여기서 t_0는 비동기 작업의 시작 시간, t_f는 작업이 완료되는 시간이다. await
키워드는 이러한 Future
의 상태가 완료될 때까지 코드 실행을 일시적으로 멈추고 기다리는 역할을 한다.
Future와 Promise의 비교
JavaScript의 Promise
와 Dart의 Future
는 매우 유사하지만, Dart에서는 await
키워드를 통해 비동기 처리에 대한 가독성을 더욱 높일 수 있다. 다만, Future
는 그 자체로 상태와 값을 가지고 있는 객체이며, Dart는 이 객체를 기반으로 다양한 비동기 처리 작업을 수행한다.
Dart에서의 Future는 주로 다음과 같은 두 가지 상태로 분류된다:
- 완료 전: 아직 값이 반환되지 않은 상태
- 완료 후: 값이 반환된 상태
이 두 상태를 수식으로 나타내면 다음과 같다:
이때 await
키워드는 \mathbf{Future}_{i}에서 상태가 완료될 때까지 기다리는 역할을 한다.
async와 await의 에러 처리
비동기 함수에서 발생할 수 있는 중요한 부분 중 하나는 에러 처리이다. async
함수는 Future
를 반환하므로, Future
에서 발생하는 에러는 catchError
메소드나 try-catch
구문을 사용하여 처리할 수 있다. 비동기 함수 내에서 await
키워드가 사용될 때, try-catch
를 통해 예외를 처리할 수 있다.
예를 들어:
Future<void> fetchData() async {
try {
print('데이터 가져오는 중...');
await Future.delayed(Duration(seconds: 2));
throw Exception('데이터 가져오기 실패'); // 의도적으로 에러 발생
} catch (e) {
print('에러 발생: $e');
}
}
이 코드에서는 await
로 비동기 작업을 처리하는 동안, Exception
을 의도적으로 발생시켰다. 이를 try-catch
블록으로 감싸서, 발생한 예외를 처리한다. 이렇게 하면 비동기 함수 내에서 에러가 발생하더라도 애플리케이션의 흐름이 끊기지 않고 안정적으로 동작할 수 있다.
또한, 비동기 함수에서 Future
는 에러가 발생할 때 해당 에러를 전달하는 역할도 한다. 이를 수학적으로 표현하면, Future
는 성공적인 완료와 실패한 완료의 두 가지 상태로 나뉠 수 있다.
따라서 await
키워드를 사용할 때는 항상 에러 처리에 신경 써야 하며, try-catch
를 적절히 사용하여 비동기 함수에서 발생할 수 있는 예외를 처리해야 한다.
await의 병렬 처리
await
키워드를 사용할 때 종종 발생하는 문제는 모든 비동기 작업이 순차적으로 실행된다는 점이다. 이 경우 여러 비동기 작업을 병렬로 실행하고 싶을 때, await
를 나란히 사용할 경우 원하는 성능을 얻을 수 없다. 병렬 처리가 필요한 경우에는 Future.wait()
를 사용할 수 있다. 이 메소드는 여러 개의 Future
를 동시에 실행하고, 모든 작업이 완료될 때까지 기다린다.
다음 예제는 여러 비동기 작업을 병렬로 처리하는 방법을 보여준다:
Future<void> loadData() async {
var future1 = Future.delayed(Duration(seconds: 2), () => '작업 1 완료');
var future2 = Future.delayed(Duration(seconds: 3), () => '작업 2 완료');
var results = await Future.wait([future1, future2]);
print(results); // ['작업 1 완료', '작업 2 완료']
}
이 코드는 두 개의 비동기 작업을 동시에 실행하고, 두 작업이 모두 완료되면 그 결과를 리스트로 반환한다. 이 방식은 각 작업이 독립적일 때 매우 유용하며, 성능 최적화에 큰 도움을 준다.
이 원리를 수식으로 표현하면, 각 비동기 작업 \mathbf{Future}_{i}가 병렬로 처리되므로 전체 작업의 완료 시간은 가장 긴 작업의 완료 시간에 의해 결정된다.
병렬 처리를 통해 모든 작업이 빠르게 완료될 수 있으며, await
키워드를 사용하는 방식에 비해 성능이 크게 향상된다.
await와 반복문
Dart에서 await
를 반복문과 함께 사용할 수 있다. 반복문 내에서 비동기 작업을 처리할 때 각 반복이 순차적으로 실행되기 때문에, 반복문이 끝나기 전까지 다음 작업이 시작되지 않는다. 이는 때때로 성능에 영향을 줄 수 있다. 하지만 때로는 이러한 순차적인 처리가 필요할 수도 있다.
다음은 반복문에서 await
를 사용하는 간단한 예시이다:
Future<void> processTasks() async {
var tasks = [1, 2, 3, 4, 5];
for (var task in tasks) {
await Future.delayed(Duration(seconds: 1));
print('작업 $task 완료');
}
}
이 코드에서는 작업 목록을 순차적으로 처리하며, 각 작업이 완료될 때까지 1초 동안 대기한다. 이 경우, 모든 작업이 순차적으로 처리되므로 총 5초가 소요된다.
이를 수식으로 나타내면, 각 작업의 완료 시간 t_{\text{완료}}은 각 대기 시간의 합계로 나타난다.
여기서 n은 반복 횟수이고, 각 t_{\mathbf{Future}_i}는 개별 비동기 작업의 소요 시간이다.
병렬 반복 처리
비동기 작업이 독립적일 때, 반복문 내에서 순차적으로 await
를 사용하는 대신 병렬로 처리하는 것이 더 효율적이다. 이를 위해 Future.wait()
를 사용할 수 있다. 이 방법을 사용하면 모든 작업이 동시에 시작되고, 가장 늦게 완료되는 작업이 끝나면 전체 작업이 완료된다.
다음은 병렬 처리를 통해 반복문 내 비동기 작업을 최적화한 예제이다:
Future<void> processTasks() async {
var tasks = [1, 2, 3, 4, 5];
var futures = tasks.map((task) => Future.delayed(Duration(seconds: task), () => '작업 $task 완료'));
var results = await Future.wait(futures);
print(results);
}
이 코드에서는 각 작업이 병렬로 실행되며, 작업 시간이 task 값에 따라 다르다. Future.wait()
는 모든 작업이 완료될 때까지 기다린 후 그 결과를 반환한다.
수식으로 표현하면, 이 경우 각 작업의 완료 시간 t_{\text{완료}}은 가장 긴 작업의 시간에 의해 결정된다.
따라서 병렬 처리를 통해 작업을 효율적으로 완료할 수 있으며, 반복문 내에서의 순차 처리보다 성능이 크게 향상된다.
Future와 await
의 성능 고려 사항
await
는 매우 유용하지만, 비동기 작업의 순차적 실행으로 인해 성능 저하가 발생할 수 있다. 특히 반복적인 비동기 작업이 있는 경우, 모든 작업이 완료될 때까지 기다리는 시간이 문제될 수 있다. 따라서 이러한 상황에서는 병렬 처리를 적극 활용하거나, 비동기 작업의 특성에 맞게 설계를 최적화해야 한다.
예를 들어, 비동기 작업 간에 의존성이 없고 병렬 처리가 가능한 경우, 순차적으로 await
를 사용하는 대신 Future.wait()
를 사용하는 것이 성능적으로 더 유리하다. 반면, 작업 간에 의존성이 있어 순차 처리가 필요한 경우, await
를 반복문 내에서 사용하는 것이 적절하다.
수식을 통해 비동기 작업의 총 소요 시간을 다시 정리하면, 순차 처리의 경우:
병렬 처리의 경우:
각 비동기 작업의 특성을 고려하여 적절한 방식을 선택하는 것이 중요하다.