26강. GUI 상태 관리와 연결 종료 처리

0. 학습 목표
→ GUI 클라이언트의 연결 상태 관리를 하나의 함수로 통일하고, 종료 시 서버에 제대로 알리는 구조를 만듭니다.
0.1 이번 글에서 다룰 내용
이번 글은 구현 중심 강의입니다.
25강까지 GUI 클라이언트는 서버에 접속하고, 메시지를 보내고, 다른 클라이언트의 메시지도 받을 수 있었습니다. 송수신 자체는 완성됐지만 종료 상황에서 빈틈이 있습니다. 창을 닫으면 서버에 퇴장 메시지가 전달되지 않고, 연결이 끊겨도 입력창과 버튼이 활성 상태로 남습니다. 이번 강의에서는 이 세 가지를 하나의 흐름으로 묶습니다.
연결 전 → 접속 버튼 활성화, 입력창·보내기 버튼 비활성화
연결 성공 → 접속 버튼 비활성화, 입력창·보내기 버튼 활성화
연결 종료 → 서버에 exit 전송 → 소켓 닫기 → 접속 버튼 활성화, 입력창·보내기 비활성화 ← 이번 강의의 성공 지점
핵심 성공 기준은 exit 입력과 창 닫기 모두 서버에 종료를 알리고, GUI 상태가 "연결 안 됨"으로 일관되게 정리되는 것입니다.
아래 코드에서 ➕는 25강 코드까지는 없던 새로 더해진 줄, ✏️는 위치나 형태가 바뀐 줄을 뜻합니다. 최종 정리 코드에는 마커 없이 깨끗한 코드만 싣습니다.

