27강 구현 중심 ⏱ 약 30분

 

0. 학습 목표

→ 서버 접속자 목록을 user_list 메시지로 GUI에 동기화하는 구조를 만듭니다.

더보기

0.1 이번 글에서 다룰 내용

이번 글은 구현 중심 강의입니다.

 

26강까지 GUI 클라이언트는 메시지 송수신과 종료 처리가 안정적으로 완성되었습니다.

하지만 화면에 "누가 접속 중인지"가 보이지 않습니다.

귓속말 기능은 특정 상대를 골라야 하므로, 화면에 현재 접속자 목록이 있어야 다음 파트로 나아갈 수 있습니다.

이번 강의가 그 준비 단계입니다.

 

새 메시지 타입 user_list를 추가합니다.

서버는 접속자 목록이 바뀔 때마다 모든 클라이언트에게 목록을 보내고, GUI는 오른쪽 패널에 그 목록을 표시합니다.

클라이언트 접속 또는 종료
        ↓
서버가 clients 목록 갱신
        ↓
broadcast_user_list() — 모든 클라이언트에게 user_list 전송
        ↓
GUI display_message() → update_user_list() → QListWidget 갱신   ← 성공 지점

아직 닉네임은 만들지 않고, 클라이언트 주소 문자열을 임시 식별자로 사용합니다. 아래 코드에서 는 이전 강의까지 없던 새 줄, ✏️는 형태가 바뀐 줄을 뜻합니다. 최종 정리 코드에는 마커 없이 깨끗한 코드만 싣습니다.

구분 내용
이해할 것 서버 접속자 목록과 GUI 사용자 목록이 user_list 메시지로 동기화되는 방식
만들 것 서버 broadcast_user_list() + GUI QListWidget · update_user_list()
확인할 것 클라이언트가 접속·종료할 때 GUI 사용자 목록이 실시간으로 늘고 줄어드는지

 

0.2 이번 강의에서 직접 다루는 구조

이번 강의에서는 서버와 GUI 클라이언트를 함께 수정합니다. protocol.py는 그대로 사용합니다.

chat_server/
├── protocol.py          ← 유지
└── server.py            ← ✏️ 이번 강의에서 수정 (사용자 목록 전송)

chat_client/
├── protocol.py          ← 유지
├── client.py            ← 유지
└── main.py              ← ✏️ 이번 강의에서 수정 (사용자 목록 표시)

(➕ 새로 생성 · ✏️ 수정 · 표시 없음은 변경 없음)

 

이번 강의에서 새로 오가는 메시지 형식입니다. users에는 접속자 주소 문자열이 담깁니다.

{
    "type": "user_list",
    "users": [
        "('127.0.0.1', 52344)",
        "('127.0.0.1', 52345)"
    ]
}

 

1. 실습 준비하기

→ 서버를 실행하고, 이번 강의에서 정할 동기화 규칙을 확인합니다.

더보기

터미널 1에서 서버를 먼저 실행합니다.

python chat_server/server.py
서버를 시작합니다.
클라이언트 접속 대기 중...

서버가 대기 상태를 유지하면 준비 완료입니다. 이번 강의에서 서버와 클라이언트가 지킬 동기화 규칙은 다음과 같습니다.

이 표가 broadcast_user_list()를 어디서 호출할지 결정하는 기준입니다.

상황 서버 동작 클라이언트 동작
새 클라이언트 접속 clients에 추가 후 목록 전송 user_list 수신 → 목록 갱신
클라이언트 종료 clients에서 제거 후 목록 전송 user_list 수신 → 목록 갱신

"접속자 목록이 바뀌는 두 지점(접속 직후·제거 직후)마다 broadcast_user_list()를 부른다"는 것이 이번 강의의 핵심 규칙입니다. 이 두 곳 중 하나라도 빠지면 목록이 화면에서 바뀌지 않습니다.

 

