PySide6 QRunnable 진행률 업데이트 가이드, QThreadPool과 QueuedConnection으로 안전한 GUI 반응성 확보
🧵 스레드에서 emit한 progress를 메인 스레드에서 끊김 없이 그리는 실전 레시피
GUI가 긴 작업 때문에 멈추는 순간 사용자의 신뢰는 눈에 띄게 떨어집니다.
파이썬과 Qt를 함께 쓰는 환경에서는 작업을 백그라운드로 보내고, 그 진행률을 화면에 매끄럽게 반영하는 구조가 특히 중요합니다.
이 글은 PySide 기반 애플리케이션에서 QRunnable로 작업을 실행하고, 진행률을 emit한 뒤, 메인 스레드의 위젯을 QueuedConnection으로 안전하게 갱신하는 패턴을 다룹니다.
실전에서 바로 가져다 쓸 수 있도록 시그널 설계, QThreadPool 세팅, 취소·에러 처리 포인트까지 균형 있게 정리해 보겠습니다.
핵심은 단순합니다.
작업 코드는 QRunnable의 run()에서 수행하고, 진행률이나 메시지는 시그널로 발행하며, 수신자는 메인 스레드의 슬롯으로 연결해 GUI 업데이트를 담당합니다.
이때 연결 방식이 QueuedConnection이면 위젯 조작이 항상 메인 스레드 문맥에서 이뤄져 안전합니다.
또한 QThreadPool을 이용하면 동시 작업 수를 제어하고, 자동 삭제나 재사용 가능한 워커 구성으로 관리 비용을 줄일 수 있습니다.
아래 목차를 따라가며 실무 관점의 체크리스트와 코드 블록을 단계별로 살펴보세요.
📋 목차
🔗 QRunnable 진행률 작업 개요
PySide에서 긴 작업을 메인 스레드(UI 스레드)에서 처리하면 이벤트 루프가 막혀 창 이동, 버튼 클릭, 프로그레스 바 갱신 같은 기본 상호작용이 정지합니다.
이 문제를 풀기 위한 표준 패턴은 QThreadPool에 QRunnable을 투입하고, 작업 중간중간 progress 값을 emit하여 메인 스레드의 슬롯에서 안전하게 UI를 갱신하는 것입니다.
핵심은 연결 방식에 QueuedConnection을 사용해 신호가 큐에 쌓인 뒤 UI 스레드에서 처리되도록 보장하는 점입니다.
이 섹션에서는 왜 QRunnable이 가볍고 재사용 가능한 워커로 적합한지, 진행률 신호를 설계할 때 어떤 데이터 타입과 타이밍을 고려해야 하는지, 그리고 GUI 업데이트가 스레드 안전하게 동작하는 경로를 전체 흐름도처럼 정리합니다.
🧭 처리 흐름 한눈에 보기
1) 사용자가 버튼을 누르면 메인 스레드는 QThreadPool.start()로 작업을 큐에 넣습니다.
2) 백그라운드에서 QRunnable.run()이 실행되며 단계별로 진행률을 계산합니다.
3) 워커는 signals.progress.emit(%)처럼 수치 또는 상태 메시지를 보냅니다.
4) 메인 스레드의 슬롯은 QueuedConnection으로 호출되어 QProgressBar.setValue(), 로그 추가, 버튼 상태 전환 등을 반영합니다.
5) 종료 시 finished 신호로 UI를 원복합니다.
from PySide6.QtCore import QObject, Signal, QRunnable, QThreadPool, Slot, Qt
from PySide6.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QProgressBar, QTextEdit
class WorkerSignals(QObject):
progress = Signal(int) # 0~100
message = Signal(str) # 상태 메시지
finished = Signal() # 작업 완료
class Worker(QRunnable):
def __init__(self, steps=100):
super().__init__()
self.setAutoDelete(True)
self.steps = steps
self.signals = WorkerSignals()
def run(self):
# 무거운 작업 시뮬레이션
import time
for i in range(self.steps + 1):
time.sleep(0.01)
self.signals.progress.emit(i)
if i % 10 == 0:
self.signals.message.emit(f"processing {i}%")
self.signals.finished.emit()
class Window(QWidget):
def __init__(self):
super().__init__()
self.btn = QPushButton("Start")
self.bar = QProgressBar()
self.log = QTextEdit(); self.log.setReadOnly(True)
lay = QVBoxLayout(self)
for w in (self.btn, self.bar, self.log): lay.addWidget(w)
self.pool = QThreadPool.globalInstance()
self.btn.clicked.connect(self.start_job)
def start_job(self):
self.btn.setEnabled(False)
worker = Worker(steps=100)
# QueuedConnection 보장: cross-thread UI 업데이트
worker.signals.progress.connect(self.on_progress, Qt.ConnectionType.QueuedConnection)
worker.signals.message.connect(self.on_message, Qt.ConnectionType.QueuedConnection)
worker.signals.finished.connect(self.on_finished, Qt.ConnectionType.QueuedConnection)
self.pool.start(worker)
@Slot(int)
def on_progress(self, value: int):
self.bar.setValue(value)
@Slot(str)
def on_message(self, msg: str):
self.log.append(msg)
@Slot()
def on_finished(self):
self.log.append("done")
self.btn.setEnabled(True)
if __name__ == "__main__":
app = QApplication([])
w = Window(); w.resize(360, 260); w.show()
app.exec()
💡 TIP: QRunnable은 QObject가 아니므로 직접 시그널을 가질 수 없습니다.
별도의 WorkerSignals(QObject)를 만들어 인스턴스를 멤버로 두면 깔끔하게 신호를 분리할 수 있습니다.
| 항목1 | 항목2 |
|---|---|
| QThread vs QThreadPool | 지속 스레드 관리 vs 풀 기반 단발성 작업 큐. 짧고 많은 작업은 풀 방식이 유리. |
| Direct vs QueuedConnection | 동일 스레드 즉시 호출 vs 대상 스레드 이벤트 루프에서 안전 처리. |
- 🛠️WorkerSignals로 progress(int), message(str), finished()를 분리 설계
- ⚙️모든 UI 슬롯 연결에 Qt.ConnectionType.QueuedConnection 지정
- 🔌풀은 QThreadPool.globalInstance() 재사용
⚠️ 주의: 워커 내부에서 위젯 메서드를 직접 호출하면 경쟁 상태와 크래시가 발생할 수 있습니다.
항상 신호를 통해 간접적으로 갱신하세요.
또한 긴 루프에서는 sleep() 남용 대신 I/O 경계나 청크 단위로 emit 타이밍을 조절해 프레임 드랍을 줄이세요.
🛠️ Signal 설계와 progress emit 패턴
QRunnable은 QObject를 상속하지 않기 때문에 직접 시그널을 가질 수 없습니다.
따라서 WorkerSignals라는 별도 클래스를 만들어 진행률(progress), 메시지(message), 완료(finished) 등의 신호를 관리해야 합니다.
이렇게 구조를 분리하면 스레드 안전성과 재사용성이 모두 향상됩니다.
또한 emit 시점은 연산 주기마다 실행하지 말고, 적절한 간격으로 호출해 메인 스레드의 이벤트 큐가 과도하게 쌓이지 않도록 조절하는 것이 중요합니다.
📡 Signal 설계 기본 구조
Signal 클래스는 QObject를 상속하며, 각각의 신호에 타입 힌트를 명시하는 것이 좋습니다.
이렇게 하면 IDE 자동 완성이나 코드 가독성이 좋아지고, emit 시 잘못된 타입을 넘겨 생기는 런타임 오류를 줄일 수 있습니다.
class WorkerSignals(QObject):
progress = Signal(int)
message = Signal(str)
finished = Signal()
error = Signal(str)
작업 중 오류가 발생할 수도 있으므로 error 신호를 함께 정의해 두면 유용합니다.
이 신호는 예외 발생 시 상세 로그를 UI 로그창에 남기거나 팝업 경고를 띄우는 데 사용할 수 있습니다.
📈 emit 최적화 패턴
emit을 지나치게 자주 호출하면 Qt의 이벤트 루프에 시그널이 과도하게 쌓여 오히려 UI 반응이 느려질 수 있습니다.
다음과 같은 패턴을 권장합니다.
- ⚙️진행률은 1% 단위로 emit (예: if i % 1 == 0:)
- 🧮로그 메시지는 주요 단계마다 emit (예: 10% 단위, 단계 완료 시점 등)
- 🔌메인 스레드 UI에선 bar.setValue() 외에 QApplication.processEvents()는 사용하지 않음 (불필요)
🧩 코드 예시: emit 구조 개선
def run(self):
try:
for i in range(101):
# CPU-bound 작업 예시
result = i ** 2
if i % 2 == 0:
self.signals.progress.emit(i)
if i % 20 == 0:
self.signals.message.emit(f"{i}% 완료")
self.signals.finished.emit()
except Exception as e:
self.signals.error.emit(str(e))
위처럼 주기적으로 emit을 분리하면 UI 업데이트 빈도와 스레드 부하가 균형을 이룹니다.
특히 CPU 부하가 높은 루프에서는 sleep() 대신 데이터 처리 구간을 청크 단위로 나누어 emit 타이밍을 조절하는 것이 성능상 유리합니다.
💡 TIP: Signal에 전달하는 데이터는 반드시 Qt가 직렬화할 수 있는 타입이어야 합니다.
파이썬 객체 전체를 emit하는 것은 피하고, 변환 가능한 최소 데이터만 넘기세요.
⚠️ 주의: emit 호출이 너무 많거나, 루프 내 sleep이 짧으면 메인 스레드의 이벤트 큐가 포화될 수 있습니다.
이 경우 프로그레스바가 끊기거나 지연되는 현상이 생기므로 반드시 적정 빈도를 유지하세요.
⚙️ QueuedConnection으로 GUI 안전 업데이트
Qt의 신호/슬롯은 연결 타입에 따라 호출 맥락이 달라집니다.
특히 백그라운드에서 QRunnable이 emit한 진행률을 메인 스레드의 위젯에 반영하려면 QueuedConnection을 사용해 이벤트 큐를 경유하도록 보장하는 것이 핵심입니다.
이 방식은 슬롯 실행이 수신자(메인 스레드)의 이벤트 루프에서 처리되므로, 위젯 조작이 스레드 안전하게 유지됩니다.
또한 AutoConnection은 서로 다른 스레드 간 연결일 때 자동으로 큐드 호출로 바뀌지만, 명시적으로 QueuedConnection을 지정하면 가독성과 의도가 분명해집니다.
🧵 스레드 경계에서의 안전 규칙
1) 위젯, 모델, 씬/뷰 같은 GUI 객체는 메인 스레드에서만 접근합니다.
2) 워커는 오직 데이터 계산·I/O 수행과 시그널 emit만 담당합니다.
3) 슬롯에서는 UI 변경 외에 긴 연산을 넣지 않습니다.
4) 동일 스레드 내부 최적화를 위해 상태 플래그만 갱신하고, 무거운 동작은 워커로 위임합니다.
from PySide6.QtCore import Qt, Slot
# 워커 연결부 (메인 스레드)
worker.signals.progress.connect(self.on_progress, Qt.ConnectionType.QueuedConnection)
worker.signals.message.connect(self.on_message, Qt.ConnectionType.QueuedConnection)
worker.signals.finished.connect(self.on_finished, Qt.ConnectionType.QueuedConnection)
@Slot(int)
def on_progress(self, v: int):
# 항상 메인 스레드 문맥에서 실행
self.bar.setValue(v)
@Slot(str)
def on_message(self, m: str):
self.log.append(m)
@Slot()
def on_finished(self):
self.log.append("작업 완료")
self.btn.setEnabled(True)
| 연결 타입 | 호출 맥락 |
|---|---|
| DirectConnection | emit 호출 스레드에서 즉시 슬롯 실행. 교차 스레드 UI 조작에 부적합. |
| QueuedConnection | 수신자 스레드의 이벤트 루프에서 실행. GUI 업데이트에 권장. |
| AutoConnection | 스레드 동일시 Direct, 교차시 Queued로 자동 결정. |
💡 TIP: 스레드 간 전달량이 많다면 신호 페이로드를 최소화하세요.
예를 들어 전체 데이터 프레임 대신 진행률(int)과 상태 코드(enum)만 보내고, UI는 해당 값으로 표시만 갱신하도록 설계하면 큐 지연을 줄일 수 있습니다.
💬 PySide 애플리케이션에서 QRunnable(emit progress) → GUI 업데이트(QueuedConnection) 흐름은 스레드 안전성, 반응성, 코드 가독성을 동시에 확보하는 실전 레시피입니다.
- 🧩GUI 조작 슬롯 연결은 반드시 QueuedConnection 지정
- 🗃️신호 페이로드는 가볍게, 불변 데이터 위주로 설계
- 🧪슬롯에서 긴 연산 금지, 필요 시 추가 워커로 위임
⚠️ 주의: 교차 스레드에서 DirectConnection으로 위젯을 만지면 간헐적 크래시나 UI 동결을 유발할 수 있습니다.
테스트 환경에서 재현되지 않더라도 배포 환경에서 빈번히 문제가 되므로 습관적으로 큐드 연결을 사용하세요.
🚀 QThreadPool 구성과 취소 처리
QThreadPool은 다수의 짧은 작업을 효율적으로 분산 실행하는 풀 매니저입니다.
글로벌 인스턴스는 프로세스 전역으로 재사용되며, 기본 스레드 수는 보통 CPU 코어 수를 기준으로 정해집니다.
UI 앱에서는 지나친 동시성을 피하고, setMaxThreadCount()로 상한을 명시해 예측 가능한 반응성을 확보하는 편이 안전합니다.
또한 취소 처리는 QRunnable 자체가 강제 중단을 제공하지 않으므로, stop 플래그나 QAtomicBool 같은 공유 상태를 점검하는 방식으로 협조적 취소를 구현해야 합니다.
I/O 작업에는 타임아웃을 지정하고, CPU 바운드 연산은 루프 청크마다 플래그를 확인해 빠르게 반환하도록 설계합니다.
🧩 풀 구성 베스트 프랙티스
1) QThreadPool.globalInstance()를 일관되게 사용합니다.
2) CPU 바운드 태스크는 코어 수에 맞게, I/O 바운드는 다소 여유 있게 스레드 수를 배치합니다.
3) 긴 작업은 하나의 거대한 루프 대신, 중단점이 있는 소루틴으로 쪼개 취소 신호를 빠르게 반영합니다.
4) 오류/취소/완료를 구분하는 별도 시그널을 두어 UI 상태 전환을 명확히 합니다.
from PySide6.QtCore import QObject, Signal, QRunnable, QThreadPool, Qt
from PySide6.QtWidgets import QPushButton
class WorkerSignals(QObject):
progress = Signal(int)
message = Signal(str)
finished = Signal()
canceled = Signal()
error = Signal(str)
class CancelableWorker(QRunnable):
def __init__(self, steps=100):
super().__init__()
self.steps = steps
self.signals = WorkerSignals()
self._stopped = False
self.setAutoDelete(True)
def stop(self):
self._stopped = True
def run(self):
try:
for i in range(self.steps + 1):
# 협조적 취소 체크
if self._stopped:
self.signals.message.emit("canceled by user")
self.signals.canceled.emit()
return
# 계산 또는 I/O
if i % 5 == 0:
self.signals.message.emit(f"step {i}")
self.signals.progress.emit(i)
self.signals.finished.emit()
except Exception as e:
self.signals.error.emit(str(e))
# 메인 위젯 예시
pool = QThreadPool.globalInstance()
pool.setMaxThreadCount(4) # 앱 성격에 맞춰 조정
start_btn = QPushButton("Start")
cancel_btn = QPushButton("Cancel")
worker = CancelableWorker(steps=100)
start_btn.clicked.connect(lambda: pool.start(worker))
cancel_btn.clicked.connect(lambda: worker.stop()) # 협조적 취소
| 설정 포인트 | 권장값/설명 |
|---|---|
| MaxThreadCount | CPU 바운드: 코어 수, I/O 바운드: 코어 수 이상 가능하나 UI 지연 모니터링 필요. |
| AutoDelete | 기본 True 권장. 명시적 생명주기 관리가 필요하면 False 후 수동 해제. |
| 취소 플래그 | 루프마다 체크. I/O는 타임아웃, 소켓/요청 취소 API 병행. |
- 🧵globalInstance() 재사용으로 스레드 재활용 이점 확보
- 🧯취소는 강제 종료가 아닌 협조적 플래그로 구현
- 🧪완료/취소/오류를 구분해 UI 상태와 버튼 라벨을 명확히 전환
💡 TIP: 다수 작업을 한꺼번에 큐잉한다면, 우선순위가 필요한 큐(예: 중요도 높은 파일부터 처리)를 고려해 태스크 분할과 UI 피드백(대기열 길이, 예상 시간)을 함께 설계하면 체감 성능이 좋아집니다.
⚠️ 주의: Python의 강제 스레드 종료는 제공되지 않으며, OS 스레드를 무리하게 kill하는 방식은 리소스 누수와 충돌을 유발할 수 있습니다.
항상 취소 가능 지점을 촘촘히 배치하고, 외부 리소스(파일, 소켓)를 예외/취소 시 안전하게 정리하세요.
📊 진행률 바와 로그 UI 동기화 팁
진행률과 로그는 사용자 체감 품질을 좌우합니다.
수치가 튀거나 로그가 폭주하면 반응성이 나빠 보이죠.
핵심은 emit 빈도 제어, 중복 갱신 억제, 버퍼링과 배치 출력입니다.
UI 스레드에서는 최소한의 조작만 수행하고, 문자열 조합·포맷팅 같은 비용은 워커가 아닌 메인 스레드에서 가볍게 처리하거나, 이미 완성된 텍스트를 전달받아 즉시 추가하는 방식을 택합니다.
프로그레스 바는 선형 증가가 체감상 가장 안정적이므로, 갑작스런 큰 폭 점프 대신 보간을 사용하면 깔끔하게 보입니다.
🧭 UI 동기화 전략
1) 중복 setValue 방지: 최근 값과 같으면 무시합니다.
2) 로그 배치: 워커는 메시지를 바로 append 하지 않고, UI에서 일정 주기(예: 50~100ms)로 묶어 출력하여 스크롤 지연을 줄입니다.
3) 상태 라인(한 줄)과 히스토리(여러 줄)를 분리해 중요한 정보는 즉시, 상세 로그는 배치로 반영합니다.
4) 불확실한 단계는 QProgressBar.setRange(0,0)로 비결정(indeterminate) 표시 후, 범위가 확정되면 0~100으로 되돌립니다.
from PySide6.QtCore import Qt, QTimer, Slot
from PySide6.QtWidgets import QProgressBar, QLabel, QTextEdit
class UiSyncMixin:
def __init__(self):
self._last_progress = -1
self._log_buffer = []
self._log_timer = QTimer(self)
self._log_timer.setInterval(80) # 80ms마다 배치 플러시
self._log_timer.timeout.connect(self._flush_logs)
@Slot(int)
def on_progress(self, v: int):
# 중복 호출 억제
if v == self._last_progress:
return
# 부드러운 보간(옵션): 큰 점프일 때만 중간값을 빠르게 채움
step = 3 if v - self._last_progress > 10 else 0
if step > 0 and self._last_progress >= 0:
for x in range(self._last_progress + step, v, step):
self.bar.setValue(x)
self.bar.setValue(v)
self._last_progress = v
self.status.setText(f"{v}%")
@Slot(str)
def on_message(self, msg: str):
# 배치 버퍼에 저장
self._log_buffer.append(msg)
if not self._log_timer.isActive():
self._log_timer.start()
def _flush_logs(self):
if not self._log_buffer:
self._log_timer.stop()
return
# 한 번에 묶어 추가 → append 비용/스크롤 이벤트 최소화
chunk = "\n".join(self._log_buffer)
self.log.moveCursor(self.log.textCursor().End)
self.log.insertPlainText(chunk + "\n")
self._log_buffer.clear()
# 메모리 보호: 오래된 로그는 자동 제거
max_blocks = 2000
if self.log.document().blockCount() > max_blocks:
self.log.document().setMaximumBlockCount(max_blocks)
💡 TIP: 로그 위젯은 QTextEdit.setMaximumBlockCount()로 블록 수를 제한하면 장시간 실행에서도 메모리 사용량이 안정적입니다.
또한 큰 텍스트를 자주 append 하면 레이아웃 재계산이 비싸므로, 배치로 묶어 넣는 것이 효과적입니다.
| UI 요소 | 권장 동기화 방식 |
|---|---|
| QProgressBar | 중복 값 무시, 큰 점프 시 보간, 불확실 단계는 range(0,0)로 전환. |
| 상태 라벨(QLabel) | 최근 상태만 유지. 길어지면 말줄임표 처리 및 툴팁에 전체 표시. |
| 로그(QTextEdit) | 배치 추가, blockCount 제한, 자동 스크롤은 마지막 줄에서만. |
- 📉같은 진행률 값에 대한 setValue 호출은 건너뛰기
- 🗂️로그는 버퍼→주기적 플러시 전략으로 처리
- 🧭불확실 단계는 indeterminate로 표기 후 확정 시 전환
⚠️ 주의: 워커에서 print()로 표준 출력에 로그를 남기고, UI에서도 동일 메시지를 추가하면 중복 출력과 성능 저하가 발생할 수 있습니다.
로그 경로를 통일하고, 필요 시 레벨 필터링(INFO, WARN, ERROR)을 적용하세요.
❓ 자주 묻는 질문 (FAQ)
QRunnable과 QThread의 차이는 무엇인가요?
emit할 때 데이터 타입은 어떤 제약이 있나요?
QueuedConnection을 꼭 명시해야 하나요?
작업 중 에러 발생 시 어떻게 처리하나요?
취소 버튼을 눌러도 즉시 멈추지 않는 이유는?
progressBar.setValue()가 늦게 반영됩니다.
GUI가 간헐적으로 멈추는 이유는?
QThreadPool의 스레드 수는 자동 조절되나요?
🧠 PySide QRunnable 진행률 관리 핵심 정리
PySide 애플리케이션에서 QRunnable 기반 백그라운드 작업을 설계할 때 가장 중요한 점은 스레드 안전성과 사용자 체감 반응성입니다.
QRunnable은 단발성 태스크를 효율적으로 처리하며, 진행률을 시그널로 emit한 뒤 메인 스레드에서 QueuedConnection을 통해 UI를 갱신하면 끊김 없는 경험을 제공합니다.
또한 QThreadPool을 사용하면 스레드 생성·해제 비용을 최소화하면서 여러 워커를 효율적으로 관리할 수 있습니다.
작업 도중 취소가 필요한 경우 강제 종료 대신 협조적 취소 방식을 도입하고, UI 업데이트는 빈도를 제한하여 부하를 최소화해야 합니다.
로그 출력은 배치 처리, 프로그레스 바는 보간 및 중복 억제 전략을 적용하면 훨씬 매끄러운 사용자 인터페이스를 완성할 수 있습니다.
결국 QRunnable(emit progress) → GUI 업데이트(QueuedConnection) 패턴은 Qt for Python의 스레드 모델을 가장 직관적으로 활용하는 레시피입니다.
이 구조를 익혀 두면 영상 처리, 대용량 파일 작업, 네트워크 다운로드 등 다양한 비동기 작업에 그대로 응용할 수 있습니다.
모듈화된 시그널 설계와 이벤트 중심 구조를 유지한다면, 프로젝트 규모가 커져도 UI 멈춤 없이 안정적인 사용자 경험을 유지할 수 있습니다.
🏷️ 관련 태그 : PySide6, QRunnable, QThreadPool, QueuedConnection, progress emit, GUI 업데이트, Python Qt, 백그라운드 작업, 스레드 안전성, 진행률 표시