Menu

C# 타이머: System.Threading.Timer, System.Timers.Timer, PeriodicTimer

C#에는 타이머 클래스가 여러 개 있고 동작이 서로 다릅니다. System.Threading.Timer, System.Timers.Timer, PeriodicTimer, UI 타이머를 언제 쓸지, 시작하고 멈추는 방법, 콜백이 스레드 풀에서 실행되고 겹칠 수 있는 이유, 타이머를 계속 참조해야 하는 이유, Stopwatch로 경과 시간을 재는 방법을 알아봅니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

타이머는 지연 뒤에 또는 반복 간격으로 코드를 실행합니다. 1분마다 캐시를 새로 고치고, 30초마다 초안을 저장하고, 서비스를 폴링하고, 시계를 갱신하는 것입니다. .NET에는 비슷해 보이지만 콜백이 실행되는 위치와 제어 방법이 다른 타이머 클래스가 여러 개 있습니다. 이 페이지는 각각과, 무언가에 걸린 시간을 재는 Stopwatch를 다룹니다.

어떤 타이머를 쓸까

타이머콜백 실행 위치스타일용도
System.Threading.Timer스레드 풀콜백 델리게이트서비스와 라이브러리의 백그라운드 작업
System.Timers.Timer스레드 풀(기본값)Elapsed 이벤트, Start/Stop이벤트를 좋아하는 컴포넌트 스타일 코드
PeriodicTimer(.NET 6+)비동기 루프가 실행되는 곳await WaitForNextTickAsync()비동기 코드, 틱이 절대 겹치지 않음
System.Windows.Forms.Timer, WPF DispatcherTimerUI 스레드Tick 이벤트데스크톱 앱에서 컨트롤 갱신

처음 두 개는 스레드 풀 스레드에서 발생하므로, 콜백이 프로그램의 나머지와 동시에, 심지어 서로 동시에 실행될 수 있습니다. UI 타이머는 UI 스레드에서 실행되며, 그래서 컨트롤을 바로 건드릴 수 있고, 또 그래서 느린 처리기가 창을 멈추게 합니다.

System.Threading.Timer

생성자는 콜백, 상태 객체, 첫 틱 전의 지연, 주기를 받습니다. 바로 시작합니다.

출력 예:

tick 1 at ~200 ms
tick 2 at ~500 ms
tick 3 at ~800 ms
tick 4 at ~1100 ms
stopped after 4 ticks

여기서 두 가지 세부 사항이 중요합니다. Main이 기다려야 합니다(done.WaitOne()). 콘솔 프로그램은 Main이 반환되면 종료되고, 타이머 콜백은 프로세스를 살려 두지 않는 백그라운드 스레드에서 실행됩니다. 그리고 타이머는 정적 필드에 있습니다. 아무것도 참조하지 않는 System.Threading.Timer는 가비지 컬렉션될 수 있으며, 그러면 조용히 멈춥니다. 반환되는 메서드 안에서 타이머를 지역 변수로 만들 때 가장 자주 문제가 됩니다.

Change(dueTime, period)는 타이머 일정을 다시 잡습니다. 둘 다 Timeout.Infinite이면 일시 정지하고, 새 쌍을 주면 다시 시작합니다. Dispose()는 완전히 멈춥니다.

System.Timers.Timer

System.Timers.Timer는 같은 메커니즘을 이벤트 기반 API로 감쌉니다. Interval을 설정하고, Elapsed를 구독하고, Start()와 Stop()을 씁니다.

출력 예:

autosave #1
autosave #2
autosave #3
autosave stopped

AutoReset = false는 일회성 타이머를 만듭니다. 한 번 발생하고 멈추며, Start()를 다시 호출하면 다음 한 번의 틱을 예약합니다. Enabled = true와 false는 Start(), Stop()과 같습니다. System.Threading에도 Timer가 있어서 여기서는 전체 이름 System.Timers.Timer를 썼습니다. 두 네임스페이스를 모두 가져오면 맨 Timer는 모호해서 컴파일되지 않습니다.