2. 서버 - 접속자 목록 전송 만들기

→ clients 구조를 바꾸고, 목록을 브로드캐스트하는 함수를 추가합니다.

더보기

2.1 clients 구조 바꾸기

가장 먼저 수정해야 하는 곳은, 기존에 clients에 소켓만 저장하던 구조입니다.

사용자 목록에서 대상을 식별해 선택하려면 주소(또는 아이디, 닉네임)도 함께 알아야 하므로, 소켓과 주소를 딕셔너리로 묶어 저장합니다.

# 변경 전 — 소켓만 저장
clients.append(client_socket)

        ↓
        
# 변경 후 — 소켓과 주소를 함께 저장   ✏️
clients.append({
    "socket": client_socket,            # 통신용 소켓
    "address": str(client_address)      # 화면에 보일 주소 문자열
})

이 구조가 바뀌면 기존에 clients를 순회하던 코드도 client["socket"] 형태로 함께 바꿔야 합니다. broadcast()remove_client()가 대표적인 대상입니다.

💡 강사 팁 — 구조를 바꾸면 이 두 함수도 반드시 함께 수정할 것

clients를 딕셔너리 리스트로 바꾸면 기존에 client를 소켓으로 직접 쓰던 코드가 모두 깨집니다. 특히 broadcast()remove_client()를 반드시 함께 수정해야 합니다.

# ❌ 수정 전 — client가 소켓이라고 가정
for client in clients[:]:
    client.send(...)           # TypeError: 'dict' object is not a socket

# ✅ 수정 후 — 딕셔너리에서 소켓을 꺼내 사용
for client in clients[:]:
    client["socket"].send(...)

실습 중 TypeError: 'dict' object is not a socket 오류가 나오면 이 두 함수 중 하나에서 수정이 빠진 것입니다. clients 구조를 바꾼 뒤에는 파일 전체에서 client를 소켓으로 직접 넘기는 줄이 없는지 한 번 훑어보세요.

 

2.2 사용자 목록 전송 함수 추가하기

현재 접속자 주소를 모아 user_list 메시지로 모두에게 보내는 함수를 추가합니다.

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                        # 전송 실패는 무시 — 제거는 handle_client 종료 시 처리

broadcast_user_list() 안에서 실패한 소켓을 직접 clients.remove()하지 않는 이유가 있습니다. 이 함수는 여러 handle_client 스레드에서 동시에 호출될 수 있습니다. 한 스레드가 순회 중인 리스트를 다른 스레드가 동시에 수정하면 예기치 않은 오류가 발생합니다. 끊긴 클라이언트 제거는 각 handle_client() 스레드가 종료될 때 remove_client()로 처리하게 두는 것이 안전합니다.

✔ 확인 기준: clients{"socket", "address"} 딕셔너리 리스트로 바뀌었고, broadcast_user_list()가 작성되어 있으면 완료. 기존 broadcast()·remove_client()에서 client["socket"]으로 접근하도록 바뀌었는지 확인하세요.

 

3. 서버 - 전체 코드 완성하기

→ 접속·제거 지점에 목록 전송을 연결한 server.py 전체 코드를 정리합니다.

더보기

2단계에서 만든 함수를 접속 처리 흐름에 연결한 chat_server/server.py 전체입니다.

handle_client()의 소켓 관리를 with 블록 대신 try/finally로 바꾼 것이 이전 버전과의 차이입니다.

 

with client_socket:을 쓰면 블록 종료 시 소켓이 자동으로 닫히는데, 종료 후에remove_client()·broadcast()를 실행해야 하므로 소켓 닫기를 finally로 분리해 순서를 명확히 합니다.

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():                                      # ➕ 2단계에서 구현
    return [client["address"] for client in clients]

def broadcast_user_list():                                # ➕ 2단계에서 구현
    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 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

                    send_error(client_socket, f"지원하지 않는 메시지 타입입니다: {message_type}")

                if exit_requested:
                    break

            except Exception as error:
                print(f"클라이언트 처리 오류: {client_address}, {error}")
                break

    finally:                                              # ✏️ with 대신 try/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()

 

