---
title: "시간을 재는 것도 공짜가 아니다"
description: "시계는 두 개이고 쓰임이 다릅니다. 1밀리초를 자라고 하면 1밀리초 뒤에 깨지도 않습니다. 프레임을 일정하게 내보내려고 할 때 어디서 어긋나는지 봅니다."
author: "유니얼"
pubDate: 2026-09-17
category: "programming/os"
tags: ["OS"]
canonical: https://blog.unialgames.com/posts/os-time-and-pacing/
---
게임은 시간을 계속 묻습니다. 이번 프레임이 얼마나 걸렸는지, 쿨다운이 끝났는지, 다음 프레임까지 얼마 남았는지.

그런데 **시계가 하나가 아닙니다.** 그리고 시간을 묻는 것도, 기다리는 것도 정확하지 않습니다.

## 시계가 둘이다

OS 는 성격이 다른 시계를 둘 줍니다.

| | 벽시계 | 단조 시계 |
| --- | --- | --- |
| 무엇을 재나 | 지금 몇 시인지 | 기기가 켜진 뒤로 흐른 시간 |
| 뒤로 갈 수 있나 | **갑니다** | 안 갑니다 |
| 왜 흔들리나 | 시각 동기화, 사용자가 시계를 바꿈, 시간대 | 흔들리지 않습니다 |
| 쓰는 자리 | 날짜를 보여줄 때, 서버와 맞출 때 | **경과 시간을 잴 때 전부** |

<figure class="diagram">
<svg viewBox="0 0 680 170" role="img" aria-label="벽시계는 중간에 앞으로 튀었다가 뒤로 물러나는 반면 단조 시계는 일정하게 흐르는 것을 두 줄의 타임라인으로 비교한 그림">
<g font-size="12" fill="var(--text-muted)">
<text x="8" y="28">벽시계</text>
<text x="8" y="112">단조 시계</text>
</g>
<g stroke="var(--border)" stroke-width="1">
<line x1="100" y1="50" x2="660" y2="50"></line>
<line x1="100" y1="134" x2="660" y2="134"></line>
</g>
<g fill="var(--warn)">
<circle cx="140" cy="50" r="4"></circle>
<circle cx="220" cy="50" r="4"></circle>
<circle cx="300" cy="50" r="4"></circle>
<circle cx="470" cy="50" r="4"></circle>
<circle cx="400" cy="50" r="4"></circle>
<circle cx="560" cy="50" r="4"></circle>
</g>
<g fill="var(--accent2)">
<circle cx="140" cy="134" r="4"></circle>
<circle cx="220" cy="134" r="4"></circle>
<circle cx="300" cy="134" r="4"></circle>
<circle cx="380" cy="134" r="4"></circle>
<circle cx="460" cy="134" r="4"></circle>
<circle cx="540" cy="134" r="4"></circle>
</g>
<g font-family="var(--font-mono)" font-size="10" fill="var(--warn)">
<text x="330" y="34">시각 동기화</text>
<text x="420" y="82">뒤로 물러남</text>
</g>
<path d="M310 40 L465 40" stroke="var(--warn)" fill="none" stroke-dasharray="3 3"></path>
<path d="M465 62 L405 62" stroke="var(--warn)" fill="none" stroke-dasharray="3 3"></path>
</svg>
<figcaption>벽시계는 밖에서 고쳐지면 튀거나 물러난다. 단조 시계는 그 일이 없다.</figcaption>
</figure>

그래서 **경과 시간을 벽시계로 재면 안 됩니다.** 두 시각을 빼서 음수가 나오는 일이 실제로 생깁니다.
쿨다운을 벽시계로 재 두면 기기 시각을 돌려서 넘길 수도 있습니다.

기기가 잠든 동안 단조 시계가 멈추는지 계속 가는지는 플랫폼마다 다릅니다.
**백그라운드에서 돌아온 직후의 값은 어느 쪽이든 한 번 의심하고 잘라 내야 합니다.**

## 시간을 묻는 값

시각을 아는 것은 원래 커널의 일입니다. 물어보려면 모드를 넘어갔다 와야 합니다.
그런데 이 질문은 너무 자주 나옵니다. 그래서 **커널이 시간 값을 사용자 쪽에서 읽을 수 있는 자리에 올려 둡니다.**

덕분에 요즘 기계에서는 시간을 묻는 데 수십 나노초면 끝납니다.
문을 넘는 값이 얼마인지는 [커널에 들어갔다 나오는 값](/posts/os-kernel-entry/)에 적었습니다.

싸다고 공짜는 아닙니다. 촘촘한 반복문 안에서 매번 시각을 묻거나,
구간마다 시간을 재서 로그로 남기면 **재는 값이 재려던 것보다 커집니다.**
프레임 시작에 한 번 받아 두고 그 프레임 내내 같은 값을 쓰는 이유가 이것입니다.

## 자는 것은 정확하지 않다

1밀리초를 자라고 하면 **1밀리초 뒤에 깨어나는 것이 아닙니다.**
1밀리초 뒤에 "깨워도 되는 상태" 가 될 뿐입니다. 실제로 언제 다시 CPU 에 올라가는지는 스케줄러가 정합니다.

여기에 둘이 더 붙습니다. 타이머가 얼마나 촘촘하게 깨울 수 있는지, 그리고 그때 다른 스레드가 자리를 차지하고 있는지.
**그래서 요청한 시간보다 늦게 깹니다. 얼마나 늦을지는 그때그때 다릅니다.**

