본문으로 건너뛰기
HY Devlog
뒤로 가기

플레이 타임은 DateTime이 아니라 TimeSpan이다

타임 어택 게임을 만들던 중이었다. 플레이 타임을 UI에 띄울 방법을 찾다가 현재 시각을 다루는 2019년 글을 스크랩해뒀다. 목표가 한 줄로 적혀 있다.

목표 — 현재 시간을 표기하기 위한 유틸리티 작성

DateTime.Now.ToString()부터 포맷 지정, 유닉스 시간 변환까지 세 단계로 올라간다. 구성은 깔끔하다.

다시 열어보니 찾던 것과 스크랩한 것이 다르다. 이 글이 답하는 질문은 “지금 몇 시인가”이고, 타임 어택에 필요한 건 “얼마나 지났는가”다. 둘은 타입부터 다르다.

목차

목차

시각과 경과 시간은 다른 타입이다

DateTime시점이다. 2026년 9월 22일 15시 47분 같은, 달력 위의 한 점. 타임 어택이 재는 건 시점이 아니라 길이다 — 3분 24초 71.

.NET에서 길이를 나타내는 타입은 따로 있다. TimeSpan이다. DateTime으로 길이를 다루려면 기준점을 하나 정해놓고 빼야 하는데, 그렇게 얻은 결과도 실은 TimeSpan이다.

DateTime start = DateTime.Now;
// ...
TimeSpan elapsed = DateTime.Now - start;   // 뺄셈 결과가 TimeSpan이다

그러니 처음부터 TimeSpan으로 다루는 게 맞다. 그리고 여기에 옮겨 붙이다 바로 걸리는 규칙이 하나 있다.

포맷 문자열의 규칙이 다르다

DateTime에서 쓰던 감각으로 timeSpan.ToString("mm:ss")를 쓰면 의도대로 안 된다. 문서가 이유를 적어놨다.

사용자 지정 TimeSpan 형식 지정자는 자리 표시자 구분 기호를 포함하지 않는다. 일(day)과 시(hour), 시와 분, 초와 소수 초를 구분하는 기호 같은 것들 말이다. 대신 이 기호들은 문자열 리터럴로 사용자 지정 형식 문자열에 포함되어야 한다.

즉 콜론과 마침표를 직접 이스케이프해야 한다. 문서의 예가 이렇다.

output = "Time of Travel: " + duration.ToString(@"dd\.hh\:mm\:ss");
// 01.12:24:02

작은따옴표로 감싸도 된다.

fmt = "mm':'ss' minutes'";
// 32:45 minutes

규칙이 하나 더 있다. 지정자를 하나만 쓸 때는 %를 앞에 붙인다. "d", "h", "m", "s", "f", "F"가 해당하고, 그냥 쓰면 표준 형식 문자열로 해석된다.

정리하면 타임 어택 타이머의 포맷은 이렇게 된다.

@"mm\:ss\.ff"      // 03:24.71
@"h\:mm\:ss"       // 1:03:24  (한 시간을 넘길 수 있다면)

어느 시계를 읽을 것인가

타입이 정해지면 다음 질문은 무엇으로 시간을 재는가다. Unity의 Time 클래스에 후보가 셋 있고, 셋의 차이가 일시정지를 어떻게 다룰지를 결정한다.

속성문서의 서술timeScale 영향
Time.time애플리케이션 시작 이후 현재 프레임 시작 시점까지의 초받음
Time.unscaledTime이 프레임의 timeScale과 무관한 시간안 받음
Time.realtimeSinceStartup게임이 시작된 이후의 실제 시간(초)안 받음

그리고 Time.timeScale은 “실제 시간 대비 게임 내 시간이 흐르는 비율”이다.

타임 어택이라면 대개 Time.time이 맞다. 일시정지를 Time.timeScale = 0으로 구현했을 때 타이머도 같이 멈춰야 하기 때문이다. 메뉴를 열어둔 동안 기록이 올라가면 안 된다.

