Qt 로 만들자: 일기장

한동안 너무 쉬었다. 오랜만에 다시 만들어보는 프로그램은 <일기장> 이다. 프로그래밍을 연습할 때 주소록과 함께 한 번쯤 만들어보는 프로그램이다. 일기장과 주소록같은 프로그램들은 주로 데이터베이스를 공부하기 위한 소재들이다. 마찬가지로 이번에 <일기장> 을 만들어 보면서 Qt 에서 제공하는 데이터베이스 기능을 알아보도록 하자.

1. 요구사항

  • 날짜, 제목, 내용 편집 지원
  • 일기 불러오기/저장/삭제/수정 지원

2. 코드 분석

2.1 프로젝트 작성

  • 프로젝트 이름: Diary
  • 메인 클래스 이름: Diary
  • 메인 클래스 유형: QMainWindow

2.1.1 프로젝트 파일 수정(Diary.pro)

이 프로그램은 SQL 데이터베이스를 쓰기 때문에 sql 모듈을 추가해주어야 한다.

QT += core gui sql

2.2 헤더 분석(diary.h)

2.2.1 헤더 파일 목록

1
2
3
4
#include <QMainWindow>

#include <QtWidgets>
#include <QtSql>


4 번째 줄: QtSql 은 SQL 데이터베이스를 쓰기 위해 필요한 헤더이다.

2.2.2 public 멤버 함수

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
 * @brief 일기장 메인 클래스
 */

class Diary : public QMainWindow
{
    Q_OBJECT

public:
    Diary(QWidget *parent = 0);
    ~Diary();

    void initMenus();
    void initWidgets();


2.2.3 protected 멤버 함수

1
2
protected:
    void closeEvent(QCloseEvent *e);


2 번째 줄: QWidget::closeEvent() 는 창이 닫힐 때 발생하는 이벤트이다. 창을 닫을 때 저장되지 않은 일기를 저장할지 물어보기 위해 재정의한다.

2.2.4 private 멤버 변수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private:
    static const int StartYear = 2010;  ///< 일기장 시작 년도
    static const int EndYear = 2020;    ///< 일기장 마지막 년도

    QComboBox *_yearCombo;      ///< 연도 콤보 박스
    QComboBox *_monthCombo;     ///< 월 콤보 박스
    QComboBox *_dayCombo;       ///< 일 콤보 박스
    QLineEdit *_titleLine;      ///< 제목
    QTextEdit *_contentText;    ///< 내용

    int _id;    ///< 일기 DB ID
    int _year;  ///< 연도
    int _month; ///< 월
    int _day;   ///< 일

    QSqlDatabase _db;   ///< 데이터베이스


16 번째 줄: QSqlDatabase 는 데이터베이스 접속을 위한 클래스이다.

2.2.5 private 멤버 함수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
    bool diaryOpenDb();
    bool diaryCreateTable();
    bool diaryFind(QSqlQuery *query);
    bool diaryInsert(QSqlQuery *query);
    bool diaryDelete(QSqlQuery *query);
    bool diaryUpdate(QSqlQuery *query);

    /**
     * @brief 창 제목을 돌려준다
     * @return 창 제목
     */

    inline QString title() const
    {
        return QDate(_year, _month, _day)
                .toString(Qt::SystemLocaleLongDate) + "[*]";
    }

    /**
     * @brief 경고창을 보여준다
     * @param msg 경고 메세지
     */

    inline void warning(const QString &msg)
    {
        QMessageBox::warning(this, qApp->applicationDisplayName(), msg);
    }

    bool askToSave();
    bool askToOverwrite();


24 번째 줄: QMessageBox::warning() 은 경고 메시지를 보여주는 정적 멤버 함수이다.

2.2.6 private slots

1
2
3
4
5
6
7
8
9
10
11
12
private slots:
    void about();
    void aboutQt();

    void setDay();

    void setDiaryModified(bool modified = true);

    void newDiary();
    bool load();
    bool save();
};



2.3 소스 분석(diary.cpp)

2.3.1 Diary 클래스

<일기장> 의 메인 클래스이다.

2.3.1.1 생성자와 소멸자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
 * @brief Diary 생성자
 * @param parent 부모 위젯
 */

Diary::Diary(QWidget *parent)
    : QMainWindow(parent)
    , _yearCombo(0)
    , _monthCombo(0)
    , _dayCombo(0)
    , _id(-1)
    , _year(-1)
    , _month(-1)
    , _day(-1)
    , _db(QSqlDatabase::addDatabase("QSQLITE"))
{
    qApp->setApplicationName(tr("일기장"));

    // 데이터베이스 파일 설정
    _db.setDatabaseName(QApplication::applicationDirPath() + "/diary.sqlite");

    initMenus();
    initWidgets();

    newDiary();
}

/**
 * @brief Diary 소멸자
 */

Diary::~Diary()
{
}


14 번째 줄: QSqlDatabase::addDatabase() 는 프로그램에서 사용할 데이터베이스를 추가하는 정적 멤버 함수이다. <일기장> 은 내장 데이터베이스인 SQLite 를 쓴다. Qt 는 SQLite 외에도 MS SQL Server, My SQL Server, PostgreSQL, Oracle, Sybase, Interbase, DB2 를 지원한다.

