← Study Hub

07 · RunLoop와 이벤트 처리

메인 스레드의 이벤트 루프가 터치·타이머·레이아웃·렌더링을 어떻게 한 순회로 엮는가
진행률
0 / 0 완료

← 챕터 목록

iOS 앱은 메인 스레드 하나가 돌리는 이벤트 루프 위에서 산다. 이 장은 그 루프(RunLoop)가 무엇을 어떤 순서로 처리하는지, 그리고 이 구조 때문에 "메인 블로킹 = 먹통", "Timer는 부정확" 같은 현상이 왜 필연인지 원리부터 짚는다.

Q1. Main Thread가 block되면 앱이 터치에 반응하지 않는 이유는 무엇인가?

🔑 30초 답변

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).

메인을 붙잡아 먹통을 만드는 전형 (Swift)
// ❌ 메인 콜백 안에서 동기 블로킹 → 런루프로 못 돌아감
@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의 스레드 위반 감지용) 반드시 프로파일러를 쓴다.

프로젝트 적용

⚠️ 흔한 오해

"메인이 막히면 그동안의 터치는 유실된다"는 부정확하다. 대개는 큐에 쌓여 있다가 풀리면 몰려서 전달된다(단, 너무 오래되면 coalesce되거나 폐기될 수 있다). 반대로 "스피너가 도니까 메인은 안 막혔네"라는 판단도 위험하다 — UIActivityIndicatorView의 회전은 CoreAnimation 애니메이션이라, 일단 렌더 서버로 커밋된 뒤에는 메인이 블로킹돼도 렌더 서버가 계속 굴린다. 즉 스피너는 돌지만 터치·스크롤·새 프레임은 멈춘 상태일 수 있다(반대로, 스피너를 켠 바로 그 콜백 안에서 곧장 메인을 막으면 애니메이션이 커밋될 기회가 없어 아예 돌지 않는다).

꼬리 질문

Q2. RunLoop는 무엇이며 왜 필요한가?

🔑 30초 답변

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되지 않는다. 그래서 백그라운드 스레드에서 타이머를 걸었는데 안 울린다면 십중팔구 런루프를 안 돌린 것이다.

보조 스레드의 Timer는 run() 없이는 안 울린다 (Swift)
Thread.detachNewThread {
    let t = Timer(timeInterval: 1, repeats: true) { _ in print("tick") }
    RunLoop.current.add(t, forMode: .default)
    // 여기서 클로저가 끝나면 스레드 종료 → 타이머는 한 번도 안 울린다
    RunLoop.current.run()   // 이 줄이 있어야 런루프가 돌며 타이머가 fire된다
}
이벤트 도착 → 런루프가 깨어남 (wake) 예약된 타이머(timer) fire 처리 입력 소스 처리 — source0 / source1 옵저버 통지 · CoreAnimation commit 할 일 없음 → mach_msg 로 스레드 잠듦 (CPU 0) 다음 순회
런루프의 한 순회. 이벤트가 오면 깨어나 타이머·소스를 처리하고 옵저버에 알린 뒤, 할 일이 없으면 mach_msg로 잠들어 CPU를 쓰지 않는다. 이벤트가 다시 오면 이 순회를 반복한다.

실험 · 도구

위 코드에서 RunLoop.current.run() 줄을 넣었다 뺐다 하면 "tick" 출력 여부가 갈린다. 또 앱이 완전히 idle일 때(아무 애니메이션·타이머 없음) Instruments Time Profiler로 메인 스레드를 보면 CPU 시간이 거의 잡히지 않는데, 이게 런루프가 mach_msg_trap에서 자고 있다는 증거다. LLDB에서 idle 상태 메인 스레드의 bt를 떠 보면 콜스택 바닥이 __CFRunLoopServiceMachPortmach_msg로 끝나는 걸 확인할 수 있다.

프로젝트 적용

⚠️ 흔한 오해

