비동기 프로그래밍: futures, async, await

비동기 프로그래밍: futures, async, await

futures와 async·await 키워드를 이용해 비동기 코드를 작성하는 법을 배워볼게요. 이 튜토리얼의 예제는 임베디드 DartPad 에디터로 제공되니 바로 실행하면서 익힐 수 있어요.

출처: Asynchronous programming: futures, async, await

본문

이 튜토리얼은 futures와 async, await 키워드를 사용해 비동기 코드를 작성하는 방법을 가르쳐 줘요. 임베디드 DartPad 에디터를 통해 예제 코드를 돌려 보고 연습문제를 풀면서 배운 내용을 확인해 볼 수 있어요.

이 튜토리얼을 제대로 이해하려면 다음 준비물이 필요해요:

이 튜토리얼이 다루는 내용이에요:

  • async와 await 키워드를 언제, 어떻게 쓰는지.
  • async와 await가 실행 순서에 어떤 영향을 주는지.
  • async 함수에서 try-catch 표현식으로 비동기 호출의 오류를 어떻게 처리하는지.

예상 소요 시간: 40~60분.

참고 이 페이지는 예제와 연습문제를 보여 주기 위해 임베디드 DartPad를 사용해요. DartPad 대신 빈 상자가 보인다면 DartPad 문제 해결 페이지를 확인하세요.

이 튜토리얼의 연습문제는 일부가 완성된 코드 조각들이에요. DartPad에서 코드를 완성하고 Run 버튼을 눌러 배운 내용을 확인해 볼 수 있어요. main 함수 안의 테스트 코드나 그 아래 코드는 수정하지 마세요. 도움이 필요하면 각 연습문제 아래의 Hint나 Solution 드롭다운을 펼쳐 보세요.

왜 비동기 코드가 중요할까 (Why asynchronous code matters)

비동기 연산(asynchronous operation)은 다른 연산이 끝나기를 기다리는 동안에도 프로그램이 작업을 계속하게 해 줘요. 흔한 비동기 연산들을 몇 가지 꼽아 볼게요:

  • 네트워크를 통해 데이터를 가져오기.
  • 데이터베이스에 쓰기.
  • 파일에서 데이터 읽기.

이런 비동기 연산들은 보통 그 결과를 Future로, 결과가 여러 부분으로 나뉘면 Stream으로 제공해요. 이런 연산은 프로그램에 비동기성을 불러 일으켜요. 그래서 이 첫 비동기성을 수용하려면 Dart의 다른 일반 함수들도 비동기 함수가 되어야 해요.

이런 비동기 결과와 상호 작용하려면 async와 await 키워드를 쓰면 돼요. 대부분의 비동기 함수는 결국 어떤 근본적으로 비동기인 계산에 (아마 깊숙이) 의존하는 async Dart 함수일 뿐이에요.

예시: 비동기 함수를 잘못 사용하기

다음 예시는 비동기 함수 fetchUserOrder()를 잘못 사용하는 모습을 보여 줘요. 나중에 async와 await로 이 예시를 고치게 될 거예요. 예시를 실행하기 전에 문제가 뭔지 짚어 보세요 — 출력이 뭘 거라고 생각하나요?

// This example shows how *not* to write asynchronous Dart code.

String createOrderMessage() {
  var order = fetchUserOrder();
  return 'Your order is: $order';
}

Future<String> fetchUserOrder() =>
    // Imagine that this function is more complex and slow.
    Future.delayed(const Duration(seconds: 2), () => 'Large Latte');

void main() {
  print(createOrderMessage());
}

이 예시가 fetchUserOrder()가 결국 만들어 내는 값을 출력하지 못하는 이유는 이래요:

  • fetchUserOrder()는 지연 후 사용자의 주문을 설명하는 문자열 "Large Latte"를 제공하는 비동기 함수예요.
  • 사용자 주문을 얻으려면 createOrderMessage()fetchUserOrder()를 호출하고 끝나기를 기다려야 해요. 그런데 createOrderMessage()fetchUserOrder()가 끝나기를 기다리지 않으므로, fetchUserOrder()가 결국 제공하는 문자열 값을 얻지 못해요.
  • 대신 createOrderMessage()는 진행 중인 작업을 나타내는 표현, 즉 아직 완료되지 않은 future를 얻게 돼요. future에 대해 더 자세한 건 다음 섹션에서 배울게요.
  • createOrderMessage()가 사용자 주문을 설명하는 값을 얻지 못했기 때문에, 예시는 콘솔에 "Large Latte"를 출력하지 못하고 대신 "Your order is: Instance of '_Future'"을 출력해요.

