25강. GUI에서 실시간 메시지 받기

0. 학습 목표
→ 이번 글에서 무엇을 이해하고, 무엇을 만들고, 무엇을 확인할지 먼저 정리합니다.
0.1 이번 글에서 다룰 내용
이번 글은 구현 중심 강의입니다.
24강까지는 GUI에서 서버로 메시지를 보낼 수 있었습니다. 하지만 서버에서 오는 메시지를 GUI 화면에 표시하지는 못했습니다. 이번 강의에서는 수신 스레드를 추가해 다른 사용자의 메시지, 시스템 메시지, 오류 메시지를 실시간으로 받아 화면에 표시합니다. 이로써 "보내기 + 받기"가 모두 연결됩니다.
핵심 주의점이 하나 있습니다. 수신 스레드에서 GUI 위젯을 직접 수정하지 않는다는 것입니다. PySide6에서는 화면 위젯을 안전하게 바꾸기 위해 Signal(신호)을 사용합니다.
GUI 클라이언트가 서버에 접속한다
↓
수신 스레드가 시작된다
↓
receive_messages()로 서버 메시지를 계속 받는다
↓
받은 메시지를 Signal로 GUI 메인 흐름에 전달한다
↓
GUI 메인 흐름이 채팅 표시 영역에 메시지를 추가한다 ← 이번 강의의 성공 지점
핵심 성공 기준은 다른 클라이언트가 보낸 메시지가 내 GUI 채팅창에 나타나는 것입니다. 아래 코드에서 ➕는 24강 코드까지는 없던 새로 더해진 줄, ✏️는 위치나 형태가 바뀐 줄을 뜻합니다. 최종 정리 코드에는 마커 없이 깨끗한 코드만 싣습니다.

| 구분 | 내용 |
| 이해할 것 | GUI가 멈추지 않도록 서버 수신을 별도 스레드에서 처리하는 이유와 Signal로 화면을 안전하게 바꾸는 방식 |
| 만들 것 | chat_client/main.py 수정 — 수신 스레드(receive_loop()) + message_received Signal + display_message() |
| 확인할 것 | 터미널 클라이언트나 다른 GUI 클라이언트가 보낸 메시지가 GUI 화면에 표시되는지 |
0.2 이번 강의에서 직접 다루는 구조
이번 강의에서는 24강의 chat_client/main.py를 이어서 수정합니다. protocol.py는 24강에서 쓴 파일을 그대로 사용하며, 이번에는 그 안의 receive_messages()를 처음으로 사용합니다.
chat_server/
├── protocol.py ← 유지
└── server.py ← 유지 (실행용으로만 사용)
chat_client/
├── protocol.py ← 유지 (receive_messages()를 이번 강의에서 처음 사용)
├── client.py ← 유지
└── main.py ← ✏️ 이번 강의에서 수정
(✏️ 수정 · 표시 없음은 변경 없음)
1. 실습 준비 및 수신 흐름 확인하기
→ 서버를 먼저 실행하고, 수신 스레드가 왜 필요한지 확인합니다.
1.1 서버 먼저 실행하기
터미널 1에서 이전 파트의 서버를 실행합니다.
python chat_server/server.py
서버를 시작합니다.
클라이언트 접속 대기 중...
서버 터미널이 종료되지 않고 대기 상태를 유지하면 준비 완료입니다.
1.2 수신 스레드가 필요한 이유

