28강 UI 중심 ⏱ 약 20분

 

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_layoutchat_displayuser_list를 바로 나란히 넣었습니다. 이번에는 selected_user_labeluser_listuser_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_listsetMaximumWidth(150)이 이미 적용되어 있으므로 user_layout의 폭도 자동으로 제한됩니다.

✔ 확인 기준: 사용자 목록 위에 "선택한 상대: 없음" 라벨이 보이고, 채팅 영역과 사용자 영역이 좌우로 나란히 있으면 완료. 안 되면 content_layoutuser_list가 아닌 user_layoutaddLayout했는지 확인하세요.

 

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()QListWidgetaddItem(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": ...} 메시지를 전송합니다.