남은 시간을 기다리는 방법은 둘입니다. 프레임 페이싱에서 실제로 고르는 갈림길입니다.

<x-tabs>
<div class="pc" data-tab="잠들어 기다리기">
<div class="pc-col" data-kind="good">
<span class="pc-tag">장점</span>
<ul><li><b>코어를 놓아 줍니다.</b> 그동안 다른 스레드가 일하고, 전력도 덜 씁니다.</li><li><b>발열이 덜합니다.</b> 모바일에서는 이게 곧 성능입니다.</li></ul>
</div>
<div class="pc-col" data-kind="bad">
<span class="pc-tag">단점</span>
<ul><li><b>언제 깰지 모릅니다.</b> 스케줄러가 다시 태워 줄 때까지 늦습니다.</li><li><b>깬 다음이 느립니다.</b> 캐시가 식은 채로 다시 시작합니다.</li></ul>
</div>
</div>
<div class="pc" data-tab="돌면서 기다리기">
<div class="pc-col" data-kind="good">
<span class="pc-tag">장점</span>
<ul><li><b>깨어나는 지연이 없습니다.</b> 목표 시각을 훨씬 정확하게 맞힐 수 있습니다.</li></ul>
</div>
<div class="pc-col" data-kind="bad">
<span class="pc-tag">단점</span>
<ul><li><b>코어를 붙잡고 있습니다.</b> 그 시간에 다른 스레드가 못 돕니다.</li><li><b>전력과 발열을 그대로 씁니다.</b> 모바일에서는 오래 못 버팁니다.</li><li><b>쓰로틀링이 걸리면</b> 정확해지려고 한 짓이 전체를 느리게 만듭니다.</li></ul>
</div>
</div>
</x-tabs>

| 어디서 | 무엇을 고르나 |
| --- | --- |
| 대부분의 구간 | 잠들어 기다립니다. 늦게 깨는 것보다 코어를 붙잡는 손해가 큽니다 |
| 목표 시각 직전 짧은 구간 | 돌면서 기다리는 것이 값어치가 있을 때가 있습니다 |
| 모바일 | 기본은 잠드는 쪽입니다. 발열이 곧 다음 프레임의 속도라서요 |

## 프레임이 일정해 보이려면

여기서 한 가지가 더 갈립니다. **일정한 간격으로 그리는 것과, 화면이 그걸 일정하게 받는 것은 다른 문제입니다.**

한 프레임이 걸린 시간을 재서 다음 프레임에 쓰는 것이 델타 시간입니다. 이 값이 흔들리면 화면도 흔들립니다.
프레임 하나가 오래 걸린 다음 프레임에서는 델타가 크게 들어오고, 그 값으로 위치를 계산하면 **캐릭터가 순간이동합니다.**

그래서 대개 이렇게 합니다.

- **델타에 상한을 둡니다.** 한 프레임이 아무리 오래 걸려도 그 값 이상은 안 넘깁니다
- **물리는 고정 간격으로 돕니다.** 남은 시간을 모아 두었다가 정해진 크기로 나눠 돌립니다
- **백그라운드에서 돌아온 직후 한 프레임은 버립니다.** 그 델타는 실제 진행이 아닙니다

화면에 언제 나가는지는 또 다른 이야기입니다. 그리기를 끝낸 시점과 화면이 새 그림을 받는 시점이 따로 있고,
그 사이를 맞춰 주는 장치가 수직 동기화입니다. 프레임레이트를 어디에 맞출지는
[한 줄로 배터리를 절반 쓰는 방법](/posts/unity-target-framerate/)에서 다뤘습니다.

## 게임에서 자주 틀리는 자리

| 틀리는 것 | 왜 |
| --- | --- |
| 쿨다운을 벽시계로 잼 | 기기 시각을 바꾸면 넘어갑니다. 시각 동기화로 음수가 나오기도 합니다 |
| 백그라운드 복귀 직후 델타를 그대로 씀 | 몇 분짜리 델타가 한 번에 들어옵니다 |
| 반복문 안에서 매번 시각을 물음 | 재는 값이 쌓입니다. 프레임 시작에 한 번이면 됩니다 |
| 정확하게 재겠다고 계속 돌면서 기다림 | 코어를 붙잡고 발열을 올려서 다음 프레임을 느리게 만듭니다 |
| 서버 시간과 기기 시간을 섞어 씀 | 기준이 둘이 되면 어긋난 만큼이 그대로 버그입니다 |

## 정리

- 시계는 둘입니다. **날짜는 벽시계로, 경과 시간은 단조 시계로** 잽니다. 벽시계는 뒤로 갑니다
- 시간을 묻는 것은 원래 커널의 일이지만 너무 자주 불려서 **사용자 쪽에서 읽을 수 있게 올려 둔** 값입니다. 싸지만 공짜는 아닙니다
- **자는 것은 정확하지 않습니다.** 요청한 시간은 깨워도 되는 시점일 뿐이고, 실제로 언제 올라가는지는 스케줄러가 정합니다
- 기다리는 방법은 둘이고 대부분은 잠드는 쪽이 맞습니다. **모바일에서는 발열이 곧 다음 프레임의 속도**라서 더 그렇습니다
- 델타 시간은 그대로 믿으면 안 됩니다. 상한을 두고, 물리는 고정 간격으로 돌리고, 복귀 직후 한 프레임은 버립니다