try/finally를 선택한 이유를 한번 더 짚겠습니다.

기존 코드는 with client_socket: 블록으로 소켓을 관리했는데, 이 방식은 블록이 끝날 때 소켓이 자동으로 닫힙니다.

 

문제는 소켓이 닫힌 뒤에도 remove_client(), broadcast()(퇴장 메시지), broadcast_user_list()를 실행해야 한다는 점입니다. try/finally를 사용하면 소켓을 닫는 시점을 직접 제어할 수 있고, finally 블록에서 나머지 정리 작업을 순서대로 실행할 수 있습니다.

✔ 확인 기준: 입장 직후와 퇴장 직후(finally 블록) 두 곳에서 broadcast_user_list()가 호출되고, broadcast()·remove_client()가 모두 client["socket"] 형태로 바뀌었으면 완료.

 

4. 클라이언트 - 사용자 목록 표시하기

→ QListWidget을 배치하고 user_list 메시지를 받아 목록을 갱신합니다.

더보기

4.1 QListWidget 배치하기

main.py의 import에 QListWidget을 추가하고, 채팅 표시 영역 오른쪽에 사용자 목록을 둡니다. 26강에서는 main_layoutchat_display를 바로 넣었지만, 이번에는 chat_displayuser_list를 가로로 묶은 content_layout을 넣습니다.

from PySide6.QtWidgets import (
    QApplication,
    QWidget,
    QTextEdit,
    QLineEdit,
    QPushButton,
    QLabel,
    QListWidget,                                    # ➕ 사용자 목록 위젯
    QVBoxLayout,
    QHBoxLayout,
)
        self.chat_display = QTextEdit()
        self.chat_display.setReadOnly(True)
        self.chat_display.setPlaceholderText("채팅 메시지가 여기에 표시됩니다.")

        self.user_list = QListWidget()                  # ➕ 접속자 목록 위젯
        self.user_list.setMaximumWidth(150)             # ➕ 목록 폭 제한 (채팅 영역이 더 넓게)

        content_layout = QHBoxLayout()                  # ➕ 채팅 + 목록을 가로 배치
        content_layout.addWidget(self.chat_display)
        content_layout.addWidget(self.user_list)

        main_layout = QVBoxLayout()
        main_layout.addLayout(top_layout)
        main_layout.addLayout(content_layout)           # ✏️ addWidget(chat_display) → addLayout(content_layout)
        main_layout.addLayout(input_layout)

setMaximumWidth(150)을 추가하는 이유는 QListWidget이 기본적으로 QTextEdit과 같은 비율로 공간을 나눠 가지기 때문입니다. 목록 폭에 제한을 두면 채팅 표시 영역이 더 넓게 유지됩니다.

 

4.2 user_list 메시지 처리하기

display_message()user_list 분기를 추가하고, 목록을 새로 그리는 update_user_list()를 만듭니다. 받은 목록으로 매번 처음부터 다시 그리므로 누락이나 중복이 생기지 않습니다.

        elif message_type == "user_list":               # ➕ 사용자 목록 메시지 분기
            users = message.get("users", [])
            self.update_user_list(users)
    def update_user_list(self, users):                  # ➕ 목록 위젯 다시 그리기
        self.user_list.clear()                          # 기존 항목 모두 비우고
        for user in users:
            self.user_list.addItem(user)                # 받은 목록을 새로 채움

수신 흐름은 25강에서 만든 구조를 그대로 탑니다. user_list 메시지도 수신 스레드가 Signal로 내보내고 display_message()가 메인 흐름에서 받아 처리합니다.

서버 user_list 메시지

        ↓ receive_loop → message_received.emit
        
display_message() (메인 흐름)

        ↓
        
