C++ GUI | Qt 프레임워크: 위젯부터 MOC·시그널/슬롯·이벤트 루프·Graphics View까지

이 글의 핵심

Qt Widgets 입문 예제에 더해, 메타 객체 시스템(MOC), 시그널/슬롯·이벤트 루프의 내부 동작, Graphics View 아키텍처, 프로덕션에서 통용되는 소유권·스레딩·테스트 패턴까지 한 번에 정리합니다.

첫 번째 Qt 애플리케이션

#include <QApplication>
#include <QPushButton>

int main(int argc, char *argv[]) {
    QApplication app(argc, argv);
    
    QPushButton button("Hello Qt!");
    button.resize(200, 100);
    button.show();
    
    return app.exec();
}

빌드:

# qmake 사용 (-project가 만든 .pro에 widgets 모듈을 추가해야 함)
qmake -project "QT += widgets"
qmake
make
# CMake 사용 (CMakeLists.txt)
cmake_minimum_required(VERSION 3.16)
project(myapp LANGUAGES CXX)
set(CMAKE_AUTOMOC ON)                 # Q_OBJECT가 있는 클래스를 위해 moc 자동 실행
find_package(Qt6 REQUIRED COMPONENTS Widgets)
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE Qt6::Widgets)

qmake -project로 만든 .pro 파일에는 기본적으로 QT += widgets가 없어서, 그냥 빌드하면 fatal error: QApplication: No such file or directory가 납니다. Qt 5부터 위젯이 QtGui에서 별도 모듈로 분리되었기 때문입니다. 새 프로젝트라면 Qt 6에서 권장하는 CMake 쪽을 쓰는 편이 IDE 연동과 의존성 관리 면에서 편합니다.

이 짧은 프로그램에서도 Qt의 기본 구조가 드러납니다. QApplication은 프로세스당 하나만 있어야 하고, 위젯보다 먼저 만들어져야 합니다. 순서를 바꾸면 QWidget: Must construct a QApplication before a QWidget라는 메시지와 함께 즉시 종료됩니다. button.show()는 창을 화면에 올려 달라는 요청을 이벤트 큐에 넣을 뿐이고, 실제 그리기와 클릭 처리는 app.exec()가 이벤트 루프를 돌리기 시작한 뒤에야 일어납니다. exec()는 마지막 창이 닫힐 때 반환합니다.

시그널과 슬롯

#include <QApplication>
#include <QPushButton>
#include <QMessageBox>

int main(int argc, char *argv[]) {
    QApplication app(argc, argv);
    
    QPushButton button("클릭하세요");
    
    // 시그널-슬롯 연결
    QObject::connect(&button, &QPushButton::clicked, [] {
        QMessageBox::information(nullptr, "알림", "버튼이 클릭되었습니다!");
    });
    
    button.show();
    
    return app.exec();
}

connect의 세 번째 인자로 람다를 바로 넘기는 이 형태는 편하지만, 람다가 다른 객체를 캡처한다면 컨텍스트 객체를 함께 넘기는 4인자 형태(connect(&button, &QPushButton::clicked, &window, [&]{...}))를 쓰는 것이 좋습니다. 컨텍스트 객체가 파괴되면 연결이 자동으로 끊어져, 이미 사라진 객체를 캡처한 람다가 호출되는 사고를 막아 줍니다. 컨텍스트 객체는 람다가 어느 스레드에서 실행될지도 정합니다.

레이아웃

수직 레이아웃

#include <QApplication>
#include <QWidget>
#include <QPushButton>
#include <QVBoxLayout>

int main(int argc, char *argv[]) {
    QApplication app(argc, argv);
    
    QWidget window;
    QVBoxLayout *layout = new QVBoxLayout(&window);
    
    layout->addWidget(new QPushButton("버튼 1"));
    layout->addWidget(new QPushButton("버튼 2"));
    layout->addWidget(new QPushButton("버튼 3"));
    
    window.show();
    
    return app.exec();
}

수평 레이아웃

QHBoxLayout *layout = new QHBoxLayout(&window);
layout->addWidget(new QPushButton("왼쪽"));
layout->addWidget(new QPushButton("중간"));
layout->addWidget(new QPushButton("오른쪽"));

그리드 레이아웃

QGridLayout *layout = new QGridLayout(&window);
layout->addWidget(new QPushButton("1"), 0, 0);
layout->addWidget(new QPushButton("2"), 0, 1);
layout->addWidget(new QPushButton("3"), 1, 0);
layout->addWidget(new QPushButton("4"), 1, 1);

