iBetter Books
수정

커피숍에서 배우는 비동기

커피숍을 떠올려보겠습니다.

손님이 아메리카노를 주문합니다. 동기(Synchronous) 방식 카페라면 바리스타가 커피를 다 내릴 때까지 다음 손님을 받지 않습니다. 두 번째 손님은 첫 손님의 커피가 완성될 때까지 줄에서 기다려야 합니다. 세 번째, 네 번째 손님도 마찬가지입니다. 카페는 점점 길어지는 대기 줄에 결국 문을 닫아야 할지도 모릅니다.

하지만 실제 카페는 다릅니다. 바리스타는 주문을 받고 진동벨을 건네줍니다. 커피를 만드는 동안 다음 손님의 주문을 받습니다. 커피가 완성되면 진동벨이 울리고, 그때 손님이 카운터로 돌아옵니다. 이것이 비동기(Asynchronous) 방식입니다.

프로그래밍에서도 똑같은 상황이 벌어집니다.

동기 코드의 문제

파일을 읽고, 서버에서 데이터를 가져오고, 타이머를 기다리는 작업은 시간이 걸립니다. 동기 방식으로 이런 작업을 처리하면 어떻게 될까요.

// 동기 방식 (실제로는 이렇게 작동하지 않지만 개념 이해용)
void main() {
  print('1. 앱 시작');
  
  // 2초 걸리는 작업 (네트워크 요청)
  var data = fetchDataFromServer(); // 여기서 2초 대기
  
  print('2. 데이터 수신: $data');
  print('3. 화면 업데이트');
}

fetchDataFromServer()가 실행되는 2초 동안 프로그램은 완전히 멈춥니다. 사용자가 버튼을 눌러도 반응이 없습니다. 화면이 새로고침되지 않습니다. 앱이 "먹통"이 된 것처럼 보입니다.

이것이 바로 비동기가 필요한 이유입니다.

Dart의 싱글 스레드 모델

Dart는 싱글 스레드(Single Thread) 언어입니다. 한 번에 하나의 작업만 처리할 수 있습니다.

"그럼 어떻게 비동기가 가능하죠?"

바로 이벤트 루프(Event Loop) 덕분입니다.

Dart 프로그램이 시작되면 메인 스레드가 하나 생성됩니다. 이 스레드는 쉬지 않고 두 개의 큐를 확인하며 작업을 처리합니다.

┌─────────────────────────────────────┐
│           Dart 이벤트 루프           │
│                                     │
│  ┌──────────────────────────────┐   │
│  │    마이크로태스크 큐           │   │
│  │  (Microtask Queue)           │   │
│  │  - Future.microtask()        │   │
│  │  - scheduleMicrotask()       │   │
│  └──────────────────────────────┘   │
│              ↓ 비어있으면            │
│  ┌──────────────────────────────┐   │
│  │    이벤트 큐                  │   │
│  │  (Event Queue)               │   │
│  │  - Future.delayed()          │   │
│  │  - I/O 이벤트                │   │
│  │  - 타이머                    │   │
│  └──────────────────────────────┘   │
└─────────────────────────────────────┘

이벤트 큐와 마이크로태스크 큐

이벤트 루프에는 두 가지 큐가 있습니다.

마이크로태스크 큐(Microtask Queue)는 우선순위가 높습니다. 이벤트 큐를 처리하기 전에 마이크로태스크 큐가 완전히 비워집니다. Future.microtask()scheduleMicrotask()로 등록한 작업이 여기에 들어갑니다.

이벤트 큐(Event Queue)는 일반적인 비동기 작업을 담습니다. 타이머, I/O 이벤트, Future.delayed() 같은 작업이 여기서 처리됩니다.

실제로 어떤 순서로 실행되는지 확인해보겠습니다.

import 'dart:async';

void main() {
  print('1. main 시작');

  // 이벤트 큐에 등록
  Future(() => print('3. Future (이벤트 큐)'));

  // 마이크로태스크 큐에 등록
  scheduleMicrotask(() => print('2. 마이크로태스크'));

  print('4. main 끝');
}

실행 결과를 예상해보겠습니다.

1. main 시작
4. main 끝
2. 마이크로태스크
3. Future (이벤트 큐)

main 함수가 먼저 끝까지 실행됩니다. 그다음 마이크로태스크 큐가 처리됩니다. 마지막으로 이벤트 큐가 처리됩니다. print('4. main 끝')print('2. 마이크로태스크')보다 먼저 출력되는 이유가 바로 이 때문입니다.

이벤트 루프의 작동 원리

이벤트 루프는 다음과 같은 순서로 끊임없이 반복합니다.

1. 현재 실행 중인 Dart 코드 완료까지 실행
   ↓
2. 마이크로태스크 큐가 비어있을 때까지 처리
   ↓
3. 이벤트 큐에서 하나의 이벤트 꺼내서 처리
   ↓
4. 다시 2번으로

핵심은 "현재 실행 중인 코드가 끝나야만" 다음 작업으로 넘어간다는 점입니다. 그래서 동기 코드에서 무거운 작업을 수행하면 이벤트 루프가 블록(block)되어 다른 작업이 처리되지 않습니다.

