28강. 상대 선택 UI 만들기

0. 학습 목표
→ 사용자 목록을 클릭 가능한 UI로 만들고, 선택한 상대를 화면에 표시합니다.
0.1 이번 글에서 다룰 내용
이번 글은 UI 중심 강의입니다.
27강에서 오른쪽 사용자 목록(QListWidget)은 접속자를 보여 주기만 했습니다.
이번 강의에서는 그 목록을 선택 가능한 UI로 만듭니다.
항목을 클릭하면 선택한 상대가 selected_user에 저장되고 화면에 표시됩니다.
아직 귓속말 메시지를 서버로 보내지는 않습니다.
이번 강의의 목표는 "누구에게 보낼지 고르는 화면 흐름"을 완성하는 것입니다.
아래 코드에서 ➕는 27강 코드까지 없던 새 줄, ✏️는 형태가 바뀐 줄을 뜻합니다.
최종 정리 코드에는 마커 없이 깨끗한 코드만 싣습니다.

| 구분 | 내용 |
| 이해할 것 | 사용자 목록 클릭 이벤트가 selected_user 저장으로 이어지는 방식, 퇴장 시 선택 자동 해제 구조 |
| 만들 것 | chat_client/main.py 수정 — selected_user_label · selected_user · on_user_selected() |
| 확인할 것 | 목록 항목을 클릭하면 선택 라벨이 바뀌고, 그 사용자가 나가면 선택이 자동으로 해제되는지 |
0.2 이번 강의에서 직접 다루는 구조
이번 강의에서는 27강에서 만든 chat_client/main.py만 수정합니다. 서버는 변경하지 않습니다.
chat_server/
├── protocol.py ← 유지
└── server.py ← 유지 (변경 없음)
chat_client/
├── protocol.py ← 유지
├── client.py ← 유지
└── main.py ← ✏️ 이번 강의에서 수정
(➕ 새로 생성 · ✏️ 수정 · 표시 없음은 변경 없음)
ChatWindow에 추가·변경되는 요소입니다.
ChatWindow
├── self.selected_user ← ➕ 현재 선택한 상대 저장 변수
├── self.selected_user_label ← ➕ 선택한 상대 화면 표시 라벨
├── user_layout (라벨 + 목록) ← ✏️ 오른쪽 사용자 영역을 세로로 묶음
├── set_connected_state() ← ✏️ 연결 해제 시 selected_user 초기화 추가
├── on_user_selected() ← ➕ 목록 클릭 시 선택 상태 갱신
└── update_user_list() ← ✏️ 목록 갱신 시 선택 상태 정리
1. UI 구조 확인하기
→ 추가할 화면 요소와 레이아웃 구조를 먼저 파악합니다.