레이아웃 예제에서 new QPushButton(...)을 하고 delete를 하지 않는데도 누수가 아닌 이유는 Qt의 부모-자식 소유권 때문입니다. new QVBoxLayout(&window)는 레이아웃을 window에 설치하고, addWidget은 버튼을 레이아웃이 관리하는 위젯(window)의 자식으로 다시 부모 지정(reparent)합니다. window가 소멸하면 자식 목록을 돌며 모두 delete합니다. 그래서 Qt 코드에서는 new로 만든 위젯마다 “부모가 누구인가”만 확인하면 되고, 부모가 없는 최상위 창만 직접 수명을 관리하면 됩니다. 반대로 레이아웃에 넣지도 않고 부모도 주지 않은 new QPushButton은 별도 창으로 떠 버리거나, 보이지 않은 채 누수됩니다.

위치를 직접 지정하는 setGeometry 대신 레이아웃을 쓰는 이유는 창 크기 변경, 고해상도(HiDPI) 배율, 번역으로 길어진 문자열에 위젯이 자동으로 맞춰지기 때문입니다. 한국어 UI를 영어나 독일어로 번역하면 버튼 텍스트 길이가 크게 달라지는데, 절대 좌표로 배치한 화면은 이때 글자가 잘립니다.

커스텀 위젯

#include <QWidget>
#include <QPushButton>
#include <QLabel>
#include <QVBoxLayout>

class CounterWidget : public QWidget {
    Q_OBJECT
    
private:
    int count;
    QLabel *label;
    QPushButton *button;
    
public:
    CounterWidget(QWidget *parent = nullptr) : QWidget(parent), count(0) {
        label = new QLabel("카운트: 0", this);
        button = new QPushButton("증가", this);
        
        QVBoxLayout *layout = new QVBoxLayout(this);
        layout->addWidget(label);
        layout->addWidget(button);
        
        connect(button, &QPushButton::clicked, this, &CounterWidget::increment);
    }
    
private slots:
    void increment() {
        count++;
        label->setText(QString("카운트: %1").arg(count));
    }
};

int main(int argc, char *argv[]) {
    QApplication app(argc, argv);
    
    CounterWidget widget;
    widget.show();
    
    return app.exec();
}

#include "main.moc"   // Q_OBJECT 클래스를 .cpp 안에 정의했을 때 필요 (파일명.moc)

이 예제처럼 Q_OBJECT 클래스를 헤더가 아니라 main.cpp 안에 정의하면, 처음 빌드할 때 거의 확실하게 undefined reference to 'vtable for CounterWidget' 링크 에러를 만납니다. CMAKE_AUTOMOC는 기본적으로 헤더를 moc에 넘기고, .cpp 파일 안의 클래스는 그 파일 끝에 #include "main.moc"가 있을 때만 처리합니다. Q_OBJECT 매크로가 선언한 가상 함수(metaObject() 등)의 정의가 moc 생성 코드에 들어 있으므로, moc가 돌지 않으면 vtable이 완성되지 않는 것입니다. 클래스를 헤더로 옮기거나 위 한 줄을 추가하면 해결되며, 이미 빌드한 뒤에 Q_OBJECT를 추가했다면 CMake 재구성(또는 qmake 재실행)이 필요할 수 있습니다.

메뉴와 툴바

#include <QMainWindow>
#include <QMenuBar>
#include <QToolBar>
#include <QAction>
#include <QMessageBox>

class MainWindow : public QMainWindow {
    Q_OBJECT
    
public:
    MainWindow() {
        // 메뉴 생성
        QMenu *fileMenu = menuBar()->addMenu("파일");
        QMenu *editMenu = menuBar()->addMenu("편집");
        
        // 액션 생성
        QAction *newAction = new QAction("새 파일", this);
        QAction *openAction = new QAction("열기", this);
        QAction *saveAction = new QAction("저장", this);
        
        // 메뉴에 액션 추가
        fileMenu->addAction(newAction);
        fileMenu->addAction(openAction);
        fileMenu->addSeparator();
        fileMenu->addAction(saveAction);
        
        // 툴바 생성
        QToolBar *toolbar = addToolBar("메인 툴바");
        toolbar->addAction(newAction);
        toolbar->addAction(openAction);
        toolbar->addAction(saveAction);
        
        // 시그널 연결
        connect(newAction, &QAction::triggered, this, &MainWindow::newFile);
        connect(openAction, &QAction::triggered, this, &MainWindow::openFile);
    }
    
private slots:
    void newFile() {
        QMessageBox::information(this, "알림", "새 파일 생성");
    }
    
