RunLoop — iOS의 이벤트 처리 루프로, CFRunLoop(Core Foundation) 및 NSRunLoop(Foundation) 객체로 구현됩니다. 이벤트(터치, 타이머, 입력 소스, 알림)를 대기하고 스레드의 해당 핸들러로 디스패치하는 메커니즘입니다. iOS의 각 스레드는 최대 하나의 RunLoop를 가지지만, Main Thread에 대해서만 자동으로 생성됩니다. Apple CFRunLoop Documentation에 따르면, RunLoop는 백그라운드 스레드에서 타이머, 애니메이션 및 소스 모니터링 작동에 필수적입니다.
핵심 요약
RunLoop — 스레드에서 이벤트 처리를 구성하는 Core Foundation 인프라 객체입니다. 본질적으로는 이벤트(sources)의 도착을 기다리고 핸들러에 전달하는 무한 루프(while true)입니다. 이벤트가 없으면 RunLoop는 스레드를 절전 모드(sleep)로 전환하여 배터리 전력을 절약합니다. 이벤트가 도착하면 스레드가 깨어나 처리하고 다시 잠듭니다. RunLoop는 iOS/macOS(XNU + Core Foundation)에만 존재합니다 — Android에서는 Looper가 그 역할을 수행합니다.
각 스레드는 최대 하나의 RunLoop를 가지며, 첫 번째 액세스 시 지연(lazy) 생성됩니다. Main Thread의 경우 RunLoop는 애플리케이션 시작 시 자동으로 생성됩니다. 백그라운드 스레드의 경우 CFRunLoopGetCurrent() 또는 RunLoop.current가 호출될 때까지 RunLoop가 생성되지 않습니다. 메인 RunLoop는 터치 이벤트 처리, 화면 렌더링, DispatchQueue.main 블록 실행 및 Core Animation 레이어 관리를 담당합니다.
RunLoop는 스레드가 아닙니다 — 스레드 내부의 메커니즘입니다. 스레드는 RunLoop 없이 존재할 수 있지만(동기 작업을 실행하고 종료하는 경우), RunLoop는 스레드 없이 존재할 수 없습니다. 활성 RunLoop가 있는 스레드에 이벤트가 없으면 CPU를 차단하지 않고 대기(waiting) 상태가 됩니다 — 이것이 CPU를 100% 소비하는 busy-wait 루프와의 주요 차이점입니다.
RunLoop는 두 가지 유형의 이벤트 소스를 처리합니다: Input Sources(입력 소스)와 Timer Sources(타이머). Input Sources는 비동기 이벤트를 전달합니다: 터치, 마우스 이동, 소켓 데이터, 다른 스레드의 메시지(performSelector:onThread:). Timer Sources는 일정에 따라 동기 이벤트를 전달합니다: NSTimer, CADisplayLink. 또한 RunLoop 상태를 모니터링하기 위한 Observers도 존재합니다.
RunLoop 사이클은 순차적 단계로 구성됩니다: 모드 진입(kCFRunLoopEntry), 타이머 처리(kCFRunLoopBeforeTimers), 입력 소스 처리(kCFRunLoopBeforeSources), 소스 처리(kCFRunLoopAfterWaiting), 대기(sleep), 모드 종료(kCFRunLoopExit). 현재 반복에서 이벤트가 처리되지 않으면 RunLoop는 새 이벤트가 스레드를 깨울 때까지 무기한 절전 상태로 전환합니다.
import Foundation
// Observer를 통한 RunLoop 단계 데모
func observeRunLoopActivities() {
let observer = CFRunLoopObserverCreateWithHandler(
nil,
CFOptionFlags([[.entry, .beforeTimers, .beforeSources,
.afterWaiting, .exit]]),
true, // repeats
0 // priority
) { observer, activity in
switch activity {
case .entry:
print("Entry — RunLoop 활성화")
case .beforeTimers:
print("BeforeTimers — 타이머 처리")
case .beforeSources:
print("BeforeSources — 소스 처리")
case .afterWaiting:
print("AfterWaiting — 절전 후 깨어남")
case .exit:
print("Exit — RunLoop 종료")
default:
break
}
}
CFRunLoopAddObserver(
CFRunLoopGetCurrent(),
observer,
.commonModes
)
}
// 예: RunLoop가 메인 스레드에서 타이머 처리
func timerOnMainRunLoop() {
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
print("Tick: \(Date())")
}
// Main Thread의 RunLoop.current.run()은 UIApplicationMain에 의해 호출됨
// 자동으로 — 수동으로 시작할 필요 없음
RunLoop.current.run() // 이 호출은 Main Thread로 반환되지 않음
}
예제 observeRunLoopActivities는 메인 RunLoop에 Observer를 등록하여 사이클의 각 단계를 로그에 기록합니다. 이는 디버깅에 유용합니다: .beforeTimers와 .afterWaiting 사이에 긴 간격이 있으면 Main Thread의 작업이 RunLoop를 차단하고 있음을 의미합니다. timerOnMainRunLoop는 NSTimer가 메인 RunLoop에서 자동으로 작동하는 방식을 보여줍니다 — Timer.scheduledTimer는 현재 RunLoop의 기본 모드(.default mode)에 타이머를 추가합니다.
Android Looper — RunLoop에 해당합니다. Looper.prepare()는 스레드에 메시지 큐(MessageQueue)를 생성하고, Looper.loop()가 무한 처리 루프를 시작합니다. Handler가 메시지와 Runnable을 이 큐로 전송합니다. 주요 차이점: RunLoop는 모드(modes)를 지원하지만 Android Looper는 지원하지 않습니다. Looper는 모드별 필터링 없이 모든 메시지를 처리하므로 더 단순하지만 우선순위 기반 시나리오(iOS에서 스크롤이 .tracking 모드에서 다른 이벤트와 별도로 처리되는 경우 등)에서는 유연성이 떨어집니다.
RunLoop Mode — 현재 활성화된 소스, 타이머 및 관찰자들의 집합입니다. 모드를 사용하면 우선순위에 따라 이벤트 처리를 분리할 수 있습니다. 사용자가 UITableView를 스크롤하면 RunLoop가 .tracking 모드로 전환되어 스크롤 이벤트와 관련 타이머/애니메이션만 처리됩니다. 다른 모든 소스(NSURLConnection 등)는 스크롤 모드가 종료될 때까지 일시 중단됩니다.
세 가지 주요 모드: .default(NSDefaultRunLoopMode) — 스크롤을 제외한 모든 이벤트가 처리되는 기본 모드; .tracking(UITrackingRunLoopMode) — 스크롤 또는 제스처 내비게이션 시 활성화; .common(NSRunLoopCommonModes) — 독립적인 모드가 아니라 .default + .tracking을 포함하는 별칭 세트입니다. .commonModes에 소스를 추가하면 세트의 모든 모드에 자동으로 추가됩니다.
| 모드 | Core Foundation 상수 | Foundation 상수 | 활성 상태 |
|---|---|---|---|
| .default | kCFRunLoopDefaultMode | RunLoop.Mode.default | 정상 상태, 스크롤 없음 |
| .tracking | UITrackingRunLoopMode | RunLoop.Mode.tracking | 스크롤, gesture recognizers |
| .common | kCFRunLoopCommonModes | RunLoop.Mode.common | 의사 모드: default + tracking |
| .initialRun | kCFRunLoopInitialRunRunLoopMode | — | RunLoop 첫 실행 |
전형적인 문제: NSTimer를 .default 모드에 추가하면 스크롤 중 RunLoop가 .tracking 모드로 전환되어 .default의 타이머를 처리하지 않기 때문에 작동이 중단됩니다. 해결책 — 타이머를 .commonModes에 추가: RunLoop.current.add(timer, forMode: .common). 이렇게 하면 Timer가 .default와 .tracking 모두에서 작동합니다. 대안 — NSTimer 대신 DispatchQueue.main.async 사용(GCD는 스레드 수준에서 작동하며 RunLoop 모드에 의존하지 않음).
백그라운드 스레드에는 기본적으로 RunLoop가 없습니다. 백그라운드 스레드에서 NSTimer를 시작하거나, NSInputStream/NSOutputStream을 처리하거나, performSelector:에 응답해야 하는 경우 수동으로 RunLoop를 생성하고 시작해야 합니다. RunLoop가 없으면 타이머와 performSelector:는 절대 작동하지 않습니다 — 스레드가 코드를 실행하고 이벤트를 기다리지 않고 종료됩니다.
백그라운드 스레드에서 RunLoop를 생성하려면 스레드 끝에서 RunLoop.current.run()을 호출하기만 하면 됩니다. 이 호출은 스레드를 무기한 차단하고 이벤트를 처리합니다. 중지하려면 CFRunLoopStop(CFRunLoopGetCurrent())를 사용합니다. 중요: RunLoop.current는 첫 번째 액세스 시 RunLoop를 지연 생성합니다 — run()을 호출하지 않으면 이벤트를 처리하지 않습니다. 패턴: 소스 구성 → RunLoop에 추가 → run() 호출.
import Foundation
// 자체 RunLoop를 가진 백그라운드 스레드
class BackgroundRunLoopManager {
private let thread: Thread
private var isRunning = false
init() {
thread = Thread { [weak self] in
// RunLoop.current 호출 시 RunLoop가 자동으로 생성됨
let runLoop = RunLoop.current
// RunLoop를 활성 상태로 유지하기 위한 포트 추가
runLoop.add(Port(), forMode: .default)
// 이벤트 처리 시작
var isFinished = false
while !isFinished {
// run(mode:before:)는 이벤트가 처리된 경우 true 반환
isFinished = !runLoop.run(mode: .default, before: Date.distantFuture)
}
}
thread.name = "com.app.background-runloop"
}
func start() {
thread.start()
isRunning = true
}
func stop() {
// 백그라운드 스레드에서 RunLoop 중지
self.perform(
#selector(BackgroundRunLoopManager.stopRunLoop),
on: thread,
with: nil,
waitUntilDone: false
)
}
@objc
private func stopRunLoop() {
CFRunLoopStop(CFRunLoopGetCurrent())
isRunning = false
}
}
// 사용법: 백그라운드 RunLoop의 타이머
let manager = BackgroundRunLoopManager()
manager.start()
// performSelector를 통한 백그라운드 RunLoop 작업 전송
manager.perform(
#selector(BackgroundRunLoopManager.backgroundTask),
on: manager.thread,
with: nil,
waitUntilDone: false
)
BackgroundRunLoopManager는 영구적인 RunLoop를 가진 백그라운드 스레드를 생성합니다. 빈 Port() 추가가 필요합니다 — 소스가 없으면 RunLoop.run()이 false를 반환하고 종료되기 때문입니다. performSelector:onThread:는 백그라운드 RunLoop에 메시지를 전송합니다 — RunLoop가 BeforeSources 단계에 진입할 때 처리됩니다. Stop은 백그라운드 스레드에서 CFRunLoopStop을 호출하여 루프를 종료합니다.
NSTimer는 RunLoop가 BeforeTimers 단계에서 처리하는 타이머 이벤트를 생성합니다. 타이머는 repeating(반복)과 non-repeating(일회성)이 있습니다. NSTimer는 정확성을 보장하지 않습니다: RunLoop가 긴 작업으로 차단된 경우 타이머는 차단 해제 후 작동하며, 놓친 모든 작동은 하나로 통합됩니다(repeating 타이머의 경우 최대 하나의 따라잡기 작동).
CADisplayLink — 화면 재생 빈도(60/120/144 Hz)와 동기화된 특수 타이머입니다. 애니메이션 및 비디오 업데이트에 사용됩니다. CADisplayLink는 RunLoop에 추가되며 각 렌더링 프레임 전에 작동합니다(Core Animation이 레이어를 렌더링으로 보내기 전). 프레임이 건너뛰어진 경우(display link가 16ms 내에 작동하지 못한 경우), 다음 호출은 다음 VSync 사이클에서 발생합니다.
import UIKit
class AnimationController {
private var displayLink: CADisplayLink?
private var displayLinkTimer: Timer?
private var startTime: CFTimeInterval = 0
// CADisplayLink — vsync를 사용한 애니메이션
func startDisplayLinkAnimation() {
displayLink = CADisplayLink(target: self,
selector: #selector(step))
// .common 모드에 추가 — 스크롤 시에도 작동
displayLink?.add(to: .current, forMode: .common)
startTime = CACurrentMediaTime()
}
@objc
private func step(displayLink: CADisplayLink) {
let elapsed = CACurrentMediaTime() - startTime
// 각 프레임에서 호출됨(60 FPS → 약 16.6ms마다)
print("Frame at \(elapsed) seconds")
if elapsed > 5.0 {
displayLink.invalidate() // 5초 후 중지
}
}
// NSTimer — 주기적 작업
func startTimerInCommonMode() {
displayLinkTimer?.invalidate()
displayLinkTimer = Timer.scheduledTimer(
withTimeInterval: 1.0,
repeats: true
) { [weak self] timer in
print("Timer tick")
}
// 중요: .common에 추가하지 않으면 스크롤 시 타이머가 멈춤
RunLoop.current.add(displayLinkTimer!, forMode: .common)
}
func stop() {
displayLink?.invalidate()
displayLinkTimer?.invalidate()
}
}
AnimationController에서 CADisplayLink는 .common 모드에 추가되어 스크롤과 관계없이 각 프레임에서 step 호출이 보장됩니다. displayLink.add(to: .current, forMode: .common) — 스크롤 시 중단되어서는 안 되는 애니메이션의 표준 패턴입니다. NSTimer도 스크롤 중에 작동하도록 .common 모드에 추가됩니다. 이것이 없으면 타이머는 .default 모드에서만 작동합니다.
CFRunLoopObserver — RunLoop 단계를 추적하는 메커니즘입니다. Observer를 사용하면 모드 진입, 타이머 처리 시작, 소스 처리 시작, 절전 후 깨어남, 모드 종료에 대한 알림을 받을 수 있습니다. Observer는 프레임워크에서 자체 목적으로 사용합니다: Core Animation은 RunLoop 절전 전 레이어 렌더링에 사용하고, UIKit은 이벤트 처리 후 레이아웃 업데이트에 사용합니다.
개발자도 자체 목적으로 Observer를 추가할 수 있습니다. 예: 이벤트 처리 시간 측정(프로파일링), RunLoop 절전 전 지연 작업 실행(UI가 이미 업데이트되고 사용자가 상호작용하지 않는 경우), 장기 비활성 시 자동 데이터 저장. Observer는 CFRunLoopAddObserver를 통해 모드와 모니터링할 활동의 비트 마스크를 지정하여 등록됩니다.
Observer에 가장 유용한 지점: .afterWaiting — RunLoop 깨어난 후 실행되며 이벤트 처리 후 시작되어야 하는 코드를 포함할 수 있음; .beforeTimers — 타이머 처리 전, 이전 처리 이후 경과 시간 측정 가능; .exit — RunLoop 중지 시 작동, 백그라운드 스레드 리소스 정리에 유용.
CFRunLoopStop — 현재 RunLoop 반복을 강제로 종료하는 함수입니다. CFRunLoopStop(CFRunLoopGetCurrent())를 호출하면 RunLoop가 현재 이벤트 처리를 종료하고 run()에서 false를 반환하며 종료됩니다. 이것이 백그라운드 스레드에서 RunLoop를 중지하는 표준 방법입니다. Main Thread에서는 CFRunLoopStop이 권장되지 않습니다 — 메인 RunLoop는 애플리케이션 수명 내내 작동해야 합니다. 백그라운드 스레드의 경우 CFRunLoopStop 후 스레드가 종료되거나 run() 이후의 코드 실행을 계속할 수 있습니다.
자주 묻는 질문
RunLoop — iOS의 이벤트 처리 루프로, CFRunLoop(Core Foundation)와 NSRunLoop(Foundation)으로 구현됩니다. 이벤트(터치, 타이머, 입력 소스)를 대기하고 스레드의 핸들러로 디스패치합니다. 각 스레드는 하나의 RunLoop를 가질 수 있지만, 자동으로 생성되는 것은 Main Thread뿐입니다. RunLoop는 모드(.default, .tracking, .common)를 관리하여 우선순위에 따라 처리를 분리합니다.
NSTimer는 기본적으로 RunLoop의 .default 모드에 추가됩니다. 사용자가 스크롤하면 RunLoop가 .tracking 모드로 전환되어 .default의 타이머를 처리하지 않습니다. 해결책: RunLoop.current.add(timer, forMode: .common)을 사용하여 타이머를 .common 모드에 추가합니다. .common은 .default와 .tracking을 통합하므로 타이머가 두 모드 모두에서 작동합니다.
필요한 경우는 백그라운드 스레드가 타이머(NSTimer), performSelector:onThread:, NSInputStream/NSOutputStream 또는 Source 이벤트를 사용하는 경우뿐입니다. 스레드가 동기 작업(파일 다운로드, 계산)을 실행하고 종료되는 경우 RunLoop가 필요하지 않습니다. 시작하려면 소스 구성 후 RunLoop.current.run()을 호출합니다. 중지하려면 — CFRunLoopStop(CFRunLoopGetCurrent()).
RunLoop는 스레드 수준에서 작동하며 모드(modes)를 지원하여 이벤트를 순차적으로 처리합니다. DispatchQueue — 스레드 풀의 추상화로, 작업은 사용 가능한 스레드에서 실행됩니다. GCD는 모드를 지원하지 않으며 RunLoop와 독립적으로 작동합니다. DispatchQueue.main은 블록 실행에 메인 RunLoop를 사용합니다 — 이것이 유일한 연결점입니다. 백그라운드 작업에는 GCD가 권장됩니다.
CADisplayLink — VSync(화면 재생 빈도)와 동기화된 타이머입니다. RunLoop에 추가되어 BeforeTimers 단계에서 각 렌더링 프레임 전에 작동합니다. CADisplayLink는 Main Thread에서만 작동합니다. 화면 렌더링이 그곳에서 이루어지기 때문입니다. 스크롤 시 중단 없는 애니메이션을 위해 .common 모드에 추가하세요: displayLink.add(to: .current, forMode: .common).
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.