"3초 뒤에 실행해줘"를 Swift로 쓰는 방법은 최소 세 가지예요.
// A
Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in work() }
// B
let t = DispatchSource.makeTimerSource(queue: q)
t.schedule(deadline: .now() + 3)
t.setEventHandler { work() }
t.resume()
// C
try await Task.sleep(for: .seconds(3))
work()
저는 그동안 이 셋을 그냥 취향 차이 정도로 생각했어요. 델리게이트냐 클로저냐처럼요.
그런데 어느 날 반복 재생 주기가 자꾸 밀리는 걸 보고 재보니까... 셋이 실제로 실행되는 시각이 전부 달랐습니다.
더 놀란 건 이거였어요.
Task.sleep(for: .seconds(1))의 실제 오차가 평균 52ms였는데,tolerance: .zero한 줄을 붙이니까 3.1ms가 됐어요. 17배 차이인데 인자 하나 차이입니다.
왜 이런 일이 생기는지 궁금해서 Apple 공개 소스를 직접 뒤지고, 측정 코드를 짜서 돌려봤어요.
1. 가장 밑 바닥: 커널은 시간을 어떻게 재나요?
먼저 오해 하나를 걷어낼게요. 앱이 초를 세는 게 아니에요.
어떤 방식을 쓰든 시계를 들고 있는 건 결국 커널이고, 우리 스레드는 잠들어 있다가 커널이 깨워줍니다.
그런데 여기서 재밌는 게 나와요. iOS/macOS 커널(XNU)에는 알람을 부탁하는 창구가 두 개 있어요.
역사적인 이유예요. Apple이 NeXTSTEP(Mach 마이크로커널 기반)을 가져와서 macOS를 만들고, 그 위에 BSD 유저랜드를 얹었거든요. 그래서 커널 안에 Mach 계층과 BSD 계층이 같이 살고 있어요. 창구가 둘인 거죠.

여기서 이미 결론의 절반이 나와요. Timer만 다른 동네에 살고 있습니다. 이게 왜 중요한지 하나씩 볼게요.
2. Timer
Timer(옛날 이름 NSTimer)는 셋 중 제일 오래됐어요. 1990년대 NeXTSTEP 시절 유산입니다. 내부는 CoreFoundation의 CFRunLoopTimer이고, 고맙게도 소스가 공개돼 있어요.
iOS의 Timer는 Mach 타이머를 직접 씁니다
#if DEPLOYMENT_TARGET_MACOSX
#define USE_DISPATCH_SOURCE_FOR_TIMERS 1
#define USE_MK_TIMER_TOO 1
#else
#define USE_DISPATCH_SOURCE_FOR_TIMERS 0 // ← iOS는 여기!
#define USE_MK_TIMER_TOO 1
#endif
CFRunLoop.c:83-88
이 매크로가 갈림길이에요. 실제로 타이머를 커널에 등록하는 __CFArmNextTimerInMode()를 보면요:
#if USE_DISPATCH_SOURCE_FOR_TIMERS
...
if (leeway > 0) {
// Only use the dispatch timer if we have any leeway
// <rdar://problem/14447675>
_dispatch_source_set_runloop_timer_4CF(rlm->_timerSource, deadline, DISPATCH_TIME_FOREVER, leeway);
} else {
mk_timer_arm(rlm->_timerPort, __CFUInt64ToAbsoluteTime(nextSoftDeadline));
}
#else
if (rlm->_timerPort) {
mk_timer_arm(rlm->_timerPort, ...); // ← iOS는 무조건 이 길
}
#endif
CFRunLoop.c:1936-1975
"Only use the dispatch timer if we have any leeway". Apple 내부 버그 번호 rdar://problem/14447675까지 붙어 있네요:)
Timer는 항상 mk_timer_arm, Mach 커널 타이머 직행입니다.Leeway는 "이 시각에서 이만큼은 늦어도 괜찮아"라고 커널에 알려주는 허용오차입니다.
왜 일부러 늦어도 된다고 할까요? => CPU는 저전력 상태에서 깨우는 건 비싼 일이라, 여유 주면 커널이 비슷한 시각의 타이머들을 한번에 몰아서 처리할 수 있어요
그래서 위 코드가 Leeway가 있으면 disptach타이머를 쓴다 라고 나와있는거죠
tolerance 기본값은 0이에요
Timer에는 tolerance라는 프로퍼티가 있어요. 초기값이 소스에 그대로 적혀 있습니다.
memory->_interval = interval;
memory->_tolerance = 0.0; // ← 기본값
CFRunLoop.c:3707-3710
그런데 setter를 보니까 상한이 걸려 있는데, 조건이 좀 특이했어요.
if (rlt->_interval > 0) {
rlt->_tolerance = MIN(tolerance, rlt->_interval / 2);
} else {
// Tolerance must be a positive value or zero
if (tolerance < 0) tolerance = 0.0;
rlt->_tolerance = tolerance;
}
CFRunLoop.c:3934-3954
반복 타이머일 때만 interval/2로 잘리고, 단발 타이머(repeats: false)는 else로 빠져서 클램프가 없어요. 진짜인가 싶어서 돌려봤습니다.
Timer(timeInterval:1.0, repeats:true) 생성 직후 tolerance = 0.0 ← :3710 확인
tolerance = 10.0 대입 후 실제값 = 0.5 ← interval/2 로 클램프 (:3947)
repeats:false, interval=2.0 에 99.0 대입 → 99.0 ← 클램프 없음!
소스가 말한 그대로네요. 문서에는 잘 안 나오는 동작이라 직접 확인해볼 만했어요.
그래서 Timer는 왜 밀릴까요?
Mach 타이머가 부정확한 게 아니에요. 문제는 알람이 도착한 다음입니다.
mk_timer는 mach port(우편함)에 메시지 한 통을 넣어요. 그리고 RunLoop이 그 우편함 앞에 앉아 있죠. 그런데 RunLoop이 지금 다른 편지(터치 이벤트, 레이아웃, 네트워크 콜백)를 뜯고 있으면? 그게 끝나야 타이머 차례가 와요.
즉 커널은 제때 알렸는데, 우리가 못 받은 거예요. 이건 "가끔 그럴 수도 있다"가 아니라 구조적인 특성이고, Apple 문서도 못 박고 있어요.

