본문 바로가기
컴퓨터 활용(한글, 오피스 등)/기타

Qt 기반 Python GUI 개발 - 시리얼 모니터 만들기

by 3604 2026. 2. 4.
728x90

 

Python에서 가능한 GUI 개발 라이브러리가 여러 개 있지만 Qt 기반의 PySide6가 가장 편하다 생각한다.

GUI 개발 능력이 있으면, 원하는 툴이 있을때 쉽게 만들 수 있다.

예를 들면 시리얼 통신으로 데이터를 받아오는 툴을 만들 수 있다. 여기에 GUI의 강점이 더해, 실시간으로 받은 데이터를 시각화 할 수 있다.

따라서 시리얼 통신 연결을 하고 받아와 보여주는 툴을 만들어보겠다.

 

PySide6는 pip install pyside6로 쉽게 설치 가능하다. 안정적인 개발환경 구축을 위해 본 예제에서는 파이참과 아나콘다를 사용한다.

 

혹시 PC에 파이참이나 아나콘다가 설치되어 있지 않다면 구글링하여 손쉽게 설치할 수 있다.

 

 

 

 

아나콘다 가상환경과 연동하여 새로운 프로젝트를 만들자. 파이썬 버전은 당장은 큰 상관 없을 것 같다.

 

 

아래에 "Terminal" 창을 열고 "pip install pyside6"로 PySide6를 설치한다.

 

 

설치가 끝났으면, 이어서 터미널 창에 "pyside6-designer" 를 입력한다. Qt는 Qt Designer 라는 프로그램을 제공한다. 이를 통해 GUI를 쉽게 디자인할 수 있다. Qt Designer를 사용하지 않고도 코드를 직접 입력하여 GUI를 디자인할 수 있지만 쉽게 그 코드를 자동으로 생성해주는 툴을 쓰자. PySide6를 설치하면 이 Qt Designer가 같이 설치된다. "pyside6-designer"를 통해 실행시킬 수 있다.

 

 

기본 템플릿을 선택할 수 있다. Widget과 MainWindow가 가장 많이 쓰인다. 어느 하나를 선택한다 해서 비가역적인 선택은 아니지만, 나중에 바꾸려면 귀찮으니 간단하게

재사용성이 높거나 간단한 요소를 디자인할때는 Widget 템플릿을, 집합적인 구성을 디자인할때는 MainWindow를 선택하면 좋다.

어느정도 큰 프로젝트성 프로그램을 만들기 전에는 Widget 템플릿만으로도 충분하다. 여기서는 Widget 템플릿을 선택한다.

 

 

Form이라는 이름의 QWidget이 생성되었다. 오른쪽에 객체 탐색기에서 현재 선택된 Form의 구성 요소를 선택할 수 있고, 그 아래 속성 편집기에서 선택된 요소의 속성을 확인하고 변경할 수 있다. 왼쪽의 위젯 상자에서는 내 Widget에 추가할 수 있는 각 기능 요소들이 있다.

 

일단 시리얼 모니터라는 프로그램이라고 부르기 위해 필요한 요소가 무엇이 있을지 생각해보면서 먼저 간단히 스케치를 해보자.

 

시리얼 통신을 위해 port 이름을 선택하고, buadrate를 선택해야 한다. 통신을 열고/닫고 원하는 입력값을 전송하거나 받은 내용을 보는 창이 필요하다. 이러한 요소를 고려해서 아주 간단하게 스케치해보면 아래와 같다.

 

 

 

그러면 이 스케치를 구현해보자.

 

 

Port는 연결된 포트 목록을 보고 선택하는 방식이 합리적이므로, "Combo Box"를 사용한다. 실제로 보면 매우 익숙한 요소이다. Baudrate도 동일하게 Combo Box를 선택한다. 왼쪽의 위셋 상자에서 Combo Box를 드래그해서 내 Widget 위에 올린다.

 

 

그 다음, 어느 Combo Box가 Port이고 Baudrate인지 알 수 있게 이름을 붙여준다. Label을 두개 추가하자.

"TextLabel"이라는 내용을 가진, 추가된 Label을 더블클릭하면 내용을 변경할 수 있다. Port와 Baudrate로 바꾸어주자.

 

 

 

