본문 바로가기
Data Engineering & Automation/Python 자동화

PySide6로 이해하기 1편: QMainWindow와 화면 뼈대 잡기

by JINJINC 2026. 6. 5.
728x90
반응형

PySide6 UI 코드를 하나씩 이해해보려 한다.

PySide6를 처음 보면 위젯 이름이 많이 나온다. QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QTabWidget, QTableWidget 같은 이름이 한꺼번에 등장한다. 처음에는 이것들이 각각 무엇을 하는지보다, 화면이 어떤 순서로 조립되는지 감을 잡는 것이 더 중요하다.

이번 글에서는 앱이 켜지는 지점부터 MainWindow가 화면의 큰 뼈대를 만드는 과정까지 살펴본다.

PySide6 앱은 어디서 시작될까

이 프로젝트의 시작점은 main.py다.

import sys
from PySide6.QtWidgets import QApplication
from app.ui.main_window import MainWindow


def main():
    app = QApplication(sys.argv)
    window = MainWindow()
    window.showMaximized()
    sys.exit(app.exec())

여기에는 PySide6 앱을 실행할 때 필요한 핵심 흐름이 모두 들어 있다.

  1. QApplication을 만든다.
  2. 직접 만든 MainWindow 창을 만든다.
  3. 창을 화면에 보여준다.
  4. 이벤트 루프를 실행한다.

QApplication은 PySide6 프로그램 전체를 관리하는 객체다. 마우스 클릭, 키보드 입력, 창 닫기, 화면 다시 그리기 같은 일을 처리하려면 반드시 필요하다.

app = QApplication(sys.argv)

sys.argv는 프로그램 실행 인자를 넘겨주는 역할을 한다. 지금 프로젝트에서는 특별한 실행 옵션을 직접 다루지 않지만, PySide6 앱을 만들 때 흔히 쓰는 기본 형태다.

그 다음 줄에서 실제 창을 만든다.

window = MainWindow()

MainWindow는 PySide6가 제공하는 기본 창이 아니라, 우리가 app/ui/main_window.py에서 만든 클래스다.

마지막으로 app.exec()를 호출한다.

sys.exit(app.exec())

여기서 이벤트 루프가 시작된다. 이벤트 루프는 사용자가 버튼을 누르거나 테이블 셀을 클릭할 때까지 기다렸다가, 해당 이벤트에 연결된 함수를 실행한다. GUI 프로그램이 터미널 프로그램과 다른 점이 바로 여기에 있다. 위에서 아래로 한 번 실행되고 끝나는 것이 아니라, 창이 열려 있는 동안 계속 사용자의 행동을 기다린다.

MainWindow는 어떤 창일까

MainWindow 클래스는 QMainWindow를 상속한다.

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

QMainWindow는 PySide6에서 “메인 창”을 만들 때 쓰는 기본 클래스다. 일반 QWidget으로도 창을 만들 수 있지만, QMainWindow는 앱의 중심 창에 필요한 구조를 미리 갖고 있다.

예를 들어 메뉴바, 툴바, 상태바, 중앙 영역 같은 개념을 지원한다. 이 프로젝트에서는 메뉴바나 툴바를 쓰지는 않지만, 앱 전체를 담는 최상위 창으로 QMainWindow를 사용한다.

super().__init__()는 부모 클래스인 QMainWindow의 초기화 코드를 실행한다. 이 줄이 있어야 PySide6 창으로서 필요한 내부 준비가 끝난다.

창 제목, 크기, 아이콘 설정하기

초기화가 시작되면 먼저 창 자체의 기본 정보를 설정한다.

self.setWindowTitle(f"{APP_NAME} v{APP_VERSION} - {APP_SUBTITLE}")
self.resize(1280, 820)
if LOGO_PATH.exists():
    self.setWindowIcon(QIcon(str(LOGO_PATH)))

setWindowTitle()은 창 제목 표시줄에 보이는 텍스트를 정한다.

resize(1280, 820)은 기본 창 크기를 지정한다. 실제 실행에서는 main.py에서 window.showMaximized()를 호출하므로 최대화된 상태로 열린다. 그래도 기본 크기를 지정해두면 최대화하지 않고 띄우거나, 창이 복원될 때 사용할 기준 크기가 생긴다.

