Dart 튜토리얼 10편: 동시성 모델(concurrency·async/await·Future/Stream·Isolate)

요약#

핵심 요지#

  • 문제 정의: 한 프로그램이 네트워크 요청과 사용자 입력처럼 여러 일을 동시에 처리하려면, 실행 순서와 대기 방식이 필요하다.
  • 핵심 주장: Dart의 concurrency1Future2/Stream3 같은 비동기 API와 Isolate4를 함께 포함하며, 상황에 따라 “기다리기”와 “병렬 실행”을 나눠 선택한다.
  • 주요 근거: Dart 코드는 event loop5를 중심으로 이벤트를 처리하고, async6/await7로 비동기 흐름을 자연스럽게 작성할 수 있다.
  • 실무 기준: I/O 대기(네트워크/파일)는 async/await로 다루고, CPU 작업이 오래 걸리면 Isolate로 분리한다.

문서가 설명하는 범위#

  • event loop가 이벤트를 처리하는 방식
  • Futureasync/await로 “나중에 끝나는 일” 다루기
  • Streamawait for8로 “여러 번 도착하는 값” 다루기
  • Isolate로 메모리를 분리하고, SendPort9/ReceivePort10로 메시지 주고받기

읽는 시간: 18분 | 난이도: 초급


참고 자료#


문제 상황#

앱은 한 번에 한 가지 일만 하는 것처럼 보여도, 실제로는 여러 일을 번갈아 처리합니다.
예를 들어 네트워크 요청을 보내고 응답을 기다리는 동안에도, 화면은 입력을 받아야 합니다.
이때 “기다리는 동안 프로그램이 멈추면” 사용자 경험이 크게 나빠집니다.

그래서 필요한 것이 동시성 모델입니다.
동시성 모델은 크게 두 가지 질문에 답합니다.
“대기 중에는 어떻게 계속 움직일까”와 “오래 걸리는 작업을 어떻게 분리할까”입니다.


해결 방법#

단계 1: event loop로 “해야 할 일”을 순서대로 처리하기#

Why#

NOTE

네트워크 응답은 “나중에” 오는데, 그동안 앱은 계속 입력을 받아야 합니다.
즉, “할 일 목록을 순서대로 처리하면서도 멈추지 않는” 실행 흐름이 필요합니다.

// 작업을 한 번에 다 하는 게 아니라, 이벤트가 올 때마다 하나씩 처리한다.

What#

NOTE

Dart의 실행 모델은 event loop를 기반으로 합니다.
이 루프는 이벤트를 기다렸다가, 다음 이벤트를 처리하는 방식으로 돌아갑니다.

How#

TIP
while (eventQueue.waitForEvent()) {
eventQueue.processNextEvent();
}

이 코드는 실행 가능한 Dart 코드가 아니라, event loop의 동작을 설명하기 위한 의사 코드입니다.

여기서 중요한 포인트는 “이 흐름 자체는 단일 스레드처럼 보일 수 있다”는 점입니다.
하지만 앱은 네트워크 요청처럼 “결과가 나중에 오는 일”이 많습니다.
그래서 Dart는 Future, Stream, async/await 같은 비동기 API를 제공합니다.

Watch out#

WARNING

event loop는 “이벤트를 하나씩 처리”하는 모델이라서, 한 번에 오래 걸리는 작업을 돌리면 다음 이벤트 처리가 늦어질 수 있습니다.
즉, 사용자 입력/화면 갱신이 느려지는 원인이 될 수 있습니다.

// 예시: 시간이 오래 걸리는 작업이 메인 흐름을 늦출 수 있다.
void heavyWork() {
for (var i = 0; i < 1000000000; i++) {
// CPU를 오래 쓴다.
}
}

결론: “지금 할 일”과 “나중에 할 일”을 나눠서, 화면이 멈추지 않게 만들 수 있습니다.


단계 2: Futureasync/await로 “나중에 끝나는 일” 기다리기#

Why#

NOTE

I/O 작업은 결과가 바로 오지 않습니다.
그래서 “결과가 올 때까지 기다렸다가, 이후 작업을 이어 붙이는 방식”이 필요합니다.

// Future가 끝난 뒤에 후속 작업을 이어 붙인다.

What#

NOTE

네트워크 요청처럼 결과가 나중에 오는 작업은 Future로 다뤄집니다.
예를 들어 아래 코드는 http.get11 결과가 돌아오면 .then12 안에서 후속 작업을 수행합니다.

How#

TIP
http.get('https://example.com').then((response) {
if (response.statusCode == 200) {
print('Success!');
}
});

같은 흐름을 async/await로 쓰면, “기다렸다가 다음 줄로 간다”는 느낌으로 읽기 쉬워집니다.

Future<void> checkVersion() async {
var version = await lookUpVersion();
// version으로 할 일
}

여기서 await는 “이 Future가 끝날 때까지 기다리되, event loop는 계속 돌게 한다”는 의미로 이해하면 됩니다.

Watch out#

WARNING

await를 쓸 때는 “이 시점에서 결과가 필요하다”는 뜻이 됩니다.
즉, 결과가 필요 없는 작업이라면 무조건 await를 붙이는 습관은 피하는 편이 좋습니다.

결론: 콜백 체인보다 코드 흐름이 단순해지고, 예외 처리도 한 덩어리로 묶기 쉬워집니다.


단계 3: Streamawait for로 “여러 번 도착하는 값” 처리하기#

Why#

NOTE

