들어가며
Qt Framework의 최고의 장점은 크로스 플랫폼(Cross-Platform)을 지원하는 풍부한 라이브러리다.
그중에서도 QTimer는 특정 시간마다 반복 실행하거나 시간 관련 기능을 개발할 때 매우 편리하다.
오늘 SW 개발 중 시간 관련 기능을 테스트하다 버그를 발견했다.
타이머가 100㎳마다 실행되어야 함에도 약 115㎳ 주기로 실행되고 있었고, 결국 실행 횟수 누락이 발생하는 버그였다.
이번 글에서는 버그를 잡기 위해 공부한 QTimer 심화 내용을 정리하고,
QTimer에서 횟수 누락이 발생하는 테스트 코드를 만들어 보았다.
QTimer 란?
QTimer는 QEventLoop 위에서 동작하며, 객체 간 소통을 위해 Signal/ Slot 구조를 사용한다.
Event Loop의 동작 원리는 이전 글에서 다룬 바 있다.
2026.01.23 - [Qt] - Qt Event Loop 동작 원리 정리 ㅡ 타이머, 이벤트, 스레드 까지
QTimer로 개발하기 쉬운 대표적인 두 가지 사례는 다음과 같다.
- 특정 시간(Interval)마다 반복되는 동작

그림1. 100㎳ 간격으로 동작하는 Timer 예시 - N초 지연 후 실행

그림2. 100㎳ 지연 후 동작하는 Timer 예시
QTimer 동작 원리
반복 타이머를 직접 만들어 본다고 생각하면 더 쉽게 이해할 수 있다.
그림1 상황에 task만 고려할 때, task 종료 시 sleep(반복주기 - task 소요시간) 로 이론적인 타이머를 구현할 수 있다.

단, 실제로는 100㎳에 정확히 호출되지 않고 미세한 Jitter 가 발생한다.
따라서 sleep시간을 계산할 때는 예정된 시간을 기준으로 계산해야 하며, Qt Timer도 이 방식을 사용한다.

QTimer는 여기서 한 가지를 더 고려해야 한다.
Signal / Slot으로 전달되는 QTimer는 EventLoop까지 포함하면 다음과 같이 동작한다.

*차트에서 Jitter와 QEvetnLoop를 크게 표현했지만, 실제로는 매우 짧은 시간 동안 발생한다.
Qt6 Precise Timer (QChronoTimer)
그림5 에서 발생할 수 있는 문제는, 짧은 task 응답(㎲단위)을 받기에 QEventLoop가 우리 생각처럼 빠르지 않다는 점에 있다.
기존의 QTimer는 밀리초(㎳) 단위의 제어를 목표로,
CPU 전력 효율을 위해 타이머를 묶어서 처리하기 때문에 ㎲(마이크로초) 단위의 오차가 발생한다.
Qt::PreciseTimer 옵션은 Qt5부터 제공되는 옵션으로,
타이머의 전력 효율 최적화를 비활성화해 더 정밀한 EventLoop 호출을 가능하게 한다.
그러나 그럼에도 불구하고,
㎳ 단위의 정밀도를 갖는 QTimer로는 100㎲ Interval 같은 고정밀 동작을 수행하지는 못한다는 한계가 있다.
이 한계를 극복하기 위해 Qt6.8에서 도입된 것이 QChronoTimer Class이다.
QChronoTimer는 내부적으로 nanosecond 정밀도를 지원하므로, 정밀한 타이머 동작이 필요한 경우 이를 사용해야 한다.
QTimer 누적 오차 Trouble Shooting
지금까지 정리한 내용을 바탕으로 재현 가능한 오류 시나리오를 구성해 보자.
구조
- main Thread 와 m_runner Thread 로 구성 (2개의 EventLoop)
- main 역할1 : 100㎳ 마다 m_runner::runProcess() Slot 호출
- main 역할2 : m_runner::taskFinished() Signal 처리
- m_runner 역할 : runProcess() 처리
버그가 발생하도록 각 단계에 지연 시간을 추가한 구조는 다음 이미지와 같다.

이 프로그램의 문제는 onTaskFinished 가 MainThread의 EventLoop를 점유해 QTimer의 발화를 방해하는 것에 있다.