System.Timers.Timer에는 WinForms에서 Elapsed를 UI 스레드로 마샬링하는 SynchronizingObject 속성도 있습니다. 실제로는 그런 용도에 UI 프레임워크 자체의 타이머가 더 간단합니다.

콜백은 겹치고 다른 스레드에서 실행됩니다

위의 두 클래스는 이전 콜백이 끝났든 아니든 일정대로 스레드 풀에서 발생합니다. 콜백이 3초 걸리고 주기가 1초라면 콜백 세 개가 동시에 실행됩니다. 이는 두 가지 의무를 만듭니다:

  • 콜백이 건드리는 모든 것은 스레드로부터 안전해야 합니다. 카운터에는 (예제처럼) Interlocked를, 더 큰 것에는 lock을 쓰세요.
  • 겹침이 문제라면 막으세요. 이전 틱이 아직 실행 중이면 틱을 건너뛰거나, 겹칠 수 없는 패턴을 쓰세요.

Interlocked를 쓰는 건너뛰기 보호:

private int running = 0;

private void OnTick(object state)
{
    if (Interlocked.Exchange(ref running, 1) == 1) return; // previous tick still busy
    try
    {
        SyncOrders();   // slow work
    }
    finally
    {
        Volatile.Write(ref running, 0);
    }
}

예외도 함정입니다. System.Threading.Timer 콜백에서 빠져나간 예외는 프로세스를 죽이고, System.Timers.Timer는 Elapsed 처리기의 예외를 삼키므로 실패가 눈에 띄지 않습니다. 타이머 콜백의 본문을 try/catch로 감싸고 기록하세요.

PeriodicTimer와 비동기 루프

비동기 코드에서는 반복 사이에 기다리는 루프가 가장 명확한 타이머입니다. 다음 기다림은 작업이 끝난 뒤에야 시작되므로 틱이 절대 겹치지 않고, 예외는 잡을 수 있는 곳에서 드러납니다. .NET 6은 바로 이것을 위해 PeriodicTimer를 추가했습니다:

using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));

while (await timer.WaitForNextTickAsync(cancellationToken))
{
    await SaveDraftAsync();
}

WaitForNextTickAsync는 타이머가 dispose되면 false를 반환해 루프를 끝내고, 토큰이 취소되면 OperationCanceledException을 던집니다. Task.Delay를 둘러싼 루프와 달리 PeriodicTimer는 일정한 박자를 유지합니다. 작업이 2초 걸려도 다음 틱은 32초가 아니라 이전 틱의 30초 뒤에 옵니다.

어떤 버전에서든 시간이 밀려도 괜찮다면 Task.Delay로 같은 모양을 쓸 수 있습니다:

출력 예:

poll 1
poll 2
poll 3
poll 4
polling cancelled after 4 rounds

취소가 Stop()을 대신합니다. 루프에 토큰을 넘기고 밖에서 취소하세요. 취소 토큰에 대해서는 태스크를 참고하세요.

Stopwatch로 경과 시간 재기

타이머는 코드를 예약하고, Stopwatch는 코드에 걸린 시간을 잽니다. 앞으로만 가는 고해상도 시계를 읽으므로 시간 측정에 맞는 도구입니다:

출력 예:

string +=      64 ms
StringBuilder  0 ms
Same length: True
High resolution clock: True

Start, Stop, Reset, Restart로 제어하며, Elapsed는 TimeSpan이고 ElapsedMilliseconds와 ElapsedTicks는 원시 숫자를 줍니다. DateTime.Now로 시간을 재지 마세요. 해상도가 10~15ms만큼 거칠 수 있고, 시스템 시계가 조정되면 건너뜁니다. JIT 워밍업과 가비지 컬렉션이 한 번의 실행을 왜곡하는 본격적인 벤치마크에는 BenchmarkDotNet 라이브러리를 쓰세요.