setWindowIcon()은 창 아이콘을 지정한다. 이 프로젝트에서는 로고 파일이 있을 때만 아이콘을 적용한다.

if LOGO_PATH.exists():
    self.setWindowIcon(QIcon(str(LOGO_PATH)))

파일이 없을 때 오류를 내지 않고 그냥 넘어가는 방식이다. UI 리소스 파일은 환경에 따라 없을 수 있으므로 이런 확인이 들어가면 실행이 덜 깨진다.

UI 상태를 저장할 변수들

창 정보 다음에는 앱이 기억해야 할 상태를 초기화한다.

self.raw_df = None
self.source_df = None
self.result_df = None
self.current_file_path = None
self.current_sheet_name = None
self.selected_header_row = None
self.settings = QSettings(AUTHOR_NAME, APP_NAME)
self.active_field_keys = [field["key"] for field in get_default_dwc_fields()]
self.combo_boxes = {}
self.manual_inputs = {}

이 변수들은 화면 위젯 자체라기보다, 사용자가 작업하면서 바뀌는 현재 상태를 담는다.

  • raw_df: 헤더를 적용하기 전 원본 데이터
  • source_df: 헤더를 적용한 뒤 실제 매핑에 사용할 데이터
  • result_df: Darwin Core 형식으로 변환한 결과 데이터
  • current_file_path: 현재 열어둔 파일 경로
  • current_sheet_name: 현재 선택한 시트 이름
  • selected_header_row: 사용자가 헤더로 선택한 행 번호
  • active_field_keys: 현재 매핑 UI에 보여줄 Darwin Core 필드 목록
  • combo_boxes: 필드별 콤보박스 위젯
  • manual_inputs: 필드별 직접 입력 위젯

PySide6를 배울 때 위젯만 보면 화면은 그릴 수 있지만, 앱이 어떤 상태인지 관리하기 어렵다. 이 프로젝트에서는 데이터프레임 상태와 UI 위젯 참조를 MainWindow가 함께 들고 있다.

마지막에 _init_ui()를 호출한다.

self._init_ui()

이 메서드가 실제 화면 뼈대를 만드는 곳이다.

centralWidget이 필요한 이유

_init_ui()의 첫 줄은 다음과 같다.

central = QWidget()
self.setCentralWidget(central)

QMainWindow는 아무 위젯이나 바로 레이아웃으로 가질 수 없다. 대신 중앙 영역에 들어갈 위젯을 하나 정해야 한다. 그 역할을 하는 것이 centralWidget이다.

여기서는 빈 QWidget을 하나 만들고, setCentralWidget()으로 메인 창의 중앙 영역에 넣었다.

이제부터 화면에 들어갈 대부분의 위젯은 이 central 안에 배치된다.

비유하면 QMainWindow가 건물이라면, central은 실제 가구를 놓을 수 있는 큰 방이다. 이 방 안에 레이아웃을 깔고, 그 위에 버튼, 테이블, 패널을 올린다.

가장 바깥 레이아웃 만들기

central 안에는 세로 레이아웃을 하나 만든다.

root_layout = QVBoxLayout(central)
root_layout.setContentsMargins(14, 14, 14, 14)
root_layout.setSpacing(10)

QVBoxLayout은 위젯을 위에서 아래로 쌓는 레이아웃이다. 이 프로젝트의 전체 화면은 크게 보면 세로 구조다.

위에서부터 다음 순서로 배치된다.

  1. 상단 제어 패널
  2. 가운데 작업 영역
  3. 하단 푸터

setContentsMargins(14, 14, 14, 14)는 레이아웃 바깥 여백을 지정한다. 왼쪽, 위, 오른쪽, 아래 순서다.

setSpacing(10)은 레이아웃 안에 들어가는 위젯 사이의 간격을 지정한다.

레이아웃을 이해할 때 중요한 점은, PySide6에서는 위젯의 좌표를 직접 계산하지 않는다는 것이다. 대부분의 경우 “이 위젯들을 세로로 쌓아줘”, “이 위젯들을 가로로 놓아줘”, “이 영역은 더 넓게 써줘”처럼 레이아웃에게 배치를 맡긴다.