16 번째 줄: QCoreApplication::setApplicationName() 은 프로그램의 이름을 설정한다.

19 번째 줄: QSqlDatabase::setDatabaseName() 을 사용할 데이터베이스의 이름을 설정하는 멤버 함수이다. <일기장> 에서는 데이터베이스의 파일 이름이다. QApplication::applicationDirPath() 는 실행파일의 디렉토리를 돌려준다. 예를 들어, 실행 파일이름이 x:/path/to/exefile 라면, x:/path/to 를 돌려준다.

2.3.1.2 initMenus()

파일 메뉴, 도움말 메뉴를 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
 * @brief 메뉴를 초기화한다
 */

void Diary::initMenus()
{
    // 파일 메뉴 생성
    QMenu *fileMenu = new QMenu(tr("파일(&F)"));
    fileMenu->addAction(tr("새 일기(&N)"), this, SLOT(newDiary()),
                        QKeySequence::New);
    fileMenu->addAction(tr("불러오기(&L)..."), this, SLOT(load()),
                        QKeySequence(tr("Ctrl+L")));
    fileMenu->addAction(tr("저장하기(&S)..."), this, SLOT(save()),
                        QKeySequence::Save);
    fileMenu->addSeparator();
    fileMenu->addAction(tr("끌내기(&x)"), this, SLOT(close()),
                        QKeySequence(tr("Ctrl+Q")));

    // 도움말 메뉴 생성
    QMenu *helpMenu = new QMenu(tr("도움말(&H)"));
    helpMenu->addAction(tr("%1 정보(&A)...").arg(qApp->applicationName()),
                        this, SLOT(about()));
    helpMenu->addAction(tr("Qt 정보(&Q)..."), this, SLOT(aboutQt()));

    // 메뉴바에 메뉴 추가
    menuBar()->addMenu(fileMenu);
    menuBar()->addMenu(helpMenu);
}


20 번째 줄: 메뉴 항목의 이름을 설정할 때 qApp->applicationName() + tr() 형태를 하지 않은 이유는 나중에 국제화를 위해서이다. 국제화를 위한 번역 과정에서 어순 등에 문제가 될 수도 있다. 이 경우 QString::arg() 를 사용하지 않으면 많은 어려움이 있다. 예를 들어,

tr("나는") + 2014 + tr("년에 태어났다")

의 경우, 문맥도 알기 어려울뿐만 아니라, 번역하더라도

"I was" + year + "born at"

정도가 된다. 참으로 이상한 문장이된다. 만일 이것을 다음처럼 바꾸면

tr("나는 %1 년에 태어났다").arg(year) 

번역자는 이를

"I was born at %1" 

으로 바꿀 수 있고, 따로 분리한 것보다 훨씬 매끄러운 번역이 된다.

따라서 어떤 문장을 표현할 때는 따로 따로 나누어서 표현하기 보다, QString::arg() 를 이용해서 전체 문장으로 표현하도록 하자.


2.3.1.3 initWidgets()

위젯을 초기화하고 레이아웃을 배치한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
 * @brief 위젯을 초기화한다
 */

void Diary::initWidgets()
{
    // 연도 콤보 박스/레이블 생성 및 초기화
    _yearCombo = new QComboBox;
    for (int i = StartYear; i < EndYear; ++i)
        _yearCombo->addItem(QString::number(i));

    QLabel *yearLabel = new QLabel(tr("연도(&Y):"));
    yearLabel->setBuddy(_yearCombo);

    // 월 콤보 박스/레이블 생성 및 초기화
    _monthCombo = new QComboBox;
    for (int i = 1; i <= 12; ++i)
        _monthCombo->addItem(QString::number(i));

    QLabel *monthLabel = new QLabel(tr("월(&M):"));
    monthLabel->setBuddy(_monthCombo);

    // 일 콤보 박스/레이블 생성 및 초기화
    _dayCombo = new QComboBox;

    QLabel *dayLabel = new QLabel(tr("일(&D):"));
    dayLabel->setBuddy(_dayCombo);

    connect(_yearCombo, SIGNAL(currentIndexChanged(int)),this, SLOT(setDay()));
    connect(_monthCombo, SIGNAL(currentIndexChanged(int)),
            this, SLOT(setDay()));
    connect(_dayCombo, SIGNAL(currentIndexChanged(int)), this, SLOT(setDay()));

    QHBoxLayout *dateLayout = new QHBoxLayout;
    dateLayout->addStretch();

    dateLayout->addWidget(yearLabel);
    dateLayout->addWidget(_yearCombo);
    dateLayout->addStretch();
    dateLayout->addWidget(monthLabel);
    dateLayout->addWidget(_monthCombo);
    dateLayout->addStretch();
    dateLayout->addWidget(dayLabel);
    dateLayout->addWidget(_dayCombo);
    dateLayout->addStretch();


연도/월/일 콤보박스와 레이블을 생성하고 배치한다.

36 번째 줄: QBoxLayout::addStretch() 은 늘임자를 추가하는데, 창의 크기가 달라질 때 확장된 부분을 빈 공간으로 메꾼다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    // 제목 편집기/레이블 생성 및 초기화
    _titleLine = new QLineEdit;
    _titleLine->setMaxLength(80);
    connect(_titleLine, SIGNAL(textEdited(QString)),
            this, SLOT(setDiaryModified()));

    QLabel *titleLabel = new QLabel(tr("제목(&T):"));
    titleLabel->setBuddy(_titleLine);

    QHBoxLayout *titleLayout = new QHBoxLayout;
    titleLayout->addWidget(titleLabel);
    titleLayout->addWidget(_titleLine);

    // 내용 편집기 생성 및 초기화
    _contentText = new QTextEdit;
    connect(_contentText, SIGNAL(textChanged()),
            this, SLOT(setDiaryModified()));


제목과 내용 관련 위젯을 생성하고 초기화한다.

1
2
3
4
5
6
7
8
9
10
11
12
    QVBoxLayout *vboxLayout = new QVBoxLayout;
    vboxLayout->addLayout(dateLayout);
    vboxLayout->addLayout(titleLayout);
    vboxLayout->addWidget(_contentText);

    QWidget *w = new QWidget;
    w->setLayout(vboxLayout);

    setCentralWidget(w);

    resize(640, 480);
}


