iOS 앱은 메인 스레드 하나가 돌리는 이벤트 루프 위에서 산다. 이 장은 그 루프(RunLoop)가 무엇을 어떤 순서로 처리하는지, 그리고 이 구조 때문에 "메인 블로킹 = 먹통", "Timer는 부정확" 같은 현상이 왜 필연인지 원리부터 짚는다.
Q1. Main Thread가 block되면 앱이 터치에 반응하지 않는 이유는 무엇인가?
UI는 메인 스레드 하나가 돌리는 이벤트 루프 위에서 동작한다. 터치는 시스템이 메인 RunLoop의 입력 소스에 넣어 두고, UIKit이 hit-test → 제스처 인식 → touchesBegan(_:with:) 순으로 처리하는데, 이 처리는 메인 스레드가 루프로 돌아왔을 때만 일어난다. 내 코드가 메인에서 동기적으로 오래 돌면 루프로 못 돌아가고, 그 사이 도착한 이벤트는 큐에 쌓인 채 방치된다. 화면 갱신(레이아웃·CoreAnimation commit)도 루프 끝에서 일어나므로 화면까지 얼어붙는다. 즉 반응이 없는 건 이벤트가 "사라져서"가 아니라, 그것을 꺼내 dispatch할 스레드가 붙잡혀 있어서다.
CS 원리
대부분의 GUI 툴킷은 단일 스레드 이벤트 루프(single-threaded event loop) 모델을 쓴다. 하나의 스레드가 이벤트 큐에서 이벤트를 꺼내(dequeue) 해당 핸들러를 호출하고, 그 핸들러가 반환(return)한 뒤에야 다음 이벤트를 꺼낸다. 이걸 run-to-completion 시맨틱이라 부른다. 핸들러가 CPU를 양보하지 않고 오래 붙잡으면 루프는 다음 이벤트로 넘어가지 못한다 — 이건 버그가 아니라 이 모델의 정의 그 자체다. 병렬이 아니라 협력(cooperative)에 의존하기 때문에, 한 참가자가 협력을 어기면 전체가 멈춘다.
iOS에서는
메인 스레드는 메인 RunLoop를 돌린다. 터치/HID 이벤트는 시스템 프로세스(backboardd)가 mach 포트를 통해 앱으로 보내고, 이는 런루프의 포트 기반 입력 소스(source1)로 도착한다. 런루프가 이 소스를 서비스할 때 비로소 hit-testing, 제스처 인식기, responder chain을 통한 touchesBegan/Moved/Ended 전달이 일어난다. 그런데 내 콜백(예: 버튼 액션) 안에서 동기 네트워크나 무거운 계산을 하면, 런루프는 그 콜백에서 빠져나오지 못해 source1을 서비스할 수 없다. 게다가 레이아웃과 렌더링은 런루프가 잠들기 직전(kCFRunLoopBeforeWaiting 옵저버)의 CoreAnimation commit에서 일어나므로, 메인이 막히면 새 프레임 자체가 커밋되지 않아 화면이 정지한다. 앱 실행·전환 중 너무 오래 막히면 시스템 워치독이 0x8badf00d 코드로 앱을 강제 종료한다(Addressing Watchdog Terminations).
// ❌ 메인 콜백 안에서 동기 블로킹 → 런루프로 못 돌아감
@objc func didTapLoad() {
let data = try? Data(contentsOf: bigRemoteURL) // 동기 네트워크: 메인 정지
render(data) // 그동안 터치·스크롤·프레임 전부 멈춤
}
// 이 사이 도착한 터치는 큐에 쌓이고, 화면은 마지막 프레임에 얼어붙는다실험 · 도구
재현은 간단하다. 버튼 액션에서 Thread.sleep(forTimeInterval: 5)를 실행하거나 while true {}를 걸고 화면을 만져 본다. 관찰 지표는 (1) 터치 무반응, (2) 스크롤/애니메이션 정지, (3) 풀린 직후 밀린 터치가 몰아서 처리되는지 여부다. 도구로는 Instruments의 Time Profiler + Hangs 인스트루먼트로 메인 스레드가 오래 잡힌 구간을 찾고, Xcode Organizer의 Hangs 리포트로 실사용자 hang을 수집하며, os_signpost로 의심 구간을 마킹한다. 순수 계산 hang은 Main Thread Checker로는 안 잡히므로(그건 UIKit의 스레드 위반 감지용) 반드시 프로파일러를 쓴다.
프로젝트 적용
- 메인에서 동기 I/O·큰
Codable디코딩·이미지 디코드/리사이즈·정규식 대량 처리 금지 → 백그라운드 큐나await(off-main)로 옮긴다. - 어쩔 수 없이 무거우면 청크로 쪼개 각 청크 사이에 런루프로 돌아오게 한다(양보). 진행률 UI도 그래야 갱신된다.
- 앱 시작(
application(_:didFinishLaunching…))과 화면 전환 구간은 워치독 감시가 특히 빡세다 — 여기서의 동기 작업은 hang이 아니라 강제 종료로 이어진다.
"메인이 막히면 그동안의 터치는 유실된다"는 부정확하다. 대개는 큐에 쌓여 있다가 풀리면 몰려서 전달된다(단, 너무 오래되면 coalesce되거나 폐기될 수 있다). 반대로 "스피너가 도니까 메인은 안 막혔네"라는 판단도 위험하다 — UIActivityIndicatorView의 회전은 CoreAnimation 애니메이션이라, 일단 렌더 서버로 커밋된 뒤에는 메인이 블로킹돼도 렌더 서버가 계속 굴린다. 즉 스피너는 돌지만 터치·스크롤·새 프레임은 멈춘 상태일 수 있다(반대로, 스피너를 켠 바로 그 콜백 안에서 곧장 메인을 막으면 애니메이션이 커밋될 기회가 없어 아예 돌지 않는다).
꼬리 질문
- 메인이 아니라 임의의 백그라운드 스레드가 블로킹되면 UI는 영향받는가? 받지 않는다면 왜인가?
- 0.3초 블로킹과 20초 블로킹의 결과는 어떻게 다른가(hang vs 워치독 종료 vs frame drop)?
- 스크롤 도중 메인을 200ms 막는 것과 정지 화면에서 200ms 막는 것은 사용자 체감이 왜 다른가?
Q2. RunLoop는 무엇이며 왜 필요한가?
RunLoop는 위의 "이벤트 루프"를 실제로 구현한 객체다. 하나의 스레드에 묶여서 입력 소스·타이머·옵저버를 관리하고, 할 일이 없으면 mach_msg로 스레드를 재워 CPU를 0으로 만들었다가, 포트로 이벤트가 오면 깨워서 dispatch한다. 이게 필요한 이유는, 프로그램이 main()이 끝나면 종료해 버리기 때문이다. 인터랙티브 앱은 계속 살아 있으면서도 놀 때는 바쁘게 돌지 않고(busy-wait 아님) 효율적으로 자야 하는데, 그 "살아 있으면서 효율적으로 대기"를 담당하는 게 RunLoop다.
CS 원리
이벤트 루프의 핵심은 바쁜 대기(busy-wait)가 아니라 블로킹 대기(blocking wait)다. while(true) { poll() }처럼 계속 확인하면 CPU를 100% 태운다. 대신 커널에게 "이 이벤트 소스들 중 아무거나 준비되면 깨워 줘"라고 부탁하고 스레드를 재운다. 이벤트가 오면 커널이 깨우고, 처리 후 다시 잠든다. 이 패턴(다수의 이벤트 소스를 하나의 대기 지점에서 다중화)이 소위 reactor 패턴이며, RunLoop는 그 구체적 구현이다.
iOS에서는
Foundation의 RunLoop는 CoreFoundation의 CFRunLoop를 얇게 감싼 것이다. 앱의 메인 런루프는 UIApplicationMain이 시작시켜 주기 때문에 우리가 직접 돌릴 일이 없다. 중요한 함정: 보조 스레드는 기본적으로 런루프가 "돌고 있지" 않다. RunLoop.current는 그 스레드의 런루프 객체를 lazy하게 만들어 주지만, 누군가 run()을 호출해 실제로 순회를 시작하지 않으면 Timer나 포트 기반 소스가 아예 fire되지 않는다. 그래서 백그라운드 스레드에서 타이머를 걸었는데 안 울린다면 십중팔구 런루프를 안 돌린 것이다.
Thread.detachNewThread {
let t = Timer(timeInterval: 1, repeats: true) { _ in print("tick") }
RunLoop.current.add(t, forMode: .default)
// 여기서 클로저가 끝나면 스레드 종료 → 타이머는 한 번도 안 울린다
RunLoop.current.run() // 이 줄이 있어야 런루프가 돌며 타이머가 fire된다
}mach_msg로 잠들어 CPU를 쓰지 않는다. 이벤트가 다시 오면 이 순회를 반복한다.실험 · 도구
위 코드에서 RunLoop.current.run() 줄을 넣었다 뺐다 하면 "tick" 출력 여부가 갈린다. 또 앱이 완전히 idle일 때(아무 애니메이션·타이머 없음) Instruments Time Profiler로 메인 스레드를 보면 CPU 시간이 거의 잡히지 않는데, 이게 런루프가 mach_msg_trap에서 자고 있다는 증거다. LLDB에서 idle 상태 메인 스레드의 bt를 떠 보면 콜스택 바닥이 __CFRunLoopServiceMachPort → mach_msg로 끝나는 걸 확인할 수 있다.
프로젝트 적용
- 백그라운드 스레드에서
Timer·URLConnection류 델리게이트·포트 기반 소스가 필요하면 그 스레드의 런루프를 명시적으로 돌려야 한다. 이 요구가 부담이면DispatchSourceTimer처럼 런루프에 의존하지 않는 GCD 기반 도구를 쓴다. - 런루프를 직접
run()으로 돌리는 커스텀 스레드는 종료 조건 관리가 까다롭다 — 대부분의 앱 코드에서는 필요 없고, 필요해 보이면 먼저 GCD/Concurrency로 대체 가능한지 검토한다.
"RunLoop는 CPU를 계속 돌리는 무한 루프다"는 틀렸다. 할 일이 없으면 mach_msg에서 블로킹되어 CPU를 전혀 쓰지 않고 잔다. 또 "모든 스레드는 런루프가 돌고 있다"도 틀리다 — 실제로 돌고 있는 건 메인 스레드뿐이고, 다른 스레드는 명시적으로 시작하기 전까지는 런루프 객체만 있을 뿐 순회하지 않는다.
꼬리 질문
- GCD의 글로벌 큐가 쓰는 워커 스레드에는 런루프가 도는가? 왜 그런 설계인가?
RunLoop(Foundation)와CFRunLoop(CoreFoundation)의 관계, 그리고 스레드 안전성 측면에서 어느 쪽이 더 안전한가?- 런루프가 잠들어 있을 때 다른 스레드에서 그 런루프를 어떻게 깨우는가?
Q3. Main Thread의 RunLoop는 어떤 이벤트를 처리하는가?
메인 런루프가 서비스하는 것은 크게 셋이다. (1) 입력 소스(input source) — 포트 기반 source1(터치·HID 이벤트가 backboardd에서 mach 포트로 도착), 비포트 기반 source0(performSelector(onThread:), 일부 UIKit), 그리고 libdispatch가 심어 둔 메인 큐 배수 소스. (2) 타이머(Timer/CFRunLoopTimer). (3) 옵저버(observer) — 순회마다 autorelease pool을 push/pop하고, 잠들기 직전 CoreAnimation commit을 실행해 레이아웃·렌더를 flush한다. 이 모든 것은 현재 런루프 모드(default·tracking·common)에 등록된 것만 처리된다.
CS 원리
이벤트 루프가 다뤄야 할 "일감"은 성격이 다르다: 외부에서 비동기로 도착하는 것(네트워크·입력 장치), 시각 기반으로 발생하는 것(타이머), 그리고 순회 자체의 특정 시점에 훅(hook)을 걸고 싶은 것(정리 작업). 잘 설계된 이벤트 루프는 이 셋을 각각 소스 / 타이머 / 옵저버라는 1급 개념으로 분리해 등록·해제할 수 있게 한다. RunLoop가 바로 그 구조다.
iOS에서는
입력 소스는 두 종류다. source1은 mach 포트 기반이라 커널이 런루프를 직접 깨울 수 있다 — 그래서 터치·HID처럼 앱 밖에서 오는 이벤트가 이 경로를 탄다. source0은 비포트 기반이라 스스로 런루프를 못 깨우고, signal() 후 누군가 wakeUp()을 해 줘야 한다. 여기에 더해 libdispatch는 메인 큐를 배수하기 위한 소스를 런루프에 등록해 두고(Q5에서 상세), CADisplayLink는 화면 리프레시(vsync)에 맞춰 콜백을 넣는다. 옵저버 중 가장 중요한 둘은 autorelease pool 관리(순회 진입 시 push, 잠들기 전 pop → 임시 객체 해제 타이밍이 여기에 걸린다)와 CoreAnimation의 commit(잠들기 직전 kCFRunLoopBeforeWaiting에서 setNeedsLayout/Display로 쌓인 것을 한 번에 처리)이다.
실험 · 도구
모드 개념을 눈으로 확인하려면, 스크롤뷰가 있는 화면에서 .default 모드로만 등록한 Timer를 걸고 스크롤해 보라. 스크롤하는 동안(모드가 UITrackingRunLoopMode로 바뀜) 타이머가 멈췄다가 손을 떼면 다시 도는 게 관찰된다. 반대로 .common 모드로 등록하면 스크롤 중에도 계속 fire된다. LLDB로 idle 메인 스레드의 콜스택을 뜨면 __CFRunLoopRun 아래에 소스/타이머/옵저버를 서비스하는 내부 함수 이름들이 그대로 드러난다.
| 구분 | source0 (비포트) | source1 (포트 기반) |
|---|---|---|
| 런루프 직접 깨우기 | 불가 — signal 후 wakeUp 필요 | 가능 — 커널이 mach 포트로 깨움 |
| 대표 예 | performSelector(onThread:), 일부 UIKit | 터치·HID 이벤트, CFMachPort |
| 발생 위치 | 주로 앱 내부 | 주로 앱 외부(다른 프로세스/커널) |
source0/source1 구분은 CFRunLoop 수준의 내부 개념이라 UIKit 앱 코드에서 직접 다룰 일은 드물다. 하지만 "터치는 왜 앱이 자고 있어도 도착하는가"(커널이 포트로 깨우니까)를 설명할 때 이 구분이 핵심이 된다.
꼬리 질문
- autorelease pool을 런루프 옵저버가 순회마다 push/pop한다면, 루프 한 순회 안에서 대량으로 임시 객체를 만들면 메모리 피크가 왜 생기고 어떻게 줄이는가?
UITrackingRunLoopMode와NSDefaultRunLoopMode, 그리고commonModes의 관계는? "common"에 넣는다는 게 정확히 무슨 뜻인가?- 메인 큐(
DispatchQueue.main)의 작업은 어느 소스로 처리되며, 왜 스크롤 중에도 실행되는가?
Q4. 입력 이벤트, 비즈니스 로직, Layout, Rendering은 어떻게 이어지는가?
한 프레임은 렌더 루프(render loop)라는 파이프라인을 탄다. ① Event: 런루프가 터치를 받아 UIKit이 dispatch → 내 비즈니스 로직 실행(여기서 setNeedsLayout/Display·상태 변경). ② Commit: 런루프가 잠들기 직전 CoreAnimation이 쌓인 요청을 한 번에 처리 — layoutSubviews(레이아웃) → draw(디스플레이) → 레이어 트리를 인코딩해 렌더 서버(별도 프로세스)로 전송. ③④ Render Prepare/Execute: 렌더 서버가 GPU로 그림. ⑤ Display: 다음 vsync에 화면에 표시. 핵심은, 내 코드에서의 layout/draw 요청이 즉시 실행되지 않고 런루프 끝에서 합쳐져 한 번에 flush된다는 점이다.
CS 원리
그래픽 시스템은 보통 즉시 모드(immediate mode)가 아니라 보존 모드(retained mode)로 동작한다. 즉 "지금 당장 그려라"가 아니라 "무엇을 그릴지 상태를 기술해 두면, 시스템이 적절한 시점에 최적화해서 그린다". 그래서 상태를 열 번 바꿔도 실제 그리기는 프레임당 한 번이면 된다. 또 그리기는 vsync(디스플레이 새로고침 신호)에 동기화되어야 화면 찢김(tearing)이 없다. 이 두 원리 때문에 "요청 수집 → 한 번에 커밋 → vsync에 표시"라는 파이프라인이 자연스럽게 나온다.
iOS에서는
Apple은 이를 5단계 렌더 루프로 정리한다(Explore UI animation hitches and the render loop). Event와 Commit은 앱 프로세스의 메인 스레드에서, Render Prepare/Execute는 별도의 렌더 서버 프로세스에서, Display는 디스플레이 하드웨어에서 일어난다. 내가 setNeedsLayout()을 부르면 뷰가 "더럽다"고 표시만 될 뿐, 실제 layoutSubviews()는 Commit 단계에서 호출된다. 이 Commit은 런루프의 BeforeWaiting 옵저버가 CATransaction(암묵적 트랜잭션)을 커밋하며 트리거한다. 그래서 한 순회 안에서 setNeedsLayout을 100번 불러도 layoutSubviews는 딱 한 번 돈다. 이 파이프라인은 vsync 주기(보통 16.7ms, ProMotion에선 더 짧음)마다 겹쳐서(pipeline) 돌기 때문에, 한 단계라도 예산을 넘기면 프레임이 늦어 hitch(끊김)가 된다.
view.setNeedsLayout() // 즉시 layout 아님 — '더럽다' 표시만
view.setNeedsLayout() // 여러 번 불러도 표시만 반복
label.setNeedsDisplay()
// → 런루프 끝(Commit)에서 딱 한 번:
// layoutSubviews(1회) → draw → 레이어 트리 인코딩 → 렌더 서버 전송
// 지금 당장 레이아웃 결과가 필요할 때만(예: 크기 즉시 측정):
view.layoutIfNeeded() // Commit을 기다리지 않고 강제로 지금 layout실험 · 도구
Instruments의 Animation Hitches(또는 Core Animation) 인스트루먼트로 hitch time ratio(초당 누적 hitch 시간)를 측정한다. 이 값이 커질수록 사용자가 끊김을 확연히 느낀다. hitch가 Commit 단계(앱이 느림)에서 나는지 Render 단계(레이어가 너무 무거움)에서 나는지 구분하는 게 튜닝의 출발점이다. layoutSubviews에 os_signpost 구간을 걸어 두면 한 프레임에서 레이아웃이 몇 번, 얼마나 도는지 정량화된다.
프로젝트 적용
layoutSubviews·draw(_:)는 프레임 예산 안의 hot path다. 여기서 동기 I/O·무거운 계산·불필요한 뷰 재생성 금지.- 레이아웃이 즉시 필요할 때만
layoutIfNeeded()를 쓴다. 습관적으로 남발하면 프레임당 layout을 강제 여러 번 돌려 오히려 hitch를 만든다. - 오프스크린 렌더링(마스크·그림자·
cornerRadius+ 마스킹)은 Render 단계 비용을 키운다 — 필요 시shadowPath지정,shouldRasterize신중히 사용.
"setNeedsLayout()을 부르면 그 자리에서 layoutSubviews()가 실행된다"는 틀리다. 그건 표시(dirty flag)일 뿐, 실제 실행은 런루프 끝 Commit에서다. 또 "렌더링은 내 앱이 GPU로 직접 그린다"도 정확하지 않다 — 앱은 레이어 트리를 렌더 서버 프로세스에 넘기고, 실제 GPU 합성은 그쪽에서 한다. 그래서 앱 메인이 잠깐 바빠도 이미 커밋된 애니메이션은 렌더 서버가 계속 굴릴 수 있다.
꼬리 질문
- 메인 스레드가 막혔는데도 이미 시작된 CoreAnimation 기본 애니메이션은 계속 도는 경우가 있는 이유는(렌더 서버 관점)?
- Commit hitch와 Render hitch를 각각 어떤 지표·도구로 구분하고, 대응 방법은 어떻게 달라지는가?
- ProMotion(120Hz)에서 프레임 예산은 어떻게 바뀌며, 가변 주사율이 튜닝 목표에 주는 영향은?
Q5. DispatchQueue.main.async로 보낸 작업은 언제 실행되는가?
메인 큐는 별도의 스레드가 아니라 메인 스레드가 메인 런루프를 통해 배수(drain)한다. DispatchQueue.main.async { ... }로 넣은 블록은 지금 실행 중인 콜스택이 런루프로 풀린 다음, 런루프가 메인 큐 소스를 서비스할 때 실행된다. 즉 "다음 줄 바로 다음"이 아니라 "이번 콜백이 끝나고 런루프로 돌아온 뒤"다. libdispatch가 메인 큐 배수 소스를 common 모드에 등록해 두기 때문에, .default 모드 Timer와 달리 스크롤 중에도 실행된다. 그리고 당연히 메인 스레드에서 돈다(백그라운드 아님).
CS 원리
큐에 비동기로 넣는다는 건 "지금 실행 흐름을 멈추고 저리로 점프"가 아니라 "나중에 처리할 목록의 뒤에 붙인다"는 뜻이다. 그래서 async로 넣은 직후의 코드가 먼저 끝나고, 넣은 블록은 현재 실행 단위가 완료되어 제어가 스케줄러(여기선 런루프)로 돌아온 뒤 실행된다. 이는 이벤트 루프에서 "자기 자신에게 이벤트를 post"하는 것과 같은 의미다.
iOS에서는
메인 큐는 특별하다. GCD의 다른 큐는 워커 스레드가 처리하지만, 메인 큐는 반드시 메인 스레드에서 실행되어야 하므로, libdispatch가 메인 런루프에 배수용 소스를 심어 둔다(런루프가 매 순회 _dispatch_main_queue_callback_4CF를 호출해 메인 큐를 비운다). 그래서 DispatchQueue.main.async 블록의 실행 시점은 런루프의 다음 서비스 지점이다. 실무에서 이게 유용한 이유: 백그라운드 스레드에서 계산을 끝내고 UI 업데이트를 DispatchQueue.main.async로 넘기는 표준 패턴이 안전한 건, 그 블록이 결국 메인 스레드에서, 현재 진행 중인 UIKit 콜백과 겹치지 않게 순차적으로 실행되기 때문이다.
print("A")
DispatchQueue.main.async { print("C") } // 런루프 다음 서비스 지점에 예약
print("B")
// 출력: A B C
// C는 지금 콜스택(A→예약→B)이 런루프로 풀린 뒤 실행된다.
// 흔한 활용: 지금 순회의 layout이 끝난 다음 프레임에서 후속 작업
DispatchQueue.main.async { self.scrollToBottom() }실험 · 도구
위 A/B/C 예제로 순서를 확인한다. 모드 차이를 보려면 스크롤뷰 위에서 반복 작업을 .default 모드 Timer로 넣은 버전과 DispatchQueue.main.async(내부적으로 common 모드)로 넣은 버전을 비교한다 — 스크롤하는 동안 전자는 멈추지만 후자는 계속 돈다. LLDB에서 async 블록에 브레이크포인트를 걸고 bt를 뜨면 콜스택 바닥이 __CFRunLoop… → 메인 큐 배수 경로로 이어지는 걸 볼 수 있다.
프로젝트 적용
- UI 업데이트는 "메인에서 실행"이 핵심 규칙이다. 이미 메인인 게 확실하면 굳이
async로 한 순회 미루지 말고 바로 실행한다(불필요한 지연·깜빡임 방지). - "현재 layout이 반영된 뒤"에 뭔가 하고 싶을 때
DispatchQueue.main.async로 한 틱 미루는 트릭이 유용하다(예: 셀 크기 확정 후 스크롤). 단, 이는 타이밍 의존이라 남용하면 취약해진다. - 같은 목적이면 Swift Concurrency의
@MainActor/await MainActor.run이 의도를 더 명확히 드러낸다.
"DispatchQueue.main.async는 백그라운드에서 돈다"는 완전히 틀리다 — 메인 큐 = 메인 스레드다. global().async와 헷갈린 것이다. 또 "async 블록은 넣은 그 순간 바로 다음에 실행된다"도 틀리다 — 현재 콜스택이 런루프로 풀려야 실행된다. 그리고 "정확한 지연 시간을 준다"는 오해도 있는데, asyncAfter조차 최소 지연 보장일 뿐 런루프가 바쁘면 더 늦게 실행된다(Q6과 같은 이유).
꼬리 질문
- 이미 메인 스레드에서
DispatchQueue.main.sync { }를 부르면 무슨 일이 일어나는가? 왜 그런가? - 같은 순회에서
DispatchQueue.main.async로 넣은 블록과, 그 순회의 CoreAnimation commit(layout)은 어느 것이 먼저 실행되는가? 보장되는가? @MainActor컨텍스트로의awaithop과DispatchQueue.main.async는 실행 시점 관점에서 어떻게 같고 다른가?
Q6. Timer는 요청한 시간에 정확히 실행되는가?
아니다. Timer는 실시간 타이머가 아니라 런루프가 챙겨 주는 예약이다. 정확히 못 맞추는 이유는 넷이다. (1) 런루프가 다른 일로 바쁘면 그 일이 끝난 뒤에야 fire된다. (2) 등록한 모드가 현재 모드와 안 맞으면(예: .default 타이머는 스크롤 중 tracking 모드에서 멈춤) 아예 안 울린다. (3) tolerance(허용 오차)와 시스템의 전력 최적화 coalescing으로 조금 늦춰 발화한다. (4) 앱이 백그라운드로 suspend되면 정지한다. 정밀함이 필요하면 화면은 CADisplayLink(vsync 동기), 그 외는 DispatchSourceTimer를 쓴다.
CS 원리
런루프 기반 타이머는 "이 시각에 인터럽트를 걸어라"가 아니라 "이 시각이 지났으면 다음에 확인할 때 알려 줘"에 가깝다. 런루프는 소스/타이머를 순차적으로 처리하므로, 한 콜백이 오래 걸리면 타이머 확인 자체가 늦어진다. 그래서 이런 타이머는 soft timer이고, 하드웨어 인터럽트 기반의 real-time 보장과는 다르다. 대신 반복 타이머의 누적 드리프트는 막도록 설계된다 — 다음 발화 시각을 "이전 실제 발화 + 간격"이 아니라 "원래 스케줄 기준"으로 다시 계산하기 때문에, 개별 발화는 흔들려도 장기 평균 주기는 유지된다.
iOS에서는
Timer(내부적으로 CFRunLoopTimer)는 tolerance 속성을 가진다. 기본값은 0(추가 여유 없음)이지만, 값을 주면 시스템이 그 범위 안에서 발화를 뒤로 미뤄 여러 타이머를 묶어(coalesce) 실행함으로써 전력을 아낀다(Apple 권장: 반복 타이머는 간격의 10% 정도 tolerance). tolerance가 0이어도 정시 발화는 보장되지 않는다 — 런루프 점유와 모드 문제가 남기 때문이다. 모드 문제가 특히 함정인데, Timer.scheduledTimer(...)는 .default 모드에 등록되므로 사용자가 스크롤하는 동안(UITrackingRunLoopMode)에는 멈춘다. 스크롤 중에도 돌리려면 .common 모드로 직접 add해야 한다. 마지막으로 앱이 백그라운드로 넘어가 suspend되면 타이머는 정지하고, 포그라운드 복귀 시점에 밀린 것이 한 번만 처리된다.
let t = Timer(timeInterval: 1.0, repeats: true) { _ in tick() }
t.tolerance = 0.1 // 10% 여유 → 시스템이 묶어서 전력 절약
RunLoop.main.add(t, forMode: .common) // 스크롤(tracking) 중에도 fire
// scheduledTimer 는 .default 모드로 등록 → 스크롤 중 멈춘다(주의)
let s = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in tick() }tracking) 구간에 걸린 .default 모드 타이머는 아예 멈췄다가 풀린 뒤 밀려서 한 번에 처리된다.실험 · 도구
CFAbsoluteTimeGetCurrent()로 각 발화의 실제 시각을 기록해 요청 간격과의 편차(지터)를 로그로 남긴다. 스크롤뷰 위에서 .default vs .common 모드 타이머를 나란히 돌리며 스크롤하면 전자만 멈추는 게 관찰된다. 메인을 인위적으로 잠깐 막고(무거운 작업) 타이머가 그만큼 밀리는지 확인한다. 화면 애니메이션이라면 CADisplayLink로 바꿔 vsync에 붙는지(프레임과 어긋나지 않는지) Instruments로 검증한다.
| 메커니즘 | 기준·정밀도 | 주 용도 | 비고 |
|---|---|---|---|
Timer | 런루프 의존, 지터 큼 | 주기적 일반 작업 | 모드·tolerance·suspend 영향 |
CADisplayLink | 화면 vsync에 동기 | UI 애니메이션·프레임 단위 | 프레임과 안 어긋남 |
DispatchSourceTimer | GCD 기반, leeway 지정 | 백그라운드 스케줄링 | 런루프 없어도 동작 |
프로젝트 적용
- UI 애니메이션·게이지 갱신처럼 화면과 맞물리는 주기 작업은
Timer가 아니라CADisplayLink를 쓴다(프레임과 동기, ProMotion 자동 대응). - 백그라운드 주기 작업은
DispatchSourceTimer가 더 낫다 — 런루프에 의존하지 않고leeway로 전력/정밀도 트레이드오프를 명시할 수 있다. - 스크롤 중에도 멈추면 안 되는 타이머는 반드시
.common모드로 등록한다. 반대로 카운트다운 정확도가 중요하면 발화에 의존하지 말고 목표 시각과 현재 시각의 차이를 매 발화마다 다시 계산한다.
"Timer는 설정한 간격마다 정확히 울린다"는 틀리다 — 런루프가 바쁘거나 모드가 안 맞으면 늦거나 멈춘다. "tolerance를 0으로 두면 정확해진다"도 오해다. tolerance 0은 추가 여유를 안 준다는 뜻일 뿐, 런루프 점유로 인한 지연까지 없애 주지는 못한다. 또 "반복 타이머는 시간이 지날수록 점점 밀려 누적 오차가 커진다"도 일반적으로는 틀리다 — CFRunLoopTimer는 원래 스케줄 기준으로 다음 발화를 재계산해 누적 드리프트를 막는다(개별 발화만 흔들린다).
꼬리 질문
- 1초 카운트다운을 10분간
Timer로 돌렸을 때, 개별 지터는 있어도 총 경과가 크게 안 틀리는 이유는? CADisplayLink와Timer는 정밀도·용도 면에서 어떻게 다르며, 60Hz와 120Hz 기기에서 콜백 빈도는 어떻게 달라지는가?- 앱이 백그라운드에 있을 때 정확한 시각에 뭔가를 하려면
Timer대신 무엇을 써야 하는가(로컬 알림/백그라운드 태스크)?