update_user_list() → QListWidget 다시 그림   ← 이번 강의의 성공 지점

✔ 확인 기준: 서버 접속 후 오른쪽 사용자 목록에 접속자 주소가 표시되면 완료. 안 보이면 display_message()user_list 분기가 있는지, main_layoutcontent_layout을 넣었는지 확인하세요.

 

5. 실행 결과 확인하기

→ 서버와 클라이언트를 실행해 접속·종료 시 사용자 목록이 바뀌는지 확인합니다.

더보기

5.1 첫 번째 클라이언트 접속 확인

python chat_server/server.py
python chat_client/main.py

GUI에서 "서버 접속" 버튼을 누르면 채팅창에 입장 시스템 메시지가 표시되고, 오른쪽 사용자 목록에 현재 클라이언트 주소가 나타납니다.

('127.0.0.1', 52344)

 

5.2 두 번째 클라이언트 접속·종료 확인

터미널 클라이언트나 GUI 클라이언트를 하나 더 실행합니다.

python chat_client/client.py

첫 번째 GUI의 사용자 목록 항목이 두 개로 늘어납니다.

('127.0.0.1', 52344)
('127.0.0.1', 52345)

두 번째 클라이언트에서 exit을 입력하면, 첫 번째 GUI 목록에서 해당 주소가 사라집니다. 접속과 종료에 맞춰 목록이 실시간으로 늘고 줄어드는지 확인합니다.

✔ 확인 기준: 접속 시 사용자 목록이 늘고 종료 시 줄면 완료. 줄지 않으면 서버 finally 블록에서 remove_client()broadcast_user_list()를 호출하는지 확인하세요.

 

5.3 실행이 안 될 때 확인할 것

오류 상황 원인 및 해결 방법
사용자 목록이 비어 있다 서버가 user_list를 보내지 않았습니다. 입장 직후 broadcast_user_list()를 호출합니다
새 클라이언트가 접속해도 목록이 안 바뀐다 clients.append() 후 스레드가 시작되고 handle_client()에서 broadcast_user_list()를 호출하는지 확인합니다
종료한 사용자가 목록에 남아 있다 finally 블록에서 remove_client()broadcast_user_list()를 호출하는지 확인합니다
서버에서 TypeError가 난다 clients를 딕셔너리 리스트로 바꿨는데 기존 코드가 소켓처럼 접근합니다. client["socket"]·client["address"]로 접근합니다
GUI에 [알 수 없는 메시지]로 뜬다 display_message()user_list 분기가 없습니다. elif message_type == "user_list":를 추가합니다
NameError: QListWidget from PySide6.QtWidgets import 목록에 QListWidget을 추가합니다
ConnectionRefusedError 서버가 실행 중이 아닙니다. python chat_server/server.py를 먼저 실행합니다

 

6. 최종 코드 정리하기

→ 이번 강의에서 완성한 chat_client/main.py 전체 코드를 정리합니다.

더보기

전체 server.py 코드는 3번 섹션에 마커 없이 정리되어 있습니다. 이번에는 변경 내용이 많은 main.py 전체를 함께 싣습니다.

6.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.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.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)

        content_layout = QHBoxLayout()
        content_layout.addWidget(self.chat_display)
        content_layout.addWidget(self.user_list)

        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.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()

    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 update_user_list(self, users):
        self.user_list.clear()
        for user in users:
            self.user_list.addItem(user)

    def closeEvent(self, event):
        self.disconnect_from_server()
        event.accept()


app = QApplication(sys.argv)

window = ChatWindow()
window.show()

sys.exit(app.exec())

 

6.2 최종 확인 표

