30강. 귓속말 오류 처리와 표시 방식

0. 학습 목표
→ 귓속말 오류를 GUI 사전 검사와 서버 검증 두 방어선으로 나눠 처리합니다.
0.1 이번 글에서 다룰 내용
이번 글은 구현 중심 강의입니다.
29강에서 귓속말 전송 자체는 완성했습니다.
- 하지만 상대를 고르지 않은 경우,
- 빈 메시지인 경우,
- 자기 자신을 선택한 경우, 상
- 대가 방금 퇴장한 경우처럼
어긋나는 상황마다 적절한 안내가 없으면 사용자는 왜 안 되는지 알 수 없습니다.
이번 강의에서는 귓속말 오류를 두 방어선으로 정리합니다.

GUI 사전 검사 → 보내기 전에 미리 거름 (빠른 안내, 좋은 경험)
서버 최종 검증 → 전송 후 마지막 안전장치 (정확한 검증)
| 구분 | 내용 |
| 이해할 것 | 귓속말 오류는 GUI 사전 검사와 서버 검증으로 나눠 처리한다는 점 |
| 만들 것 | GUI show_error() · 자기 주소 저장 · 4단계 사전 검사 + 서버 자기 자신 차단 · 전송 실패 방어 |
| 확인할 것 | 잘못된 귓속말 상황에서 프로그램이 멈추지 않고 상황에 맞는 안내 메시지를 보여 주는지 |
0.2 이번 강의에서 직접 다루는 구조
이번 강의에서는 29강의 server.py와 main.py를 함께 수정합니다.
chat_server/
├── protocol.py ← 유지
└── server.py ← ✏️ 이번 강의에서 수정 (귓속말 검증·실패 방어)
chat_client/
├── protocol.py ← 유지
├── client.py ← 유지
└── main.py ← ✏️ 이번 강의에서 수정 (사전 검사·오류 표시)
(➕ 새로 생성 · ✏️ 수정 · 표시 없음은 변경 없음)
이번 강의에서 정리할 오류 흐름은 다음과 같습니다.
main.py — on_whisper_clicked()
├── 1. 서버 연결 여부 확인
├── 2. 메시지 내용 있는지 확인
├── 3. 상대 선택 여부 확인
└── 4. 자기 자신 선택 여부 확인 ← ➕ 새 검사
server.py — whisper 분기
├── target 없음 / content 없음 / 자기 자신 → 즉시 오류
└── 대상 없음 / 전송 실패 → error 응답
0.3 📦 Part 6 구현
part6_final.zip
├── README.md
├── chat_server/
│ ├── protocol.py ← Part 3~4와 동일
│ └── server.py ← 30강 완성본 (user_list + whisper + 오류 처리)
└── chat_client/
├── protocol.py ← Part 3~4와 동일
└── main.py ← 30강 완성본 (QListWidget + 귓속말 버튼 + 사전 검사)
1. 실습 준비하기
→ 오류 처리를 두 방어선으로 나누는 기준을 정리합니다.
오류를 두 군데에서 나눠 막습니다.
GUI는 사용자가 보내기 전에 미리 거르고, 서버는 전송이 도착한 뒤 마지막으로 검증합니다.
둘 중 하나만 있어도 동작하지만, 둘 다 있어야 안정적입니다.
| 처리 위치 | 처리할 수 있는 상황 |
| GUI 클라이언트 | 서버 미연결, 빈 메시지, 상대 미선택, 자기 자신 선택 |
| 서버 | target 없음, 대상 사용자를 찾을 수 없음 (요청 직전에 퇴장한 경우 포함), 소켓 전송 실패 |
GUI가 자기 자신을 이미 막더라도 서버에 같은 검사를 두는 이유가 있습니다.
GUI 검사는 사용자 경험을 좋게 하고, 서버 검사는 다른 클라이언트나 변형된 요청에도 흔들리지 않는 안전장치가 됩니다.
선택한 상대가 요청 직전에 퇴장하는 경우는 클라이언트 GUI가 미리 알 수 없으므로, 서버가 최종 안전장치 역할을 합니다.
python chat_server/server.py
python chat_client/main.py
python chat_client/main.py
서버와 GUI 클라이언트 2개를 실행해두면 각 오류 상황을 직접 만들어볼 수 있습니다.
2. 클라이언트 - 오류 표시 함수와 자기 주소 준비하기
→ 오류 메시지 표시를 함수로 통일하고, 자기 자신 선택을 막기 위한 주소를 저장합니다.
2.1 오류 메시지 표시 함수 만들기
오류 메시지를 여러 곳에서 같은 형식으로 쓰도록 함수로 모읍니다.
이렇게 하면 chat_display.append(f"[오류] ...")를 반복해서 적지 않아도 됩니다.
display_message() 내부도 이 함수를 쓰도록 정리하면 표시 형식이 한 곳에서 관리됩니다.
def show_error(self, content): # ➕ 오류를 [오류] 형식으로 표시
self.chat_display.append(f"[오류] {content}")
def show_system(self, content): # ➕ 시스템 메시지 표시
self.chat_display.append(f"[시스템] {content}")
display_message()의 system·error 분기도 이 함수로 교체합니다.
elif message_type == "system":
content = message.get("content", "")
self.show_system(content) # ✏️ append → show_system
elif message_type == "error":
content = message.get("content", "")
self.show_error(content) # ✏️ append → show_error
2.2 자기 자신 선택 차단을 위한 주소 저장
자기 자신에게 귓속말을 보내는 경우를 막으려면 GUI가 자기 주소를 알아야 합니다. 소켓이 연결되면 getsockname()으로 내 주소를 알 수 있으므로, 접속 성공 직후에 저장합니다.
self.client_socket = None
self.my_address = None # ➕ 내 주소 (접속 후 채워짐)
self.client_socket.connect((HOST, PORT))
self.my_address = str(self.client_socket.getsockname()) # ➕ 접속 성공 후 저장
getsockname()은 ('127.0.0.1', 52344) 형태의 튜플을 반환합니다.
서버가 clients에 저장하는 주소도 str(client_address)로 같은 형식이므로, 양쪽 모두 str()로 감싸면 문자열 비교가 정확하게 됩니다.
getsockname() → ('127.0.0.1', 52344) 튜플
str(getsockname()) → "('127.0.0.1', 52344)" 문자열
서버 str(client_address) → "('127.0.0.1', 52344)" 문자열 ← 같은 형식
연결이 끊어질 때 set_connected_state(False)에서 my_address도 함께 초기화합니다.
else:
self.status_label.setText("상태: 서버에 연결되지 않음")
self.user_list.clear()
self.selected_user = None
self.selected_user_label.setText("선택한 상대: 없음")
self.my_address = None # ➕ 연결 해제 시 초기화
✔ 확인 기준: show_error()가 있고, 접속 성공 후 self.my_address = str(...getsockname())로 채워지면 완료. str()로 감싸 문자열 형식을 서버와 맞췄는지 확인하세요.
3. 클라이언트 - 사전 검사 강화하기
→ on_whisper_clicked()에 자기 자신 선택 차단을 추가하고 오류 표시를 통일합니다.
검사는 순서대로 진행하고,
앞 단계에서 문제가 발견되면 서버로 보내지 않고 바로 show_error()로 안내합니다.
29강의 on_whisper_clicked()에서 달라지는 점은 두 가지입니다.
자기 자신 선택 검사(4번)가 새로 들어갔고, 오류 출력이 chat_display.append("[오류] ...")에서 show_error() 호출로 바뀌었습니다.
def on_whisper_clicked(self):
text = self.message_input.text().strip()
if self.client_socket is None: # 1. 연결 확인
self.show_error("서버에 먼저 접속하세요.")
return
if not text: # 2. 빈 내용 확인
self.show_error("귓속말 내용을 입력하세요.")
return
if self.selected_user is None: # 3. 상대 선택 확인
self.show_error("귓속말 상대를 먼저 선택하세요.")
return
if self.selected_user == self.my_address: # ➕ 4. 자기 자신 선택 차단
self.show_error("자기 자신에게는 귓속말을 보낼 수 없습니다.")
return
try: # 5. 전송
send_message(self.client_socket, {
"type": "whisper",
"target": self.selected_user,
"content": text
})
self.chat_display.append(f"[귓속말 보냄] {self.selected_user}: {text}")
self.message_input.clear()
except Exception as error:
self.show_error(f"귓속말 전송 실패: {error}") # ✏️ append → show_error로 통일
self.disconnect_from_server()
✔ 확인 기준: 연결·내용·선택·자기 자신 네 가지가 순서대로 검사되고, 각 단계에서 막히면 서버로 보내지 않고 show_error()로 안내하면 완료.
4. 서버 - 검증과 전송 실패 방어하기
→ send_whisper()에 전송 실패 방어를 추가하고, whisper 분기에 자기 자신 검사를 더합니다.
4.1 send_whisper() 실패 방어
대상 소켓으로 보내다 실패해도 서버가 멈추지 않도록 try/except로 감쌉니다.
실패 시 False를 반환해 호출한 쪽이 보낸 사람에게 알릴 수 있게 합니다.
끊긴 클라이언트 제거는 handle_client 스레드가 종료될 때 remove_client()로 처리하게 두므로,
여기서는 clients.remove()를 직접 호출하지 않습니다.
def send_whisper(sender_identifier, target_identifier, content):
target_client = find_client_by_identifier(target_identifier)
if target_client is None:
return False
try: # ✏️ 전송을 try로 감쌈
send_message(target_client["socket"], {
"type": "whisper",
"sender": sender_identifier,
"content": content
})
return True
except Exception: # ➕ 전송 실패 시 조용히 False 반환
return False
4.2 whisper 분기에 검사 추가하기
29강의 whisper 분기에 자기 자신 검사를 더하고, 대상이 없을 때 안내 문구를 구체화합니다.
성공 시 서버가 별도 시스템 메시지를 보내지 않는다는 원칙은 29강 수정본과 동일합니다.
GUI가 이미 [귓속말 보냄]을 직접 표시하기 때문입니다.
if message_type == "whisper":
target = message.get("target", "")
content = message.get("content", "").strip()
if not target:
send_error(client_socket, "귓속말 대상을 선택하세요.")
continue
if not content:
send_error(client_socket, "귓속말 내용을 입력하세요.")
continue
if target == address_text: # ➕ 자기 자신 검사 (서버 안전장치)
send_error(client_socket, "자기 자신에게는 귓속말을 보낼 수 없습니다.")
continue
success = send_whisper(address_text, target, content)
if not success: # 실패 시에만 오류 안내
send_error(client_socket, "선택한 사용자가 더 이상 접속 중이 아닙니다.")
broadcast_user_list() # ➕ 사라진 대상 반영해 목록 갱신
continue
대상이 사라진 경우 broadcast_user_list()를 호출하는 이유가 있습니다. 퇴장 처리가 정상적으로 이뤄졌다면 이미 목록이 갱신되었겠지만, 소켓이 갑자기 끊겨 정리가 늦어지는 경우 이 호출이 클라이언트 화면을 최신 상태로 맞춰줍니다.
✔ 확인 기준: send_whisper()가 전송 실패 시 False를 반환하고, whisper 분기에 자기 자신 검사가 있으며, 실패 시에만 send_error()와 broadcast_user_list()를 호출하면 완료.
5. 실행 결과 확인하기
→ 각 오류 상황에서 올바른 안내 메시지가 표시되는지 확인합니다.
각 상황을 하나씩 만들어 확인합니다.