    void openFile() {
        QMessageBox::information(this, "알림", "파일 열기");
    }
};

실전 예시

예시 1: 간단한 텍스트 에디터

#include <QMainWindow>
#include <QTextEdit>
#include <QMenuBar>
#include <QFileDialog>
#include <QFile>
#include <QTextStream>

class TextEditor : public QMainWindow {
    Q_OBJECT
    
private:
    QTextEdit *textEdit;
    
public:
    TextEditor() {
        textEdit = new QTextEdit(this);
        setCentralWidget(textEdit);
        
        createMenus();
        
        setWindowTitle("간단한 텍스트 에디터");
        resize(800, 600);
    }
    
private:
    void createMenus() {
        QMenu *fileMenu = menuBar()->addMenu("파일");
        
        QAction *openAction = fileMenu->addAction("열기");
        connect(openAction, &QAction::triggered, this, &TextEditor::openFile);
        
        QAction *saveAction = fileMenu->addAction("저장");
        connect(saveAction, &QAction::triggered, this, &TextEditor::saveFile);
        
        fileMenu->addSeparator();
        
        QAction *exitAction = fileMenu->addAction("종료");
        connect(exitAction, &QAction::triggered, this, &QWidget::close);
    }
    
private slots:
    void openFile() {
        QString filename = QFileDialog::getOpenFileName(this, "파일 열기");
        
        if (!filename.isEmpty()) {
            QFile file(filename);
            if (file.open(QIODevice::ReadOnly | QIODevice::Text)) {
                QTextStream in(&file);
                textEdit->setText(in.readAll());
                file.close();
            }
        }
    }
    
    void saveFile() {
        QString filename = QFileDialog::getSaveFileName(this, "파일 저장");
        
        if (!filename.isEmpty()) {
            QFile file(filename);
            if (file.open(QIODevice::WriteOnly | QIODevice::Text)) {
                QTextStream out(&file);
                out << textEdit->toPlainText();
                file.close();
            }
        }
    }
};

예시 2: 계산기

#include <QWidget>
#include <QLineEdit>
#include <QPushButton>
#include <QGridLayout>

class Calculator : public QWidget {
    Q_OBJECT
    
private:
    QLineEdit *display;
    double currentValue;
    char currentOp;
    
public:
    Calculator() : currentValue(0), currentOp('\0') {
        display = new QLineEdit("0", this);
        display->setReadOnly(true);
        display->setAlignment(Qt::AlignRight);
        
        QGridLayout *layout = new QGridLayout(this);
        layout->addWidget(display, 0, 0, 1, 4);
        
        // 숫자 버튼
        for (int i = 0; i < 10; i++) {
            QPushButton *btn = new QPushButton(QString::number(i), this);
            connect(btn, &QPushButton::clicked, this, &Calculator::digitClicked);
            int row = (9 - i) / 3 + 1;
            int col = (i - 1) % 3;
            if (i == 0) {
                layout->addWidget(btn, 4, 1);
            } else {
                layout->addWidget(btn, row, col);
            }
        }
        
        // 연산자 버튼
        QPushButton *addBtn = new QPushButton("+", this);
        connect(addBtn, &QPushButton::clicked, this, &Calculator::operatorClicked);
        layout->addWidget(addBtn, 1, 3);
        
        QPushButton *equalBtn = new QPushButton("=", this);
        connect(equalBtn, &QPushButton::clicked, this, &Calculator::equalClicked);
        layout->addWidget(equalBtn, 4, 3);
        
        resize(300, 400);
    }
    
private slots:
    void digitClicked() {
        QPushButton *btn = qobject_cast<QPushButton*>(sender());
        QString digit = btn->text();
        
        if (display->text() == "0") {
            display->setText(digit);
        } else {
            display->setText(display->text() + digit);
        }
    }
    
    void operatorClicked() {
        QPushButton *btn = qobject_cast<QPushButton*>(sender());
        currentOp = btn->text().at(0).toLatin1();
        currentValue = display->text().toDouble();
        display->setText("0");
    }
    