서버 메시지를 받는 recv() 계열 코드는 메시지가 올 때까지 그 자리에서 계속 기다립니다. 이 기다리는 코드를 GUI 메인 흐름에서 직접 실행하면, GUI가 "기다리는 일"에 붙들려 화면을 그리거나 클릭을 처리하지 못합니다. 창이 멈춘 것처럼 보이고, 버튼이 눌리지 않으며, 입력창에 글자를 입력하기 어렵습니다.
그래서 서버 메시지를 받는 일은 화면을 담당하는 메인 흐름과 분리해, 별도 스레드가 맡아야 합니다.
| 작업 | 담당 |
| 버튼 클릭, 입력창 입력, 화면 표시 | GUI 메인 흐름 |
| 서버 메시지 계속 받기 | 수신 스레드 |
✔ 확인 기준: 서버 접속 후에도 GUI 창이 멈추지 않고 입력창과 버튼이 반응하면 완료. 멈춘다면 receive_loop()를 직접 호출하지 않고 스레드로 시작했는지 확인하세요.
2. 클라이언트 - import와 Signal 추가하기
→ threading과 Signal 관련 import를 더하고, 클래스에 message_received 신호를 만듭니다.
threading과 PySide6의 Signal, Slot을 추가합니다. 또 protocol에서 receive_messages도 함께 가져옵니다. 24강에서는 send_message만 가져왔습니다.
import socket
import sys
import threading # ➕ 별도 스레드 실행용
from PySide6.QtCore import Signal, Slot # ➕ 신호/슬롯 사용
from PySide6.QtWidgets import (
QApplication,
QWidget,
QTextEdit,
QLineEdit,
QPushButton,
QLabel,
QVBoxLayout,
QHBoxLayout,
)
from protocol import receive_messages, send_message # ✏️ receive_messages 추가 (24강은 send_message만)
HOST = "127.0.0.1"
PORT = 5000
다음으로 ChatWindow 클래스 맨 위(메서드 바깥, 클래스 속성 자리)에 Signal을 선언합니다. 이 신호는 수신 스레드가 받은 메시지를 GUI 메인 흐름으로 넘길 때 사용합니다. dict는 "딕셔너리 한 개를 실어 보낸다"는 뜻입니다.