이번 강의에서 추가하는 화면 요소는 두 가지입니다.
| 화면 요소 | 역할 |
selected_user_label |
현재 선택한 귓속말 대상을 화면에 표시 |
selected_user |
코드 안에서 선택한 상대를 기억하는 변수 |
27강에서 오른쪽은 user_list 하나뿐이었습니다. 이번에는 그 위에 selected_user_label을 한 줄 추가하고, 두 요소를 user_layout(세로)으로 묶습니다.
전체 창
├── 상단 영역 (status_label + connect_button)
├── 중앙 영역 (content_layout — 가로)
│ ├── 채팅 표시 영역 (chat_display)
│ └── 사용자 영역 (user_layout — 세로) ← ✏️ 이번 강의에서 변경
│ ├── selected_user_label ← ➕
│ └── user_list
└── 하단 영역 (message_input + send_button)
✔ 확인 기준: 27강에서는 오른쪽이 user_list 하나였는데, 이번에는 그 위에 selected_user_label이 한 줄 더 생기고 둘을 user_layout으로 세로 묶음한다는 점을 말할 수 있으면 완료.
2. 화면 요소 배치하기
→ 선택 상태 변수와 라벨을 만들고 사용자 영역 레이아웃을 다시 구성합니다.
2.1 선택 상태 변수와 라벨 만들기
__init__() 안, user_list를 만드는 곳 근처에 선택 상태 변수와 라벨을 추가합니다. 처음에는 아무도 선택하지 않았으므로 None으로 둡니다.
self.user_list = QListWidget()
self.user_list.setMaximumWidth(150)
self.selected_user = None # ➕ 선택한 상대 (아직 없음)
self.selected_user_label = QLabel("선택한 상대: 없음") # ➕ 선택 상태 표시 라벨
사용자가 목록에서 항목을 클릭하면 라벨 텍스트가 바뀝니다.
선택한 상대: 없음
↓ (목록에서 클릭)
선택한 상대: ('127.0.0.1', 52345)
2.2 사용자 영역 레이아웃 다시 구성하기
27강에서는 content_layout에 chat_display와 user_list를 바로 나란히 넣었습니다. 이번에는 selected_user_label과 user_list를 user_layout(세로)으로 먼저 묶은 뒤, 그 묶음을 채팅 영역 오른쪽에 넣습니다.
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) # ✏️ addWidget(user_list) → addLayout(user_layout)
main_layout = QVBoxLayout()
main_layout.addLayout(top_layout)
main_layout.addLayout(content_layout)
main_layout.addLayout(input_layout)
self.setLayout(main_layout)
content_layout.addWidget(self.user_list)가 아니라 content_layout.addLayout(user_layout)으로 바꾸는 것이 핵심입니다. user_list에 setMaximumWidth(150)이 이미 적용되어 있으므로 user_layout의 폭도 자동으로 제한됩니다.
✔ 확인 기준: 사용자 목록 위에 "선택한 상대: 없음" 라벨이 보이고, 채팅 영역과 사용자 영역이 좌우로 나란히 있으면 완료. 안 되면 content_layout에 user_list가 아닌 user_layout을 addLayout했는지 확인하세요.
3. 이벤트 연결하기
→ 목록 클릭 이벤트를 연결하고, 선택 상태를 갱신·정리하는 함수를 작성합니다.
3.1 클릭 이벤트 연결과 선택 처리 함수
__init__()의 이벤트 연결 부분에 한 줄을 추가합니다. itemClicked는 사용자가 목록 항목을 클릭할 때 발생하는 신호이고, 클릭된 항목 객체(QListWidgetItem)가 함수의 item 인자로 전달됩니다.
self.message_received.connect(self.display_message)
self.user_list.itemClicked.connect(self.on_user_selected) # ➕ 목록 클릭 → on_user_selected
def on_user_selected(self, item): # ➕ 클릭한 항목 처리
self.selected_user = item.text() # 예: "('127.0.0.1', 52345)"
self.selected_user_label.setText(f"선택한 상대: {self.selected_user}")
item.text()는 QListWidget에 addItem(user)로 추가한 문자열을 그대로 돌려줍니다. 27강에서 주소 문자열을 넣었으므로 여기서도 주소 문자열이 selected_user에 저장됩니다. 27강 과제(닉네임)를 구현한 경우에는 닉네임이 저장됩니다.
3.2 사용자 목록 갱신 시 선택 상태 정리하기
선택한 사용자가 퇴장하면 선택 상태를 자동으로 지워야 합니다. 그렇지 않으면 이미 나간 상대가 선택된 것처럼 남아 있습니다. 27강의 update_user_list()에 정리 코드를 추가합니다.
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("선택한 상대: 없음")
서버가 보내는 user_list는 항상 현재 접속자 전체 목록이므로, 갱신 때마다 선택한 상대가 그 안에 있는지만 확인하면 됩니다.
3.3 연결 해제 시 선택 상태 초기화하기
서버와의 연결이 끊기면 사용자 목록도 비워지므로, set_connected_state(False)에서 selected_user도 함께 초기화해야 합니다. 그렇지 않으면 재접속 후 이전 선택 상태가 라벨에 남아 있습니다.
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.user_list.clear()
self.selected_user = None # ➕ 선택 상태 초기화
self.selected_user_label.setText("선택한 상대: 없음") # ➕ 라벨 초기화
이 강의에서 만든 선택 흐름 전체를 정리하면 다음과 같습니다.
목록 클릭 → on_user_selected() → selected_user 저장 → 라벨 갱신
↓ (퇴장 또는 연결 해제)
update_user_list() 또는 set_connected_state(False)
↓
selected_user = None → 라벨 "선택한 상대: 없음" ← 이번 강의의 성공 지점
✔ 확인 기준: 목록 항목을 클릭하면 선택 라벨이 바뀌고, 선택한 사용자가 퇴장하거나 서버 연결이 끊기면 "선택한 상대: 없음"으로 돌아오면 완료. 안 되면 update_user_list()의 selected_user not in users 조건과 set_connected_state()의 초기화 코드를 확인하세요.
4. 화면에서 결과 확인하기
→ 두 번째 클라이언트를 접속해 선택 기능이 동작하는지 확인합니다.

