iBetter Books
수정

코드가 읽히지 않는 문제

앞 챕터에서 배운 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 결과 기다리기

awaitasync 함수 안에서만 사용할 수 있습니다. 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() 체이닝으로 작성한 loadDashboardasync/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을 배웁니다.

Ch 03. async와 await — 비동기를 동기처럼 — 소설처럼 읽는 Dart | iBetter Books