상단 제어 패널

상단에는 파일 업로드 버튼, 헤더 적용 버튼, 결과 확인 버튼, 저장 버튼, 현재 파일 상태 같은 정보가 모인다.

이 영역은 QFrame으로 만든다.

control_panel = QFrame()
control_panel.setObjectName("controlPanel")
control_layout = QVBoxLayout(control_panel)

QFrameQWidget과 비슷하지만, 테두리나 배경을 가진 영역을 만들 때 자주 쓴다. 이 프로젝트에서는 스타일시트에서 QFrame#controlPanel을 지정해 상단 패널에 배경색과 테두리를 입힌다.

control_panel.setObjectName("controlPanel")

objectName은 스타일을 적용할 때 이름표처럼 사용할 수 있다. 나중에 스타일시트에서 다음처럼 특정 위젯만 선택할 수 있다.

QFrame#controlPanel {
    background: #fbfdff;
    border: 1px solid #d6e0ea;
    border-radius: 12px;
}

상단 패널 안에도 다시 세로 레이아웃이 들어간다. 위에는 로고와 버튼들이 있고, 아래에는 파일/시트/헤더 상태가 있다.

control_layout.addLayout(title_layout)
control_layout.addLayout(status_layout)
root_layout.addWidget(control_panel)

이렇게 레이아웃 안에 다른 레이아웃을 넣으면서 화면 구조를 만든다.

가운데 작업 영역은 QSplitter로 나눈다

상단 패널 아래에는 실제 작업 영역이 들어간다.

content_splitter = QSplitter(Qt.Horizontal)
content_splitter.setChildrenCollapsible(False)

QSplitter는 사용자가 경계선을 드래그해서 영역 크기를 조절할 수 있게 해주는 위젯이다. Qt.Horizontal을 넘기면 좌우로 나뉜다.

이 프로젝트의 가운데 영역은 좌우로 나뉜다.

  • 왼쪽: 원본 엑셀 미리보기
  • 오른쪽: 매핑 작업과 결과 미리보기
content_splitter.addWidget(left_panel)
content_splitter.addWidget(right_panel)
content_splitter.setStretchFactor(0, 4)
content_splitter.setStretchFactor(1, 9)
content_splitter.setSizes([330, 900])

addWidget()으로 왼쪽 패널과 오른쪽 패널을 추가한다.

setStretchFactor(0, 4)setStretchFactor(1, 9)는 화면 크기가 바뀔 때 두 영역이 어느 비율로 늘어날지 알려준다. 오른쪽 작업 영역이 더 넓게 필요하므로 9를 줬고, 왼쪽 원본 미리보기는 4를 줬다.

setSizes([330, 900])은 처음 표시될 때의 대략적인 크기다.

왼쪽 패널: 원본 미리보기

왼쪽 패널은 일반 QWidget으로 만들고, 그 안에 세로 레이아웃을 둔다.

left_panel = QWidget()
left_panel.setMinimumWidth(300)
left_layout = QVBoxLayout(left_panel)
left_layout.setContentsMargins(0, 0, 0, 0)
left_layout.setSpacing(10)

왼쪽에는 섹션 제목, 현재 선택 위치 라벨, 원본 미리보기 테이블이 들어간다.

left_layout.addWidget(
    self._build_section_header(
        "원본 엑셀 미리보기",
        "행을 클릭해서 실제 헤더로 사용할 줄을 고르세요.",
    )
)

self.preview_position_label = QLabel("현재 선택 위치: 없음")
left_layout.addWidget(self.preview_position_label)

self.preview_table = QTableWidget()
left_layout.addWidget(self.preview_table)

_build_section_header()는 제목과 설명이 들어간 작은 카드형 위젯을 만들어준다. 같은 모양의 섹션 헤더가 여러 곳에서 필요하기 때문에 메서드로 빼둔 것이다.

QTableWidget은 표 형태 데이터를 보여주는 위젯이다. 여기서는 원본 엑셀/CSV 데이터를 미리보기로 표시하고, 사용자가 헤더로 사용할 행을 클릭할 수 있게 한다.

self.preview_table.cellClicked.connect(self.select_header_row)