전체 레이아웃을 배치하고, 창의 크기를 조정한다.

2.3.1.4 newDiary()

새 일기를 준비한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
 * @brief 새 일기를 준비한다
 */

void Diary::newDiary()
{
    // 사용자가 호출했을 때만 저장 여부 확인
    if (sender() && !askToSave())
        return;

    // 멤버 변수 및 위젯 초기화
    _id = -1;

    QDate now(QDate::currentDate());

    _yearCombo->setCurrentIndex(now.year() - StartYear);
    _monthCombo->setCurrentIndex(now.month() - 1);

    setDay();

    _titleLine->clear();
    _contentText->clear();

    _contentText->setFocus();

    setDiaryModified(false);
}


7 번째 줄: sender() 는 시그널을 보낸 QObject 의 포인터를 돌려준다. 만약 시그널로 호출된 것이 아니라면 sender() 는 0 을 돌려준다. 따라서 프로그램 초기화 중에 호출될 경우 저장 여부를 묻는 것을 피할 수 있다.

2.3.1.5 askToSave()

내용이 바뀐 일기를 저장할지를 묻는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
 * @brief 일기를 저장할지 물어본다
 * @return 계속 진행해도 되면 true, 중단해야 되면 false
 */

bool Diary::askToSave()
{
    if (isWindowModified())
    {
        QMessageBox::StandardButton button =
                QMessageBox::information(this, qApp->applicationName(),
                                         tr("바뀐 일기를 저장하시겠습니까?"),
                                         QMessageBox::Save |
                                         QMessageBox::Discard |
                                         QMessageBox::Cancel);

        return button == QMessageBox::Discard
                || (button == QMessageBox::Save && save());
    }

    return true;
}


QMessageBox::Save 또는 QMessageBox::Discard 일 때는 계속 진행을 하고(true), QMessageBox::Cancel 일 때는 진행을 멈춘다(false).

2.3.1.6 setDay()

연도와 월에 따라 일 콤보 박스를 조정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
 * @brief 일기의 일을 설정한다
 */

void Diary::setDay()
{
    QDate now(QDate::currentDate());

    // 콤보 박스를 연도/월/일로 바꿈
    int y = _yearCombo->currentIndex() + StartYear;
    int m = _monthCombo->currentIndex() + 1;
    int d = _dayCombo->currentIndex() + 1;

    // 해당 연도와 월의 일수를 얻음
    int daysInMonth = QDate(y, m, 1).daysInMonth();

    disconnect(_dayCombo, SIGNAL(currentIndexChanged(int)), this, 0);

    _dayCombo->clear();
    for (int i = 1; i <= daysInMonth; ++i)
        _dayCombo->addItem(QString::number(i));

    // 일기의 일을 조정
    if (d == 0)
        d = now.day();
    else if (d > daysInMonth)
        d = daysInMonth;

    _dayCombo->setCurrentIndex(d - 1);

    connect(_dayCombo, SIGNAL(currentIndexChanged(int)), this, SLOT(setDay()));

    // 조정된 연도/월/일 저장
    _year = y;
    _month = m;
    _day = d;

    setWindowTitle(title());

    // 사용자가 조정했을 때에만 바뀐 것으로 설정
    if (sender())
        setDiaryModified();
}


14 번째 줄: QDate::daysInMonth() 는 주어진 연도와 주어진 월의 일 수를 돌려준다. 윤년에 따라 2 월의 일 수가 달라지기 때문에 이를 위한 작업이다.

16 번째 줄: QObject::disconnect() 는 해당 객체의 시그널/슬롯 연결을 끊는다. 일 콤보박스의 인덱스가 수정되면 setDay() 가 다시 호출되므로, 시그널/슬롯을 연결을 끊지 않으면, 무한 루프에 빠진다.

