본문 바로가기

Qt

QML Stopwatch 구현 따라 하기 - MVVM 구조로 설계한 Qt 아키텍처

들어가며

이전 글에서 QML의 내부 동작 원리와 C++ 연동 방법을 정리했다.

 

QML은 상태 기반 UI에 강점이 있고,

C++은 성능과 로직 처리에 강점이 있다.

 

하지만 두 영역을 철저히 분리하지 않으면 금방 스파게티 코드가 되어버리고,

유지보수 비용은 기하급수적으로 상승한다.

 

이번 글에서는 Stopwatch 예제를 통해

MVVM 원칙을 따르는 설계 방식을 정리해 보려 한다.

글을 작성하며 느낀 점은,
MVVM을 설명하는 과정이 곧 객체지향 개념을 설명하는 과정과 매우 닮아 있다는 것이었다.

내가 객체지향을 이해하는데 가장 큰 도움을 준 책은
<객체지향의 사실과 오해> 였다.
이 책은 "상태가 아니라 행동을 중심으로 설계하라"는 메시지를 강조한다.


이번 예제 역시 그 관점에서 접근했다.
https://blog.aladin.co.kr/Bbird/16866413
 

객체지향의 사실과 오해_2023.01.01

모든 프로그래머라면 읽어보는 것을 추천하는 책. 객체지향에 대한 전반적인 개념을 깔끔하게 정리하여 좋았다.일명 토끼책으로 유명한 책이다. 객체지향 프로그래밍에 대해 전반적인 내용을..

blog.aladin.co.kr


MVVM 기본 원칙

MVVM은 Model + View + ViewModel로 구성되는 아키텍처 패턴으로,

UI와 비즈니스 로직의 분리를 목표로 한다.

 

Qt에 대입하면 다음과 같이 정리할 수 있다.

패턴 구현방법 역할 원칙
View QML ☑ UI 구성
☑ 사용자 입력 전달
☑ 상태 표현
로직을 넣지 않는다
ViewModel QObject 기반 클래스 ☑ UI 상태 제공
☑ Signal / Slot 연결
Model과 View를 연결
Model 순수 C++ 로직 ☑ 비즈니스 로직 구현 View와 ViewModel을 알지 못한다

설계 접근 방식

이번 예제에서는 View보다 Model부터 설계했다.

 

객체지향 설계에서는 "화면이 무엇을 보여줄 것인가?" 보다

"사용자가 무엇을 할 수 있는가?"가 더 중요하기 때문이다.

 

사용자의 행동은 다음 네 가지다.

  • Start
  • Stop
  • Reset
  • Lap

행동을 먼저 정의하고,

행동에 필요한 최소한의 상태를 추가해 Model을 설계했다.


Model (순수 C++)

StopwatchModel은 Qt와 의존성을 가지지 않고, 시간은 std::chrono::steady_clock으로 관리한다.

이렇게 의존성 없이 설계했을 때의 장점은 다음과 같다.

  • 유닛 테스트가 쉽다
  • Qt에 종속되지 않는다 (개별 빌드가 가능하다)
  • 재사용 가능하다

이 Model은 오직 시간 계산과 상태 관리만 담당한다.

핵심: Model은 View와 ViewModel을 모른다.
더보기

StopwatchModel.h

#pragma once
#include <chrono>
#include <vector>

class StopwatchModel {
public:
    struct Lap {
        int index;
        std::chrono::milliseconds elapsed;
    };

    void start();
    void stop();
    void reset();
    void lap();

    const std::vector<Lap>& laps() const { return m_laps; }
    std::chrono::milliseconds elapsed() const {
        if (m_running)
            return m_accum + std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - m_startPoint);
        return m_accum;
    }
    bool isRunning() const { return m_running; }

private:
    using Clock = std::chrono::steady_clock;

    bool m_running = false;
    Clock::time_point m_startPoint{};
    std::chrono::milliseconds m_accum{0};
    std::vector<Lap> m_laps;
};

StopwatchModel.cpp

#include "StopwatchModel.h"

void StopwatchModel::start() {
    if (m_running) return;
    m_running = true;
    m_startPoint = Clock::now();
}

void StopwatchModel::stop() {
    if (!m_running) return;
    m_running = false;
    m_accum += std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - m_startPoint);
}

void StopwatchModel::reset() {
    m_running = false;
    m_accum = std::chrono::milliseconds{0};
    m_laps.clear();
}