    void equalClicked() {
        double secondValue = display->text().toDouble();
        double result = 0;
        
        switch (currentOp) {
            case '+': result = currentValue + secondValue; break;
            case '-': result = currentValue - secondValue; break;
            case '*': result = currentValue * secondValue; break;
            case '/': result = currentValue / secondValue; break;
        }
        
        display->setText(QString::number(result));
        currentOp = '\0';
    }
};

숫자 버튼 10개를 슬롯 하나(digitClicked)에 연결하고 sender()로 어느 버튼인지 알아내는 방식은 Qt 예제에서 흔하지만, sender()는 슬롯이 시그널을 통해 호출됐을 때만 유효하고 직접 호출하면 nullptr이라 위 코드는 그 경우 크래시합니다. 람다로 값을 캡처해 연결하면(connect(btn, &QPushButton::clicked, this, [this, i]{ appendDigit(i); })) sender()와 qobject_cast가 필요 없어지고 의도도 더 분명해집니다. 또 이 계산기는 0으로 나누면 inf를 그대로 표시하므로, 실제 앱이라면 secondValue == 0을 먼저 검사해야 합니다.

예시 3: 이미지 뷰어

#include <QMainWindow>
#include <QLabel>
#include <QMenuBar>
#include <QFileDialog>
#include <QPixmap>
#include <QScrollArea>

class ImageViewer : public QMainWindow {
    Q_OBJECT
    
private:
    QLabel *imageLabel;
    QScrollArea *scrollArea;
    
public:
    ImageViewer() {
        imageLabel = new QLabel;
        imageLabel->setScaledContents(true);
        
        scrollArea = new QScrollArea;
        scrollArea->setWidget(imageLabel);
        setCentralWidget(scrollArea);
        
        createMenus();
        
        setWindowTitle("이미지 뷰어");
        resize(800, 600);
    }
    
private:
    void createMenus() {
        QMenu *fileMenu = menuBar()->addMenu("파일");
        
        QAction *openAction = fileMenu->addAction("열기");
        connect(openAction, &QAction::triggered, this, &ImageViewer::openImage);
    }
    
private slots:
    void openImage() {
        QString filename = QFileDialog::getOpenFileName(
            this, "이미지 열기", "", "Images (*.png *.jpg *.bmp)");
        
        if (!filename.isEmpty()) {
            QPixmap image(filename);
            imageLabel->setPixmap(image);
            imageLabel->resize(image.size());
        }
    }
};

메타 객체 시스템과 MOC

Qt의 핵심은 C++ 언어 사양에 없는 리플렉션(reflection)에 가까운 기능을, 별도의 전처리기인 moc(meta-object compiler) 로 보완한다는 점입니다. QObject를 상속한 클래스에 Q_OBJECT 매크로를 넣으면, moc는 해당 클래스에 대해 메타 객체 코드를 생성합니다. 이 코드는 시그널 인덱스, 슬롯 시그니처 문자열, 프로퍼티 메타데이터 등을 담은 정적 구조체와, 런타임에 이를 조회하는 진입점을 제공합니다.

빌드 관점에서는 일반적인 C++ 컴파일 파이프라인 앞단에 “.h를 읽고 moc_*.cpp를 추가”하는 단계가 끼어듭니다. CMake에서는 set(CMAKE_AUTOMOC ON) 또는 qt_add_executable(...) 계열 타깃 속성으로 자동화하는 것이 표준입니다. qmake 사용자는 .pro에 해당 헤더가 포함되면 자동으로 moc가 호출됩니다.

설계상 제약도 이해해야 합니다. moc는 클래스 정의를 정적으로 분석하므로, 클래스 템플릿에는 Q_OBJECT를 쓸 수 없고(시그널/슬롯이 필요하면 템플릿이 아닌 베이스 클래스에 선언), 다중 상속에서 QObject 계열은 첫 번째 베이스여야 하며 두 번 이상 나타날 수 없습니다. 또한 시그널/슬롯 선언은 클래스 본문에 직접 두는 것이 안전합니다. 이런 제약은 “C++만으로는 부족한 부분을 코드 생성으로 메우는” 트레이드오프의 결과입니다.

런타임에는 QMetaObject를 통해 클래스 이름, 열거형, 동적 프로퍼티, Q_INVOKABLE 메서드 등을 조회할 수 있으며, 이는 스크립트 바인딩, 직렬화, 원격 프로시저 호출 같은 상위 레이어의 토대가 됩니다. 예를 들어 QMetaObject::invokeMethod는 문자열 이름으로 슬롯을 호출할 수 있어, UI XML 로딩이나 플러그인 경계에서 유용합니다.