2.3.1.6 setDiaryModified()

일기가 수정되었는지 설정한다.

1
2
3
4
5
6
7
8
/**
 * @brief 문서 내용이 변경되었는지 여부를 설정한다
 * @param modified 변경되었으면 true, 아니면 false
 */

void Diary::setDiaryModified(bool modified)
{
    setWindowModified(modified);
}


2.3.1.7 save()

현재 편집 중인 일기를 저장한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
 * @brief 일기를 저장한다
 * @return
 */

bool Diary::save()
{
    if (!isWindowModified())
    {
        if (QMessageBox::information(this, tr("저장하기"),
                                     tr("바뀐 내용이 없습니다. "
                                        "그래도 저장하시겠습니까?"),
                                     QMessageBox::Yes | QMessageBox::No)
                == QMessageBox::No)
            return true;
    }

    if (!diaryOpenDb() || !diaryCreateTable())
        return false;

    QSqlQuery query;
    bool ok = diaryFind(&query) ? diaryUpdate(&query) : diaryInsert(&query);

    if (ok)
        setDiaryModified(false);

    return ok;
}


20 번째 줄: QSqlQuery 는 Qt 에서 SQL 데이터베이스에 질의할 때 쓰이는 클래스이다. QSqlQuery 는 SQL 질의문을 그대로 쓸 수 있다. QSqlQuery 기본 생성자는 기본 데이터베이스에 연결된다. 기본 데이터베이스는 QSqlDatabase::addDatabase() 의 두번째 인자를 제공하지 않은 경우에 생성된다. Qt 는 QSqlQuery 말고도 고수준의 질의 클래스가 있는데, QSqlTableModel() 계열이 그것이다. SQL 질의문 대신에 추상화한 멤버 함수를 제공하며, 이름에서 볼 수 있듯이, 모델이므로 QListView 나 QTableView 에 모델로 쓰일 수 있다.

2.3.1.8 diaryOpenDb()

데이터베이스에 접속한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
 * @brief 데이터베이스를 연다
 * @return 성공하면 true, 실패하면 false
 */

bool Diary::diaryOpenDb()
{
    if (!_db.isOpen() && !_db.open())
    {
        warning(tr("DB 를 열 수 없습니다."));

        return false;
    }

    return true;
}


7 번째 줄: QSqlDatabase::isOpen() 은 데이터베이스가 열려 있는지 알려주고, QSqlDatabase::open() 은 데이터베이스를 연다. 데이터베이스를 열기 전에는 사용하는 SQL 드라이버에 따라 데이터베이스 이름외에도 사용자 이름, 암호, 호스트 이름, 포트 등을 먼저 설정해야 하는 경우도 있다.

2.3.1.9 diaryCreateTable()

데이터베이스에 일기장 테이블을 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
 * @brief 데이터베이스에 일기 테이블을 만든다
 * @return 성공하면 true, 실패하면 false
 */

bool Diary::diaryCreateTable()
{
    QSqlQuery query;
    if (!query.exec("CREATE TABLE IF NOT EXISTS diary ("
                    "id INTEGER PRIMARY KEY AUTOINCREMENT,"
                    "date DATE NOT NULL,"
                    "title VARCHAR(80) NOT NULL,"
                    "content TEXT NOT NULL"
                    ")"
                    ))
    {
        warning("테이블을 만들지 못했습니다.");

        return false;
    }

    return true;
}


8 번째 줄: QSqlQuery::exec() 는 주어진 SQL 질의문을 실행한다. SQL 질의문은 연결된 데이터베이스의 백엔드가 받아들일 수 있는 것이어야 한다.

2.3.1.10 diaryFind()

현재 작성 중인 일기 ID 를 이용하여 일기를 찾는다.

1
2
3
4
5
6
7
8
9
10
11
/**
 * @brief 데이터베이스에서 일기를 찾는다
 * @param[out] query 질의 결과
 * @return 찾았으면 true, 못찾았으면 false
 */

bool Diary::diaryFind(QSqlQuery *query)
{
    query->prepare("SELECT id FROM diary WHERE id = :id");
    query->bindValue(":id", _id);
    return query->exec() && query->next();
}


8~9 번째 줄: QSqlQuery::prepare() 는 질의문이 길어나 변수들을 따로 지정하고 싶을 때 쓰인다. 질의문 중에 [:변수이름](Oracle 스타일)은 이후에 QSqlQuery::bindValue() 에서 주어진 값으로 대체된다. [:변수이름] 대신에 Qt 는 '?'(ODBC 스타일) 도 지원한다. 이 때에는 QSqlQuery::addBindValue() 를 순서에 맞춰 쓰면 된다. 하지만 둘을 섞어서 쓸 수는 없다.

10 번째 줄: QSqlQuery::next() 는 질의문을 실행하고 나서 결과셋을 탐색할 때 쓰인다. 질의문을 실행한 직후에 결과셋은 invalid 상태이므로, 반드시 QSqlQuery::next() 를 실행시켜야 실제 결과셋을 확인할 수 있다.

