수정 이력(9건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635121)
- 추가한 그림의 Mermaid 소스 들여쓰기를 글 안의 규칙에 맞추고, 리뷰에서 지적된 그림 표현을 본문 서술에 맞게 조정했습니다. 본문 문장은 바꾸지 않았습니다.
- 리뷰 지적에 따라, 오늘 추가한 그림 중 너비가 너무 컸던 것을 세로 구성으로 고치고, 일부 그림과 캡션 표현을 본문 서술에 맞게 정확하게 맞췄습니다. 본문 문장은 바꾸지 않았습니다.
- 본문의 흐름·대비를 그림으로도 따라갈 수 있도록 Mermaid 그림을 17점 추가했습니다(본문 500~750자당 그림 1개의 규칙에 맞춘 것입니다). 기존 판단 흐름 그림에는 캡션을 추가했습니다. 본문 문장은 바꾸지 않았습니다.
- 글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건) 대응으로 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참조하세요.
- 샘플 리포지토리에 실제로 있는 테스트에서 발췌를 추가했습니다(callback 겹침과, 방치 후 tick 접힘). `DispatcherPriority.Background`가 기본값이라는 설명, WinForms 타이머의 정밀도는 55밀리초 정도인 점, `async void`에 해당하는 예외가 스레드 풀에서는 프로세스 전체를 종료시킬 수 있다는 점을 추가했습니다.
- 본문 중 관련 기사 링크 문구가 링크 대상의 현재 제목과 어긋나 있던 것을 실제 제목에 맞췄습니다. 본문 내용은 바꾸지 않았습니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635120)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「.NET 타이머 3종의 용도 구분 - PeriodicTimer/Timer/DispatcherTimer」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635120 https://comcomponent.com/ko/blog/periodictimer-system-threading-timer-dispatchertimer-guide/
- DOI(최신 버전)
- 10.5281/zenodo.21635120
- DOI(이 버전)
- 10.5281/zenodo.22217427
지난번 일반 Windows에서 소프트 리얼타임을 가능한 한 실현하기 위한 실천 가이드 - 먼저 보는 체크리스트에서는 Sleep에 맡긴 주기 루프를 피하고, 이벤트 구동이나 waitable timer를 쓰는 이야기를 정리했습니다. 한 줄로 말하면, 주기의 흔들림과 deadline miss를 줄이려면 타이머 종류보다 먼저 「기다리는 방법」 자체를 설계한다는 것이 지난번의 결론입니다.
그럼, 더 일상적인 .NET 앱 개발에서는 어떻게 할까요.
여기서 망설이기 쉬운 것이 PeriodicTimer, System.Threading.Timer, DispatcherTimer입니다.
이름은 전부 타이머이지만,
await로 틱을 기다리는 타이머- ThreadPool에서 callback이 날아오는 타이머
- UI 스레드의
Dispatcher위에서 동작하는 타이머
라는 식으로 성격이 꽤 다릅니다.
flowchart TB
accTitle: 이름은 비슷해도 성격이 다른 세 타이머
accDescr: PeriodicTimer는 await로 틱을 기다리는 타이머, System.Threading.Timer는 ThreadPool에서 callback이 날아오는 타이머, DispatcherTimer는 UI 스레드의 Dispatcher 위에서 동작하는 타이머라는 성격 차이를 나타낸다.
t["3개의 타이머"] --> pt["PeriodicTimer: await로 기다린다"]
t --> st["Timer: callback이 날아온다"]
t --> dt["DispatcherTimer: UI 스레드"]
그림1: 이름은 전부 타이머이지만, 기다리는 방식과 실행 위치의 성격이 전혀 다르다.
실무에서 섞이기 쉬운 것은 대개 이 부근입니다.
- 비동기 정기 처리인데
System.Threading.Timer에async람다를 넘겨 버린다 - WPF UI 업데이트인데 ThreadPool 타이머에서 화면을 직접 만진다
DispatcherTimer에 무거운 처리를 넣어 화면까지 둔하게 만든다- 지난번의 「소프트 리얼타임」 이야기와, 일상의 앱 정기 실행이 머릿속에서 섞인다
이 글에서는 주로 .NET 6 이후의 일반적인 C# / .NET 앱을 전제로,
PeriodicTimer / System.Threading.Timer / DispatcherTimer를 일상 실무에서 망설이기 어려운 순서로 정리합니다.
대상으로 삼는 것은 이 정도입니다.
- worker / 백그라운드 서비스
- 콘솔 앱
- ASP.NET Core의 뒷단 처리
- WPF 데스크톱 앱
이 글에서 말하는 DispatcherTimer는 주로 WPF의 System.Windows.Threading.DispatcherTimer를 가리킵니다.
WinUI / UWP에도 같은 사고방식의 DispatcherTimer가 있습니다.
WinForms라면 UI용 타이머로는 System.Windows.Forms.Timer를 보는 편이 자연스럽습니다.
이 글은 UI 쪽 설명을 WPF에 맞추고 있지만, WinForms도 대상에 포함합니다. DispatcherTimer를 System.Windows.Forms.Timer로 바꿔 읽으면 4.3과 5.2의 주의는 그대로 해당합니다. 다만 다음 두 점만 다릅니다.
System.Windows.Forms.Timer는 메시지 루프를 통해 Tick이 일어나는 단일 스레드 타이머이며,DispatcherTimer와 같은 우선순위(DispatcherPriority) 지정은 없습니다- Microsoft 문서에서는 정밀도가 55밀리초 정도가 한계라고 합니다. 세밀한 주기에는 맞지 않으므로, 그때는 UI용 타이머 이외를 검토합니다
여기서 다루는 것은 앱 쪽 정기 실행을 어떻게 쓸까입니다. 주기 정확성 자체가 주제일 때는 지난번 소프트 리얼타임 기사의 이야기로 돌아갑니다.
flowchart TB
accTitle: 이 글의 범위
accDescr: 앱 쪽 정기 실행을 어떻게 쓸지가 이 글의 범위이고, 주기 정확성 자체가 주제일 때는 기다리는 방법을 설계하는 소프트 리얼타임 기사의 이야기로 돌아간다는 구분을 나타낸다.
q{"무엇이 주제인가"}
q -->|"앱의 정기 실행 작성법"| here["이 글의 세 타이머 이야기"]
q -->|"주기 정확성 그 자체"| rt["기다리는 방법을 설계하는 지난번 이야기"]
그림2: 「일정 간격으로 무언가를 한다」여도, 정기 실행의 작성과 주기 정밀도 설계는 다른 문제이다.
또한 이 글에 나오는 코드는 빌드·실행할 수 있는 샘플 세트(PeriodicTimer / System.Threading.Timer의 라이브러리와 콘솔 데모, tick 접힘이나 callback 겹침을 검증하는 유닛 테스트)로 GitHub에 공개하고 있습니다.
periodictimer-system-threading-timer-dispatchertimer-guide - komurasoft-blog-samples (GitHub)
목차
- 먼저 결론(한 줄로)
- 먼저 한 장으로 정리
- 2.1. 전체 모습
- 2.2. 먼저 보는 판단 표
- 먼저 구분할 것
- 3.1. callback형인가, tick을 기다리는 형인가
- 3.2. ThreadPool에서 동작하는가, UI 스레드에서 동작하는가
- 3.3. 주기 처리와 정밀도 보장은 다른 이야기
- 전형 패턴
- 4.1. async 정기 처리라면
PeriodicTimer - 4.2. 가벼운 callback을 ThreadPool에서 돌린다면
System.Threading.Timer - 4.3. WPF UI 업데이트라면
DispatcherTimer - 4.4. 소프트 리얼타임에 가까운 주기 처리라면 다른 도구를 본다
- 4.1. async 정기 처리라면
- 흔한 안티패턴
- 리뷰 시 체크리스트
- 대략적인 용도 구분
- 정리
- 참고 자료
이 글의 지식 맵
이 글은 .NET의 3가지 타이머를 실행되는 위치와 처리를 쓰는 방식으로 정리합니다. PeriodicTimer는 CancellationToken을 써서 await로 tick을 기다리는 형의 타이머로, worker/BackgroundService에서의 비동기 정기 처리에 어울리지만, 고정밀 주기 실행을 위한 도구는 아닙니다. System.Threading.Timer는 ThreadPool 위에서 callback이 호출되는 경량 타이머로, 이전 callback의 완료를 기다리지 않으므로 겹침이 일어날 수 있는 데다, void인 TimerCallback에 async 람다를 넘기면 async void에 준하는 취급이 되어, ThreadPool 위의 미처리 예외가 프로세스를 떨어뜨릴 수 있습니다. DispatcherTimer는 WPF의 Dispatcher 위에서 Tick이 처리되므로 UI를 직접 업데이트할 수 있지만, 무거운 처리는 UI 스레드의 정체를 부르고, Stop()이나 구독 해제를 게을리하면 오브젝트의 수명을 끌고 갈 수 있습니다. WinForms용 Timer도 정밀도가 55밀리초 정도로 제한되는 점은 공통입니다.
flowchart LR
accTitle: PeriodicTimer·System.Threading.Timer·DispatcherTimer의 사용 구분
accDescr: PeriodicTimer가 CancellationToken을 써서 BackgroundService에서의 비동기 정기 처리에 어울리는 것, System.Threading.Timer가 ThreadPool 위에서 callback의 겹침이나 async void에 준하는 함정을 가지는 것, DispatcherTimer가 WPF의 Dispatcher 위에서 UI 스레드의 정체나 구독 해제 누락의 위험을 대가로 UI 업데이트를 담당하는 것, 어느 쪽도 고정밀 주기 실행의 요건에는 맞지 않는다는 것을 보여주는 그림
periodictimer["PeriodicTimer"]
system_threading_timer["System.Threading.Timer"]
dispatcher_timer["DispatcherTimer"]
cancellationtoken_dotnet["CancellationToken(.NET)"]
backgroundservice["BackgroundService"]
async_periodic_processing["비동기 정기 처리"]
precise_periodic_timing["고정밀 주기 실행 요구사항"]
dotnet_threadpool[".NET 스레드 풀"]
timer_callback_overlap["타이머 콜백 중첩"]
async_void_callback["async void에 해당하는 콜백"]
unhandled_threadpool_exception["ThreadPool의 처리되지 않은 예외로 인한 프로세스 종료"]
dispatcher_priority["DispatcherPriority"]
ui_thread_blocking["UI 스레드 막힘"]
event_subscription_leak["이벤트 구독 해제 누락"]
winforms_timer["System.Windows.Forms.Timer"]
reentrancy_guard["재진입 방지 가드(Interlocked.Exchange 등)"]
wpf_dispatcher["Dispatcher(WPF)"]
periodictimer -->|"이용한다"| cancellationtoken_dotnet
periodictimer -->|"권장되는 대응"| backgroundservice
periodictimer -->|"권장되는 대응"| async_periodic_processing
periodictimer -.->|"사용은 비권장"| precise_periodic_timing
system_threading_timer -->|"이용한다"| dotnet_threadpool
system_threading_timer -.->|"원인이 될 수 있다"| timer_callback_overlap
system_threading_timer -.->|"원인이 될 수 있다"| async_void_callback
system_threading_timer -.->|"사용은 비권장"| precise_periodic_timing
async_void_callback -->|"사용은 비권장"| async_periodic_processing
async_void_callback -.->|"원인이 될 수 있다"| unhandled_threadpool_exception
dispatcher_timer -->|"이용한다"| dispatcher_priority
dispatcher_timer -.->|"원인이 될 수 있다"| ui_thread_blocking
dispatcher_timer -.->|"사용은 비권장"| precise_periodic_timing
dispatcher_timer -.->|"원인이 될 수 있다"| event_subscription_leak
winforms_timer -->|"사용은 비권장"| precise_periodic_timing
reentrancy_guard -->|"권장되는 대응"| timer_callback_overlap
dispatcher_timer -->|"전제로 한다"| wpf_dispatcher
wpf_dispatcher -->|"이용한다"| dispatcher_priority
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 18건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
1. 먼저 결론(한 줄로)
await기반으로 일정 간격 처리를 자연스럽게 쓰고 싶다면, 먼저PeriodicTimer- ThreadPool 위에서 가벼운 callback을 정기적으로 실행하고 싶다면,
System.Threading.Timer - WPF UI 스레드에서 화면을 업데이트하고 싶다면,
DispatcherTimer System.Threading.Timer는 callback이 겹칠 수 있다. 비동기 처리를 대충 넣으면 쉽게 흐트러진다DispatcherTimer는 UI를 직접 만질 수 있는 대신, 무거운 처리를 넣으면 UI까지 멈추기 쉽다- 지난번 소프트 리얼타임 문맥에서는, 이 세 가지는 고정밀 대기의 주역이 아니다
요컨대 처음에 봐야 할 것은 다음 세 가지입니다.
- 어느 스레드 / 컨텍스트에서 돌리고 싶은가
- 처리 본체를
async/await로 직렬로 쓰고 싶은가 - callback 겹침을 허용할 수 있는가
이 세 가지만 나누어도 꽤 망설이기 어려워집니다.
flowchart TB
accTitle: 처음에 보는 세 질문
accDescr: 어느 스레드나 컨텍스트에서 돌리고 싶은가, 처리 본체를 async/await로 직렬로 쓰고 싶은가, callback 겹침을 허용할 수 있는가의 셋을 나누기만 해도 망설이기 어려워진다.
q1["어디서 돌리고 싶은가"] --> pick["타이머 선택이 정해진다"]
q2["async로 직렬로 쓰고 싶은가"] --> pick
q3["callback 겹침을 허용할 수 있는가"] --> pick
그림3: 타이머 이름보다 먼저, 이 세 질문을 나누어 생각한다.
2. 먼저 한 장으로 정리
2.1. 전체 모습
flowchart LR
A["일정 간격으로 무언가를 하고 싶다"] --> B{"UI 스레드에서 돌리고 싶은가?"}
B -- "예" --> C["DispatcherTimer"]
B -- "아니요" --> D{"처리 본체를<br/>async / await로<br/>자연스럽게 쓰고 싶은가?"}
D -- "예" --> E["PeriodicTimer"]
D -- "아니요" --> F{"ThreadPool에서<br/>가벼운 callback을<br/>돌리고 싶은가?"}
F -- "예" --> G["System.Threading.Timer"]
F -- "아니요" --> H["다른 설계도 검토<br/>Channel / BackgroundService / event / waitable timer"]
그림4: UI 스레드인가, async로 쓰고 싶은가, 가벼운 callback인가의 순으로 나누면 세 타이머가 정해진다.
실무에서는 대개 이 분기로 충분합니다. 망설일 때 가장 빗나가기 어려운 것은,
비동기 처리라면 PeriodicTimer, UI 업데이트라면 DispatcherTimer를 먼저 가르는 것입니다.
System.Threading.Timer는 편리하지만, callback 겹침과 수명 관리 버릇이 있어
첫 한 줄로는 조금 까다롭습니다.
2.2. 먼저 보는 판단 표
| 상황 | 우선 선택 | 실행되는 곳 | 맞는 이유 | 우선 주의점 |
|---|---|---|---|---|
| 일정 간격으로 HTTP / DB / 파일 I/O 등 async 처리를 돌리고 싶다 | PeriodicTimer |
지금 async 메서드 흐름 안 | await 기반으로 쓸 수 있고, 정지와 취소가 단순하다 |
타이머 하나당 컨슈머 하나 전제. 지연은 알아서 병렬화되지 않는다 |
| 가벼운 heartbeat / 메트릭 전송 / 캐시 만료 검사를 ThreadPool에서 돌리고 싶다 | System.Threading.Timer |
ThreadPool | 가볍고 callback형. 기존 callback 기반 설계에 올리기 쉽다 | callback은 재진입 가능 전제. 겹칠 수 있다. 참조를 유지한다 |
| WPF 시계 표시나 가벼운 UI 업데이트를 일정 간격으로 돌리고 싶다 | DispatcherTimer |
WPF의 Dispatcher(UI 스레드) |
UI를 그대로 만질 수 있다. 우선순위를 가질 수 있다 | 정확한 실행 시각은 보장되지 않는다. 무거운 처리로 UI가 막힌다 |
주기 정확성이 본체이고, Sleep에 맡기는 것을 피하고 싶다 |
이 세 가지를 주역으로 두지 않는다 | - | 목적이 앱의 정기 실행이 아니라 대기 정밀도 설계가 된다 | event / waitable timer 쪽을 본다 |
이 표에서 중요한 것은 타이머 이름보다 실행 위치와 작성법을 보는 것입니다. 타이머 선택에서 사고가 날 때는 API 명칭보다 「어디서 실행되는가」를 보지 않은 경우가 많습니다.
flowchart TB
accTitle: 타이머 선택의 착안점
accDescr: 타이머 선택에서 사고가 나는 것은 API 이름으로 고를 때이고, 어디서 실행되는가라는 실행 위치와, 어떻게 쓰고 싶은가라는 작성법을 보면 빗나가기 어려워진다.
name["이름으로 고른다"] --> miss["어디서 실행되는지를 보지 않고 사고한다"]
look["실행 위치와 작성법으로 고른다"] --> hit["빗나가기 어려운 선택이 된다"]
그림5: 사고의 대부분은 이름 고르기. 봐야 할 것은 「어디서 실행되는가」와 「어떻게 쓰고 싶은가」이다.
3. 먼저 구분할 것
3.1. callback형인가, tick을 기다리는 형인가
여기를 나누면 한눈에 구조가 드러납니다.
System.Threading.Timer와DispatcherTimer는 callback / event형PeriodicTimer는 tick을await해서 기다리는 형
즉,
- callback형은 「타이머 쪽이 불러 온다」
PeriodicTimer는 「이쪽이 다음 tick을 기다린다」
는 차이입니다.
처리 본체가 async이고,
「기다린다 → 처리한다 → 다시 기다린다」를 한 줄기 흐름으로 읽고 싶다면 PeriodicTimer가 더 자연스럽습니다.
반대로,
- 기존 callback 기반 설계에 올리고 싶다
- 처리 본체가 짧고 동기적이다
- 단순히 정기적으로 호출하고 싶다
는 장면에서는 System.Threading.Timer가 맞습니다.
PeriodicTimer는 편리하지만 만능은 아닙니다.
하나의 타이머에 동시에 여러 WaitForNextTickAsync를 던지는 전제가 아니고,
기다리는 쪽이 없는 동안 tick이 여러 번 나와도 한 번으로 접힙니다.
여기를 「알아서 따라잡아 준다」고 오해하지 않는 것이 중요합니다.
flowchart TB
accTitle: callback형과 tick을 기다리는 형
accDescr: System.Threading.Timer와 DispatcherTimer는 타이머 쪽이 불러 오는 callback형이고, PeriodicTimer는 이쪽이 다음 tick을 await로 기다리는 형이라는 차이를 나타낸다.
q{"어느 형인가"}
q -->|"callback형"| cb["타이머 쪽이 불러 온다"]
q -->|"tick을 기다리는 형"| tick["이쪽이 다음 tick을 기다린다"]
cb --> cbex["Timer와 DispatcherTimer"]
tick --> ptex["PeriodicTimer"]
tick -.-> flow["기다린다→처리→다시 기다린다가 한 줄기로"]
그림6: 「불린다」인가 「기다린다」인가. 이 구분만으로 한눈에 들어온다.
3.2. ThreadPool에서 동작하는가, UI 스레드에서 동작하는가
다음에 봐야 할 것은 어디서 실행되는가입니다.
System.Threading.Timer의 callback은 만든 스레드가 아니라 ThreadPool에서 동작합니다.
이 때문에 백그라운드 처리에는 맞는 한편, UI를 직접 만지는 전제는 아닙니다.
반면 DispatcherTimer는 Dispatcher 큐에 통합된 UI용 타이머입니다.
WPF에서는 같은 Dispatcher 위에서 동작하므로, Tick 핸들러 안에서 UI를 그대로 업데이트할 수 있습니다.
이 차이는 꽤 큽니다.
- ThreadPool 타이머에서 UI를 만지려면 명시적으로 UI로 돌아가야 한다
DispatcherTimer는 UI를 만지기 쉽지만, 그만큼 UI 스레드 시간을 쓴다
즉 DispatcherTimer는 「UI를 안전하게 만질 수 있다」가 강점이지만,
그것은 동시에 「무거운 처리를 넣으면 입력과 다시 그리기까지 끌어들인다」는 뜻이기도 합니다.
flowchart TB
accTitle: 실행 위치의 차이와 트레이드오프
accDescr: System.Threading.Timer의 callback은 ThreadPool에서 동작하므로 UI를 만지려면 명시적으로 돌아가야 하고, DispatcherTimer는 UI를 그대로 만질 수 있는 대신 UI 스레드 시간을 쓴다.
st["Timer: ThreadPool에서 동작"] --> back["UI를 만지려면 명시적으로 돌아간다"]
dt["DispatcherTimer: UI 스레드"] --> easy["UI를 그대로 만질 수 있다"]
easy --> cost["무거운 처리는 입력과 그리기를 끌어들인다"]
그림7: 어디서 실행되는가의 차이는, UI를 만지기 쉬움과 UI 스레드 소비라는 트레이드오프가 된다.
3.3. 주기 처리와 정밀도 보장은 다른 이야기
여기는 지난번 글과의 연결로서 중요합니다.
일정 간격으로 무언가를 한다는 말은 같아도,
- 앱 사정으로 몇 초마다 정기 처리를 하고 싶다
- 1ms~수 ms급으로, 가능한 한 deadline에 가깝게 하고 싶다
는 다른 문제입니다.
System.Threading.Timer는 가볍고 다루기 쉬운 타이머이지만,
정밀도를 위한 전용 도구는 아닙니다.
DispatcherTimer도 Dispatcher 큐 사정과 우선순위 영향을 받습니다.
PeriodicTimer도 이름만 보면 「주기가 딱 맞을 것 같아」 보이지만,
실무에서의 강점은 precision이라기보다 async 흐름을 쓰기 쉽다는 점입니다.
그래서,
- 앱의 정기 실행을 쓰고 싶은가
- 대기 정밀도를 다지고 싶은가
는 처음에 나누는 편이 안전합니다.
이 둘이 섞이면 타이머 선택 논의가 점점 이상한 쪽으로 갑니다.
flowchart TB
accTitle: 정기 실행과 대기 정밀도의 구분
accDescr: 몇 초마다의 정기 처리라는 앱 사정과, 밀리초급으로 deadline에 가깝게 하고 싶다는 대기 정밀도 문제는 다르며, 세 타이머는 정밀도를 위한 전용 도구가 아니다.
same["일정 간격으로 무언가를 하고 싶다"] --> a["앱의 정기 실행"]
same --> b["대기 정밀도를 다지고 싶다"]
a --> timers["세 타이머가 나설 자리"]
b --> design["기다리는 방법 자체의 설계로"]
b -.-> note["세 타이머는 정밀도의 전용 도구가 아니다"]
그림8: 「일정 간격」이라는 말이 같아도, 정기 실행과 정밀도 보장은 다른 문제로 다룬다.
4. 전형 패턴
4.1. async 정기 처리라면, PeriodicTimer
worker나 BackgroundService, 콘솔의 상주 처리 등에서
일정 간격으로 async 처리를 돌리고 싶다면, 먼저 PeriodicTimer가 쓰기 쉽습니다.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public sealed class CacheRefreshWorker : BackgroundService
{
private readonly ILogger<CacheRefreshWorker> _logger;
public CacheRefreshWorker(ILogger<CacheRefreshWorker> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("CacheRefreshWorker started.");
await RefreshCacheAsync(stoppingToken);
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await RefreshCacheAsync(stoppingToken);
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("CacheRefreshWorker stopping.");
}
}
private async Task RefreshCacheAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Refreshing cache...");
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}
}
이 형태의 좋은 점은,
- 코드 흐름을 하나의
async메서드로 따라가기 쉽다 CancellationToken을 그대로 하위 호출로 전달하기 쉽다- callback 기반의 수명 관리와 예외 관리를 줄일 수 있다
특히 처리 본체가
- HTTP를 호출한다
- DB에 질의한다
- 파일을 읽는다
- 다른 async API를 await한다
같은 I/O 대기 중심이면 궁합이 꽤 좋습니다.
주의점은 두 가지입니다.
- 타이머 하나당 컨슈머 하나 전제로 쓴다
- 처리 시간이 주기보다 길 때의 방침을 스스로 정한다
PeriodicTimer는 이전 처리가 길어졌다고 해서 자동으로 병렬화해 따라잡아 주지 않습니다.
그 의미에서 「일정 간격의 async 루프를 자연스럽게 쓰기」 위한 타이머입니다.
테스트하기 쉬운 점까지 보면, TimeProvider를 받는 생성자를 쓸 수 있는 것도 은근히 편리합니다.
flowchart TB
accTitle: PeriodicTimer에 의한 정기 루프의 흐름
accDescr: WaitForNextTickAsync로 다음 tick을 기다리고, async 처리 본체를 실행한 뒤 다시 기다린다는 한 줄기 흐름으로 쓸 수 있으며, CancellationToken 취소로 루프를 빠져나온다.
wait["WaitForNextTickAsync로 기다린다"] --> work["async 처리 본체를 실행"]
work --> wait
wait -.->|"token의 취소"| exitl["루프를 빠져나와 정지"]
work -.-> note["늦어도 자동으로 병렬화되지 않는다"]
그림9: PeriodicTimer는 「기다린다→처리한다→다시 기다린다」를 하나의 async 메서드로 쓸 수 있다.
4.2. 가벼운 callback을 ThreadPool에서 돌린다면, System.Threading.Timer
정기적으로 짧은 callback만 호출하고 싶다면 System.Threading.Timer는 단순합니다.
예를 들어,
- heartbeat를 보낸다
- 가벼운 메트릭을 수집한다
- 짧은 만료 검사를 넣는다
- 기존 callback 기반 설계에 연결한다
같은 장면입니다.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public sealed class HeartbeatService : IHostedService, IDisposable
{
private readonly ILogger<HeartbeatService> _logger;
private Timer? _timer;
private int _running;
public HeartbeatService(ILogger<HeartbeatService> logger)
{
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_timer = new Timer(OnTimer, null, TimeSpan.Zero, TimeSpan.FromSeconds(5));
return Task.CompletedTask;
}
private void OnTimer(object? state)
{
if (Interlocked.Exchange(ref _running, 1) != 0)
{
return;
}
try
{
_logger.LogInformation("Heartbeat: {Now}", DateTimeOffset.Now);
}
finally
{
Volatile.Write(ref _running, 0);
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
return Task.CompletedTask;
}
public void Dispose()
{
_timer?.Dispose();
}
}
이 예에서 Interlocked.Exchange를 넣은 이유는
System.Threading.Timer가 이전 callback 완료를 기다리지 않기 때문입니다.
여기는 꽤 중요합니다.
- callback은 ThreadPool에서 동작한다
- callback은 재진입 가능 전제이다
- 간격보다 처리가 길면 겹칠 수 있다
이 「겹칠 수 있다」는 샘플 유닛 테스트에서 실제로 관측할 수 있는 형태로 만들어 두었습니다. 주기 50ms 타이머에 300ms 걸리는 처리를 넣고 동시 실행 수 최댓값을 세면 2 이상이 됩니다. 위와 같은 Interlocked.Exchange 가드를 넣은 버전에서는 같은 조건에서도 동시 실행 수 최댓값은 1로 남고, 대신 실행 중에 호출된 callback은 건너뜁니다.
// tests/KomuraSoft.TimerSelection.Tests/ThreadPoolTimerOverlapTests.cs 에서 발췌
// 가드 없음: 주기 50ms에 대해 처리 300ms. 겹침이 관측될 때까지 기다린다
bool overlapped = await WaitUntilAsync(
() => Volatile.Read(ref maxObserved) >= 2,
TimeSpan.FromSeconds(10));
Assert.True(overlapped, "timer callbacks did not overlap within the timeout.");
// 가드 있음: 같은 조건에서도 동시 실행 수 최댓값은 1로 남는다
Assert.Equal(1, Volatile.Read(ref maxConcurrent));
처리가 가볍지 않다면,
- 중복 실행을 건너뛴다
- 큐에 쌓는다
PeriodicTimer로 기운다
처럼 설계하는 편이 무난합니다.
flowchart TB
accTitle: callback 겹침과 가드
accDescr: System.Threading.Timer는 이전 callback 완료를 기다리지 않으므로, 간격보다 처리가 길면 겹칠 수 있다. Interlocked.Exchange 가드를 넣으면 동시 실행은 1로 남고, 실행 중에 호출된 callback은 건너뛴다.
fire["간격마다 callback 호출"] --> q{"이전이 아직 실행 중인가?"}
q -->|"가드 없음"| overlap["callback이 겹쳐 실행된다"]
q -->|"가드 있음"| skip["이번 호출을 건너뛴다"]
skip --> one["동시 실행은 1로 남는다"]
그림10: 이전 완료를 기다리지 않는 타이머에서는, 겹침을 허용할지 가드로 걸러낼지를 스스로 정한다.
또 하나 은근히 중요한 것은 참조를 유지하는 것입니다.
System.Threading.Timer는 동작 중이라도 참조가 없어지면 GC 대상이 됩니다.
또한 Dispose()를 호출한 직후에도, 이미 큐에 들어간 callback이 나중에 실행될 수 있습니다.
즉 System.Threading.Timer는,
- 가볍다
- 빠르다
- 단순하다
이지만, 그 대신 callback 사정을 이쪽이 제대로 떠맡는 타이머입니다.
flowchart TB
accTitle: System.Threading.Timer의 수명 주의
accDescr: System.Threading.Timer는 동작 중이라도 참조가 없어지면 GC 대상이 되고, Dispose를 호출한 직후에도 이미 큐에 들어간 callback이 나중에 실행될 수 있다.
t["System.Threading.Timer"] --> ref["참조를 계속 유지한다"]
ref -.-> gc["참조가 끊기면 GC 대상이 된다"]
t --> disp["Dispose로 정리한다"]
disp -.-> late["큐에 들어간 callback은 나중에 실행될 수 있다"]
그림11: 가벼운 대신, 참조 유지와 Dispose 이후 callback까지 이쪽이 떠맡는다.
4.3. WPF UI 업데이트라면, DispatcherTimer
WPF에서 화면의 시계나 가벼운 상태 표시를 정기 업데이트하고 싶다면 DispatcherTimer가 자연스럽습니다.
using System;
using System.Windows;
using System.Windows.Threading;
public partial class MainWindow : Window
{
private readonly DispatcherTimer _clockTimer;
public MainWindow()
{
InitializeComponent();
_clockTimer = new DispatcherTimer(DispatcherPriority.Background)
{
Interval = TimeSpan.FromSeconds(1)
};
_clockTimer.Tick += ClockTimer_Tick;
_clockTimer.Start();
}
private void ClockTimer_Tick(object? sender, EventArgs e)
{
ClockText.Text = DateTime.Now.ToString("HH:mm:ss");
}
protected override void OnClosed(EventArgs e)
{
_clockTimer.Stop();
_clockTimer.Tick -= ClockTimer_Tick;
base.OnClosed(e);
}
}
생성자에 넘기는 DispatcherPriority.Background는 Tick을 Dispatcher 큐의 어느 우선순위로 처리할지 지정입니다. 인자 없는 new DispatcherTimer()도 기본값이 Background이므로, 여기는 기본값을 명시한 것일 뿐 동작을 바꾼 것은 아닙니다. Background(값 4)는 「유휴 상태가 아닌 다른 처리가 모두 끝난 뒤에 처리한다」는 우선순위이며, Input(5)이나 Render(7)보다 낮은 위치에 있습니다. 즉 입력 처리나 그리기를 밀어내고까지 Tick을 돌리지 않는다는 뜻입니다. 시계 표시처럼 「조금 어긋나도 괜찮지만 조작을 방해하고 싶지는 않다」는 용도와 잘 맞습니다. Tick 결과를 가능한 한 빨리 화면에 내고 싶다면 Normal(9)까지 올리는 선택도 있지만, 그 경우에는 Tick 내용을 가볍게 유지한다는 전제가 더 강해집니다.
flowchart TB
accTitle: DispatcherPriority의 위치
accDescr: Background는 유휴 상태가 아닌 다른 처리가 모두 끝난 뒤에 처리하는 우선순위로, Input이나 Render보다 낮아 입력이나 그리기를 밀어내고 Tick을 돌리지 않는다. 빨리 화면에 내고 싶다면 Normal까지 올리는 선택도 있다.
tick["DispatcherTimer의 Tick"] --> bg["Background〔값 4〕로 쌓는다"]
bg --> after["입력이나 그리기 뒤에 처리된다"]
after -.-> fit["조작을 방해하지 않는 시계에 맞다"]
bg -.-> up["급하면 Normal〔값 9〕로"]
up -.-> cond["그만큼 Tick은 가볍게 유지하는 전제"]
그림12: 기본 Background 우선순위는 「입력이나 그리기를 밀어내지 않는」 위치에 Tick을 쌓는다.
DispatcherTimer의 좋은 점은 Tick이 WPF의 Dispatcher 위에서 처리되므로 UI를 그대로 만질 수 있다는 점입니다.
이것은 예를 들어,
- 시계 표시
- 연결 상태의 가벼운 표시 업데이트
- Command 재평가 계기
- 화면에 나와 있는 숫자의 가벼운 업데이트
같은 장면과 궁합이 좋습니다.
다만 여기서도 분위기가 바뀌는 점이 있습니다.
DispatcherTimer는 UI 스레드에서 동작하므로,
Tick 핸들러에서 무거운 처리를 하면 그대로 입력·그리기·재배치까지 끌어들여 느려집니다.
또한 DispatcherTimer는 「지정 시각에 딱 맞춤」을 보장하는 도구가 아닙니다.
Dispatcher 큐 위의 다른 일과 우선순위 영향을 받습니다.
그래서 실무에서는,
- Tick 내용은 가볍게 둔다
- 무거운 I/O나 CPU는 다른 쪽으로 넘긴다
- 닫을 때는
Stop()과 구독 해제로 수명을 명시한다
정도까지 의식해 두면 안정됩니다.
flowchart TB
accTitle: DispatcherTimer를 안정시키는 세 점
accDescr: Tick 내용은 가볍게 두고, 무거운 I/O나 CPU 처리는 다른 쪽으로 넘기며, 화면을 닫을 때는 Stop과 구독 해제로 수명을 명시한다는 세 점으로 안정된다.
dt["DispatcherTimer 운용"] --> l1["Tick 내용은 가볍게 둔다"]
dt --> l2["무거운 일은 배경으로 넘긴다"]
dt --> l3["Stop과 구독 해제로 수명을 명시"]
l1 -.-> why["UI 스레드 시간을 쓰기 때문"]
그림13: UI를 그대로 만질 수 있는 편리함만큼, Tick을 가볍게 유지하고 수명을 명시하는 운용이 필요하다.
4.4. 소프트 리얼타임에 가까운 주기 처리라면, 다른 도구를 본다
여기가 지난번 글과의 연결점입니다.
지난번 소프트 리얼타임 기사에서 다룬 것은 「몇 초마다 대략 움직이면 된다」가 아니라, 주기의 흔들림이나 deadline miss를 어떻게 줄일까라는 이야기였습니다.
그 문맥에서는,
Sleep에 맡긴 상대 대기로 두지 않는다- 이벤트 구동이나 waitable timer를 쓴다
- fast path와 slow path를 나눈다
- 지연을 계측한다
가 주제가 됩니다.
그래서,
- 일상 앱의 async 정기 처리
→
PeriodicTimer - ThreadPool callback
→
System.Threading.Timer - UI 업데이트
→
DispatcherTimer - 주기 정밀도 자체가 주역 → 지난번 기사의 세계
처럼, 처음부터 문제를 나누어 버리는 것이 깔끔합니다.
「1ms마다 가능한 한 딱 돌리고 싶다. 어느 .NET 타이머가 좋은가」라는 질문은, 절반 정도는 이미 타이머 고르기가 아니라 대기 방법과 설계의 문제입니다.
flowchart TB
accTitle: 문제를 처음부터 넷으로 나눈다
accDescr: 일상 앱의 async 정기 처리는 PeriodicTimer, ThreadPool callback은 System.Threading.Timer, UI 업데이트는 DispatcherTimer, 주기 정밀도 자체가 주역이면 대기 방법 설계라는 소프트 리얼타임 쪽 이야기로 나눈다.
p["하고 싶은 것"] --> a["async: PeriodicTimer"]
p --> b["callback: Timer"]
p --> c["UI: DispatcherTimer"]
p --> d["정밀도: 대기 방법의 설계"]
그림14: 「어느 타이머인가」보다 먼저, 애초에 어느 문제인지를 넷으로 나누어 버리는 것이 깔끔하다.
5. 흔한 안티패턴
5.1. System.Threading.Timer에 async 람다를 그대로 넘긴다
이것은 꽤 하기 쉽습니다.
_timer = new Timer(async _ => await RefreshAsync(), null,
TimeSpan.Zero, TimeSpan.FromSeconds(5));
겉모습은 깔끔하지만, TimerCallback은 void입니다.
즉 이 async 람다는 실질적으로 async void처럼 다뤄집니다.
그러면,
- 호출 측이 await할 수 없다
- 완료를 기다릴 수 없다
- 예외 관리가 어렵다
- callback 겹침도 따로 생각해야 한다
는, 다루기 어려운 상태가 됩니다.
예외 관리가 어려운 이유는 조금 더 분명히 적어 둘 가치가 있습니다. async Task라면 예외는 Task에 실리므로, 호출 측이 await한 시점에 받을 수 있습니다. async void(상당)에는 그 Task가 없으므로, 던져진 예외는 그 메서드가 시작될 때 유효했던 SynchronizationContext로 직접 다시 던져집니다. System.Threading.Timer의 callback은 ThreadPool 위에서 동작하고, 거기에는 SynchronizationContext가 없습니다. 결과적으로 예외는 ThreadPool 스레드 위의 미처리 예외가 되어, 기본값에서는 프로세스 전체가 종료됩니다. callback 안을 try / catch로 직접 감싸지 않는 한, 바깥에서는 받아낼 수 없습니다.
flowchart TB
accTitle: async 람다를 callback에 넘긴 예외의 행선지
accDescr: TimerCallback은 void이므로 넘긴 async 람다는 async void 상당이 되고, 예외는 시작 시의 SynchronizationContext로 다시 던져지지만 ThreadPool 위에는 그것이 없어 미처리 예외가 되어 기본값에서는 프로세스 전체가 종료된다.
ex["async 람다 안의 예외"] --> void["async void 상당으로 밖으로 빠진다"]
void --> ctx{"SynchronizationContext는?"}
ctx -->|"ThreadPool 위에는 없다"| unh["ThreadPool의 미처리 예외가 된다"]
unh --> crash["기본값에서는 프로세스 전체가 종료된다"]
ex -.-> guard["직접 쓴 try / catch로만 막을 수 있다"]
그림15: 겉모습이 깔끔한 async 람다의 예외는, 받아낼 곳이 없는 채로 프로세스를 종료시킨다.
처리 본체가 async라면 먼저 PeriodicTimer를 검토하는 편이 읽기 쉽습니다.
5.2. DispatcherTimer의 Tick에 무거운 처리를 넣는다
DispatcherTimer는 UI를 그대로 만질 수 있어서, 그만 무엇이든 쓰고 싶어집니다.
하지만 그곳은 UI 스레드입니다.
- 긴 동기 처리
- 무거운 CPU 계산
- 블로킹 I/O
- 긴
await를 포함해 이중 실행될 수 있는 처리
를 넣으면 UI의 입력이나 그리기와 정면으로 충돌합니다.
Tick 내용은 가볍게 두고, 무거운 일은 배경으로 넘기며, 필요한 결과만 UI로 되돌리는 편이 안정됩니다.
5.3. PeriodicTimer라면 지연을 자동으로 되찾아 준다고 생각한다
여기도 오해하기 쉽습니다.
PeriodicTimer는 일정 간격의 async 루프를 깔끔하게 쓰는 도구로는 우수하지만,
이전 처리가 길어졌을 때 알아서 병렬 실행해 따라잡아 주지는 않습니다.
이것은 샘플 유닛 테스트에서도 확인할 수 있습니다. 주기 250ms 타이머를 아무도 기다리지 않는 상태로 1.5초 방치한 뒤 기다리면, 쌓여 있던 분으로 첫 번째 대기는 즉시 완료되지만 두 번째는 즉시 완료되지 않습니다. 방치한 동안의 여러 번 tick이 한 번으로 접혀 있다는 뜻입니다.
// tests/KomuraSoft.TimerSelection.Tests/PeriodicTimerBehaviorTests.cs 에서 발췌
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(250));
await Task.Delay(TimeSpan.FromMilliseconds(1500)); // 이 동안 아무도 기다리지 않는다
// 1회째 대기는, 쌓여 있던 tick에 의해 즉시 완료된다
ValueTask<bool> first = timer.WaitForNextTickAsync();
Assert.True(first.IsCompleted);
Assert.True(await first);
// 2회째 대기는 즉시 완료되지 않는다(여러 번 분의 tick은 남아 있지 않다)
ValueTask<bool> second = timer.WaitForNextTickAsync();
Assert.False(second.IsCompleted);
기다리는 쪽이 없는 동안의 tick이 한 번으로 접히는 일도 있으므로,
- 늦으면 건너뛸 것인가
- 최신만 보면 되는가
- 반드시 전체 횟수만큼 처리하고 싶은가
는 설계에서 정해야 합니다.
flowchart TB
accTitle: 기다리는 쪽이 없는 동안 tick의 접힘
accDescr: PeriodicTimer를 아무도 기다리지 않는 동안 여러 번 tick해도 한 번으로 접히므로, 늦으면 건너뛸지, 최신만 볼지, 전체 횟수만큼 처리하고 싶은지는 설계에서 정해야 한다.
idle["아무도 기다리지 않는 동안 여러 tick"] --> fold["한 번으로 접힌다"]
fold --> q{"지연의 처리는?"}
q --> s1["건너뛴다"]
q --> s2["최신만 본다"]
q --> s3["전체 횟수만큼 처리한다"]
그림16: 쌓인 tick은 한 번으로 접힌다. 따라잡는 방법은 자동이 아니라 설계에서 정한다.
5.4. 정지와 수명 관리를 뒤로 미룬다
타이머는 돌리는 것보다 멈추는 쪽이 사고가 납니다.
놓치기 쉬운 것은 이 부근입니다.
System.Threading.Timer를 로컬 변수 그대로 만들어 참조를 유지하지 않는다System.Threading.Timer를 멈추지 않고Dispose()주변을 애매하게 둔다DispatcherTimer를Stop()하지 않고 Tick 구독도 빼지 않는다- 화면을 닫은 뒤에도 타이머가 객체 수명을 끌어당긴다
특히 DispatcherTimer는 메서드가 바인드된 객체를 살려 두는 경우가 있습니다.
「이 Window, 닫았을 텐데 남아 있네」라는 묘한 느낌이 나면, 여기를 의심하고 싶어집니다.
flowchart TB
accTitle: 정지와 수명 관리의 함정
accDescr: 참조를 유지하지 않는 Timer나 애매한 Dispose는 멈추는 방법이 애매한 채로 남는 놓침이며, Stop도 Tick 구독 해제도 하지 않는 DispatcherTimer는 바인드 대상 객체를 살려 두어, 닫은 줄 알았던 Window가 남는 형태로 나타난다.
m1["Timer의 참조를 유지하지 않는다"] --> trouble["멈추는 방법이 애매한 채로"]
m2["Dispose 주변이 애매하다"] --> trouble
m3["Stop도 Tick 구독 해제도 하지 않는다"] --> keep["바인드 대상을 살려 둔다"]
keep --> ghost["닫은 줄 알았던 Window가 남는다"]
그림17: 타이머는 돌리는 것보다 멈추는 쪽이 사고가 난다. 수명 뒷정리를 처음부터 적어 둔다.
6. 리뷰 시 체크리스트
- 그 주기 처리를 UI 업데이트 / ThreadPool callback / async 루프 중 무엇으로 써야 하는지 설명할 수 있는가
- 처리 본체가 async인데 callback형 타이머로 억지로 밀어 넣지 않았는가
System.Threading.Timer를 쓴다면 callback 겹침을 견딜 수 있는가, 또는 가드하고 있는가DispatcherTimer의 Tick에 무거운 처리, 블로킹 I/O, 긴 동기 처리를 넣지 않았는가PeriodicTimer를 쓴다면 늦었을 때의 방침이 정해져 있는가- 정지 방법(
Change/Dispose/Stop)과 앱 종료 시 흐름이 명확한가 System.Threading.Timer의 참조를 제대로 유지하고 있는가DispatcherTimer의 구독 해제와 화면 닫을 때 뒷정리가 있는가- 그 문제가 「앱의 정기 실행」인지 「대기 정밀도」인지 처음에 나뉘어 있는가
7. 대략적인 용도 구분
실무에서의 기준을 적어 둡니다.
-
30초마다 API를 호출해 설정을 업데이트하고 싶다 →
PeriodicTimer -
5초마다 heartbeat나 가벼운 메트릭을 보내고 싶다 →
System.Threading.Timer -
WPF에서 시계 표시나 가벼운 상태 업데이트를 하고 싶다 →
DispatcherTimer -
Tick마다 UI를 직접 만지고 싶다 →
DispatcherTimer -
정기 처리 본체에
await가 많고, 정지와 예외도 자연스럽게 다루고 싶다 →PeriodicTimer -
callback 기반의 작은 호출을 낮은 비용으로 넣고 싶다 →
System.Threading.Timer -
1~5ms급 주기 정밀도나 흔들림 관리가 본체 → 이 세 가지보다 먼저, 지난번 기사의 대기 방법을 본다
꽤 거칠게 한 줄로 말하면,
PeriodicTimer는 async를 위한 타이머System.Threading.Timer는 ThreadPool callback을 위한 타이머DispatcherTimer는 UI를 위한 타이머
입니다.
이 기억법이면 크게 빗나가기 어렵습니다.
8. 정리
.NET 타이머 선택에서 정말 중요한 것은 이름의 차이가 아니라 이 세 점입니다.
- 어디서 동작하는가
- 어떤 흐름으로 쓰고 싶은가
- 겹침이나 지연을 어떻게 다루는가
방침으로는 이것만으로 충분합니다.
- async 정기 처리라면
PeriodicTimer - 가벼운 callback을 ThreadPool에서 돌린다면
System.Threading.Timer - WPF UI 업데이트라면
DispatcherTimer - 정밀도가 주역이면 다른 대기 방법을 본다
타이머는 이름이 비슷해서 섞입니다. 하지만 역할은 그렇게 비슷하지 않습니다.
PeriodicTimer는 async 흐름을 다듬는 도구System.Threading.Timer는 callback을 정기 호출하는 도구DispatcherTimer는 UI 스레드에서 정기 업데이트하는 도구
이 셋을 나누어 생각하기만 해도 코드는 한결 단순해집니다.
flowchart TB
accTitle: 세 타이머 역할의 정리
accDescr: PeriodicTimer는 async 흐름을 다듬는 도구, System.Threading.Timer는 callback을 정기 호출하는 도구, DispatcherTimer는 UI 스레드에서 정기 업데이트하는 도구라는 역할 차이를 정리한다.
t["타이머의 역할"] --> pt["PeriodicTimer: async"]
t --> st["Timer: callback"]
t --> dt["DispatcherTimer: UI"]
t -.-> rt["정밀도라면 대기 설계로"]
그림18: 이름은 비슷해도 역할은 비슷하지 않다. 이 세 분류로 기억하면 크게 빗나가지 않는다.
반대로 여기가 섞이면,
- async여야 하는데
async void처럼 된다 - UI를 직접 만져서 크래시가 난다
- callback이 겹쳐 상태가 꼬인다
- 주기 정밀도 이야기까지 한데 섞인다
는, 꽤 흔하게 성가신 일이 일어납니다.
우선 「어디서 돌리고 싶은가」부터 봅니다. 그것만으로 타이머 선택은 상당히 수월해집니다.
9. 참고 자료
- 이 글의 샘플 코드 세트(라이브러리, 데모, 유닛 테스트) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/periodictimer-system-threading-timer-dispatchertimer-guide
- 관련 기사: 일반 Windows에서 소프트 리얼타임을 가능한 한 실현하기 위한 실천 가이드 - 먼저 보는 체크리스트
- 관련 기사: C# async/await 실무 판단 표 - Task.Run과 ConfigureAwait
- 관련 기사: WPF/WinForms의 async와 UI 스레드를 한 장으로 정리
- Timers - .NET
- PeriodicTimer Class
- PeriodicTimer.WaitForNextTickAsync(CancellationToken) Method
- PeriodicTimer.Dispose Method
- PeriodicTimer Constructor
- Timer Class (System.Threading)
- Timer Constructor (System.Threading)
- Background tasks with hosted services in ASP.NET Core
- DispatcherTimer Class (System.Windows.Threading)
- DispatcherTimer Class (Microsoft.UI.Xaml)
- DispatcherTimer Constructor(기본값은 Background 우선순위)
- DispatcherPriority Enum
- Timer Class (System.Windows.Forms)(정밀도는 55밀리초 정도)
- Async/Await - Best Practices in Asynchronous Programming(async void의 예외 처리)
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
.NET Generic Host와 BackgroundService를 데스크톱 앱에서 쓰는 이유
Windows 도구나 상주 앱에서 시작, 주기 처리, 종료 처리, 로그, 설정, DI를 정리하기 위해 Generic Host와 BackgroundService를 어떻게 쓰는지 정리합니다.
멀티스레드 실무 베스트 프랙티스 .NET 편 ── 스레드를 늘리기 전에 정해 둘 것
「스레드를 만들었더니 가끔 죽거나 멈춘다」를 막는 설계의 정석을 .NET/C# 대상으로 정리합니다. 스레드를 직접 만들지 않고 Task에 맡기기, 공유 가변 상태 줄이기, 락의 규율, CancellationToken으로 정지 설계하기, UI 스레...
업무 시스템의 코드 설계 ── 상품 코드·고객 코드를 정하는 법과 check digit
상품 코드·고객 코드 등 업무 시스템의 코드 체계를 정하는 실무 가이드. 유의미 코드와 무의미 일련번호 판단표, JAN·Luhn 등의 check digit 산식과 C# 구현, Excel의 선행 0 소실 대책, 자릿수 초과와 이행까지 정리합니다.
WinForms / WPF 앱의 CI/CD 실전 ── GitHub Actions로 빌드부터 서명·배포까지 자동화하기
WinForms / WPF 앱의 CI/CD를 GitHub Actions로 구성하는 실무 가이드입니다. windows-latest에서 빌드+테스트를 돌리는 최소 YAML, 태그 기반 버전 부여, signtool 서명 연동, MSI/MSIX/Clic...
슬립・최대 절전 모드・Modern Standby와 장시간 가동 앱 ── '한밤중에 멈춰 있었다'를 설계로 막는다
장시간 동작하는 Windows 앱이 '아침에 보니 멈춰 있었다'가 되는 원인을 S3 슬립/최대 절전 모드/Modern Standby의 차이에서 정리합니다. 슬립 중 타이머와 TCP 연결의 동작, SetThreadExecutionState로 억제하...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
UI 스레드 & 타이머
WPF / WinForms UI 스레드, async 흐름, Dispatcher 사용, 타이머 판단을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
정기 실행이나 UI 업데이트, 백그라운드 처리를 포함한 Windows 앱 개발에서는 타이머 선정이 그대로 구현 품질에 영향을 줍니다.
기술 상담 & 설계 리뷰
PeriodicTimer와 DispatcherTimer의 책임을 어디서 나눌지 정리하고 싶은 단계라면, 기술 상담·설계 리뷰로 검토할 수 있습니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- PeriodicTimer와 System.Threading.Timer의 차이는 무엇인가요?
- 가장 큰 차이는 PeriodicTimer가 tick을 await해서 기다리는 방식이고, System.Threading.Timer가 callback 방식이라는 점입니다. PeriodicTimer는 「기다린다 → 처리한다 → 다시 기다린다」를 하나의 async 메서드 흐름으로 쓸 수 있고, CancellationToken도 하위 호출로 전달하기 쉽습니다. 반면 System.Threading.Timer의 callback은 ThreadPool에서 동작하고, 이전 callback 완료를 기다리지 않으므로 간격보다 처리가 길면 겹칠 수 있습니다. 비동기 정기 처리라면 PeriodicTimer, 가벼운 동기 callback의 정기 호출이라면 System.Threading.Timer가 맞습니다.
- PeriodicTimer는 처리가 늦어졌을 때 자동으로 따라잡아 주나요?
- 아니요. 이전 처리가 길어졌다고 해서 알아서 병렬 실행해 따라잡아 주지는 않습니다. 기다리는 쪽이 없는 동안 tick이 여러 번 나와도 한 번으로 접힙니다. 그래서 늦으면 건너뛸지, 최신만 보면 될지, 반드시 전체 횟수만큼 처리할지 설계 쪽에서 정해야 합니다. 또한 하나의 타이머에 동시에 여러 WaitForNextTickAsync를 던지는 전제도 아닙니다.
- System.Threading.Timer에 async 람다를 넘기면 안 되나요?
- 피하는 편이 좋습니다. TimerCallback은 void이므로, 넘긴 async 람다는 실질적으로 async void처럼 다뤄집니다. 호출 측이 await할 수 없고, 완료를 기다릴 수 없으며, 예외 관리도 어려워지고, callback 겹침도 따로 생각해야 합니다. 처리 본체가 async라면 먼저 PeriodicTimer를 검토하는 편이 읽기 쉽고 안전합니다.
- DispatcherTimer는 어떤 때 써야 하나요?
- WPF에서 시계 표시나 가벼운 상태 표시처럼 UI를 정기 업데이트하고 싶을 때 씁니다. Tick이 WPF의 Dispatcher(UI 스레드) 위에서 처리되므로, 핸들러 안에서 UI를 그대로 만질 수 있는 것이 강점입니다. 다만 UI 스레드에서 동작하는 만큼 Tick에 무거운 처리나 블로킹 I/O를 넣으면 입력과 그리기까지 함께 느려집니다. 지정 시각에 딱 맞춰 실행된다는 보장도 없습니다. Tick 내용은 가볍게 두고, 무거운 일은 배경으로 넘기며, 화면을 닫을 때는 Stop()과 구독 해제로 수명을 명시하는 것이 안정적입니다.