확인할 코드 의미
server.py  
clients.append({"socket": ..., "address": ...}) 소켓과 주소를 함께 저장
get_user_list() 접속자 주소만 추려 리스트로 반환
broadcast_user_list() — pass만 (remove 없음) 스레드 안전성 유지 — 제거는 handle_client 종료 시 처리
try/finally (handle_client) 소켓 닫기 시점을 직접 제어해 퇴장 처리 순서 보장
입장 직후·finally 블록에서 broadcast_user_list() 목록 변경 두 지점에서 모두 동기화
main.py  
QListWidget import · self.user_list = QListWidget() 사용자 목록 위젯 생성
self.user_list.setMaximumWidth(150) 목록 폭 제한으로 채팅 영역 확보
content_layout (chat_display + user_list) 채팅 영역과 목록을 가로 배치
elif message_type == "user_list": 사용자 목록 메시지 분기
update_user_list(users) 목록 위젯을 비우고 새로 그림
self.user_list.clear() (set_connected_state(False)) 연결 해제 시 목록도 함께 초기화

메시지 타입은 user_list, 목록 키는 users로 서버와 클라이언트가 동일하게 맞춰야 합니다.

→ 다음 강의 (28강): 이번에 만든 사용자 목록을 단순 표시용에서 선택 가능한 UI로 발전시킵니다. QListWidget에서 상대를 클릭해 선택하면, 29강에서 그 상대에게만 whisper 메시지를 보내는 귓속말 구조로 이어집니다.

 


27강 이후 과제 구현 과제 ⏱ 예상 30~50분

 

0. 과제 안내

→ 이번 과제에서 무엇을 만들고 어디까지 하면 되는지 먼저 확인합니다.

더보기

27강에서는 사용자 목록에 클라이언트 주소('127.0.0.1', 52344)를 표시했습니다. 이번 과제에서는 사용자 목록에 주소 대신 닉네임이 표시되도록 서버와 클라이언트를 함께 수정합니다.

구분 내용
만들 것 닉네임 입력 창 or 입력 단계 + 서버에서 닉네임으로 목록 관리
안 해도 되는 것 닉네임 중복 처리(선택), 파일 저장, 귓속말 기능
확인할 것 사용자 목록에 주소 대신 닉네임이 표시되는지

 

1. 전체 흐름 이해하기

→ 닉네임이 어떻게 서버에 전달되고 사용자 목록에 반영되는지 이해합니다.

더보기

1.1 닉네임 등록 흐름

클라이언트 접속
        ↓
닉네임 입력 후 서버에 전송 {"type": "set_nickname", "nickname": "alice"}
        ↓
서버가 clients에서 해당 클라이언트의 nickname 필드 업데이트
        ↓
broadcast_user_list() — 닉네임 기준으로 목록 전송
        ↓
GUI 사용자 목록에 닉네임 표시

 

1.2 메시지 설계

새 메시지 타입 하나만 추가합니다. protocol.py는 수정하지 않습니다.

닉네임 등록 (클라이언트 → 서버)
{"type": "set_nickname", "nickname": "alice"}

user_list 메시지 (서버 → 클라이언트) — 형식 동일, 내용만 달라짐
{"type": "user_list", "users": ["alice", "bob"]}

 

1.3 서버 clients 구조 변경

27강에서 만든 딕셔너리에 nickname 필드를 추가합니다. 닉네임을 설정하기 전에는 주소를 임시 표시합니다.

clients.append({
    "socket": client_socket,
    "address": str(client_address),
    "nickname": str(client_address)     # ➕ 닉네임 미설정 시 주소를 임시 사용
})

get_user_list()nickname을 기준으로 바꿉니다.

def get_user_list():
    return [client["nickname"] for client in clients]   # ✏️ address → nickname

 

2. 과제 A — 서버에 닉네임 처리 추가하기

→ handle_client()에 set_nickname 분기를 추가합니다.

더보기

