PySide6 실전 강좌 #5 트레이 상주와 리마인더 — 닫아도 죽지 않는 앱

5 분 소요

습관 트래커는 열려 있어야 쓸모가 있는 앱이 아니라, 잊고 있을 때 나타나야 쓸모가 있는 앱입니다. 그러려면 창을 닫아도 백그라운드에 남아 있다가 정해진 시각에 알림을 보내는 상주 구조가 필요합니다. 이번 편은 데스크톱 앱을 데스크톱답게 만드는 세 가지, 트레이 아이콘, 리마인더, 단일 인스턴스를 구현합니다.

QSystemTrayIcon: 상주의 거점 #

시스템 트레이(맥의 메뉴 막대, 윈도우의 작업 표시줄 구석)에 아이콘을 두면, 창이 없어도 앱이 살아 있다는 표식과 접점이 생깁니다.

src/daily/ui/tray.py
# src/daily/ui/tray.py
from PySide6.QtGui import QAction, QIcon
from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon


def create_tray(window) -> QSystemTrayIcon:
    tray = QSystemTrayIcon(QIcon(":/icons/daily.png"), parent=window)

    menu = QMenu()
    show_action = QAction("열기", menu)
    show_action.triggered.connect(window.show_and_raise)
    quit_action = QAction("종료", menu)
    quit_action.triggered.connect(QApplication.instance().quit)
    menu.addAction(show_action)
    menu.addSeparator()
    menu.addAction(quit_action)

    tray.setContextMenu(menu)
    tray.activated.connect(
        lambda reason: window.show_and_raise()
        if reason == QSystemTrayIcon.ActivationReason.Trigger
        else None
    )
    tray.show()
    return tray

activated 시그널의 Trigger는 아이콘 클릭입니다. 클릭이면 창 복원, 우클릭이면 메뉴라는 트레이 앱의 표준 문법을 그대로 따랐습니다. window.show_and_raise는 show() 후 raiseWindow()와 activateWindow()까지 부르는 헬퍼로 만들어 둡니다. 숨어 있던 창은 show만으로는 앞으로 나오지 않는 플랫폼이 있기 때문입니다.

닫기 = 숨기기: closeEvent 재정의 #

상주 앱의 핵심 전환이 이것입니다. 창의 X 버튼이 앱을 죽이지 않게 만듭니다.

src/daily/ui/main_window.py
# MainWindow에 추가
from PySide6.QtGui import QCloseEvent


class MainWindow(QMainWindow):
    def closeEvent(self, event: QCloseEvent) -> None:
        if self._quit_requested:          # 트레이 메뉴의 '종료'로만 진짜 종료
            event.accept()
            return
        event.ignore()                    # 종료를 막고
        self.hide()                       # 숨기기로 대체

함께 챙길 조각이 둘 있습니다. 첫째, QApplication은 마지막 창이 닫히면 기본적으로 종료되므로 app.setQuitOnLastWindowClosed(False)를 앱 시작 시 넣어야 합니다. 둘째, 트레이 메뉴의 “종료"는 _quit_requested 플래그를 세우고 닫는 경로로 만들어, 진짜 종료와 숨기기를 구분합니다.

UX 관점의 주의도 적어 둡니다. “닫았는데 안 꺼짐"은 사용자에 따라 배신감을 주는 동작입니다. 처음 숨겨질 때 트레이 알림으로 “백그라운드에서 계속 실행됩니다"를 한 번 알려 주고, 6편의 설정 화면에 “닫기 버튼 동작: 숨기기/종료” 옵션을 두는 것이 성숙한 처리입니다. 상주는 기능이 아니라 사용자와의 합의입니다.

리마인더: QTimer로 시각 감시 #

“매일 21시에 오늘 체크 안 한 습관이 있으면 알림"을 구현합니다. 데스크톱 앱에서 가장 단순하고 견고한 방식은 1분마다 깨어나 시각을 확인하는 타이머입니다.

src/daily/ui/reminder.py
# src/daily/ui/reminder.py
from datetime import date, datetime

from PySide6.QtCore import QObject, QTimer
from PySide6.QtWidgets import QSystemTrayIcon


