PySide6 실전 강좌 #3 오늘 화면 — 커스텀 모델과 체크 토글
데이터 층이 생겼으니 이제 사용자가 만나는 첫 화면, “오늘” 탭을 만듭니다. 습관 목록이 보이고, 클릭으로 오늘의 체크를 켜고 끄는 화면입니다. 입문 #5에서 모델/뷰의 개념을 다뤘다면, 이번에는 그 지식이 실제 앱에서 어떤 모양이 되는지를 봅니다.
왜 QListWidget으로 안 하는가 #
가장 빠른 길은 QListWidget에 아이템을 직접 채우는 것입니다. 실제로 프로토타입이라면 그것이 맞습니다. 그런데 이 앱은 같은 데이터가 화면 두 곳(오늘 목록, 통계)에서 쓰이고, 체크 상태가 DB와 동기화되어야 하고, 7편에서 화면 없이 테스트도 해야 합니다. 위젯에 데이터를 직접 넣는 방식은 이 요구들에서 전부 삐걱거립니다. 데이터의 진실이 위젯 안에 갇히기 때문입니다.
모델/뷰의 답은 분리입니다. 데이터의 진실은 모델이 들고, 뷰는 그리기만 합니다. 모델은 리포지토리를 감싸고, 뷰(QListView)는 모델에게 “몇 번째 줄에 뭘 그릴까"를 물을 뿐입니다.
커스텀 모델: QAbstractListModel 상속 #
리스트형 커스텀 모델의 최소 계약은 세 가지입니다. 행 수(rowCount), 데이터(data), 그리고 편집이 있다면 setData와 flags입니다.
# src/daily/ui/today_model.py
from datetime import date
from PySide6.QtCore import QAbstractListModel, QModelIndex, Qt
from daily.data.repository import HabitRepository
class TodayModel(QAbstractListModel):
def __init__(self, repo: HabitRepository) -> None:
super().__init__()
self._repo = repo
self._habits = [] # [(Habit, checked: bool)]
self.reload()
def reload(self) -> None:
self.beginResetModel()
today = date.today().isoformat()
self._habits = [
(h, today in self._repo.checked_days(h.id))
for h in self._repo.active_habits()
]
self.endResetModel()
# --- 읽기 계약 ---
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
return len(self._habits)
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
habit, checked = self._habits[index.row()]
if role == Qt.ItemDataRole.DisplayRole:
return habit.name
if role == Qt.ItemDataRole.CheckStateRole:
return Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked
return None
# --- 쓰기 계약 ---
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
return (
Qt.ItemFlag.ItemIsEnabled
| Qt.ItemFlag.ItemIsSelectable
| Qt.ItemFlag.ItemIsUserCheckable
)
def setData(self, index: QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool:
if role != Qt.ItemDataRole.CheckStateRole:
return False
habit, _ = self._habits[index.row()]
checked = Qt.CheckState(value) == Qt.CheckState.Checked
self._repo.set_checked(habit.id, date.today(), checked) # DB 반영
self._habits[index.row()] = (habit, checked) # 캐시 반영
self.dataChanged.emit(index, index, [Qt.ItemDataRole.CheckStateRole])
return True읽어 둘 지점이 셋 있습니다.
- 역할(role)이 데이터의 여러 얼굴입니다. 같은 행이라도 DisplayRole로 물으면 이름을, CheckStateRole로 물으면 체크 상태를 돌려줍니다. 뷰와 델리게이트는 필요한 역할만 골라 씁니다. “한 행 = 값 하나"가 아니라 “한 행 = 역할별 값들"이라는 것이 모델/뷰 이해의 절반입니다.
- 쓰기의 경로가 한 줄로 정리됩니다. 사용자가 체크박스를 클릭하면 뷰가
setData를 부르고, 모델이 DB와 내부 캐시를 갱신한 뒤dataChanged를 쏩니다. 데이터 변경이 지나가는 문이 하나뿐이므로, 버그가 생겨도 볼 곳이 한 군데입니다. dataChanged에는 바뀐 범위와 역할을 정확히 알립니다. 전체 리셋(beginResetModel)은 목록 구성 자체가 바뀔 때(reload)만 씁니다. 행 하나의 체크가 바뀌었는데 전체를 리셋하면 선택 상태와 스크롤이 튀는, 사용자 눈에 보이는 품질 저하가 생깁니다.
화면 조립: QListView와 추가 다이얼로그 #
# src/daily/ui/today_page.py
from PySide6.QtWidgets import (
QInputDialog, QListView, QPushButton, QVBoxLayout, QWidget,
)
from daily.ui.today_model import TodayModel
class TodayPage(QWidget):
def __init__(self, model: TodayModel) -> None:
super().__init__()
self._model = model
view = QListView()
view.setModel(model)
add_btn = QPushButton("습관 추가")
add_btn.clicked.connect(self._add_habit)
layout = QVBoxLayout(self)
layout.addWidget(view, stretch=1)
layout.addWidget(add_btn)
def _add_habit(self) -> None:
name, ok = QInputDialog.getText(self, "습관 추가", "이름:")
if ok and name.strip():
self._model._repo.add_habit(name.strip())
self._model.reload()QListView는 CheckStateRole을 돌려주는 모델을 만나면 체크박스를 알아서 그려 줍니다. 커스텀 델리게이트 없이도 “이름 + 체크박스” 목록이 완성되는 것입니다. 더 화려한 행(달성 연속 일수 배지, 색상 태그)이 필요해지는 시점이 델리게이트를 꺼낼 시점인데, 이 앱에서는 4편의 히트맵이 커스텀 드로잉의 무대가 되므로 목록은 기본 그리기로 충분합니다.
한 가지 개선할 부분이 있습니다. self._model._repo처럼 모델의 내부에 손을 넣는 것은 좋은 신호가 아닙니다. 실전 감각으로는 모델에 add_habit(name) 메서드를 얹어 “모델의 공개 인터페이스만 쓰는” 형태로 다듬는 것이 맞고, 이런 리팩터링 판단 기준 자체가 이 시리즈에서 가져갈 감각입니다.
메인 윈도우에서는 1편의 자리 표시 라벨을 실제 화면으로 교체합니다.
# main_window.py 수정
self.today_model = TodayModel(repo)
self.pages.addWidget(TodayPage(self.today_model)) # index 0실행해 보기 #
uv run python -m daily로 실행하면, 습관을 추가하고 체크를 토글할 수 있고, 앱을 껐다 켜도 상태가 남습니다. 데이터가 DB에 있고 화면은 그것을 비추는 거울일 뿐이라는 구조가 처음으로 체감되는 지점입니다.
정리 #
- 위젯에 데이터를 직접 넣는 방식은 다중 화면, DB 동기화, 테스트 요구 앞에서 무너집니다. 진실은 모델에, 그리기는 뷰에 둡니다.
- 커스텀 리스트 모델의 계약은 rowCount, data, 그리고 편집이 있으면 flags와 setData입니다. 역할(role)이 한 행의 여러 얼굴을 노출합니다.
- 체크 토글은 뷰 → setData → DB·캐시 갱신 → dataChanged의 단일 경로로 흐릅니다. 변경의 문이 하나면 디버깅도 하나입니다.
- dataChanged는 바뀐 범위와 역할만 정확히 알리고, 전체 리셋은 목록 구성이 바뀔 때만 씁니다.
- QListView는 CheckStateRole만으로 체크박스를 그려 줍니다. 다음 편은 기성 위젯이 없는 화면, QPainter로 직접 그리는 히트맵입니다.