기존 handle_client()의 메시지 분기에 set_nickname을 추가합니다. 빈칸(___)을 채워 완성하세요.

                if message_type == "set_nickname":
                    nickname = message.get("nickname", "").strip()

                    if not nickname:
                        send_error(client_socket, "닉네임을 입력하세요.")
                        continue

                    # clients에서 이 소켓에 해당하는 딕셔너리를 찾아 nickname을 업데이트
                    for client in clients:
                        if client["socket"] == client_socket:
                            client[___] = nickname      # 빈칸: 어느 키에 저장할까요?
                            break

                    address_text = nickname             # 이후 chat 메시지의 sender도 닉네임으로
                    broadcast_user_list()               # 닉네임 변경 후 목록 동기화
                    continue

✔ 확인 기준: set_nickname 분기가 추가되고, 닉네임 설정 후 broadcast_user_list()가 호출되어 GUI 목록이 갱신되면 완료.

 

3. 과제 B — 클라이언트에 닉네임 입력 추가하기

→ 서버 접속 후 닉네임을 입력해 전송하는 방법을 두 가지 방향 중 하나로 구현합니다.

더보기

방향 A — 간단한 방법: 접속 직후 입력 다이얼로그

PySide6의 QInputDialog를 사용하면 별도 창 없이 한 줄로 입력을 받을 수 있습니다. connect_to_server()의 연결 성공 블록 끝에 추가합니다.

from PySide6.QtWidgets import QInputDialog  # 파일 위쪽에 추가

# connect_to_server() 성공 블록 끝에 추가
nickname, ok = QInputDialog.getText(self, "닉네임 설정", "사용할 닉네임을 입력하세요:")
if ok and nickname.strip():
    send_message(self.client_socket, {
        "type": ___,                    # 빈칸: 닉네임 등록 타입
        "nickname": nickname.strip()
    })

 

방향 B — 조금 더 정교한 방법: 닉네임 전용 입력 UI

채팅창 상단에 닉네임 입력창과 "설정" 버튼을 추가합니다. 닉네임을 설정하면 입력창이 사라지거나 비활성화됩니다.

[ 상태 라벨 ] [ 서버 접속 ]
[ 닉네임 입력창 ] [ 닉네임 설정 ]   ← 서버 접속 후 활성화
[ 채팅 표시 영역 ] | [ 사용자 목록 ]
[ 메시지 입력창 ] [ 보내기 ]

✔ 확인 기준: 닉네임을 입력하면 GUI 사용자 목록에 주소 대신 닉네임이 표시되면 완료. 방향 A와 방향 B 중 하나만 구현해도 됩니다.

 

4. 실행해서 확인하기

→ 서버와 클라이언트를 실행해 닉네임이 사용자 목록에 표시되는지 확인합니다.

더보기
# 터미널 1 — 서버 먼저 실행
python chat_server/server.py
# 터미널 2 — 클라이언트 실행
python chat_client/main.py

서버 접속 후 닉네임을 입력합니다. GUI 사용자 목록에 주소 대신 닉네임이 표시되면 완료입니다. 두 개의 클라이언트를 실행해 서로 다른 닉네임을 설정하면 두 닉네임이 목록에 나타나야 합니다.

동작 기대 결과
접속 직후 (닉네임 미설정) 사용자 목록에 주소 임시 표시
alice 닉네임 설정 사용자 목록에 alice 표시
두 번째 클라이언트가 bob 설정 목록에 alicebob 함께 표시
bob 접속자 종료 목록에 alice만 남음

 

5. 도전 과제

→ 여유 있는 수강생을 위한 추가 과제를 안내합니다.

더보기
도전 항목 힌트
닉네임 중복 방지 get_user_list() 결과에 이미 같은 닉네임이 있으면 send_error()로 알리고 continue합니다
채팅 메시지에도 닉네임 표시 handle_client()에서 address_text를 닉네임으로 업데이트하면 이후 chat 메시지의 sender도 자동으로 닉네임이 됩니다
닉네임 변경 허용 set_nickname을 이미 닉네임이 있는 상태에서도 다시 받아 처리하면 됩니다. 변경 후 broadcast_user_list()를 호출합니다