// CMake: target에 Qt6::Core 연결 후 AUTOMOC ON 가정
#include <QObject>
#include <QDebug>

class Worker : public QObject {
    Q_OBJECT
public:
    explicit Worker(QObject *parent = nullptr) : QObject(parent) {}

public slots:
    void onTask(int id) { qDebug() << "task" << id; }
};

// 문자열 기반 호출(런타임 바인딩). 프로덕션에서는 타입 안전한 connect를 우선한다.
void invokeByName(Worker *w) {
    QMetaObject::invokeMethod(w, "onTask", Qt::QueuedConnection, Q_ARG(int, 42));
}

위 예에서 invokeMethod는 메타 객체에 등록된 슬롯 이름을 해석합니다. 큐 연결(Qt::QueuedConnection)을 쓰면 호출이 이벤트 큐를 통해 비동기로 전달되므로, 다른 스레드에 속한 객체로 안전하게 작업을 넘길 때 자주 씁니다. 다만 문자열 기반 호출은 오타나 시그니처 불일치를 런타임까지 늦출 수 있으므로, 가능하면 함수 포인터 기반 connect를 기본으로 하며, 플러그인·리스너 같은 경계에서만 invokeMethod를 쓰는 편이 안전합니다.

시그널과 슬롯의 내부 동작

Qt 5 이후 권장 방식은 함수 포인터 오버로드를 사용하는 connect(sender, &Sender::signal, receiver, &Receiver::slot) 형태입니다. 이 방식은 컴파일 타임에 시그널·슬롯 시그니처가 맞는지 검사할 수 있어, 과거의 SIGNAL/SLOT 매크로보다 유지보수성이 뛰어납니다.

연결의 저장 구조를 개략적으로 말하면, 각 QObject는 수신 연결 목록을 유지하며, 시그널이 발생하면 해당 시그널 인덱스에 매달린 연결들을 순회하며 슬롯을 호출합니다. 내부적으로는 QObjectPrivate와 연결 테이블이 깊게 얽혀 있으며, 시그널 하나에 다수의 슬롯이 연결되는 멀티캐스트가 자연스럽게 지원됩니다.

연결 타입이 핵심입니다.

  • Qt::DirectConnection: 시그널을 emit한 스레드에서 즉시 슬롯을 실행합니다. 같은 스레드 안에서의 동기 호출에 가깝습니다.
  • Qt::QueuedConnection: QEvent로 감싼 슬롯 호출을 수신자 객체가 속한 스레드의 이벤트 루프에 넣습니다. 워커 스레드에서 UI 객체를 직접 건드리지 않고 안전하게 갱신할 때 표준 패턴입니다.
  • Qt::BlockingQueuedConnection: 큐에 넣되 발신 스레드는 슬롯이 끝날 때까지 블로킹합니다. 데드락 위험이 있으므로 같은 스레드끼리는 절대 사용하지 말아야 합니다.

람다를 연결할 때는 컨텍스트 객체를 세 번째 인자로 넘기면, 해당 객체가 파괴될 때 연결이 자동 해제되어 댕글링 참조를 줄일 수 있습니다.

#include <QObject>
#include <QTimer>

class Counter : public QObject {
    Q_OBJECT
public:
    explicit Counter(QObject *parent = nullptr) : QObject(parent) {
        auto *t = new QTimer(this);
        t->setInterval(100);
        connect(t, &QTimer::timeout, this, [this]() { ++m_value; emit valueChanged(m_value); });
        t->start();
    }
signals:
    void valueChanged(int v);
private:
    int m_value = 0;
};

시그널 인자는 복사 가능한 타입이어야 하며, 큐 연결에서는 인자를 복사해 이벤트에 담아야 하므로 Qt가 그 타입을 알아야 합니다. Qt 5에서 사용자 정의 타입을 등록하지 않고 큐 연결하면 연결 자체는 성공한 것처럼 보이다가 실행 중에 QObject::connect: Cannot queue arguments of type 'MyData' (Make sure 'MyData' is registered using qRegisterMetaType().) 경고만 찍히고 슬롯이 호출되지 않습니다. 같은 스레드에서 테스트할 때는 직접 연결이라 잘 되다가, 워커 스레드로 옮기는 순간 조용히 동작을 멈추는 것이 전형적인 증상입니다. Q_DECLARE_METATYPE(MyData)와 qRegisterMetaType<MyData>()를 호출해 해결하며, Qt 6에서는 시그널 인자 타입 대부분이 자동 등록되어 이 문제가 크게 줄었습니다. 큰 데이터를 매 틱마다 시그널로 밀어 넣기보다는, 공유 버퍼 + 변경 플래그, 또는 std::shared_ptr로 불변 스냅샷을 넘기는 식으로 설계를 조정하는 것이 좋습니다.