| 상황 | 기대 표시 |
| 상대를 선택하지 않고 귓속말 버튼 클릭 | [오류] 귓속말 상대를 먼저 선택하세요. |
| 빈 입력창으로 귓속말 버튼 클릭 | [오류] 귓속말 내용을 입력하세요. |
| 자기 자신을 선택하고 귓속말 버튼 클릭 | [오류] 자기 자신에게는 귓속말을 보낼 수 없습니다. |
| 상대 선택 직후 그 상대 퇴장 후 귓속말 버튼 클릭 | [오류] 선택한 사용자가 더 이상 접속 중이 아닙니다. |
✔ 확인 기준: 각 상황에 맞는 안내가 표시되고, 어떤 오류에서도 서버·GUI가 멈추거나 꺼지지 않으면 완료.
5.1 실행이 안 될 때 확인할 것
| 오류 상황 | 원인 및 해결 방법 |
| 상대를 안 골랐는데 안내가 없다 | on_whisper_clicked()에 if self.selected_user is None: 검사를 추가합니다 |
| 자기 자신에게 귓속말이 보내진다 | 접속 후 self.my_address = str(self.client_socket.getsockname())를 저장하고 비교합니다 |
my_address와 selected_user가 같아 보이는데 비교가 안 된다 |
문자열 형식이 다릅니다. 서버 목록 주소와 getsockname()을 모두 str()로 통일합니다 |
| 대상이 퇴장했는데 서버 오류가 터미널에만 뜬다 | send_whisper()를 try/except로 감싸 False를 반환하고, 분기에서 send_error()로 안내합니다 |
| 오류가 전체 사용자에게 보인다 | broadcast()로 보냈습니다. send_error()로 요청한 클라이언트에게만 보냅니다 |
6. 최종 코드 정리하기
→ 이번 강의에서 완성한 server.py와 chat_client/main.py 전체 코드를 정리합니다.
6.1 chat_server/server.py
import socket
import threading
from protocol import receive_messages, send_message
HOST = "127.0.0.1"
PORT = 5000
clients = []
def print_clients():
print(f"현재 접속자 수: {len(clients)}")
def get_user_list():
return [client["address"] for client in clients]
def broadcast_user_list():
user_list_message = {
"type": "user_list",
"users": get_user_list()
}
for client in clients[:]:
try:
send_message(client["socket"], user_list_message)
except Exception:
pass
def broadcast(message, sender_socket=None):
for client in clients[:]:
client_socket = client["socket"]
if client_socket != sender_socket:
try:
send_message(client_socket, message)
except Exception:
pass
def send_error(client_socket, content):
send_message(client_socket, {
"type": "error",
"content": content
})
def remove_client(client_socket):
for client in clients[:]:
if client["socket"] == client_socket:
clients.remove(client)
return client["address"]
return None
def find_client_by_identifier(identifier):
for client in clients:
if client["address"] == identifier:
return client
if client.get("nickname") == identifier:
return client
return None
def send_whisper(sender_identifier, target_identifier, content):
target_client = find_client_by_identifier(target_identifier)
if target_client is None:
return False
try:
send_message(target_client["socket"], {
"type": "whisper",
"sender": sender_identifier,
"content": content
})
return True
except Exception:
return False
def handle_client(client_socket, client_address):
print(f"클라이언트 처리 시작: {client_address}")
buffer = ""
address_text = str(client_address)
try:
broadcast({
"type": "system",
"content": f"{address_text} 님이 입장했습니다."
}, client_socket)
broadcast_user_list()
while True:
try:
buffer, messages, connected = receive_messages(client_socket, buffer)
if not connected:
print(f"클라이언트 연결 끊김: {client_address}")
break
exit_requested = False
for message in messages:
print(f"{client_address} 메시지: {message}")
message_type = message.get("type")
if message_type == "exit":
print(f"클라이언트 종료 요청: {client_address}")
exit_requested = True
break
if message_type == "chat":
content = message.get("content", "").strip()
if not content:
send_error(client_socket, "빈 메시지는 보낼 수 없습니다.")
continue
broadcast({
"type": "chat",
"sender": address_text,
"content": content
}, client_socket)
continue
if message_type == "whisper":
target = message.get("target", "")
content = message.get("content", "").strip()
if not target:
send_error(client_socket, "귓속말 대상을 선택하세요.")
continue
if not content:
send_error(client_socket, "귓속말 내용을 입력하세요.")
continue
if target == address_text:
send_error(client_socket, "자기 자신에게는 귓속말을 보낼 수 없습니다.")
continue
success = send_whisper(address_text, target, content)
if not success:
send_error(client_socket, "선택한 사용자가 더 이상 접속 중이 아닙니다.")
broadcast_user_list()
continue
send_error(client_socket, f"지원하지 않는 메시지 타입입니다: {message_type}")
if exit_requested:
break
except Exception as error:
print(f"클라이언트 처리 오류: {client_address}, {error}")
break
finally:
try:
client_socket.close()
except Exception:
pass
removed_address = remove_client(client_socket)
broadcast({
"type": "system",
"content": f"{address_text} 님이 퇴장했습니다."
}, client_socket)
broadcast_user_list()
print(f"접속자 목록에서 제거: {removed_address}")
print_clients()
print(f"클라이언트 처리 종료: {client_address}")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind((HOST, PORT))
server_socket.listen()
print("서버를 시작합니다.")
print("클라이언트 접속 대기 중...")
while True:
client_socket, client_address = server_socket.accept()
address_text = str(client_address)
print(f"클라이언트 접속: {client_address}")
clients.append({
"socket": client_socket,
"address": address_text
})
print(f"접속자 목록에 추가: {client_address}")
print_clients()
client_thread = threading.Thread(
target=handle_client,
args=(client_socket, client_address)
)
client_thread.daemon = True
client_thread.start()
6.2 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,
QListWidget,
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.selected_user = None
self.my_address = None
self.setWindowTitle("채팅")
self.resize(700, 480)
self.status_label = QLabel("상태: 서버에 연결되지 않음")
self.connect_button = QPushButton("서버 접속")
self.chat_display = QTextEdit()
self.chat_display.setReadOnly(True)
self.chat_display.setPlaceholderText("채팅 메시지가 여기에 표시됩니다.")
self.user_list = QListWidget()
self.user_list.setMaximumWidth(150)
self.selected_user_label = QLabel("선택한 상대: 없음")
self.message_input = QLineEdit()
self.message_input.setPlaceholderText("메시지를 입력하세요")
self.send_button = QPushButton("보내기")
self.whisper_button = QPushButton("귓속말")
top_layout = QHBoxLayout()
top_layout.addWidget(self.status_label)
top_layout.addWidget(self.connect_button)
user_layout = QVBoxLayout()
user_layout.addWidget(self.selected_user_label)
user_layout.addWidget(self.user_list)
content_layout = QHBoxLayout()
content_layout.addWidget(self.chat_display)
content_layout.addLayout(user_layout)
input_layout = QHBoxLayout()
input_layout.addWidget(self.message_input)
input_layout.addWidget(self.send_button)
input_layout.addWidget(self.whisper_button)
main_layout = QVBoxLayout()
main_layout.addLayout(top_layout)
main_layout.addLayout(content_layout)
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.whisper_button.clicked.connect(self.on_whisper_clicked)
self.message_received.connect(self.display_message)
self.user_list.itemClicked.connect(self.on_user_selected)
self.set_connected_state(False)
def show_error(self, content):
self.chat_display.append(f"[오류] {content}")
def show_system(self, content):
self.chat_display.append(f"[시스템] {content}")
def set_connected_state(self, connected):
self.connect_button.setEnabled(not connected)
self.message_input.setEnabled(connected)
self.send_button.setEnabled(connected)
self.whisper_button.setEnabled(connected)
if connected:
self.status_label.setText("상태: 서버에 연결됨")
else:
self.status_label.setText("상태: 서버에 연결되지 않음")
self.user_list.clear()
self.selected_user = None
self.selected_user_label.setText("선택한 상대: 없음")
self.my_address = None
def connect_to_server(self):
try:
self.client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.client_socket.connect((HOST, PORT))
self.my_address = str(self.client_socket.getsockname())
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.show_system("서버에 연결되었습니다.")
except ConnectionRefusedError:
self.client_socket = None
self.connect_button.setEnabled(True)
self.status_label.setText("상태: 서버 연결 실패")
self.show_error("서버가 실행 중인지 확인하세요.")
except Exception as error:
self.client_socket = None
self.connect_button.setEnabled(True)
self.status_label.setText("상태: 서버 연결 실패")
self.show_error(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.show_error("서버에 먼저 접속하세요.")
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.show_error(f"메시지 전송 실패: {error}")
self.disconnect_from_server()
def on_whisper_clicked(self):
text = self.message_input.text().strip()
if self.client_socket is None:
self.show_error("서버에 먼저 접속하세요.")
return
if not text:
self.show_error("귓속말 내용을 입력하세요.")
return
if self.selected_user is None:
self.show_error("귓속말 상대를 먼저 선택하세요.")
return
if self.selected_user == self.my_address:
self.show_error("자기 자신에게는 귓속말을 보낼 수 없습니다.")
return
try:
send_message(self.client_socket, {
"type": "whisper",
"target": self.selected_user,
"content": text
})
self.chat_display.append(f"[귓속말 보냄] {self.selected_user}: {text}")
self.message_input.clear()
except Exception as error:
self.show_error(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.show_system("서버와의 연결을 종료했습니다.")
@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.show_system(content)
elif message_type == "error":
content = message.get("content", "")
self.show_error(content)
elif message_type == "user_list":
users = message.get("users", [])
self.update_user_list(users)
elif message_type == "whisper":
sender = message.get("sender", "unknown")
content = message.get("content", "")
self.chat_display.append(f"[귓속말 받음] {sender}: {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 on_user_selected(self, item):
self.selected_user = item.text()
self.selected_user_label.setText(f"선택한 상대: {self.selected_user}")
def update_user_list(self, users):
self.user_list.clear()
for user in users:
self.user_list.addItem(user)
if self.selected_user not in users:
self.selected_user = None
self.selected_user_label.setText("선택한 상대: 없음")
def closeEvent(self, event):
self.disconnect_from_server()
event.accept()
app = QApplication(sys.argv)
window = ChatWindow()
window.show()
sys.exit(app.exec())
6.3 최종 확인 표
| 확인할 코드 | 의미 |
| main.py | |
show_error(content) / show_system(content) |
오류·시스템 메시지 표시 형식 통일 |
self.my_address = str(...getsockname()) |
접속 성공 후 내 주소 저장 (자기 자신 비교용) |
if self.selected_user == self.my_address: |
자기 자신 귓속말 차단 (GUI 사전 검사) |
self.my_address = None (set_connected_state(False)) |
연결 해제 시 내 주소도 함께 초기화 |
| server.py | |
send_whisper() try/except → return False |
전송 실패에도 서버가 죽지 않게 방어 |
if target == address_text: |
서버 측 자기 자신 차단 (안전장치) |
"더 이상 접속 중이 아닙니다" + broadcast_user_list() |
대상 부재 안내 후 목록 재동기화 |
오류 표시는 [오류] 형식으로 통일하고, 오류는 요청한 클라이언트에게만 보냅니다. 이로써 텍스트 채팅과 귓속말까지 갖춘 안정적인 채팅 단계가 마무리됩니다.
→ 다음 강의 (31강): 여기서부터 파일 송수신 파트로 넘어갑니다. 파일 전송을 만들기 전에, 문자열은 encode()로 바이트가 됐는데 이미지·PDF 같은 파일은 어떻게 보내야 하는지, 텍스트 데이터와 바이너리 데이터가 무엇이 다른지부터 이해합니다.
0. 과제 안내
→ 이번 과제에서 무엇을 만들고 어디까지 하면 되는지 먼저 확인합니다.
30강까지 완성된 채팅 앱에 귓속말 이력 관리 기능을 추가합니다. 현재 모든 메시지(전체 채팅, 귓속말, 시스템)가 하나의 채팅창에 섞여 표시됩니다. 이번 과제에서는 보낸 귓속말과 받은 귓속말을 별도로 기록해, 지금 선택된 상대와 주고받은 귓속말 이력을 따로 볼 수 있게 합니다.
| 구분 | 내용 |
| 만들 것 | 귓속말 이력 저장 + 선택한 상대의 이력을 보여 주는 UI |
| 안 해도 되는 것 | 서버 수정, 파일 저장, 31강 이후 기능 |
| 확인할 것 | 상대를 선택했을 때 그 상대와의 귓속말 이력이 표시되는지 |
1. 전체 흐름 이해하기
→ 귓속말 이력을 어디에 저장하고 어떻게 표시할지 이해합니다.
1.1 이력 저장 구조
귓속말 이력은 딕셔너리로 관리합니다. 키는 상대방 식별자(주소 또는 닉네임), 값은 해당 상대와 주고받은 메시지 리스트입니다.
self.whisper_history = {} # {"상대방": ["[보냄] 안녕", "[받음] 반가워", ...]}
# 귓속말을 보낼 때
self.whisper_history.setdefault(self.selected_user, [])
self.whisper_history[self.selected_user].append(f"[보냄] {text}")
# 귓속말을 받을 때
self.whisper_history.setdefault(sender, [])
self.whisper_history[sender].append(f"[받음] {content}")
1.2 이력 표시 방법
두 가지 방향 중 하나를 선택합니다.
| 방향 | 방식 |
| 방향 A — 간단 | 상대를 클릭했을 때 on_user_selected()에서 해당 이력을 chat_display에 구분선과 함께 출력 |
| 방향 B — 정교 | 별도 QTextEdit(귓속말 이력창)을 화면 하단에 추가하고, 상대를 선택하면 그 창에 이력만 표시 |
2. 과제 A — 이력 저장하기
→ 보내고 받을 때마다 whisper_history에 기록합니다.
__init__()에 이력 딕셔너리를 초기화합니다.
self.whisper_history = {} # ➕ 상대별 귓속말 이력
on_whisper_clicked()의 전송 성공 직후에 기록합니다.
self.chat_display.append(f"[귓속말 보냄] {self.selected_user}: {text}")
# ➕ 이력 저장
self.whisper_history.setdefault(self.selected_user, [])
self.whisper_history[___].append(f"[보냄] {text}") # 빈칸: 어느 키에 저장할까요?
self.message_input.clear()
display_message()의 whisper 분기에도 기록합니다.
elif message_type == "whisper":
sender = message.get("sender", "unknown")
content = message.get("content", "")
self.chat_display.append(f"[귓속말 받음] {sender}: {content}")
# ➕ 이력 저장
self.whisper_history.setdefault(sender, [])
self.whisper_history[___].append(f"[받음] {content}") # 빈칸: 어느 키에 저장할까요?
✔ 확인 기준: 귓속말을 주고받은 뒤 print(self.whisper_history)로 이력이 쌓이는지 확인하면 완료.
3. 과제 B — 이력 표시하기
→ 상대를 클릭할 때 그 상대와의 귓속말 이력을 화면에 표시합니다.
방향 A — chat_display에 이력 출력
on_user_selected()에서 상대를 선택할 때 해당 이력을 채팅창에 출력합니다.
def on_user_selected(self, item):
self.selected_user = item.text()
self.selected_user_label.setText(f"선택한 상대: {self.selected_user}")
# ➕ 이력 표시
history = self.whisper_history.get(self.selected_user, [])
if history:
self.chat_display.append(f"\n--- {self.selected_user}와의 귓속말 이력 ---")
for entry in history:
self.chat_display.append(entry)
self.chat_display.append("---")
방향 B — 별도 귓속말 이력창
화면 하단에 읽기 전용 QTextEdit을 추가하고, 상대를 선택할 때마다 그 창에 이력을 표시합니다.
self.whisper_display = QTextEdit() # ➕ 귓속말 이력창
self.whisper_display.setReadOnly(True)
self.whisper_display.setMaximumHeight(100)
self.whisper_display.setPlaceholderText("선택한 상대와의 귓속말 이력")
def on_user_selected(self, item):
self.selected_user = item.text()
self.selected_user_label.setText(f"선택한 상대: {self.selected_user}")
# ➕ 이력창 갱신
history = self.whisper_history.get(self.selected_user, [])
self.whisper_display.clear()
for entry in history:
self.whisper_display.append(entry)
✔ 확인 기준: 방향 A 또는 방향 B 중 하나를 구현하고, 상대를 클릭할 때 그 상대와 주고받은 귓속말 이력이 표시되면 완료.
4. 도전 과제
→ 여유 있는 수강생을 위한 추가 과제를 안내합니다.
| 도전 항목 | 힌트 |
| 읽지 않은 귓속말 표시 | user_list 항목에 미확인 귓속말이 있으면 아이템 텍스트를 "alice (1)" 형태로 바꿉니다. 상대를 선택하면 초기화합니다 |
| 이력 파일 저장 | closeEvent()에서 json.dump(self.whisper_history, ...)로 파일에 저장하고, 시작 시 불러옵니다 |
| 연결 해제 시 이력 보존 | set_connected_state(False)에서 whisper_history는 초기화하지 않으면 재접속 후에도 이전 이력을 볼 수 있습니다 |