void StopwatchModel::lap() {
    if (!m_running) return;
    Lap l;
    l.index = static_cast<int>(m_laps.size() + 1);
    l.elapsed = m_accum + std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - m_startPoint);
    m_laps.push_back(l);
}

일부러 StopwatchModel은 Qt에 의존하지 않도록 설계했다.

하지만 프로젝트의 성격에 따라 Qt 타입(QElapsedTimer, QString 등)을 사용하거나, 경우에 따라 QObject를 상속해도 무방하다.

중요한 것은 의존성을 선택할 수 있는 구조를 만드는 것이다.

이 구조에서는 Model을 gtest 같은 프레임워크로 단위 테스트가 가능하다.
UI 없이도 Stopwatch의 동작을 검증할 수 있다는 점이 구조적 분리의 가장 큰 장점이다.

 

View (QML)

QML은 명령을 수행하는 곳이 아니라 상태를 반영하는 곳이다.

따라서 View에서 로직을 작성하기 시작하는 순간 MVVM 구조는 무너지기 시작한다.

 

View를 설계할 때는 UI와 연결되는 객체의 상태를 중심으로 질문해야 한다.

  • "Start 버튼을 누르면 어떤 동작을 할까?" → 동작(로직)에 관한 잘못된 질문
  • "Start버튼이 활성화될 수 있는 상태는 무엇인가?" → 상태에 관한 질문
Button { 
    text: "Start"
    enabled: !vm.isRunning
    onClicked: vm.start()
}

이 코드에는 시간계산 로직도 없고, 상태변경 로직도 없다.

핵심: View는 상태를 표현하고 사용자의 입력을 ViewModel로 전달한다.
더보기

Main.qml

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import antigravityTest

Window {
    width: 400; height: 500
    visible: true
    title: "Stopwatch"

    StopwatchViewModel { id: vm }

    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 20
        spacing: 12

        Text {
            Layout.alignment: Qt.AlignHCenter
            text: vm.elapsedText
            font.pixelSize: 48
            font.family: "Courier New"
        }

        RowLayout {
            Layout.alignment: Qt.AlignHCenter
            spacing: 10

            Button { text: "Start"; onClicked: vm.start(); enabled: !vm.isRunning }
            Button { text: "Stop";  onClicked: vm.stop();  enabled: vm.isRunning }
            Button { text: "Lap";   onClicked: vm.lap();   enabled: vm.isRunning }
            Button { text: "Reset"; onClicked: vm.reset() }
        }

	// 사용자가 Lap을 통해 기록한 상태를 어떻게 보여줄까? → List로 보여주자
        ListView {
            Layout.fillWidth: true
            Layout.fillHeight: true
            model: vm.laps
            delegate: Text {
                text: "Lap " + modelData.index + "  " + modelData.elapsed
                font.family: "Courier New"
            }
        }
    }
}

ViewModel

ViewModel은 Model과 View를 연결하는 계층이다.

 

사용자가 UI로 호출하는 행동은 SLOT으로 정의하고,

Q_PROPERTY와 시그널을 바인딩했다.

 

QML과 Model을 연결하다 보면 다음과 같이 부족한 점들을 발견하게 된다.

  • Model의 Laps() 행동은 std::vector 형태를 반환하는데 QML의 labs상태는 QVariantList 형태를 취급한다.
  • Model은 시간을 "계산"할 수 있지만 QML이 그 변화를 감지할 수 있는 "신호"는 제공하지 않는다.

이러한 문제를 생각하다 보면 ViewModel의 역할이 뚜렷해지고,

깊게는 최적화와 관련된 내용으로 이어지기도 한다.

핵심: View와 Model 사이에서 View에 맞게 데이터를 가공한다.
더보기

StopwatchViewModel.h

#pragma once
#include <QObject>
#include <QTimer>
#include <QVariantList>
#include "StopwatchModel.h"

class StopwatchViewModel : public QObject
{
    Q_OBJECT
    Q_PROPERTY(bool isRunning   READ isRunning   NOTIFY isRunningChanged)
    Q_PROPERTY(int  elapsedMs   READ elapsedMs   NOTIFY elapsedChanged)
    Q_PROPERTY(QString elapsedText READ elapsedText NOTIFY elapsedChanged)
    Q_PROPERTY(QVariantList laps READ laps NOTIFY lapsChanged)

public:
    explicit StopwatchViewModel(QObject *parent = nullptr);