2.3.1.11 diaryInsert()

일기를 데이터베이스에 추가한다. 만약 일기 ID 가 주어져 있으면, 해당 ID 로 저장하고, 아니면 새로운 ID 를 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
 * @brief 데이터베이스 일기를 저장한다
 * @param[out] query 질의 결과
 * @return 성공하면 true, 실패하면 false
 */

bool Diary::diaryInsert(QSqlQuery *query)
{
    query->prepare("INSERT INTO diary (id, date, title, content) "
                   "VALUES (:id, :date, :title, :content)");
    if (_id != -1)
        query->bindValue(":id", _id);
    else
        query->bindValue(":id", QVariant::Int);
    query->bindValue(":date", QDate(_year, _month, _day));
    query->bindValue(":title", _titleLine->text());
    query->bindValue(":content", _contentText->toPlainText());
    if (!query->exec())
    {
        warning(tr("일기를 저장하지 못했습니다."));

        return false;
    }

    if (_id == -1)
    {
        // 마지막에 삽입된 일기의 ID 를 얻는다
        if (!query->exec("SELECT last_insert_rowid() from diary") || !query->next())
        {
            warning(tr("DB 에서 일기 ID 를 얻지 못했습니다."));

            return false;
        }

        _id = query->value(0).toInt();
    }

    return true;
}


34 번째 줄: QSqlQuery::value() 는 주어진 컬럼의 값을 QVariant 로 돌려준다.

2.3.1.12 diaryDelete()

일기를 데이터베이스에서 지운다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
 * @brief 데이터베이스에서 일기를 지운다
 * @param[out] query 질의 결과
 * @return 성공하면 true, 실패하면 false
 */

bool Diary::diaryDelete(QSqlQuery *query)
{
    query->prepare("DELETE FROM diary WHERE id = :id");
    query->bindValue(":id", _id);
    if (!query->exec())
    {
        warning(tr("일기를 지우지 못했습니다."));

        return false;
    }

    return true;
}


2.3.1.13 diaryUpdate()

일기를 갱신한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
 * @brief 데이터베이스 일기를 갱신한다
 * @param[out] query 질의 결과
 * @return 성공하면 true, 실패하면 false
 */

bool Diary::diaryUpdate(QSqlQuery *query)
{
    query->prepare("UPDATE diary "
                   "SET date = :date, title = :title, content = :content "
                   "WHERE id = :id");
    query->bindValue(":date", QDate(_year, _month, _day));
    query->bindValue(":title", _titleLine->text());
    query->bindValue(":content", _contentText->toPlainText());
    query->bindValue(":id", _id);
    if (!query->exec())
    {
        warning(tr("일기를 갱신하지 못했습니다."));

        return false;
    }

    return true;
}


2.3.1.14 load()

일기를 불러온다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
 * @brief 일기를 불러온다
 * @return 성공하면 true, 실패하면 false
 */

bool Diary::load()
{
    if (!askToSave())
        return false;

    if (!diaryOpenDb())
        return false;

    LoadDialog dlg(_db, this);

    if (dlg.exec() == QDialog::Accepted)
    {
        _id = dlg.id();
        _yearCombo->setCurrentIndex(dlg.year() - StartYear);
        _monthCombo->setCurrentIndex(dlg.month() - 1);
        _dayCombo->setCurrentIndex(dlg.day() - 1);
        _titleLine->setText(dlg.title());
        _contentText->setPlainText(dlg.content());

        setDiaryModified(false);
    }

    return true;
}


15 번째 줄: QDialog::exec() 는 대화상자를 modal 상태로 실행하고, 성공했을 경우에는 QDialog::Accepted 를 돌려주고, 실패했을 경우에는 QDialog::Rejected 를 돌려준다.

2.3.1.15 about()

<일기장> 에 대한 정보를 보여준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
 * @brief 일기장에 대한 정보를 보여준다
 */

void Diary::about()
{
    // 컴파일한 날짜와 시간을 현지 시간으로 바꿈
    QString dateStr(__DATE__);
    if (dateStr.at(4) == ' ')
        dateStr[4] = '0';

    QDateTime dt;

    dt.setDate(QLocale(QLocale::C).toDate(dateStr, "MMM dd yyyy"));
    dt.setTime(QTime::fromString(__TIME__, "hh:mm:ss"));

    QMessageBox::about( this, tr("%1 정보").arg(qApp->applicationName()), tr(
"<h2>%1</h2>"
"<p>%2 에 만듦</p>"
"<p>이 프로그램에 대한 어떤 책임도 지지 않습니다. 이 프로그램은 공개 "
"소프트웨어이며, WTFPL v2 에 따라 재배포 및 수정될 수 있습니다.</p>"
"<p>자세한 것은 <a href=http://www.wtfpl.net>http://www.wtfpl.net</a> 를 "
"보십시오.</p>"
                      ).arg(qApp->applicationName())
                       .arg(dt.toString(Qt::SystemLocaleLongDate)));
}


