PySide6 실전 강좌 #3 오늘 화면 — 커스텀 모델과 체크 토글

4 분 소요

데이터 층이 생겼으니 이제 사용자가 만나는 첫 화면, “오늘” 탭을 만듭니다. 습관 목록이 보이고, 클릭으로 오늘의 체크를 켜고 끄는 화면입니다. 입문 #5에서 모델/뷰의 개념을 다뤘다면, 이번에는 그 지식이 실제 앱에서 어떤 모양이 되는지를 봅니다.

왜 QListWidget으로 안 하는가 #

가장 빠른 길은 QListWidget에 아이템을 직접 채우는 것입니다. 실제로 프로토타입이라면 그것이 맞습니다. 그런데 이 앱은 같은 데이터가 화면 두 곳(오늘 목록, 통계)에서 쓰이고, 체크 상태가 DB와 동기화되어야 하고, 7편에서 화면 없이 테스트도 해야 합니다. 위젯에 데이터를 직접 넣는 방식은 이 요구들에서 전부 삐걱거립니다. 데이터의 진실이 위젯 안에 갇히기 때문입니다.

모델/뷰의 답은 분리입니다. 데이터의 진실은 모델이 들고, 뷰는 그리기만 합니다. 모델은 리포지토리를 감싸고, 뷰(QListView)는 모델에게 “몇 번째 줄에 뭘 그릴까"를 물을 뿐입니다.

커스텀 모델: QAbstractListModel 상속 #

리스트형 커스텀 모델의 최소 계약은 세 가지입니다. 행 수(rowCount), 데이터(data), 그리고 편집이 있다면 setData와 flags입니다.

src/daily/ui/today_model.py
# 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
# 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편의 자리 표시 라벨을 실제 화면으로 교체합니다.

src/daily/ui/main_window.py
# 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로 직접 그리는 히트맵입니다.
X