그 다음은, 열고/닫고/쓰는 버튼을 만드는 것이다. "Push Button"을 3개 추가해주자. 추가하고 "PushButton"이라는 내용을 더블클릭하여 Open/Close/Write로 수정해주자.

 

 

그 다음은 Write의 내용을 입력할 텍스트 박스를 추가해주자. "Line Edit"가 가장 적합하다. 하나 추가해서 Write 옆에 두자.

 

 

마지막으로 Read 된 내용이 표시되는 텍스트창을 추가하자. "Text Edit"이 가장 적합하다. "Plain Text Edit"을 쓸수도 있지만 일단 더 간단한 "Text Edit"을 쓰자. "Line Edit"과의 차이는 Text의 줄바꿈이 텍스트창 안에서 가능한지 아닌지로 일단 구분할 수 있다.

 

이제 넣을 요소는 다 넣었다. 대충 자리 좀 잘 배치하고 Widget의 크기도 좀 조절하자.

 

이제 정렬을 해보자. 정렬을 위해 Layout 위젯을 쓰는게 가장 깔끔하지만 내용이 너무 길어지니 일단 Widget의 배치를 바꾸어보겠다.

 

 

Form의 빈곳을 우클릭하여 "배치"의 "폼 레이아웃으로 배치"를 클릭한다. 잘 정리해놓고 클릭한다면 알아서 잘 정렬된다. 이상하게 배치됐다면 드래그하여 잘 놔보면 된다. 정 안되면 나중에 레이아웃 사용 방법을 설명할테니 우선은 이상한 상태로 넘어가자

 

 

이제 외관을 다 만들었다.

 

 

저장 한번 해주자. 이름은 적당히 편하게 지으면 되는데 지금은 *.ui 파일이지만 나중에 *.py 파일도 생성되므로 GUI 폼 파일임을 명시하기 위해 *_form.ui 형식으로 저장하겠다.

 

아직 폼의 알맹이가 없지만 우선 실행해서 형태를 봐보자.

 

이렇게 만든 *.ui를 PySide6에서 사용하는 방법이 몇가지 있는데, 가장 편한 방법은 *.ui파일을 파이썬 코드로 변환하여 그 코드를 import 하는 것이다.

 

다행히도 Qt Designer와 마찬가지로 PySide6를 설치하면 변환툴이 자동으로 설치되어 사용할 수 있다.

 

 

 

다시 파이참의 터미널로 돌아와서, Qt Designer가 실행되어있는 현재의 터미널을 놔두고 새로 하나 생성하여

"pyside-uic '파일명'.ui >> '파일명'.py"를 입력하자.

 

 

그러면 파일 목록에 '파일명'.py가 생성된다.

 

 

이제 main.py으로 돌아와서 생성된 폼을 불러와 실행하겠다. 내용을 싹 지우고 아래와 같이

PySide6.QtWidgets의 QWidget, QApplication을 import, 생성된 폼 파일의 폼 클래스를 import 하고,

QWidget과 생성된 폼을 상속받고 parent를 매개변수로 받는 클래스를 만든다.

(생성된 폼의 클래스 이름은 지정할 수 있는데, 위의 절차에서 이름을 바꾼적이 없어서 기본값인 "Form"을 이름으로 가지는 클래스명 "Ui_Form"을 사용한다.)

 

그리고 생성자에 self.setupUi(self)를 불러온다. Ui_Form의 UI를 생성하는 함수이다.

 

 

이제 __main__으로 넘어와서, QApplication 객체를 생성하고, 시리얼 모니터 클래스를 생성하여 show 매소드를 불러온다. 그리고 QApplication 객체의 exec 매소드를 불러오면 된다.

 

 

이제 실행을 해보면 여태 만든게 뜨는걸 볼 수 있다. 알맹이가 없어서 버튼을 클릭해도 아무 반응이 없을 것이다.

다음번에 클릭 이벤트와 시리얼 모니터 기능 구현을 진행해보겠다.

 

* 기획중인 연재 흐름: Lidar Point Cloud 취득 -> GUI 기반으로 모니터링하며 디버깅 -> 칼만필터 적용하여 IMU 구현 -> IMU와 연동하여 SLAM 구현 -> GUI 기반 SLAM 모니터 구현 -> 모형차 위에 