"RunLoop는 CPU를 계속 돌리는 무한 루프다"는 틀렸다. 할 일이 없으면 mach_msg에서 블로킹되어 CPU를 전혀 쓰지 않고 잔다. 또 "모든 스레드는 런루프가 돌고 있다"도 틀리다 — 실제로 돌고 있는 건 메인 스레드뿐이고, 다른 스레드는 명시적으로 시작하기 전까지는 런루프 객체만 있을 뿐 순회하지 않는다.

꼬리 질문

Q3. Main Thread의 RunLoop는 어떤 이벤트를 처리하는가?

🔑 30초 답변

메인 런루프가 서비스하는 것은 크게 셋이다. (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로 쌓인 것을 한 번에 처리)이다.

터치·HID → source1 (mach port) Timer (CFRunLoopTimer) performSelector → source0 DispatchQueue.main (dispatch source) CADisplayLink (vsync) 메인 RunLoop modes: default · tracking · common 옵저버 · autorelease pool 옵저버 · CA commit → 렌더
메인 런루프는 여러 입력원(왼쪽)을 받아 처리하고, 순회의 특정 시점에 옵저버(오른쪽)로 autorelease pool 정리와 CoreAnimation commit을 실행한다. 어떤 항목이 처리되는지는 현재 모드에 등록됐는지에 달려 있다.

실험 · 도구

모드 개념을 눈으로 확인하려면, 스크롤뷰가 있는 화면에서 .default 모드로만 등록한 Timer를 걸고 스크롤해 보라. 스크롤하는 동안(모드가 UITrackingRunLoopMode로 바뀜) 타이머가 멈췄다가 손을 떼면 다시 도는 게 관찰된다. 반대로 .common 모드로 등록하면 스크롤 중에도 계속 fire된다. LLDB로 idle 메인 스레드의 콜스택을 뜨면 __CFRunLoopRun 아래에 소스/타이머/옵저버를 서비스하는 내부 함수 이름들이 그대로 드러난다.

구분source0 (비포트)source1 (포트 기반)
런루프 직접 깨우기불가 — signal 후 wakeUp 필요가능 — 커널이 mach 포트로 깨움
대표 예performSelector(onThread:), 일부 UIKit터치·HID 이벤트, CFMachPort
발생 위치주로 앱 내부주로 앱 외부(다른 프로세스/커널)
📝 노트

source0/source1 구분은 CFRunLoop 수준의 내부 개념이라 UIKit 앱 코드에서 직접 다룰 일은 드물다. 하지만 "터치는 왜 앱이 자고 있어도 도착하는가"(커널이 포트로 깨우니까)를 설명할 때 이 구분이 핵심이 된다.

꼬리 질문

Q4. 입력 이벤트, 비즈니스 로직, Layout, Rendering은 어떻게 이어지는가?

🔑 30초 답변

