PySide6 실전 강좌 #6 설정 화면 — QSettings, 다크 테마, 데이터 백업

4 분 소요

지금까지 리마인더 시각은 21시로, 닫기 동작은 숨기기로 코드에 박혀 있었습니다. 좋은 도구는 이런 결정을 사용자에게 돌려줍니다. 이번 편은 설정 화면을 만들고, 테마를 입히고, 데이터를 내보내는, 앱의 완성도를 끌어올리는 회입니다.

QSettings: 설정 저장의 표준 답안 #

설정을 직접 JSON 파일로 관리할 수도 있지만, Qt에는 이미 표준 답안이 있습니다. QSettings는 키-값을 플랫폼별 표준 위치(macOS는 plist, Windows는 레지스트리, 리눅스는 ini 파일)에 저장해 줍니다. 1편에서 setOrganizationName과 setApplicationName을 설정해 둔 덕에, 인자 없이 만들어도 올바른 위치를 씁니다.

src/daily/settings.py
# src/daily/settings.py
from PySide6.QtCore import QSettings


class AppSettings:
    """QSettings를 감싸 키 오타와 타입 실수를 한곳에서 막는다."""

    def __init__(self) -> None:
        self._s = QSettings()

    @property
    def reminder_hour(self) -> int:
        return int(self._s.value("reminder/hour", 21))

    @reminder_hour.setter
    def reminder_hour(self, value: int) -> None:
        self._s.setValue("reminder/hour", value)

    @property
    def close_to_tray(self) -> bool:
        return self._s.value("window/close_to_tray", True, type=bool)

    @close_to_tray.setter
    def close_to_tray(self, value: bool) -> None:
        self._s.setValue("window/close_to_tray", value)

    @property
    def theme(self) -> str:                      # "system" | "light" | "dark"
        return str(self._s.value("appearance/theme", "system"))

    @theme.setter
    def theme(self, value: str) -> None:
        self._s.setValue("appearance/theme", value)

래퍼 클래스를 한 겹 두는 이유는 QSettings의 유명한 함정 때문입니다. 저장된 값이 플랫폼에 따라 문자열로 돌아올 수 있습니다. bool을 저장했는데 "true"라는 문자열이 돌아와 if 판정이 뒤집히는 사고가 전형적입니다. type=bool 지정이나 명시적 변환을 래퍼 안에 모아 두면, 이 함정을 앱 전체에서 한 번만 처리하면 됩니다. 키 이름도 "reminder/hour"처럼 그룹/이름 형식의 상수가 되어 오타가 사라집니다.

설정 화면과 동작의 연결 #

설정 페이지 자체는 입문에서 배운 폼 조립입니다. 요점은 위젯이 아니라 연결입니다.

src/daily/ui/settings_page.py
# src/daily/ui/settings_page.py (핵심만)
class SettingsPage(QWidget):
    def __init__(self, settings: AppSettings, reminder, main_window) -> None:
        super().__init__()
        self._settings = settings

        hour = QSpinBox()
        hour.setRange(0, 23)
        hour.setValue(settings.reminder_hour)
        hour.valueChanged.connect(self._on_hour_changed)

        close_tray = QCheckBox("닫기 버튼을 누르면 트레이로 숨기기")
        close_tray.setChecked(settings.close_to_tray)
        close_tray.toggled.connect(self._on_close_mode_changed)
        ...

    def _on_hour_changed(self, value: int) -> None:
        self._settings.reminder_hour = value
        self._reminder.set_hour(value)          # 실행 중인 리마인더에 즉시 반영

원칙은 두 줄입니다. 저장과 적용을 함께 합니다. 설정 값을 QSettings에 쓰는 것으로 끝내면 “재시작해야 반영되는” 앱이 됩니다. 5편의 Reminder에 set_hour 메서드를 추가하고, closeEvent가 플래그 대신 settings.close_to_tray를 읽게 고치면, 설정이 그 자리에서 살아 움직입니다. 그리고 “확인/취소” 다이얼로그 대신 즉시 적용을 택했습니다. 로컬 도구 앱의 설정은 바꾸면 바로 반영되는 쪽이 요즘 관례에 맞습니다.