실험 3을 그림으로. Timer가 밀리는 건 커널 탓이 아니라 RunLoop 큐에서 줄을 서기 때문이에요.
"A timer is not a real-time mechanism. […] the effective resolution of the time interval for a timer is limited to on the order of 50-100 milliseconds."
— Apple Developer Documentation, Timer
3. DispatchSourceTimer
GCD가 2009년에 들고 온 방식이에요. Mach이 아니라 BSD 계층의 kqueue를 씁니다. libdispatch도 오픈소스예요.
dispatch_kevent_s ke = {
.ident = DISPATCH_KEVENT_TIMEOUT_IDENT_MASK | tidx,
.filter = EVFILT_TIMER, // ← BSD kqueue 타이머
.flags = action | EV_ONESHOT,
.data = (int64_t)target,
#if DISPATCH_HAVE_TIMER_COALESCING
.ext[1] = leeway, // ← leeway가 커널로 넘어가는 지점
#endif
};
libdispatch src/event/event_kevent.c:2376-2402
중요한 건 누가 실행하느냐예요.
알림이 오면 RunLoop을 거치지 않고 지정한 DispatchQueue의 워커 스레드가 바로 집어갑니다. 메인 스레드 줄에 서지 않아요.
"격자"가 유지되는 비밀
repeating:을 주면 libdispatch는 발화 기준점을 누적으로 관리해요. 한 번 늦어도 다음 발화는 원래 자리로 돌아옵니다.
uint64_t missed = (now - dt->dt_timer.target) / dt->dt_timer.interval;
...
if (dt->dt_timer.interval < INT64_MAX) {
uint64_t push_by = missed * dt->dt_timer.interval;
dt->dt_timer.target += push_by; // ← 원래 격자에 맞춰 밀어올림
dt->dt_timer.deadline += push_by;
}
libdispatch src/event/event_internal.h:576-591
늦은 만큼을 interval로 나눠서 몇 번 놓쳤는지 계산하고, 그 배수만큼 target을 밀어올려요.
그래서 드리프트가 쌓이지 않습니다. 대신 놓친 발화는 합쳐져서 한 번만 실행돼요 (따라잡기 폭주 방지).