13 번째 줄: QLocale() 은 로케일 관련 기능을 제공하는 클래스이다. QLocale::C 는 C 로케일을 뜻하며, QLocale::toDate() 는 주어진 문자열을 주어진 형식 문자열에 따라 QDate 로 바꾼다.

14 번째 줄: QTime::fromString() 은 주어진 문자열을 주어진 형식 문자열에 따라 QTime 으로 바꾼다.

16 번째 줄: QMessageBox::about() 은 주어진 인자를 바탕으로 About 대화상자를 보여준다.

About 대화 상자는 프로그램을 소개하고 라이센스를 보여주는 역할에 많이 쓰인다.

2.3.1.16 aboutQt()

Qt 에 대한 정보를 보여준다.

1
2
3
4
5
6
7
/**
 * @brief 사용된 Qt 에 대한 정보를 보여준다
 */

void Diary::aboutQt()
{
    QMessageBox::aboutQt(this);
}


6 번째 줄: QMessageBox::aboutQt() 는 현재 사용되고 있는 Qt 에 대한 정보를 보여준다.

2.3.1.17 closeEvent()

창이 닫힐 때 호출되며, 바뀐 내용이 있으면 저장할지 물어본다.

1
2
3
4
5
6
7
8
9
10
11
/**
 * @brief 창을 닫을 때 저장할지 확인한다
 * @param e 이벤트
 */

void Diary::closeEvent(QCloseEvent *e)
{
    if (askToSave())
        e->accept();
    else
        e->ignore();
}


8~10 번째 줄: QCloseEvent::accept() 는 창을 닫아도 된다는 것을 뜻하고, QCloseEvent::ignore() 는 창을 닫지 말라는 것을 뜻한다.

2.3.2 LoadDialog 클래스

일기를 불러오기 위한 대화상자이다.

2.3.2.1 멤버 변수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private:
    /** An enum of diary column
     */

    enum
    {
        Diary_Id = 0,       ///< 'ID' 컬럼
        Diary_Date = 1,     ///< '날짜' 컬럼
        Diary_Title = 2,    ///< '제목' 컬럼
        Diary_Content = 3   ///< '내용' 컬럼
    };

    QTableView *_view;          ///< 테이블 뷰
    QTextEdit *_contentText;    ///< '내용' 편집기
    QPushButton *_loadButton;   ///< '불러오기' 버튼
    QPushButton *_deleteButton; ///< '지우기' 버튼
    QPushButton *_cancelButton; ///< '취소' 버튼

    QSqlTableModel *_model;                 ///< SQL 모델
    QItemSelectionModel *_selectionModel;   ///< 선택 모델
    QSqlRecord _record;                     ///< 일기 정보


18 번째 줄: QSqlTableModel 은 SQL 데이터베이스를 모델로 추상화한 클래스이다. SQL 데이터베이스를 조작할 수도 있고, QListView 나 QTableView 에 모델로 제공될 수도 있다.

19 번째 줄: QItemSelectionModel 은 여러 뷰의 선택된 모델을 나타낸다.

20 번째 줄: QSqlRecord 는 QSqlTableModel() 에서 한 줄(row)에 해당하는 정보를 추상화한 클래스이다.

2.3.2.2 생성자