테마: 스타일시트 교체와 시스템 감지 #

Qt 위젯의 외형은 CSS와 닮은 스타일시트(QSS)로 바꿀 수 있습니다. 테마 구현의 뼈대는 “다크용 QSS 문자열과 라이트용 QSS 문자열을 준비해 두고 app.setStyleSheet으로 갈아 끼우기"입니다.

src/daily/ui/theme.py
# src/daily/ui/theme.py (발췌)
DARK_QSS = """
QWidget { background: #1e1f22; color: #e6e6e6; }
QPushButton { background: #2d333b; border: 1px solid #444c56; padding: 6px 12px; border-radius: 4px; }
QPushButton:hover { background: #39404a; }
QListView { background: #16171a; border: none; }
"""


def apply_theme(app: QApplication, mode: str) -> None:
    if mode == "system":
        scheme = app.styleHints().colorScheme()          # OS의 라이트/다크 설정
        mode = "dark" if scheme == Qt.ColorScheme.Dark else "light"
    app.setStyleSheet(DARK_QSS if mode == "dark" else "")

챙길 포인트가 둘 있습니다. 첫째, “system” 모드는 styleHints().colorScheme()으로 OS 설정을 읽고, colorSchemeChanged 시그널에 연결해 두면 OS 테마가 바뀔 때 앱도 따라 바뀝니다. 둘째, 4편의 히트맵처럼 직접 그리는 위젯은 스타일시트의 영향을 받지 않습니다. 하드코딩했던 색을 팔레트나 테마 모듈의 상수에서 읽도록 고쳐야 테마 전환에 함께 반응합니다. 커스텀 드로잉의 숨은 비용이 이런 곳에서 나타납니다.

데이터 내보내기: 사용자의 데이터는 사용자의 것 #

로컬 앱의 마지막 예의는 데이터를 인질로 잡지 않는 것입니다. 두 가지를 제공합니다.

src/daily/data/export.py
def export_csv(repo: HabitRepository, path: Path) -> None:
    import csv

    with open(path, "w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f)
        writer.writerow(["habit", "day"])
        for habit in repo.active_habits():
            for day in sorted(repo.checked_days(habit.id)):
                writer.writerow([habit.name, day])
  • CSV 내보내기: 파일 위치는 QFileDialog.getSaveFileName으로 사용자가 고릅니다. 스프레드시트에서 열리는 형식이 곧 “내 데이터를 들고 나갈 수 있다"는 신뢰가 됩니다.
  • DB 백업: sqlite3의 Connection.backup()을 쓰면 사용 중인 DB도 안전하게 복사됩니다. 파일을 그냥 복사하는 것보다 안전한, SQLite가 제공하는 정식 백업 경로입니다. 리포지토리에 backup_to(path) 메서드로 추가해 둡니다.

정리 #

  • 설정은 QSettings가 표준입니다. 플랫폼별 저장 위치를 알아서 처리하고, 타입 함정(문자열로 돌아오는 bool)은 래퍼 클래스에서 한 번에 막습니다.
  • 설정은 저장과 동시에 적용합니다. 재시작해야 반영되는 설정은 미완성입니다.
  • 테마는 QSS 교체가 뼈대이고, system 모드는 colorScheme 감지와 변경 시그널로 만듭니다. 직접 그리는 위젯은 색을 테마 상수에서 읽게 고쳐야 합니다.
  • CSV 내보내기와 backup() 기반 DB 백업으로 사용자 데이터의 이동 자유를 보장합니다.
  • 기능은 이제 완성입니다. 다음 편은 이 앱이 계속 완성 상태로 남게 만드는 장치, pytest-qt 테스트입니다.
X