PySide6로 데스크톱 앱 만들기 #6 스레드와 타이머 — 멈추지 않는 UI
지금까지 만든 화면은 모든 처리가 순식간에 끝나는 작업이었습니다. 그런데 실제 앱에는 시간이 걸리는 작업이 반드시 들어옵니다. 파일 수백 개를 변환하고, 네트워크에서 데이터를 받아 오고, 무거운 계산을 돌립니다. 이런 작업을 버튼 핸들러에 그대로 연결하면 작업이 끝날 때까지 창 전체가 응답을 멈춥니다. 이번 글은 그 이유를 구조로 이해하고, 해결책인 QThread 와 QTimer 를 다룹니다.
총 7편으로 구성됩니다.
- #1 PySide6란 — Qt와 파이썬으로 데스크톱 앱
- #2 위젯과 레이아웃 — 화면을 조립하는 법
- #3 시그널과 슬롯 — 이벤트 처리의 핵심
- #4 Qt Designer와 UI 파일 — 화면을 그려서 불러오기
- #5 모델과 뷰 — 리스트·테이블에 데이터 연결
- #6 스레드와 타이머 — 멈추지 않는 UI ← 이번 글
- #7 패키징과 배포 — PyInstaller로 실행 파일 만들기
문제 재현 — 창이 응답을 멈추는 코드 #
먼저 문제를 직접 만들어 보겠습니다. 버튼을 누르면 5초짜리 작업이 실행되는 창입니다.
import sys
import time
from PySide6.QtWidgets import (
QApplication, QMainWindow, QPushButton, QVBoxLayout, QWidget, QLabel,
)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.label = QLabel("대기 중")
self.button = QPushButton("작업 시작")
self.button.clicked.connect(self.run_task)
layout = QVBoxLayout()
layout.addWidget(self.label)
layout.addWidget(self.button)
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
def run_task(self):
self.label.setText("작업 중...")
time.sleep(5) # 무거운 작업 시뮬레이션
self.label.setText("완료")
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()버튼을 누르면 5초 동안 창을 옮길 수도, 닫을 수도 없습니다. 라벨도 “작업 중…“으로 바뀌지 않고 5초 뒤에 곧바로 “완료"가 됩니다. 운영체제는 이 앱을 응답 없음 상태로 표시합니다.
이유는 #3에서 다룬 이벤트 루프에 있습니다. app.exec()가 돌리는 이벤트 루프는 한 번에 하나의 이벤트만 처리합니다. 클릭 이벤트를 처리하는 run_task가 5초 동안 반환하지 않으면, 그동안 화면 갱신 요청과 마우스 이벤트가 전부 큐에서 대기합니다. 라벨 텍스트 변경도 다음 화면 갱신 때 반영되므로 “작업 중…“이 보이지 않았던 것입니다.
원칙 — UI는 메인 스레드만 만진다 #
해결 방향은 무거운 작업을 다른 스레드로 옮기는 것입니다. 이때 Qt의 대전제를 먼저 새겨 두어야 합니다. 위젯을 만들고 수정하는 일은 메인 스레드에서만 합니다. 작업 스레드에서 self.label.setText(...)를 직접 호출하면 당장은 돌아가는 것처럼 보여도 예고 없이 크래시가 나는 코드가 됩니다.
그러면 작업 스레드는 결과를 어떻게 화면에 반영할까요? 답은 #3에서 배운 시그널입니다. 시그널은 스레드 경계를 넘을 때 자동으로 큐에 들어가 메인 스레드에서 슬롯이 실행됩니다. 작업 스레드는 시그널만 발신하고, 위젯 조작은 메인 스레드의 슬롯이 담당하는 구조입니다.
QThread 정석 패턴 — Worker와 moveToThread #
QThread 를 상속해 run을 오버라이드하는 예제도 널리 퍼져 있지만, 공식적으로 권장되는 형태는 작업을 QObject(Worker)로 분리하고 moveToThread 로 스레드에 배치하는 패턴입니다. 진행률 표시까지 포함한 전체 코드입니다.
import time
from PySide6.QtCore import QObject, Signal, Slot
class Worker(QObject):
progress = Signal(int) # 진행률 (0~100)
finished = Signal(str) # 완료 메시지
failed = Signal(str) # 에러 메시지
@Slot()
def run(self):
try:
for i in range(1, 101):
time.sleep(0.05) # 실제로는 파일 처리, 네트워크 등
self.progress.emit(i)
self.finished.emit("100건 처리 완료")
except Exception as e:
self.failed.emit(str(e))from PySide6.QtCore import QThread
from PySide6.QtWidgets import QProgressBar
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.progress_bar = QProgressBar()
self.button = QPushButton("작업 시작")
self.button.clicked.connect(self.start_task)
# ... 레이아웃 구성은 앞 예제와 동일 ...
def start_task(self):
self.button.setEnabled(False)
self.thread = QThread()
self.worker = Worker()
self.worker.moveToThread(self.thread)
# 스레드가 시작되면 Worker.run 실행
self.thread.started.connect(self.worker.run)
# 진행 상황과 결과는 전부 시그널로 수신
self.worker.progress.connect(self.progress_bar.setValue)
self.worker.finished.connect(self.on_finished)
self.worker.failed.connect(self.on_failed)
# 뒷정리 — 작업이 끝나면 스레드 종료 후 객체 해제
self.worker.finished.connect(self.thread.quit)
self.worker.failed.connect(self.thread.quit)
self.thread.finished.connect(self.worker.deleteLater)
self.thread.finished.connect(self.thread.deleteLater)
self.thread.start()
def on_finished(self, message: str):
self.statusBar().showMessage(message)
self.button.setEnabled(True)
def on_failed(self, error: str):
self.statusBar().showMessage(f"실패: {error}")
self.button.setEnabled(True)버튼을 눌러도 창은 자유롭게 움직이고, 진행률 바가 실시간으로 올라갑니다. 구조를 정리하면 세 줄입니다.
- Worker 는 계산만 하고 결과를 시그널로 발신합니다. 위젯을 전혀 모릅니다.
- 메인 윈도우는 시그널을 슬롯에 연결해 메인 스레드에서 위젯을 갱신합니다.
- 에러도 예외를 밖으로 던지지 않고
failed시그널로 전달합니다. 작업 스레드에서 발생한 예외는 그대로 두면 조용히 사라지기 때문입니다.
self.thread, self.worker처럼 인스턴스 속성으로 보관하는 데는 이유가 있습니다. 지역 변수로 만들면 메서드가 끝나는 순간 파이썬 가비지 컬렉션이 객체를 정리해 스레드가 시작하자마자 사라지는 문제가 생깁니다. 실행 중 참조가 유지되도록 반드시 속성으로 잡아 둡니다.QTimer — 주기 작업과 지연 실행 #
스레드까지 갈 필요 없는 가벼운 반복 작업도 있습니다. 1초마다 시계를 갱신하거나, 몇 초 뒤에 메시지를 지우는 일입니다. 이때는 QTimer 가 알맞습니다. QTimer 는 별도 스레드를 만들지 않고 이벤트 루프의 일정에 슬롯 호출을 등록하는 방식이라, 슬롯이 순식간에 끝나는 작업이라면 UI 에 영향이 없습니다.
from PySide6.QtCore import QTimer, QTime
self.clock_label = QLabel()
self.timer = QTimer(self)
self.timer.timeout.connect(self.update_clock)
self.timer.start(1000) # 1000ms 간격
def update_clock(self):
now = QTime.currentTime().toString("HH:mm:ss")
self.clock_label.setText(now)한 번만 지연 실행하는 용도로는 singleShot이 간단합니다.
QTimer.singleShot(3000, self.statusBar().clearMessage)QTimer 의 슬롯도 메인 스레드에서 실행된다는 점을 기억해야 합니다. 슬롯 안에 무거운 작업을 넣으면 처음 예제처럼 UI 가 멈추므로, 시간이 걸리는 일은 여전히 Worker 스레드의 몫입니다.
더 가 볼 방향 — QThreadPool과 asyncio #
작업을 여러 건 병렬로 처리해야 한다면 스레드를 직접 여러 개 만드는 대신 QThreadPool 과 QRunnable 조합이 있습니다. 스레드 풀이 실행 개수를 관리해 주므로 다운로드 여러 건, 파일 변환 여러 건 같은 상황에 맞습니다. 이 시리즈에서는 존재와 용도만 소개해 둡니다.
파이썬의 asyncio 와의 관계도 정리해 두겠습니다. Qt 의 이벤트 루프와 asyncio 의 이벤트 루프는 서로 다른 세계라서, async def 코드를 PySide6 앱에서 그대로 돌릴 수 없습니다. 둘을 잇는 전용 통합(PySide6 의 QtAsyncio 모듈 등)이 있지만 별도 학습이 필요한 주제이므로 이 시리즈의 범위 밖에 둡니다. 이번 글의 Worker 패턴만으로도 데스크톱 앱의 비동기 요구 대부분을 감당할 수 있습니다.
마무리 #
이번 글의 핵심은 세 가지입니다.
- 이벤트 루프는 한 번에 하나의 이벤트만 처리하므로, 핸들러가 오래 걸리면 창 전체가 응답을 멈춥니다.
- 무거운 작업은 Worker(QObject) + moveToThread 패턴으로 분리하고, 결과와 에러는 전부 시그널로 메인 스레드에 전달합니다. 위젯은 메인 스레드만 만집니다.
- 가벼운 주기 작업과 지연 실행은 QTimer 로 충분합니다. 다만 QTimer 의 슬롯도 메인 스레드에서 돌아갑니다.
다음 글인 “PySide6로 데스크톱 앱 만들기 #7 패키징과 배포 — PyInstaller로 실행 파일 만들기"는 시리즈의 마지막 편입니다. 지금까지 만든 앱을 파이썬이 설치되지 않은 컴퓨터에서도 실행되는 배포판으로 만드는 과정을 정리하겠습니다.