타이머 정확도

타이머 간격은 약속이 아니라 최솟값입니다. Windows에서 기본 시스템 타이머 해상도는 약 15.6ms이므로 10ms 간격은 15ms나 16ms마다 발생하는 경향이 있고, 바쁜 컴퓨터에서는 스레드 풀 콜백이 더 늦게 시작할 수 있습니다. 타이머는 "대략 N초마다"에 맞습니다. 정밀한 프레임 타이밍이나 미디어에는 그 용도로 만들어진 API를 쓰세요.

흔한 실수

  • 지역 변수에만 저장한 System.Threading.Timer. 가비지 컬렉션되어 멈출 수 있습니다. 필드에 두세요.
  • Main이 반환되게 두기. 프로세스가 끝나면서 타이머도 함께 사라집니다.
  • 모호한 Timer. System.Threading과 System.Timers를 모두 가져왔다면 전체 이름을 쓰세요.
  • 틱이 절대 겹치지 않으리라 가정하기. 스레드 풀 타이머는 작업이 간격보다 느리면 겹칩니다.
  • 스레드 풀 타이머에서 UI 컨트롤 건드리기. UI 프레임워크의 타이머를 쓰거나 UI 스레드로 마샬링하세요.
  • 콜백의 처리되지 않은 예외. 프로세스를 죽이거나(Threading.Timer) 사라집니다(Timers.Timer). 잡아서 기록하세요.
  • Dispose를 잊기. 타이머는 시스템 자원을 붙잡고 dispose될 때까지 계속 발생합니다.

자주 묻는 질문

C#에서 어떤 타이머를 써야 하나요?

.NET 6 이상의 비동기 코드라면 루프 안에서 await timer.WaitForNextTickAsync()를 쓰는 PeriodicTimer입니다. 스레드 풀의 콜백이라면 System.Threading.Timer입니다. Start, Stop, Elapsed 이벤트를 가진 이벤트 기반 타이머라면 System.Timers.Timer입니다. WinForms나 WPF에서는 처리기가 UI 스레드에서 실행되도록 UI 프레임워크의 타이머를 쓰세요.

C#에서 몇 초마다 코드를 실행하려면 어떻게 하나요?

간격을 지정해 타이머를 만들고 참조를 유지하세요. 예를 들어 new System.Threading.Timer(_ => Refresh(), null, 0, 5000)은 지금, 그리고 5초마다 Refresh를 호출합니다. 비동기 코드에서는 PeriodicTimer(.NET 6+)나 await Task.Delay(5000)로 루프를 도세요. 루프 버전은 두 틱을 절대 동시에 실행하지 않습니다.

C#에서 타이머는 어떻게 멈추나요?

System.Timers.Timer는 Stop()을 호출하고(또는 Enabled = false), 더 이상 필요 없으면 Dispose()하세요. System.Threading.Timer는 Change(Timeout.Infinite, Timeout.Infinite)로 일시 정지하거나 Dispose()로 완전히 멈춥니다. 이미 시작한 콜백은 타이머를 멈춘 뒤에도 끝날 수 있습니다.

System.Threading.Timer가 더 이상 동작하지 않는 이유는 무엇인가요?

타이머를 참조하는 것이 없어서 가비지 컬렉터가 수집했고 콜백이 멈춘 것입니다. 반환되는 메서드 안에서 타이머를 지역 변수로 만들 때 일어납니다. 계속 실행되어야 하는 동안 필드에 저장하세요.

C#에서 경과 시간은 어떻게 재나요?

System.Diagnostics.Stopwatch를 쓰세요: var sw = Stopwatch.StartNew(); ... sw.Stop(); 뒤에 sw.ElapsedMilliseconds나 sw.Elapsed를 읽습니다. 고해상도 단조 시계를 쓰므로, 해상도가 더 거칠고 시스템 시계가 조정되면 건너뛰는 DateTime.Now 두 값의 차이와 다릅니다.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기