껍데기를 만들었으니 이제 알맹이를 만들 차례이다.

 

Qt Widget이 돌아가는 흐름은, 초기화 -> 인터럽트 (시그널 수신에 따른 슬롯 실행) 정도로 볼 수 있다.

Qt는 시그널과 슬롯이라는 것이 있는데, 시그널에 슬롯을 connect해두면, 시그널이 발생될 때 연결된 슬롯이 실행된다.

시그널은 각 위젯마다 고유의 시그널과, 사용자가 임의로 만드는 시그널이 있다. 시그널은 PySide6.QtCore에 Signal이라는 클래스로 만들 수 있다.

위젯의 시그널의 예로는, 푸쉬버튼의 "clicked", 콤보박스의 "CurrentIndexChanged" 같은게 있다.

슬롯은 쉽게 말해 해당 위젯에서 실행되는 함수같은 것이다. 시그널과 마찬가지로 각 위젯 고유의 슬롯이 있고, 슬롯도 사용자가 해당 위젯 클래스의 멤버함수를 정의하는 방식으로 임의의 슬롯을 만들 수 있다.

그리고 시그널과 슬롯은 매개변수를 가지는데(지정할수 있는데) 이를 통해 시그널측에서 슬롯측으로 데이터를 전달할 수 있다.

 

이러한 시그널과 슬롯으로 인터럽트를 구현할 수 있고, 향후에 큰 규모의 어플리케이션 개발을 위해 모듈 단위의 위젯을 개발하게 되는데

각 모듈과 parent 3자 간에 데이터를 공유하는데 시그널과 슬롯을 사용할 수 있다.

하위 모듈에서 parent를 받아 parent를 타고 가서 데이터에 접근할수도 있지만, 이러한 구조는 각 모듈/부모 간의 종속성을 증가시켜서

독립성을 확보하여 재사용성을 높이기 위해 복잡한 구조를 모듈화 하는 것인데, 쓸데없이 종속성을 높이는 방식은 모듈화 하는 의미를 퇴색시킨다.

 

따라서 모듈을 개발하기 앞서 합리적인 시그널과 슬롯을 잘 설계하는 것이 중요하다. 다만 이러한 "큰 그림"을 그리는 일은 어렵다.

첫 술에 배부를 수 없으니 프로젝트 단위로 몇번 삽질을 해보고 갈아 엎어보면서 감을 익히는 것도 자연스러운 과정이다.

 

각설하고, 지금은 외부와 통신할 것이 없으므로 '큰 그림'을 생각할 필요까지는 없고 각 위젯간의 통신과 인터럽트를 구현할 것이다.

 

시그널과 슬롯을 연결하는 방법은 시그널.connect(슬롯)의 방식도 있지만, Qt Designer를 통해 간편하게 만들고 연결할 수 있다.

 

 

 

Qt Designer의 "시그널/슬롯 편집" 버튼을 누르면 시그널/슬롯을 편집할 수 있는 모드로 바뀐다. 다시 위젯을 건들기 위해서는 "위젯 편집" 버튼을 눌러 돌아와야 한다.

"시그널/슬롯 편집" 모드에서 시그널을 표출할 위젯을 클릭-드래그하여, 연결할 슬롯이 있는 위젯에 놓는다.

 

 

시리얼 모니터를 위해서는 버튼 클릭에 따른 시그널에 메인위젯의 슬롯을 연결하여 동작하는 방식이 적합하다.

따라서 Open/Close/Write 버튼 각각을 (바탕 인) 메인위젯에 연결시켜준다.

 

 

드래그하여 연결하면 "연결 설정" 창이 뜨며 시그널과 슬롯을 선택하게 한다. 왼쪽이 시그널, 오른쪽이 슬롯이다.

원하는 시그널이나 슬롯이 없으면 "편집"을 눌러서 직접 선언할 수 있다. 다만 시그널은 "QWidget"과 같은 위젯에서만 만들 수 있다.

우리는 버튼 클릭에 따른 시그널을 슬롯에 연결할 것이기 때문에 "clicked()" 시그널을 선택한다. 시그널을 선택하면 해당 시그널이 반환하는 자료형을 받을 수 있거나 매개변수가 없는 슬롯을 선택할 수 있게 활성화 된다.

