본문 바로가기
목차
Python/PySide PyQt

[PySide] CustomModel 구현을 통한 Model-View 이해 - 작성중

by ds31x 2025. 6. 3.
728x90
반응형

Qt Model-View Tutorial:
QListView + QAbstractListModel 이해

Qt에서는 복잡한 데이터 구조를 UI에 효율적으로 표현하고 조작하기 위해 모델-뷰(Model-View) Architecture를 채택함.

이 문서에서는 그 개념을 정리하고, QAbstractListModel기반의 Custom Model과  QListView를 사용한 도서 관리 예제를 통해 실습해 봄.


1. Qt의 Model-View Architecture : 

Qt의 Model-View Architecture 는 데이터(Model)와 사용자 인터페이스(View)를 명확히 분리하여 관리하는 구조임.

이 구조는 재사용성과 유지보수성을 크게 높여주며, 특히 동적으로 변경되는 데이터 UI에 적합합니다.

  • Model:
    • 데이터를 보관하고 외부에 제공하는 역할
      • QAbstractListModel
      • QStandardItemModel
    • 도서 관리 예제에서는 도서 데이터 목록(제목, 저자, 연도 등)에 해당.
  • View:
    • 데이터를 사용자에게 보여주는 역할
      • QListView
      • QTableView
    • 모델의 데이터를 list 또는 table 등으로 보여주는 역할.
    • 클릭, 선택 등의 이벤트를 감지.
  • Delegate (선택적):
    • 데이터 표현과 편집 방식을 위임받아 사용자 정의할 수 있는 구성 요소:
      • QStyledItemDelegate
    • 책 제목을 어떻게 보여줄지, 클릭시 어떤 동작을 할지 등등을 구현.
    • 지정하지 않는 경우, View의 기본 delegate를 사용함:
      • 텍스트 기반인 경우 보통 QLabel을 이용해 랜더링.

View는 Model의 데이터를 읽어와 화면에 표시하며, 사용자의 입력은 시그널/슬롯을 통해 Model로 전달됨.


참고: 전통적인 MVC Architecture와 Qt Model-View Architecture의 비교 : 

구성 요소 전통적인 MVC Qt Model-View
Model 데이터 및 비즈니스 로직 데이터 제공
View 사용자 UI 사용자 UI
Controller 입력을 받아 Model/Logic 제어 View가 직접 시그널 처리

 

Qt의 Model-View Architecture는 Controller가 View와 통합된 구조임.
View가 시그널과 슬롯(custom method 도 slot으로 사용됨)을 통해 Model과 직접 통신함.


2. 도서 목록 예제를 통해 Model-View Architecture 이해하기 : 

도서 목록(Book List) 예제에서의 Model-View Architecture는 다음과 같이 구현:

구성 요소 역할 관련 class
Model 책 list 데이터를 보관, 관리하며 view에 제공 BookModel
View 모델의 데이터를 사용자에게 보여주고, event를 감지 QListView
Delegate 출판연도 편집을 어떻게 할지 결정 YearSpinBoxDelegate

 

즉, 사용자가 UI를 조작할 때:

  • View는 입력을 감지하고
  • Delegate는 그에 맞는 위젯(예: QSpinBox)을 보여주며
  • Model은 최종적으로 데이터를 갱신함.

해당 예제를 잘 이해하기 위해

우선, QAbstractListModelQListView 클래스들에 대한 설명으로 시작함.


3. QAbstractListModel 의 구성요소 및 동작 방식 : 

QAbstractListModel은 리스트 형태의 데이터를 다루는 Qt가 제공하는 Model 클래스.

 

다음의 상속구조를 가짐

QObject
    ↑ 
QAbstractItemModel
    ↑ 
QAbstractListModel
  • list에 대응하는 사용자 정의 모델을 만들기 위해 상속해서 사용함.

다음과 같은 핵심 메서드와 시그널을 재정의하거나 호출합니다.

  • rowCount, data, flags 메서드는 override 하여 사용 (flags는 optional)
  • dataChanged, layoutChangedSignal을 통해 연결된 View에게 갱신을 요청.
  • beginInsertRows, beginRemoveRows, beginResetModel 등은 명시적으로 호출하여 갱신 요청.
    • Signal 을 사용(emit)하는 것이 보다 편함.
    • 보다 성능을 극대화하려면 begin/end 메서드를 사용하는 것이 좋음.

이를 간단히 table로 정리하면 다음과 같음.

Method/Signal Desc. override
여부
호출 시점
rowCount() 항목 수 반환 필수
override
View가 모델 크기 요청 시
data(index, role) 항목 데이터 반환 필수
override
View가 표시 요청 시
setData(index, value, role) 항목 데이터 수정 처리 override View가 편집 결과를
모델에 반영할 때
flags(index) 항목의 속성 반환 override 선택/편집 가능성
확인 시
beginInsertRows()
endInsertRows()
새 항목 삽입 알림 호출 항목 추가 전후
beginRemoveRows()
endRemoveRows()
항목 제거 알림 호출 항목 삭제 전후
beginResetModel()
endResetModel()
전체 교체 시 호출 모델 리셋 전후
dataChanged 특정 항목 값 변경 알림 Signal emit() 항목 수정 후
layoutAboutToBeChanged
layoutChanged
모델 구조 변경 시 사용되는 Signal emit() 정렬 전후

3-1. .rowCount() 메서드 : 

Qt의 View 클래스(QListView 등) 에서 Model이 보유한 데이터의 개수를 알아내기 위해 자동으로 호출되는 메서드로 반드시 overriding 해야함.