클래스를 생성하고 레이아웃을 배치한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
    /**
     * @brief LoadDialog 생성자
     * @param db 연결된 데이터베이스
     * @param parent 부모 위젯
     */

    LoadDialog(QSqlDatabase db, QWidget *parent = 0)
        : QDialog(parent)
    {
        // 모델 생성
        _model = new QSqlTableModel(this, db);
        _model->setTable("diary");
        // 직접 submitAll() 호출할 예정
        _model->setEditStrategy(QSqlTableModel::OnManualSubmit);
        // '날짜' 컬럼 이름 설정
        _model->setHeaderData(Diary_Date, Qt::Horizontal, tr("날짜"));
        // '제목' 컬럼 이름 설정
        _model->setHeaderData(Diary_Title, Qt::Horizontal, tr("제목"));
        // 데이터베이스에서 읽어들임
        _model->select();

        // 선택 모델 생성
        _selectionModel = new QItemSelectionModel(_model, this);


11 번째 줄: QSqlTableModel::setTable() 은 사용할 테이블을 설정한다.

13 번째 줄: QSqlTableModel::setEditStrategy() 은 데이터베이스를 수정하는 방식을 결정한다. QSqlTableModel::OnManualSubmit 은 개발자가 직접 결정하겠다는 뜻이다. 데이터베이스에 적용하려면 반드시 QSqlTableModel::submitAll() 을 실행해야 한다.

15~17 번째 줄: QSqlTableModel::setHeaderData() 는 주어진 컬럼의 데이터를 설정한다. 여기에서는 날짜와 제목에 대한 컬럼 제목이다.

19 번째 줄: QSqlTableModel::select() 는 SQL 의 SELECT 문에 해당하며, WHERE 절을 조절하고 싶으면 QSqlTableModel::setFilter() 를 쓰면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
        // 테이블 뷰 생성
        _view = new QTableView;
        // 읽기 전용
        _view->setEditTriggers(QAbstractItemView::NoEditTriggers);
        // 탭 키 쓰지 않음
        _view->setTabKeyNavigation(false);
        // 하나만 선택
        _view->setSelectionMode(QAbstractItemView::SingleSelection);
        // 줄 단위로 선택
        _view->setSelectionBehavior(QAbstractItemView::SelectRows);

        // 모델 및 선택 모델 설정
        _view->setModel(_model);
        _view->setSelectionModel(_selectionModel);
        // 'ID' 와 '내용' 은 감춤
        _view->hideColumn(Diary_Id);
        _view->hideColumn(Diary_Content);
        // 내용에 맞게 컬럼 크기 조정
        _view->resizeColumnsToContents();
        // '날짜' 기준으로 내림차순 정렬
        _view->setSortingEnabled(true);
        _view->sortByColumn(Diary_Date, Qt::DescendingOrder);

        // 마지막 컬럼은 창 크기에 맞게 늘임
        _view->horizontalHeader()->setStretchLastSection(true);

        // 더블 클릭하면 해당 자료 불러들임
        connect(_view, SIGNAL(doubleClicked(QModelIndex)),
                this, SLOT(accept()));
        connect(_selectionModel,
                SIGNAL(currentRowChanged(QModelIndex,QModelIndex)),
                this, SLOT(showCurrentContent(QModelIndex)));


4 번째 줄: QAbstractItemView::setEditTriggers() 는 편집을 시작하는 수단을 지정한다. QAbstractItemView::NoEditTriggers 는 읽기 전용이라는 뜻이다.

6 번째 줄: QAbstractItemView::setTabKeyNavigation() 은 탭 이동 여부를 결정한다. 여기에서는 대화상자의 탭 이동을 위해서 테이블 뷰의 탭 이동을 쓰지 않는다.

8 번째 줄: QAbstractItemView::setSelectionMode() 는 몇 개를 어떻게 선택할지를 설정한다. QAbstractItemView::SingleSelection 은 하나만 고른다는 것을 뜻한다.

10 번째 줄: QAbstractItemView::setSelectionBehavior() 는 어떤 것을 선택할지를 설정한다. QAbstractItemView::SelectRows 는 줄단위로 선택하는 것을 뜻한다.

13~14 번째 줄: QAbstractItemView::setModel() 은 나타낼 모델을 설정하고, QAbstractItemView::setSelectionModel() 은 선택 모델을 설정한다.

16~17 번째 줄: QAbstractItemView::hideColumn() 은 주어진 컬럼을 감춘다.

19 번째 줄: QAbstractItemView::resizeColumnToContents() 는 내용에 맞게 컬럼 폭을 조절한다.

21~22 번째 줄: QAbstractItemView::setSortingEnabled() 는 소팅 여부를 설정하고, QAbstractItemView::sortByColumn() 은 소팅 기준으로 사용할 컬럼을 설정한다.

25 번째 줄: QHeaderView::setStretchLastSection() 은 마지막 컬럼의 폭을 창 크기에 맞게 조절한다.

30~32 번째 줄: 사용자가 선택한 일기가 바뀌면 새로이 선택된 일기의 내용을 보여준다. 그런데, 주목한 것은 QTableView 의 시그널을 연결하는 것이 아니라는 것이다. QTableView 에도 selectedIndexes()currentChanged() 라는 멤버 함수가 있으나 있는 protected 멤버 함수이다. 따라서 외부에서 쓸 수가 없다.이를 해결하기 위한 것이 QItemSelectionModel() 이다. 코드에서 보듯이 QItemSelectionModel() 은 우리가 원하는 바로 그 시그널을 제공하고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
        // 읽기 전용으로 '내용' 편집기 생성
        _contentText = new QTextEdit;
        _contentText->setReadOnly(true);

        // '불러오기' 버튼 생성
        _loadButton = new QPushButton(tr("불러오기(&L)"));
        connect(_loadButton, SIGNAL(clicked(bool)), this, SLOT(accept()));
        _loadButton->setDefault(true);

        // '지우기' 버튼 생성
        _deleteButton = new QPushButton(tr("지우기(&D)"));
        connect(_deleteButton, SIGNAL(clicked(bool)),
                this, SLOT(deleteDiary()));

        // '취소' 버튼 생성
        _cancelButton = new QPushButton(tr("취소(&C)"));
        connect(_cancelButton, SIGNAL(clicked(bool)), this, SLOT(reject()));

        QHBoxLayout *hboxLayout = new QHBoxLayout;
        hboxLayout->addStretch();
        hboxLayout->addWidget(_loadButton);
        hboxLayout->addStretch();
        hboxLayout->addWidget(_deleteButton);
        hboxLayout->addStretch();
        hboxLayout->addWidget(_cancelButton);
        hboxLayout->addStretch();

        QVBoxLayout *vboxLayout = new QVBoxLayout;
        vboxLayout->addWidget(_view);
        vboxLayout->addWidget(_contentText);
        vboxLayout->addLayout(hboxLayout);

        setLayout(vboxLayout);

        resize(500, 375);

        // 도움말 버튼 감추기
        setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
        setWindowTitle(tr("불러오기"));
    }