위 그림처럼 QTimer timeout 처리가 지연되고, 그것이 누적되어 tick 누락으로 이어질 수 있다.
QTimer 누적 오차 결과 및 TestCode
---- ---------- ----------- --------
1 101 100 1
2 216 200 16
3 331 300 31
4 501 400 101
Total elapsed : 10015 ms
Expected ticks: 100
Actual ticks : 76
Missing ticks : 24
테스트에서도 실제 QTimer::timeout 처리가 밀리는 것과, timeout이 누락되는 현상을 명확히 확인할 수 있었다.
main.cpp
#include <QCoreApplication>
#include <QElapsedTimer>
#include <QTextStream>
#include <QThread>
#include <QTimer>
#include <QMutex>
#include <QMutexLocker>
#include <QMetaObject>
// ── Worker ─────────────────────────────────────────
class Runner : public QObject
{
Q_OBJECT
public:
explicit Runner(QObject *parent = nullptr) : QObject(parent) {}
public slots:
void runProcess()
{
// Simulation (WorkerThread — no impact on MainThread)
QElapsedTimer t;
t.start();
volatile double acc = 0.0;
for (long long i = 1; t.elapsed() < 70; ++i) // delay 70ms
acc += 1.0 / static_cast<double>(i);
(void)acc;
emit taskFinished();
}
signals:
void taskFinished();
};
// ── Main Thread ─────────────────────────────────────────────────────
class TaskManager : public QObject
{
Q_OBJECT
public:
explicit TaskManager(QObject *parent = nullptr)
: QObject(parent)
, m_cycleTrigger(new QTimer(this))
, m_stopTimer(new QTimer(this))
, m_runner(new Runner())
, m_tickCount(0)
, m_busy(false)
{
// CycleTrigger: QTimer in MainThread
m_cycleTrigger->setInterval(100);
connect(m_cycleTrigger, SIGNAL(timeout()), this, SLOT(onTaskTriggered()));
// worker thread
m_runner->moveToThread(&m_workerThread);
connect(&m_workerThread, SIGNAL(finished()), m_runner, SLOT(deleteLater()));
connect(m_runner, SIGNAL(taskFinished()), this, SLOT(onTaskFinished())); // QueuedConnection
// 10 seconds after stop
m_stopTimer->setSingleShot(true);
m_stopTimer->setInterval(10000);
connect(m_stopTimer, SIGNAL(timeout()), this, SLOT(onStop()));
}
void start()
{
QTextStream out(stdout);
out << "Interval : 100 ms (CycleTrigger, MainThread)\n";
out << "Duration : 10 s\n";
out << "Expected ticks: 100\n";
out << "runProcess : ~70 ms (WorkerThread, no impact on MainThread)\n";
out << "onTaskFinished : ~45 ms (MainThread blocking)\n";
out << QString("%1\t%2\t%3\t%4\n")
.arg("tick", 4)
.arg("elapsed_ms", 10)
.arg("expected_ms", 11)
.arg("drift_ms", 8);
out << "----\t----------\t-----------\t--------\n";
out.flush();
m_workerThread.start();
m_elapsed.start();
m_stopTimer->start();
m_cycleTrigger->start();
}
private slots:
// ── CycleTrigger (main Thread) ──────────────────────────────────
void onTaskTriggered()
{
if (m_busy) {
// just skip if the main thread is busy (simulate tick drop)
return;
}
m_busy = true;
++m_tickCount;
const qint64 actualMs = m_elapsed.elapsed();
const qint64 expectedMs = static_cast<qint64>(m_tickCount) * 100;
const qint64 driftMs = actualMs - expectedMs;
QTextStream out(stdout);
out << QString("%1\t%2\t%3\t%4\n")
.arg(m_tickCount, 4)
.arg(actualMs, 10)
.arg(expectedMs, 11)
.arg(driftMs, 8);
out.flush();
// RunProcess is executed in the worker thread (no impact on main timer)
QMetaObject::invokeMethod(m_runner, "runProcess", Qt::QueuedConnection);
}
// ── Worker finished (main Thread, QueuedConnection) ───────────────────
void onTaskFinished()
{
// syncOutputs + monitor update: main thread blocking
syncOutputs();
updateMonitor();
m_busy = false;
}
void onStop()
{
m_cycleTrigger->stop();
const qint64 totalMs = m_elapsed.elapsed();
QTextStream out(stdout);
out << "\n=== Result ===\n";
out << "Total elapsed : " << totalMs << " ms\n";
out << "Expected ticks: 100\n";
out << "Actual ticks : " << m_tickCount << "\n";
out << "Missing ticks : " << (100 - m_tickCount) << "\n";
out.flush();
m_workerThread.quit();
m_workerThread.wait();
QCoreApplication::quit();
}
private:
void syncOutputs()
{
QElapsedTimer t; t.start();
volatile double acc = 0.0;
for (long long i = 1; t.elapsed() < 40; ++i)
acc += 1.0 / static_cast<double>(i);
(void)acc;
}
void updateMonitor()
{
// monitor update (lightweight task)
QElapsedTimer t; t.start();
volatile double acc = 0.0;
for (long long i = 1; t.elapsed() < 5; ++i)
acc += 1.0 / static_cast<double>(i);
(void)acc;
}
QTimer *m_cycleTrigger;
QTimer *m_stopTimer;
Runner *m_runner;
QThread m_workerThread;
QElapsedTimer m_elapsed;
int m_tickCount;
bool m_busy;
};
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
TaskManager manager;
manager.start();
return app.exec();
}
#include "main.moc"
회고
이번 버그는 테스트 간 시간 측정을 비교하는 과정에서 발견했다.
QTimer 관련 버그를 만났다면, 대부분 수많은 Signal/Slot이 얽혀 있고 아키텍처가 이미 커진 상황일 것이다.
규모가 커진 프로젝트는 디버깅이 어렵기 때문에,
동일한 문제를 재현하는 작은 테스트 프로젝트를 별도로 만드는 것이 오히려 더 빠를 수 있다.
이번 트터블슈팅을 통해 QEventLoop 사용 시 고려사항을 되짚어 볼 수 있었고,
QEventLoop와 QTimer의 동작 원리, 나아가 Qt6.8의 QChronoTimer까지 배울 수 있어서 재미있게 풀 수 있었다.
직접 공부해서 다음 글로 정리해 보려고 합니다.
'Qt' 카테고리의 다른 글
| QML Stopwatch 구현 따라 하기 - MVVM 구조로 설계한 Qt 아키텍처 (0) | 2026.02.20 |
|---|---|
| Qt6 QML 따라하기 ㅡ 프로젝트 생성부터 C++ 연동까지 (0) | 2026.02.10 |
| Qt QML 내부 동작 원리 이해하기 ㅡ Property Binding, Event Loop, Scene Graph (0) | 2026.02.02 |
| Qt QObject와 moc의 동작 원리 이해하기 ㅡ Signal/Slot과 런타임 리플렉션 (0) | 2026.01.28 |
| Qt Event Loop 동작 원리 정리 ㅡ 타이머, 이벤트, 스레드 까지 (0) | 2026.01.23 |