| 구분 | 내용 |
| 이해할 것 | GUI 프로그램에서 연결 상태에 따라 위젯 상태를 함께 관리해야 하는 이유, 종료 시 순서가 중요한 이유 |
| 만들 것 | chat_client/main.py 수정 — set_connected_state(), disconnect_from_server(), closeEvent() |
| 확인할 것 | exit 입력과 창 닫기 모두 서버 접속자 수를 줄이고, GUI 입력창이 비활성화되는지 |
0.2 이번 강의에서 직접 다루는 구조
이번 강의에서는 25강의 chat_client/main.py를 이어서 수정합니다. 서버와 protocol.py는 그대로 사용합니다.
chat_server/
├── protocol.py ← 유지
└── server.py ← 유지 (실행용으로만 사용)
chat_client/
├── protocol.py ← 유지
├── client.py ← 유지
└── main.py ← ✏️ 이번 강의에서 수정
(➕ 새로 생성 · ✏️ 수정 · 표시 없음은 변경 없음)
ChatWindow에서 이번 강의에 추가하거나 변경하는 구조입니다.
ChatWindow
├── set_connected_state() ← ➕ 연결 여부 하나로 버튼·입력창 일괄 변경
├── disconnect_from_server() ← ➕ exit 전송 → running 종료 → 소켓 닫기 → UI 정리
├── closeEvent() ← ➕ 창 닫을 때 종료 처리 호출
├── connect_to_server() ← ✏️ set_connected_state(True)로 정리
├── on_send_clicked() ← ✏️ exit 입력·전송 실패 시 종료 흐름 연결
└── receive_loop() / display_message() ← ✏️ state 신호 처리 추가
0.3 📦 Part 5 구현
part5_final.zip
├── README.md
├── chat_server/
│ ├── protocol.py ← Part 3~4와 동일
│ └── server.py ← Part 3~4와 동일
└── chat_client/
├── protocol.py ← Part 3~4와 동일
└── main.py ← ★ 26강 GUI 클라이언트 완성본
1. 실습 준비하기
→ 서버를 실행하고, 이번 강의에서 사용할 상태 기준을 정리합니다.
터미널 1에서 서버를 먼저 실행합니다.
python chat_server/server.py
서버를 시작합니다.
클라이언트 접속 대기 중...
서버가 대기 상태를 유지하면 준비 완료입니다. 이번 강의의 핵심은 "연결됨"과 "연결 안 됨" 두 상태에 따라 화면 요소를 일관되게 관리하는 것입니다. 이 표가 set_connected_state()의 설계도입니다.
| 상태 | 접속 버튼 | 입력창 | 보내기 버튼 |
| 연결 안 됨 | 활성화 | 비활성화 | 비활성화 |
| 연결됨 | 비활성화 | 활성화 | 활성화 |
"연결 여부 하나로 세 위젯 상태가 모두 결정된다"는 점이 이 함수를 만드는 이유입니다. 위젯마다 따로 켜고 끄는 대신 set_connected_state() 한 번 호출로 처리합니다.
2. 클라이언트 - 상태 변경·종료 함수 만들기
→ set_connected_state(), disconnect_from_server(), closeEvent()를 작성합니다.
2.1 set_connected_state() 만들기
연결 상태에 따라 위젯을 한 번에 바꾸는 함수입니다. connected 인자 하나로 세 위젯의 활성화 여부와 상태 라벨 텍스트가 함께 결정됩니다.
def set_connected_state(self, connected): # ➕ 연결 여부 하나로 위젯 일괄 변경
self.connect_button.setEnabled(not connected) # 연결되면 접속 버튼은 잠금
self.message_input.setEnabled(connected) # 연결되면 입력창 활성화
self.send_button.setEnabled(connected) # 연결되면 보내기 활성화
if connected:
self.status_label.setText("상태: 서버에 연결됨")
else:
self.status_label.setText("상태: 서버에 연결되지 않음")
이제 연결 성공·종료 어디서든 다음 한 줄로 화면을 정리할 수 있습니다.
self.set_connected_state(True) # 연결 성공 시
self.set_connected_state(False) # 연결 종료 시
단, 연결 실패 시에는 상태 라벨 텍스트가 "서버에 연결되지 않음"이 아니라 "서버 연결 실패"여야 합니다. set_connected_state(False)는 텍스트를 "연결되지 않음"으로 설정하므로, 연결 실패 상황에서는 setEnabled 처리만 재사용하고 상태 라벨은 별도로 설정합니다. 이 부분은 2.2 이후 connect_to_server() 수정에서 확인합니다.
2323
💡 강사 팁 — "왜 연결 실패 때는 set_connected_state(False)를 안 쓰나요?"
실습 중 이 질문이 반드시 나옵니다. 핵심은 상태 라벨 텍스트가 다르다는 점입니다.
| 상황 | 상태 라벨 텍스트 | 처리 방법 |
| 연결 종료 (정상) | 상태: 서버에 연결되지 않음 | set_connected_state(False) |
| 연결 실패 (오류) | 상태: 서버 연결 실패 | setEnabled만 재사용 + 라벨 별도 설정 |
set_connected_state(False)는 라벨을 "연결되지 않음"으로 고정하기 때문에, 연결 실패 상황에는 그대로 쓸 수 없습니다. 그래서 connect_to_server()의 except 블록에서는 setEnabled(True)와 setText("상태: 서버 연결 실패")를 직접 호출하는 것입니다. 이 부분은 connect_to_server() 수정 단계에서 코드로 다시 확인합니다.
2.2 disconnect_from_server() 만들기
서버 연결을 끊는 모든 작업을 한곳에 모읍니다. 소켓을 닫기 전에 exit 메시지를 먼저 보내는 순서가 핵심입니다. 소켓을 먼저 닫으면 보낼 통로가 사라지기 때문입니다.
def disconnect_from_server(self): # ➕ 모든 종료 작업을 한곳에 모음
if self.client_socket is None: # 이미 연결이 없으면 UI만 정리하고 종료
self.set_connected_state(False)
return
try:
send_message(self.client_socket, {"type": "exit"}) # ① 소켓 닫기 전에 종료 알림
except Exception:
pass # 전송 실패해도 나머지 정리는 계속
self.running = False # ② 수신 루프 중단 신호
try:
self.client_socket.close() # ③ 소켓 닫기
except Exception:
pass
self.client_socket = None # ④ 소켓 참조 제거
self.set_connected_state(False) # ⑤ 위젯 상태 정리
self.chat_display.append("[시스템] 서버와의 연결을 종료했습니다.")
각 단계에 번호를 붙인 이유는 순서를 바꾸면 동작이 달라지기 때문입니다. ①에서 exit를 보내야 서버가 "정상 퇴장"으로 처리하고 다른 클라이언트에게 퇴장 메시지를 뿌립니다. ③에서 소켓을 닫기 전에 ②로 running을 False로 바꿔야 수신 스레드가 다음 루프에서 멈춥니다.
2.3 closeEvent() 만들기
창의 닫기 버튼(X)을 눌렀을 때도 같은 종료 처리를 하려면 closeEvent()를 재정의합니다. PySide6는 창이 닫히기 직전에 이 메서드를 자동으로 호출합니다.
def closeEvent(self, event): # ➕ 창 닫기 직전에 자동 호출됨
self.disconnect_from_server() # 종료 처리 재사용
event.accept() # 실제로 창을 닫도록 허용
event.accept()를 빠뜨리면 창이 닫히지 않습니다. 반대로 event.ignore()를 쓰면 닫기를 취소할 수 있습니다. 여기서는 종료 처리를 마친 뒤 항상 닫히도록 accept()를 사용합니다.
✔ 확인 기준: 세 함수가 모두 작성되어 있고, disconnect_from_server()에서 send_message(..., {"type": "exit"})가 close()보다 먼저 호출되면 완료. ①②③ 순서가 바뀌지 않았는지 확인하세요.
3. 클라이언트 - 기존 함수에 새 흐름 연결하기
→ connect_to_server, on_send_clicked, receive_loop, display_message를 새 상태 함수에 맞게 정리합니다.
3.1 __init__ — 시작 상태를 한 줄로 통일하기
__init__ 끝에서 초기 상태를 설정하는 방식을 바꿉니다. 25강에서는 위젯마다 setEnabled(False)를 직접 썼습니다. 이제 이 한 줄이 그것을 대신합니다.
self.message_received.connect(self.display_message)
self.set_connected_state(False) # ➕ 시작 상태를 "연결 안 됨"으로 통일 (25강의 setEnabled 3줄을 대체)
3.2 connect_to_server() — 성공·실패 분기 정리하기
연결 성공 블록은 버튼을 따로 켜던 코드를 set_connected_state(True) 한 줄로 바꿉니다. 연결 실패 블록에서는 self.client_socket을 None으로 복구하는 것이 중요합니다. 소켓 생성(socket.socket())은 성공했지만 connect()에서 실패한 경우, 소켓 객체가 남아있어 다음 접속 시도 시 꼬일 수 있기 때문입니다.
def connect_to_server(self):
try:
self.client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.client_socket.connect((HOST, PORT))
self.running = True
self.receive_thread = threading.Thread(target=self.receive_loop)
self.receive_thread.daemon = True
self.receive_thread.start()
self.set_connected_state(True) # ✏️ 25강의 setEnabled 3줄 + status_label 설정을 대체
self.chat_display.append("[시스템] 서버에 연결되었습니다.")
except ConnectionRefusedError:
self.client_socket = None # ➕ 소켓 객체 복구 (연결 실패 시 None으로)
self.connect_button.setEnabled(True) # 접속 버튼은 다시 활성화
self.status_label.setText("상태: 서버 연결 실패") # ✏️ set_connected_state와 별도로 실패 텍스트 설정
self.chat_display.append("[오류] 서버가 실행 중인지 확인하세요.")
except Exception as error:
self.client_socket = None # ➕ 소켓 객체 복구
self.connect_button.setEnabled(True)
self.status_label.setText("상태: 서버 연결 실패")
self.chat_display.append(f"[오류] 연결 중 문제가 발생했습니다: {error}")
except 블록에서 set_connected_state(False)를 쓰지 않는 이유: set_connected_state(False)는 상태 라벨을 "서버에 연결되지 않음"으로 바꾸지만, 연결 실패 시에는 "서버 연결 실패"로 표시해야 합니다. 두 텍스트가 다르므로 set_connected_state 대신 setEnabled와 setText를 직접 씁니다.
3.3 on_send_clicked() — exit 입력과 전송 실패 처리 추가
exit 입력과 전송 실패 두 가지 상황이 모두 disconnect_from_server()로 이어지도록 합니다.
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
if text == "exit": # ➕ exit 입력이면 종료 흐름으로
self.disconnect_from_server()
self.message_input.clear()
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}")
self.disconnect_from_server() # ✏️ 전송 실패 시 상태 정리까지
3.4 receive_loop()와 display_message() — 연결 끊김을 state 신호로 알리기
receive_loop()는 수신 스레드에서 실행되므로 화면 위젯을 직접 건드리지 않고 신호만 보내야 합니다. 25강에서는 루프가 끝난 뒤 정리 코드가 없었는데, 이번에 루프 종료 후 state 신호를 추가합니다. OSError는 소켓이 이미 닫힌 상태에서 recv()를 시도할 때 발생하는 예외로, disconnect_from_server()가 소켓을 닫은 직후 수신 스레드가 다음 루프를 시작하면 만납니다. 오류 메시지를 화면에 출력하지 않고 조용히 종료합니다.
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 OSError: # ➕ 닫힌 소켓 접근은 조용히 종료 (disconnect 후 자연 발생)
break
except Exception as error:
self.message_received.emit({
"type": "error",
"content": f"메시지 수신 중 오류가 발생했습니다: {error}"
})
break
self.running = False
self.message_received.emit({ # ➕ 루프 종료를 메인 흐름에 신호로 알림
"type": "state",
"connected": False
})
display_message()에 state 타입 분기를 추가합니다. 이 분기가 수신 스레드 종료를 감지해 화면을 정리합니다.
@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}")
elif message_type == "state": # ➕ 수신 스레드 종료 신호 처리
connected = message.get("connected", False)
if not connected:
self.client_socket = None
self.set_connected_state(False)
else:
self.chat_display.append(f"[알 수 없는 메시지] {message}")
연결 끊김이 발생하는 전체 흐름을 정리하면 다음과 같습니다.
연결 끊김 / exit 입력 / 전송 실패
↓
disconnect_from_server() 또는 receive_loop 자연 종료
↓
set_connected_state(False) (state 신호 경유 포함)
↓
접속 버튼 활성화 · 입력창·보내기 비활성화 · 소켓 None ← 이번 강의의 성공 지점
✔ 확인 기준: exit 입력 후 입력창·보내기 버튼이 비활성화되고 접속 버튼이 다시 활성화되면 완료. 안 되면 on_send_clicked()에 text == "exit" 분기가 있는지 확인하세요.
4. 실행 결과 확인하기
→ 서버 접속, exit 종료, 창 닫기 각 상황에서 GUI 상태가 올바르게 바뀌는지 확인합니다.
4.1 서버 접속 확인하기