이 줄은 테이블 셀이 클릭되었을 때 select_header_row() 메서드를 실행하도록 연결한다. 이벤트 연결은 다음 편에서 더 자세히 볼 예정이다.

오른쪽 패널: 탭과 작업 영역

오른쪽 패널도 QWidget과 세로 레이아웃으로 시작한다.

right_panel = QWidget()
right_panel.setMinimumWidth(620)
right_layout = QVBoxLayout(right_panel)

오른쪽 안에는 QTabWidget이 들어간다.

self.main_tabs = QTabWidget()

이 프로젝트에는 두 개의 탭이 있다.

  • 매핑 작업
  • 자동 매핑 설정
self.main_tabs.addTab(mapping_tab, "매핑 작업")
self.main_tabs.addTab(self._build_auto_mapping_settings_tab(), "자동 매핑 설정")

탭을 쓰면 한 화면 안에서 서로 다른 작업 영역을 나눌 수 있다. 매핑 작업은 자주 쓰는 주요 화면이고, 자동 매핑 설정은 필요할 때 들어가는 보조 화면이다.

매핑 작업 탭 안의 세로 분할

매핑 작업 탭 안에는 다시 QSplitter가 있다.

workspace_splitter = QSplitter(Qt.Vertical)
workspace_splitter.setChildrenCollapsible(False)

이번에는 Qt.Vertical이므로 위아래로 나뉜다.

  • 위쪽: GBIF 필드 매핑
  • 아래쪽: 변환 결과 미리보기
workspace_splitter.addWidget(mapping_panel)
workspace_splitter.addWidget(result_panel)
workspace_splitter.setStretchFactor(0, 5)
workspace_splitter.setStretchFactor(1, 5)
workspace_splitter.setSizes([360, 360])

위아래 영역은 같은 비율로 시작한다. 사용자는 필요에 따라 매핑 영역을 더 크게 보거나, 결과 테이블을 더 크게 볼 수 있다.

이렇게 보면 전체 화면은 다음 구조로 되어 있다.

QMainWindow
└─ central QWidget
   └─ root QVBoxLayout
      ├─ 상단 control_panel
      ├─ content_splitter (좌우)
      │  ├─ left_panel: 원본 미리보기
      │  └─ right_panel
      │     └─ main_tabs
      │        ├─ 매핑 작업 탭
      │        │  └─ workspace_splitter (상하)
      │        │     ├─ 매핑 패널
      │        │     └─ 결과 패널
      │        └─ 자동 매핑 설정 탭
      └─ footer_label

PySide6 화면을 읽을 때는 이런 트리 구조를 머릿속에 그리면 훨씬 이해하기 쉽다.

C#이나 Java GUI와 비교해보기

PySide6가 처음 낯설게 느껴지는 이유는 이름이 달라서인 경우가 많다. 하지만 GUI 프로그램의 큰 개념은 C#이나 Java와 꽤 비슷하다.

먼저 앱 시작 흐름을 비교해보면 QApplication은 C# WinForms의 Application.Run(...)이나 Java Swing의 이벤트 디스패치 스레드와 비슷한 역할을 한다.

app = QApplication(sys.argv)
window = MainWindow()
window.showMaximized()
sys.exit(app.exec())

PySide6에서는 QApplication 객체를 만들고, 마지막에 app.exec()로 이벤트 루프를 시작한다. C# WinForms에서는 보통 다음처럼 쓴다.

Application.Run(new MainForm());

Java Swing에서는 대개 다음처럼 이벤트 디스패치 스레드 안에서 창을 만든다.

SwingUtilities.invokeLater(() -> {
    new MainFrame().setVisible(true);
});

문법은 다르지만 핵심은 같다. GUI 프로그램은 창을 만든 뒤 이벤트 루프에 들어가고, 사용자의 클릭이나 입력이 들어올 때마다 연결된 코드가 실행된다.

메인 창도 비슷하게 대응된다.