이벤트 루프와 QCoreApplication

GUI가 없는 서비스나 테스트 코드에서는 QCoreApplication만으로도 이벤트 루프를 돌릴 수 있습니다. QApplication은 QGuiApplication을 거쳐 위젯·스타일·데스크톱 통합까지 포함합니다. 즉, 반드시 GUI가 필요한 것은 아니며, 네트워크·타이머·소켓 notifier만 쓰는 백그라운드 프로세스도 QCoreApplication 기반이 흔합니다.

exec()는 사실상 “큐에서 QEvent를 꺼내 QObject::event로 디스패치하는 루프”입니다. 윈도우 시스템에서 온 입력, 타이머, 소켓 준비 완료, 그리고 큐 연결로 예약된 슬롯 호출 모두 이 큐를 통해 처리됩니다.

중첩 이벤트 루프는 QEventLoop 로컬 객체로 만들 수 있습니다. 다이얼로그의 exec() 같은 것이 대표적입니다. 다만 processEvents()를 남용하면 재진입으로 인해 상태가 꼬이기 쉬우므로, 모달 진행률 창 등 명확한 용도가 아니면 지양하는 것이 좋습니다.

#include <QCoreApplication>
#include <QTimer>
#include <iostream>

int main(int argc, char *argv[]) {
    QCoreApplication app(argc, argv);
    QTimer::singleShot(0, &app, []() { std::cout << "이벤트 루프가 돈 뒤 한 번 실행\n"; });
    return app.exec(); // 이벤트 루프 진입; 종료 시까지 블로킹
}

QCoreApplication::quit() 또는 마지막 윈도우가 닫힐 때의 기본 동작 등으로 루프가 빠져나오면 exec()가 반환값을 돌려줍니다. 장시간 실행되는 작업은 이벤트 루프 스레드를 막지 말고, QThread+큐 연결 또는 QtConcurrent 등으로 오프로딩하는 것이 반응성 유지의 기본 원칙입니다.

Graphics View 프레임워크 아키텍처

QGraphicsView·QGraphicsScene·QGraphicsItem으로 구성되는 Graphics View는 대량의 2D 객체를 효율적으로 그리기 위한 장면 그래프(scene graph)입니다. 단순 QPainter로 위젯에 직접 그리는 방식과 달리, 항목 단위 좌표계·선택·충돌 검사·변환이 프레임워크에 논리적으로 분리되어 있습니다.

  • QGraphicsScene: 논리적 장면. 여러 QGraphicsItem을 보유하며, BSP 트리 등으로 가시 영역에 대한 항목 쿼리를 가속할 수 있습니다.
  • QGraphicsView: 뷰포트에 장면을 투영합니다. 여러 뷰가 하나의 장면을 공유할 수 있어, 동일 데이터에 대한 확대·분할 화면이 단순해집니다.
  • QGraphicsItem: 로컬 좌표계를 가지며, 부모-자식 변환으로 계층적 배치가 가능합니다.

좌표는 아이템 로컬 → 장면(scene) → 뷰(view) 순으로 변환되며, 줌·회전은 QGraphicsView의 변환 행렬로 처리하는 것이 일반적입니다. 대화형 편집기, 회로도, 간단한 CAD, 보드게임 맵 등 수천 개 이상의 객체가 화면에 존재할 때 위젯 하나에 전부 그리는 것보다 유지보수와 성능 면에서 유리한 경우가 많습니다.

#include <QApplication>
#include <QGraphicsScene>
#include <QGraphicsView>
#include <QGraphicsRectItem>

int main(int argc, char *argv[]) {
    QApplication app(argc, argv);
    QGraphicsScene scene;
    scene.setSceneRect(0, 0, 400, 300);
    auto *item = scene.addRect(10, 10, 100, 60);
    item->setFlag(QGraphicsItem::ItemIsMovable); // 사용자 드래그
    QGraphicsView view(&scene);
    view.setRenderHint(QPainter::Antialiasing);
    view.show();
    return app.exec();
}