터미널 1에서 서버가 대기 중인 상태에서, 터미널 2에서 GUI 클라이언트를 실행합니다.
python chat_client/main.py
"서버 접속" 버튼을 누르면 입력창과 보내기 버튼이 활성화되고 접속 버튼이 비활성화됩니다.
상태: 서버에 연결됨
[시스템] 서버에 연결되었습니다.
4.2 exit 입력과 창 닫기로 종료하기
GUI 입력창에 exit를 입력합니다. 채팅창과 상태 라벨이 다음처럼 바뀝니다.
[시스템] 서버와의 연결을 종료했습니다.
상태: 서버에 연결되지 않음
서버 터미널에는 다음처럼 출력됩니다.
클라이언트 종료 요청: ('127.0.0.1', 52344)
접속자 목록에서 제거: ('127.0.0.1', 52344)
현재 접속자 수: 0
창의 닫기 버튼(X)을 눌러도 closeEvent() 덕분에 같은 종료 메시지가 서버에 전달되고, 서버는 계속 실행됩니다. 다른 클라이언트가 남아 있으면 그 화면에 퇴장 시스템 메시지가 표시됩니다.
✔ 확인 기준: exit 입력이나 창 닫기 시 서버 접속자 수가 줄고, GUI 입력창이 비활성화되면 완료. 줄지 않으면 disconnect_from_server()에서 send_message(..., {"type": "exit"})가 close()보다 먼저 실행되는지 확인하세요.
4.3 실행이 안 될 때 확인할 것
| 오류 상황 | 원인 및 해결 방법 |
exit 후 서버 접속자 수가 줄지 않는다 |
disconnect_from_server()에서 send_message()를 close()보다 먼저 호출합니다 (① → ③ 순서) |
| 창을 닫아도 서버에 퇴장이 안 보인다 | closeEvent()에서 disconnect_from_server()를 호출하는지 확인합니다 |
| 연결이 끊겼는데 입력창이 계속 활성화된다 | receive_loop() 끝의 state 신호 emit과 display_message()의 state 분기를 확인합니다 |
| 재접속 시도 시 소켓 오류가 난다 | connect_to_server()의 except 블록에서 self.client_socket = None으로 복구했는지 확인합니다 |
| 종료 시 오류 메시지가 잠깐 보인다 | 수신 스레드가 닫힌 소켓에 recv()를 시도한 것입니다. receive_loop()에 except OSError: break가 있는지 확인합니다 |
ConnectionRefusedError |
서버가 실행 중이 아닙니다. python chat_server/server.py를 먼저 실행합니다 |
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.status_label = QLabel("상태: 서버에 연결되지 않음")
self.connect_button = QPushButton("서버 접속")
self.chat_display = QTextEdit()
self.chat_display.setReadOnly(True)
self.chat_display.setPlaceholderText("채팅 메시지가 여기에 표시됩니다.")
self.message_input = QLineEdit()
self.message_input.setPlaceholderText("메시지를 입력하세요")
self.send_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)
self.set_connected_state(False)
def set_connected_state(self, connected):
self.connect_button.setEnabled(not connected)
self.message_input.setEnabled(connected)
self.send_button.setEnabled(connected)
if connected:
self.status_label.setText("상태: 서버에 연결됨")
else:
self.status_label.setText("상태: 서버에 연결되지 않음")
def connect_to_server(self):
try:
self.client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.client_socket.connect((HOST, PORT))
self.running = True
self.receive_thread = threading.Thread(target=self.receive_loop)
self.receive_thread.daemon = True
self.receive_thread.start()
self.set_connected_state(True)
self.chat_display.append("[시스템] 서버에 연결되었습니다.")
except ConnectionRefusedError:
self.client_socket = None
self.connect_button.setEnabled(True)
self.status_label.setText("상태: 서버 연결 실패")
self.chat_display.append("[오류] 서버가 실행 중인지 확인하세요.")
except Exception as error:
self.client_socket = None
self.connect_button.setEnabled(True)
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 OSError:
break
except Exception as error:
self.message_received.emit({
"type": "error",
"content": f"메시지 수신 중 오류가 발생했습니다: {error}"
})
break
self.running = False
self.message_received.emit({
"type": "state",
"connected": 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
if text == "exit":
self.disconnect_from_server()
self.message_input.clear()
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}")
self.disconnect_from_server()
def disconnect_from_server(self):
if self.client_socket is None:
self.set_connected_state(False)
return
try:
send_message(self.client_socket, {"type": "exit"})
except Exception:
pass
self.running = False
try:
self.client_socket.close()
except Exception:
pass
self.client_socket = None
self.set_connected_state(False)
self.chat_display.append("[시스템] 서버와의 연결을 종료했습니다.")
@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}")
elif message_type == "state":
connected = message.get("connected", False)
if not connected:
self.client_socket = None
self.set_connected_state(False)
else:
self.chat_display.append(f"[알 수 없는 메시지] {message}")
def closeEvent(self, event):
self.disconnect_from_server()
event.accept()
app = QApplication(sys.argv)
window = ChatWindow()
window.show()
sys.exit(app.exec())
5.2 최종 확인 표
| 확인할 코드 | 의미 |
set_connected_state(connected) |
연결 여부 하나로 버튼·입력창·상태 라벨 일괄 변경 |
self.set_connected_state(False) (__init__ 끝) |
시작 상태를 "연결 안 됨"으로 통일 |
self.client_socket = None (except 블록) |
연결 실패 시 소켓 객체 복구 — 재접속 시 꼬임 방지 |
disconnect_from_server() |
exit 전송 → running 종료 → 소켓 닫기 → UI 정리 |
send_message(..., {"type": "exit"}) (close 이전) |
소켓 닫기 전에 서버에 종료 알림 |
if text == "exit": |
입력창 exit 명령으로 종료 흐름 진입 |
except OSError: break (receive_loop) |
닫힌 소켓 접근을 조용히 종료 |
emit({"type": "state", "connected": False}) |
수신 루프 종료를 메인 흐름에 신호로 알림 |
elif message_type == "state": (display_message) |
state 신호를 받아 소켓 None 처리 + UI 정리 |
closeEvent(self, event) |
창 닫기(X) 시에도 동일한 종료 처리 |
이번 강의로 GUI 채팅 클라이언트의 접속·송신·수신·종료가 하나의 상태 기준으로 정리되었습니다. 상태 변경은 set_connected_state(), 종료는 disconnect_from_server()로 통일되어 있는지 다시 확인하세요.
→ 다음 강의 (27강): 안정화된 GUI를 바탕으로 사용자 목록과 귓속말 파트로 넘어갑니다. 서버가 현재 접속자 목록을 클라이언트에 알려 주면, GUI는 display_message()의 분기를 늘려 그 목록을 화면에 표시합니다. 이는 이후 귓속말 대상 선택으로 이어집니다.
0. 과제 안내
→ 이번 과제에서 무엇을 만들고, 어디까지만 하면 되는지 먼저 확인합니다.
0.1 과제 목표
26강까지 완성한 GUI 채팅 프로그램은 서버에 접속하면 바로 채팅에 참여할 수 있습니다. 이번 과제에서는 채팅 입장 전에 회원가입과 로그인 단계를 추가합니다.
파일에 회원 정보를 저장하지 않고, 서버가 실행되는 동안 메모리(딕셔너리)에만 저장합니다. 서버를 끄면 회원 정보도 사라지지만, 로그인 흐름을 이해하기에는 충분합니다.

