싱글 스레드인데 어떻게 여러 작업을 동시에 처리할까요?
클로저 → 이벤트 루프 → async/await 까지 연결해서 정리합니다.
Dart는 싱글 스레드입니다. 그런데 어떻게 여러 작업을 동시에 처리하는 것처럼 보일까요? 바로 이벤트 루프 덕분입니다.
Dart 실행 환경
┌─────────────────────────────────┐
│ Call Stack │ ← 지금 실행 중인 코드
├─────────────────────────────────┤
│ Microtask Queue │ ← 최우선 처리 (Future, then)
├─────────────────────────────────┤
│ Event Queue │ ← 일반 이벤트 (타이머, I/O, 클릭)
└─────────────────────────────────┘
이벤트 루프는 이 순서로 반복합니다.
1. Call Stack이 비었는가?
2. Microtask Queue에 뭔가 있으면 → 꺼내서 실행
3. Microtask Queue가 비었으면 → Event Queue에서 꺼내서 실행
4. 다시 1번으로
지금 실행 중인 함수들이 쌓이는 곳입니다.
void main() { // 1. main이 스택에 쌓임
greet("홍렬"); // 2. greet이 스택에 쌓임
}
void greet(String name) {
print("안녕 $name"); // 3. print가 스택에 쌓임
// 4. print 끝 → 스택에서 제거
// 5. greet 끝 → 스택에서 제거
// 6. main 끝 → 스택에서 제거
}
Future.value(), .then(), async/await 완료 후 실행될 콜백이 여기 들어갑니다.
Event Queue보다 항상 먼저 처리됩니다.
void main() {
print("1");
Future.microtask(() => print("2")); // Microtask Queue에 추가
print("3");
}
// 출력 순서
// 1
// 3
// 2 ← main() 끝나고 Microtask Queue 처리
타이머, I/O, 사용자 입력, Future.delayed 등이 여기 들어갑니다.
void main() {
print("1");
Future.delayed(Duration.zero, () => print("2")); // Event Queue
Future.microtask(() => print("3")); // Microtask Queue
print("4");
}
// 출력 순서
// 1
// 4
// 3 ← Microtask Queue 먼저
// 2 ← Event Queue 나중
Future<void> fetchUser() async {
print("A");
final user = await api.getUser(); // 여기서 일시 중단
print("B"); // await 완료 후 Microtask Queue에 등록
}
void main() {
fetchUser();
print("C");
}
// 출력 순서
// A
// C ← fetchUser가 await에서 멈추고 main 계속 실행
// B ← api.getUser() 완료 후 Microtask Queue에서 실행
await은 Call Stack을 블로킹하지 않습니다. 대기 중에도 다른 코드가 실행될 수 있어요.
이벤트 큐에 들어가는 것들이 전부 클로저입니다.
void main() {
int count = 0;
// 이 람다(클로저)가 Event Queue에 등록됨
// count를 참조로 캡처하고 있음
Future.delayed(Duration(seconds: 1), () {
count++;
print(count);
});
count = 100; // 큐에 등록된 후 count를 바꾸면?
}
// 출력: 101
// 값이 아닌 참조를 캡처했기 때문
setState(() {
count++;
});
print(count); // count++는 이미 반영됨 — setState는 동기 실행
// 하지만 실제 화면 업데이트는 다음 프레임(Event Queue)에서
// ❌ 위험한 코드
Future<void> onButtonTap() async {
await api.fetchUser(); // 여기서 위젯이 사라질 수 있음
// await 이후는 Event Queue에서 실행됨
// 이 시점에 위젯이 이미 dispose됐을 수 있음
Navigator.push(context, ...);
}
// ✅ 안전한 코드
Future<void> onButtonTap() async {
await api.fetchUser();
if (!mounted) return; // 위젯이 살아있는지 확인 후 사용
Navigator.push(context, ...);
}
class _MyState extends State<MyWidget> {
Timer? _timer;
void initState() {
super.initState();
// 이 클로저가 Event Queue에 반복 등록됨
// this(State)를 캡처하고 있음
_timer = Timer.periodic(Duration(seconds: 1), (timer) {
setState(() { ... });
});
}
void dispose() {
_timer?.cancel(); // 큐에서 제거 → 클로저 참조 해제
super.dispose();
}
}
사용자 탭 발생
↓
Event Queue에 클로저 등록
↓
Call Stack이 빌 때까지 대기
↓
Microtask Queue 먼저 처리
↓
Event Queue에서 클로저 꺼내 실행
↓
클로저가 캡처한 외부 변수(State 등) 접근
↓
setState() 호출 → 다음 프레임에 화면 업데이트
| 큐 | 들어가는 것 | 우선순위 |
|---|---|---|
| Microtask Queue | Future.value, .then(), await 완료 콜백 | 높음 |
| Event Queue | 타이머, I/O, 사용자 입력, Future.delayed | 낮음 |
싱글 스레드 → 이벤트 루프 → 클로저 캡처 → GC → 메모리 누수까지 전부 연결되는 흐름이 보이시나요?
이 개념들을 이해하고 나면 dispose()를 왜 꼭 해야 하는지, mounted 체크를 왜 해야 하는지, Timer.cancel()을 왜 빠뜨리면 안 되는지가 자연스럽게 납득이 됩니다.