class ChatWindow(QWidget):
message_received = Signal(dict) # ➕ 메시지(dict)를 실어 보내는 신호
수신 스레드 안에서 self.chat_display.append()를 직접 호출하면, 화면 위젯을 메인 흐름이 아닌 다른 스레드가 건드리게 되어 충돌이나 멈춤이 생길 수 있습니다. Signal을 사용하면 메시지가 GUI 메인 흐름에서 처리되어 안전합니다.
💡 강사 팁 — 메시지가 화면에 안 뜰 때 먼저 확인할 것Signal(dict)로 선언한 신호는 emit()에 반드시 딕셔너리를 넘겨야 합니다. 다른 타입을 넘기면 PySide6가 조용히 무시하거나 런타임 오류를 내는데, 오류 메시지가 눈에 잘 안 띄어 원인을 찾기 어렵습니다.
# ✅ 올바른 형태 — 딕셔너리를 emit
self.message_received.emit({"type": "chat", "content": text})
# ❌ 잘못된 형태 — 문자열을 emit (Signal(dict)와 타입 불일치)
self.message_received.emit(text)
실습 중 메시지를 보냈는데 상대방 화면에 아무것도 뜨지 않는다면, emit()에 넘기는 값이 딕셔너리인지 먼저 확인하세요. 두 번째로 message_received.connect(self.display_message) 연결 줄이 __init__ 안에 있는지 확인합니다.
수신 스레드
↓ message_received.emit(message)
GUI 메인 흐름
↓
display_message(message) 실행 → chat_display.append(...)
✔ 확인 기준: import threading, from PySide6.QtCore import Signal, Slot, from protocol import receive_messages, send_message가 모두 있고, 클래스 안에 message_received = Signal(dict)가 선언되어 있으면 완료. Signal 선언은 메서드 바깥(클래스 속성)에 두어야 합니다.
3. 클라이언트 - 수신 스레드와 화면 표시 연결하기
→ 스레드 변수 초기화·신호 연결·스레드 시작을 더하고, receive_loop()와 display_message()를 만듭니다.
3.1 __init__에 스레드 변수와 신호 연결 추가하기
__init__ 안에서 소켓 초기화 옆에 스레드 관리 변수를 추가하고, 이벤트 연결 줄 끝에 신호-함수 연결을 한 줄 더합니다.
self.client_socket = None
self.receive_thread = None # ➕ 수신 스레드 보관 변수
self.running = False # ➕ 수신 반복 on/off 플래그
self.connect_button.clicked.connect(self.connect_to_server)
self.send_button.clicked.connect(self.on_send_clicked)
self.message_input.returnPressed.connect(self.on_send_clicked)
self.message_received.connect(self.display_message) # ➕ 신호 → display_message 연결
self.running은 수신 반복을 제어하는 플래그입니다. True이면 receive_loop()가 계속 돌고, False로 바꾸면 다음 루프에서 종료됩니다. 26강에서 연결 종료 처리를 구현할 때 이 플래그를 사용합니다.
3.2 connect_to_server()에서 수신 스레드 시작하기
23강에서 만든 connect_to_server()의 연결 성공 블록 끝에, 수신 스레드를 시작하는 코드를 추가합니다.
self.connect_button.setEnabled(False)
self.message_input.setEnabled(True)
self.send_button.setEnabled(True)
self.running = True # ➕ 수신 반복 시작
self.receive_thread = threading.Thread(target=self.receive_loop) # ➕ 수신 스레드 생성
self.receive_thread.daemon = True # ➕ 메인 종료 시 함께 종료
self.receive_thread.start() # ➕ 스레드 시작
daemon = True는 메인 프로그램(창)이 종료될 때 이 보조 스레드도 함께 종료되도록 합니다. 이 설정이 없으면 창을 닫아도 수신 스레드가 남아 프로그램이 깔끔히 끝나지 않을 수 있습니다.
3.3 receive_loop()와 display_message() 추가하기
receive_loop()는 스레드에서 돌며 메시지를 계속 받고, 받은 메시지를 신호로만 내보냅니다. display_message()는 메인 흐름에서 그 신호를 받아 실제 화면을 바꿉니다.
def receive_loop(self): # ➕ 수신 스레드에서 실행되는 반복
buffer = "" # 아직 처리하지 못한 데이터 임시 보관
while self.running:
try:
buffer, messages, connected = receive_messages(self.client_socket, buffer)
if not connected: # 서버 연결이 끊긴 경우
self.message_received.emit({
"type": "system",
"content": "서버와의 연결이 끊어졌습니다."
})
break
for message in messages: # 받은 메시지를 하나씩 신호로 전달
self.message_received.emit(message)
except Exception as error:
self.message_received.emit({
"type": "error",
"content": f"메시지 수신 중 오류가 발생했습니다: {error}"
})
break
self.running = False # 반복이 끝나면 플래그 정리
@Slot(dict) # ➕ 신호를 받는 슬롯 (메인 흐름에서 실행)
def display_message(self, message):
message_type = message.get("type") # 메시지 종류로 분기
if message_type == "chat":
sender = message.get("sender", "unknown")
content = message.get("content", "")
self.chat_display.append(f"{sender}: {content}")
elif message_type == "system":
content = message.get("content", "")
self.chat_display.append(f"[시스템] {content}")
elif message_type == "error":
content = message.get("content", "")
self.chat_display.append(f"[오류] {content}")
else:
self.chat_display.append(f"[알 수 없는 메시지] {message}")
받는 쪽과 그리는 쪽이 Signal 하나로만 이어집니다.

receive_loop() (수신 스레드)
↓ message_received.emit(message)
display_message() (GUI 메인 흐름)
↓
chat_display.append(...) ← 이번 강의의 성공 지점
| 메시지 type | 화면 표시 형태 |
chat |
보낸사람: 내용 |
system |
[시스템] 내용 |
error |
[오류] 내용 |
| 그 외 | [알 수 없는 메시지] ... |
✔ 확인 기준: 서버 접속 후 수신 스레드가 시작되고, 다른 클라이언트가 보낸 메시지가 GUI 채팅창에 표시되면 완료. 표시되지 않으면 self.message_received.connect(self.display_message)가 있는지부터 확인하세요.
4. 실행 결과 확인하기
→ 서버·GUI 클라이언트·터미널 클라이언트를 함께 실행해 메시지 수신을 확인합니다.
4.1 서버와 GUI 클라이언트 실행하기

터미널 1에서 서버가 대기 중인 상태에서, 터미널 2에서 GUI 클라이언트를 실행합니다.
python chat_client/main.py
"서버 접속" 버튼을 누르면 GUI 채팅창에 다음이 표시됩니다.
[시스템] 서버에 연결되었습니다.
✔ 확인 기준: 접속 후에도 GUI 창이 멈추지 않고 입력창과 버튼이 정상 동작하면, 수신 스레드가 올바르게 시작된 것입니다.
4.2 터미널 클라이언트와 함께 테스트하기