| 구분 | 내용 |
| 만들 것 | 서버 메모리에 회원 저장, 로그인 창, 회원가입 처리 |
| 안 해도 되는 것 | 파일 저장, 비밀번호 암호화, 27강 이후 기능 |
| 확인할 것 | 로그인 성공 후에만 채팅창이 열리는지, 실패 시 메시지가 보이는지 |
0.2 수정하는 파일은 단 2개
26강 코드를 그대로 두고, 아래 두 파일에만 코드를 추가합니다. protocol.py와 client.py는 손대지 않습니다.
chat_server/
├── protocol.py ← 그대로 사용
└── server.py ← ✏️ 회원 딕셔너리 + register/login 처리 추가
chat_client/
├── protocol.py ← 그대로 사용
├── client.py ← 그대로 사용
└── main.py ← ✏️ LoginWindow 추가, 진입점 변경
(✏️ 수정 · 표시 없음은 그대로 사용)
1. 전체 흐름 이해하기
→ 로그인 단계가 기존 채팅 흐름 앞에 어떻게 추가되는지, 서버와 클라이언트가 어떻게 협력하는지 이해합니다.
1.1 기존 흐름과 변경 후 흐름
26강까지:
프로그램 실행 → 채팅창 바로 열림 → 서버 접속 버튼 → 채팅
이번 과제:
프로그램 실행 → 로그인 창 → 인증 성공 → 채팅창 열림 → 채팅
채팅창(ChatWindow)은 26강 것을 그대로 사용합니다. 로그인 창만 새로 만들고, 로그인에 성공하면 기존 채팅창을 엽니다.
1.2 서버에서 인증 상태를 어떻게 관리하는가
이번 과제에서 서버가 처리해야 할 두 가지 상태가 있습니다.
인증 전 상태 → 같은 소켓에서 register / login 메시지만 처리
인증 후 상태 → 같은 소켓에서 chat / exit 메시지 처리 (기존 채팅 로직)
클라이언트는 소켓을 하나만 만들고, 그 소켓으로 로그인 요청과 채팅 메시지를 모두 보냅니다. 서버는 handle_client() 안에서 인증 완료 여부를 나타내는 변수 하나로 이 두 상태를 구분합니다.
# handle_client() 안의 상태 변수
authenticated = False # 로그인 성공 전에는 False
이 authenticated 변수가 False인 동안에는 register/login 메시지만 처리하고, True가 되면 기존 chat/exit 처리로 넘어갑니다. 이 구조가 과제 A 서버 수정의 핵심입니다.
1.3 메시지 설계
기존 chat, exit 메시지에 세 가지 타입만 추가합니다. protocol.py는 어떤 딕셔너리든 보낼 수 있으므로 파일 수정 없이 새 타입을 바로 쓸 수 있습니다.
회원가입 요청 (클라이언트 → 서버)
{"type": "register", "username": "alice", "password": "1234"}
로그인 요청 (클라이언트 → 서버)
{"type": "login", "username": "alice", "password": "1234"}
결과 응답 (서버 → 클라이언트)
{"type": "auth_result", "success": true, "message": "..."}
1.4 회원 정보는 딕셔너리 하나로
회원 정보는 서버 코드 안의 딕셔너리 하나에 담습니다. 미리 회원 2명을 넣어 두면 회원가입 없이도 바로 로그인 테스트를 할 수 있습니다.
users = {
"alice": "1234",
"bob": "abcd"
}
2. 과제 A — 서버에 회원 처리 추가하기
→ server.py에 회원 딕셔너리와 인증 상태 관리, register·login 처리를 추가합니다.
2.1 회원 딕셔너리 추가하기
ChatServer 클래스의 __init__에 회원 딕셔너리를 추가합니다. 클래스 구조를 사용하지 않는다면 전역 변수로 선언해도 됩니다.
self.users = { # 서버가 실행되는 동안만 기억하는 회원 목록
"alice": "1234",
"bob": "abcd"
}
2.2 handle_client()에 인증 상태와 분기 추가하기
기존 handle_client()의 메시지 수신 루프 맨 앞에 인증 상태 변수를 두고, 인증 전·후로 처리를 나눕니다. 빈칸(___)을 채워 완성하세요.
def handle_client(self, client_socket, addr):
authenticated = False # 로그인 성공 전에는 False
buffer = ""
while True:
try:
buffer, messages, connected = receive_messages(client_socket, buffer)
if not connected:
break
for message in messages:
message_type = message.get("type")
# ─── 인증 전 처리 ───────────────────────────────────────
if not authenticated:
if message_type == "register":
username = message.get("username", "")
password = message.get("password", "")
if username in self.users:
send_message(client_socket, {
"type": "auth_result",
"success": ___, # 빈칸: 이미 존재하는 아이디면?
"message": "이미 사용 중인 아이디입니다."
})
else:
self.users[username] = ___ # 빈칸: 무엇을 저장할까요?
send_message(client_socket, {
"type": "auth_result",
"success": True,
"message": "회원가입 완료. 로그인하세요."
})
elif message_type == "login":
username = message.get("username", "")
password = message.get("password", "")
if self.users.get(username) == ___: # 빈칸: 무엇과 비교할까요?
authenticated = ___ # 빈칸: 인증 성공 시 무엇으로?
send_message(client_socket, {
"type": "auth_result",
"success": True,
"message": f"{username}님 환영합니다."
})
else:
send_message(client_socket, {
"type": "auth_result",
"success": False,
"message": "아이디 또는 비밀번호가 틀렸습니다."
})
# 인증 전에는 chat/exit 무시
continue
# ─── 인증 후 처리 (기존 채팅 로직) ────────────────────────
if message_type == "chat":
# 기존 broadcast 코드 그대로 유지
...
elif message_type == "exit":
# 기존 퇴장 처리 코드 그대로 유지
break
except Exception:
break
# 연결 종료 처리 (기존 코드 그대로)
...
if not authenticated: 블록 마지막에 continue가 있는 것이 중요합니다. continue를 두지 않으면 인증 전 메시지가 아래의 chat/exit 분기로도 넘어가 오류가 발생합니다.
✔ 확인 기준: 서버에 self.users 딕셔너리와 authenticated 변수, register·login 분기가 추가되고, 서버가 26강처럼 오류 없이 실행되면 완료.
3. 과제 B — 로그인 창 만들기
→ main.py에 LoginWindow를 추가하고, 로그인 성공 시 기존 ChatWindow를 엽니다.
3.1 화면 구성과 뼈대 코드
main.py에 새 클래스 LoginWindow를 추가합니다. 위젯은 26강에서 쓰던 것만 사용합니다.
LoginWindow
├── 아이디 입력창 (QLineEdit)
├── 비밀번호 입력창 (QLineEdit, 숨김)
├── 로그인 버튼
├── 회원가입 버튼
└── 안내 라벨 (결과 메시지)
class LoginWindow(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("로그인")
self.username_input = QLineEdit()
self.username_input.setPlaceholderText("아이디")
self.password_input = QLineEdit()
self.password_input.setPlaceholderText("비밀번호")
self.password_input.setEchoMode(QLineEdit.EchoMode.Password) # 비밀번호 숨김
self.login_button = QPushButton("로그인")
self.register_button = QPushButton("회원가입")
self.info_label = QLabel("")
layout = QVBoxLayout()
layout.addWidget(self.username_input)
layout.addWidget(self.password_input)
layout.addWidget(self.login_button)
layout.addWidget(self.register_button)
layout.addWidget(self.info_label)
self.setLayout(layout)
self.login_button.clicked.connect(self.on_login_clicked)
self.register_button.clicked.connect(self.on_register_clicked)
3.2 로그인 처리 — 보내고, 받고, 판단하기
로그인은 "한 번 보내고 한 번 받는" 단순한 흐름입니다. 소켓을 만들어 요청을 보내고, receive_messages()로 결과를 받습니다. 성공하면 이 소켓 그대로 ChatWindow에 전달합니다. 같은 소켓으로 이후 채팅 메시지를 주고받기 때문입니다.
def on_login_clicked(self):
username = self.username_input.text().strip()
password = self.password_input.text().strip()
if not username or not password:
self.info_label.setText("아이디와 비밀번호를 입력하세요.")
return
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((HOST, PORT))
send_message(sock, {
"type": ___, # 빈칸: 로그인 요청 타입
"username": username,
"password": password
})
buffer = ""
buffer, messages, _ = receive_messages(sock, buffer)
result = messages[0] # 서버가 보낸 auth_result
if result.get("success"):
self.open_chat(sock) # 성공 → 채팅창 열기 (소켓을 넘김)
else:
self.info_label.setText(result.get("message"))
sock.close() # 실패 → 소켓 닫기
except ConnectionRefusedError:
self.info_label.setText("서버에 연결할 수 없습니다. 서버를 먼저 켜세요.")
3.3 성공하면 기존 채팅창 열기
로그인에 성공하면 이미 연결된 소켓을 ChatWindow에 넘기고 로그인 창을 닫습니다. ChatWindow.__init__에 소켓을 받는 인자를 추가하고, 넘겨받은 소켓이 있으면 바로 수신 스레드를 시작합니다.
def open_chat(self, sock):
self.chat_window = ChatWindow(client_socket=sock) # 연결된 소켓 전달
self.chat_window.show()
self.close() # 로그인 창 닫기
ChatWindow.__init__는 두 부분만 바꿉니다.
def __init__(self, client_socket=None): # ✏️ 소켓을 인자로 받음 (기본값 None)
super().__init__()
self.client_socket = client_socket # ✏️ 넘겨받은 소켓 사용 (없으면 None)
self.receive_thread = None
self.running = False
# ... 위젯·레이아웃 코드 동일 ...
self.set_connected_state(False)
if self.client_socket is not None: # ➕ 로그인으로 들어온 경우 — 바로 수신 시작
self.running = True
self.receive_thread = threading.Thread(target=self.receive_loop)
self.receive_thread.daemon = True
self.receive_thread.start()
self.set_connected_state(True)
26강의 "서버 접속" 버튼과 connect_to_server()는 그대로 두어도 됩니다. 로그인으로 들어온 경우 이미 연결된 상태이므로 버튼이 비활성화되어 있어 다시 누를 일이 없습니다.
✔ 확인 기준: alice / 1234로 로그인하면 채팅창이 열리고, 틀린 비밀번호를 넣으면 로그인 창에 오류 메시지가 표시되면 완료.
4. 과제 C — 회원가입 처리하기
→ 회원가입 버튼을 누르면 입력값을 서버에 register로 보내고 결과를 표시합니다.
회원가입은 로그인과 거의 같은 구조입니다. 별도 창을 만들지 않고, 로그인 창의 아이디·비밀번호 입력값을 그대로 사용해 register 요청을 보냅니다. 회원가입 후에는 채팅창으로 바로 넘어가지 않고 소켓을 닫습니다. 같은 아이디로 로그인 버튼을 누르면 채팅창이 열립니다.
def on_register_clicked(self):
username = self.username_input.text().strip()
password = self.password_input.text().strip()
if not username or not password:
self.info_label.setText("아이디와 비밀번호를 입력하세요.")
return
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((HOST, PORT))
send_message(sock, {
"type": ___, # 빈칸: 회원가입 요청 타입
"username": username,
"password": password
})
buffer = ""
buffer, messages, _ = receive_messages(sock, buffer)
result = messages[0]
self.info_label.setText(result.get("message")) # 결과를 라벨에 표시
sock.close() # 가입만 하고 연결 종료
except ConnectionRefusedError:
self.info_label.setText("서버에 연결할 수 없습니다. 서버를 먼저 켜세요.")
✔ 확인 기준: 새 아이디로 회원가입하면 "회원가입 완료" 메시지가 뜨고, 같은 아이디로 다시 가입하면 "이미 사용 중인 아이디입니다." 메시지가 표시되면 완료.
5. 과제 D — 진입점 바꾸기
→ main.py의 첫 화면을 ChatWindow에서 LoginWindow로 바꿉니다.
main.py 맨 아래에서 처음 여는 창만 바꾸면 됩니다.
# 26강까지 (기존)
app = QApplication(sys.argv)
window = ChatWindow()
window.show()
sys.exit(app.exec())
# 이번 과제 (변경)
app = QApplication(sys.argv)
window = ___() # 빈칸: 어떤 창을 먼저 열까요?
window.show()
sys.exit(app.exec())
✔ 확인 기준: python chat_client/main.py 실행 시 채팅창이 아니라 로그인 창이 먼저 뜨면 완료.
6. 실행해서 확인하기
→ 서버와 클라이언트를 실행해 로그인부터 채팅까지 직접 확인합니다.
6.1 실행 순서
# 터미널 1 — 서버 먼저 실행
python chat_server/server.py
# 터미널 2 — 클라이언트 실행 (로그인 창이 뜸)
python chat_client/main.py
미리 넣어 둔 회원 alice / 1234로 바로 로그인을 시도해 볼 수 있습니다.
6.2 테스트 시나리오
| 입력 | 기대 결과 |
alice / 1234 로그인 |
채팅창이 열린다 |
alice / 0000 로그인 |
"아이디 또는 비밀번호가 틀렸습니다." 표시 |
charlie / 1111 로그인 (없는 아이디) |
"아이디 또는 비밀번호가 틀렸습니다." 표시 |
charlie / 1111 회원가입 |
"회원가입 완료" 표시 → 같은 값으로 로그인하면 채팅창 열림 |
alice 회원가입 재시도 |
"이미 사용 중인 아이디입니다." 표시 |
서버를 껐다 켜면 charlie는 사라지고 alice, bob만 남습니다. 메모리에만 저장하기 때문이며, 오류가 아닙니다.
7. 도전 과제
→ 여유 있는 수강생을 위한 추가 과제를 안내합니다.
| 도전 항목 | 힌트 |
| 채팅창 제목에 아이디 표시 | ChatWindow.__init__에 username 인자를 추가하고 setWindowTitle(f"채팅 — {username}")으로 설정하세요 |
| 비밀번호 확인 입력창 추가 | 회원가입 시 입력창을 하나 더 두고, 서버에 보내기 전 두 값이 같은지 비교합니다 |
| 빈 입력 방어 강화 | 아이디에 공백만 넣은 경우도 막아보세요 (strip() 후 길이 확인) |
PySide6 GUI 상태 관리, set_connected_state, disconnect_from_server, closeEvent, OSError 처리, state 신호, receive_loop 종료, 로그인 창, LoginWindow, 서버 인증 상태 관리