한 프레임은 렌더 루프(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(끊김)가 된다.

앱 프로세스 · 메인 스레드 Render Server 프로세스 디스플레이 ① Event 터치·제스처 처리 ② Commit layout · display ③ Render Prepare ④ Render Execute (GPU) ⑤ Display (vsync) ② Commit 은 런루프 끝(BeforeWaiting 옵저버)에서 한 번에 flush 된다. → setNeedsLayout 을 여러 번 불러도 그 순회에 layoutSubviews 는 1회. 각 단계는 vsync 주기에 맞춰 파이프라인처럼 겹쳐 돈다 → 한 단계라도 예산 초과 시 hitch.
렌더 루프 5단계. 앱은 Event와 Commit까지만 책임지고, 그 결과(레이어 트리)를 렌더 서버로 넘긴다. Commit이 런루프 끝에서 한 번에 일어나므로 여러 layout 요청이 합쳐진다.
layout 요청은 즉시가 아니라 Commit에서 합쳐진다 (Swift)
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 단계(레이어가 너무 무거움)에서 나는지 구분하는 게 튜닝의 출발점이다. layoutSubviewsos_signpost 구간을 걸어 두면 한 프레임에서 레이아웃이 몇 번, 얼마나 도는지 정량화된다.

프로젝트 적용

⚠️ 흔한 오해

"setNeedsLayout()을 부르면 그 자리에서 layoutSubviews()가 실행된다"는 틀리다. 그건 표시(dirty flag)일 뿐, 실제 실행은 런루프 끝 Commit에서다. 또 "렌더링은 내 앱이 GPU로 직접 그린다"도 정확하지 않다 — 앱은 레이어 트리를 렌더 서버 프로세스에 넘기고, 실제 GPU 합성은 그쪽에서 한다. 그래서 앱 메인이 잠깐 바빠도 이미 커밋된 애니메이션은 렌더 서버가 계속 굴릴 수 있다.

꼬리 질문

Q5. DispatchQueue.main.async로 보낸 작업은 언제 실행되는가?

🔑 30초 답변

메인 큐는 별도의 스레드가 아니라 메인 스레드가 메인 런루프를 통해 배수(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 콜백과 겹치지 않게 순차적으로 실행되기 때문이다.

async 블록은 콜스택이 런루프로 풀린 뒤 실행된다 (Swift)
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… → 메인 큐 배수 경로로 이어지는 걸 볼 수 있다.

프로젝트 적용

⚠️ 흔한 오해

"DispatchQueue.main.async는 백그라운드에서 돈다"는 완전히 틀리다 — 메인 큐 = 메인 스레드다. global().async와 헷갈린 것이다. 또 "async 블록은 넣은 그 순간 바로 다음에 실행된다"도 틀리다 — 현재 콜스택이 런루프로 풀려야 실행된다. 그리고 "정확한 지연 시간을 준다"는 오해도 있는데, asyncAfter조차 최소 지연 보장일 뿐 런루프가 바쁘면 더 늦게 실행된다(Q6과 같은 이유).

꼬리 질문

Q6. Timer는 요청한 시간에 정확히 실행되는가?

🔑 30초 답변

아니다. 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되면 타이머는 정지하고, 포그라운드 복귀 시점에 밀린 것이 한 번만 처리된다.

tolerance와 모드까지 챙긴 Timer 설정 (Swift)
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() }
UITracking (스크롤) default 타이머 정지 요청 실제 t0 t1 t2 t3 t4 t2·t3 밀려서 합쳐짐
요청 시각(위)과 실제 발화(아래). 평소엔 약간의 지터로 살짝 늦고, 스크롤(tracking) 구간에 걸린 .default 모드 타이머는 아예 멈췄다가 풀린 뒤 밀려서 한 번에 처리된다.

실험 · 도구

CFAbsoluteTimeGetCurrent()로 각 발화의 실제 시각을 기록해 요청 간격과의 편차(지터)를 로그로 남긴다. 스크롤뷰 위에서 .default vs .common 모드 타이머를 나란히 돌리며 스크롤하면 전자만 멈추는 게 관찰된다. 메인을 인위적으로 잠깐 막고(무거운 작업) 타이머가 그만큼 밀리는지 확인한다. 화면 애니메이션이라면 CADisplayLink로 바꿔 vsync에 붙는지(프레임과 어긋나지 않는지) Instruments로 검증한다.

메커니즘기준·정밀도주 용도비고
Timer런루프 의존, 지터 큼주기적 일반 작업모드·tolerance·suspend 영향
CADisplayLink화면 vsync에 동기UI 애니메이션·프레임 단위프레임과 안 어긋남
DispatchSourceTimerGCD 기반, leeway 지정백그라운드 스케줄링런루프 없어도 동작

프로젝트 적용

⚠️ 흔한 오해

"Timer는 설정한 간격마다 정확히 울린다"는 틀리다 — 런루프가 바쁘거나 모드가 안 맞으면 늦거나 멈춘다. "tolerance를 0으로 두면 정확해진다"도 오해다. tolerance 0은 추가 여유를 안 준다는 뜻일 뿐, 런루프 점유로 인한 지연까지 없애 주지는 못한다. 또 "반복 타이머는 시간이 지날수록 점점 밀려 누적 오차가 커진다"도 일반적으로는 틀리다 — CFRunLoopTimer는 원래 스케줄 기준으로 다음 발화를 재계산해 누적 드리프트를 막는다(개별 발화만 흔들린다).

꼬리 질문

출처 · 참고자료