우리는 각 버튼에 따른 행동을 새롭게 만들 것이므로 "편집"을 눌러 새로운 슬롯을 만들어 선택한다.

 

 

 

Open/Close/Write에 각각 port_open()/port_close()/serial_write() 정도의 슬롯을 만들어준다.

 

 

그렇게 시그널과 슬롯을 연결해주면 "시그널/슬롯 편집" 모드에서 그 연결이 보인다. 이를 더블클릭하여 편집할 수 있고, 오른쪽 아래 "시그널/슬롯 편집기"를 통해서도 편집할 수 있다. 이렇게 나머지도 연결해준다.

 

 

 

 

 

 

이제 Qt Designer에서 해줄 것이 끝났으므로 저장하고 uic를 실행해 시그널/슬롯 연결 사항이 반영된 코드로 업데이트 한다.

 

 

 

그냥 uic만 실행시키면 기존의 코드 파일에 덮어쓰는게 아닌, 코드 내용물을 '붙이는' 방식으로 업데이트가 될때 있으므로 기존의 form 코드를 삭제하고 uic를 실행시키면 좋다.

 

 

 

시그널/슬롯 연결이 이와 같이 form 코드에 반영된 것을 확인할 수 있다.

이 상태에서 main.py를 실행하게 되면 오류가 나게 된다. 메인위젯에 앞서 연결한 슬롯이 없어서 나는 오류이다.

따라서 메인위젯에 슬롯을 선언해 준다.

슬롯은 사실 @Slot(매개변수) 와 같은 형태로 슬롯임을 표시할 수 있지만, 표시하지 않아도 간단한 사용에서는 문제 없다.

 

 

따라서 우선은 간단히 멤버함수 선언처럼 작성하자. 내용은 일단 버튼이 눌렸음을 알 수 있게 print(슬롯이름) 정도로 넣어서 실행하여 확인해 본다.

 

 

버튼을 누를때마다 print가 되는 것을 확인할 수 있다.

이미 시리얼통신 주고받는 구현까지 캡쳐를 끝내놨지만 시그널/슬롯 설명이 길어져서 슬롯 내용물 구현은 다음번에 이어서 하겠다.

버튼 시그널에 슬롯이 연결된 것까지 확인하였으니 이제 내용물을 구현하자.

 

버튼 클릭에 대한 슬롯 구현에 앞서, 초기화를 위한 몇가지 기능을 먼저 구현할 것이다.

 

시리얼 통신을 위해 YDLIDAR X4 예제와 같이 pyserial을 사용할 것이다.

추가로 PC에 연결된 port 정보를 가져오기 위해 PySide6.QtSerialPort의 QSerialPortInfo 모듈을 사용할 것이다.

pyserial 대신 QtSerialPort에 있는 QSerialPort를 사용할수도 있지만 pyserial이 좀더 편하다. pyserial에서 포트 정보를 가저올 방법을 알아낸다면 굳이 QSerialPortInfo를 쓰지 않아도 된다.

 

 

헤더 영역에 QSerialPortInfo를 불러온다.

 

 

파이썬 콘솔에서 QSerialPortInfo가 어떤 메소드를 가지고 있는지 간단히 확인해보면, availableports() 라는 메소드가 포트 정보를 가져올것 같이 생겼다.

 

 

실제로 해당 메소드를 실행시키면 PC연결된 Port 정보가 QSerialPortInfo 객체의 배열로 반환되고, 해당 객체의 portName() 메소드를 실행해보면 port 이름이 나오는 것을 알 수 있다.

이렇게 가져온 'PC에 연결된 port 정보'를 port 선택 콤보박스에 할당하자.

 

 

앞서 만들어 배치한 콤보박스의 이름은 'comboBox', 'comboBox_2'이다. 해당 이름은 Qt Designer에서 ObjectName 수정을 통해 바꿀 수 있다.

 

콤보박스는 문자열 형식의 아이템을 가지고 리스트로 보여주며, 각 아이템은 인덱스와 'userData'를 가질 수 있다.

