코드가 읽히지 않는 문제
앞 챕터에서 배운 then() 체이닝은 분명히 강력합니다. 하지만 실제 코드에서 여러 비동기 작업을 순서대로 실행해야 할 때, 체이닝이 깊어질수록 코드가 복잡해집니다.
예를 들어, 로그인 후 사용자 정보를 가져오고, 사용자 정보로 권한을 확인하고, 권한에 따라 화면을 표시하는 작업을 생각해보겠습니다.
// then 체이닝 방식
void loadDashboard(String username, String password) {
login(username, password)
.then((token) {
return fetchUserInfo(token);
})
.then((user) {
return checkPermissions(user.id);
})
.then((permissions) {
return loadDashboardData(permissions);
})
.then((data) {
displayDashboard(data);
})
.catchError((error) {
showError(error);
});
}
논리는 맞지만 코드 모양이 오른쪽으로 점점 들어가는 "콜백 지옥(Callback Hell)"과 비슷한 느낌을 줍니다. 어디서 어떤 데이터가 오는지 한눈에 파악하기 어렵습니다.
async/await가 이 문제를 해결합니다.
async 함수 선언
함수 앞에 async 키워드를 붙이면 그 함수는 비동기 함수가 됩니다. 비동기 함수 안에서는 await를 사용할 수 있습니다.
// 일반 함수
String greet(String name) {
return '안녕하세요, $name!';
}
// 비동기 함수
Future<String> greetAsync(String name) async {
return '안녕하세요, $name!'; // String을 반환해도 자동으로 Future<String>이 됨
}
async 함수는 반환 타입이 자동으로 Future로 감싸집니다. return '안녕하세요';라고 써도 실제로는 Future<String>이 반환됩니다.
반환값이 없는 비동기 함수의 반환 타입은 Future<void>입니다.
Future<void> saveData(String data) async {
// 데이터 저장 작업
await Future.delayed(Duration(seconds: 1));
print('저장 완료: $data');
}
await로 Future 결과 기다리기
await는 async 함수 안에서만 사용할 수 있습니다. Future 앞에 await를 붙이면 그 Future가 완료될 때까지 기다렸다가 결과값을 반환합니다.
Future<String> fetchUserName(int id) async {
await Future.delayed(Duration(seconds: 1));
return '홍길동_$id';
}
Future<void> printUserName() async {
print('이름 조회 중...');
String name = await fetchUserName(42); // Future가 완료될 때까지 대기
print('이름: $name'); // Future가 완료된 후 실행
}
void main() async {
await printUserName();
}
await fetchUserName(42)는 "fetchUserName(42)가 완료될 때까지 기다려줘. 완료되면 그 결과를 name에 담아줘"라는 뜻입니다. 이렇게 하면 동기 코드를 읽는 것처럼 자연스럽게 위에서 아래로 코드를 읽을 수 있습니다.
then 체이닝 vs async/await 비교
앞서 then() 체이닝으로 작성한 loadDashboard를 async/await로 다시 작성해보겠습니다.
// async/await 방식
Future<void> loadDashboard(String username, String password) async {
try {
var token = await login(username, password);
var user = await fetchUserInfo(token);
var permissions = await checkPermissions(user.id);
var data = await loadDashboardData(permissions);
displayDashboard(data);
} catch (error) {
showError(error);
}
}
같은 로직인데 훨씬 읽기 쉬워졌습니다. 각 줄이 순서대로 실행되는 것처럼 보이며, 에러 처리도 익숙한 try-catch로 할 수 있습니다.
두 방식을 표로 비교하면 다음과 같습니다.
| 비교 항목 | then 체이닝 | async/await |
|---|---|---|
| 가독성 | 체인이 길면 복잡 | 동기 코드처럼 읽힘 |
| 에러 처리 | catchError() | try-catch |
| 디버깅 | 스택 추적 어려움 | 스택 추적 쉬움 |
| 권장 상황 | 단순한 변환 | 복잡한 순차 작업 |
순차 실행과 병렬 실행 패턴
await는 편리하지만 주의가 필요합니다. await를 연속으로 사용하면 작업이 순서대로 실행됩니다.
Future<void> sequentialExample() async {
var stopwatch = Stopwatch()..start();
// 순차 실행: 2 + 3 = 5초
var profile = await fetchProfile(); // 2초 기다림
var posts = await fetchPosts(); // 3초 기다림
print('소요: ${stopwatch.elapsed.inSeconds}초'); // 5초
}
두 작업이 서로 의존하지 않는다면 굳이 순서대로 기다릴 필요가 없습니다. 동시에 시작하고 함께 기다리면 됩니다.
Future<void> parallelExample() async {
var stopwatch = Stopwatch()..start();
// Future를 먼저 시작 (await 없이)
var profileFuture = fetchProfile(); // 즉시 시작
var postsFuture = fetchPosts(); // 즉시 시작
// 그다음 결과를 기다림
var profile = await profileFuture; // 이미 진행 중인 Future 대기
var posts = await postsFuture; // 이미 진행 중인 Future 대기
print('소요: ${stopwatch.elapsed.inSeconds}초'); // 3초
}
또는 Future.wait()를 사용하면 더 간결합니다.
Future<void> parallelWithWait() async {
var stopwatch = Stopwatch()..start();
var [profile, posts] = await Future.wait([
fetchProfile(),
fetchPosts(),
]);
print('소요: ${stopwatch.elapsed.inSeconds}초'); // 3초
print('$profile / $posts');
}
핵심 원칙: 다음 작업이 이전 작업의 결과를 필요로 한다면 순차 실행, 서로 독립적이라면 병렬 실행을 선택합니다.
async 함수의 반환 타입
async 함수는 항상 Future를 반환합니다. 반환 타입 규칙을 정리하겠습니다.
// String을 반환하면 → Future<String>
Future<String> getName() async {
return '홍길동';
}
// void를 반환하면 → Future<void>
Future<void> doSomething() async {
print('작업 중');
}
// 반환값 없으면 → Future<void> (동일)
Future<void> doNothing() async {
// 아무것도 반환하지 않음
}
// Future<String>을 반환해도 → Future<String> (중복 감싸지 않음)
Future<String> getNameWrapped() async {
return Future.value('홍길동'); // Future<String>이지만 자동으로 unwrap
}
async 함수 안에서 Future<String>을 return하면 Future<Future<String>>이 되는 게 아닙니다. Dart가 자동으로 unwrap해서 Future<String>이 됩니다.
main 함수도 async가 될 수 있다
main() 함수도 async로 선언할 수 있습니다. 프로그램 진입점에서 바로 비동기 코드를 실행할 수 있습니다.
void main() async {
print('프로그램 시작');
var result = await fetchData();
print('데이터: $result');
print('프로그램 종료');
}
Future<String> fetchData() async {
await Future.delayed(Duration(seconds: 1));
return '서버 데이터';
}
main()이 async이면 반환 타입은 Future<void>가 됩니다. Dart 런타임이 main()의 Future가 완료될 때까지 프로그램을 종료하지 않습니다.
실전 예제: 날씨 앱 API 호출
실제 앱에서 볼 수 있는 패턴으로 정리해보겠습니다.
// 데이터 모델
class WeatherData {
final String city;
final double temperature;
final String condition;
WeatherData({
required this.city,
required this.temperature,
required this.condition,
});
@override
String toString() => '$city: $temperature°C, $condition';
}
// 서비스 함수들
Future<String> getApiToken() async {
await Future.delayed(Duration(milliseconds: 300));
return 'mock_api_token_xyz';
}
Future<WeatherData> fetchWeather(String token, String city) async {
await Future.delayed(Duration(milliseconds: 500));
// 실제로는 HTTP 요청
return WeatherData(
city: city,
temperature: 22.5,
condition: '맑음',
);
}
Future<List<WeatherData>> fetchMultipleCities(
String token,
List<String> cities,
) async {
// 여러 도시를 동시에 조회
return Future.wait(
cities.map((city) => fetchWeather(token, city)).toList(),
);
}
// 메인 로직
Future<void> showWeatherReport(List<String> cities) async {
print('날씨 정보 조회 시작...\n');
try {
// 토큰 먼저 얻기 (순차 - 토큰이 있어야 다음 단계 가능)
var token = await getApiToken();
print('인증 완료');
// 여러 도시 동시 조회 (병렬 - 서로 독립적)
var weatherList = await fetchMultipleCities(token, cities);
print('\n=== 날씨 보고서 ===');
for (var weather in weatherList) {
print(weather);
}
} catch (e) {
print('날씨 정보 조회 실패: $e');
}
}
void main() async {
await showWeatherReport(['서울', '부산', '제주', '대구']);
}
실행 결과입니다.
날씨 정보 조회 시작...
인증 완료
=== 날씨 보고서 ===
서울: 22.5°C, 맑음
부산: 22.5°C, 맑음
제주: 22.5°C, 맑음
대구: 22.5°C, 맑음
토큰 발급은 순차로, 여러 도시 조회는 병렬로 처리하는 패턴이 실전에서 가장 많이 쓰이는 형태입니다.
정리
async/await는 비동기 코드를 동기 코드처럼 읽기 좋게 만들어줍니다.
async키워드로 함수를 비동기 함수로 선언합니다.await로 Future가 완료될 때까지 기다리고 결과를 받습니다.async함수의 반환 타입은 항상Future입니다.- 독립적인 작업은
Future.wait()로 병렬 실행합니다. - 에러 처리는 익숙한
try-catch를 사용합니다.
이제 단일 값을 반환하는 Future는 잘 다룰 수 있게 되었습니다. 하지만 실시간으로 데이터가 계속 흘러오는 상황, 예를 들어 채팅 메시지, 주가 변동, 센서 데이터 같은 상황은 어떻게 처리할까요.
다음 챕터에서는 시간에 따라 여러 값을 연속으로 내보내는 Stream을 배웁니다.