값이 한 번만 오는 게 아니라, 시간이 지나면서 여러 번 들어오는 상황이 있습니다.
이때는 Future 하나로는 표현이 어렵습니다.

// 이벤트가 여러 번 들어오는 흐름을 다룬다.

What#

NOTE

Stream은 시간이 지나면서 값이 여러 번 도착하는 상황을 다룹니다.
예를 들어 서버 요청이 연속으로 들어오거나, 이벤트가 계속 발생하는 경우가 여기에 해당합니다.

How#

TIP

await for를 사용하면 Stream에서 값이 도착할 때마다 블록을 반복 실행할 수 있습니다.

await for (varOrType identifier in expression) {
// stream이 값을 방출할 때마다 실행된다.
}

이때 await forasync 함수 안에서만 쓸 수 있습니다.

Watch out#

WARNING

Stream은 “여러 번 올 수 있다”는 성질이므로, 언제 끝나는지도 설계에 포함돼야 합니다.
즉, 끝나지 않는 Stream을 기다리는 코드라면 종료/취소 흐름을 고려해야 합니다.

결론: “반복해서 들어오는 이벤트”를 자연스러운 반복문 형태로 다룰 수 있습니다.


단계 4: Isolate로 “진짜로 분리해서” 병렬 실행하기#

Why#

NOTE

비동기는 “대기”를 잘 다루는 모델입니다.
하지만 CPU를 오래 쓰는 작업이 있으면, 단일 흐름만으로는 다른 작업이 늦어질 수 있습니다.

// CPU를 오래 쓰는 작업은 메인 흐름을 늦출 수 있다.

What#

NOTE

이럴 때 Isolate를 사용하면 별도의 실행 단위로 작업을 분리할 수 있습니다.
새로 만든 Isolate는 분리된 메모리를 가지며, 메시지로 통신합니다.

How#

TIP
4-1) 간단한 분리: Isolate.run#

Isolate.run13은 기존 함수를 새로운 isolate에서 실행하고 결과를 돌려받는 형태입니다.

import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
const String filename = 'with_keys.json';
Future<void> main() async {
// 파일을 읽고 JSON으로 파싱하는 작업을 다른 isolate로 보낸다.
final jsonData = await Isolate.run(_readAndParseJson);
// 결과를 받아서 사용한다.
print('Number of JSON keys: ${jsonData.length}');
}
Future<Map<String, dynamic>> _readAndParseJson() async {
final fileData = await File(filename).readAsString();
final jsonData = jsonDecode(fileData) as Map<String, dynamic>;
return jsonData;
}
4-2) 더 직접적인 통신: ReceivePort/SendPortIsolate.spawn#

Isolate.spawn14으로 isolate를 만들고, ReceivePort/SendPort로 메시지를 주고받을 수 있습니다.

Future<void> spawn() async {
final receivePort = ReceivePort();
receivePort.listen(_handleResponsesFromIsolate);
await Isolate.spawn(_startRemoteIsolate, receivePort.sendPort);
}

Watch out#

WARNING

Isolate는 메모리가 분리되어 있어서, “변수를 공유해서” 상태를 맞추는 방식이 아닙니다.
즉, 결과/명령은 SendPort/ReceivePort로 메시지로 주고받아야 합니다.

final receivePort = ReceivePort();
await Isolate.spawn(_startRemoteIsolate, receivePort.sendPort);
receivePort.listen((message) {
print('받은 메시지: $message');
});

결론: 긴 작업을 분리해도 메인 흐름이 멈추지 않아, 반응성이 좋아집니다.

Footnotes#

  1. concurrency(동시성): 여러 일을 동시에 처리하는 것처럼 보이게 하거나, 실제로 병렬로 실행하는 모델을 말한다.

  2. Future(퓨처): 나중에 완료되는 작업의 결과를 표현하는 타입이다.

  3. Stream(스트림): 시간이 지나면서 여러 값이 순서대로 도착하는 흐름을 표현하는 타입이다.

  4. Isolate(아이솔레이트): Dart에서 실행과 메모리가 분리된 동시성 단위다.

  5. event loop(이벤트 루프): 이벤트를 모으고, 하나씩 꺼내 실행 흐름에 반영하는 실행 모델이다.

  6. async(어싱크): 함수가 비동기 동작을 하며 await7를 사용할 수 있음을 표시하는 키워드다.

  7. await(어웨이트): Future2가 완료될 때까지 기다린 뒤 다음 코드를 진행하게 하는 키워드다. 2

  8. await for(어웨이트 포): Stream3이 값을 방출할 때마다 반복 실행하는 비동기 반복문이다.

  9. SendPort(센드 포트): 다른 isolate로 메시지를 보내는 포트다.

  10. ReceivePort(리시브 포트): 다른 isolate로부터 메시지를 받는 포트다.

  11. http.get(HTTP GET): HTTP GET 요청을 보내는 예시 호출이다.

  12. then(덴): Future2가 완료된 뒤 실행할 작업을 등록하는 메서드다.

  13. Isolate.run(아이솔레이트 런): 함수를 새 isolate에서 실행하고 결과를 돌려받는 API다.

  14. Isolate.spawn(아이솔레이트 스폰): 새 isolate를 만들고 시작하는 API다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Dart 튜토리얼 10편: 동시성 모델(concurrency·async/await·Future/Stream·Isolate)
https://moodturnpost.net/posts/dart/dart-concurrency-model/
작성자
Moodturn
게시일
2026-01-04
Moodturn

목차