⏱️ WinAPI 타이머 기반 비동기 처리 완벽 가이드
💡 SetTimer, KillTimer, WM_TIMER로 구현하는 안정적인 주기 작업 처리
윈도우 애플리케이션을 개발하다 보면 일정한 간격으로 반복 작업을 실행해야 하는 경우가 많습니다.
예를 들어 실시간 데이터 업데이트, 애니메이션 효과, 상태 체크, 자동 저장 등이 있죠.
이럴 때 가장 간단하면서도 효과적인 방법이 바로 WinAPI의 타이머 기능입니다.
특히 GUI 환경에서 메인 스레드를 차단하지 않고 비동기 방식으로 주기 작업을 처리할 수 있어 프로그램 응답성을 높이는 데 유용합니다.
이번 글에서는 SetTimer로 타이머를 설정하고, KillTimer로 해제하며, WM_TIMER 메시지를 통해 주기 작업을 처리하는 방법을 자세히 알아봅니다.
또한 초보 개발자도 쉽게 이해할 수 있도록 코드 예시와 함께 실무에서 주의해야 할 점까지 정리했습니다.
📋 목차
⏱️ WinAPI 타이머의 기본 원리
WinAPI에서 제공하는 타이머는 운영체제 레벨에서 주기적으로 WM_TIMER 메시지를 윈도우 메시지 큐에 전달하는 방식으로 동작합니다.
즉, 개발자가 직접 쓰레드를 만들거나 루프를 제어하지 않아도, 지정한 간격마다 자동으로 이벤트가 발생하는 셈입니다.
이 구조의 장점은 메인 스레드의 흐름을 차단하지 않고 비동기적으로 반복 작업을 수행할 수 있다는 점입니다.
메시지 루프를 기반으로 동작하기 때문에 CPU 부하가 적고, 애플리케이션이 다른 이벤트(버튼 클릭, 윈도우 리사이즈 등)를 동시에 처리할 수 있습니다.
🔍 동작 흐름 이해하기
1. SetTimer 함수를 호출하면 지정한 간격(밀리초 단위)과 식별자(ID)를 기준으로 타이머가 등록됩니다.
2. 시스템 타이머가 해당 시간이 경과할 때마다 WM_TIMER 메시지를 해당 윈도우의 메시지 큐에 넣습니다.
3. 애플리케이션의 메시지 루프가 이를 감지하면, WndProc에서 처리할 수 있습니다.
// 타이머 생성
SetTimer(hWnd, 1, 1000, NULL); // 1초 간격
// 메시지 처리
case WM_TIMER:
if (wParam == 1) {
// 주기적으로 실행할 작업
}
break;
💎 핵심 포인트:
타이머 이벤트는 메시지 큐 기반이므로, CPU 점유율이 낮고 GUI 응답성이 유지됩니다.
하지만 타이머 간격은 시스템 타이머 해상도에 따라 약간의 지연이 발생할 수 있으며, 밀리초 단위의 정밀 타이밍이 필요한 경우에는 별도의 고해상도 타이머 API를 사용하는 것이 좋습니다.
🛠️ SetTimer로 주기 작업 설정하기
SetTimer 함수는 윈도우 애플리케이션에서 주기적인 작업을 등록할 때 사용하는 핵심 API입니다.
이 함수를 호출하면 운영체제가 타이머를 생성하고, 지정한 간격마다 WM_TIMER 메시지를 전달하게 됩니다.
📌 함수 원형과 매개변수
UINT_PTR SetTimer(
HWND hWnd, // 타이머 메시지를 받을 윈도우 핸들
UINT_PTR nIDEvent, // 타이머 식별자
UINT uElapse, // 주기(밀리초)
TIMERPROC lpTimerFunc // 콜백 함수(선택 사항)
);
일반적으로 lpTimerFunc은 NULL로 설정하여 WM_TIMER 메시지 기반으로 처리합니다.
타이머 식별자(nIDEvent)는 여러 개의 타이머를 구분하는 데 필요하며, 중복되지 않는 값을 사용해야 합니다.
⚙️ 기본 사용 예제
// 2초마다 WM_TIMER 메시지 발생
SetTimer(hWnd, 1, 2000, NULL);
// WndProc에서 처리
case WM_TIMER:
if (wParam == 1) {
UpdateUI();
}
break;
💡 TIP: 타이머를 등록하면 반드시 해제 시점을 고려해야 합니다. 그렇지 않으면 프로그램 종료 후에도 타이머 메시지가 남아 예기치 않은 동작이 발생할 수 있습니다.
특히 다중 타이머를 사용할 때는 ID 충돌을 방지하기 위해 상수나 enum을 활용하는 것이 좋습니다.
또한 uElapse 값이 너무 짧으면 CPU 부하가 증가할 수 있으므로 적절한 주기를 선택해야 합니다.
🛑 KillTimer로 타이머 안전하게 해제하기
타이머를 생성했으면, 더 이상 필요하지 않을 때 KillTimer를 호출하여 해제해야 합니다.
이를 소홀히 하면 프로그램이 불필요하게 WM_TIMER 메시지를 계속 처리하게 되고, 심하면 메모리 누수나 예기치 않은 동작이 발생할 수 있습니다.
📌 함수 원형과 매개변수
BOOL KillTimer(
HWND hWnd, // 타이머를 소유한 윈도우 핸들
UINT_PTR uIDEvent // 해제할 타이머 식별자
);
uIDEvent는 SetTimer에서 지정한 타이머 식별자와 동일해야 하며, 해당 타이머를 종료합니다.
성공하면 TRUE를, 실패하면 FALSE를 반환합니다.
⚙️ 사용 예제
// 타이머 해제
KillTimer(hWnd, 1);
⚠️ 주의: 다중 타이머 환경에서는 정확한 ID를 지정하지 않으면 다른 타이머까지 의도치 않게 해제될 수 있습니다.
또한 창이 종료되기 전에 모든 타이머를 해제하는 습관을 들이는 것이 좋습니다.
이는 프로그램 종료 시 깔끔한 리소스 정리를 위해 필수적인 과정입니다.
📨 WM_TIMER 메시지 처리 방식
WM_TIMER는 SetTimer로 등록한 주기가 도래할 때 윈도우 프로시저로 전달되는 메시지입니다.
메시지 루프에서 가져와 WndProc에서 처리하며, 일반적으로 wParam은 타이머 ID, lParam은 콜백 타이머인 경우 TIMERPROC 포인터(그 외에는 0)로 활용됩니다.
GUI 이벤트와 동일한 큐를 공유하므로, 버튼 클릭이나 리사이즈 같은 입력 이벤트와 공존하며 비동기적으로 주기 작업을 실행할 수 있습니다.
🧭 WndProc에서의 표준 처리 흐름
// 메시지 루프는 일반적인 형태를 사용(GetMessage/TranslateMessage/DispatchMessage)
// 창 프로시저
LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
switch (msg)
{
case WM_TIMER:
if (wParam == 1) { // 타이머 ID 확인
DoPeriodicWork(); // 주기 작업 실행 (UI 그리기, 상태 체크 등)
}
return 0; // 처리 완료를 알림
case WM_DESTROY:
KillTimer(hWnd, 1); // 종료 전에 반드시 해제
PostQuitMessage(0);
return 0;
}
return DefWindowProc(hWnd, msg, wParam, lParam);
}
🔁 TIMERPROC 콜백을 사용하는 변형
WM_TIMER 메시지 대신 콜백으로 직접 호출받고 싶다면 SetTimer(NULL, 0, interval, TimerProc)처럼 윈도우 핸들을 NULL로 두고 콜백을 지정할 수 있습니다.
이 경우 특정 윈도우가 아닌, 타이머를 만든 스레드의 메시지 큐를 통해 콜백이 실행됩니다.
VOID CALLBACK TimerProc(HWND hWnd, UINT uMsg, UINT_PTR idEvent, DWORD dwTime)
{
// 콜백은 비교적 짧게! 긴 작업은 별도 스레드/작업큐로 분리
DoPeriodicWork();
}
// 등록 예시 (콜백 방식)
SetTimer(NULL, 1, 1000, (TIMERPROC)TimerProc);
| 항목 | 설명 |
|---|---|
| wParam | 타이머 ID(식별자). |
| lParam | TIMERPROC 콜백 포인터(콜백 방식일 때). 그 외에는 0. |
- 🧩타이머 ID로 분기 처리하여 여러 타이머가 섞이지 않도록 관리합니다.
- 🧵긴 작업은 타이머 핸들러에서 직접 수행하지 말고 작업 큐나 별도 스레드로 넘겨 UI 멈춤을 방지합니다.
- 🎚️간격이 너무 짧으면 과도한 호출로 CPU 사용량이 상승할 수 있으니, 목적에 맞는 적정 주기를 선택합니다.
- 🧹창 종료(WM_DESTROY) 전에 KillTimer로 반드시 해제합니다.
💎 핵심 포인트:
WM_TIMER는 일반 용도의 주기 작업에 적합합니다. 밀리초 단위 정밀 타이밍이나 오디오·영상 동기화처럼 고해상도가 필요한 경우에는 CreateTimerQueueTimer, SetWaitableTimer 등 대안을 검토하세요.
⚠️ 주의: WM_TIMER 처리 중에 또다른 긴 동기 작업을 호출하면 다음 타이머 틱이 밀릴 수 있습니다. 재진입 가능성 및 타이머 누적 호출을 고려해 설계하세요.
⚡ 실무에서 유용한 타이머 활용 팁
타이머는 간단하지만, 실제 제품 코드에서는 응답성 저하와 자원 누수를 막기 위한 세심한 설계가 필요합니다.
여기서는 SetTimer, KillTimer, WM_TIMER를 기반으로 한 GUI 주기 작업을 좀 더 견고하게 만드는 실전 패턴을 정리합니다.
UI 갱신, 상태 폴링, 자동 저장 같은 반복 로직에 바로 적용할 수 있도록 체크리스트와 코드 스니펫을 함께 제공합니다.
🧭 주기 조절과 일시중지 패턴
사용자 상호작용에 따라 주기를 동적으로 조절하면 퍼포먼스와 배터리 효율을 동시에 얻을 수 있습니다.
예를 들어 사용자가 비활성 탭에 있을 때는 주기를 늘리고, 포커스를 얻으면 다시 줄이는 방식이 유용합니다.
또한 긴 작업이 발생하면 재진입을 막기 위해 타이머를 잠시 멈춘 뒤 작업이 끝나면 재개하는 패턴을 권장합니다.
// 토글 가능한 타이머 (ID=1)
bool g_running = false;
UINT g_interval = 1000; // 1초
void StartOrUpdateTimer(HWND hWnd, UINT intervalMs)
{
g_interval = intervalMs;
if (!g_running) {
SetTimer(hWnd, 1, g_interval, NULL);
g_running = true;
} else {
// 간격 변경: 다시 설정
KillTimer(hWnd, 1);
SetTimer(hWnd, 1, g_interval, NULL);
}
}
void PauseTimer(HWND hWnd)
{
if (g_running) {
KillTimer(hWnd, 1);
g_running = false;
}
}
// WndProc 내 처리 예시
case WM_TIMER:
if (wParam == 1) {
// 재진입 방지: 일시 중지 후 작업
PauseTimer(hWnd);
DoPeriodicWork();
// 작업 종료 후 재개
StartOrUpdateTimer(hWnd, g_interval);
}
break;
🎨 UI 깜빡임 최소화와 작업 분리
타이머 핸들러에서 무거운 계산을 직접 수행하면 다음 틱이 밀리면서 UI가 버벅일 수 있습니다.
핸들러에서는 최소한의 상태 갱신만 수행하고, 실제 무거운 일은 작업 큐나 별도 스레드로 넘기세요.
UI 업데이트가 필요하다면, 계산이 끝난 뒤 PostMessage로 경량 신호를 보내 InvalidateRect와 UpdateWindow를 조합해 그리기를 트리거하는 방식이 깔끔합니다.
- 🧩여러 타이머를 쓰면 ID 범위를 명확히 구분하고 상수/enum으로 관리합니다.
- 🔄긴 작업 전에는 타이머를 잠시 중단하고, 작업 후 재개해 재진입을 방지합니다.
- 🧼WM_DESTROY 등 종료 경로에서 KillTimer로 모두 정리합니다.
- 🎚️배터리·발열 고려: 백그라운드/비활성 상태에서는 주기를 늘려 자원을 아낍니다.
- 🛡️콜백(TIMERPROC)을 쓸 때는 예외 전파를 막고 핸들 릭이 없도록 방어 코드를 둡니다.
🧪 디버깅과 로깅 요령
타이머는 이벤트성이라 타이밍 문제를 재현하기가 까다롭습니다.
틱 번호와 시간 스탬프를 함께 로깅하면 지연 원인을 파악하기 쉽습니다.
또한 예상치 못한 연속 호출이 보이면, 핸들러 내부에서 상태 플래그로 중복 실행을 차단하고, 호출 간격을 기록해 병목 지점을 찾아보세요.
💎 핵심 포인트:
핸들러는 가볍게, 긴 일은 분리, 종료 경로에서 일괄 해제—이 세 가지만 지켜도 대부분의 타이머 관련 버그를 피할 수 있습니다.
⚠️ 주의: 타이머 간격을 지나치게 짧게 설정하면 메시지 큐가 포화되어 다른 입력 이벤트 처리가 지연될 수 있습니다. 측정 기반으로 적정 주기를 선택하세요.
❓ 자주 묻는 질문 (FAQ)
SetTimer로 만든 타이머는 정확도가 어느 정도인가요?
밀리초 단위의 정밀 동기화가 필요하다면 CreateTimerQueueTimer, SetWaitableTimer 같은 대안을 고려하세요.
WM_TIMER에서 무거운 연산을 바로 실행해도 되나요?
WM_TIMER는 GUI 메시지 큐를 공유하므로 긴 연산을 수행하면 입력 지연과 깜빡임이 발생합니다.
최소한의 상태 갱신만 하고, 실제 연산은 작업 큐나 별도 스레드로 분리하세요.
여러 개의 타이머를 동시에 쓰려면 어떻게 구분하나요?
상수 또는 enum으로 범위를 명확히 관리하면 충돌을 예방할 수 있습니다.
TIMERPROC 콜백을 쓰는 방식과 WM_TIMER 방식의 차이는 무엇인가요?
TIMERPROC 방식은 지정된 콜백이 호출되며, 윈도우 핸들을 NULL로 지정하면 타이머를 만든 스레드 메시지 큐를 통해 실행됩니다.
구조적 제어와 UI 연계는 WM_TIMER가 편하며, 느슨한 연계가 필요하면 콜백이 유용합니다.
타이머를 중지했다가 재개하려면 어떻게 하나요?
긴 작업 직전에 잠시 멈췄다가, 작업이 끝나면 동일한 간격으로 재설정하는 패턴이 재진입을 방지하는 데 효과적입니다.
타이머를 해제하지 않으면 어떤 문제가 생기나요?
창 종료 경로(WM_DESTROY 등)에서 모든 타이머를 KillTimer로 정리하는 습관이 필요합니다.
uElapse 간격은 최소 얼마까지 안전할까요?
UI 업데이트, 상태 폴링 등 목적에 맞는 실측 기반 간격을 선택하고, 필요 시 TimerQueue 등 고급 타이머를 고려하세요.
타이머에서 UI를 갱신할 때 깜빡임을 줄이려면?
큰 그리기 작업은 별도 루틴으로 분리해 프레임 드랍을 줄이세요.
🧭 SetTimer와 WM_TIMER로 GUI 비동기 주기 작업 완성하기
이 글에서는 WinAPI에서 타이머 기반 비동기 처리를 구현하는 전 과정을 정리했습니다.
메시지 루프에 주기적으로 전달되는 WM_TIMER를 중심으로, SetTimer로 등록하고 KillTimer로 안전하게 해제하는 규칙을 살폈습니다.
핸들러는 가볍게 유지하고 긴 작업은 작업 큐 또는 별도 스레드로 분리해 UI 응답성을 확보하는 것이 핵심입니다.
다중 타이머는 ID로 엄격히 구분하고, 종료 경로에서 일괄 해제하는 습관이 중요합니다.
정밀 타이밍이 필요하지 않은 일반적인 GUI 주기 작업에는 기본 타이머가 가장 간단하고 효과적입니다.
반면 밀리초 단위의 정확도가 요구되거나 호출 누적에 민감한 시나리오에서는 TIMERPROC, CreateTimerQueueTimer, SetWaitableTimer 같은 대안을 검토하면 안정성과 성능을 동시에 잡을 수 있습니다.
실측 기반으로 간격을 조정하고, 필요한 경우 일시중지와 재개 패턴을 적용하세요.
🏷️ 관련 태그 : WinAPI, SetTimer, KillTimer, WM_TIMER, Windows GUI, 타이머, 비동기처리, 메시지루프, TIMERPROC, CreateTimerQueueTimer