비동기가 필요한 실전 상황

Dart와 Flutter에서 비동기 처리가 필요한 대표적인 상황들이 있습니다.

파일 읽기와 쓰기. 하드디스크에서 파일을 읽는 작업은 메모리 접근보다 훨씬 느립니다. 파일 시스템 I/O는 비동기로 처리하지 않으면 앱이 멈춥니다.

import 'dart:io';

Future<void> readFile() async {
  var file = File('data.txt');
  var contents = await file.readAsString(); // 비동기로 읽기
  print(contents);
}

HTTP 요청 (API 호출). 서버에서 데이터를 가져오는 시간은 수백 밀리초에서 수 초까지 걸릴 수 있습니다. 이 작업을 동기로 처리하면 그 시간 동안 앱이 완전히 정지합니다.

import 'package:http/http.dart' as http;

Future<void> fetchUsers() async {
  var response = await http.get(
    Uri.parse('https://api.example.com/users'),
  );
  print(response.body);
}

타이머와 지연. 일정 시간 후에 작업을 실행하거나, 주기적으로 반복 실행할 때 비동기를 사용합니다.

void main() async {
  print('작업 시작');
  
  await Future.delayed(Duration(seconds: 2)); // 2초 대기
  
  print('2초 후 실행');
}

데이터베이스 접근. SQLite 같은 로컬 데이터베이스 또는 Firebase 같은 클라우드 데이터베이스 접근도 모두 비동기로 처리합니다.

동기 vs 비동기 비교

두 방식의 차이를 코드로 직접 비교해보겠습니다.

import 'dart:io';

// 동기 방식 (블로킹)
void syncApproach() {
  print('[동기] 작업 1 시작');
  sleep(Duration(seconds: 2)); // 2초 동안 완전히 멈춤
  print('[동기] 작업 1 완료');
  
  print('[동기] 작업 2 시작');
  sleep(Duration(seconds: 1));
  print('[동기] 작업 2 완료');
}

// 비동기 방식 (논블로킹)
Future<void> asyncApproach() async {
  print('[비동기] 작업 1 시작');
  await Future.delayed(Duration(seconds: 2)); // 기다리는 동안 다른 작업 가능
  print('[비동기] 작업 1 완료');
  
  print('[비동기] 작업 2 시작');
  await Future.delayed(Duration(seconds: 1));
  print('[비동기] 작업 2 완료');
}

void main() async {
  print('=== 비동기 방식 ===');
  await asyncApproach();
}

두 코드는 겉으로 비슷해 보이지만 근본적인 차이가 있습니다. sleep()은 스레드 자체를 멈추지만, await Future.delayed()는 이벤트 루프에 제어권을 돌려주어 다른 작업이 진행될 수 있게 합니다.

비동기의 진짜 힘: 동시 처리

비동기의 진가는 여러 작업을 동시에 처리할 때 나타납니다.

import 'dart:async';

Future<String> fetchUserData() async {
  await Future.delayed(Duration(seconds: 2));
  return '사용자 정보';
}

Future<String> fetchOrderData() async {
  await Future.delayed(Duration(seconds: 3));
  return '주문 정보';
}

void main() async {
  var stopwatch = Stopwatch()..start();
  
  // 순차 실행: 2 + 3 = 5초
  var user = await fetchUserData();
  var order = await fetchOrderData();
  
  print('순차: ${stopwatch.elapsed.inSeconds}초');
  print('$user, $order');
  
  stopwatch.reset();
  
  // 병렬 실행: max(2, 3) = 3초
  var results = await Future.wait([
    fetchUserData(),
    fetchOrderData(),
  ]);
  
  print('병렬: ${stopwatch.elapsed.inSeconds}초');
  print('${results[0]}, ${results[1]}');
}

순차 실행은 5초가 걸리지만, Future.wait()를 사용한 병렬 실행은 3초에 끝납니다. 두 작업이 동시에 진행되기 때문입니다.

정리

비동기 프로그래밍의 핵심을 정리하겠습니다.

  • Dart는 싱글 스레드이지만 이벤트 루프 덕분에 비동기 처리가 가능합니다.
  • 이벤트 루프는 마이크로태스크 큐와 이벤트 큐를 순서대로 처리합니다.
  • 비동기 코드는 대기 시간 동안 이벤트 루프에 제어권을 돌려줍니다.
  • 파일 읽기, HTTP 요청, 타이머, 데이터베이스 접근은 모두 비동기로 처리합니다.

진동벨을 받고 자리로 돌아가 커피를 기다리는 것처럼, 비동기 코드는 결과를 기다리는 동안 다른 일을 할 수 있게 해줍니다. 그 "기다림"을 표현하는 것이 바로 Dart의 Future입니다.

다음 챕터에서는 비동기의 핵심 개념인 Future를 자세히 살펴보겠습니다. 아직 완료되지 않은 작업의 결과를 어떻게 다루는지, 그리고 여러 Future를 어떻게 연결하는지 배웁니다.