PySide6 C# WinForms/WPF Java Swing/JavaFX 역할
QMainWindow Form, Window JFrame, Stage 앱의 최상위 창
QWidget Panel, UserControl, Grid JPanel, Pane 화면 일부를 담는 컨테이너
QVBoxLayout StackPanel, FlowLayout 일부 BoxLayout, VBox 세로 배치
QHBoxLayout StackPanel 가로 방향 BoxLayout, HBox 가로 배치
QSplitter SplitContainer, GridSplitter JSplitPane, SplitPane 드래그 가능한 분할 영역
QTabWidget TabControl JTabbedPane, TabPane 탭 화면
QTableWidget DataGridView, DataGrid JTable, TableView 표 데이터 표시

물론 완전히 같은 것은 아니다. 예를 들어 C# WinForms는 디자이너에서 컨트롤을 끌어다 놓고 Dock, Anchor로 배치하는 방식에 익숙한 경우가 많다. PySide6도 Qt Designer를 사용할 수 있지만, 이 프로젝트에서는 파이썬 코드로 직접 위젯과 레이아웃을 만들고 있다.

WPF나 JavaFX를 써본 적이 있다면 PySide6의 레이아웃 방식이 더 익숙할 수 있다. QVBoxLayout, QHBoxLayout, QSplitter를 조합하는 방식은 WPF의 Grid, StackPanel, JavaFX의 VBox, HBox, SplitPane을 조합하는 느낌과 닮아 있다.

가장 중요한 차이는 QMainWindowcentralWidget 개념이다. C# WinForms의 Form에는 버튼이나 패널을 바로 추가하는 느낌이 강하지만, PySide6의 QMainWindow는 중앙 영역을 담당할 위젯을 먼저 지정한다.

central = QWidget()
self.setCentralWidget(central)
root_layout = QVBoxLayout(central)

이 구조는 “메인 창 자체에 모든 것을 바로 붙인다”기보다, “메인 창 안의 중앙 공간에 하나의 큰 컨테이너를 넣고, 그 안에 레이아웃을 깐다”에 가깝다.

그래서 PySide6 코드를 읽을 때는 다음 질문을 하면 좋다.

  • 이 위젯은 최상위 창인가, 내부 컨테이너인가?
  • 이 컨테이너에는 어떤 레이아웃이 깔려 있는가?
  • 위젯들이 직접 좌표로 배치되는가, 레이아웃에 의해 배치되는가?
  • 사용자가 크기를 조절할 수 있는 영역은 QSplitter인가?
  • 여러 화면을 나누는 구조는 QTabWidget인가?

이렇게 보면 PySide6는 완전히 새로운 세계라기보다, C#이나 Java GUI에서 보던 개념을 Qt식 이름과 구조로 다시 만나는 것에 가깝다.

Flutter를 써본 적이 있다면 또 다른 방향으로도 비교할 수 있다. 레이아웃을 코드로 조립한다는 점에서는 PySide6가 Flutter와 꽤 비슷하게 느껴질 수 있다.

예를 들어 이 프로젝트의 바깥 구조는 다음처럼 만들어진다.

central = QWidget()
self.setCentralWidget(central)

root_layout = QVBoxLayout(central)
root_layout.addWidget(control_panel)
root_layout.addWidget(content_splitter)
root_layout.addWidget(footer_label)

Flutter로 생각하면 대략 이런 구조와 닮았다.

Scaffold(
  body: Column(
    children: [
      controlPanel,
      Expanded(child: contentArea),
      footer,
    ],
  ),
)

PySide6의 QVBoxLayout은 Flutter의 Column, QHBoxLayoutRow와 비슷한 감각으로 볼 수 있다. 위젯을 직접 좌표에 배치하기보다, 부모 레이아웃 안에 자식 위젯을 차례로 넣는다는 점이 닮았다.

PySide6 Flutter 느낌
QWidget Widget 화면 조각
QVBoxLayout Column 세로 배치
QHBoxLayout Row 가로 배치
QTabWidget TabBarTabBarView 탭 화면
QLabel Text 텍스트
QPushButton ElevatedButton, TextButton 버튼
QTableWidget DataTable 또는 커스텀 테이블

하지만 상태를 화면에 반영하는 방식은 꽤 다르다.

Flutter는 보통 상태가 바뀌면 setState()나 상태 관리 도구를 통해 build()가 다시 실행되고, 새 상태에 맞는 위젯 트리가 다시 만들어진다.