class Reminder(QObject):
    def __init__(self, repo, tray: QSystemTrayIcon, hour: int = 21) -> None:
        super().__init__()
        self._repo = repo
        self._tray = tray
        self._hour = hour
        self._fired_on: str | None = None    # 오늘 이미 울렸는지

        self._timer = QTimer(self)
        self._timer.setInterval(60 * 1000)   # 1분
        self._timer.timeout.connect(self._tick)
        self._timer.start()

    def _tick(self) -> None:
        now = datetime.now()
        today = date.today().isoformat()
        if now.hour < self._hour or self._fired_on == today:
            return
        undone = [
            h.name for h in self._repo.active_habits()
            if today not in self._repo.checked_days(h.id)
        ]
        if undone:
            self._tray.showMessage(
                "데일리", f"아직 안 한 습관: {', '.join(undone)}",
                QSystemTrayIcon.MessageIcon.Information,
            )
        self._fired_on = today

“정확히 21:00:00에 실행"이 아니라 “21시 이후 첫 틱에 실행 + 오늘 발화 기록"으로 설계한 것이 요점입니다. 잠자기에서 깨어난 노트북, 앱을 21시 넘어 켠 경우까지 자연스럽게 처리되고, 초 단위 정밀도는 이 용도에 필요하지 않습니다. 시각을 하드코딩하지 않고 생성자 인자로 받은 것은 6편 설정 화면의 복선입니다.

showMessage의 트레이 알림은 운영체제의 알림 센터를 통해 표시되는데, 모양과 동작(지속 시간, 클릭 반응)이 플랫폼마다 다르고 사용자가 OS 설정에서 끌 수도 있습니다. 알림은 보조 수단이고, 앱 안에서도 미완료 상태가 보여야 한다는 원칙으로 설계하는 것이 안전합니다.

단일 인스턴스: 두 개 뜨는 사고 막기 #

상주 앱의 고전 사고가 있습니다. 창이 숨어 있는 걸 모르고 사용자가 앱을 또 실행해서, 프로세스 두 개가 같은 DB를 잡는 상황입니다. 해결의 표준 패턴은 로컬 소켓으로 선점 확인입니다.

src/daily/single_instance.py
# src/daily/single_instance.py
from PySide6.QtNetwork import QLocalServer, QLocalSocket

_NAME = "daily-app-instance"


def acquire_or_notify() -> QLocalServer | None:
    """첫 인스턴스면 서버를 반환, 이미 떠 있으면 신호만 보내고 None."""
    probe = QLocalSocket()
    probe.connectToServer(_NAME)
    if probe.waitForConnected(200):
        probe.write(b"show")             # 기존 인스턴스에게 '창 보여 줘'
        probe.waitForBytesWritten(200)
        return None

    server = QLocalServer()
    QLocalServer.removeServer(_NAME)     # 비정상 종료의 찌꺼기 정리
    server.listen(_NAME)
    return server

앱 시작 시 acquire_or_notify()가 None이면 그대로 종료하고, 서버를 얻은 첫 인스턴스는 newConnection 시그널에서 “show” 메시지를 받아 창을 복원합니다. 두 번째 실행이 “새 앱"이 아니라 “기존 창 열기"로 동작하는, 사용자 기대에 맞는 결과가 됩니다.

정리 #

  • 트레이 아이콘은 상주의 거점입니다. 클릭 = 창 복원, 우클릭 = 메뉴라는 표준 문법을 따릅니다.
  • 닫기 = 숨기기는 closeEvent의 ignore + hide로 만들고, setQuitOnLastWindowClosed(False)와 진짜 종료 경로(플래그)를 세트로 챙깁니다. 그리고 이 동작은 사용자에게 고지하고 설정으로 열어 둡니다.
  • 리마인더는 1분 타이머 + “시각 경과 후 첫 틱 발화 + 당일 기록” 패턴이 단순하고 견고합니다. 알림은 보조 수단으로 설계합니다.
  • 단일 인스턴스는 로컬 소켓 선점으로 확인하고, 재실행을 기존 창 복원으로 바꿉니다.
  • 다음 편은 설정 화면입니다. QSettings로 리마인더 시각과 닫기 동작, 테마를 사용자에게 넘깁니다.
X