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 포트로 앱에 보내는데, 그 포트를 실제로 수신하는 것은 UIKit이 따로 돌리는 com.apple.uikit.eventfetch-thread의 런루프(source1)이고, 이 스레드가 메인 런루프의 source0(__eventFetcherSourceCallback)을 signal·wakeUp 해서 이벤트를 넘긴다. 메인 런루프가 그 source0을 서비스할 때 비로소 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)로 옮긴다.
import UIKit
// 메인이 런루프로 돌아가지 못하면 터치·스크롤·프레임이 전부 멈춘다.
// 아래 네 가지가 메인에서 자주 발견되는 범인이다.
@MainActor
final class FeedViewController: UIViewController {
private var items: [Item] = []
private let tableView = UITableView()
// ❌ ① 동기 I/O ② 큰 Codable 디코딩 ③ 이미지 디코드 ④ 정규식 대량 처리
func loadBad() {
let data = try! Data(contentsOf: bigURL) // ①
items = try! JSONDecoder().decode([Item].self, from: data) // ②
let image = UIImage(contentsOfFile: path)! // ③
_ = items.filter { $0.title.range(of: pattern,
options: .regularExpression) != nil } // ④
_ = image
}
// ✅ 무거운 일은 전부 메인 밖으로, 반영만 메인에서
func load() async {
let url = self.bigURL // self 캡처를 피해 값만 넘긴다
let decoded: [Item] = await Task.detached(priority: .userInitiated) {
// ⚠️ Task.detached도 같은 협력 스레드 풀에서 돈다 — 동기 I/O로 블로킹하면
// 풀 스레드를 잠식한다. 실제 코드에서는 전용 큐나 async API로 읽어야 한다.
guard let data = try? Data(contentsOf: url) else { return [] }
return (try? JSONDecoder().decode([Item].self, from: data)) ?? []
}.value
items = decoded // ✅ 메인에서는 대입과 reload만
tableView.reloadData()
}
private let bigURL = URL(fileURLWithPath: "/big.json")
private let path = "/img.jpg"
private let pattern = "[a-z]+"
}
// ✅ 개발 중 방어선 — 메인에서 부르면 즉시 크래시로 잡는다
func loadFromDisk(_ url: URL) throws -> Data {
dispatchPrecondition(condition: .notOnQueue(.main))
return try Data(contentsOf: url)
}
struct Item: Decodable { let title: String }② 어쩔 수 없이 무거우면 청크로 쪼개 각 청크 사이에 런루프로 돌아오게 한다(양보). 진행률 UI도 그래야 갱신된다.
import UIKit
// 메인에서만 할 수 있는 작업(대량 뷰 삽입, 스냅샷 적용)은 옮길 수 없다.
// 대신 중간중간 런루프로 돌려보내면 터치와 렌더가 처리된다.
@MainActor
final class BulkImporter {
private let progressView = UIProgressView()
// ❌ 한 번에 전부 — 진행률 UI도 안 움직이고 터치도 안 먹는다
func importAllBad(_ rows: [Row]) {
for row in rows {
insert(row)
progressView.progress += 1 / Float(rows.count) // 화면엔 반영 안 된다
}
}
// ✅ 청크마다 양보 — 진행률이 실제로 그려지고 취소 버튼도 눌린다
func importAll(_ rows: [Row]) async {
let chunkSize = 200
var index = 0
while index < rows.count {
let end = min(index + chunkSize, rows.count)
for i in index..<end { insert(rows[i]) }
index = end
progressView.progress = Float(index) / Float(rows.count)
await Task.yield() // ✅ 런루프로 돌아가 이벤트·렌더를 처리시킨다
if Task.isCancelled { return }
}
}
// ✅ 청크 크기는 '한 프레임 예산' 기준으로 잡는다
func importAdaptive(_ rows: [Row]) async {
let budget = Duration.milliseconds(8) // 120Hz 기준
var index = 0
while index < rows.count {
let start = ContinuousClock.now
while index < rows.count, ContinuousClock.now - start < budget {
insert(rows[index]); index += 1
}
await Task.yield()
}
}
private func insert(_ r: Row) {}
}
struct Row {}③ 앱 시작(application(_:didFinishLaunching…))과 화면 전환 구간은 워치독 감시가 특히 빡세다 — 여기서의 동기 작업은 hang이 아니라 강제 종료로 이어진다.
import UIKit
// 워치독(0x8badf00d)은 특정 구간에서 더 짧은 시간만 허용한다.
// 여기서의 동기 작업은 '느림'이 아니라 '강제 종료'로 끝난다.
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ app: UIApplication,
didFinishLaunchingWithOptions o: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// ❌ 여기서 하면 안 되는 것들
// · 동기 네트워크 호출
// · Core Data 무거운 마이그레이션
// · 큰 파일 읽기·파싱
// · 서드파티 SDK의 동기 초기화 여러 개
// ✅ 첫 화면에 꼭 필요한 것만 동기로
setupWindow()
// ✅ 나머지는 전부 뒤로 미룬다
Task.detached(priority: .utility) {
await Analytics.configure()
await RemoteConfig.fetch()
await CrashReporter.start()
}
return true
}
private func setupWindow() {}
}
// ✅ 마이그레이션처럼 오래 걸리는 건 스플래시를 띄우고 백그라운드에서
@MainActor
final class LaunchCoordinator {
func start() async {
showSplash()
await Task.detached(priority: .userInitiated) {
try? await Store.migrateIfNeeded() // 메인을 막지 않는다
}.value
showMainScreen()
}
private func showSplash() {}
private func showMainScreen() {}
}
// ✅ 감시 구간을 계측해 회귀를 잡는다
func measureLaunchPhase(_ name: String, _ body: () -> Void) {
let elapsed = ContinuousClock().measure(body)
if elapsed > .milliseconds(200) {
assertionFailure("\(name)이 \(elapsed) 소요 — 워치독 위험")
}
}
enum Analytics { static func configure() async {} }
enum RemoteConfig { static func fetch() async {} }
enum CrashReporter { static func start() async {} }
enum Store { static func migrateIfNeeded() async throws {} }"메인이 막히면 그동안의 터치는 유실된다"는 부정확하다. 대개는 큐에 쌓여 있다가 풀리면 몰려서 전달된다(단, 너무 오래되면 coalesce되거나 폐기될 수 있다). 반대로 "스피너가 도니까 메인은 안 막혔네"라는 판단도 위험하다 — UIActivityIndicatorView의 회전은 CoreAnimation 애니메이션이라, 일단 렌더 서버로 커밋된 뒤에는 메인이 블로킹돼도 렌더 서버가 계속 굴린다. 즉 스피너는 돌지만 터치·스크롤·새 프레임은 멈춘 상태일 수 있다(반대로, 스피너를 켠 바로 그 콜백 안에서 곧장 메인을 막으면 애니메이션이 커밋될 기회가 없어 아예 돌지 않는다).
놀이공원 매표소에 창구가 딱 하나 있다고 해 보자. 직원은 지금 앞에 선 손님을 끝까지 처리해야 다음 사람을 부른다. 그런데 한 손님이 창구에서 "잠깐만요, 본사에 확인 좀 할게요" 하며 20분을 통화하면, 뒷줄 사람들은 사라진 게 아니라 그냥 서 있다. 앱이 터치에 반응하지 않는 게 정확히 이 상황이다. 터치는 큐에 잘 도착해 있는데, 그걸 꺼내서 처리해 줄 사람(메인 스레드)이 붙잡혀 있는 것이다. 게다가 이 직원은 손님 사이사이에 매표소 전광판도 갈아 끼우는데, 붙잡혀 있으니 전광판도 아까 화면 그대로다 — 화면까지 얼어붙는 이유가 이거다.
여기서 제일 많이 걸려 넘어지는 게 "근데 로딩 스피너는 계속 도는데요?"다. 스피너는 직원이 매 프레임 손으로 돌리는 게 아니라, 태엽을 한 번 감아 옆 부서에 넘겨 둔 회전 간판에 가깝다. 그 옆 부서가 render server고, 직원이 멈춰도 간판은 계속 돈다. 비유가 깨지는 지점도 하나 짚어 두면, 줄 선 손님과 달리 너무 오래 방치된 터치는 시스템이 중간 위치를 합쳐 버리거나(coalesce) 아예 취소해 버릴 수 있다.
꼬리 질문
메인이 아니라 임의의 백그라운드 스레드가 블로킹되면 UI는 영향받는가? 받지 않는다면 왜인가?
직접적으로는 영향받지 않는다. UIKit의 이벤트 dispatch·hit-test·CATransaction commit은 모두 메인 스레드에서만 일어나므로, 다른 스레드가 sleep이나 동기 I/O로 멈춰 있어도 메인 런루프는 계속 돌며 터치와 프레임을 처리한다. 다만 두 가지 간접 경로가 있다. 그 백그라운드 작업의 결과를 메인이 동기적으로 기다리고 있거나(예: 메인에서 백그라운드 큐에 대고 someQueue.sync로 결과를 기다리거나, 세마포어 대기 — 메인이 wait, 백그라운드가 signal) 공유 락을 두 스레드가 다투면 메인까지 전파돼 hang이 된다. 또 막힌 스레드가 GCD 워커라면 스레드 폭발(thread explosion)로 시스템 전체 응답성이 떨어질 수 있다.
sync나 세마포어 대기가 딱 그 상황이고, 잠든 건 옆방인데 멈추는 건 창구다.0.3초 블로킹과 20초 블로킹의 결과는 어떻게 다른가(hang vs 워치독 종료 vs frame drop)?
0.3초는 그 사이 커밋될 몇 프레임이 누락되는 정도라 대개 짧은 끊김(마이크로 hang)이나 스크롤 버벅임으로 끝난다. Xcode의 hang 리포트는 통상 250ms 이상을 hang으로 집계하므로 이 정도부터 계측 대상이 된다. 반면 20초는 정상 실행 중이라면 사용자가 명백한 먹통으로 느끼는 심각한 hang이고, 앱 시작·씬 전환·백그라운드 진입처럼 워치독이 감시하는 구간에서라면 제한 시간(구간별로 수 초~십수 초)을 넘겨 0x8badf00d로 강제 종료된다. 즉 시간 길이 자체보다 "어느 생명주기 구간에서 막혔는가"가 종료 여부를 가른다.
메인이 몇 초 막혔다 풀리면 그 사이 쌓인 터치는 전부 순서대로 다시 전달되나, 일부가 합쳐지거나(coalesced) 버려지나? touchesMoved 같은 연속 이벤트와 touchesBegan/touchesEnded 같은 이산 이벤트가 이때 다르게 취급된다면, 그 경계는 무엇을 기준으로 갈리나?
쌓인 게 전부 그대로 재생되지는 않는다. 터치의 상태를 바꾸는 이산 전이(touchesBegan·touchesEnded·touchesCancelled)는 서로 합쳐지지 않고 각각 별도로, 순서대로 전달된다 — UIKit이 각 UITouch의 phase 전이를 잘 정의된 순서로 유지해야 responder chain과 제스처 인식기의 상태 기계가 "시작만 하고 끝나지 않은" 식으로 깨지지 않기 때문이다. 다만 "반드시 전부 재생된다"는 뜻은 아니다 — hang이 길면 시스템이 그 터치 시퀀스를 통째로 폐기하거나 touchesCancelled로 끊어 버릴 수 있다(그 경우에도 Began 없이 Ended가 오지는 않는다).
반면 그 사이의 연속적인 위치 보고(touchesMoved)는 한 번의 전달로 합쳐져(coalesce) 최신 위치만 콜백에 들어온다. 합쳐진 중간 샘플이 필요하면(예: 정밀 드로잉) UIEvent의 coalescedTouches(for:)로 꺼내는데, 이건 원래 입력 샘플링 주기가 전달 주기보다 빠를 때(Apple Pencil·고주사율 터치 스캔) 병합된 샘플을 돌려주는 API이지, 시스템이 이미 오래됐다고 버린 샘플까지 복구해 주지는 않는다. 즉 경계는 "터치 생명주기 상태를 바꾸는 이벤트냐, 진행 중 위치만 알리는 이벤트냐"다.
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 기반 도구를 쓴다.
import Foundation
// 런루프 객체는 스레드마다 있지만, '돌고 있는' 건 메인뿐이다.
// 보조 스레드에서 Timer·포트 소스를 쓰려면 직접 run()을 불러야 한다.
// ❌ 타이머가 한 번도 안 울린다 — 클로저가 끝나며 스레드가 종료된다
func brokenTimerThread() {
Thread.detachNewThread {
let t = Timer(timeInterval: 1, repeats: true) { _ in print("tick") }
RunLoop.current.add(t, forMode: .default)
// run()이 없다 → 여기서 스레드 끝
}
}
// ✅ 명시적으로 런루프를 돌린다
final class TimerThread {
private var thread: Thread?
private var timer: Timer?
func start() {
let t = Thread { [weak self] in
guard let self else { return }
let timer = Timer(timeInterval: 1, repeats: true) { _ in self.tick() }
RunLoop.current.add(timer, forMode: .default)
self.timer = timer
// ⚠️ 종료 조건을 직접 관리해야 한다
while !Thread.current.isCancelled {
RunLoop.current.run(mode: .default, before: .distantFuture)
}
}
t.name = "com.app.timer"
t.start()
thread = t
}
func stop() {
// ⚠️ invalidate()는 타이머를 '설치한 그 스레드'에서 불러야 한다(Apple 문서 명시).
// 다른 스레드에서 부르면 런루프에서 입력 소스가 제거되지 않아 스레드가 안 끝난다.
if let thread {
perform(#selector(invalidateOnTimerThread), on: thread, with: nil, waitUntilDone: false)
}
thread?.cancel() // ⚠️ run()이 블로킹 중이면 즉시 깨어나지 않는다
thread = nil
}
@objc private func invalidateOnTimerThread() { timer?.invalidate(); timer = nil }
private func tick() {}
}
// ⚠️ 위 종료 처리가 까다롭다는 게 핵심이다. before: .distantFuture로 자면
// cancel()해도 다음 이벤트가 올 때까지 깨어나지 않는다.
// 짧은 타임아웃을 주거나, 깨우기용 포트 소스를 하나 더 달아야 한다.② 런루프를 직접 run()으로 돌리는 커스텀 스레드는 종료 조건 관리가 까다롭다 — 대부분의 앱 코드에서는 필요 없고, 필요해 보이면 먼저 GCD/Concurrency로 대체 가능한지 검토한다.
import Foundation
// 커스텀 스레드 + 런루프는 종료 조건·수명 관리가 까다롭다.
// 앱 코드에서 필요한 경우는 드물고, 대부분 더 단순한 대안이 있다.
// ✅ 주기 작업 → DispatchSourceTimer (런루프 비의존)
final class Poller {
private var timer: DispatchSourceTimer?
func start(interval: TimeInterval) {
let t = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
t.schedule(deadline: .now() + interval,
repeating: interval,
leeway: .milliseconds(200)) // 전력 절약 여지를 준다
t.setEventHandler { [weak self] in self?.poll() }
t.resume()
timer = t // ⚠️ 반드시 강참조로 보관
}
func stop() {
timer?.cancel() // 즉시 확실하게 멈춘다
timer = nil
}
private func poll() {}
}
// ✅ 주기 작업 → Swift Concurrency (취소가 구조적으로 따라온다)
func startPolling(interval: Duration) -> Task<Void, Never> {
Task {
while !Task.isCancelled {
await poll()
try? await Task.sleep(for: interval)
}
}
}
// ✅ 직렬 실행 컨텍스트 → serial queue 또는 actor
actor Worker {
func perform(_ job: Job) { }
}
// ✅ 파일·포트 감시 → DispatchSource
func watchFile(at url: URL) -> DispatchSourceFileSystemObject? {
let fd = open(url.path, O_EVTONLY)
guard fd >= 0 else { return nil }
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd, eventMask: .write, queue: .main)
source.setEventHandler { print("파일 변경됨") }
source.setCancelHandler { close(fd) }
source.resume()
return source
}
// 런루프가 정말 필요한 경우: 스레드에 묶인 레거시 API(일부 C 라이브러리,
// CFRunLoop 기반 서드파티)를 감쌀 때뿐이다.
struct Job {}
func poll() async {}"RunLoop는 CPU를 계속 돌리는 무한 루프다"는 틀렸다. 할 일이 없으면 mach_msg에서 블로킹되어 CPU를 전혀 쓰지 않고 잔다. 또 "모든 스레드는 런루프가 돌고 있다"도 틀리다 — 내가 만든 스레드나 GCD 워커 스레드는 명시적으로 run()을 부르기 전까지 런루프 객체만 있을 뿐 순회하지 않는다(프레임워크가 자기 목적으로 런루프를 돌리는 스레드는 따로 있다 — UIKit의 com.apple.uikit.eventfetch-thread, CFNetwork의 com.apple.NSURLConnectionLoader 등).
낚시터에서 물고기를 기다리는 방법은 크게 둘이다. 하나는 1초에 한 번씩 줄을 들었다 놨다 하며 "왔나? 왔나?" 확인하는 것. 잡기야 잡겠지만 팔이 남아나질 않는다 — 이게 busy-wait, 즉 CPU를 계속 태우는 방식이다. 다른 하나는 입질 방울을 달아 두고 의자에 기대 눈을 감는 것이다. 방울이 울리면 그때 일어나 챈다. RunLoop는 두 번째 쪽이고, "눈 감기"에 해당하는 게 mach_msg에서 스레드를 재우는 동작이다. 자는 동안에는 CPU를 한 톨도 안 쓰다가, 커널이 방울을 흔들어 주면 그때 깨어난다.
그리고 낚시터에 아예 앉아 있지 않으면 방울을 달아 둔 게 무슨 소용일까. 보조 스레드에서 Timer를 걸어 놓고도 안 울리는 이유가 딱 이거다. RunLoop.current.add(...)는 방울을 매다는 행동이고, run()이 "그 자리에 앉아 지키기"다. 지키는 사람이 없으면 방울이 늦게 울리는 게 아니라 아예 아무 일도 일어나지 않는다. 참고로 이 낚시꾼은 메인 스레드 딱 한 명만 기본으로 앉아 있다.
꼬리 질문
GCD의 글로벌 큐가 쓰는 워커 스레드에는 런루프가 도는가? 왜 그런 설계인가?
돌지 않는다. libdispatch의 워커 스레드는 커널이 관리하는 스레드 풀에서 필요할 때 꺼내 쓰고, 블록을 실행한 뒤에는 다시 반납하는 방식이라 각 스레드에 상태를 오래 유지하는 런루프를 얹지 않는다. 런루프는 "이 스레드는 계속 살아서 이벤트를 기다린다"는 장수(long-lived) 모델인데, GCD는 그 반대로 스레드를 익명의 실행 자원으로 다뤄 스레드 생성·소멸 비용을 숨기는 게 목적이기 때문이다. 그래서 워커 스레드에서 Timer나 포트 소스를 걸어도 아무도 서비스해 주지 않으며, 이 환경에선 DispatchSourceTimer를 써야 한다.
RunLoop(Foundation)와 CFRunLoop(CoreFoundation)의 관계, 그리고 스레드 안전성 측면에서 어느 쪽이 더 안전한가?
RunLoop는 CFRunLoop를 감싼 Objective-C 래퍼이고, 내부적으로 같은 실체를 가리킨다(getCFRunLoop()로 꺼낼 수 있다). 스레드 안전성은 CFRunLoop 쪽이 낫다. CFRunLoopAddSource/AddTimer 같은 CF 함수는 내부 락으로 보호돼 다른 스레드에서 호출해도 비교적 안전한 반면, RunLoop 인스턴스 메서드는 Apple 문서상 자신을 소유한 스레드에서만 호출하는 것이 원칙이다. 그래서 다른 스레드의 런루프를 건드려야 하면 CFRunLoop API를 쓰는 편이 안전하다.
CFRunLoop 쪽 손잡이는 안쪽에 자물쇠가 걸려 있어 다른 사람이 잡아도 탈이 덜 나고, RunLoop 쪽은 "주인만 만지세요"가 원칙이다. 남의 스레드 런루프를 건드릴 일이 생기면 CF 손잡이를 잡는다.런루프가 잠들어 있을 때 다른 스레드에서 그 런루프를 어떻게 깨우는가?
잠든 런루프는 mach_msg에서 자신의 wakeup 포트로 오는 메시지를 기다리고 있으므로, 그 포트로 메시지를 보내면 깨어난다. 직접 하는 API가 CFRunLoopWakeUp(runLoop)이고, 내부적으로 이 포트에 메시지를 넣는다. 실무에서는 대개 이를 직접 부를 일이 없다. 타이머를 추가하거나 메인 큐에 DispatchQueue.main.async로 블록을 넣으면 프레임워크가 알아서 해당 런루프를 깨워 주기 때문이다. 반면 source0을 CFRunLoopSourceSignal로 signal하는 것만으로는 발화 준비 표시만 될 뿐 잠든 런루프가 깨어나지 않으므로, 이어서 CFRunLoopWakeUp을 직접 호출해야 한다. 반대로 source1(mach 포트 기반)은 커널이 직접 그 포트로 이벤트를 넣으므로 별도 wakeup이 필요 없다.
CFRunLoopWakeUp이 그 초인종이다. source0을 signal만 하는 건 문 앞에 택배만 살짝 두고 가는 것이라 초인종을 따로 눌러 줘야 알아채고, source1은 커널이 초인종까지 대신 눌러 준다. 평소 앱 코드에서는 타이머를 걸거나 DispatchQueue.main.async만 해도 프레임워크가 알아서 눌러 준다.Q3. Main Thread의 RunLoop는 어떤 이벤트를 처리하는가?
메인 런루프가 서비스하는 것은 크게 셋이다. (1) 입력 소스(input source) — 포트 기반 source1(앱 밖에서 오는 mach 메시지), 비포트 기반 source0(performSelector(onThread:), 그리고 UIKit의 터치·HID 전달), 그리고 libdispatch가 심어 둔 메인 큐 배수 소스. (2) 타이머(Timer/CFRunLoopTimer). (3) 옵저버(observer) — 순회마다 autorelease pool을 push/pop하고, 잠들기 직전 CoreAnimation commit을 실행해 레이아웃·렌더를 flush한다. 이 모든 것은 현재 런루프 모드(default·tracking·common)에 등록된 것만 처리된다.
CS 원리
이벤트 루프가 다뤄야 할 "일감"은 성격이 다르다: 외부에서 비동기로 도착하는 것(네트워크·입력 장치), 시각 기반으로 발생하는 것(타이머), 그리고 순회 자체의 특정 시점에 훅(hook)을 걸고 싶은 것(정리 작업). 잘 설계된 이벤트 루프는 이 셋을 각각 소스 / 타이머 / 옵저버라는 1급 개념으로 분리해 등록·해제할 수 있게 한다. RunLoop가 바로 그 구조다.
iOS에서는
입력 소스는 두 종류다. source1은 mach 포트 기반이라 커널이 그 포트로 메시지를 넣어 런루프를 직접 깨울 수 있다 — 앱 밖에서 오는 이벤트가 프로세스로 들어오는 첫 관문이 이 경로다. 다만 터치·HID는 UIKit의 eventfetch 스레드가 그 포트를 받은 뒤 메인 런루프에는 source0(__eventFetcherSourceCallback)으로 넘기므로, 메인 런루프에서 관찰되는 터치 처리 경로는 source0이다. source0은 비포트 기반이라 스스로 런루프를 못 깨우고, signal() 후 누군가 wakeUp()을 해 줘야 한다. 여기에 더해 libdispatch는 메인 큐를 배수하기 위한 소스를 런루프에 등록해 두고(Q5에서 상세), CADisplayLink는 화면 리프레시(vsync)에 맞춰 콜백을 넣는다. 옵저버 중 가장 중요한 둘은 autorelease pool 관리(순회 진입 시 push, 잠들기 전 pop → 임시 객체 해제 타이밍이 여기에 걸린다)와 CoreAnimation의 commit(잠들기 직전 kCFRunLoopBeforeWaiting에서 setNeedsLayout/Display로 쌓인 것을 한 번에 처리)이다.
실험 · 도구
모드 개념을 눈으로 확인하려면, 스크롤뷰가 있는 화면에서 .default 모드로만 등록한 Timer를 걸고 스크롤해 보라. 스크롤하는 동안(모드가 UITrackingRunLoopMode로 바뀜) 타이머가 멈췄다가 손을 떼면 다시 도는 게 관찰된다. 반대로 .common 모드로 등록하면 스크롤 중에도 계속 fire된다. LLDB로 idle 메인 스레드의 콜스택을 뜨면 __CFRunLoopRun 아래에 소스/타이머/옵저버를 서비스하는 내부 함수 이름들이 그대로 드러난다.
.default에만 올린 타이머는 스크롤 중(tracking) 목록에 없어서 멈추고, .common으로 올린 항목은 두 목록 모두에 들어가 계속 처리된다.| 구분 | source0 (비포트) | source1 (포트 기반) |
|---|---|---|
| 런루프 직접 깨우기 | 불가 — signal 후 wakeUp 필요 | 가능 — 커널이 mach 포트로 깨움 |
| 대표 예 | performSelector(onThread:), UIKit의 터치·HID 전달 | 앱 밖에서 오는 mach 메시지, CFMachPort |
| 발생 위치 | 주로 앱 내부 | 주로 앱 외부(다른 프로세스/커널) |
source0/source1 구분은 CFRunLoop 수준의 내부 개념이라 UIKit 앱 코드에서 직접 다룰 일은 드물다. 하지만 "터치는 왜 앱이 자고 있어도 도착하는가"(커널이 포트로 깨우니까)를 설명할 때 이 구분이 핵심이 된다.
프로젝트 적용
① 스크롤 중에도 멈추면 안 되는 타이머·소스는 .common 모드로 등록하라. Timer.scheduledTimer는 .default라 tracking 모드에서 멈춘다.
import UIKit
// 런루프는 한 번에 한 모드로만 돈다. 스크롤 중에는 tracking 모드로 바뀌므로
// .default에만 등록한 항목은 '늦게' 처리되는 게 아니라 아예 처리되지 않는다.
final class CountdownView: UIView {
private var timer: Timer?
// ❌ scheduledTimer는 .default 모드 — 스크롤을 시작하면 멈춘다
func startBroken() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.tick()
}
}
// ✅ .common에 등록하면 default와 tracking 양쪽 목록에 들어간다
func start() {
let t = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
self?.tick()
}
RunLoop.main.add(t, forMode: .common)
timer = t
}
// ✅ scheduledTimer를 쓰고 싶다면 만든 뒤에 모드를 추가한다
func startAlternative() {
let t = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.tick()
}
RunLoop.main.add(t, forMode: .common)
timer = t
}
func stop() { timer?.invalidate(); timer = nil }
private func tick() {}
}
// ✅ CADisplayLink도 동일하다
final class Animator {
private var link: CADisplayLink?
func start() {
let l = CADisplayLink(target: self, selector: #selector(step))
l.add(to: .main, forMode: .common) // ✅ 스크롤 중에도 애니메이션 유지
link = l
}
@objc private func step() {}
}
// ⚠️ .common은 '제3의 모드'가 아니다. "common 집합에 속한 모든 모드에 등록"이라는
// 표시일 뿐이고, 런루프는 여전히 한 번에 한 모드로만 돈다.② DispatchQueue.main.async는 libdispatch가 메인 큐 배수 소스를 common 모드에 심어 두기 때문에 이 모드 문제를 겪지 않는다 — 모드 때문에 헤맬 바엔 이쪽이 단순하다.
import UIKit
// libdispatch가 메인 큐 배수 소스를 common 모드에 심어 두기 때문에,
// main.async로 넣은 블록은 스크롤 중에도 실행된다.
final class BadgeUpdater {
// ✅ 모드를 신경 쓸 필요가 없다
func refresh() {
DispatchQueue.main.async {
self.updateBadge() // 스크롤 중에도 실행된다
}
}
// ✅ Swift Concurrency도 마찬가지
func refreshModern() {
Task { @MainActor in
self.updateBadge()
}
}
// ❌ Timer로 같은 걸 하려면 모드를 챙겨야 한다
func refreshWithTimer() {
let t = Timer(timeInterval: 0, repeats: false) { _ in self.updateBadge() }
RunLoop.main.add(t, forMode: .common) // 이걸 빠뜨리면 스크롤 중 안 돈다
}
private func updateBadge() {}
}
// ✅ 지연 실행도 asyncAfter가 Timer보다 단순하다
func delayed(_ seconds: TimeInterval, _ work: @escaping () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: work)
}
// ⚠️ 단, asyncAfter도 '최소 지연'만 보장한다. 런루프가 바쁘면 더 늦게 실행된다.
// 정확한 시각이 필요하면 목표 시각과의 차이를 매번 다시 계산해야 한다(Q6).
// 정리: "모드 때문에 헤맬 바에는 main.async / @MainActor 쪽이 단순하다."
// Timer가 필요한 진짜 이유(런루프 통합, tolerance 제어)가 없다면 이쪽을 쓴다.③ 한 순회 안에서 임시 객체를 대량으로 만드는 루프는 autoreleasepool { }로 감싸 pool 경계를 앞당겨라. 그러지 않으면 순회가 끝날 때까지 전부 살아 있어 메모리가 치솟는다.
import UIKit
// 메인 런루프의 autorelease pool은 '순회 단위'로 push/pop된다.
// 한 순회 안에서 임시 객체를 수만 개 만들면 순회가 끝날 때까지 전부 살아 있다.
// ❌ 1만 개 이미지를 한 순회에서 로드 — pop 시점까지 메모리가 계속 쌓인다
func loadAllBad(_ paths: [String]) -> [CGSize] {
paths.map { path in
let image = UIImage(contentsOfFile: path)! // 내부에서 NSData·ImageIO 임시 객체가 autorelease된다
return image.size
}
// 여기서 메모리 피크 → 최악의 경우 jetsam
}
// ✅ 청크마다 pool 경계를 앞당긴다
func loadAll(_ paths: [String]) -> [CGSize] {
var sizes: [CGSize] = []
sizes.reserveCapacity(paths.count)
for chunk in stride(from: 0, to: paths.count, by: 100) {
let end = min(chunk + 100, paths.count)
autoreleasepool { // ✅ 이 블록 끝에서 해제
for i in chunk..<end {
if let image = UIImage(contentsOfFile: paths[i]) {
sizes.append(image.size)
}
}
}
}
return sizes
}
// ✅ 매 반복이 무거우면 반복마다 감싼다
func processHeavy(_ urls: [URL]) {
for url in urls {
autoreleasepool {
let data = try? Data(contentsOf: url)
_ = data.map { transform($0) }
}
}
}
// ⚠️ 순수 Swift 값 타입·ARC 객체는 대부분 autorelease를 타지 않는다.
// 효과가 있는 건 ObjC/CoreFoundation 경계를 넘는 API다:
// UIImage(contentsOfFile:), NSString 계열, ImageIO, Core Graphics 등.
// 확인: Instruments의 Allocations에서 순회 중 "Persistent Bytes"가
// 계속 오르다가 순회 끝에 뚝 떨어지면 이 패턴이다.
func transform(_ d: Data) -> Data { d }④ 하지 말 것: source0/source1 같은 CFRunLoop 내부 개념에 앱 로직을 의존시키는 것. 관찰·설명에는 유용하지만 직접 다룰 일은 드물다.
import Foundation
// source0/source1 구분은 CFRunLoop 수준의 내부 개념이다.
// "터치는 왜 앱이 자고 있어도 도착하는가"를 설명할 때는 유용하지만,
// 앱 코드에서 직접 다루면 취약하고 이식성이 없다.
// ❌ CFRunLoopSource를 직접 만들어 앱 로직을 얹는 코드
func createCustomSourceBad() {
var context = CFRunLoopSourceContext()
context.perform = { _ in print("source0 fired") }
let source = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &context)
CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .defaultMode)
// 깨우기·모드·수명을 전부 직접 관리해야 하고, 상위 API로 다 되는 일이다
}
// ✅ 같은 목적을 상위 API로 — 훨씬 단순하고 안전하다
// "다른 스레드에서 메인에 작업을 넘긴다"
func postToMain(_ work: @escaping () -> Void) {
DispatchQueue.main.async(execute: work)
}
// "이벤트가 생기면 깨어나 처리한다"
final class EventPump {
private let stream: AsyncStream<Event>
private let continuation: AsyncStream<Event>.Continuation
init() {
var c: AsyncStream<Event>.Continuation!
stream = AsyncStream { c = $0 }
continuation = c
}
func send(_ e: Event) { continuation.yield(e) }
func run() async {
for await event in stream { handle(event) } // 이벤트가 없으면 자연히 대기
}
private func handle(_ e: Event) {}
}
// "파일·포트·시그널 감시"
func watch(fd: Int32) -> DispatchSourceRead {
let s = DispatchSource.makeReadSource(fileDescriptor: fd, queue: .main)
s.setEventHandler { }
s.resume()
return s
}
struct Event {}".common은 default·tracking에 이은 세 번째 모드다"는 흔한 오해다. .common은 그 자체로 순회하는 모드가 아니라 모드들의 집합에 붙인 표시이고, 여기에 등록하면 그 집합에 속한 모든 모드(default·tracking 등)의 목록에 함께 올라간다 — 런루프는 언제나 한 번에 한 모드로만 돈다. 또 "source0은 source1보다 우선순위가 낮다"도 틀렸다. 둘의 차이는 급이 아니라 런루프를 스스로 깨울 수 있는가이다 — source1은 mach 포트로 커널이 직접 깨우고, source0은 signal() 후 누군가 wakeUp()을 해 줘야 처리된다. 마지막으로 "autorelease pool은 함수를 벗어나면 비워진다"도 정확하지 않다 — 메인 런루프의 풀은 순회 단위로 push/pop되므로, 한 콜백 안에서 임시 객체를 대량으로 만들면 순회가 끝날 때까지 메모리에 남는다(그래서 루프 안에서는 autoreleasepool { }로 직접 끊어 준다).
도서관 사서 데스크를 떠올려 보자. 사서에게 일이 들어오는 통로가 여러 갈래다. 바깥 반납함에 책이 툭 떨어지는 소리는 사서가 졸고 있어도 깨운다 — 이게 source1이고, 터치나 HID 이벤트가 이 길로 온다(커널이 mach 포트를 두드려 준다). 반대로 자기가 자기 책상에 붙여 둔 포스트잇은 소리가 안 나니, 누가 어깨를 툭 쳐 줘야 본다 — source0이 그렇다. 벽시계 알람은 Timer고. 여기에 더해 사서에게는 자리를 뜨기 직전에 늘 하는 뒷정리 루틴이 있는데, 그게 옵저버다. 쓰레기통 비우기가 autorelease pool(임시로 만든 물건을 한꺼번에 버리는 통) 정리, 그동안 쌓인 서류를 묶어 옆방으로 넘기는 게 CoreAnimation commit이다.
제일 헷갈리는 건 "모드"다. 모드는 사서가 지금 어떤 업무 중인지에 붙은 이름이고, 모드마다 "이 상태에서는 이 목록만 확인한다"가 미리 정해져 있다. 그래서 사용자가 스크롤하는 동안엔 tracking 모드로 바뀌고, default 목록에만 올려 둔 알람은 늦게 확인하는 게 아니라 아예 쳐다보지도 않는다. .common은 새로운 세 번째 업무가 아니라 "어느 목록에나 다 넣어 줘"라는 지시다.
꼬리 질문
autorelease pool을 런루프 옵저버가 순회마다 push/pop한다면, 루프 한 순회 안에서 대량으로 임시 객체를 만들면 메모리 피크가 왜 생기고 어떻게 줄이는가?
autorelease된 객체는 pool이 pop될 때 한꺼번에 release되는데, 그 pop은 순회가 끝나며 잠들기 직전에 일어난다. 따라서 한 순회 안의 루프에서 임시 이미지·문자열 같은 걸 수만 개 만들면 pop 시점까지 전부 살아 있어 그 순간 메모리가 치솟는다. 해결책은 무거운 반복문을 autoreleasepool { } 블록으로 감싸 매 반복(또는 청크마다) pool을 비우는 것이다. 이러면 pool 경계가 순회 끝이 아니라 블록 끝으로 당겨져 임시 객체가 즉시 해제된다. Swift에서 값 타입이나 ARC 관리 객체는 대부분 autorelease를 안 타지만, 이미지 디코딩 등 Objective-C/CoreFoundation 경계를 넘는 API에서는 여전히 유효한 기법이다.
autoreleasepool { }로 감싸면 한 바퀴가 아니라 매 반복마다 통을 비워서 봉우리가 안 생긴다.UITrackingRunLoopMode와 NSDefaultRunLoopMode, 그리고 commonModes의 관계는? "common"에 넣는다는 게 정확히 무슨 뜻인가?
런루프는 한 번에 정확히 하나의 모드로만 돈다. 평상시엔 default, 스크롤·제스처 추적 중엔 UITrackingRunLoopMode로 전환되며, 현재 모드에 등록된 소스·타이머·옵저버만 서비스된다. commonModes는 별개의 실제 모드가 아니라 "공통 모드 집합"이라는 가상 이름표다. 메인 런루프는 default와 tracking을 모두 이 common 집합의 멤버로 등록해 둔다. 따라서 어떤 항목을 .common으로 add한다는 것은 "이 집합에 속한 모든 모드에 동시에 등록하라"는 뜻이고, 결과적으로 default에서든 tracking에서든 계속 처리된다.
commonModes는 세 번째 이름표가 아니라 "그 목록들에 다 적어 둬"라는 지시다. 그래서 .common으로 올린 타이머는 스크롤 중에도 안 끊긴다.메인 큐(DispatchQueue.main)의 작업은 어느 소스로 처리되며, 왜 스크롤 중에도 실행되는가?
libdispatch가 메인 런루프에 심어 둔 전용 배수 소스(내부적으로 _dispatch_main_queue_callback_4CF를 호출)로 처리된다. 이 소스가 commonModes에 등록돼 있기 때문에 default에서든 UITrackingRunLoopMode에서든 매 순회 서비스된다. 그래서 .default 모드로만 등록된 Timer가 스크롤 중 멈추는 것과 달리, DispatchQueue.main.async 블록은 스크롤하는 동안에도 계속 실행된다. UI 갱신을 메인 큐로 넘기는 표준 패턴이 트래킹 중에도 안전하게 동작하는 근거가 여기에 있다.
DispatchQueue.main.async 블록은 계속 처리되고, .default에만 올려 둔 Timer만 그 시간 동안 쳐다봐 주는 사람이 없어 멈춘다.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·무거운 계산·불필요한 뷰 재생성 금지.
import UIKit
final class FeedCell: UICollectionViewCell {
private let iconView = UIImageView()
private let titleLabel = UILabel()
// ❌ 여기서 하면 안 되는 것들 — 매 프레임, 셀마다 호출된다
// override func layoutSubviews() {
// super.layoutSubviews()
// let data = try? Data(contentsOf: iconURL) // 동기 I/O
// iconView.image = UIImage(data: data!) // 디코드
// let h = title.boundingRect(...) // 매번 텍스트 측정
// addSubview(UIView()) // 뷰 재생성
// }
// ✅ 가벼운 배치 계산만
override func layoutSubviews() {
super.layoutSubviews()
let inset: CGFloat = 12
iconView.frame = CGRect(x: inset, y: inset, width: 40, height: 40)
titleLabel.frame = CGRect(x: iconView.frame.maxX + 8, y: inset,
width: bounds.width - iconView.frame.maxX - 8 - inset,
height: 40)
}
// ✅ 무거운 것은 configure 시점에 한 번, 그것도 비동기로
func configure(_ item: Item) {
titleLabel.text = item.title
iconView.image = nil
Task {
let image = await ImagePipeline.shared.image(for: item.iconURL)
guard self.currentID == item.id else { return } // 재사용 검증
self.iconView.image = image
}
currentID = item.id
}
private var currentID: UUID?
}
// ✅ 텍스트 높이 같은 계산은 캐시한다
final class HeightCache {
private var cache: [UUID: CGFloat] = [:]
func height(for item: Item, width: CGFloat, font: UIFont) -> CGFloat {
// ⚠️ 높이는 width·font에도 의존한다 — id만 키로 쓰면 회전·Dynamic Type 변경 시
// 낡은 값이 반환돼 레이아웃이 깨진다. 실제 코드에서는 복합 키를 써야 한다.
// struct Key: Hashable { let id: UUID; let width: CGFloat; let font: UIFont }
if let h = cache[item.id] { return h }
let h = (item.title as NSString).boundingRect(
with: CGSize(width: width, height: .greatestFiniteMagnitude),
options: .usesLineFragmentOrigin,
attributes: [.font: font], context: nil).height.rounded(.up)
cache[item.id] = h
return h
}
}
struct Item { let id: UUID; let title: String; let iconURL: URL }
enum ImagePipeline {
static let shared = ImagePipelineImpl()
}
struct ImagePipelineImpl { func image(for url: URL) async -> UIImage? { nil } }② 레이아웃이 즉시 필요할 때만 layoutIfNeeded()를 쓴다. 습관적으로 남발하면 프레임당 layout을 강제 여러 번 돌려 오히려 hitch를 만든다.
import UIKit
// setNeedsLayout은 '표시'만 하고 실제 실행은 런루프 끝 Commit에서 한 번에 모인다.
// layoutIfNeeded는 그 합치기를 깨고 지금 당장 강제한다.
final class PanelView: UIView {
private let contentView = UIView()
// ❌ 여러 번 강제하면 프레임당 layout이 여러 번 돈다
func updateBad(_ items: [String]) {
for item in items {
addRow(item)
layoutIfNeeded() // 💥 항목 수만큼 전체 레이아웃
}
}
// ✅ 표시만 하고 Commit에서 한 번에 처리되게 둔다
func update(_ items: [String]) {
for item in items { addRow(item) }
setNeedsLayout() // 한 번만 — 실제 실행은 런루프 끝
}
// ✅ layoutIfNeeded가 정당한 경우 ① 지금 당장 크기를 알아야 할 때
func measuredHeight() -> CGFloat {
layoutIfNeeded()
return contentView.bounds.height
}
// ✅ ② 애니메이션 시작 전 '현재 상태'를 확정할 때
func animateExpansion() {
layoutIfNeeded() // 시작 상태 확정
heightConstraint.constant = 300
UIView.animate(withDuration: 0.3) {
self.layoutIfNeeded() // 종료 상태로 보간
}
}
private func addRow(_ s: String) {}
private let heightConstraint = NSLayoutConstraint()
}
// 확인: Instruments의 Time Profiler에서 layoutSubviews 호출 횟수를 본다.
// 한 프레임에 여러 번 나오면 어딘가에서 강제하고 있는 것이다.③ 오프스크린 렌더링(마스크·그림자·cornerRadius + 마스킹)은 Render 단계 비용을 키운다 — 필요 시 shadowPath 지정, shouldRasterize 신중히 사용.
import UIKit
// 오프스크린 렌더링은 GPU가 별도 버퍼에 그렸다가 합치는 것이다.
// 버퍼 전환 비용이 붙어 Render 단계가 비싸진다.
final class CardView: UIView {
// ❌ 둥근 모서리 + 마스킹 + 그림자 조합
func styleBad() {
layer.cornerRadius = 12
layer.masksToBounds = true // 마스킹
layer.shadowOpacity = 0.2 // 위 마스킹 때문에 이 그림자는 아예 잘려 안 보인다. 마스킹을 풀어도 shadowPath가 없으면 오프스크린 패스가 붙는다
}
// ✅ 그림자는 경로를 지정해 알파 계산을 없앤다
func style() {
layer.cornerRadius = 12
layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 12).cgPath
layer.shadowOpacity = 0.2
layer.shadowRadius = 8
layer.shadowOffset = CGSize(width: 0, height: 2)
// masksToBounds는 켜지 않는다
}
// ✅ 모서리와 그림자를 둘 다 원하면 뷰를 나눈다
// 바깥 뷰: 그림자 (masksToBounds = false)
// 안쪽 뷰: 모서리 + 클리핑 (masksToBounds = true)
func styleLayered(shadowHost: UIView, content: UIView) {
shadowHost.layer.shadowPath = UIBezierPath(
roundedRect: shadowHost.bounds, cornerRadius: 12).cgPath
shadowHost.layer.shadowOpacity = 0.2
shadowHost.layer.masksToBounds = false
content.layer.cornerRadius = 12
content.layer.masksToBounds = true
}
// ⚠️ shouldRasterize는 신중히 — 내용이 매 프레임 바뀌면 오히려 손해다
func rasterizeIfStatic() {
layer.shouldRasterize = true
layer.rasterizationScale = traitCollection.displayScale // ✅ 안 하면 흐려진다
// 정적인 내용에만. 스크롤·애니메이션 중 내용이 바뀌면 매번 다시 그린다.
}
// ✅ 불투명하게 둘 수 있으면 블렌딩이 줄어든다
func opaqueIfPossible() {
isOpaque = true
backgroundColor = .systemBackground
}
}
// 확인: 시뮬레이터 → Debug → Color Offscreen-Rendered (노란색으로 표시)
// 실기기는 Instruments의 Core Animation / Animation Hitches"setNeedsLayout()을 부르면 그 자리에서 layoutSubviews()가 실행된다"는 틀리다. 그건 표시(dirty flag)일 뿐, 실제 실행은 런루프 끝 Commit에서다. 또 "렌더링은 내 앱이 GPU로 직접 그린다"도 정확하지 않다 — 앱은 레이어 트리를 렌더 서버 프로세스에 넘기고, 실제 GPU 합성은 그쪽에서 한다. 그래서 앱 메인이 잠깐 바빠도 이미 커밋된 애니메이션은 렌더 서버가 계속 굴릴 수 있다.
식당 홀 직원과 주방을 떠올리면 편하다. 손님이 "물 주세요" 했다가 "아, 포크도" 했다가 "냅킨도요" 하면, 홀 직원은 그때마다 주방으로 뛰어가지 않는다. 주문지에 적어 뒀다가 한 번에 넘긴다. setNeedsLayout()을 백 번 불러도 layoutSubviews()가 한 번만 도는 게 딱 이거다. 적는 것(요청)과 만드는 것(실행)은 다른 일이고, 주방으로 넘기는 시점이 런루프가 잠들기 직전의 Commit이다. layoutIfNeeded()는 "이거 지금 당장요" 하며 직접 주방으로 뛰어가는 것 — 되기는 되지만 습관이 되면 홀이 마비된다.
그리고 요리를 하는 건 홀 직원이 아니다. 앱은 "무엇을 어떻게 그릴지" 적어 둔 레이어 트리를 render server라는 별개의 프로세스에 넘기고, 실제 GPU 합성은 거기서 한다. 그래서 홀 직원이 잠깐 붙잡혀도 이미 주방에 넘어간 애니메이션은 계속 굴러간다(Q1의 스피너가 이래서 돈다). 다만 접시가 손님 앞에 놓이는 타이밍은 홀도 주방도 못 정한다 — vsync라는 정해진 박자에 맞춰서만 나간다. 한 박자 안에 못 끝내면 다음 박자로 밀리고, 그게 hitch(끊김)다.
꼬리 질문
메인 스레드가 막혔는데도 이미 시작된 CoreAnimation 기본 애니메이션은 계속 도는 경우가 있는 이유는(렌더 서버 관점)?
애니메이션의 실제 프레임 보간과 합성은 앱 프로세스가 아니라 렌더 서버(backboardd/render server)가 담당하기 때문이다. 앱이 CABasicAnimation을 커밋하면 시작·끝 값, duration, 타이밍 함수를 담은 애니메이션 서술이 레이어 트리와 함께 렌더 서버로 한 번 전송된다. 그 뒤로는 렌더 서버가 매 vsync마다 스스로 중간 값을 계산해 GPU로 합성하므로, 앱 메인이 잠깐 블로킹돼도 이미 넘어간 애니메이션은 끝까지 굴러간다. 단, 매 프레임 앱이 값을 다시 계산해 주는 방식(예: CADisplayLink 기반 수동 애니메이션이나 layout에 묶인 갱신)은 메인이 막히면 함께 멈춘다.
Commit hitch와 Render hitch를 각각 어떤 지표·도구로 구분하고, 대응 방법은 어떻게 달라지는가?
Instruments의 Animation Hitches(또는 Core Animation) 트랙에서 hitch가 어느 단계에 걸쳐 있는지로 구분한다. Commit hitch는 앱 프로세스의 메인 스레드 구간(layoutSubviews·draw(_:)·과도한 뷰 생성)에서 발생하므로 Time Profiler로 그 hot path를 찾아 계산을 백그라운드로 빼거나 레이아웃을 가볍게 만든다. Render hitch는 렌더 서버·GPU 구간에서 발생하며, 오프스크린 패스·과도한 블렌딩·마스킹·큰 텍스처가 원인이다. 대응은 shadowPath 지정, 불투명 뷰로 블렌딩 축소, 레이어 단순화처럼 렌더 서버의 작업량 자체를 줄이는 쪽이다. 즉 "누가 느린가"(앱 vs 렌더 서버)가 처방을 가른다.
layoutSubviews가 무거우면 홀(앱 메인) 문제라 계산을 덜어 내야 하고, 그림자·마스크 때문에 GPU가 헐떡이면 주방(렌더 서버) 문제라 레이어를 단순하게 만들어야 한다. 홀이 밀리는데 주방 화력을 올려 봐야 아무 소용이 없다.ProMotion(120Hz)에서 프레임 예산은 어떻게 바뀌며, 가변 주사율이 튜닝 목표에 주는 영향은?
60Hz의 프레임 예산은 약 16.7ms지만 120Hz에서는 절반인 약 8.3ms로 줄어, 같은 작업량이라도 프레임을 놓칠 여지가 커진다. 다만 ProMotion은 고정 120Hz가 아니라 콘텐츠에 따라 10~120Hz로 변하는 가변 주사율이라, 정지 화면에서는 낮은 주사율로 내려가 전력을 아낀다. 튜닝 관점에서 중요한 건 절대 프레임 시간이 아니라 "현재 주사율의 예산 안에 드는가"이고, 지표도 단순 dropped frame 수보다 hitch time ratio(초당 누적 지연)를 봐야 주사율 변동과 무관하게 체감 끊김을 일관되게 잴 수 있다. CADisplayLink의 preferredFrameRateRange로 원하는 주사율 범위를 시스템에 알려 주는 것도 도움이 된다.
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() }async는 실행 흐름을 그리로 점프시키는 게 아니라 목록 뒤에 붙이는 것이다. 지금 콜스택이 다 풀려 런루프로 돌아와야 비로소 그 블록이 실행된다.실험 · 도구
위 A/B/C 예제로 순서를 확인한다. 모드 차이를 보려면 스크롤뷰 위에서 반복 작업을 .default 모드 Timer로 넣은 버전과 DispatchQueue.main.async(내부적으로 common 모드)로 넣은 버전을 비교한다 — 스크롤하는 동안 전자는 멈추지만 후자는 계속 돈다. LLDB에서 async 블록에 브레이크포인트를 걸고 bt를 뜨면 콜스택 바닥이 __CFRunLoop… → 메인 큐 배수 경로로 이어지는 걸 볼 수 있다.
프로젝트 적용
① UI 업데이트는 "메인에서 실행"이 핵심 규칙이다. 이미 메인인 게 확실하면 굳이 async로 한 순회 미루지 말고 바로 실행한다(불필요한 지연·깜빡임 방지).
import UIKit
// 이미 메인 스레드인데 async로 감싸면 한 순회가 늦어진다.
// 그 사이에 프레임이 한 번 그려지면 깜빡임이나 한 박자 늦은 반응으로 보인다.
final class BadgeView: UIView {
private let label = UILabel()
// ❌ 이미 메인 콜백 안인데 또 async
@objc func didTapBad() {
DispatchQueue.main.async {
self.label.text = "1" // 한 순회 뒤에 반영된다
}
}
// ✅ 그냥 실행한다
@objc func didTap() {
label.text = "1" // 이번 순회의 Commit에 포함된다
}
// ✅ 어느 스레드인지 모르는 콜백이면 분기한다
func update(_ text: String) {
if Thread.isMainThread {
label.text = text
} else {
DispatchQueue.main.async { self.label.text = text }
}
}
}
// ✅ 더 나은 방법: 타입을 @MainActor로 격리해 분기 자체를 없앤다
@MainActor
final class CleanBadgeView {
private let label = UILabel()
func update(_ text: String) {
label.text = text // 컴파일러가 메인임을 보장한다
}
}
// 호출부에서 필요할 때만 hop한다
func fromBackground(_ view: CleanBadgeView) async {
let text = await fetchText()
await view.update(text) // ✅ 필요한 곳에서만 경계를 넘는다
}
func fetchText() async -> String { "" }② "현재 layout이 반영된 뒤"에 뭔가 하고 싶을 때 DispatchQueue.main.async로 한 틱 미루는 트릭이 유용하다(예: 셀 크기 확정 후 스크롤). 단, 이는 타이밍 의존이라 남용하면 취약해진다.
import UIKit
// 한 틱 미루기가 정당한 경우는 "이번 순회의 Commit이 끝난 뒤"가 필요할 때다.
final class ChatViewController: UIViewController {
private let tableView = UITableView()
private var messages: [Message] = []
// ✅ 새 메시지를 추가한 뒤 맨 아래로 스크롤
func append(_ message: Message) {
messages.append(message)
tableView.insertRows(at: [IndexPath(row: messages.count - 1, section: 0)],
with: .automatic)
// 이 시점엔 아직 contentSize가 갱신되지 않았다
DispatchQueue.main.async {
self.scrollToBottom() // ✅ layout이 반영된 뒤 실행된다
}
}
// ✅ 대안: layoutIfNeeded로 지금 확정한다 (더 명시적)
func appendExplicit(_ message: Message) {
messages.append(message)
tableView.insertRows(at: [IndexPath(row: messages.count - 1, section: 0)],
with: .automatic)
tableView.layoutIfNeeded() // 지금 layout을 강제하고
scrollToBottom() // 확정된 크기로 스크롤
}
// ✅ 셀 크기가 확정된 뒤 무언가 해야 할 때
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// ✅ 여기가 'layout 직후'를 표현하는 정식 지점이다 — async 트릭보다 낫다
adjustInsets()
}
private func scrollToBottom() {}
private func adjustInsets() {}
}
// ⚠️ async 한 틱 트릭은 타이밍 의존이다. 남용하면
// "왜 이게 여기 있는지 아무도 모르는 코드"가 되고, 프레임워크 동작이
// 바뀌면 조용히 깨진다. 가능하면 viewDidLayoutSubviews·layoutIfNeeded처럼
// 의도가 드러나는 지점을 쓴다.
struct Message {}③ 같은 목적이면 Swift Concurrency의 @MainActor / await MainActor.run이 의도를 더 명확히 드러낸다.
import UIKit
// DispatchQueue.main.async는 "메인으로 보낸다"만 말한다.
// @MainActor는 "이건 메인에서만 유효하다"를 타입에 새긴다.
// ❌ 런타임 규약 — 지키지 않아도 컴파일은 된다
final class OldViewModel {
var onChange: ((String) -> Void)?
func load() {
URLSession.shared.dataTask(with: url) { data, _, _ in
DispatchQueue.main.async { // 부르는 사람이 기억해야 한다
self.onChange?("완료")
}
}.resume()
}
private let url = URL(string: "https://x")!
}
// ✅ 컴파일러가 강제 — 잘못 부르면 컴파일 에러
@MainActor
final class ViewModel: ObservableObject {
@Published private(set) var status = ""
func load() async {
status = "로딩 중" // 메인에서 실행
let result = await fetch() // 여기서 메인을 반납
status = result // 재개 시 다시 메인
}
private nonisolated func fetch() async -> String { "완료" }
}
// ✅ 일부만 격리할 수도 있다
final class ImageService {
nonisolated func download(_ url: URL) async throws -> Data {
try await URLSession.shared.data(from: url).0 // 어느 스레드든 OK
}
@MainActor
func apply(_ data: Data, to view: UIImageView) { // 여기만 메인
view.image = UIImage(data: data)
}
}
// ✅ 클로저·프로토콜에도 붙일 수 있다
@MainActor protocol ViewUpdating {
func refresh()
}
func schedule(_ work: @MainActor @escaping () -> Void) {
Task { @MainActor in work() }
}"DispatchQueue.main.async는 백그라운드에서 돈다"는 완전히 틀리다 — 메인 큐 = 메인 스레드다. global().async와 헷갈린 것이다. 또 "async 블록은 넣은 그 순간 바로 다음에 실행된다"도 틀리다 — 현재 콜스택이 런루프로 풀려야 실행된다. 그리고 "정확한 지연 시간을 준다"는 오해도 있는데, asyncAfter조차 최소 지연 보장일 뿐 런루프가 바쁘면 더 늦게 실행된다(Q6과 같은 이유).
통화하는 도중에 "아 맞다, 이따 세탁물 찾아와야지" 하고 떠오르는 순간을 생각해 보자. 그렇다고 전화를 끊고 곧장 세탁소로 뛰어가진 않는다. 할 일 목록 맨 아래에 적어 두고, 통화를 끝낸 다음 목록을 다시 본다. DispatchQueue.main.async가 정확히 이거다. 적는 순간이 아니라 지금 하던 콜백이 끝나고 런루프로 돌아왔을 때 실행된다. 그래서 A를 말하고, C를 적어 두고, B를 말한 다음, 맨 마지막에 C가 나온다. 순서가 이상한 게 아니라 "적기"와 "하기"가 다른 시점인 것뿐이다.
같은 그림으로 main.sync가 왜 앱을 얼려 버리는지도 보인다. "이 심부름이 끝날 때까지 전화를 안 끊고 기다리겠다"고 버티는 셈인데, 그 심부름은 전화를 끊어야 시작된다. 서로가 서로를 기다리다 영원히 멈춘다(deadlock). 하나 더 — 이 할 일 목록은 스크롤 중에도 꼭 확인하는 목록(common 모드)에 올라가 있어서, 스크롤하면 멈춰 버리는 .default 모드 Timer와 달리 손가락을 대고 있는 동안에도 계속 처리된다.
꼬리 질문
이미 메인 스레드에서 DispatchQueue.main.sync { }를 부르면 무슨 일이 일어나는가? 왜 그런가?
즉시 교착(deadlock)으로 앱이 멈춘다. sync는 "이 블록이 메인 큐에서 실행을 끝낼 때까지 현재 스레드를 블로킹하고 기다린다"는 뜻인데, 지금 그 현재 스레드가 바로 메인 스레드다. 메인 스레드는 sync 호출에서 대기하느라 런루프로 돌아가지 못하고, 넣은 블록은 메인 큐가 배수되어야 실행되는데 그 배수를 할 메인 스레드는 이미 대기 중이라, 서로가 서로를 무한히 기다린다. 그래서 "메인일 수도 있는 코드"에서는 sync를 쓰기 전에 Thread.isMainThread로 분기하거나 애초에 sync를 피해야 한다.
같은 순회에서 DispatchQueue.main.async로 넣은 블록과, 그 순회의 CoreAnimation commit(layout)은 어느 것이 먼저 실행되는가? 보장되는가?
일반적으로는 현재 순회의 CA commit(BeforeWaiting 옵저버에서의 layoutSubviews)이 먼저 돌고, async 블록은 그 다음 서비스 지점에서 실행되는 경향이 있다. 그래서 "layout이 반영된 뒤에 스크롤 위치를 잡는다"류의 트릭이 실무에서 통한다. 하지만 이는 공식 보장이 아니다. 실행 순서는 런루프 옵저버의 우선순위와 메인 큐 배수 소스가 그 순회에서 서비스되는 상대 시점에 달려 있어 OS 버전·상황에 따라 달라질 수 있다. 순서에 의존해야 한다면 async 타이밍에 기대지 말고 layoutIfNeeded()로 layout을 명시적으로 강제한 뒤 후속 작업을 하는 편이 견고하다.
async 블록이 돌아서, "한 틱 미루면 크기가 잡혀 있다"는 요령이 실무에서 잘 통한다. 하지만 그건 관습이지 약속이 아니라 OS가 바뀌면 깨질 수 있다. 크기가 꼭 필요하면 미루지 말고 layoutIfNeeded()로 "지금 계산해"라고 시키는 쪽이 안전하다.@MainActor 컨텍스트로의 await hop과 DispatchQueue.main.async는 실행 시점 관점에서 어떻게 같고 다른가?
공통점은 둘 다 결국 메인 스레드에서, 현재 실행 단위가 끝나 제어가 풀린 뒤에 이어서 실행된다는 점이다. 차이는 스케줄러다. DispatchQueue.main.async는 메인 런루프의 배수 소스에 얹혀 순회 단위로 실행되지만, @MainActor로의 await hop은 MainActor executor가 스케줄한다. Swift 6/최신 런타임에서 MainActor는 기본적으로 메인 런루프와 협조하지만 별도의 실행자 경로를 가지므로, 두 방식으로 예약한 작업 사이의 상대 순서나 우선순위가 항상 같다고 단정할 수 없다. 또 await는 값 반환·에러 전파·취소(cancellation)를 언어 차원에서 다루므로 단순 블록 예약인 async보다 의도가 명확하고 안전하다.
await 쪽은 결과값·에러·취소까지 언어가 챙겨 준다.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 자동 대응).
import UIKit
// Timer는 런루프 예약이라 프레임과 어긋난다.
// CADisplayLink는 vsync에 동기화된다. 단 ProMotion 아이폰에서 60Hz를 넘기려면
// Info.plist에 CADisableMinimumFrameDurationOnPhone = YES가 있어야 한다(iPad Pro는 불필요).
final class GaugeView: UIView {
private var link: CADisplayLink?
private var progress: CGFloat = 0
func start() {
let l = CADisplayLink(target: self, selector: #selector(step(_:)))
l.add(to: .main, forMode: .common) // ✅ 스크롤 중에도 유지
link = l
}
// ⚠️ CADisplayLink는 target을 강하게 붙든다 — self → link → self 순환이라 deinit은 절대
// 안 불린다. 아래 DisplayLinkProxy로 약참조를 끼우거나, viewDidDisappear 등에서
// 소유자가 직접 stop()을 불러야 한다.
func stop() {
link?.invalidate()
link = nil
}
deinit { stop() }
@objc private func step(_ link: CADisplayLink) {
// ✅ delta time 기반 — 60Hz든 120Hz든 같은 속도로 움직인다
let dt = link.targetTimestamp - link.timestamp
progress += CGFloat(dt) * 0.5 // 초당 0.5씩
if progress >= 1 { progress = 1; stop() }
setNeedsDisplay()
}
// ✅ 필요한 주사율을 명시하면 시스템이 전력을 아낀다
func setLowPowerMode() {
link?.preferredFrameRateRange = CAFrameRateRange(
minimum: 20, maximum: 30, preferred: 30)
}
}
// ✅ target-action의 순환을 피하려면 프록시를 둔다
final class DisplayLinkProxy {
weak var target: AnyObject?
let action: Selector
init(target: AnyObject, action: Selector) {
self.target = target; self.action = action
}
@objc func fire(_ link: CADisplayLink) {
_ = target?.perform(action, with: link)
}
}② 백그라운드 주기 작업은 DispatchSourceTimer가 더 낫다 — 런루프에 의존하지 않고 leeway로 전력/정밀도 트레이드오프를 명시할 수 있다.
import Foundation
// 런루프에 의존하지 않고, leeway로 전력·정밀도 트레이드오프를 명시할 수 있다.
final class SyncScheduler {
private var timer: DispatchSourceTimer?
private let queue = DispatchQueue(label: "com.app.sync", qos: .utility)
func start(interval: TimeInterval) {
let t = DispatchSource.makeTimerSource(queue: queue)
// leeway = "이만큼 늦어도 된다" → 시스템이 다른 타이머와 묶어 깨우기를 줄인다
t.schedule(deadline: .now() + interval,
repeating: interval,
leeway: .milliseconds(Int(interval * 100))) // 10% 여유
t.setEventHandler { [weak self] in
self?.performSync()
}
t.resume()
timer = t // ⚠️ 강참조로 보관하지 않으면 즉시 취소된다
}
func stop() {
timer?.cancel() // ✅ 확실하게 멈춘다
timer = nil
}
private func performSync() {}
}
// ⚠️ suspend/resume 균형을 맞춰야 한다 — suspend 상태로 해제하면 크래시
final class Careful {
private var timer: DispatchSourceTimer?
private var isSuspended = false
func pause() {
guard !isSuspended else { return }
timer?.suspend(); isSuspended = true
}
func resume() {
guard isSuspended else { return }
timer?.resume(); isSuspended = false
}
deinit {
if isSuspended { timer?.resume() } // ✅ suspend 상태로 버리면 안 된다
timer?.cancel()
}
}
// ✅ Swift Concurrency 버전 — 취소가 구조적으로 따라온다
func periodic(interval: Duration, _ work: @escaping () async -> Void) -> Task<Void, Never> {
Task {
while !Task.isCancelled {
await work()
try? await Task.sleep(for: interval)
}
}
}③ 스크롤 중에도 멈추면 안 되는 타이머는 반드시 .common 모드로 등록한다. 반대로 카운트다운 정확도가 중요하면 발화에 의존하지 말고 목표 시각과 현재 시각의 차이를 매 발화마다 다시 계산한다.
import Foundation
// 타이머 발화는 밀린다. "1초마다 1씩 빼기"로 카운트다운을 만들면
// 발화가 밀린 만큼 실제 시간과 어긋난다.
// ❌ 발화 횟수에 의존 — 앱이 바쁘면 카운트다운이 느려진다
final class BrokenCountdown {
private var remaining = 300
private var timer: Timer?
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.remaining -= 1 // 💥 발화가 3번 밀리면 3초가 사라진다
}
}
}
// ✅ 목표 시각을 고정하고 매번 차이를 다시 계산한다
@MainActor
final class Countdown {
private var deadline: Date?
private var timer: Timer?
var onTick: ((TimeInterval) -> Void)?
func start(duration: TimeInterval) {
deadline = Date().addingTimeInterval(duration)
let t = Timer(timeInterval: 0.1, repeats: true) { [weak self] _ in
self?.tick()
}
RunLoop.main.add(t, forMode: .common) // ✅ 스크롤 중에도
timer = t
tick()
}
private func tick() {
guard let deadline else { return }
let remaining = max(0, deadline.timeIntervalSinceNow) // ✅ 항상 정확
onTick?(remaining)
if remaining == 0 { stop() }
}
func stop() { timer?.invalidate(); timer = nil; deadline = nil }
}
// ⚠️ 벽시계(Date)는 사용자가 바꾸거나 NTP 보정으로 점프할 수 있다.
// 경과 시간 측정에는 단조 시계를 쓴다.
final class MonotonicCountdown {
private var start: ContinuousClock.Instant?
private let duration: Duration
init(duration: Duration) { self.duration = duration }
func begin() { start = ContinuousClock.now }
var remaining: Duration {
guard let start else { return duration }
let elapsed = ContinuousClock.now - start
return elapsed >= duration ? .zero : duration - elapsed
}
}
// ⚠️ 백그라운드 동안 타이머는 멈춘다 — 복귀 시 deadline과 현재 시각의 차이로
// 한 번에 따라잡아야 한다. 위 구조는 그게 자동으로 된다."Timer는 설정한 간격마다 정확히 울린다"는 틀리다 — 런루프가 바쁘거나 모드가 안 맞으면 늦거나 멈춘다. "tolerance를 0으로 두면 정확해진다"도 오해다. tolerance 0은 추가 여유를 안 준다는 뜻일 뿐, 런루프 점유로 인한 지연까지 없애 주지는 못한다. 또 "반복 타이머는 시간이 지날수록 점점 밀려 누적 오차가 커진다"도 일반적으로는 틀리다 — CFRunLoopTimer는 원래 스케줄 기준으로 다음 발화를 재계산해 누적 드리프트를 막는다(개별 발화만 흔들린다).
설거지하는 사람한테 "3분 뒤에 가스불 좀 꺼 줘"라고 부탁했다고 해 보자. 3분이 되는 순간 그 사람 손이 저절로 움직이진 않는다. 지금 닦던 그릇을 마저 끝내고 고개를 들었을 때 비로소 "아, 3분 지났네" 하고 끈다. Timer가 딱 이 수준이다 — 시계가 앱을 강제로 찔러 주는 게 아니라, 런루프가 한 바퀴 돌다가 "이 시각 지났네" 하고 챙기는 예약이다. 그러니 메인이 바쁘면 그만큼 늦고, 스크롤 중처럼 "지금은 그 목록 안 봄"인 모드에 걸리면 아예 안 울린다. 앱이 백그라운드로 내려가 잠들면 부탁받은 사람이 자리를 뜬 셈이라 타이머도 멈춘다.
그런데 신기하게도 10분 뒤에 세어 보면 총 횟수는 얼추 맞는다. 학교 시간표를 떠올리면 된다. 1교시가 5분 늦게 끝나도 2교시 시작 시각은 원래 시간표 그대로다. Timer도 "방금 울린 시각 + 1초"가 아니라 "처음 기준 시각 + N초"로 다음 발화를 다시 계산해서, 지연이 뒤로 쌓이지 않는다. 종소리 하나하나는 흔들려도 하루 전체 일정은 안 밀리는 셈이다. 그래도 카운트다운 화면은 tick 횟수를 세지 말고, 매 발화마다 "끝나는 시각 − 지금 시각"을 다시 계산해 보여 주는 편이 안전하다.
꼬리 질문
1초 카운트다운을 10분간 Timer로 돌렸을 때, 개별 지터는 있어도 총 경과가 크게 안 틀리는 이유는?
CFRunLoopTimer는 다음 발화 시각을 "직전에 실제로 울린 시각 + 1초"가 아니라 "최초 기준 시각 + N초"라는 절대 스케줄로 계산하기 때문이다. 그래서 어떤 발화가 런루프 점유로 30ms 늦게 울려도 다음 발화 목표는 그만큼 앞당겨져 오차가 상쇄되고, 지연이 다음 회차로 누적되지 않는다. 결과적으로 개별 tick은 흔들려도 발화 시각은 원래 스케줄 위에 남아 마지막 발화가 최초 기준 시각 + 600초 근처에 온다. 다만 지연이 한 간격(1초)을 넘긴 구간은 한 번만 발화하므로 tick 횟수는 600회보다 적어질 수 있다 — 그래서 카운트다운은 횟수를 세지 말고 매 발화마다 (목표 종료 시각 − 현재 시각)을 다시 계산해야 한다. 다만 정확한 카운트다운 UI를 원한다면 tick 횟수를 세지 말고 매 발화마다 (목표 종료 시각 − 현재 시각)을 다시 계산해 표시하는 편이 안전하다.
Timer가 후자라서, 종소리 하나하나는 흔들려도 10분은 10분에 가깝게 맞는다.CADisplayLink와 Timer는 정밀도·용도 면에서 어떻게 다르며, 60Hz와 120Hz 기기에서 콜백 빈도는 어떻게 달라지는가?
Timer는 임의 간격을 런루프에 예약하는 범용 타이머라 화면 갱신과 무관하게 지터가 있는 반면, CADisplayLink는 디스플레이의 vsync에 콜백을 동기시켜 "매 프레임 직전"에 정확히 불린다. 따라서 프레임 단위로 값을 갱신하는 애니메이션·게이지에는 CADisplayLink가 맞다. 콜백 빈도는 주사율을 따라가 60Hz에서는 초당 약 60회, ProMotion 아이폰은 기본 상한이 60Hz라 Info.plist에 CADisableMinimumFrameDurationOnPhone = YES를 넣어야 비로소 최대 약 120회까지 올라간다(iPad Pro는 이 키 없이도 120Hz를 쓴다). 다만 ProMotion은 가변 주사율이라 실제 빈도는 상황에 따라 낮아질 수 있고, preferredFrameRateRange로 원하는 범위를 지정하며 timestamp/targetTimestamp로 프레임 간 실제 경과를 읽어 보간에 반영해야 한다.
Timer는 벽시계를 보다가 "1초 됐네" 하는 것이고, CADisplayLink는 화면이 새로 그려지는 박자에 손뼉을 맞추는 것이다. 화면 위에서 움직이는 걸 그린다면 박자에 붙어야 어긋나 보이지 않는다. 박자 수는 기기에 따라 초당 60번이 되기도 120번이 되기도 하니, 호출 횟수를 세지 말고 timestamp 차이로 실제 흐른 시간을 읽어야 한다.앱이 백그라운드에 있을 때 정확한 시각에 뭔가를 하려면 Timer 대신 무엇을 써야 하는가(로컬 알림/백그라운드 태스크)?
Timer는 앱이 suspend되면 정지하므로 백그라운드 정시 실행에는 부적합하다. 사용자에게 특정 시각을 알리는 것이 목적이면 UNUserNotificationCenter의 로컬 알림을 예약한다 — 이건 앱이 죽어 있어도 시스템이 대신 발화한다. 앱을 잠깐 깨워 데이터를 처리해야 하면 BGTaskScheduler(BGAppRefreshTask/BGProcessingTask)를 쓰는데, 이건 정확한 시각 보장이 아니라 시스템이 여건 될 때 실행하는 기회주의적 스케줄링이다. 포그라운드 세션을 잠깐 연장하려면 beginBackgroundTask로 수십 초를 벌 수 있지만 이 역시 정시 트리거가 아니다. 즉 "정확한 시각 알림"은 로컬 알림, "언젠가 갱신"은 BGTaskScheduler로 목적을 나눠야 한다.
Timer는 그냥 멈춘다. "7시에 꼭 알려 줘"는 시스템이 대신 울려 주는 로컬 알림에 맡기고, "틈날 때 데이터만 받아 둬"는 BGTaskScheduler에 맡긴다. 뒤쪽은 시각 약속이 아니라 "여건 되면"이라 정시를 기대하면 안 된다.