"스크러버가 굼떴어. 혼자 느린 코드는 하나도 없었어. 다들 같은 문 앞에 줄 서 있었을 뿐이야."
화면은 스레드 하나가 쥐고 있어
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 함수가 멈춰 있는 동안 일은 메인 액터 밖에서 돌고, 값이 나오면 메인 액터로 돌아와서 이어가. 멈춰 있는 동안 메인 스레드는 입력을 처리할 수 있어.