터미널 3에서 기존 터미널 클라이언트를 실행합니다.
python chat_client/client.py
터미널 클라이언트가 접속하면 GUI 화면에 시스템 메시지가 표시됩니다.
[시스템] ('127.0.0.1', 52345) 님이 입장했습니다.
터미널 클라이언트에서 메시지를 입력하면 GUI 화면에도 그 메시지가 나타납니다.
('127.0.0.1', 52345): 안녕하세요 터미널 클라이언트입니다
반대로 GUI에서 보낸 메시지는 터미널 클라이언트에 표시됩니다. 이제 양쪽이 서로의 메시지를 주고받습니다. GUI 클라이언트를 두 개 띄워 테스트해도 동일하게 동작합니다.
✔ 확인 기준: GUI 채팅 표시 영역에 다른 클라이언트의 채팅과 입장(시스템) 메시지가 나타나면 완료. 보이지 않으면 GUI 접속 후 수신 스레드가 시작되었는지 확인하세요.
4.3 실행이 안 될 때 확인할 것
| 오류 상황 | 원인 및 해결 방법 |
| GUI가 멈춘다 | receive_loop()를 스레드가 아니라 직접 호출했습니다. threading.Thread(target=self.receive_loop)로 실행합니다 |
| 서버엔 도착하는데 GUI에 표시 안 됨 | 수신 스레드가 시작되지 않았거나 신호 연결이 빠졌습니다. receive_thread.start()와 message_received.connect(self.display_message)를 확인합니다 |
NameError: threading |
import threading이 빠졌습니다. 파일 위쪽에 추가합니다 |
NameError: Signal |
from PySide6.QtCore import Signal, Slot이 빠졌습니다. 추가합니다 |
ModuleNotFoundError: protocol |
main.py와 같은 chat_client/ 폴더에 protocol.py를 둡니다 |
| 메시지가 엉뚱한 형태로 보인다 | display_message()의 type 분기를 확인합니다. 서버가 보내는 type 값과 비교 문자열이 일치해야 합니다 |
| 종료 시 오류 메시지가 보인다 | 수신 스레드가 소켓 종료 상황을 받는 중입니다. 26강에서 종료 처리를 안정화합니다. 지금은 무시해도 됩니다 |
5. 최종 코드 정리하기
→ 이번 강의에서 완성한 chat_client/main.py 전체 코드를 한곳에 정리합니다.
5.1 chat_client/main.py
import socket
import sys
import threading
from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import (
QApplication,
QWidget,
QTextEdit,
QLineEdit,
QPushButton,
QLabel,
QVBoxLayout,
QHBoxLayout,
)
from protocol import receive_messages, send_message
HOST = "127.0.0.1"
PORT = 5000
class ChatWindow(QWidget):
message_received = Signal(dict)
def __init__(self):
super().__init__()
self.client_socket = None
self.receive_thread = None
self.running = False
self.setWindowTitle("채팅")
self.resize(560, 480)
self.chat_display = QTextEdit()
self.chat_display.setReadOnly(True)
self.chat_display.setPlaceholderText("채팅 메시지가 여기에 표시됩니다.")
self.message_input = QLineEdit()
self.message_input.setPlaceholderText("메시지를 입력하세요")
self.message_input.setEnabled(False)
self.send_button = QPushButton("보내기")
self.send_button.setEnabled(False)
self.status_label = QLabel("상태: 서버에 연결되지 않음")
self.connect_button = QPushButton("서버 접속")
top_layout = QHBoxLayout()
top_layout.addWidget(self.status_label)
top_layout.addWidget(self.connect_button)
input_layout = QHBoxLayout()
input_layout.addWidget(self.message_input)
input_layout.addWidget(self.send_button)
main_layout = QVBoxLayout()
main_layout.addLayout(top_layout)
main_layout.addWidget(self.chat_display)
main_layout.addLayout(input_layout)
self.setLayout(main_layout)
self.connect_button.clicked.connect(self.connect_to_server)
self.send_button.clicked.connect(self.on_send_clicked)
self.message_input.returnPressed.connect(self.on_send_clicked)
self.message_received.connect(self.display_message)
def connect_to_server(self):
try:
self.client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.client_socket.connect((HOST, PORT))
self.status_label.setText("상태: 서버에 연결됨")
self.chat_display.append("[시스템] 서버에 연결되었습니다.")
self.connect_button.setEnabled(False)
self.message_input.setEnabled(True)
self.send_button.setEnabled(True)
self.running = True
self.receive_thread = threading.Thread(target=self.receive_loop)
self.receive_thread.daemon = True
self.receive_thread.start()
except ConnectionRefusedError:
self.status_label.setText("상태: 서버 연결 실패")
self.chat_display.append("[오류] 서버가 실행 중인지 확인하세요.")
except Exception as error:
self.status_label.setText("상태: 서버 연결 실패")
self.chat_display.append(f"[오류] 연결 중 문제가 발생했습니다: {error}")
def receive_loop(self):
buffer = ""
while self.running:
try:
buffer, messages, connected = receive_messages(self.client_socket, buffer)
if not connected:
self.message_received.emit({
"type": "system",
"content": "서버와의 연결이 끊어졌습니다."
})
break
for message in messages:
self.message_received.emit(message)
except Exception as error:
self.message_received.emit({
"type": "error",
"content": f"메시지 수신 중 오류가 발생했습니다: {error}"
})
break
self.running = False
def on_send_clicked(self):
text = self.message_input.text().strip()
if not text:
return
if self.client_socket is None:
self.chat_display.append("[오류] 서버에 먼저 접속하세요.")
return
try:
send_message(self.client_socket, {
"type": "chat",
"content": text
})
self.chat_display.append(f"나: {text}")
self.message_input.clear()
except Exception as error:
self.chat_display.append(f"[오류] 메시지 전송 실패: {error}")
@Slot(dict)
def display_message(self, message):
message_type = message.get("type")
if message_type == "chat":
sender = message.get("sender", "unknown")
content = message.get("content", "")
self.chat_display.append(f"{sender}: {content}")
elif message_type == "system":
content = message.get("content", "")
self.chat_display.append(f"[시스템] {content}")
elif message_type == "error":
content = message.get("content", "")
self.chat_display.append(f"[오류] {content}")
else:
self.chat_display.append(f"[알 수 없는 메시지] {message}")
app = QApplication(sys.argv)
window = ChatWindow()
window.show()
sys.exit(app.exec())
5.2 최종 확인 표
| 확인할 코드 | 의미 |
import threading / from PySide6.QtCore import Signal, Slot |
스레드·신호 사용 준비 |
from protocol import receive_messages, send_message |
수신 함수 추가 (24강에서 send_message만 가져왔던 것에서 확장) |
message_received = Signal(dict) |
스레드 → 화면 전달용 신호 선언 (클래스 속성) |
self.running = False / self.receive_thread = None |
수신 반복 플래그·스레드 보관 변수 초기화 |
self.message_received.connect(self.display_message) |
신호를 화면 표시 함수에 연결 |
threading.Thread(target=self.receive_loop) |
수신을 별도 스레드로 분리 |
self.receive_thread.daemon = True |
메인 종료 시 수신 스레드도 함께 종료 |
receive_messages(self.client_socket, buffer) |
서버 메시지 수신·파싱 (protocol.py) |
self.message_received.emit(message) |
받은 메시지를 신호로 메인 흐름에 전달 |
@Slot(dict) def display_message(...) |
메인 흐름에서 화면에 메시지 표시 |
이번 강의로 GUI 채팅의 핵심 송수신 흐름이 모두 연결되었습니다. 수신 처리는 receive_messages(), 화면 업데이트는 Signal(dict)와 display_message()로 이어집니다. GUI 클라이언트 두 개를 띄워 서로 대화가 되면 이번 강의의 목표를 달성한 것입니다.
→ 다음 강의 (26강): 송수신은 됐지만 아직 안정화가 남았습니다. 창을 닫을 때 서버에 종료를 알리고, 연결이 끊기면 입력창·버튼 상태를 되돌리며, 중복 접속과 전송 실패 상황을 정리합니다. 지금 self.running 플래그로 제어 기반을 만들어 둔 것이 26강 종료 처리의 출발점입니다.