PySide6로 데스크톱 앱 만들기 #3 시그널과 슬롯 — 이벤트 처리의 핵심

5 분 소요

지난 글에서 만든 할 일 앱은 화면만 있고 동작이 없습니다. 버튼을 눌렀을 때 무슨 일이 일어날지를 정하는 것이 이번 글의 주제이고, Qt에서 그 연결을 담당하는 모델이 시그널과 슬롯입니다. 이 모델 하나가 Qt 프로그래밍 전체를 관통하므로, 여기서 정확히 잡아 두면 남은 편이 전부 수월해집니다.

총 7편으로 구성됩니다.

  • #1 PySide6란 — Qt와 파이썬으로 데스크톱 앱
  • #2 위젯과 레이아웃 — 화면을 조립하는 법
  • #3 시그널과 슬롯 — 이벤트 처리의 핵심 ← 이번 글
  • #4 Qt Designer와 UI 파일 — 화면을 그려서 불러오기
  • #5 모델과 뷰 — 리스트·테이블에 데이터 연결
  • #6 스레드와 타이머 — 멈추지 않는 UI
  • #7 패키징과 배포 — PyInstaller로 실행 파일 만들기

시그널과 슬롯 — 발신과 수신 #

모델은 두 단어로 요약됩니다. 위젯에 어떤 일이 생기면 시그널이 발신되고, 그 시그널에 슬롯(받아서 실행될 함수)을 연결해 두면 자동으로 호출됩니다.

가장 단순한 연결
def on_add_clicked():
    print("추가 버튼이 눌렸습니다")

self.add_button.clicked.connect(on_add_clicked)

clicked는 QPushButton이 미리 갖추고 있는 시그널입니다. 버튼이 눌리는 순간 Qt가 연결된 함수를 호출합니다. 함수를 호출하는 코드를 어디에도 쓰지 않았다는 점이 중요합니다. 언제 실행할지는 이벤트 루프가 결정하고, 무엇을 실행할지만 connect로 등록하는 구조입니다.

연결 대상은 보통 클래스 메서드입니다. 지난 글의 TodoWindow에 연결하면 이렇게 됩니다.

메서드를 슬롯으로 연결
class TodoWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        # ... #2에서 만든 화면 조립 코드 ...

        self.add_button.clicked.connect(self.add_todo)

    def add_todo(self):
        print("추가 버튼이 눌렸습니다")

인자를 실어 나르는 시그널 #

시그널은 단순한 알림을 넘어 값을 함께 전달합니다. 입력창의 textChanged는 바뀐 텍스트를 str로, 콤보박스의 currentIndexChanged는 선택된 위치를 int로 넘겨 줍니다.

인자 있는 시그널
def on_text_changed(text: str):
    print(f"입력 내용: {text}")

def on_index_changed(index: int):
    print(f"선택 위치: {index}")

self.todo_input.textChanged.connect(on_text_changed)
combo.currentIndexChanged.connect(on_index_changed)

슬롯의 매개변수가 시그널이 보내는 값을 그대로 받습니다. 어떤 시그널이 어떤 값을 보내는지는 공식 문서의 각 위젯 페이지에 정리되어 있고, 실무에서는 자동 완성으로 시그널 이름을 확인하는 흐름이 일반적입니다.

할 일 앱에 동작 연결하기 #

이제 화면 뼈대에 실제 동작을 연결합니다. 추가, 삭제, 전체 삭제 세 가지입니다.

TodoWindow — 동작 연결
class TodoWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        # ... #2에서 만든 화면 조립 코드 ...

        # 시그널 연결 — 화면 조립 뒤에 모아서
        self.add_button.clicked.connect(self.add_todo)
        self.todo_input.returnPressed.connect(self.add_todo)
        self.delete_button.clicked.connect(self.delete_selected)
        self.clear_button.clicked.connect(self.todo_list.clear)

    def add_todo(self):
        text = self.todo_input.text().strip()
        if not text:
            return
        self.todo_list.addItem(text)
        self.todo_input.clear()

    def delete_selected(self):
        for item in self.todo_list.selectedItems():
            row = self.todo_list.row(item)
            self.todo_list.takeItem(row)