def rowCount(
    self, 
    parent:QModelIndex,
    ) -> int
  • View에 Model을 view.setMode(model)로 연결할 때 및
  • View를 다시 갱신( resize 이벤트 로 인해 화면에 다시 표시)할 때,
  • 데이터의 숫자가 갱신된 경우 등에 View가 호출.

예제 코드에서의 구현은 다음과 같음

def rowCount(self, parent=QModelIndex()):
    return len(self._books)

 

QListView와 연결되는 경우엔

  • parent가 무조건 QModelIndex()로 지정되어
  • 부모가 없다는 것을 나타내어 전체 row 수(root에서의 node수)를 구하지만,

QTreeView에 연결되는 경우엔

  • parent 가 넘겨지고,
  • 해당 항목 아래에 children의 수를 반환하도록 overriding.

 

이를 반영하여 QListView에서의 구현하며 다음과 같음:

def rowCount(self, parent=QModelIndex()):
    if parent.isValid():
        return 0  # 리스트 모델이므로 자식 없음
    return len(self._books)

 

QTreeView에서의 구현은 다음과 같음:

def rowCount(self, parent):
    if parent.isValid():
        return <parent 노드의 자식 수>
    else:
        return <root 노드의 자식 수>

 

참고로, QModelIndex 클래스는 모델 내부의 특정 위치를 나타내는 객체의 타입으로,

  • .row().column() 의 getter를 가지고,
  • .isValid()를 통해 유효한 index인지 확인 가능함.

3-2. .data(index, role) 메서드:

Qt의 View 객체에서 데이터를 요청할 때, 적절한 결과를 반환해주는 메서드로 반드시 overriding 해야함.

def data(
    self, 
    index: QModelIndex, 
    role: int,
    ) -> Any
  • View 객체가 데이터를 표시하기 위해
  • 자신의 Model 객체에게 데이터를 요청할 때 호출됨.
  • QAbstractItemModel을 상속한 모든 클래스에서 override해야하는 메서드임.

Model을 구현시 overriding 하여 구현 하며 핵심적인 동작을 결정함.

 

argument는 다음과 같음:

3-2-1. index 파라미터:

  • View 객체가 요구하는 데이터의 index로 .row().column() 메서드를 가진 객체임.
  • list 의 경우엔 .row()만 처리하고 .column()은 무시함.

3-2-2. role 파라미터:

  • View 객체가 어떤 목적으로 데이터를 요청했는지를 나타냄.
  • Qt에서 제공하는 role이 가질 수 있는 값은 다음과 같음 (enum 형으로 실제로 정수상수임)