setState(() {
  fileLabel = "파일: sample.xlsx";
  canExport = true;
});

반면 PySide6에서는 이미 만들어둔 위젯 객체를 self.lbl_file, self.btn_export처럼 보관해두고, 그 위젯의 속성을 직접 바꾸는 흐름이 많다.

self.lbl_file.setText("파일: sample.xlsx")
self.btn_export.setEnabled(True)

그래서 한 줄로 정리하면 이렇다.

PySide6는 화면을 트리로 조립하는 감각은 Flutter와 비슷하고, 이미 만들어진 위젯 객체를 직접 잡고 바꾸는 방식은 C# WinForms나 Java Swing 쪽과 더 비슷하다.

푸터와 스타일시트

가장 아래에는 앱 버전과 저작권 문구를 보여주는 라벨을 추가한다.

footer_label = QLabel(f"{APP_NAME} v{APP_VERSION} · {COPYRIGHT_TEXT}")
footer_label.setObjectName("footerCredit")
footer_label.setAlignment(Qt.AlignCenter)
root_layout.addWidget(footer_label)

QLabel은 텍스트를 보여주는 가장 기본적인 위젯이다. setAlignment(Qt.AlignCenter)로 가운데 정렬을 지정했다.

그 뒤에는 setStyleSheet()로 전체 UI 스타일을 적용한다.

self.setStyleSheet(
    """
    QMainWindow {
        background: #eef3f7;
    }
    QWidget {
        color: #1e293b;
        font-family: "Segoe UI", "Malgun Gothic", sans-serif;
        font-size: 12px;
    }
    ...
    """
)

PySide6의 스타일시트는 CSS와 비슷한 문법을 사용한다. QMainWindow, QWidget, QPushButton 같은 위젯 종류를 기준으로 스타일을 줄 수도 있고, QFrame#controlPanel처럼 특정 이름을 가진 위젯만 고를 수도 있다.

스타일시트는 뒤에서 별도 편으로 더 자세히 다루는 편이 좋다. 이번 글에서는 “화면 뼈대를 먼저 만들고, 마지막에 스타일을 입힌다” 정도만 잡으면 충분하다.

이번 글에서 이해한 것

이번에는 PySide6 앱이 어떻게 시작되고, MainWindow가 화면의 큰 구조를 어떻게 만드는지 살펴봤다.

정리하면 다음과 같다.

  • QApplication은 PySide6 앱 전체와 이벤트 루프를 관리한다.
  • MainWindowQMainWindow를 상속한 앱의 메인 창이다.
  • QMainWindow 안에 실제 내용을 넣으려면 setCentralWidget()으로 중앙 위젯을 지정한다.
  • QVBoxLayoutQHBoxLayout은 위젯을 세로 또는 가로로 배치한다.
  • QSplitter는 사용자가 크기를 조절할 수 있는 분할 영역을 만든다.
  • QTabWidget은 한 창 안에서 여러 작업 화면을 나눌 때 사용한다.
  • 이 프로젝트의 화면은 상단 제어 패널, 좌우 작업 영역, 하단 푸터로 구성된다.
  • C# WinForms/WPF나 Java Swing/JavaFX와 이름은 다르지만, 최상위 창, 컨테이너, 레이아웃, 탭, 테이블이라는 큰 개념은 비슷하다.
  • Flutter와는 코드로 UI 트리를 조립한다는 점이 닮았지만, 상태 변경은 기존 위젯 객체를 직접 수정하는 방식이 더 많이 쓰인다.

PySide6 코드를 처음 읽을 때는 모든 위젯의 세부 기능을 한 번에 외우려고 하지 않아도 된다. 먼저 “어떤 위젯이 어떤 위젯 안에 들어가는가”를 따라가면 된다. 화면은 결국 위젯과 레이아웃이 만든 트리 구조다.

다음 글에서는 상단 버튼들을 중심으로 PySide6의 이벤트 연결을 살펴볼 예정이다. QPushButton을 만들고, clicked.connect(...)로 메서드를 연결하고, 작업 상태에 따라 버튼을 활성화하거나 비활성화하는 흐름을 자세히 볼 것이다.

728x90
반응형

댓글