Qt Quick Controls - 채팅 튜토리얼
이 튜토리얼에서는 Qt Quick Controls 를 사용하여 기본적인 채팅 애플리케이션을 작성하는 방법을 보여줍니다. 또한 Qt 애플리케이션에 SQL 데이터베이스를 통합하는 방법도 설명합니다.
제1장: 환경 설정
새 프로젝트를 설정할 때는 Qt Creator를 사용하는 것이 가장 간편합니다. 이 프로젝트에서는 Qt Quick 애플리케이션 템플릿을 선택했는데, 이 템플릿은 다음과 같은 파일들로 구성된 기본적인 "Hello World" 애플리케이션을 생성합니다:
CMakeLists.txt- CMake에 프로젝트 빌드 방식을 지시합니다Main.qml- 빈 Window를 포함하는 기본 UI를 제공합니다main.cpp- 로드합니다main.qmlqtquickcontrols2.conf- 애플리케이션에 사용할 스타일을 지정합니다
main.cpp
main.cpp 의 기본 코드에는 두 개의 #include 문이 있습니다:
#include <QGuiApplication>
#include <QQmlApplicationEngine>첫 번째는 QGuiApplication 에 접근할 수 있게 해줍니다. 모든 Qt 애플리케이션에는 애플리케이션 객체가 필요하지만, 정확한 유형은 애플리케이션의 기능에 따라 다릅니다. 비그래픽 애플리케이션의 경우 QCoreApplication 로 충분합니다. QGuiApplication 는 Qt Widgets, 반면 그래픽 요소를 사용하는 애플리케이션의 경우 QApplication 가 필요합니다.
두 번째 include 문은 QQmlApplicationEngine 를 사용할 수 있게 하여, QML을 로드할 수 있게 해줍니다.
main() 내에서 애플리케이션 객체와 QML 엔진을 설정합니다:
int main(int argc, char *argv[])
{
QGuiApplication app(argc, argv);
QQmlApplicationEngine engine;
engine.loadFromModule("chattutorial", "Main");
return app.exec();
}QQmlApplicationEngine 는 QQmlEngine 을 편리하게 감싸는 래퍼로, 애플리케이션용 QML을 쉽게 로드할 수 있는 ` loadFromModule ` 함수를 제공합니다. 또한 파일 선택기를 사용하는 데 있어 몇 가지 편의 기능을 추가합니다.
C++에서 설정을 마친 후에는 QML의 사용자 인터페이스로 넘어갈 수 있습니다.
Main.qml
필요에 맞게 기본 QML 코드를 수정해 봅시다.
import QtQuick
import QtQuick.Controls이미 Qt Quick 모듈이 이미 임포트되어 있음을 알 수 있습니다. 이를 통해 Item, Rectangle, Text 등과 같은 그래픽 기본 요소에 접근할 수 있습니다. 전체 유형 목록은 Qt Quick QML Types 문서를 참조하십시오.
Qt Quick Controls 모듈을 임포트하세요. 이 모듈은 여러 기능 중에서도 ApplicationWindow 에 대한 접근을 제공하며, 이는 기존의 루트 타입인 Window 을 대체하게 됩니다:
ApplicationWindow {
width: 540
height: 960
visible: true
...
}ApplicationWindow 는 header 및 footer 을 생성하는 데 편의를 더한 Window 입니다. 또한 popups 의 기반을 제공하며, 배경 color 과 같은 기본적인 스타일링을 지원합니다.
ApplicationWindow 를 사용할 때 거의 항상 설정되는 세 가지 속성이 있습니다: width, height, visible 입니다. 이 속성들을 설정하면 콘텐츠로 채울 준비가 된 적절한 크기의 빈 창이 생성됩니다.
참고: 기본 코드에 있던 title 속성은 제거되었습니다.
애플리케이션의 첫 번째 “화면”은 연락처 목록이 될 것입니다. 각 화면 상단에 해당 화면의 용도를 설명하는 텍스트가 있으면 좋을 것입니다. 이 경우 ApplicationWindow 의 header 및 footer 속성을 활용할 수 있습니다. 이 속성들은 애플리케이션의 모든 화면에 표시되어야 하는 항목에 이상적인 몇 가지 특성을 가지고 있습니다:
- 각각 창의 상단과 하단에 고정됩니다.
- 창의 너비를 가득 채웁니다.
하지만 사용자가 보고 있는 화면에 따라 헤더와 푸터의 내용이 달라지는 경우에는 Page 를 사용하는 것이 훨씬 더 편리합니다. 지금은 페이지 하나를 추가할 뿐이지만, 다음 장에서는 여러 페이지 간을 이동하는 방법을 시연해 보겠습니다.
Page {
anchors.fill: parent
header: Label {
padding: 10
text: qsTr("Contacts")
font.pixelSize: 20
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}먼저, ` anchors.fill ` 속성을 사용하여 창 전체 공간을 차지하도록 크기가 조정된 `Page`를 추가합니다.
그런 다음, ` header ` 속성에 ` Label `을 할당합니다. `Label`은 ` Qt Quick ` 모듈의 기본 ` Text ` 항목을 확장하여 스타일링과 ` font ` 상속 기능을 추가한 것입니다. 즉, `Label`은 사용 중인 스타일에 따라 모양이 달라질 수 있으며, 픽셀 크기를 자식 요소로 전달할 수도 있습니다.
애플리케이션 창의 상단과 텍스트 사이에 약간의 간격을 두기 위해 padding 속성을 설정합니다. 이렇게 하면 라벨의 양쪽(경계 내)에 추가 공간이 할당됩니다. 대신 topPadding 및 bottomPadding 속성을 명시적으로 설정할 수도 있습니다.
qsTr() 함수를 사용하여 레이블의 텍스트를 설정하면, Qt의 번역 시스템을 통해 텍스트를 번역할 수 있습니다. 이는 애플리케이션의 최종 사용자에게 표시되는 텍스트에 대해 따르는 것이 좋은 관행입니다.
기본적으로 텍스트는 수직 방향으로 경계 상단에 정렬되며, 수평 정렬은 텍스트의 자연스러운 방향에 따라 달라집니다. 예를 들어, 왼쪽에서 오른쪽으로 읽는 텍스트는 왼쪽에 정렬됩니다. 이러한 기본값을 사용하면 텍스트가 창의 왼쪽 상단 모서리에 위치하게 됩니다. 이는 헤더로 사용하기에는 적합하지 않으므로, 텍스트를 수평 및 수직 방향 모두에서 경계 영역의 중앙에 정렬하도록 합니다.
프로젝트 파일
CMakeLists.txt 파일에는 프로젝트를 실행 가능한 프로그램으로 빌드하는 데 CMake에 필요한 모든 정보가 포함되어 있습니다.
이 파일에 대한 자세한 설명은 QML 애플리케이션 빌드하기를 참조하십시오.
다음은 현재 우리 애플리케이션을 실행했을 때의 모습입니다:

2장: 목록
이 장에서는 ` ListView ` 및 ` ItemDelegate`을 사용하여 상호작용 가능한 항목 목록을 만드는 방법을 설명합니다.
ListView Qt Quick 는 모듈에서 제공되며, 모델에서 가져온 항목 목록을 표시합니다. 는 모듈에서 제공되며, 및 와 같은 뷰 및 컨트롤에서 사용할 수 있는 표준 뷰 항목을 제공합니다. 예를 들어, 각 는 텍스트를 표시하고, 선택/해제를 할 수 있으며, 마우스 클릭에 반응할 수 있습니다. ItemDelegate Qt Quick Controls ListView ComboBox ItemDelegate
다음은 우리의 ListView 입니다:
...
ListView {
id: listView
anchors.fill: parent
topMargin: 48
leftMargin: 48
bottomMargin: 48
rightMargin: 48
spacing: 20
model: ["Albert Einstein", "Ernest Hemingway", "Hans Gude"]
delegate: ItemDelegate {
id: contactDelegate
text: modelData
width: listView.width - listView.leftMargin - listView.rightMargin
height: avatar.implicitHeight
leftPadding: avatar.implicitWidth + 32
required property string modelData
Image {
id: avatar
source: "images/" + contactDelegate.modelData.replace(" ", "_") + ".png"
}
}
}
...크기 조정 및 위치 지정
가장 먼저 뷰의 크기를 설정합니다. 뷰는 페이지의 사용 가능한 공간을 모두 채워야 하므로 ` anchors.fill`를 사용합니다. `Page`는 헤더와 푸터에 충분한 공간을 확보해 두므로, 이 경우 뷰는 예를 들어 헤더 아래에 배치됩니다.
다음으로, ` ListView ` 주위에 ` margins `을 설정하여 이 요소와 창 가장자리 사이에 약간의 간격을 둡니다. `margin` 속성은 뷰 경계 내에서 공간을 확보하므로, 빈 영역은 여전히 사용자가 “휙” 하고 넘길 수 있습니다.
항목들은 뷰 내에서 적절한 간격을 두고 배치되어야 하므로, ` spacing ` 속성은 ` 20`로 설정됩니다.
모델
뷰에 항목을 빠르게 채우기 위해 JavaScript 배열을 모델로 사용했습니다. QML의 가장 큰 장점 중 하나는 애플리케이션 프로토타이핑을 매우 빠르게 수행할 수 있다는 점이며, 이것이 바로 그 예시입니다. 또한 모델 속성에 숫자를 간단히 할당하여 필요한 항목 수를 지정할 수도 있습니다. 예를 들어, model 속성에 10 을 할당하면 각 항목의 표시 텍스트는 0 부터 9 까지의 숫자가 됩니다.
하지만 애플리케이션이 프로토타입 단계를 지나면 곧 실제 데이터를 사용해야 할 필요가 생깁니다. 이를 위해서는 subclassing QAbstractItemModel 의 적절한 C++ 모델을 사용하는 것이 가장 좋습니다.
Delegate
delegate 로 넘어가 보겠습니다. 모델의 해당 텍스트를 ItemDelegate 의 text 속성에 할당합니다. 모델의 데이터가 각 델리게이트에 제공되는 정확한 방식은 사용된 모델의 유형에 따라 다릅니다. 자세한 내용은 Qt Quick 의 ‘Models and Views’를 참조하십시오.
이 애플리케이션에서 뷰 내 각 항목의 너비는 뷰의 너비와 동일해야 합니다. 이렇게 하면 사용자가 목록에서 연락처를 선택할 때 충분한 공간을 확보할 수 있으며, 이는 휴대폰과 같이 터치스크린이 작은 기기에서 중요한 요소입니다. 그러나 뷰의 너비에는 48 픽셀의 여백이 포함되어 있으므로, width 속성에 값을 할당할 때 이를 고려해야 합니다.
다음으로, Image 를 정의합니다. 이 델리게이트는 사용자의 연락처 사진을 표시합니다. 이미지의 너비는 40 픽셀, 높이는 40 픽셀입니다. 세로 방향으로 빈 공간이 생기지 않도록 델리게이트의 높이는 이미지의 높이를 기준으로 설정할 것입니다.

3장: 탐색
이 장에서는 StackView 를 사용하여 애플리케이션 내 페이지 간을 이동하는 방법을 배웁니다. 다음은 수정된 main.qml 파일입니다:
import QtQuick.Controls
ApplicationWindow {
id: window
width: 540
height: 960
visible: true
StackView {
id: stackView
anchors.fill: parent
initialItem: ContactPage {}
}
}StackView를 이용한 탐색
이름에서 알 수 있듯이, StackView 는 스택 기반의 탐색 기능을 제공합니다. 스택에 가장 마지막으로 "푸시" 된 항목이 가장 먼저 제거되며, 최상단 항목이 항상 화면에 표시됩니다.
Page에서 했던 것과 마찬가지로, StackView 에 애플리케이션 창을 채우도록 지시합니다. 그 후 남은 유일한 작업은 initialItem 를 통해 표시할 항목을 전달하는 것입니다. StackView 는 items, components 및 URLs 를 받아들입니다.
연락처 목록에 대한 코드를 ContactPage.qml 로 옮겼다는 것을 알 수 있습니다. 애플리케이션에 어떤 화면이 포함될지 대략적인 구상이 서는 대로 이렇게 하는 것이 좋습니다. 이렇게 하면 코드의 가독성이 높아질 뿐만 아니라, 해당 컴포넌트에서 항목이 꼭 필요한 경우에만 인스턴스화되도록 하여 메모리 사용량을 줄일 수 있습니다.
참고: Qt Creator 는 QML을 위한 몇 가지 편리한 빠른 수정 기능을 제공하며, 그중 하나는 코드 블록을 별도의 파일로 이동할 수 있게 해줍니다(Alt + Enter > Move Component into Separate File).
ListView 를 사용할 때 고려해야 할 또 다른 사항은 id 를 통해 참조할지, 아니면 첨부된 ListView.view 속성을 사용할지 여부입니다. 가장 좋은 접근 방식은 몇 가지 요인에 따라 달라집니다. 뷰에 ID를 부여하면 첨부된 속성의 오버헤드가 매우 적기 때문에 더 짧고 효율적인 바인딩 표현식을 얻을 수 있습니다. 그러나 델리게이트를 다른 뷰에서 재사용할 계획이라면, 델리게이트가 특정 뷰에 묶이는 것을 방지하기 위해 첨부 속성을 사용하는 것이 좋습니다. 예를 들어, 첨부 속성을 사용하면 델리게이트 내의 ` width ` 할당 문은 다음과 같이 변경됩니다:
width: ListView.view.width - ListView.view.leftMargin - ListView.view.rightMargin2장에서는 헤더 아래에 ` ListView `를 추가했습니다. 해당 장의 애플리케이션을 실행해 보면, 뷰의 내용이 헤더 위를 넘어 스크롤되는 것을 확인할 수 있습니다:
이는 특히 델리게이트의 텍스트가 헤더의 텍스트까지 닿을 정도로 길 경우 보기 좋지 않습니다. 이상적으로는 헤더 텍스트 아래에, 하지만 뷰 위쪽에 단색 블록을 배치하는 것이 좋습니다. 이렇게 하면 리스트뷰의 내용이 시각적으로 헤더의 내용을 방해하지 않게 됩니다. 뷰의 ` clip ` 속성을 ` true`로 설정하여도 동일한 효과를 얻을 수 있지만, 이 경우 ` can affect performance`가 발생합니다.
ToolBar 이 작업에 가장 적합한 도구입니다. 이 컨트롤은 내비게이션 버튼이나 검색 필드와 같이 애플리케이션 전반에 적용되거나 상황에 따라 달라지는 동작 및 컨트롤을 모두 포함하는 컨테이너입니다. 무엇보다도, 평소와 마찬가지로 애플리케이션 스타일에서 가져온 배경색을 가지고 있습니다. 실제 적용 예시는 다음과 같습니다:
이 컨트롤 자체에는 레이아웃이 없으므로, 레이블을 중앙에 배치하는 작업은 직접 수행합니다.
나머지 코드는 2장에서 다룬 내용과 동일하지만, ` clicked ` 신호를 활용하여 다음 페이지를 스택뷰에 푸시한다는 점이 다릅니다:
onClicked: root.StackView.view.push("ConversationPage.qml", { inConversationWith: modelData })` Component ` 또는 ` url `를 ` StackView`에 푸시할 때, (나중에) 인스턴스화될 항목을 일부 변수로 초기화해야 하는 경우가 종종 있습니다. ` StackView`의 ` push()` 함수는 두 번째 인수로 JavaScript 객체를 받아 이 점을 처리합니다. 우리는 이를 사용하여 다음 페이지에 연락처의 이름을 제공하며, 해당 페이지는 이를 바탕으로 관련 대화를 표시합니다. root.StackView.view.push 구문에 유의하십시오. 이는 첨부 속성의 작동 방식 때문에 필요합니다.
ConversationPage.qml 를 단계별로 살펴보겠습니다. 먼저 임포트부터 시작하겠습니다:
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls이전 코드와 동일하지만, 곧 다룰 QtQuick.Layouts 임포트가 추가된 점이 다릅니다.
Page {
id: root
property string inConversationWith
header: ToolBar {
ToolButton {
text: qsTr("Back")
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
onClicked: root.StackView.view.pop()
}
Label {
id: pageTitle
text: root.inConversationWith
font.pixelSize: 20
anchors.centerIn: parent
}
}
...이 컴포넌트의 루트 항목은 또 다른 Page이며, 이 Page에는 inConversationWith 라는 사용자 정의 속성이 있습니다. 현재로서는 이 속성이 헤더의 레이블에 표시될 내용을 결정하는 역할만 합니다. 나중에 대화 내 메시지 목록을 채우는 SQL 쿼리에서 이 속성을 사용할 것입니다.
사용자가 ‘연락처’ 페이지로 돌아갈 수 있도록, 클릭 시 ` pop()`를 호출하는 ` ToolButton `를 추가합니다. ` ToolButton `는 기능적으로 ` Button`와 유사하지만, ` ToolBar` 내에서 더 적합한 외관을 제공합니다.
QML에서 항목을 배치하는 방법에는 두 가지가 있습니다: 항목 포지셔너와 Qt Quick Layouts입니다. 항목 포지셔너(Row, Column 등)는 항목의 크기가 알려져 있거나 고정되어 있으며, 특정 형태로 깔끔하게 배치하기만 하면 되는 경우에 유용합니다. Qt Quick Layouts 의 레이아웃은 항목의 위치와 크기를 모두 조정할 수 있어, 크기를 조절할 수 있는 사용자 인터페이스에 적합합니다. 아래에서는 ColumnLayout 를 사용하여 ListView 와 Pane 를 수직으로 배치합니다:
ColumnLayout {
anchors.fill: parent
ListView {
id: listView
Layout.fillWidth: true
Layout.fillHeight: true
...
}
...
Pane {
id: pane
Layout.fillWidth: true
...
}Pane은 기본적으로 애플리케이션의 스타일에 따라 색상이 결정되는 직사각형입니다. 이는 Frame 와 유사하지만, 테두리 주변에 획이 없다는 점만 다릅니다.
레이아웃의 직접 자식 요소인 항목에는 다양한 attached properties 를 사용할 수 있습니다. 우리는 ListView 에서 Layout.fillWidth 및 Layout.fillHeight 를 사용하여, 해당 요소가 ColumnLayout 내에서 가능한 한 많은 공간을 차지하도록 합니다. Pane에 대해서도 동일한 처리를 적용합니다. ColumnLayout 는 수직 레이아웃이므로 각 자식 요소의 좌우에 다른 요소가 없으며, 이로 인해 각 요소가 레이아웃의 전체 너비를 차지하게 됩니다.
반면, ListView 에 있는 Layout.fillHeight 문은 Pane을 배치한 후 남은 공간을 이 요소가 차지할 수 있도록 합니다.
이제 리스트뷰를 자세히 살펴보겠습니다:
ListView {
id: listView
Layout.fillWidth: true
Layout.fillHeight: true
Layout.margins: pane.leftPadding + messageField.leftPadding
displayMarginBeginning: 40
displayMarginEnd: 40
verticalLayoutDirection: ListView.BottomToTop
spacing: 12
model: 10
delegate: Row {
id: messageDelegate
anchors.right: sentByMe ? listView.contentItem.right : undefined
spacing: 6
required property int index
readonly property bool sentByMe: index % 2 == 0
Rectangle {
id: avatar
width: height
height: parent.height
color: "grey"
visible: !messageDelegate.sentByMe
}
Rectangle {
width: 80
height: 40
color: messageDelegate.sentByMe ? "lightgrey" : "steelblue"
Label {
anchors.centerIn: parent
text: messageDelegate.index
color: messageDelegate.sentByMe ? "black" : "white"
}
}
}
ScrollBar.vertical: ScrollBar {}
}부모 뷰의 너비와 높이를 채운 후, 뷰에 여백도 설정했습니다. 이를 통해 "메시지 작성" 필드의 자리 표시자 텍스트와 깔끔하게 정렬할 수 있습니다:

다음으로, ` displayMarginBeginning `과 ` displayMarginEnd`을 설정합니다. 이 속성들은 뷰의 가장자리에서 스크롤할 때 뷰 경계 밖에 있는 델리게이트가 사라지지 않도록 보장합니다. 이 점을 이해하는 가장 쉬운 방법은 해당 속성을 주석 처리한 후 뷰를 스크롤했을 때 어떤 일이 일어나는지 확인하는 것입니다.
그런 다음 뷰의 수직 방향을 반전시켜 첫 번째 항목이 하단에 위치하도록 합니다. 델리게이트 간 간격은 12픽셀로 설정되며, 4장에서 실제 모델을 구현할 때까지는 테스트 목적으로 "더미" 모델이 할당됩니다.
델리게이트 내부에서는 위 이미지에 표시된 대로 아바타 뒤에 메시지 내용이 따라오도록 하기 위해, 루트 아이템으로 ` Row `을 선언합니다.
사용자가 보낸 메시지와 연락처가 보낸 메시지는 구분되어야 합니다. 당분간은 sentByMe 라는 더미 속성을 설정하여, 단순히 델리게이트의 인덱스를 사용하여 작성자를 번갈아 표시하도록 합니다. 이 속성을 사용하여 다음 세 가지 방법으로 작성자를 구분합니다:
- 사용자가 보낸 메시지는 `
anchors.right`을 `listView.contentItem.right`으로 설정하여 화면 오른쪽에 정렬합니다. - 아바타(현재는 단순히 Rectangle 객체임)의
visible속성을sentByMe에 따라 설정함으로써, 연락처가 보낸 메시지의 경우에만 아바타를 표시합니다. - 작성자에 따라 사각형의 색상을 변경합니다. 어두운 배경에 어두운 텍스트가 표시되거나 그 반대의 상황이 발생하지 않도록 하기 위해, 작성자에 따라 텍스트 색상도 설정합니다. 5장에서는 스타일링이 이러한 문제를 어떻게 해결해 주는지 살펴보겠습니다.
화면 하단에는 여러 줄의 텍스트를 입력할 수 있도록 ` TextArea ` 항목을 배치하고, 메시지를 전송할 수 있는 버튼도 추가합니다. 리스트뷰의 내용이 페이지 헤더와 겹치지 않도록 ` ToolBar `을 사용하는 것과 마찬가지로, 이 두 항목 아래 영역을 가리기 위해 `Pane`을 사용합니다:
Pane {
id: pane
Layout.fillWidth: true
Layout.fillHeight: false
RowLayout {
width: parent.width
TextArea {
id: messageField
Layout.fillWidth: true
placeholderText: qsTr("Compose message")
wrapMode: TextArea.Wrap
}
Button {
id: sendButton
text: qsTr("Send")
enabled: messageField.length > 0
Layout.fillWidth: false
}
}
}TextArea 는 화면의 사용 가능한 너비를 모두 차지해야 합니다. 사용자가 어디에서 입력을 시작해야 하는지 시각적으로 알 수 있도록 자리 표시자 텍스트를 지정합니다. 입력 영역 내의 텍스트는 화면 밖으로 벗어나지 않도록 줄 바꿈이 적용됩니다.
마지막으로, 실제로 전송할 메시지가 있을 때만 버튼이 활성화됩니다.
4장: 모델
4장에서는 C++로 읽기 전용 및 읽기-쓰기 SQL 모델을 모두 생성하고, 이를 QML에 노출하여 뷰를 채우는 과정을 단계별로 안내해 드리겠습니다.
QSqlQueryModel
튜토리얼을 간단하게 진행하기 위해, 사용자 연락처 목록을 편집할 수 없도록 설정했습니다. QSqlQueryModel은 SQL 결과 집합에 대한 읽기 전용 데이터 모델을 제공하므로, 이러한 용도로 사용하기에 가장 적합한 선택입니다.
QSqlQueryModel 에서 파생된 SqlContactModel 클래스를 살펴보겠습니다:
#include <QQmlEngine>
#include <QSqlQueryModel>
class SqlContactModel : public QSqlQueryModel
{
Q_OBJECT
QML_ELEMENT
public:
SqlContactModel(QObject *parent = nullptr);
};여기에는 별다른 내용이 없으니, 이제 ` .cpp ` 파일로 넘어가 보겠습니다:
#include "sqlcontactmodel.h"
#include <QDebug>
#include <QSqlError>
#include <QSqlQuery>
static void createTable()
{
if (QSqlDatabase::database().tables().contains(QStringLiteral("Contacts"))) {
// 테이블이 이미 존재하므로 별도의 작업이 필요 없습니다.
return;
}
QSqlQuery query;
if (!query.exec(
"CREATE TABLE IF NOT EXISTS 'Contacts' ("
" 'name' TEXT NOT NULL,"
" PRIMARY KEY(name)"
")")) {
qFatal("Failed to query database: %s", qPrintable(query.lastError().text()));
}
query.exec("INSERT INTO Contacts VALUES('Albert Einstein')");
query.exec("INSERT INTO Contacts VALUES('Ernest Hemingway')");
query.exec("INSERT INTO Contacts VALUES('Hans Gude')");
}클래스의 헤더 파일과 Qt에서 필요한 헤더 파일을 포함합니다. 그런 다음, SQL 테이블이 아직 존재하지 않을 경우 이를 생성하고, 가상의 연락처 정보를 입력하는 데 사용할 createTable() 라는 정적 함수를 정의합니다.
아직 구체적인 데이터베이스를 설정하지 않았기 때문에 database() 호출이 다소 헷갈릴 수 있습니다. 이 함수에 연결 이름이 전달되지 않으면, 곧 다룰 '기본 연결'을 반환합니다.
SqlContactModel::SqlContactModel(QObject*parent) :
QSqlQueryModel(parent)
{
createTable();
QSqlQuery query;
if (!query.exec("SELECT * FROM Contacts"))
qFatal("Contacts SELECT query failed: %s", qPrintable(query.lastError().text()));
setQuery(std::move(query));
if (lastError().isValid())
qFatal("Cannot set query on SqlContactModel: %s", qPrintable(lastError().text()));
}생성자에서 ` createTable()`를 호출합니다. 그런 다음 모델에 데이터를 채우는 데 사용할 쿼리를 구성합니다. 이 경우, 단순히 ` Contacts ` 테이블의 모든 행에 관심이 있습니다.
QSqlTableModel
SqlConversationModel 은 더 복잡합니다:
#include <QQmlEngine>
#include <QSqlTableModel>
class SqlConversationModel : public QSqlTableModel
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(QString recipient READ recipient WRITE setRecipient NOTIFY recipientChanged)
public:
SqlConversationModel(QObject *parent = nullptr);
QString recipient() const;
void setRecipient(const QString &recipient);
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
Q_INVOKABLE void sendMessage(const QString &recipient, const QString &message);
signals:
void recipientChanged();
private:
QString m_recipient;
};Q_PROPERTY 와 Q_INVOKABLE 매크로를 모두 사용하므로, Q_OBJECT 매크로를 사용하여 moc에 이를 알려야 합니다.
recipient 속성은 QML에서 설정되어 모델이 어떤 대화의 메시지를 가져와야 하는지 알 수 있게 합니다.
QML에서 사용자 정의 역할을 사용할 수 있도록 data() 및 roleNames() 함수를 오버라이드합니다.
또한 QML에서 호출하고자 하는 ` sendMessage() ` 함수를 정의하므로, ` Q_INVOKABLE ` 매크로를 사용합니다.
.cpp 파일을 살펴보겠습니다:
#include "sqlconversationmodel.h"
#include <QDateTime>
#include <QDebug>
#include <QSqlError>
#include <QSqlRecord>
#include <QSqlQuery>
static const char *conversationsTableName = "Conversations";
static void createTable()
{
if (QSqlDatabase::database().tables().contains(conversationsTableName)) {
// 테이블이 이미 존재하므로 별도의 작업이 필요하지 않습니다.
return;
}
QSqlQuery query;
if (!query.exec(
"CREATE TABLE IF NOT EXISTS 'Conversations' ("
"'author' TEXT NOT NULL,"
"'recipient' TEXT NOT NULL,"
"'timestamp' TEXT NOT NULL,"
"'message' TEXT NOT NULL,"
"FOREIGN KEY('author') REFERENCES Contacts ( name ),"
"FOREIGN KEY('recipient') REFERENCES Contacts ( name )"
")")) {
qFatal("Failed to query database: %s", qPrintable(query.lastError().text()));
}
query.exec("INSERT INTO Conversations VALUES('Me', 'Ernest Hemingway', '2016-01-07T14:36:06', 'Hello!')");
query.exec("INSERT INTO Conversations VALUES('Ernest Hemingway', 'Me', '2016-01-07T14:36:16', 'Good afternoon.')");
query.exec("INSERT INTO Conversations VALUES('Me', 'Albert Einstein', '2016-01-01T11:24:53', 'Hi!')");
query.exec("INSERT INTO Conversations VALUES('Albert Einstein', 'Me', '2016-01-07T14:36:16', 'Good morning.')");
query.exec("INSERT INTO Conversations VALUES('Hans Gude', 'Me', '2015-11-20T06:30:02', 'God morgen. Har du fått mitt maleri?')");
query.exec("INSERT INTO Conversations VALUES('Me', 'Hans Gude', '2015-11-20T08:21:03', 'God morgen, Hans. Ja, det er veldig fint. 정말 감사합니다! "
"그 그림을 그리는 데 몇 시간을 들였나요?')");
}이는 sqlcontactmodel.cpp 과 매우 유사하지만, 이제 Conversations 테이블을 대상으로 작업한다는 점이 다릅니다. 또한 파일 전체에서 여러 곳에서 사용되므로 conversationsTableName 를 정적 const 변수로 정의합니다.
SqlConversationModel::SqlConversationModel(QObject *parent) :
QSqlTableModel(parent)
{
createTable();
setTable(conversationsTableName);
setSort(2, Qt::DescendingOrder);
// Ensures that the model is sorted correctly after submitting a new row.
setEditStrategy(QSqlTableModel::OnManualSubmit);
}SqlContactModel 와 마찬가지로, 생성자에서 가장 먼저 하는 일은 테이블을 생성하는 것입니다. setTable() 함수를 통해 QSqlTableModel 에 사용할 테이블 이름을 지정합니다. 대화의 최신 메시지가 먼저 표시되도록 하기 위해, timestamp 필드를 기준으로 쿼리 결과를 내림차순으로 정렬합니다. 이는 ListView 의 verticalLayoutDirection 속성을 ListView.BottomToTop 로 설정하는 작업과 밀접한 관련이 있습니다(이 내용은 3장에서 다루었습니다).
QString SqlConversationModel::recipient() const
{
return m_recipient;
}
void SqlConversationModel::setRecipient(const QString &recipient)
{
if (recipient == m_recipient)
return;
m_recipient = recipient;
const QString filterString = QString::fromLatin1(
"(recipient = '%1' AND author = 'Me') OR (recipient = 'Me' AND author='%1')").arg(m_recipient);
setFilter(filterString);
select();
emit recipientChanged();
}setRecipient() 에서는 데이터베이스에서 반환된 결과에 필터를 적용합니다.
QVariant SqlConversationModel::data(const QModelIndex &index, int role) const
{
if (role < Qt::UserRole)
return QSqlTableModel::data(index, role);
const QSqlRecord sqlRecord = record(index.row());
return sqlRecord.value(role - Qt::UserRole);
}data() 함수는 역할이 사용자 정의 사용자 역할이 아닌 경우 QSqlTableModel 의 구현으로 대체됩니다. 역할이 사용자 역할인 경우, Qt::UserRole 를 뺀 값을 통해 해당 필드의 인덱스를 구한 다음, 이를 사용하여 반환해야 할 값을 찾을 수 있습니다.
QHash<int, QByteArray> SqlConversationModel::roleNames() const
{
QHash<int, QByteArray> names;
names[Qt::UserRole] = "author";
names[Qt::UserRole + 1] = "recipient";
names[Qt::UserRole + 2] = "timestamp";
names[Qt::UserRole + 3] = "message";
return names;
}roleNames() 에서는 사용자 정의 역할 값과 역할 이름 간의 매핑을 반환합니다. 이를 통해 QML에서 이러한 역할을 사용할 수 있습니다. 모든 역할 값을 담을 열거형(enum)을 선언하는 것이 유용할 수 있지만, 이 함수 외부의 코드에서는 특정 값을 참조하지 않으므로 굳이 그렇게 하지 않습니다.
void SqlConversationModel::sendMessage(const QString&recipient, const QString&message)
{
const QString timestamp = QDateTime::currentDateTime().toString(Qt::ISODate);
QSqlRecord newRecord = record();
newRecord.setValue("author", "Me");
newRecord.setValue("recipient", recipient);
newRecord.setValue("timestamp", timestamp);
newRecord.setValue("message", message);
if (!insertRecord(rowCount(), newRecord)) {
qWarning() << "Failed to send message:" << lastError().text();
return;
}sendMessage() 함수는 주어진 recipient 와 message 를 사용하여 데이터베이스에 새 레코드를 삽입합니다. QSqlTableModel::OnManualSubmit 를 사용했기 때문에, submitAll()를 수동으로 호출해야 합니다.
QML을 사용하여 데이터베이스에 연결하고 유형 등록하기
모델 클래스를 정의했으니, 이제 main.cpp 을 살펴보겠습니다:
#include <QtCore>
#include <QGuiApplication>
#include <QSqlDatabase>
#include <QSqlError>
#include <QtQml>
static void connectToDatabase()
{
QSqlDatabase database = QSqlDatabase::database();
if (!database.isValid()) {
database = QSqlDatabase::addDatabase("QSQLITE");
if (!database.isValid())
qFatal("Cannot add database: %s", qPrintable(database.lastError().text()));
}
const QDir writeDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
if (!writeDir.mkpath("."))
qFatal("Failed to create writable directory at %s", qPrintable(writeDir.absolutePath()));
// 모든 기기에서 쓰기 가능한 위치가 있는지 확인합니다.
const QString fileName = writeDir.absolutePath() + "/chat-database.sqlite3";
// SQLite 드라이버를 사용할 때, open() 메서드는 SQLite 데이터베이스가 존재하지 않으면 이를 생성합니다.
database.setDatabaseName(fileName);
if (!database.open()) {
qFatal("Cannot open database: %s", qPrintable(database.lastError().text()));
QFile::remove(fileName);
}
}
int main(int argc, char *argv[])
{
QGuiApplication app(argc, argv);
connectToDatabase();
QQmlApplicationEngine engine;
engine.loadFromModule("chattutorial", "Main");
if (engine.rootObjects().isEmpty())
return-1;
return app.exec();
}connectToDatabase() SQLite 데이터베이스에 연결하며, 해당 파일이 아직 존재하지 않으면 파일을 생성합니다.
main() 내에서 qmlRegisterType()을 호출하여 QML 내에서 모델을 타입으로 등록합니다.
QML에서 모델 사용하기
이제 모델을 QML 타입으로 사용할 수 있게 되었으므로, ContactPage.qml 파일에 약간의 변경이 필요합니다. 이 타입들을 사용하려면 먼저 main.cpp 에서 설정한 URI를 사용하여 타입을 임포트해야 합니다:
import chattutorial그런 다음 더미 모델을 올바른 모델로 대체합니다:
model: SqlContactModel {}델리게이트 내부에서는 모델 데이터에 접근하기 위해 다른 구문을 사용합니다:
text: model.displayConversationPage.qml 에서는 동일한 chattutorial 임포트를 추가하고, 더미 모델을 다음과 같이 대체합니다:
model: SqlConversationModel {
recipient: root.inConversationWith
}모델 내에서 recipient 속성을 페이지가 표시되는 연락처의 이름으로 설정합니다.
모든 메시지 아래에 표시하고자 하는 타임스탬프를 수용하기 위해 루트 델리게이트 항목을 Row에서 Column으로 변경합니다:
delegate: Column {
id: conversationDelegate
anchors.right: sentByMe ? listView.contentItem.right : undefined
spacing: 6
required property string author
required property string recipient
required property date timestamp
required property string message
readonly property bool sentByMe: recipient !== "Me"
Row {
id: messageRow
spacing: 6
anchors.right: conversationDelegate.sentByMe ? parent.right : undefined
Image {
id: avatar
source: !conversationDelegate.sentByMe
? "images/" + conversationDelegate.author.replace(" ", "_") + ".png" : ""
}
Rectangle {
width: Math.min(messageText.implicitWidth + 24,
listView.width - (!conversationDelegate.sentByMe ? avatar.width + messageRow.spacing : 0))
height: messageText.implicitHeight + 24
color: conversationDelegate.sentByMe ? "lightgrey" : "steelblue"
Label {
id: messageText
text: conversationDelegate.message
color: conversationDelegate.sentByMe ? "black" : "white"
anchors.fill: parent
anchors.margins: 12
wrapMode: Label.Wrap
}
}
}
Label {
id: timestampText
text: Qt.formatDateTime(conversationDelegate.timestamp, "d MMM hh:mm")
color: "lightgrey"
anchors.right: conversationDelegate.sentByMe ? parent.right : undefined
}
}
이제 적절한 모델이 준비되었으므로, ` sentByMe ` 속성의 표현식에서 해당 모델의 ` recipient ` 역할을 사용할 수 있습니다.
아바타에 사용되었던 Rectangle은 Image로 변환되었습니다. 이미지에는 자체적인 암시적 크기가 있으므로, 크기를 명시적으로 지정할 필요가 없습니다. 이전과 마찬가지로 작성자가 사용자가 아닐 때만 아바타를 표시하지만, 이번에는 visible 속성을 사용하는 대신 이미지의 source 을 빈 URL로 설정합니다.
각 메시지의 배경은 텍스트보다 양쪽이 각각 12픽셀씩 더 넓게 표시되도록 하고 싶습니다. 하지만 메시지가 너무 길 경우, 너비를 리스트뷰의 가장자리까지로 제한하고 싶으므로 Math.min() 를 사용합니다. 메시지가 본인이 보낸 것이 아닐 때는 항상 그 앞에 아바타가 표시되므로, 아바타의 너비와 행 간격을 빼서 이를 반영합니다.
예를 들어, 위 이미지에서 메시지 텍스트의 암시적 너비는 더 작은 값입니다. 반면, 아래 이미지에서는 메시지 텍스트가 상당히 길기 때문에 더 작은 값(뷰의 너비)이 선택되어, 텍스트가 화면의 반대쪽 가장자리에서 멈추도록 보장합니다:

앞서 설명한 각 메시지의 타임스탬프를 표시하기 위해 Label을 사용합니다. 날짜와 시간은 Qt.formatDateTime()을 사용하여 사용자 정의 형식으로 포맷팅됩니다.
이제 “보내기” 버튼이 클릭되었을 때 반응하도록 해야 합니다:
Button {
id: sendButton
text: qsTr("Send")
enabled: messageField.length > 0
Layout.fillWidth: false
onClicked: {
listView.model.sendMessage(root.inConversationWith, messageField.text)
messageField.text = ""
}
}먼저, 모델의 호출 가능한 sendMessage() 함수를 호출하여 Conversations 데이터베이스 테이블에 새 행을 삽입합니다. 그런 다음, 향후 입력을 위해 텍스트 필드의 내용을 지웁니다.
5장: 스타일링
Qt Quick Controls 의 스타일은 모든 플랫폼에서 작동하도록 설계되었습니다. 이 장에서는 Basic, Material, Universal 스타일로 애플리케이션을 실행했을 때 외관이 깔끔하게 보이도록 약간의 시각적 조정을 해보겠습니다.
지금까지는 Basic 스타일로만 애플리케이션을 테스트해 왔습니다. 예를 들어, Material 스타일로 실행하면 즉시 몇 가지 문제가 눈에 띕니다. 다음은 ‘연락처’ 페이지입니다:

헤더 텍스트가 짙은 파란색 배경 위에 검은색으로 표시되어 있어 읽기가 매우 어렵습니다. 대화 페이지에서도 같은 문제가 발생합니다:

해결책은 툴바에 “다크(Dark)” 테마를 사용하도록 지시하여, 이 정보가 하위 요소로 전달되도록 함으로써 해당 요소들의 텍스트 색상을 더 밝은 색으로 변경할 수 있게 하는 것입니다. 이를 수행하는 가장 간단한 방법은 Material 스타일을 직접 임포트하고 Material 첨부 속성을 사용하는 것입니다:
import QtQuick.Controls.Material 2.12
// ...
header: ToolBar {
Material.theme: Material.Dark
// ...
}하지만 이 방법은 Material 스타일에 대한 강력한 종속성을 수반합니다. 대상 기기에서 Material 스타일을 사용하지 않더라도 Material 스타일 플러그인을 애플리케이션과 함께 배포해야 하며, 그렇지 않으면 QML 엔진이 임포트를 찾지 못하게 됩니다.
대신, Qt Quick Controls 에서 제공하는 스타일 기반 파일 선택기에 대한 내장 지원을 활용하는 것이 더 좋습니다. 이를 위해서는 ` ToolBar `를 별도의 파일로 분리해야 합니다. 이 파일을 ChatToolBar.qml 라고 부르겠습니다. 이 파일은 “기본” 버전으로, 즉 아무것도 지정되지 않았을 때 사용되는 ‘기본 ( Basic )’ 스타일이 적용될 때 사용됩니다. 새로운 파일 내용은 다음과 같습니다:
import QtQuick.Controls
ToolBar {
}이 파일에서는 ToolBar 타입만 사용하므로, Qt Quick Controls 임포트만 있으면 됩니다. 코드 자체는 ContactPage.qml 에 있던 것과 달라진 점이 없는데, 이는 당연한 일입니다. 파일의 기본 버전인 만큼, 달라질 필요가 없기 때문입니다.
다시 ContactPage.qml 파일로 돌아가서, 새로운 타입을 사용하도록 코드를 업데이트합니다:
header: ChatToolBar {
Label {
text: qsTr("Contacts")
font.pixelSize: 20
anchors.centerIn: parent
}
}이제 툴바의 Material 버전을 추가해야 합니다. 파일 선택기는 파일의 변형본이 해당 파일의 기본 버전과 동일한 디렉터리에 적절하게 명명된 디렉터리 내에 존재할 것으로 기대합니다. 즉, ChatToolBar.qml이 위치한 디렉터리, 즉 루트 디렉터리에 "+Material"이라는 이름의 폴더를 추가해야 합니다. "+" 기호는 QFileSelector 에서 선택 기능이 실수로 실행되는 것을 방지하기 위해 필수적으로 사용됩니다.
다음은 +Material/ChatToolBar.qml 파일입니다:
import QtQuick.Controls
import QtQuick.Controls.Material
ToolBar {
Material.theme: Material.Dark
}ConversationPage.qml 에도 동일한 변경 사항을 적용하겠습니다:
header: ChatToolBar {
ToolButton {
text: qsTr("Back")
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
onClicked: root.StackView.view.pop()
}
Label {
id: pageTitle
text: root.inConversationWith
font.pixelSize: 20
anchors.centerIn: parent
}
}이제 두 페이지 모두 올바르게 표시됩니다:


이제 유니버설 스타일을 적용해 봅시다:


별다른 문제는 없습니다. 이와 같이 비교적 간단한 애플리케이션의 경우, 스타일을 전환할 때 필요한 조정 사항은 거의 없을 것입니다.
이제 각 스타일의 다크 테마를 사용해 보겠습니다. ‘기본(Basic)’ 스타일에는 다크 테마가 없습니다. 이는 성능을 최대한 높이기 위해 설계된 스타일에 약간의 부하를 가할 수 있기 때문입니다. 먼저 ‘머티리얼(Material)’ 스타일을 테스트해 볼 테니, qtquickcontrols2.conf 파일에 다크 테마를 사용하도록 지시하는 항목을 추가해 주세요:
[Material]
Primary=Indigo
Accent=Indigo
Theme=Dark이 작업이 완료되면 애플리케이션을 빌드하고 실행하세요. 다음과 같은 화면이 표시될 것입니다:


두 페이지 모두 정상적으로 표시됩니다. 이제 유니버설(Universal) 스타일에 대한 항목을 추가해 보겠습니다:
[universal]
Theme=Dark애플리케이션을 빌드하고 실행한 후 다음과 같은 결과가 표시되어야 합니다:


요약
이 튜토리얼에서는 Qt Quick Controls 를 사용하여 기본 애플리케이션을 작성하는 다음 단계를 단계별로 안내해 드렸습니다:
- Qt Creator 를 사용하여 새 프로젝트 생성.
- ApplicationWindow 의 기본 설정.
- Page를 사용하여 헤더와 푸터를 정의하기.
- ListView 에 콘텐츠 표시하기.
- 컴포넌트를 별도의 파일로 리팩토링하기.
- StackView 를 사용하여 화면 간 이동하기.
- 레이아웃을 사용하여 애플리케이션 크기가 원활하게 조정되도록 하기.
- SQL 데이터베이스를 애플리케이션에 통합하는 사용자 정의 읽기 전용 및 쓰기 가능 모델을 모두 구현합니다.
- Q_PROPERTY, Q_INVOKABLE 및 qmlRegisterType()를 통해 C++을 QML과 통합.
- 여러 스타일 테스트 및 구성.
© 2026 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.