레이아웃을 설정하고, 크기를 조정한다.

38 번째 줄: QWidget::setWindowFlags 는 위젯의 플래그를 설정한다. QWidget::windowFlags() 은 플래그를 돌려준다. Qt::WindowContextHelpButtonHint 는 문맥 도움말 버튼을 뜻하며, 여기에서는 해당 버튼을 보이지 않도록 설정한다.


2.3.2.3 id(), year(), month(), day(), title(), content()

대화상자의 결과를 돌려준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
    /**
     * @brief 일기 ID 를 돌려준다
     * @return 일기 ID
     */

    int id() const
    {
        return _record.value(Diary_Id).toInt();
    }

    /**
     * @brief 일기의 연도를 돌려준다
     * @return 일기의 연도
     */

    int year() const
    {
        return _record.value(Diary_Date).toDate().year();
    }

    /**
     * @brief 일기의 월을 돌려준다
     * @return 일기의 월
     */

    int month() const
    {
        return _record.value(Diary_Date).toDate().month();
    }

    /**
     * @brief 일기의 일을 돌려준다
     * @return 일기의 일
     */

    int day() const
    {
        return _record.value(Diary_Date).toDate().day();
    }

    /**
     * @brief 일기의 제목을 돌려준다
     * @return 일기의 제목
     */

    QString title() const
    {
        return _record.value(Diary_Title).toString();
    }

    /**
     * @brief 일기의 내용을 돌려준다
     * @return 일기의 내용
     */

    QString content() const
    {
        return _record.value(Diary_Content).toString();
    }




2.3.2.4 accept()

사용자가 대화상자를 종료할 때 선택한 결과를 저장한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public slots:
    /**
     * @brief 사용자가 일기를 선택하면 대화상자를 마무리한다
     */

    void accept()
    {
        // 현재 선택된 일기
        QModelIndex index = _view->currentIndex();

        if (!index.isValid())
            return;

        // 일기 정보를 저장
        _record = _model->record(index.row());

        QDialog::accept();
    }



8 번째 줄: QTableView::currentIndex() 는 현재 아이템의 인덱스를 돌려준다.

10 번째 줄: QModelIndex::isValid() 는 모델 인덱스가 올바른지 알려준다.

14 번째 줄: QSqlTableModel::record() 는 주어진 컬럼의 레코드를 돌려준다. 컬럼이 주어지지 않는다면 필드 이름들을 돌려준다. QModelIndex::row() 는 모델 인덱스의 줄 위치를 알려준다.

16 번째 줄: 대화상자를 올바르게 마무리하기 위해 QDialog::accept() 를 호출한다.

2.3.2.5 deleteDiary()

선택된 일기를 데이터베이스에서 지운다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private slots:
    /**
     * @brief 선택된 일기를 지운다
     */

    void deleteDiary()
    {
        QModelIndex index = _view->currentIndex();

        if (!index.isValid())
            return;

        _model->removeRow(index.row());
        _model->submitAll();
    }


12 번째 줄: QSqlTableModel::removeRow() 는 주어진 줄 하나를 지운다.
13 번째 줄: QSqlTableModel::submitAll() 은 그동안 수정한 내용을 데이터베이스에 실제로 적용한다. 앞서 QSqlTableMode::OnManualSubmit 전략을 쓰기로 했기 때문에, QSqlTableModel 로 수정한 내용을 데이터베이스에 반영하기 위해서는 반드시 QSqlTableModel::submitAll() 을 호출해야 한다.

2.3.2.6 showCurrentContent()

현재 선택된 일기의 내용을 보여준다.

1
2
3
4
5
6
7
8
9
10
    /**
     * @brief 선택된 일기의 내용을 보여준다
     * @param current 선택된 모델 인덱스
     */

    void showCurrentContent(const QModelIndex &current)
    {
        _contentText->setText(_model->record(current.row())
                                .value("content").toString());
    }
};


3. 마무리하면서...

<일기장> 을 만들면서 Qt 에서 데이터베이스를 다루는 방법을 살펴보았다. 언제나 그렇듯이 여기에 서술된 기능들은 매우 기초적인 것들이다. 그리고 이외에도 오버로딩된 멤버 함수들이 많이 존재하기 때문에 반드시 Qt 도움말과 Qt Assistant 를 살펴보자.

번역을 고려해서 QObject::tr() 을 쓰고 있지만, 정작 Qt 의 위젯들, 예를 들어 QMessageBox, 같은 경우에는 번역되지 않은채 나타나고 있는데, 이는 Qt 번역 파일을 설치하지 않았기 때문이다. 이에 대해서는 차후에 기회가 되면 살펴보도록 하자.

다음은 <일기장> 의 실행 모습이다.



전체 소스는 여기에서 확인하자.




댓글

이 블로그의 인기 게시물

토렌트: < 왕좌의 게임 > 시즌 1 ~ 시즌 8 완결편 마그넷

토렌트: < 스타워즈 > Ep.1 ~ Ep.6 마그넷

토렌트: NGC < 코스모스 > 우리말 더빙 전편(1편~13편) 마그넷