실험 1을 그림으로. 파란 막대가 sleep, 빨강/초록이 작업이에요. 위쪽은 작업 시간이 계속 더해지죠.
4. Task.sleep
여기가 제가 제일 크게 착각했던 부분이에요. 저는 Task.sleep이 당연히 DispatchSourceTimer랑 같은 거라고 생각했거든요.
아니었습니다. 기본적으로는 dispatch_after를 타요. Swift 런타임 소스를 따라가 볼게요.
if let tolerance = tolerance {
(toleranceSeconds, toleranceNanoseconds)
= durationComponents(for: tolerance, clock: clock)
} else {
toleranceSeconds = 0
toleranceNanoseconds = -1 // ← tolerance 생략하면 -1
}
swift/stdlib/public/Concurrency/TaskSleepDuration.swift:138-157
tolerance를 안 주면 -1이라는 신호값이 넘어가요. 이걸 C++ 런타임이 받습니다.
if (tnsec != -1) { // ← tolerance를 준 경우
dispatch_source_t source =
dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
dispatch_source_set_timer(source, when, DISPATCH_TIME_FOREVER, leeway);
dispatch_activate(source);
} else { // ← 생략한 경우 (기본)
dispatch_after_f(when, queue, (void *)job, &__swift_run_job);
}
swift/stdlib/public/Concurrency/DispatchGlobalExecutor.cpp:340-393
Task.sleep(for:)에서 tolerance를 생략하면 dispatch_after_f,명시하면
dispatch_source_set_timer.같은 API인데 인자 하나로 내부 구현이 통째로 바뀝니다.
그런데 이게 왜 성능 차이로 이어질까요?

인자 하나로 내부 구현이 통째로 갈라집니다. 같은 Task.sleep인데요.
5. leeway
이 글의 핵심 개념이에요.
leeway는 "이 시각부터 이만큼 늦어도 괜찮아"라고 커널에 알려주는 허용 오차예요. 근데 왜 일부러 늦어도 된다고 할까요? 배터리 때문입니다.
CPU를 저전력 상태에서 깨우는 건 비싸요. 타이머들이 제각각 다른 시각에 깨우면 CPU가 계속 깨어나야 하죠. leeway를 주면 커널이 비슷한 시각의 타이머들을 한 번에 몰아서(coalescing) 처리해서 CPU를 한 번만 깨웁니다.

leeway는 커널에게 주는 재량권이에요. 넓을수록 배터리에 좋고 정확도는 떨어져요.
타이머는 시각을 두 개 갖고 있어요
if (interval < INT64_MAX && leeway > interval / 2) {
leeway = interval / 2; // 상한: interval의 절반
}
dtc->dtc_timer.target = target;
dtc->dtc_timer.deadline = target + leeway; // ← deadline = target + leeway
libdispatch src/source.c:1223-1233
target은 "이르면 여기부터", deadline은 "늦어도 여기까지". 커널은 이 구간 안에서 편한 시점에 발화해요. Timer의 soft/hard deadline과 정확히 같은 개념이에요.