'userData'는 해당 아이템에 할당시키고 싶은 객체를 할당하면 된다. 여기서는 문자열인 포트 이름만으로도 충분하므로 데이터를 할당하지는 않겠다.

 

아이템이나 텍스트 등을 추가/설정하는 메소드는 여러개 있다. 여기서는 insertItem을 쓰겠다. 각자의 장단점과 기능차이가 있으니 직접 써보면서 익히는게 좋다.

 

 

포트 정보는 프로그램이 실행되면 초기화 단계에서 한번 갱신하는 것이 좋으므로 생성자에 작성한다.

QSerialPortInfo를 통해 가져온 availablePorts 배열을 for문으로 풀어서 comboBox.insertItem()에 넣어준다. 순서대로 index와 text를 매개변수로 받는다.

참고로 icon이 들어가는 insertItem()가 오버라이딩 되어 있어 매개변수명을 명시하면 (ex. insertItem(index=1, text="COM1") 에러가 난다. 명시 없이 인덱스, 텍스트, 유저데이터 순서로 넣자.

 

 

이제 main.py를 실행해보면 port의 combobox에 연결된 포트 이름이 반영된 것을 볼 수 있다.

 

마찬가지로 baudrate의 combobox도 초기화 해주자.

baudrate는 자주 쓰는 몇가지 속도가 있으니 그걸 수작업으로 넣어주면 된다.

여기서는 9600, 115200 그리고 YDLIDAR X4를 위해 128000을 넣어주겠다.

 

포트 콤보박스와 동일하게, baudrate 배열을 만들고 for문을 이용하여 combobox_2에 넣어준다.

참고로 pyserial은 baudrate를 정수형을 받아가고 combobox는 문자열로 text를 가져가므로

나중에 pyserial에 combobox 아이템을 가져올때 정수형으로 변환하거나, 처음부터 userData에 정수형 baudrate를 병행해서 넣어주면 된다.

여기서는 나중에 정수형으로 캐스팅하는 방법을 이용하겠다.

 

 

실행해보면 보드레이트 또한 제대로 뜨는걸 볼 수 있다.

 

이제 슬롯 부분을 구현할 것이다.

 

pyserial이 설치되어 있지 않다면 설치를 하고 진행한다.

 

pyserial 사용법은 YDLIDAR X4에서 어느정도 다루었으니 사용법 설명은 생략하겠다.

 

 

pyserial 객체를 지역변수로 선언하면 계속 사용할 수 없으므로 생성자에 None 타입으로 먼저 선언해준다.

 

 

port_open 슬롯에서 pyserial을 open해준다.

현재 선택된 콤보박스의 아이템을 가져오는 메소드는 currentText()이다. userData 형태로 보드레이트를 저장했다면 currentData()를 사용하면 된다.

받아온 콤보박스의 텍스트를 이용해 pyserial 객체를 open한다.

 

* 참고로 PySide6의 메소드 이름은 Qt가 크로스 플랫폼 툴인 만큼 모든 언어에서 동일하다. 다만 Qt가 Cpp로 주로 쓰여서 그런지 메소드 이름 규칙은 Cpp 형태를 띄고 있다.

아무튼 크로스 플랫폼의 특성을 가지고 있으므로 Qt의 메소드나 객체를 검색할때는 굳이 PySide6에 한해서 검색하지 않고 PyQt나 cpp에서 설명하는 Qt 예제를 보아도 도움이 된다.

 

 

마찬가지로 port_close 슬롯도 pyserial의 기능을 이용해 구현해준다.

 

 

serial_write 슬롯에서는 lineEdit에 입력한 값을 시리얼로 보내주는 기능을 구현한다.

lineEdit에 현재 놓여있는 텍스트 내용을 가져오려면 lineEdit.text()를 이용하면 된다.

 

그렇게 입력할 메시지를 문자열 형식으로 받아와서 ascii 형식으로 변환하여 pyserial 버스에 입력하면 된다.

문자열을 ascii 형식으로 변환하는 방식은 "문자열".encode('ascii') 이다. 이렇게 시리얼 write 기능을 구현할 수 있다.

 

부가적으로, 송신 한 데이터를 알 수 있도록, 아래의 텍스트창 textEdit에 문자열을 추가해주면 좋다.

textEdit에는 append() 메소드로 맨 아래줄에 문자열을 추가할 수 있다.

송신 데이터이므로 "TX >> " + str(메시지.encode('ascii')) 정도로 표현하여 텍스트창에 써주자.

 

 

  

 

main.py를 실행하여 아무 시리얼 버스나 연결하여 아무 글자를 입력해 write을 눌러보면 아래의 텍스트에딧에 문장이 추가된 것을 볼 수 있다.

좀더 fancy하게 만드려면 시리얼 write 했을때 송신에 성공한 경우에만 텍스트에딧에 문장을 표출하게 만드는 것이 좋다.

 

이제 시리얼 read 기능을 구현하자.

 

사실 read도 버튼으로 만들어 누를때만 수신하게 하면 write 처럼 바로 구현 가능하다.

하지만 read는 비동기적으로 실시간 수신해야 그 기능에 의미가 있으므로 계속 돌아가는 시리얼 수신 기능 구현이 필요하다.

하지만 단일 스레드로 수신을 시작해서 끊기까지 계속 수신한다면 그 사이에 아무 작업도 할 수 없다.

따라서 시리얼 수신 기능을 멀티 스레드로 구현해야 한다.

 

파이썬에서 멀티 스레딩을 구현하는 방법으로 multiprocessing 모듈이 있지만 사용 방식에 제약이 있고,

PySide6에 자체 멀티스레딩 기능 QThread가 있어서 그것을 사용하면 좋다.

 

 

QThread는 PySide6.QtCore에 있다. import해주자.

QThread를 상속하는 객체를 만들어 주자. 일반적인 QWidget과 마찬가지로 parent를 매개변수로 받는 식으로 생성자를 만들어주자.

form을 가지는 위젯으로도 QThread를 상속할 수 있지만 시리얼 수신 기능에 form이 필요하지 않으니 간단한 QThread 객체를 만들어주자.

 

 

QThread 객체는 run 멤버함수를 만들어 그 안에 멀티 스레딩으로 작동할 기능을 구현할 수 있다. 그리고 그 run의 내용은 QThread 객체의 start 메소드를 이용해 실행시킬 수 있다.

run 함수는 매개변수를 받을 수 없으니 생성자에서 멤버 객체로 만들어 미리 전달해주자.

 

시리얼 수신을 위해 QThread의 부모 객체인 메인 위젯을 멤버로 저장하여 시리얼 버스에 접근할 수 있다.

원래는 이처럼 부모 객체를 통한 접근은 좋지 않다. 좀더 fancy하게 만들자면 시리얼 버스 초기화 이후부터의 기능을 QThread 객체에 구현하고 시리얼 버스 객체를 QThread에 전달하여 시리얼 버스 기능 전체를 전담하게 할 수 있다.

 

아무튼 부모 객체를 통해 접근한 시리얼 버스를 이용해 시리얼 수신을 구현하자. 시리얼 수신 while 문에 is_running이라는 변수로 동작의 끝을 조작해줄 수 있다.

 

부모객체인 메인위젯에서 시리얼 수신 QThread 객체를 생성하여 멤버로 추가해 주고,

시리얼 open에서 start해주고, close에서 is_running에 False를 전달하여 QThread의 while문을 종료시켜주자.

 

 

 

간단한 예제를 위해 주기적으로 Hello World를 송신하고, 수신된게 있으면 그 내용을 echo해주는 아두이노 시리얼 통신 모듈을 만들어 연결해준다.

 

 

이제 메인을 실행해 시리얼을 open하면 Hello World가 주기적으로 수신된다.

여기서 Write을 해주면 정상적으로 echo가 되는 것을 볼 수 있다.

 

 

이로서 시리얼 모니터를 구현했다.

 

 

write 기능에 ascii형식, bytes형식을 선택하는 기능을 추가하거나, 수신된 데이터를 시각화하는 기능까지 추가할 여지가 있다.

앞으로 써먹기 위해 다음은 수신된 데이터를 그래프로 실시간 업데이트해주는 기능 추가를 진행하겠다.

 

출처; https://sc.sogang.ac.kr/bbs/bbsview.do?pkid=69584&bbsid=3857&wslID=mecha&searchField=&searchValue=&currentPage=1

728x90