    bool        isRunning()   const { return m_model.isRunning(); }
    int         elapsedMs()   const { return static_cast<int>(m_model.elapsed().count()); }
    QString     elapsedText() const { return formatMs(m_model.elapsed().count()); }
    QVariantList laps()       const;

public slots:
    void start();
    void stop();
    void reset();
    void lap();

signals:
    void isRunningChanged();
    void elapsedChanged();
    void lapsChanged();

private:
    StopwatchModel m_model;
    QTimer         m_timer;
    QString formatMs(qint64 ms) const;
};

StopwatchViewModel.cpp

#include "StopwatchViewModel.h"

StopwatchViewModel::StopwatchViewModel(QObject *parent)
    : QObject(parent)
{
    // ~60fps update for smooth display
    m_timer.setInterval(16);
    connect(&m_timer, &QTimer::timeout, this, &StopwatchViewModel::elapsedChanged);
}

void StopwatchViewModel::start()
{
    if (m_model.isRunning()) return;
    m_model.start();
    m_timer.start();
    emit isRunningChanged();
    emit elapsedChanged();
}

void StopwatchViewModel::stop()
{
    if (!m_model.isRunning()) return;
    m_model.stop();
    m_timer.stop();
    emit isRunningChanged();
    emit elapsedChanged();
}

void StopwatchViewModel::reset()
{
    m_timer.stop();
    m_model.reset();
    emit isRunningChanged();
    emit elapsedChanged();
    emit lapsChanged();
}

void StopwatchViewModel::lap()
{
    if (!m_model.isRunning()) return;
    m_model.lap();
    emit lapsChanged();
}

QVariantList StopwatchViewModel::laps() const
{
    QVariantList result;
    const auto &laps = m_model.laps();
    for (const auto &lap : laps) {
        QVariantMap m;
        m["index"]   = lap.index;
        m["elapsed"] = formatMs(lap.elapsed.count());
        result.prepend(m); // most recent lap first
    }
    return result;
}

QString StopwatchViewModel::formatMs(qint64 ms) const
{
    int minutes = static_cast<int>(ms / 60000);
    int seconds = static_cast<int>((ms % 60000) / 1000);
    int centis  = static_cast<int>((ms % 1000) / 10);
    return QString("%1:%2.%3")
        .arg(minutes, 2, 10, QChar('0'))
        .arg(seconds, 2, 10, QChar('0'))
        .arg(centis,  2, 10, QChar('0'));
}

회고

실행된 QML Stopwatch 프로그램

프로그램은 단순하지만 설계는 단순하지 않았다.

 

처음에는 "QML 프로젝트 따라 하기"로 글을 쓰려했다.

하지만 더 명확하게 설명하기 위해 MVVM 구조를 적용했다.

그 과정에서 QML의 동작방식과 객체지향적 사고력을 키울 수 있었다.

 

요즘엔 AI딸깍으로 프로젝트가 생성되기 때문에 SW 아키텍처가 더욱 중요시되고,

작은 프로젝트라도 직접 만들어보며 깊이 생각해 보는 시간이 필요한 것 같다.

부족한 부분이나 더 궁금한 주제가 있다면 댓글로 남겨주세요.
직접 공부해서 다음 글로 정리해 보려고 합니다.

이전 글 : 2026.02.10 - [Qt] - Qt6 QML 따라하기 ㅡ 프로젝트 생성부터 C++ 연동까지

 

Qt6 QML 따라하기 ㅡ 프로젝트 생성부터 C++ 연동까지

들어가며이전 글에서 QML의 이론적 배경을 정리했다.런타임 리플렉션을 활용한 pub/sub 구조와,프레임 기반 렌더링을 통한 최적화 개념을 살펴보며QML이 QWidget보다 충분히 경쟁력 있는 기술이라는

prejudice.tistory.com

이전 글 : 2026.02.02 - [Qt] - Qt QML 내부 동작 원리 이해하기 ㅡ Property Binding, Event Loop, Scene Graph

 

Qt QML 내부 동작 원리 이해하기 ㅡ Property Binding, Event Loop, Scene Graph

들어가며QtFramework에 대해 공부하며 QEventLoop, QObjec, moc(Meta Object Compiler)에 대해 공부했다.이 글은 지금까지의 내용을 배경지식을 활용하여 QML에 대해 공부해 보려 한다.2026.01.23 - [Qt] - Qt Event Loop

prejudice.tistory.com