Dart 이벤트 루프(Event Loop) 완벽 이해

개발렬·2026년 3월 15일

Flutter

목록 보기
10/10
post-thumbnail

싱글 스레드인데 어떻게 여러 작업을 동시에 처리할까요?
클로저 → 이벤트 루프 → async/await 까지 연결해서 정리합니다.


이벤트 루프(Event Loop)란?

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번으로

Call Stack

지금 실행 중인 함수들이 쌓이는 곳입니다.

void main() {     // 1. main이 스택에 쌓임
  greet("홍렬");  // 2. greet이 스택에 쌓임
}

void greet(String name) {
  print("안녕 $name"); // 3. print가 스택에 쌓임
                        // 4. print 끝 → 스택에서 제거
                        // 5. greet 끝 → 스택에서 제거
                        // 6. main 끝 → 스택에서 제거
}

Microtask Queue

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 처리

Event 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 나중

async/await은 내부적으로 이렇게 동작한다

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
// 값이 아닌 참조를 캡처했기 때문

Flutter에서 자주 만나는 패턴

setState가 왜 비동기처럼 동작하는가

setState(() {
  count++;
});
print(count); // count++는 이미 반영됨 — setState는 동기 실행
              // 하지만 실제 화면 업데이트는 다음 프레임(Event Queue)에서

BuildContext를 async 이후에 쓰면 안 되는 이유

// ❌ 위험한 코드
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, ...);
}

Timer와 메모리 누수

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 QueueFuture.value, .then(), await 완료 콜백높음
Event Queue타이머, I/O, 사용자 입력, Future.delayed낮음

마치며

싱글 스레드 → 이벤트 루프 → 클로저 캡처 → GC → 메모리 누수까지 전부 연결되는 흐름이 보이시나요?

이 개념들을 이해하고 나면 dispose()를 왜 꼭 해야 하는지, mounted 체크를 왜 해야 하는지, Timer.cancel()을 왜 빠뜨리면 안 되는지가 자연스럽게 납득이 됩니다.

profile
Flutter

0개의 댓글