다음 섹션들에서 future에 대해, 그리고 (async와 await를 이용한) future 다루기에 대해 배우면 fetchUserOrder()가 원하는 값("Large Latte")을 콘솔에 출력하도록 만드는 코드를 작성할 수 있게 될 거예요.

핵심 용어

  • 동기 연산(synchronous operation): 동기 연산은 완료될 때까지 다른 연산의 실행을 막아요(block).
  • 동기 함수(synchronous function): 동기 함수는 오직 동기 연산만 수행해요.
  • 비동기 연산(asynchronous operation): 한번 시작되면 비동기 연산은 완료되기 전에 다른 연산이 실행되는 것을 허용해요.
  • 비동기 함수(asynchronous function): 비동기 함수는 적어도 하나의 비동기 연산을 수행하며, 동기 연산도 함께 수행할 수 있어요.

Future란 무엇인가 (What is a future?)

future(소문자 "f")는 Future(대문자 "F") 클래스의 인스턴스예요. future는 비동기 연산의 결과를 나타내며, 두 가지 상태 중 하나일 수 있어요: 미완료(uncompleted) 또는 완료(completed).

참고 Uncompleted는 future가 값을 만들어 내기 전의 상태를 가리키는 Dart 용어예요.

미완료 (Uncompleted)

비동기 함수를 호출하면 미완료 future를 반환받아요. 그 future는 함수의 비동기 연산이 끝나거나 오류를 던질 때까지 기다리고 있어요.

완료 (Completed)

비동기 연산이 성공하면 future는 값과 함께 완료돼요. 그렇지 않으면 오류와 함께 완료돼요.

값과 함께 완료하기

Future<T> 타입의 future는 T 타입의 값으로 완료돼요. 예를 들어 Future<String> 타입의 future는 문자열 값을 만들어 내요. future가 쓸모 있는 값을 만들어 내지 않으면 그 future의 타입은 Future<void>가 돼요.

오류와 함께 완료하기

함수가 수행하는 비동기 연산이 어떤 이유로든 실패하면 future는 오류와 함께 완료돼요.

예시: futures 소개

다음 예시에서 fetchUserOrder()는 콘솔에 출력한 뒤 완료되는 future를 반환해요. 쓸모 있는 값을 반환하지 않으므로 fetchUserOrder()Future<void> 타입이에요. 예시를 실행하기 전에 "Large Latte"와 "Fetching user order..." 중 어떤 게 먼저 출력될지 예측해 보세요.

Future<void> fetchUserOrder() {
  // Imagine that this function is fetching user info from another service or database.
  return Future.delayed(const Duration(seconds: 2), () => print('Large Latte'));
}

void main() {
  fetchUserOrder();
  print('Fetching user order...');
}

앞의 예시에서, fetchUserOrder()가 8번 줄의 print() 호출보다 먼저 실행됨에도 콘솔에는 8번 줄의 출력("Fetching user order...")이 fetchUserOrder()의 출력("Large Latte")보다 먼저 나타나요. fetchUserOrder()가 "Large Latte"를 출력하기 전에 지연하기 때문이에요.

예시: 오류와 함께 완료하기

다음 예시를 실행해 future가 오류와 함께 완료되는 모습을 확인해 보세요. 조금 뒤에 그 오류를 처리하는 방법을 배우게 될 거예요.

Future<void> fetchUserOrder() {
  // Imagine that this function is fetching user info but encounters a bug.
  return Future.delayed(
    const Duration(seconds: 2),
    () => throw Exception('Logout failed: user ID is invalid'),
  );
}

void main() {
  fetchUserOrder();
  print('Fetching user order...');
}

이 예시에서 fetchUserOrder()는 사용자 ID가 유효하지 않다는 오류와 함께 완료돼요.

future와 그것이 완료되는 방식에 대해 배웠는데, 비동기 함수의 결과는 어떻게 쓰는 걸까요? 다음 섹션에서 async와 await 키워드로 결과를 얻는 방법을 배울 거예요.

빠른 복습

  • Future<T> 인스턴스는 T 타입의 값을 만들어 내요.
  • future가 쓸모 있는 값을 만들지 않으면 future의 타입은 Future<void>예요.
  • future는 미완료 또는 완료, 두 가지 상태 중 하나일 수 있어요.
  • future를 반환하는 함수를 호출하면 함수는 해야 할 작업을 큐에 넣고 미완료 future를 반환해요.
  • future의 연산이 끝나면 future는 값 또는 오류와 함께 완료돼요.

