PySide6로 데스크톱 앱 만들기 #5 모델과 뷰 — 리스트·테이블에 데이터 연결
지금까지 할 일 목록은 QListWidget에 문자열을 addItem으로 직접 넣었습니다. 이 방식은 시작이 쉽지만 곧 벽을 만납니다. 할 일에 마감일과 완료 여부를 함께 담으려는 순간, 데이터가 위젯 안에 갇혀 있다는 사실이 문제가 됩니다. 같은 데이터를 다른 화면에도 보여 주려면 복사해서 두 번 넣어야 하고, 정렬이나 검색을 하려면 위젯 항목을 직접 뒤져야 합니다. Qt는 이 문제를 모델과 뷰의 분리로 풉니다.
총 7편으로 구성됩니다.
- #1 PySide6란 — Qt와 파이썬으로 데스크톱 앱
- #2 위젯과 레이아웃 — 화면을 조립하는 법
- #3 시그널과 슬롯 — 이벤트 처리의 핵심
- #4 Qt Designer와 UI 파일 — 화면을 그려서 불러오기
- #5 모델과 뷰 — 리스트·테이블에 데이터 연결 ← 이번 글
- #6 스레드와 타이머 — 멈추지 않는 UI
- #7 패키징과 배포 — PyInstaller로 실행 파일 만들기
이번 글은 모델/뷰 구조의 개념을 잡고, QAbstractTableModel을 상속해 할 일 테이블 모델을 직접 구현한 뒤, 프록시 모델로 정렬과 검색 필터까지 붙이겠습니다.
모델/뷰 구조 — 데이터와 표시의 분리 #
Qt의 모델/뷰는 널리 알려진 MVC 패턴의 Qt식 변형입니다. 역할은 두 축으로 나뉩니다.
- 모델 — 데이터를 보관하고, “몇 행 몇 열인가”, “이 칸의 값은 무엇인가"라는 질문에 답합니다. 화면에 대해서는 아무것도 모릅니다.
- 뷰 — 모델에 질문을 던져 받은 답을 그립니다. 데이터를 소유하지 않습니다.
이 분리가 주는 이득은 명확합니다. 같은 모델을 리스트 뷰와 테이블 뷰에 동시에 연결해도 데이터는 한 벌이고, 모델의 데이터가 바뀌면 연결된 모든 뷰가 함께 갱신됩니다. 정렬과 필터도 데이터 원본을 건드리지 않고 중간 계층에서 처리할 수 있습니다.
QListWidget처럼 이름이 Widget으로 끝나는 항목형 위젯들은 사실 모델과 뷰를 한 몸에 붙여 둔 간편형입니다. 시작은 쉽지만 데이터가 위젯에 갇히는 구조라, 데이터에 구조가 생기는 순간 모델/뷰로 넘어가는 것이 Qt의 표준 경로입니다.
가장 작은 모델/뷰 — QStringListModel #
상속 없이 바로 쓸 수 있는 기성 모델부터 확인합니다. 문자열 목록에는 QStringListModel이 있습니다.
from PySide6.QtCore import QStringListModel
from PySide6.QtWidgets import QListView
model = QStringListModel(["우유 사기", "PySide6 글 읽기"])
view = QListView()
view.setModel(model)QListWidget과 화면은 같지만 구조가 다릅니다. 데이터는 모델이 들고 있고, 뷰는 setModel로 연결만 했습니다. model.setStringList로 데이터를 바꾸면 뷰가 즉시 따라옵니다. 그런데 할 일에는 제목 외에 마감일과 완료 여부도 필요하므로, 문자열 목록으로는 부족합니다. 열이 여러 개인 테이블 모델을 직접 만들 차례입니다.
QAbstractTableModel 상속 — 할 일 테이블 모델 #
표 형태의 커스텀 모델은 QAbstractTableModel을 상속하고 최소 세 개의 메서드에 답하면 됩니다. 몇 행인지(rowCount), 몇 열인지(columnCount), 각 칸의 값이 무엇인지(data)입니다.
from PySide6.QtCore import Qt, QAbstractTableModel, QModelIndex
class TodoModel(QAbstractTableModel):
HEADERS = ["제목", "마감", "상태"]
def __init__(self, todos=None):
super().__init__()
self._todos = todos or []
def rowCount(self, parent=QModelIndex()):
return len(self._todos)
def columnCount(self, parent=QModelIndex()):
return len(self.HEADERS)
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return None
todo = self._todos[index.row()]
if role == Qt.ItemDataRole.DisplayRole:
if index.column() == 0:
return todo["title"]
if index.column() == 1:
return todo["due"]
if index.column() == 2:
return "완료" if todo["done"] else "진행 중"
return None
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
return self.HEADERS[section]
return Nonedata의 두 번째 인자인 role이 모델/뷰 이해의 관문입니다. 뷰는 한 칸을 그릴 때 여러 번 질문합니다. 표시할 글자는 무엇인지(DisplayRole), 배경색은 무엇인지(BackgroundRole), 정렬은 어느 쪽인지(TextAlignmentRole)를 각각 물어 오고, 모델은 답하고 싶은 role에만 값을 반환하면 됩니다. 위 구현은 표시 글자에만 답하고 나머지는 None을 반환해 뷰의 기본 동작에 맡겼습니다.
뷰 연결은 리스트 때와 같습니다.
from PySide6.QtWidgets import QTableView
model = TodoModel([
{"title": "우유 사기", "due": "2026-08-10", "done": False},
{"title": "원고 보내기", "due": "2026-08-12", "done": True},
])
view = QTableView()
view.setModel(model)데이터 변경 통지 — 모델이 뷰를 부른다 #
커스텀 모델에서 가장 자주 하는 실수는 내부 리스트만 고치고 끝내는 것입니다. self._todos.append(...)만 실행하면 데이터는 늘었는데 뷰는 모릅니다. 모델은 변경 전후에 정해진 통지 메서드를 호출해야 하고, 그래야 연결된 모든 뷰가 갱신됩니다.
def add_todo(self, title, due):
row = len(self._todos)
self.beginInsertRows(QModelIndex(), row, row)
self._todos.append({"title": title, "due": due, "done": False})
self.endInsertRows()
def toggle_done(self, row):
self._todos[row]["done"] = not self._todos[row]["done"]
index = self.index(row, 2)
self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole])행을 추가할 때는 beginInsertRows와 endInsertRows로 감싸고, 기존 칸의 값이 바뀌면 dataChanged 시그널로 바뀐 범위를 알립니다. 규칙은 하나로 요약됩니다. 데이터를 고치는 코드는 반드시 모델의 메서드 안에 있어야 하고, 그 메서드가 통지까지 책임집니다. 바깥 코드가 _todos를 직접 만지기 시작하면 화면과 데이터가 어긋나는 버그로 이어집니다.
정렬과 검색 — QSortFilterProxyModel #
정렬과 필터를 위해 모델을 고칠 필요는 없습니다. Qt는 모델과 뷰 사이에 끼워 넣는 중간 모델을 제공합니다.
from PySide6.QtCore import QSortFilterProxyModel
from PySide6.QtWidgets import QLineEdit
proxy = QSortFilterProxyModel()
proxy.setSourceModel(model)
proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
proxy.setFilterKeyColumn(0) # 제목 열 기준으로 필터
view.setModel(proxy) # 뷰에는 프록시를 연결
view.setSortingEnabled(True) # 헤더 클릭으로 정렬
search = QLineEdit(placeholderText="검색")
search.textChanged.connect(proxy.setFilterFixedString)구조가 핵심입니다. 뷰가 보는 것은 원본 모델이 아니라 프록시이고, 프록시는 원본을 정렬하거나 걸러낸 결과만 뷰에 보여 줍니다. 원본 데이터의 순서는 그대로입니다. 검색 입력창의 textChanged 시그널을 프록시의 setFilterFixedString 슬롯에 바로 이었으므로, 타이핑할 때마다 테이블이 실시간으로 걸러집니다. #3에서 정리한 시그널·슬롯 연결이 기성 슬롯과 만나면 이렇게 코드 한 줄이 됩니다.
선택 처리 — 어느 행을 골랐는가 #
사용자가 고른 행을 알아내는 창구는 뷰의 selectionModel입니다.
view.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
def on_row_changed(current, previous):
source_index = proxy.mapToSource(current) # 프록시 → 원본 좌표
print("선택된 행:", source_index.row())
view.selectionModel().currentRowChanged.connect(on_row_changed)주의할 점이 하나 있습니다. 뷰가 프록시에 연결되어 있으므로 선택 인덱스도 프록시 기준 좌표입니다. 정렬이나 필터가 걸린 상태에서는 화면의 3번째 행이 원본의 3번째 행이 아닙니다. 원본 데이터를 수정하려면 mapToSource로 좌표를 원본 기준으로 변환한 뒤 사용해야 합니다. 프록시를 쓰는 앱에서 가장 흔한 버그가 이 변환 누락입니다.
setData와 flags 메서드를 추가로 구현합니다. 이번 글의 범위를 넘어서지만, “뷰는 묻고 모델이 답한다"는 같은 원리로 편집 요청도 모델에 위임된다는 점만 기억해 두면 필요할 때 어렵지 않게 확장할 수 있습니다.마무리 #
이번 글의 핵심은 네 가지입니다.
- 모델은 데이터와 질문 응답을, 뷰는 그리기를 맡습니다. Widget형 간편 위젯은 이 둘을 붙여 둔 형태라 데이터에 구조가 생기면 모델/뷰로 넘어갑니다.
- 커스텀 테이블 모델은
QAbstractTableModel을 상속해rowCount,columnCount,data에 답하면 되고, role 별로 답하고 싶은 것에만 답합니다. - 데이터 변경은 반드시 모델 메서드 안에서 통지(
beginInsertRows,dataChanged)와 함께 이뤄져야 뷰가 따라옵니다. - 정렬과 검색은
QSortFilterProxyModel을 끼워 해결하고, 선택 좌표는mapToSource로 원본 기준으로 변환해 사용합니다.
테이블에 데이터가 늘어나면 파일 저장이나 네트워크 동기화처럼 시간이 걸리는 작업이 따라옵니다. 이런 작업을 버튼 클릭 핸들러에서 그대로 실행하면 창 전체가 얼어붙습니다. 다음 글인 “PySide6로 데스크톱 앱 만들기 #6 스레드와 타이머 — 멈추지 않는 UI"에서 이 문제를 다루겠습니다.