성능을 다룰 때는 항목 수뿐 아니라 페인트 복잡도·충돌 검사 빈도도 함께 봐야 합니다. QGraphicsItem::CacheMode로 캐싱을 켜거나, 변경이 적은 배경은 별도 레이어로 분리하는 식의 튜닝이 사용됩니다. 또한 QGraphicsPixmapItem 대량 배치 시 메모리와 텍스처 업로드 비용을 프로파일링하는 것이 좋습니다.

프로덕션에서 통용되는 Qt 패턴

객체 소유권은 Qt 데스크톱 앱의 메모리 안전성을 좌우합니다. 부모 QObject가 소멸할 때 자식 위젯과 자식 QObject가 순서대로 삭제되므로, 동적 할당 시 부모를 명시하는 습관이 중요합니다. 반대로 다른 곳에서 소유하는 QObject를 가리키는 포인터는 QPointer<T>로 들고 있으면, 대상이 삭제되는 순간 자동으로 nullptr이 되어 파괴 여부를 감지할 수 있습니다.

소유권 규칙에는 흔한 함정이 하나 있습니다. 부모 위젯보다 나중에 선언한 스택 객체를 그 부모의 자식으로 만들면, 함수가 끝날 때 스택 객체가 먼저 소멸하며 부모의 자식 목록에서 자신을 제거하므로 문제가 없습니다. 하지만 순서가 반대, 즉 자식을 먼저 스택에 선언하고 부모를 나중에 선언하면 부모가 먼저 소멸하면서 자식을 delete하고, 이어서 스택의 자식 소멸자가 한 번 더 실행되어 이중 해제로 크래시합니다. 자식 위젯은 new로 만들고 부모에게 맡기는 것이 규칙인 이유입니다. 또 이벤트 처리 중인 객체를 슬롯 안에서 delete this나 delete sender()로 지우면, 그 객체의 코드가 아직 호출 스택 위에 있어 크래시할 수 있으므로 deleteLater()로 이벤트 루프가 안전한 시점에 지우게 합니다.

스레드와 QObject 친화성(affinity) 을 지켜야 합니다. 특정 인스턴스는 생성된 스레드에 귀속되며, 다른 스레드에서 직접 메서드를 호출하면 데이터 레이스가 납니다. 워커 로직은 QObject 서브클래스로 만들고 moveToThread로 옮긴 뒤, 시그널/슬롯을 큐 연결로만 통신하는 패턴이 가장 흔한 정석입니다.

설정과 배포 측면에서는 QSettings로 OS별 저장 위치를 추상화하며, 로깅은 qDebug/qWarning을 넘어 애플리케이션 로거나 파일 싱크로 확장합니다. UI 코드와 비즈니스 로직을 분리하면 테스트에서 QSignalSpy로 시그널 발생 여부를 검증하기 쉬워집니다.

// 데이터 레이스를 피하기: 워커 QObject는 전용 스레드로만 moveToThread하며,
// UI 쪽에서는 시그널을 Qt::QueuedConnection(또는 기본 큐 연결)로 보낸다.
#include <QObject>
#include <QThread>

class ImageTask : public QObject {
    Q_OBJECT
public slots:
    void process(const QString &path) {
        Q_UNUSED(path);
        // 디스크·CPU 작업 — 이 슬롯은 worker가 속한 스레드에서 실행됨
    }
};

void attachWorkerToThread(ImageTask *worker, QThread *thread) {
    worker->moveToThread(thread);
    QObject::connect(thread, &QThread::finished, worker, &QObject::deleteLater);
    // UI: emit taskRequested(path) → connect(..., Qt::QueuedConnection) → ImageTask::process
}

실제 앱에서는 스레드를 start()한 뒤, UI나 조율기(coordinator)가 시그널로 경로를 넘기고 슬롯이 워커 스레드에서 실행되게 구성합니다. 요지는 UI 스레드에 귀속된 QWidget 등을 워커에서 직접 호출하지 않는 것입니다.

마지막으로, Qt 6에서는 일부 API와 모듈 구조가 정리되었으므로, 새 프로젝트는 Qt 6 기준 문서를 기본으로 삼으며, 레거시 Qt 5 코드는 QStringRef→QStringView 등 마이그레이션 가이드를 병행하는 것이 좋습니다. 이 글의 입문 예제는 Widgets 중심이지만, 같은 메타 객체·이벤트 루프 철학은 Qt Quick에도 공통으로 깔려 있어, 이후 QML로 확장할 때도 기반이 됩니다.

자주 발생하는 문제

문제 1: Q_OBJECT 매크로 에러