반대로 일시정지 중에도 흘러야 하는 것이 있다면 unscaledTime이다. UI 애니메이션이나 “3초 뒤 자동 재개” 같은 것들.

realtimeSinceStartup은 성격이 또 다르다. 프레임과 무관한 실제 시간이라 로딩 구간처럼 프레임이 갱신되지 않는 동안에도 흐른다. 프로파일링에는 맞지만 게임 기록에는 과하다.

한 가지 짚어둘 것은, Time.time“현재 프레임 시작 시점” 이라는 점이다. 한 프레임 안에서 몇 번을 읽어도 같은 값이 나온다. 타이머에는 오히려 그쪽이 낫다 — 같은 프레임의 표시와 판정이 어긋나지 않는다.

DateTime.Now를 두 번 빼서 재는 방식은 이 셋 어디에도 해당하지 않는다. 기기 시계를 읽기 때문에 플레이어가 시계를 바꾸면 기록이 바뀌고, 자동 시각 동기화가 한 번 튀어도 기록이 튄다. 타임 어택 기록을 걸 값으로는 쓸 수 없다.

DateTime이 맞는 자리 — 기록을 남길 때

그렇다고 DateTime이 필요 없는 건 아니다. “이 기록을 언제 세웠는가” 는 시점이고, 그건 DateTime의 일이다. 원문의 내용이 유효해지는 자리도 여기다.

다만 원문 코드를 그대로 가져가면 세 군데가 걸린다.

첫째, 포맷 문자열이 되읽기를 막는다.

return DateTime.Now.ToString(("yyyy-MM-dd HH:mm:ss tt"));

HH는 이미 24시간제인데 tt(AM/PM 지정자)를 붙였다. 화면에는 2026-09-22 15:47:21 오후처럼 찍힌다. 그리고 문서가 더 강한 말을 해뒀다.

AM/PM 지정자가 포함된 문자열을 파싱할 때HHH(24시간제) 대신 hhh(12시간제) 시 지정자를 사용하라. 24시간제 지정자는 파싱 연산에서 AM/PM 지정자와 호환되지 않으며, FormatException을 던진다.

기록을 저장했다가 되읽는 순간 예외가 난다.

둘째, 문화권을 따른다.

ToString(String) 메서드는 현재 문화권의 형식 규칙을 사용하는 특정 형식으로 날짜와 시간 값의 문자열 표현을 반환한다.

tt가 한국어 기기에서는 “오후”, 영어 기기에서는 “PM”이다. 인수 없는 ToString()은 구분자와 순서까지 바뀐다. 보여줄 때는 맞는 동작이지만 저장할 때는 버그다.

셋째, 에포크 변환에 전용 API가 있다. 원문은 손으로 뺀다.

TimeSpan time = (DateTime.UtcNow - new DateTime(1970,1,1));

(참고로 원문의 이 함수와 아래 함수는 선언되지 않은 식별자를 써서 컴파일되지 않는다. texpiredTime이 각각 timemilliSecond여야 한다.)

.NET에 이미 있다.

public long ToUnixTimeMilliseconds(); 1970-01-01T00:00:00.000Z 이후 경과한 밀리초 수를 반환한다. 이 메서드는 먼저 현재 인스턴스를 UTC로 변환한 뒤 유닉스 시간의 밀리초 수를 반환한다.

반환형이 double이 아니라 long 이라 정밀도가 깎이지 않고, DateTimeOffset은 오프셋을 타입 안에 들고 있어 원문의 Kind 혼선 (UtcNow에서 Unspecified를 빼는 쪽과 DateTimeKind.Utc를 명시하는 쪽이 한 파일에 공존)도 생기지 않는다.

어디에 왜 쓰나

정리하면 타임 어택에 필요한 건 셋이다 — 재는 것은 Time.time, 다루는 타입은 TimeSpan, 남기는 것은 long 타임스탬프.

타임 어택 타이머

using System;
using TMPro;
using UnityEngine;