터미널 1에서 서버를, 터미널 2에서 GUI 클라이언트를 실행합니다.
python chat_server/server.py
python chat_client/main.py
서버 접속 후 오른쪽 사용자 영역에는 라벨과 목록이 다음처럼 표시됩니다.
선택한 상대: 없음
('127.0.0.1', 52344)
터미널 3에서 클라이언트를 하나 더 접속시키면 목록이 두 개로 늘어납니다. 두 번째 항목을 클릭하면 라벨이 바뀝니다.
선택한 상대: ('127.0.0.1', 52345)
접속자 리스트
('127.0.0.1', 52344)
('127.0.0.1', 52345) # 선택된 접속자
두 번째 클라이언트가 exit을 입력하거나 창을 닫으면, 서버가 갱신된 user_list를 보내고 선택 상태가 자동으로 초기화됩니다.
선택한 상대: 없음
✔ 확인 기준: 클릭 시 선택 라벨이 바뀌고, 선택한 사용자가 사라지면 선택 상태가 자동으로 해제되면 완료.
4.1 실행이 안 될 때 확인할 것
| 오류 상황 | 원인 및 해결 방법 |
| 목록을 클릭해도 반응이 없다 | itemClicked를 연결하지 않았습니다. self.user_list.itemClicked.connect(self.on_user_selected)를 추가합니다 |
| 선택 라벨이 바뀌지 않는다 | on_user_selected() 안에서 selected_user_label.setText(...)를 호출하지 않았습니다 |
| 퇴장한 사용자가 선택 상태로 남는다 | update_user_list()에 self.selected_user not in users 조건을 추가합니다 |
| 재접속 후 이전 선택 상태가 라벨에 남는다 | set_connected_state(False)에서 self.selected_user = None과 라벨 초기화를 추가합니다 |
| 사용자 목록이 채팅창 아래로 내려간다 | content_layout = QHBoxLayout()으로 채팅 영역과 사용자 영역을 좌우로 배치합니다 |
on_user_selected() missing argument |
itemClicked가 클릭된 항목을 인자로 넘깁니다. def on_user_selected(self, item):처럼 item을 받아야 합니다 |
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,
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.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("보내기")
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)
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.message_received.connect(self.display_message)
self.user_list.itemClicked.connect(self.on_user_selected)
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("상태: 서버에 연결되지 않음")
self.user_list.clear()
self.selected_user = None
self.selected_user_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 == "user_list":
users = message.get("users", [])
self.update_user_list(users)
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())
5.2 최종 확인 표
| 확인할 코드 | 의미 |
self.selected_user = None |
선택한 상대 초기값 (없음) |
self.selected_user_label = QLabel("선택한 상대: 없음") |
선택 상태를 보여 주는 라벨 |
user_layout (라벨 + 목록 세로 묶음) |
오른쪽 사용자 영역을 세로로 구성 |
content_layout.addLayout(user_layout) |
채팅 영역 오른쪽에 사용자 영역 배치 |
user_list.itemClicked.connect(on_user_selected) |
목록 클릭 이벤트 연결 |
on_user_selected(item) |
클릭한 항목을 selected_user에 저장, 라벨 갱신 |
if self.selected_user not in users: |
퇴장한 상대의 선택 자동 해제 |
self.selected_user = None (set_connected_state(False)) |
연결 해제 시 선택 상태도 함께 초기화 |
이번 강의에서 만든 self.selected_user는 29강에서 귓속말 대상으로 그대로 쓰입니다.
→ 다음 강의 (29강): self.selected_user 값을 실제로 사용합니다. 입력창 메시지를 전체 채팅으로 보낼지, 선택한 상대에게만 귓속말로 보낼지 구분해 {"type": "whisper", "target": selected_user, "content": ...} 메시지를 전송합니다.