증상: undefined reference to 'vtable for MyClass' 또는 undefined reference to 'MyClass::staticMetaObject' 링크 에러

원인: Q_OBJECT가 선언한 메타 객체 함수의 정의는 moc가 생성하는데, moc가 그 클래스를 처리하지 않았습니다. CMake에서 CMAKE_AUTOMOC가 꺼져 있거나, 클래스가 .cpp 안에 있는데 #include "파일명.moc"가 없거나, 헤더가 타깃 소스에 포함되지 않은 경우입니다.

해결법: AUTOMOC를 켜고, 클래스를 헤더로 옮기거나 .moc include를 추가한 뒤 빌드 디렉터리를 재구성합니다. qmake라면 qmake를 다시 실행해야 새로 추가한 Q_OBJECT가 반영됩니다.

문제 2: 시그널-슬롯 연결 안 됨

증상: 클릭해도 반응 없음. 콘솔에 QObject::connect: No such signal QPushButton::clicke() 또는 No such slot MyClass::onClicked() 경고

원인: 문자열 기반 SIGNAL/SLOT 매크로는 이름을 런타임에 문자열로 찾기 때문에, 오타나 인자 타입 불일치가 컴파일을 통과하고 실행 중 경고로만 나타납니다. 슬롯을 slots: 섹션 밖의 일반 멤버 함수로 선언한 경우도 같은 증상입니다.

해결법: 함수 포인터 기반 connect로 바꾸면 이런 실수는 컴파일 에러가 됩니다. 오버로드된 시그널(예: QComboBox::currentIndexChanged가 Qt 5에서 int/QString 두 버전)은 qOverload<int>(&QComboBox::currentIndexChanged)로 어느 버전인지 지정해야 합니다.

// ❌ 런타임 체크
connect(button, SIGNAL(clicked()), this, SLOT(onClicked()));

// ✅ 컴파일 타임 체크
connect(button, &QPushButton::clicked, this, &MyClass::onClicked);

문제 3: 메모리 누수

증상: 메모리 증가

원인: 위젯 삭제 안함

해결법: 부모 위젯 설정

// ✅ 부모 설정하면 자동 삭제
QPushButton *button = new QPushButton("버튼", parentWidget);

FAQ

Q1: Qt와 다른 C++ GUI 선택지는 어떻게 다른가요?

A: Qt는 위젯, 네트워크, 파일, 스레드, 데이터베이스까지 포함한 큰 프레임워크라 데스크톱 앱 전체를 한 생태계로 만들 수 있는 대신 moc라는 추가 빌드 단계와 큰 배포 크기를 감수해야 합니다. wxWidgets는 각 OS의 네이티브 위젯을 감싸 운영체제 고유의 모양을 얻기 쉽고, Dear ImGui는 매 프레임 UI를 다시 그리는 즉시 모드 방식이라 게임 엔진의 디버그 툴이나 내부 도구에 적합하지만 일반 사용자용 앱의 접근성·텍스트 입력 기능은 약합니다.

Q2: 상용 제품에 Qt를 쓰려면 라이선스를 사야 하나요?

A: 반드시 그렇지는 않습니다. Qt의 주요 모듈은 LGPLv3로도 제공되어, 동적 링크로 사용하고 사용자가 Qt 라이브러리를 교체할 수 있게 하는 등 LGPL 조건을 지키면 소스를 공개하지 않은 상용 앱에도 쓸 수 있습니다. 다만 정적 링크, 잠긴 임베디드 기기처럼 라이브러리 교체가 어려운 배포, 일부 GPL 전용 모듈을 쓰는 경우에는 조건이 달라지므로, 제품에 넣기 전에 사용하는 모듈별 라이선스를 확인하고 필요하면 법무 검토를 받는 것이 안전합니다.

Q3: Qt Widgets와 Qt Quick(QML) 중 무엇으로 시작하나요?

A: 표, 트리, 폼이 많은 업무용 데스크톱 앱은 Widgets가 여전히 가장 생산적입니다. 애니메이션이 많은 터치 UI, 모바일, 임베디드 디스플레이는 GPU로 그리는 Qt Quick이 맞습니다. 두 방식 모두 이 글에서 다룬 QObject, 시그널/슬롯, 이벤트 루프 위에 서 있으므로, 비즈니스 로직을 QObject 클래스로 분리해 두면 나중에 UI 계층만 바꾸기도 쉽습니다.


같이 보면 좋은 글