/// <summary>
/// 한 판의 경과 시간을 재고 UI에 표시한다.
/// </summary>
public class RunTimer : MonoBehaviour
{
    // TimeSpan 포맷은 구분 기호를 직접 이스케이프해야 한다.
    private const string TIME_FORMAT = @"mm\:ss\.ff";

    [Header("UI")]
    [SerializeField, Tooltip("경과 시간을 표시할 텍스트")]
    private TMP_Text _label;

    private float _startTime;
    private float _finishedElapsed;
    private bool _isRunning;
    private int _lastShownHundredths = -1;

    /// <summary>현재 경과 시간. 멈춘 뒤에는 최종 기록을 유지한다.</summary>
    public TimeSpan Elapsed => TimeSpan.FromSeconds(
        _isRunning ? Time.time - _startTime : _finishedElapsed);

    public event Action<TimeSpan> OnFinished;

    public void Begin()
    {
        // timeScale = 0으로 일시정지하면 이 시계도 같이 멈춘다.
        _startTime = Time.time;
        _isRunning = true;
    }

    public void Finish()
    {
        if (!_isRunning)
        {
            return;
        }

        _finishedElapsed = Time.time - _startTime;
        _isRunning = false;
        OnFinished?.Invoke(Elapsed);
    }

    private void Update()
    {
        if (!_isRunning)
        {
            return;
        }

        TimeSpan elapsed = Elapsed;

        // 표시값이 바뀔 때만 문자열을 새로 만든다.
        int hundredths = (int)(elapsed.TotalSeconds * 100.0);
        if (hundredths == _lastShownHundredths)
        {
            return;
        }

        _lastShownHundredths = hundredths;
        _label.text = elapsed.ToString(TIME_FORMAT);
    }
}

몇 가지 의도를 적어둔다.

deltaTime을 누적하는 방식

앞 코드는 뺄셈이다 — 시작 시각을 기억해두고 지금과의 차를 구한다. 다른 방법은 매 프레임 Time.deltaTime누적하는 것이다.

_elapsedSeconds += Time.deltaTime;

문서에 따르면 Time.deltaTime은 “마지막 프레임에서 현재 프레임까지의 간격(초)“이고, Time.time과 마찬가지로 timeScale의 영향을 받는다. 그래서 timeScale = 0이면 deltaTime도 0이 되어 누적이 멈춘다. 일시정지에서 타이머가 멈추는 건 두 방식 모두 같다.

차이는 그 외의 경우에 나온다.

누적 (+= deltaTime)뺄셈 (Time.time - 시작)
timeScale = 0 일시정지멈춤멈춤
슬로모션(timeScale = 0.5)절반 속도로 쌓임절반 속도로 흐름
timeScale을 안 쓰는 일시정지플래그로 멈출 수 있다멈출 수 없다
중간 일시정지·재개그냥 된다시작 시각을 다시 잡아야 한다
부동소수 오차쌓인다두 값의 차라 안 쌓인다

세 번째 줄이 누적 방식의 값어치다. 일시정지를 timeScale이 아니라 상태 전환이나 입력 차단으로 구현했다면, Time.time은 계속 흐르므로 뺄셈 쪽은 멈추지 않는다. 누적은 그냥 더하기를 멈추면 된다.

using System;
using TMPro;
using UnityEngine;

/// <summary>
/// deltaTime을 누적해 경과 시간을 잰다. 일시정지 구현 방식과 무관하게 멈출 수 있다.
/// </summary>
public class AccumulatingRunTimer : MonoBehaviour
{
    private const string TIME_FORMAT = @"mm\:ss\.ff";

    [Header("UI")]
    [SerializeField, Tooltip("경과 시간을 표시할 텍스트")]
    private TMP_Text _label;

    // float이 아니라 double로 둔다. 매 프레임 더하는 값이라 오차가 쌓인다.
    private double _elapsedSeconds;
    private bool _isRunning;
    private int _lastShownHundredths = -1;