Timer의 soft/hard deadline도 정확히 같은 구조예요 (CFRunLoop.c:1913-1914).
🚨 그리고 여기가 진짜 중요한 부분이에요
dispatch_after는 leeway를 자동으로 넣어버립니다.
delta = _dispatch_timeout(when);
...
leeway = delta / 10; // <rdar://problem/13447496>
if (leeway < NSEC_PER_MSEC) leeway = NSEC_PER_MSEC; // 하한 1ms
if (leeway > 60 * NSEC_PER_SEC) leeway = 60 * NSEC_PER_SEC; // 상한 60s
libdispatch src/source.c:1330-1375
Task.sleep(for: .seconds(1))은 최대 1.1초까지, .seconds(60)은 최대 66초까지 늦어도 "정상"입니다.| 요청 | 자동 leeway | 허용 최대 지연 |
|---|---|---|
| 10 ms | 1 ms (하한) | 11 ms |
| 1 s | 100 ms | 1.1 s |
| 60 s | 6 s | 66 s |
| 1 시간 | 60 s (상한) | 1시간 1분 |
이게 Apple 문서가 Task.sleep을 "at least"(최소 그만큼)로만 보장하는 이유예요. 상한은 약속한 적이 없었던 거죠.
6. 실험
소스만 읽고 끝내면 찜찜하잖아요. 전부 swiftc -O로 빌드해서 직접 돌렸고, 각 실험을 2회씩 반복해서 재현되는지도 봤어요.
실험 1 ) 반복 루프에서 오차가 쌓일까?
200ms 간격으로 20번, 매번 60ms짜리 작업을 시켰어요.
| tick | Task.sleep 루프 | 오차 | DispatchSourceTimer | 오차 |
|---|---|---|---|---|
| 1 | 0.2695s | +0.010 | 0.2632s | +0.003 |
| 10 | 2.7062s | +0.646 | 2.0622s | +0.002 |
| 20 | 5.4088s | +1.349 | 4.0621s | +0.002 |
20번 만에 1.35초가 벌어졌어요. 2회차도 1.34초로 똑같았고요.
이유는 명확해요. Task.sleep 루프는 매번 "지금부터 200ms"라서 작업 시간(60ms)이랑 leeway가 계속 더해집니다. 반면 DispatchSourceTimer는 아까 본 target += push_by 덕분에 절대 격자를 유지해요.
실험 2 ) 오차가 요청 시간에 비례할까?
leeway = delta / 10이 맞다면 오차도 비례해야 하잖아요. 각 15회씩 재봤어요.
| 요청 | 평균 오차 | 최대 오차 | 최대오차/요청 | 소스 상한 |
|---|---|---|---|---|
| 0.20 s | 11.37 ms | 13.43 ms | 6.71% | 10% |
| 0.80 s | 47.07 ms | 53.71 ms | 6.71% | 10% |
| 1.60 s | 82.23 ms | 106.86 ms | 6.68% | 10% |
| 3.20 s | 137.78 ms | 212.72 ms | 6.65% | 10% |
요청 시간을 16배 늘리는 동안 비율이 6.65~6.91%로 거의 고정이에요. 2회차도 6.60~6.93%. 소스의 10% 상한 바로 아래에서 정확히 비례합니다.
소스를 읽고 세운 가설이 실측으로 맞아떨어지니까 기분이 좋더라고요:)
실험 3 ) 메인 스레드가 막히면 어떻게 될까?
0.1초 반복 타이머 두 개를 돌리면서, 0.25초 지점에서 메인 스레드를 0.35초 통째로 막아봤어요.
| 발화 # | Timer (메인 RunLoop) | DispatchSourceTimer |
|---|---|---|
| 2 | 0.203s | 0.203s |
| 3 | 0.614s ← | 0.303s |
| 10 | 1.302s | 1.005s |
3번째 발화가 0.3초 → 0.614초로 밀렸어요. 그리고 블로킹이 끝난 뒤에도 따라잡지 못하고 계속 0.3초씩 밀린 채로 갑니다. 같은 시간에 DispatchSourceTimer는 10번을 정확히 채웠고요.
아까 말한 "커널은 제때 알렸는데 RunLoop이 못 받은" 상황이 눈에 보이는 거예요.
🌟 실험 4 ) tolerance: .zero의 위력
이게 이 글에서 제일 실용적인 부분이에요. §4에서 본 갈림길이 성능에 어떻게 드러나는지, 1.0초 sleep을 12회씩 재봤어요.
| 설정 | 내부 경로 | 평균 오차 | 최대 오차 |
|---|---|---|---|
tolerance 생략 |
dispatch_after |
52.66 ms | 66.69 ms |
tolerance: .zero |
dispatch_source |
3.15 ms | 4.79 ms |
tolerance: .milliseconds(500) |
dispatch_source |
205.06 ms | 476.01 ms |
Task.sleep에는 tolerance: .zero를 붙이세요. 오차가 17배 줄어듭니다(52.66ms → 3.15ms).반대로 배터리가 중요한 긴 대기라면
tolerance를 넉넉히 주는 게 맞아요. 500ms를 줬더니 딱 그만큼 늦게 깨어났는데, 그게 의도된 동작이에요.
같은 Task.sleep인데 tolerance: .zero 하나로 맨 위에서 맨 아래로 내려옵니다.
실험 5 ) 그럼 Task.sleep으로 반복 루프는 못 쓰나요?
쓸 수 있어요! 기준점을 절대 시각으로 누적하면 됩니다. 실험 1의 상황을 이렇게 고쳐봤어요.
// ❌ 순진한 방법 — 매번 "지금부터 200ms"
for _ in 0..<N {
try await Task.sleep(for: .milliseconds(200))
work()
}
// ✅ 보정한 방법 — 절대 격자 + tolerance .zero
let clock = ContinuousClock()
let origin = clock.now
for i in 1...N {
let deadline = origin.advanced(by: .milliseconds(200 * i))
try await Task.sleep(until: deadline, tolerance: .zero, clock: clock)
work()
}
이상적 : 4.0600s
순진한 루프 : 5.3771s 누적오차 1.3171s
보정한 루프 : 4.0644s 누적오차 0.0044s ← 300배 개선!
1.32초 → 0.004초. DispatchSourceTimer가 커널에서 해주던 일을 우리가 손으로 해준 셈이에요.
7. 그래서 뭘 써야 할까요?
| Timer | DispatchSourceTimer | Task.sleep | |
|---|---|---|---|
| 커널 계층 | Mach | BSD | BSD |
| 커널 API | mk_timer_arm |
EVFILT_TIMER |
dispatch_after |
| 실행 주체 | RunLoop (주로 메인) | 지정 큐 워커 | cooperative pool |
| 메인 블로킹 영향 | 직격 | 없음 | 없음 |
| 반복 격자 | 있음 | 있음 | 없음 (수동) |
| 기본 허용 오차 | 0 | 0 (직접 지정) | 요청의 10% |
| 취소 | invalidate() |
cancel() 필수 |
Task 취소 자동 |
상황별로 정리하면요:

상황별 선택 가이드. 대부분은 이 흐름도로 정리됩니다.
- 반복 + 콜백으로 받아도 된다 (폴링, 샘플링, 하트비트) →
DispatchSourceTimer+repeating:. 격자를 커널이 잡아줘요. - 반복 + async 흐름 안 → Task.sleep(until:) 로 절대 격자를 누적하세요. 정확도는 DispatchSourceTimer와 거의 같고(+0.004s), 취소가 자동 전파되는 게 큰 장점이에요. 단, 매번 "지금부터" 재면 오차가 쌓이니 기준점은 반드시 절대 시각으로
- 긴 대기, 배터리 우선 →
tolerance/leeway를 넉넉히. Apple 권장이 간격의 10%인데, 공교롭게도dispatch_after기본값이랑 같아요:) - 단발 대기 → Task.sleep(for:). 정확도가 필요하면 tolerance: .zero 잊지 마시고요
- 화면 갱신이 목적 →
Timer보다CADisplayLink가 맞아요.
마무리 — 그냥 "3초 뒤"인 줄 알았는데
솔직히 저는 Task.sleep을 그냥 더 현대적인 문법 정도로만 생각했어요. 최신 API니까 더 정확하겠지, 하고요.
그런데 파보니까 정반대였어요. 기본 설정에서는 셋 중 제일 부정확했고(10% 자동 leeway), 그건 버그가 아니라 배터리를 위한 의도된 설계였습니다. 그리고 그걸 바꾸는 방법은 문서 시그니처에 계속 있었는데 제가 안 보고 있었던 거예요. tolerance: 하나로요.
API를 그냥 쓰기만 하고 왜 그렇게 만들어졌는지 한 번도 안 궁금해했던 게 좀 부끄럽네요. 소스가 다 공개돼 있는데 말이죠...🥲
혹시 반복 주기가 자꾸 밀려서 고생하고 계신 분이 있다면, 이 글이 도움이 됐으면 좋겠어요!
📚 참고 자료
- CFRunLoop.c — L83-88(매크로), L1913-1914(soft/hard deadline), L1936-1975(arm 분기), L3707-3710(tolerance 기본값), L3934-3954(setter 클램프)
- libdispatch src/source.c — L1223-1233(leeway 상한·deadline), L1330-1375(
dispatch_after자동 leeway) - libdispatch event_kevent.c — L2376-2402(
EVFILT_TIMER등록) - libdispatch event_internal.h — L576-591(격자 유지
target += push_by) - swift DispatchGlobalExecutor.cpp — L340-393(
tnsec != -1분기) - swift TaskSleepDuration.swift — L138-157(tolerance sentinel)
- Apple — Timer · Apple — Task.sleep(for:tolerance:clock:) · Apple — Minimize Timer Use
'SWIFT개발일지' 카테고리의 다른 글
| iOS FCM 푸시 알림 — 동작 원리부터 권한 타이밍까지 컨트롤하기 (0) | 2026.05.26 |
|---|---|
| TCA Dependency는 어떻게 동작하는지 정확하게 설명할수 있는 사람 (0) | 2026.05.24 |
| SwiftUI TabView 오버레이가 iOS 26에서 안 맞는 이유 (0) | 2026.04.04 |
| 로컬 알림 다국어 적용기: 언어가 바뀌어도 알림을 다시 등록하지 않아도 되는 이유 (1) | 2026.02.03 |
| 왜 한번 빌드 이후부터는 빌드가 더 빠를까? - 증분 빌드 (0) | 2026.01.25 |