본문 바로가기
C.W.K.
Stream
Lesson 01 of 07 · published

메인 스레드는 문이 하나뿐인 방이야

~15 min · concurrency, main-thread, mainactor, responsiveness

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"스크러버가 굼떴어. 혼자 느린 코드는 하나도 없었어. 다들 같은 문 앞에 줄 서 있었을 뿐이야."

화면은 스레드 하나가 쥐고 있어

AppKit이랑 UIKit은 스레드 안전하지 않아. 뷰, 창, 리스폰더 체인, 그리고 걔네 상태 대부분은 메인 스레드에서만 만질 수 있어. 메인 스레드는 앱의 런 루프를 돌려. 입력 이벤트 하나를 받고, 네 코드가 반응하게 하고, 레이아웃을 잡고 화면을 그린 다음, 다음 이벤트를 받으러 가. 네 코드가 메인 스레드를 1밀리초 붙잡으면 그 1밀리초 동안 클릭도 처리 안 되고 프레임도 안 그려져. 몇백 밀리초 붙잡으면 앱이 고장 난 것 같고, 몇 초 붙잡으면 macOS가 무지개 커서를 띄워.

Swift는 그 스레드에 컴파일러가 알아보는 이름을 붙여줬어. @MainActor 전역 액터야. @MainActor가 붙은 코드는 메인 스레드에서 돌고, 격리 안 된 코드가 거길 동기로 건드리는 걸 컴파일러가 막아. SwiftUI 뷰는 메인 액터에 격리돼 있고, SDK의 AppKit, UIKit 뷰 타입도 마찬가지야. 규칙이 주석에서 끝나지 않고 타입 검사기가 지키게 되는 거지.

방이 붐비는 이유

가족의 Mac 비디오 플레이어가 스크러버를 끌 때마다 굼떴어. 혼자 느린 함수는 없었어. 두 가지 패턴이 메인 스레드 문 앞에 줄을 서고 있었지.

  • 관찰 대신 폴링. 플레이어가 깨어날 때마다, 그리고 200ms 타이머마다 엔진 속성 70~100개를 메인 스레드에서 동기로 다시 읽었어. 읽을 때마다 재생 엔진이 안전한 지점에 올 때까지 기다렸는데, 탐색 중이면 재생 루프 한 바퀴를 통째로 기다린 거야. 재보니 폴링 한 번에 10ms쯤 걸렸고 초당 26번 돌았어. 메인 스레드 시간의 4분의 1이 여기서 날아갔고, 그 쓰기가 일으킨 SwiftUI 갱신까지 얹혔지. 고친 방법은 관찰이야. 엔진이 바뀐 값만 밀어주게 하고, 값이 진짜 달라졌을 때만 UI를 갱신했어.
  • 화면 기다리기. 비디오 프레임을 AppKit 그리기 호출 안에서 렌더링했는데, 그 렌더 호출은 프레임을 표시할 시각까지 일부러 잠들어. 그래서 4K 재생의 43% 동안 메인 스레드가 잠들어 있었어. 렌더링을 Core Animation 레이어가 쓰는 전용 큐로 옮기니까, 같은 재생 동안 메인 스레드가 94%는 놀고 있었어.

고치는 방향

메인 액터엔 걔만 할 수 있는 일만 둬. UI 상태 바꾸기, 뷰 다루기 같은 거. 기다리거나 훑거나 디코딩하거나 막히는 일은 전부 다른 데로 보내고, 결과만 들고 돌아와. Swift 동시성에서 그 이동이 await야. @MainActor 함수가 멈춰 있는 동안 일은 메인 액터 밖에서 돌고, 값이 나오면 메인 액터로 돌아와서 이어가. 멈춰 있는 동안 메인 스레드는 입력을 처리할 수 있어.

Code

느린 일은 메인 액터 밖으로, UI는 다시 메인 액터에서·swift
import Foundation

@MainActor
final class StatusModel {
    var line = "idle"
}

nonisolated func scanDisk() -> String {
    Thread.sleep(forTimeInterval: 0.2)          // stands in for slow, synchronous work
    return "412 files"
}

/// Blocking work gets a thread that is allowed to block, not the cooperative pool
/// (the last lesson of this track measures why).
nonisolated func scanDiskOffMain() async -> String {
    await withCheckedContinuation { continuation in
        DispatchQueue.global(qos: .utility).async {
            continuation.resume(returning: scanDisk())
        }
    }
}

@MainActor
func refresh(_ model: StatusModel) async {
    model.line = "scanning…"                   // on the main actor
    let summary = await scanDiskOffMain()      // suspended: a dispatch thread blocks, the main actor is free
    model.line = summary                       // back on the main actor
}

External links

Exercise

Spark에 StatusModel이랑 refresh 함수를 넣어. 먼저 @MainActor 함수에서 scanDisk()를 바로 부르는 순진한 버전을 만들고, 메인 액터가 얼마나 막히는지 재봐. 그러고 나서 디스패치 큐로 넘기는 버전으로 바꾸고, 스캔하는 동안에도 다른 메인 액터 일(50ms마다 시각을 찍는 두 번째 태스크)이 계속 돈다는 걸 보여줘.
Hint
refresh를 부르기 전에 Task { @MainActor in … } 루프를 하나 더 띄워. 순진한 버전에선 스캔 내내 시각 찍기가 멈추고, 고친 버전에선 계속 찍혀. refresh가 멈춰 있는 동안 메인 액터가 비어 있으니까.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.