    public TimeSpan Elapsed => TimeSpan.FromSeconds(_elapsedSeconds);

    public event Action<TimeSpan> OnFinished;

    public void Begin()
    {
        _elapsedSeconds = 0.0;
        _isRunning = true;
    }

    /// <summary>timeScale을 건드리지 않고 타이머만 멈춘다.</summary>
    public void SetPaused(bool paused)
    {
        _isRunning = !paused;
    }

    public void Finish()
    {
        if (!_isRunning)
        {
            return;
        }

        _isRunning = false;
        OnFinished?.Invoke(Elapsed);
    }

    private void Update()
    {
        if (!_isRunning)
        {
            return;
        }

        // deltaTime은 timeScale의 영향을 받는다. 슬로모션이면 그만큼 느리게 쌓인다.
        _elapsedSeconds += Time.deltaTime;

        int hundredths = (int)(_elapsedSeconds * 100.0);
        if (hundredths == _lastShownHundredths)
        {
            return;
        }

        _lastShownHundredths = hundredths;
        _label.text = Elapsed.ToString(TIME_FORMAT);
    }
}

누적기를 double로 둔 게 의도다. float로 두면 60fps 기준 10분에 3만 번 넘게 더하게 되는데, 그만큼 반올림 오차가 쌓인다. 기록을 백분의 일 초까지 비교하는 타임 어택이라면 무시하기 애매한 크기다. double로 바꾸는 비용은 없다시피 하니 그냥 double을 쓰면 된다.

둘 중 무엇이 맞는지는 일시정지를 어떻게 구현했느냐로 갈린다. timeScale로 게임 전체를 얼리는 구조면 뺄셈 쪽이 간단하고 오차도 없다. 타이머만 따로 제어해야 하거나 일시정지가 timeScale과 무관하다면 누적 쪽이다.

기록을 저장할 때

기록 하나는 길이와 시점 두 개로 이루어진다. 저장 형식은 둘 다 정수가 편하다.

[Serializable]
public class RunRecord
{
    public long elapsedMilliseconds;   // 기록 — 길이
    public long achievedAt;            // 세운 시점 — 유닉스 밀리초
}

// 저장
var record = new RunRecord
{
    elapsedMilliseconds = (long)timer.Elapsed.TotalMilliseconds,
    achievedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};

// 표시
TimeSpan best = TimeSpan.FromMilliseconds(record.elapsedMilliseconds);
DateTimeOffset when = DateTimeOffset.FromUnixTimeMilliseconds(record.achievedAt);
string line = $"{best.ToString(TIME_FORMAT)}  ({when.ToLocalTime():yyyy-MM-dd})";

문자열로 저장하지 않는 게 요점이다. 문화권도 포맷도 끼어들지 않고, Unity의 직렬화 규칙 페이지가 나열하는 필드 타입 목록에 long은 정수형으로 그냥 들어간다(DateTime은 그 목록에 없다). 직렬화기가 무엇을 받아주는지가 저장 형식을 정한다는 이야기는 JsonUtility 쪽 글에서 한 번 다뤘다.

쓰지 말아야 할 자리

정리

찾던 것과 스크랩한 것이 어긋나는 경우가 있다. “시간을 UI에 띄우는 법”으로 검색하면 현재 시각 유틸리티가 먼저 나오는데, 타임 어택이 필요한 건 시계가 아니라 스톱워치였다. 질문을 “몇 시인가”에서 “얼마나 지났는가”로 바꾸고 나서야 타입이 정해졌다.


참고

이 글의 출발점이 된 자료는 김잉장 — [Unity] DataTime을 사용하여 현재 시간 표시하기 (2019-11-19)이다. 원문은 현재 시각을 다루는 글이고, 경과 시간 쪽은 .NET과 Unity 문서로 따로 확인했다.


이 글 공유하기:

이전 글
isGrounded는 센서가 아니라 지난 Move의 기록이다
다음 글
Generate C# Class의 본체는 SetCallbacks다