Role (Qt.*) Desc. return type example
Qt.DisplayRole 화면에 표시할 기본 텍스트 str, int, float "The Great Gatsby"
Qt.EditRole 편집 시 사용되는 원시 값 str, int, float 1949
Qt.ToolTipRole 마우스 오버 시 툴팁 텍스트 str "출판년도: 1949"
Qt.TextAlignmentRole 셀 내 텍스트 정렬 정보 Qt.AlignmentFlag `Qt.AlignRight
Qt.FontRole 글꼴 정보 QFont QFont(
   "Arial", 12,
   QFont.Bold,
   )
Qt.ForegroundRole 글자색 (텍스트 색상 강조) QColor, QBrush QColor("red")
Qt.BackgroundRole 셀 배경색 지정 QColor, QBrush QBrush(QColor("#eef"))
Qt.DecorationRole 아이콘, 이미지, 색상 등 셀 앞 장식 QIcon, QPixmap,
QColor, QBrush
QIcon(":/icons/book.png")

 

예제에서 사용되는 code는 다음과 같음:

def data(self, index, role=Qt.DisplayRole):
    if not index.isValid():
        return None  # 유효하지 않은 요청은 무시

    book = self._books[index.row()]  # 해당 row의 도서 정보를 가져옴

    if role == Qt.DisplayRole:
        # QListView가 화면에 표시할 문자열 요청
        return f"{book['title']} by {book['author']} ({book['year']})"

    if role == Qt.EditRole:
        # Delegate가 QSpinBox에 설정할 값 요청
        return book["year"]

    return None  # 다른 role은 처리하지 않음python

Qt.DisplayRole
사실 Qt.ItemDataRole.DisplayRole의 alias임.
보다 짧은 표현(shortcut)이라 더 많이 애용됨.


3-3. .setData() 메서드:

View나 Delegate에서 데이터 변경을 요청할 때, 모델 내부 데이터를 갱신하는 메서드.

  • View가 편집 결과를 모델에 반영하고자 할 때 자동으로 호출됨.
  • 모델 클래스(QAbstractItemModel, QAbstractListModel)를 상속할 때 편집이 가능 하도록 하려면 반드시 override 해야 함.
  • 읽기 기능만 필요한 경우 굳이 구현할 필요 없음: 기본으로는 False만 반환하도록 구현됨.
def setData(
    self, 
    index: QModelIndex, 
    value: Any, 
    role: int = Qt.EditRole,
    ) -> bool:

 

보통 dataChanged signal 과 함께 사용되며 예제코드에서는 다음과 같이 작성됨

def setData(self, index, value, role=Qt.EditRole):
    if not index.isValid() or role != Qt.EditRole:
        return False

    # 내부 데이터 갱신
    self._books[index.row()]["year"] = int(value)

    # View에게 값이 바뀌었다고 알림
    self.dataChanged.emit(index, index, [Qt.DisplayRole, Qt.EditRole])
    return True
  • QSpinBox 로 년도가 수정되도록 Delegate를 사용하고 있는데,
  • 해당 delegate에서 모델로 값을 전달할 경우 이를 setData() 호출되어
  • 년도가 수정됨.

3-4. .flags(index) 메서드:

Qt의 모델에서 item(항목)의 기본적인 동작 가능 여부를 결정하는 메서드.

def flags(
    self, 
    index: QModelIndex,
    ) -> Qt.ItemFlags

 

View는 이 메서드가 반환하는 플래그(Flag) 정보를 바탕으로 해당 item 이

  • 선택 가능한지,
  • 편집 가능한지,
  • 드래그 가능한지 등을 결정

반환하는 Qt.ItemFlags의 주요 상수는 다음과 같음:

플래그 상수 (Qt.ItemFlag) 설명
Qt.ItemIsEnabled 항목이 활성화됨 (비활성화 상태가 아님)
Qt.ItemIsSelectable 항목을 마우스 또는 키보드로 선택할 수 있음
Qt.ItemIsEditable 항목이 편집 가능함 (예: 더블클릭 시 편집 위젯 표시됨)
Qt.ItemIsDragEnabled 항목을 드래그할 수 있음
Qt.ItemIsDropEnabled 항목 위에 다른 항목을 드롭할 수 있음
Qt.ItemIsUserCheckable 항목에 체크박스를 표시하고 체크할 수 있음
Qt.ItemIsTristate 체크박스가 3가지 상태 (체크됨/안됨/부분 체크) 가능
Qt.ItemNeverHasChildren 항목이 자식을 가질 수 없음 (트리뷰 전용)
Qt.ItemIsSelectable 항목이 선택될 수 있음
  • bitwise or 연산자(|) 로 조합하여 동시에 여러 속성을 부여할 수 있음

일반적인 flags 조합은 다음과 같음.

3-4-1. 기본 flags 조합

다음은 기본으로 구현된 flags 메서드임.

def flags(self, index):
    if not index.isValid():
        return Qt.NoItemFlasgs
    return Qt.ItemIsEnabled | Qt.ItemIsSelectable
  • index의 항목은 보이고 선택은 가능하지만 편집은 안 됨.

3-4-2. 편집 가능 flags 조합

def flags(self, index):
    return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable
  • View는 EditRole 에 따라 편집 위젯(예: QSpinBox, QLineEdit) 을 띄움.

다음은 특정 행만 편집 가능하게 처리하는 경우임.

def flags(self, index):
    if index.row() == 0:
        return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable
    return Qt.ItemIsEnabled | Qt.ItemIsSelectable

3-4-4. 예제에서 사용된 코드.

def flags(self, index):
    return Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsEditable

3-5. .beginInsertRows(parent, first, last) / .endInsertRows 메서드

항목이 추가될 때 View와 동기화 를 위해 사용하는 페어 메서드 (수동으로 명시적으로 알려주는 용도)

  • beginInsertRows: View에게 “지금부터 이 위치에 행이 삽입될 것이다” 라고 알림.
  • endInsertRows: 실제 데이터가 추가된 후, View에게 “추가가 끝났고 이제 화면을 새로 그려도 됨"을 알림.
beginInsertRows(
    parent: QModelIndex, 
    first: int, 
    last: int,
    ) -> None

endInsertRows() -> None
  • parent: 삽입될 항목의 부모 인덱스. list 관련 모델에선 QModelIndex()로 설정.
  • first: 삽입이 시작될 index.
  • last: 삽입이 끝날 index.

예제에서는 책을 추가할 때에 사용됨.

  • firstlast가 같을 경우, 한 row만 insert.
def add_book(self, title):
    pos = self.rowCount()  # 새 책을 추가할 위치는 리스트의 마지막 인덱스

    # View에 알림: "pos ~ pos 위치에 새 항목이 들어올 예정임"
    self.beginInsertRows(QModelIndex(), pos, pos)

    # 실제 데이터 추가
    self._books.append({"title": title})

    # View에 알림: "삽입 완료. 다시 갱신하면 됨"
    self.endInsertRows()

3-6. .beginRemoveRows(parent, first, last) / .endRemoveRows 메서드

항목이 삭제될 때 View와 동기화를 위해 사용하는 페어 메서드 (수동으로 명시적으로 알려주는 용도)

  • beginRemoveRows: View에게 “지금부터 이 위치에 행이 삭제될 것임” 라고 알림
  • endRemoveRows: 실제 데이터가 삭제된 후, View에게 “삭제가 끝났고 이제 화면을 새로 그려도 됨"을 알림
beginRemoveRows(
    parent: QModelIndex, 
    first: int, 
    last: int,
    ) -> None

endRemoveRows() -> None
  • parent: 삭제될 항목의 부모 인덱스. list 관련 모델에선 QModelIndex()로 설정.
  • first: 삭제가 시작될 index.
  • last: 삭제가 끝날 index.

예제에서는 책을 추가할 때에 사용됨.

  • firstlast가 같을 경우, 한 row만 insert.
def remove_book(self, row):
    # 유효한 행 번호인지 확인
    if 0 <= row < self.rowCount():
        # View에 행 삭제가 시작된다고 알림
        self.beginRemoveRows(QModelIndex(), row, row)

        # 내부 데이터 리스트에서 해당 항목 삭제
        del self._books[row]

        # View에 행 삭제가 완료되었다고 알림
        self.endRemoveRows()

3-7. .beginResetModel()/.endResetModel() 메서드

모델의 전체 데이터가 변경되거나 대규모 수정이 발생할 경우
View에 “모델 전체가 리셋된다”는 사실을 알리기 위한 페어 메서드

  • beginResetModel: View에게 “지금부터 모델이 완전히 바뀔 것”이라고 알림
  • endResetModel: 데이터 교체가 완료되었고 View가 다시 표시를 갱신해도 된다고 알림

signature는 다음과 같음:

beginResetModel() -> None
endResetModel() -> None

 

예제에서 사용된 code는 다음과 같음.

def reset_books(self):
    # View에 전체 데이터 리셋이 시작됨을 알림
    self.beginResetModel()

    # 내부 데이터를 초기화 또는 완전히 교체
    self._books = []

    # View에 리셋 완료됨을 알림 → 전체 다시 그려짐
    self.endResetModel()

3-8.  dataChanged(topLeft, bottomRight, roles=[]) (signal)

모델의 데이터 값 자체가 변경되었을 때,
View에게 해당 항목 범위를 다시 업데이트하라고 알리는 시그널.

모델의 shape가 바뀌는 추가나 삭제의 경우를 제외
변경에 emit()시켜야 함.

  • dataChanged:
    • 모델 데이터가 바뀌었음을 View에게 알리고,
    • View는 지정된 범위의 항목에 대해 다시 data()를 호출함
  • 데이터의 item의 갯수가 변한 경우엔 사용하면 안 됨: 모델의 shape가 변경된 경우이므로.

signature는 다음과 같음:

dataChanged(
	topLeft: QModelIndex, 
    bottomRight: QModelIndex, 
    roles: list[int] = []
    ) -> None
  • topLeft : 변경된 범위의 시작 idx
  • bottomRight: 변경된 범위의 끝 idx.
    • 보통 topLeft와 같이 사용하여 하나만 지정.
  • roles: 변경된 역할 목록 [Qt.DisplayRole]등.
    • 복수의 role을 지정하는 방식은 bitwise or 연산으로 합친 하나의 flag 값이 아니라,
    • list로 여러 개의 role을 각각 명시 **

주의할 점.

  • topLeft == bottomRight 이면 single item 또는 single row 만 변경된 경우
  • roles 인자를 생략하면 View는 모든 role(DisplayRole, EditRole 등등)에 대해 새로 요청함.
  • View는 이 범위에 해당하는 인덱스에 대해 모델의 data()를 다시 호출하여 UI를 갱신함.

다음은 예제 코드에서 사용한 부분임.

def update_book(self, row, title=None, author=None):
    # 유효한 행 인덱스인지 확인
    if 0 <= row < self.rowCount():
        # title이 주어졌다면 해당 행의 제목을 수정
        if title:
            self._books[row]["title"] = title

        # author가 주어졌다면 해당 행의 저자 정보를 수정
        if author:
            self._books[row]["author"] = author

        # View에 알림: row 번째 항목의 데이터가 변경되었음을 시그널로 전달
        # topLeft = bottomRight = 해당 인덱스 (단일 항목 변경)
        index = self.index(row)
        self.dataChanged.emit(index, index)

3-9. layoutAboutToBeChanged / layoutChanged (signal)

사실상 만능 signal로 모든 변경에 emit() 할 경우, view가 변경이 반영됨.

 

권장되는 경우는

  • 모델의 데이터 수(row/column 수)는 그대로지만,
  • 행의 순서가 바뀌거나 정렬되는 경우, View에게 레이아웃이 바뀔 것임을 알려주는 시그널(signal) 페어

즉, 추가나 삭제의 경우는 앞서의 begin/end 페어 메서드들을 사용하는 것이 성능에는 좋음.

 

하지만,

  • 간단한 시제품 등에서는 이 signal 중 layoutChanged 만 활용해도 충분하고 편리함.
  • 단, 복잡한 데이터나 어플리케이션에선 성능 저하를 보일 수 있음.

 

앞서의 pair methods가 반드시 짝을 이루어 호출되어야 하는 것과 달리 layoutChanged 시그널만 emit해도 충분함.

  • layoutAboutToBeChanged: View에게 “곧 레이아웃이 바뀔 것이다”라고 알림
  • layoutChanged: View에게 “레이아웃 변경이 완료되었고, 다시 갱신해도 된다”라고 알림

두 시그널 모두 직접 emit() 호출
사용해야 함

 

self.layoutAboutToBeChanged.emit()
# 데이터 재정렬 등
self.layoutChanged.emit()

 

Qt문서의 권장에도

  • item의 삭제나 추가에는 전용 함수(begin/end)를 통해 동기화하고,
  • 이 signal은 재정렬에 쓰라고 하지만,
  • 실제로 많은 시작품에서는 추가/삭제 의 경우에도 사용함.

layoutChanged()는 단독 사용 및 item 추가/삭제에서도 동작하는 이유는 다음과 같이 동작하기 때문임:

  1. View는 layoutChanged.emit()이 발생하면:
  2. 내부 인덱스 매핑을 모두 초기화
  3. 모든 항목에 대해 새로 index() → data() 호출

즉, 변화의 내용( 삽입 / 삭제 등)은 View가 관심 없고,
그저 “전체가 바뀌었으니 다 무효화하고 다시 확인할게”라는 리셋 성격의 시그널임.

 

참고로 layoutAboutToBeChanged 는 View 또는 Delegate가 내부에 상태를 유지하거나
현재 선택 상태, 포커스, 스크롤 위치 등을 사전에 보존하고 싶을 때 사용됨.

 

다음은 예제에서 사용된 코드임.

def sort_books(self):
    # View에 레이아웃이 곧 변경될 것임을 알림 (시그널 emit)
    self.layoutAboutToBeChanged.emit()

    # 내부 데이터 정렬 (row 순서 변경)
    self._books.sort(key=lambda book: book["title"])

    # View에 레이아웃 변경이 완료되었음을 알림 (시그널 emit)
    self.layoutChanged.emit()

4. QListView의 역할과 활용

QListView는 :

  • QAbstractItemView를 기반으로 한 클래스이며,
  • 모델(Model)로부터 데이터를 받아 list 형태로 화면에 출력.

상속구조는 다음과 같음

QObject
    ↑ 
QWidget
    ↑ 
QFrame
    ↑ 
QAbstractScrollArea
    ↑ 
QAbstractItemView 
    ↑ 
QListView

QListView는 override하지 않고
사용자 코드에서 인스턴스 생성 후 메서드를 호출
하여 사용하는 것이 일반적.


4-1. 주요 메서드 및 시그널

Method/Signal Desc. 호출 방식
setModel(model) 뷰에 표시할 모델 객체를 설정 호출
setItemDelegate(delegate) 항목을 표현하거나 편집할 때 사용할 delegate 설정 호출
setEditTriggers(flags) 어떤 사용자 동작이 편집을 트리거할지 설정 (DoubleClicked 등) 호출
currentIndex() 현재 선택된 항목의 QModelIndex 반환 호출
edit(index) 지정된 인덱스를 편집 상태로 전환 호출
clicked 항목이 클릭되었을 때 발생하는 시그널 (QModelIndex 전달됨) 시그널
doubleClicked 항목이 더블클릭되었을 때 발생하는 시그널 시그널
setSelectionMode(mode) 항목 선택 방식을 설정 (SingleSelection, MultiSelection 등 가능) 호출
  • setModel()QAbstractListModel 객체와 연결
  • setItemDelegate()QStyledItemDelegate 객체와 연결
  • setEditTriggers()를 통해 어떤 동작이 편집을 유발할지 설정 가능

4-2. code snippet

# View 생성 및 설정
self.view = QListView()
self.view.setModel(self.model)  # 모델 연결
self.view.setItemDelegate(YearSpinBoxDelegate())  # delegate 설정
self.view.setEditTriggers(QAbstractItemView.DoubleClicked)  # 더블클릭 시 편집 허용

 

위의 코드의 Double Click(더블클릭)을 통한 setEditTriggers 방식은 자동 트리거이며,

다음과 같이 signals and slots을 통해 직접 수동 호출방식으로 구현할 수도 있음

self.view.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.view.doubleClicked.connect(self.view.edit)  # 더블클릭 시 명시적 편집 시작

 

QListView에서 더블클릭을 통한 모델 업데이트의 내부 호출 흐름은 다음과 같음

[사용자 더블클릭]
       ↓
[편집 시작]
  ├─ (자동 트리거: setEditTriggers)
  └─ (수동 호출: doubleClicked → edit(index))
       ↓
Delegate.createEditor() → 편집기 위젯 생성 (QSpinBox 등)
       ↓
Delegate.setEditorData() → 기존 데이터를 위젯에 설정
       ↓
[사용자 값 입력 후 포커스 아웃 등으로 편집 완료]
       ↓
Delegate.setModelData() → 위젯에서 값 가져옴
       ↓
model.setData() → 모델 데이터 갱신
       ↓
model.dataChanged.emit() → View에 변경 알림
       ↓
View가 해당 인덱스에 대해 다시 data() 호출 → 화면 갱신
  • QListView, QTableView, QTreeView에 대해
  • 기본 Delegate 객체로 QStyledItemDelegate 인스턴스를 자동으로 설정.
  • 이 경우 QLineEdit 가 문자열 입력으로 사용됨

5. Delegate의 역할과 활용

Delegate는 Qt 모델-뷰 구조에서 항목의 표현 및 편집 방식을 사용자 정의할 수 있도록 도와주는 구성 요소임.

  • View는 Delegate를 통해 항목을 어떻게 표시할지, 편집할지를 위임(delegate).
  • View와 Model 사이에서 사용자와의 데이터 편집을 연결함.

View객체의 setItemDelegate(custom_delegate)메서드를 통해
특정 view와 연결됨.

 

Qt에서는 QStyledItemDelegate가 기본 제공되며, 사용자 정의 위젯을 통한 입력이나 렌더링이 필요할 경우 이 QStyledItemDelegate를 상속하여 확장함.

  • 사용자 정의 Delegate를 통해 보다 직관적이고 제약 있는 사용자 입력 인터페이스를 구성할 수 있음.
  • 숫자, 날짜, 색상 등 비문자 기반 입력 처리에서 Custom Delegate는 중요한 역할을 함.

Delegate 없어도 되는가?

  • Delegate를 따로 설정하지 않으면 Qt는 기본적으로 QStyledItemDelegate를 사용
  • 이 경우, 편집기는 QLineEdit(문자 입력기) 로 자동 지정됨
  • 복잡한 입력 제약 조건이 필요하거나 UI 컨트롤을 커스터마이즈하려면 명시적 Delegate 설정이 권장됨.

5-1. 주요 메서드 동작 흐름 (Delegate)

다음의 순서는 사용자가 항목을 편집할 때 Qt가 Delegate를 통해 호출하는 표준 동작 흐름임

method Desc. 호출 시점
createEditor(parent, option, index) 편집기 위젯을 생성
(QSpinBox, QLineEdit, QComboBox 등)
사용자가 항목을
편집 시작할 때
setEditorData(editor, index) 모델 데이터를 읽어 편집기 초기화 편집기 생성 직후
setModelData(editor, model, index) 편집기 데이터를 모델에 저장
(→ model.setData() 호출)
사용자가 편집을 마치고
저장할 때
updateEditorGeometry(editor, option, index) 편집기 위젯의 위치와 크기 설정 View가 편집기 배치할 때

 


5-1-1. .createEditor(self, parent, option, index) 메서드

항목을 편집할 때 사용할 실제 편집기 위젯(예: QSpinBox, QLineEdit 등) 을 생성하는 메서드.

Arguemnt Type Desc
parent QWidget 생성할 편집기의 부모 위젯. 보통 View (QListView, QTableView 등)
option QStyleOptionViewItem 항목의 시각적 상태 및 스타일 정보 (선택 여부, 포커스 등 포함)
index QModelIndex 편집하려는 항목의 인덱스. 어떤 항목인지 구분하는 데 사용됨

 


5-1-2. .setEditorData(self, editor, index) 메서드

모델의 데이터를 읽어와 편집기에 초기값으로 설정하는 메서드.

Arguemnt Type Desc
editor QWidget createEditor()에서 생성된 편집기 위젯
index QModelIndex 모델에서 데이터를 가져올 인덱스 (EditRole 역할로 접근)

 


5-1-3. .setModelData(self, editor, model, index) 메서드

편집기에서 입력된 데이터를 모델로 저장하는 메서드.

Arguemnt Type Desc
editor QWidget 사용자 입력이 들어있는 편집기 위젯
model QAbstractItemModel 데이터를 저장할 모델 객체
index QModelIndex 데이터를 저장할 대상 인덱스 위치

 


5-1-4. .updateEditorGeometry(self, editor, option, index) 메서드

편집기 위젯의 위치와 크기를 설정하는 메서드.

Arguemnt Type Desc
editor QWidget 위치와 크기를 지정할 편집기 위젯
option QStyleOptionViewItem 셀의 사각형 영역 정보 포함 (option.rect)
index QModelIndex 편집 대상의 모델 인덱스 (위치 조정 시 필요한 경우 사용)

 


5-2. QStyleOptionViewItem

QStyleOptionViewItem

  • PySide6에서 View (QListView, QTableView) 항목(item)의 시각적 상태와 레이아웃, 스타일 정보를 담는 객체 임.
  • delegate에서 넘겨져서, View의 정보에 접근하는데 사용됨.
    • delegate.paint() 또는
    • delegate.updateEditorGeometry() 같은 메서드에서 사용되며,
    • View가 그리거나 편집기를 배치할 때 시각적 힌트를 제공

5-2-1. 주요 Attribute 목록

Attribute Name Type Desc.
rect QRect 항목의 표시 영역
(위치와 크기)
state QStyle.State (아래 참고) 항목의 상태 플래그
(선택, 포커스 등)
font QFont 항목 텍스트에 사용할 폰트
palette QPalette 항목의 전경/배경 색상 정보
displayAlignment Qt.Alignment 텍스트 정렬 방식
(ex: Qt.AlignCenter)
features QStyleOptionViewItem.ViewItemFeatures 항목의 기능 플래그
(체크박스 표시 등)
viewItemPosition QStyleOptionViewItem.Position 항목이 리스트 내 어디에 위치하는지
(Beginning, Middle, End)
decorationPosition QStyleOptionViewItem.Position 아이콘(Decoration)의 위치
decorationAlignment Qt.Alignment 아이콘 정렬 방식
checkState Qt.CheckState 체크박스 상태
(예: Qt.Checked)

index 속성은 Qt 6에서만 제공되며,
PySide6 (Qt 6.x 이상)에서만 사용 가능.

 


5-2-2. 예제 코드

def updateEditorGeometry(self, editor, option, index):
    # 항목의 rect 정보를 기반으로 편집기의 위치와 크기 설정
    editor.setGeometry(option.rect)

def paint(self, painter, option, index):
    # 항목이 선택되었을 경우 배경색 처리
    if option.state & QStyle.State_Selected: # 선택되었는지 flag bit로 확인.
        painter.fillRect(option.rect, option.palette.highlight())

    # 텍스트 정렬 정보 사용
    alignment = option.displayAlignment or Qt.AlignLeft #왼쪽 정렬 추가
    text = index.data(Qt.DisplayRole)
    painter.drawText(option.rect, alignment, text)

5-2-3. state 값들.

QStyle.State는 View 항목의 시각적 상태를 나타내는 플래그(enum) 로,
QStyleOptionViewItem.state에서 bitwise OR 로 결합된 형태로 전달됨.

 

플래그 이름 Desc.
QStyle.State_None 아무 상태도 없음 (기본값)
QStyle.State_Enabled 항목이 활성화되어 있음 (비활성 상태가 아님)
QStyle.State_Raised 마우스가 올라가 있는 상태 (플랫 버튼 등에서 사용)
QStyle.State_Sunken 눌린 상태로 표현됨
QStyle.State_Off 체크박스, 라디오 버튼이 해제된 상태
QStyle.State_NoChange 체크 상태 없음 (혼합 상태 등)
QStyle.State_On 체크된 상태
QStyle.State_DownArrow 아래 방향 화살표 표시용 (콤보박스 등)
QStyle.State_UpArrow 위 방향 화살표 표시용
QStyle.State_Horizontal 수평 방향 상태 표시 (슬라이더 등)
QStyle.State_HasFocus 포커스를 가지고 있음
QStyle.State_Top View 항목 중 상단 항목 (스타일 계산용)
QStyle.State_Bottom View 항목 중 하단 항목
QStyle.State_Selected 항목이 선택된 상태
QStyle.State_Active 현재 활성화된 위젯 내 항목
QStyle.State_Window 현재 표시되는 윈도우의 항목
QStyle.State_Open 트리뷰 항목이 확장(open)된 상태
QStyle.State_MouseOver 마우스가 항목 위에 올라간 상태

여러 상태는 bitwise OR 연산으로 결합됨.
예: QStyle.State_Enabled | QStyle.State_Selected | QStyle.State_HasFocus

 

 

다음은 선택 항목 강조 예제 코드임.

from PySide6.QtWidgets import QStyledItemDelegate
from PySide6.QtGui import QPainter
from PySide6.QtCore import Qt

class HighlightSelectedDelegate(QStyledItemDelegate):
    def paint(self, painter, option, index):
        # 선택된 항목이면 파란색 배경을 그림
        if option.state & QStyle.State_Selected: # 해당 항목이 선택되어 있음을 확인
            painter.fillRect(option.rect, option.palette.highlight())

        # 텍스트 정렬 정보 확인하여
        # option.displayAlignment에 정렬 정보가 이미 있으면 그것을 사용하고,
        # 없다면 기본적으로 왼쪽 정렬(Qt.AlignLeft)을 사용.
        alignment = option.displayAlignment or Qt.AlignLeft
        text = index.data(Qt.DisplayRole)
        # 텍스트 출력
        painter.drawText(option.rect, alignment, text)

5-3. 예제 코드에서의 구현:

class YearSpinBoxDelegate(QStyledItemDelegate):

    # 사용자가 항목을 편집할 때 호출되어, 실제로 사용할 편집기 위젯을 생성하는 메서드
    def createEditor(self, parent, option, index):
        # QSpinBox를 생성하여 연도 입력 전용으로 사용
        editor = QSpinBox(parent)
        # 최소 연도 설정 (예: 1000년부터)
        editor.setMinimum(1000)
        # 최대 연도 설정 (예: 9999년까지)
        editor.setMaximum(9999)
        return editor  # 생성한 위젯을 View에 반환

    # 모델로부터 값을 읽어와 편집기에 반영하는 메서드 (편집 시작 시 자동 호출됨)
    def setEditorData(self, editor, index):
        # 모델에서 현재 편집 대상 셀의 EditRole 데이터를 가져옴
        year = index.model().data(index, Qt.EditRole)
        # 가져온 값을 QSpinBox 위젯에 설정
        editor.setValue(int(year))

    # 편집기에서 수정된 데이터를 모델에 저장하는 메서드 (편집 종료 시 호출됨)
    def setModelData(self, editor, model, index):
        # QSpinBox에서 입력된 값을 읽어와서
        value = editor.value()
        # 모델에 EditRole로 설정 → 내부적으로 model.setData() 호출됨
        model.setData(index, value, Qt.EditRole)

    # 편집기가 화면에 정확히 위치하도록 위치/크기를 설정하는 메서드
    def updateEditorGeometry(self, editor, option, index):
        # View가 제공한 사각형(rect)에 맞춰 편집기의 위치와 크기를 설정
        editor.setGeometry(option.rect)
  • QSpinBox를 사용하여 출판연도를 int(정수)로만 입력하도록 강제
  • setEditorData()setModelData()를 통해 모델과 편집기 간의 데이터 동기화 수행

6. 예제 코드: 도서 목록 앱

# Qt의 핵심(Core) 기능과 모델/뷰 관련 클래스 임포트
from PySide6.QtCore import Qt, QAbstractListModel, QModelIndex
# 위젯 관련 클래스 임포트
from PySide6.QtWidgets import (
    QApplication, QWidget, QVBoxLayout, QHBoxLayout,
    QListView, QPushButton, QLineEdit, QLabel, QStyledItemDelegate,
    QSpinBox, QAbstractItemView
)
import sys

# 모델 클래스 정의: 도서 리스트를 관리
class BookModel(QAbstractListModel):
    def __init__(self, books=None):
        super().__init__()
        self._books = books or []  # 도서 정보 리스트

    def rowCount(self, parent=QModelIndex()):
        return len(self._books)  # 전체 도서 수 반환

    def data(self, index, role=Qt.DisplayRole):
        if not index.isValid():
            return None
        book = self._books[index.row()]  # 해당 행의 도서 정보
        if role == Qt.DisplayRole:
            # 리스트 뷰에 표시될 문자열 구성
            return f"{book['title']} by {book['author']} ({book['year']})"
        if role == Qt.EditRole:
            # 편집 모드에서 사용할 출판연도 값
            return book["year"]
        return None

    def flags(self, index):
        # 항목이 선택 가능하고, 활성화되며, 편집 가능하도록 설정
        return Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsEditable

    def setData(self, index, value, role=Qt.EditRole):
        if role == Qt.EditRole:
            # 사용자가 수정한 연도 값을 모델에 반영
            self._books[index.row()]["year"] = int(value)
            self.dataChanged.emit(index, index)  # 변경 알림
            return True
        return False

    def add_book(self, title, author, year):
        # 새로운 도서 추가
        pos = self.rowCount()
        self.beginInsertRows(QModelIndex(), pos, pos)
        self._books.append({"title": title, "author": author, "year": year})
        self.endInsertRows()

    def remove_book(self, row):
        # 선택된 도서 삭제
        if 0 <= row < self.rowCount():
            self.beginRemoveRows(QModelIndex(), row, row)
            del self._books[row]
            self.endRemoveRows()

    def update_book(self, row, title=None, author=None):
        # 제목이나 저자 정보 수정
        if 0 <= row < self.rowCount():
            if title:
                self._books[row]["title"] = title
            if author:
                self._books[row]["author"] = author
            index = self.index(row)
            self.dataChanged.emit(index, index)  # UI에 반영되도록 알림

    def reset_books(self):
        # 도서 리스트 초기화
        self.beginResetModel()
        self._books = []
        self.endResetModel()

# 출판연도 항목에 대한 Delegate 정의 (편집용 위젯으로 QSpinBox 사용)
class YearSpinBoxDelegate(QStyledItemDelegate):
    def createEditor(self, parent, option, index):
        editor = QSpinBox(parent)
        editor.setMinimum(1000)
        editor.setMaximum(9999)
        return editor

    def setEditorData(self, editor, index):
        year = index.model().data(index, Qt.EditRole)
        editor.setValue(int(year))  # 모델의 값을 편집 위젯에 설정

    def setModelData(self, editor, model, index):
        model.setData(index, editor.value(), Qt.EditRole)  # 위젯 값을 모델에 반영

    def updateEditorGeometry(self, editor, option, index):
        editor.setGeometry(option.rect)  # 편집 위젯 위치 지정

# 메인 어플리케이션 클래스 정의
class BookApp(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Qt Model-View + Delegate + CRUD + Reset")
        self.resize(600, 500)

        # 초기 도서 목록 세팅
        self.model = BookModel([
            {"title": "1984", "author": "George Orwell", "year": 1949},
            {"title": "Dune", "author": "Frank Herbert", "year": 1965}
        ])

        # QListView 생성 및 설정
        self.view = QListView()
        self.view.setModel(self.model)  # 모델 연결
        self.view.setItemDelegate(YearSpinBoxDelegate())  # 출판연도 편집을 위한 Delegate 설정
        self.view.setEditTriggers(QAbstractItemView.DoubleClicked)  # 더블클릭으로 편집 모드 진입

        # 입력 필드: 제목, 저자, 출판연도
        self.title_input = QLineEdit()
        self.author_input = QLineEdit()
        self.year_input = QLineEdit()
        for w, p in zip(
            [self.title_input, self.author_input, self.year_input],
            ["제목", "저자", "출판연도"]
        ):
            w.setPlaceholderText(p)

        # 버튼들 생성
        self.add_btn = QPushButton("도서 추가")
        self.update_btn = QPushButton("선택 항목 수정")
        self.delete_btn = QPushButton("선택 항목 삭제")
        self.reset_btn = QPushButton("초기화")

        # 입력 필드 수평 레이아웃
        input_layout = QHBoxLayout()
        input_layout.addWidget(self.title_input)
        input_layout.addWidget(self.author_input)
        input_layout.addWidget(self.year_input)

        # 버튼 수평 레이아웃
        btn_layout = QHBoxLayout()
        btn_layout.addWidget(self.add_btn)
        btn_layout.addWidget(self.update_btn)
        btn_layout.addWidget(self.delete_btn)
        btn_layout.addWidget(self.reset_btn)

        # 전체 수직 레이아웃
        layout = QVBoxLayout(self)
        layout.addWidget(QLabel("📚 도서 목록 - 출판연도는 더블클릭으로 편집"))
        layout.addWidget(self.view)
        layout.addLayout(input_layout)
        layout.addLayout(btn_layout)

        # 버튼에 기능 연결 (시그널-슬롯 연결)
        self.add_btn.clicked.connect(self.add_book)
        self.update_btn.clicked.connect(self.update_book)
        self.delete_btn.clicked.connect(self.delete_book)
        self.reset_btn.clicked.connect(self.reset_books)

    # 입력 필드 값 가져오기
    def get_inputs(self):
        return self.title_input.text(), self.author_input.text(), self.year_input.text()

    # 입력 필드 초기화
    def clear_inputs(self):
        self.title_input.clear()
        self.author_input.clear()
        self.year_input.clear()

    # 도서 추가 버튼 동작
    def add_book(self):
        title, author, year = self.get_inputs()
        if title and author and year.isdigit():
            self.model.add_book(title, author, int(year))
            self.clear_inputs()

    # 선택 항목 수정 버튼 동작
    def update_book(self):
        index = self.view.currentIndex()
        if index.isValid():
            title, author, _ = self.get_inputs()
            self.model.update_book(index.row(), title, author)
            self.clear_inputs()

    # 선택 항목 삭제 버튼 동작
    def delete_book(self):
        index = self.view.currentIndex()
        if index.isValid():
            self.model.remove_book(index.row())

    # 전체 초기화 버튼 동작
    def reset_books(self):
        self.model.reset_books()
        self.clear_inputs()

# 프로그램 실행
if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = BookApp()
    window.show()
    sys.exit(app.exec())
  • 파일에 저장하고 시작시 로드하는 기능 추가 필요.
728x90