세 가지 연결 방식이 모두 들어 있습니다.

  • 한 슬롯에 여러 시그널 — 추가 버튼의 clicked와 입력창의 returnPressed(엔터 키)를 같은 add_todo에 연결했습니다. 버튼 클릭과 엔터 입력이 같은 동작을 하는 이유입니다.
  • 위젯의 내장 슬롯 직접 연결 — 전체 삭제는 함수를 만들 필요 없이 QListWidget.clear를 그대로 연결했습니다. 시그널을 받을 수 있는 것은 내가 만든 함수만이 아닙니다.
  • 입력 검증은 슬롯 안에서 — 빈 문자열이면 추가하지 않는 처리처럼, 슬롯이 실행 시점의 상태를 읽고 판단합니다.

실행해 보면 입력, 추가, 선택 삭제, 전체 삭제가 전부 동작하는 앱이 됩니다.

커스텀 시그널 — 내 클래스도 발신자가 된다 #

내장 시그널만으로는 부족한 순간이 옵니다. 예를 들어 입력 부분을 별도 위젯 클래스로 분리했다면, 그 위젯이 “새 할 일이 제출됐다"는 사실을 바깥에 알려야 합니다. 이때 Signal로 시그널을 직접 선언합니다.

커스텀 시그널 선언과 발신
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QWidget, QHBoxLayout, QLineEdit, QPushButton


class TodoInput(QWidget):
    submitted = Signal(str)          # 클래스 속성으로 선언

    def __init__(self):
        super().__init__()
        self.line = QLineEdit()
        button = QPushButton("추가")
        layout = QHBoxLayout(self)
        layout.addWidget(self.line)
        layout.addWidget(button)

        button.clicked.connect(self.submit)
        self.line.returnPressed.connect(self.submit)

    def submit(self):
        text = self.line.text().strip()
        if text:
            self.submitted.emit(text)   # 시그널 발신
            self.line.clear()

사용하는 쪽은 이 위젯의 내부 구조를 전혀 모른 채 연결만 합니다.

사용하는 쪽
self.todo_input = TodoInput()
self.todo_input.submitted.connect(self.todo_list.addItem)

시그널은 클래스 속성으로 Signal(타입)을 선언하고, 발신은 emit(값)으로 합니다. 이렇게 하면 입력 위젯은 “제출됐다"고 알리기만 하고, 그 값을 목록에 넣을지 파일에 저장할지는 연결하는 쪽이 정합니다.

콜백과 무엇이 다른가 #

함수를 등록해 두고 나중에 호출되게 한다는 점에서 시그널-슬롯은 콜백과 비슷해 보입니다. 차이는 결합의 방향에 있습니다.

  • 발신자는 수신자를 모릅니다. TodoInput은 submitted를 누가 받는지 알지 못하고, 알 필요도 없습니다. 콜백처럼 “호출할 함수"를 발신자가 들고 있지 않습니다.
  • 다대다 연결이 됩니다. 하나의 시그널에 슬롯 여러 개를 연결할 수 있고, 여러 시그널이 한 슬롯으로 모일 수도 있습니다. 연결마다 코드가 늘어나지 않습니다.

이 성질 덕분에 위젯을 부품처럼 분리해도 결합이 느슨하게 유지됩니다. 앱이 커질수록 효과가 커지는 구조입니다.

노트
연결을 끊어야 할 때는 button.clicked.disconnect(슬롯)을 쓰고, 값을 코드로 변경하는 동안 시그널이 연쇄 발신되는 것을 막고 싶을 때는 widget.blockSignals(True)로 잠시 침묵시켰다가 False로 되돌립니다. 슬롯 안에서 위젯 값을 바꾸는 코드가 다시 같은 슬롯을 부르는 무한 반복이 의심될 때 먼저 확인할 도구입니다.

마무리 #

이번 글의 핵심은 세 가지입니다.

  • Qt의 이벤트 처리는 시그널 발신과 슬롯 연결로 이루어지고, 실행 시점은 이벤트 루프가 결정합니다.
  • 한 슬롯에 여러 시그널을 연결할 수 있고, 내장 슬롯도 연결 대상이 됩니다. 할 일 앱의 추가·삭제가 이 방식으로 완성됐습니다.
  • Signal(타입) 선언과 emit으로 내 클래스도 발신자가 되고, 발신자가 수신자를 모르는 느슨한 구조를 유지할 수 있습니다.

다음 글인 “PySide6로 데스크톱 앱 만들기 #4 Qt Designer와 UI 파일 — 화면을 그려서 불러오기"에서는 화면 조립을 코드가 아니라 마우스로 하는 방법을 다룹니다. Qt Designer로 화면을 그리고, 그 결과물을 파이썬 코드에서 불러와 시그널을 연결하는 흐름입니다.

X