핵심 용어:

  • Future: Dart의 Future 클래스.
  • future: Dart Future 클래스의 인스턴스.

futures 다루기: async와 await (Working with futures: async and await)

async와 await 키워드는 비동기 함수를 정의하고 그 결과를 사용하는 선언적인 방법을 제공해요. async와 await를 쓸 때 다음 두 가지 기본 지침을 기억하세요:

  • async 함수를 정의하려면 함수 본문 앞에 async를 추가해요.
  • await 키워드는 오직 async 함수에서만 동작해요.

main()을 동기 함수에서 비동기 함수로 바꾸는 예시를 볼게요.

먼저 함수 본문 앞에 async 키워드를 추가해요:

void main() async { ··· }

함수에 선언된 반환 타입이 있다면 그 타입을 Future<T>로 바꾸는데, 여기서 T는 함수가 반환하는 값의 타입이에요. 함수가 값을 명시적으로 반환하지 않는다면 반환 타입은 Future<void>가 돼요:

Future<void> main() async { ··· }

이제 async 함수가 생겼으니, await 키워드로 future가 완료될 때까지 기다릴 수 있어요:

print(await createOrderMessage());

다음 두 예시가 보여 주듯이, async와 await 키워드는 동기 코드처럼 보이는 비동기 코드를 만들어 내요. 차이점은 비동기 예시에서 강조돼요 (창이 충분히 넓다면 동기 예시 오른쪽에 있어요).

예시: 동기 함수

String createOrderMessage() {
  var order = fetchUserOrder();
  return 'Your order is: $order';
}

Future<String> fetchUserOrder() =>
    // Imagine that this function is
    // more complex and slow.
    Future.delayed(const Duration(seconds: 2), () => 'Large Latte');

void main() {
  print('Fetching user order...');
  print(createOrderMessage());
}

출력:

Fetching user order...
Your order is: Instance of 'Future<String>'

다음 두 예시가 보여 주듯이, 이것은 동기 코드처럼 동작해요.

예시: 비동기 함수

Future<String> createOrderMessage() async {
  var order = await fetchUserOrder();
  return 'Your order is: $order';
}

Future<String> fetchUserOrder() =>
    // Imagine that this function is
    // more complex and slow.
    Future.delayed(const Duration(seconds: 2), () => 'Large Latte');

Future<void> main() async {
  print('Fetching user order...');
  print(await createOrderMessage());
}

출력:

Fetching user order...
Your order is: Large Latte

비동기 예시는 세 가지 점에서 달라요:

  • createOrderMessage()의 반환 타입이 String에서 Future<String>으로 바뀌었어요.
  • createOrderMessage()main()의 함수 본문 앞에 async 키워드가 생겼어요.
  • 비동기 함수 fetchUserOrder()createOrderMessage()를 호출하는 앞에 await 키워드가 붙었어요.

핵심 용어

  • async: 함수 본문 앞에 async 키워드를 붙여 그 함수를 비동기로 표시할 수 있어요.
  • async 함수: async 키워드로 표시된 함수를 말해요.
  • await: await 키워드로 비동기 표현식의 완료된 결과를 얻을 수 있어요. await 키워드는 오직 async 함수 안에서만 동작해요.

async와 await의 실행 흐름

async 함수는 첫 번째 await 키워드에 도달할 때까지 동기적으로 실행돼요. 즉 async 함수 본문 안에서 첫 await 키워드 앞의 모든 동기 코드는 즉시 실행된다는 뜻이에요.

예시: async 함수 안에서의 실행

다음 예시를 실행해 async 함수 본문 안에서 실행이 어떻게 진행되는지 확인해 보세요. 출력이 뭘 거라고 생각하나요?

Future<void> printOrderMessage() async {
  print('Awaiting user order...');
  var order = await fetchUserOrder();
  print('Your order is: $order');
}

Future<String> fetchUserOrder() {
  // Imagine that this function is more complex and slow.
  return Future.delayed(const Duration(seconds: 4), () => 'Large Latte');
}

void main() async {
  countSeconds(4);
  await printOrderMessage();
}

// You can ignore this function - it's here to visualize delay time in this example.
void countSeconds(int s) {
  for (var i = 1; i <= s; i++) {
    Future.delayed(Duration(seconds: i), () => print(i));
  }
}

앞의 예시의 코드를 실행한 뒤, 2번과 3번 줄을 바꿔서 실행해 보세요:

var order = await fetchUserOrder();
print('Awaiting user order...');

이제 print('Awaiting user order')printOrderMessage()의 첫 await 키워드 뒤에 나오므로 출력 타이밍이 달라지는 걸 확인할 수 있어요.

연습문제: async와 await 사용 연습

다음 연습문제는 실패하는 단위 테스트로, 일부가 완성된 코드 조각들을 담고 있어요. 테스트가 통과하도록 코드를 작성해 연습문제를 완성하는 게 여러분의 과제예요. main()을 구현할 필요는 없어요.

비동기 연산을 시뮬레이션하려면 여러분에게 제공되는 다음 함수들을 호출하면 돼요:

함수 타입 시그니처 설명
fetchRole() Future<String> fetchRole() 사용자 역할의 짧은 설명을 가져와요.
fetchLoginAmount() Future<int> fetchLoginAmount() 사용자가 로그인한 횟수를 가져와요.
1부: reportUserRole()

reportUserRole() 함수에 코드를 추가해서 다음을 하도록 만들어 보세요:

  • 다음 문자열과 함께 완료되는 future를 반환해요: "User role: <user role>"
    • 참고: fetchRole()이 실제로 반환한 값을 반드시 사용해야 해요. 예시 반환값을 복사해 붙여 넣으면 테스트가 통과하지 않아요.
    • 예시 반환값: "User role: tester"
  • 제공된 함수 fetchRole()을 호출해 사용자 역할을 가져와요.
2부: reportLogins()

다음을 하도록 async 함수 reportLogins()를 구현해 보세요:

  • "Total number of logins: <# of logins>" 문자열을 반환해요.
    • 참고: fetchLoginAmount()가 실제로 반환한 값을 반드시 사용해야 해요. 예시 반환값을 복사해 붙여 넣으면 테스트가 통과하지 않아요.
    • reportLogins()의 예시 반환값: "Total number of logins: 57"
  • 제공된 함수 fetchLoginAmount()을 호출해 로그인 횟수를 가져와요.
// Part 1
// Call the provided async function fetchRole()
// to return the user role.
Future<String> reportUserRole() async {
  // TODO: Implement the reportUserRole function here.
}

// Part 2
// TODO: Implement the reportLogins function here.
// Call the provided async function fetchLoginAmount()
// to return the number of times that the user has logged in.
reportLogins() {}

// The following functions those provided to you to simulate
// asynchronous operations that could take a while.

Future<String> fetchRole() => Future.delayed(_halfSecond, () => _role);
Future<int> fetchLoginAmount() => Future.delayed(_halfSecond, () => _logins);

정답:

Future<String> reportUserRole() async {
  final username = await fetchRole();
  return 'User role: $username';
}

Future<String> reportLogins() async {
  final logins = await fetchLoginAmount();
  return 'Total number of logins: $logins';
}

힌트: reportUserRole 함수에 async 키워드를 추가했는지, fetchRole()을 호출하기 전에 await 키워드를 사용했는지 확인해 보세요. reportUserRole은 Future를 반환해야 한다는 점을 기억하세요.

오류 처리 (Handling errors)

async 함수에서 오류를 처리하려면 try-catch를 사용해요:

try {
  print('Awaiting user order...');
  var order = await fetchUserOrder();
} catch (err) {
  print('Caught error: $err');
}

async 함수 안에서 try-catch 절을 동기 코드에서 쓰던 것과 똑같이 작성하면 돼요.

예시: try-catch와 함께 쓰는 async와 await

다음 예시를 실행해 비동기 함수의 오류를 처리하는 방법을 확인해 보세요. 출력이 뭘 거라고 생각하나요?

Future<void> printOrderMessage() async {
  try {
    print('Awaiting user order...');
    var order = await fetchUserOrder();
    print(order);
  } catch (err) {
    print('Caught error: $err');
  }
}

Future<String> fetchUserOrder() {
  // Imagine that this function is more complex.
  var str = Future.delayed(
    const Duration(seconds: 4),
    () => throw 'Cannot locate user order',
  );
  return str;
}

void main() async {
  await printOrderMessage();
}

연습문제: 오류 처리 연습

다음 연습문제는 이전 섹션에서 다룬 방식으로 비동기 코드의 오류를 처리하는 연습을 제공해요. 비동기 연산을 시뮬레이션하려면 여러분의 코드가 제공된 다음 함수를 호출하게 될 거예요:

함수 타입 시그니처 설명
fetchNewUsername() Future<String> fetchNewUsername() 새 사용자 이름을 대체하는 데 쓸 수 있는 새 사용자 이름을 반환해요.

async와 await를 사용해 다음을 하는 비동기 changeUsername() 함수를 구현해 보세요:

  • 제공된 비동기 함수 fetchNewUsername()을 호출하고 그 결과를 반환해요.
    • changeUsername()의 예시 반환값: "jane_smith_92"
  • 발생한 어떤 오류든 잡아서 그 오류의 문자열 값을 반환해요.
// TODO: Implement changeUsername here.
changeUsername() {}

// The following function is provided to you to simulate
// an asynchronous operation that could take a while and
// potentially throw an exception.

Future<String> fetchNewUsername() =>
    Future.delayed(const Duration(milliseconds: 500), () => throw UserError());

class UserError implements Exception {
  @override
  String toString() => 'New username is invalid';
}

정답:

Future<String> changeUsername() async {
  try {
    return await fetchNewUsername();
  } catch (err) {
    return err.toString();
  }
}

힌트: changeUsernamefetchNewUsername에서 온 문자열을 반환하고, 그것이 실패하면 발생한 오류의 문자열 값을 반환하게 구현해 보세요. 오류를 잡고 처리하려면 try-catch 문을 쓸 수 있다는 걸 기억하세요.

종합 연습문제: 배운 것 모두 쓰기

배운 것을 하나의 마지막 연습문제로 연습할 시간이에요. 비동기 연산을 시뮬레이션하기 위해 이 연습문제는 비동기 함수 fetchUsername()logoutUser()를 제공해요:

함수 타입 시그니처 설명
fetchUsername() Future<String> fetchUsername() 현재 사용자와 연결된 이름을 반환해요.
logoutUser() Future<String> logoutUser() 현재 사용자를 로그아웃시키고 로그아웃된 사용자 이름을 반환해요.

다음을 작성해 보세요:

1부: addHello()
  • String 인자를 하나 받는 함수 addHello()를 작성해요.
  • addHello()는 그 String 인자 앞에 'Hello '를 붙여 반환해요.
    • 예: addHello('Jon')'Hello Jon'을 반환해요.
2부: greetUser()
  • 인자를 받지 않는 함수 greetUser()를 작성해요.
  • 사용자 이름을 얻으려면 greetUser()가 제공된 비동기 함수 fetchUsername()을 호출해요.
  • greetUser()addHello()를 호출해 사용자에게 인사를 만들어 내고(사용자 이름을 전달) 그 결과를 반환해요.
    • 예: fetchUsername()'Jenny'를 반환하면, greetUser()'Hello Jenny'를 반환해요.
3부: sayGoodbye()

다음을 하는 함수 sayGoodbye()를 작성해 보세요:

  • 인자를 받지 않아요.
  • 어떤 오류든 잡아요.
  • 제공된 비동기 함수 logoutUser()을 호출해요.
  • logoutUser()가 실패하면 sayGoodbye()는 원하는 문자열을 반환해요.
  • logoutUser()가 성공하면 sayGoodbye()'<result> Thanks, see you next time' 문자열을 반환해요. 여기서 <result>logoutUser()을 호출해 반환된 문자열 값이에요.
// Part 1
addHello(String user) {}

// Part 2
// Call the provided async function fetchUsername()
// to return the username.
greetUser() {}

// Part 3
// Call the provided async function logoutUser()
// to log out the user.
sayGoodbye() {}

// The following functions are provided to you to use in your solutions.

Future<String> fetchUsername() => Future.delayed(_halfSecond, () => 'Jean');

Future<String> logoutUser() => Future.delayed(_halfSecond, _failOnce);

정답:

String addHello(String user) => 'Hello $user';

Future<String> greetUser() async {
  final username = await fetchUsername();
  return addHello(username);
}

Future<String> sayGoodbye() async {
  try {
    final result = await logoutUser();
    return '$result Thanks, see you next time';
  } catch (e) {
    return 'Failed to logout user: $e';
  }
}

힌트: greetUsersayGoodbye 함수는 비동기여야 하고, addHello는 일반적인 동기 함수여야 해요. 오류를 잡으려면 try-catch 문을 쓸 수 있다는 걸 기억하세요.

futures에 어떤 lint가 동작할까

async와 futures를 다룰 때 흔히 생기는 실수를 잡으려면 다음 lint를 활성화하세요:

다음 단계

축하해요, 튜토리얼을 끝냈어요! 더 배우고 싶다면 다음을 추천할게요:

더 알아보기