PySide6 실전 강좌 #1 프로젝트 설계 — 습관 트래커를 만들며 배우는 앱 구조
PySide6로 데스크톱 앱 만들기 입문 시리즈에서 위젯, 시그널, 모델/뷰, 패키징까지 부품을 익혔습니다. 그런데 부품을 아는 것과 앱을 완성하는 것 사이에는 골짜기가 있습니다. 파일을 어떻게 나누는지, 데이터는 어디에 두는지, “동작은 하는데 코드가 엉망"을 어떻게 피하는지는 입문 예제가 알려 주지 않기 때문입니다. 이 실전 시리즈는 그 골짜기를 건넙니다. 앱 하나를 8편에 걸쳐 설계부터 배포 가능한 상태까지 완성합니다.
무엇을 만드는가: 로컬 습관 트래커 “데일리” #
만들 앱은 습관 트래커입니다. 매일 실천할 습관(운동, 독서, 물 마시기)을 등록하고, 오늘 했는지 체크하고, 잔디밭 스타일의 히트맵으로 꾸준함을 확인하는 로컬 앱입니다. 이 소재를 고른 이유는 학습 밀도 때문입니다.
- CRUD + 영속성: 습관과 체크 기록을 SQLite에 저장합니다 (2편).
- 모델/뷰 실전: 커스텀 모델과 델리게이트로 목록 화면을 만듭니다 (3편).
- 커스텀 드로잉: 히트맵은 기성 위젯이 없어서 QPainter로 직접 그립니다 (4편).
- 데스크톱다움: 트레이 상주와 리마인더 알림 (5편), 설정과 테마 (6편).
- 품질과 배포: pytest-qt 테스트 (7편), 아이콘·버전·CI까지 배포 마감 (8편).
기능 요구는 처음에 화면 단위로 적어 두는 것이 좋습니다. 이 앱의 화면은 셋입니다. 메인 화면(오늘의 습관 목록 + 체크), 통계 화면(습관별 히트맵과 달성률), 설정 화면(테마, 리마인더 시각). 화면 목록이 곧 작업 목록이 되고, “이번 편에서 어디까지"의 경계가 됩니다.
구조: UI, 코어, 데이터의 3층 #
실전에서 가장 먼저 무너지는 것이 구조입니다. 버튼 핸들러 안에 SQL이 들어가기 시작하면, 테스트도 수정도 불가능한 한 덩어리가 됩니다. 이 시리즈는 처음부터 3층으로 나눕니다.
daily/
├── pyproject.toml
└── src/daily/
├── __main__.py # 진입점: python -m daily
├── app.py # QApplication 생성과 조립
├── ui/ # 화면: 위젯, 창, 모델(뷰용)
│ └── main_window.py
├── core/ # 규칙: 습관·연속 기록 계산 등 Qt와 무관한 로직
│ └── habits.py
└── data/ # 저장: SQLite 접근 (2편에서 구현)
└── repository.py층의 규칙은 두 줄로 요약됩니다. 의존 방향은 ui → core → data의 한 방향이고, core에는 PySide6를 임포트하지 않습니다. core가 Qt를 모르면 좋은 일이 연쇄적으로 생깁니다. “연속 14일 달성” 같은 규칙을 GUI 없이 일반 pytest로 검증할 수 있고(7편), 나중에 CLI나 웹으로 옮길 때도 core는 그대로 갑니다. 데스크톱 앱이 망가지는 전형적 경로가 “UI에 로직이 눌어붙는 것"이므로, 이 규칙 하나가 시리즈 전체의 뼈대입니다.
프로젝트 셋업 #
셋업은 uv 기준입니다. 환경 관리가 낯설면 파이썬 패키징 시리즈를 먼저 보시기 바랍니다.
uv init daily --package
cd daily
uv add pyside6
uv add --dev pytest pytest-qt--package 옵션으로 src 레이아웃의 패키지형 프로젝트를 만들었습니다. 단일 스크립트가 아니라 패키지로 시작하는 이유는 8편에서 다루겠습니다. PyInstaller로 묶을 때도, 테스트에서 임포트할 때도 패키지 구조가 문제를 줄여 줍니다.
메인 윈도우 뼈대 #
이번 편의 코드 목표는 화면 전환 구조까지입니다. 세 화면을 QStackedWidget에 담고 사이드 버튼으로 전환하는, 앞으로 7편을 지탱할 뼈대입니다.
# src/daily/ui/main_window.py
from PySide6.QtWidgets import (
QHBoxLayout, QLabel, QMainWindow, QPushButton,
QStackedWidget, QVBoxLayout, QWidget,
)
class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("데일리")
self.resize(480, 640)
# 화면 자리: 지금은 자리 표시 라벨, 다음 편부터 실제 화면으로 교체
self.pages = QStackedWidget()
self.pages.addWidget(QLabel("오늘")) # index 0: 3편에서 구현
self.pages.addWidget(QLabel("통계")) # index 1: 4편에서 구현
self.pages.addWidget(QLabel("설정")) # index 2: 6편에서 구현
nav = QVBoxLayout()
for i, name in enumerate(["오늘", "통계", "설정"]):
btn = QPushButton(name)
btn.clicked.connect(lambda _=False, idx=i: self.pages.setCurrentIndex(idx))
nav.addWidget(btn)
nav.addStretch()
root = QWidget()
layout = QHBoxLayout(root)
layout.addLayout(nav)
layout.addWidget(self.pages, stretch=1)
self.setCentralWidget(root)# src/daily/app.py
import sys
from PySide6.QtWidgets import QApplication
from daily.ui.main_window import MainWindow
def main() -> int:
app = QApplication(sys.argv)
app.setApplicationName("daily")
app.setOrganizationName("daily-app") # 6편의 QSettings가 이 값을 씁니다
window = MainWindow()
window.show()
return app.exec()# src/daily/__main__.py
import sys
from daily.app import main
sys.exit(main())uv run python -m daily로 실행하면 좌측 내비게이션과 세 개의 빈 화면이 있는 창이 뜹니다. 람다에서 idx=i로 값을 고정하는 것은 입문 #3에서 다룬 클로저 함정의 실전 적용입니다. setOrganizationName처럼 사소해 보이는 줄도 뒤 편(QSettings)의 복선이므로 지금 넣어 둡니다.
이 시리즈의 지도 #
| 편 | 만드는 것 | 핵심 기술 |
|---|---|---|
| 1 (이 글) | 설계와 뼈대 | 3층 구조, QStackedWidget |
| 2 | 데이터 층 | SQLite, 리포지토리, 마이그레이션 |
| 3 | 오늘 화면 | 커스텀 모델, 델리게이트 |
| 4 | 통계 화면 | QPainter 히트맵 |
| 5 | 트레이 상주 | QSystemTrayIcon, 알림, 단일 인스턴스 |
| 6 | 설정 | QSettings, 테마, 백업 |
| 7 | 테스트 | pytest-qt, 층별 테스트 전략 |
| 8 | 배포 마감 | 아이콘·버전, 서명 개요, GitHub Actions |
정리 #
- 이 시리즈는 습관 트래커 앱 하나를 8편에 걸쳐 설계부터 배포 가능 상태까지 완성합니다. 화면 목록(오늘·통계·설정)이 작업의 경계입니다.
- 구조는 ui → core → data의 한 방향 3층이고, core는 PySide6를 임포트하지 않습니다. 이 규칙이 테스트 가능성과 유지보수성의 뿌리입니다.
- 프로젝트는 uv의 src 레이아웃 패키지로 시작합니다. 패키징과 테스트에서 이 선택이 값을 합니다.
- 메인 윈도우는 QStackedWidget 기반의 화면 전환 뼈대로 만들어 두었습니다. 다음 편에서 이 앱의 심장인 SQLite 데이터 층을 만듭니다.