29강 구현 중심 ⏱ 약 30분

 

0. 학습 목표

→ whisper 메시지 타입을 추가해 선택한 상대 한 명에게만 메시지를 전달합니다.

더보기

0.1 이번 글에서 다룰 내용

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

 

28강까지 사용자 목록에서 상대를 선택하고 selected_user에 저장할 수 있었습니다.

하지만 그 선택은 화면에만 머물러 있었습니다.

 

이번 강의에서는 새 메시지 타입 whisper를 추가해,

selected_usertarget으로 삼아 선택한 상대에게만 메시지를 보냅니다.

 

서버는 target과 주소가 일치하는 클라이언트를 찾아 그 한 명에게만 전달합니다.

# 클라이언트
GUI에서 사용자 선택 (28강 selected_user)
        ↓
입력창에 메시지 작성 → 귓속말 버튼 클릭
        ↓
{"type": "whisper", "target": ..., "content": ...} 전송
        ↓

# 서버
서버가 target 주소로 클라이언트 찾기 → 그 소켓에만 전달
        ↓
받는 사람 화면에 [귓속말 받음] 표시   ← 이번 강의의 성공 지점

핵심 성공 기준은 선택한 상대에게만 메시지가 표시되고, 관계없는 다른 사용자에게는 보이지 않는 것입니다. 

구분 내용
이해할 것 전체 채팅과 귓속말은 메시지 타입과 전달 대상이 다르다는 점
만들 것 서버 find_client_by_identifier() · send_whisper() + GUI 귓속말 버튼 · on_whisper_clicked()
확인할 것 선택한 상대에게만 메시지가 표시되고 다른 사용자에게는 보이지 않는지

 

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

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

chat_server/
├── protocol.py          ← 유지
└── server.py            ← ✏️ 이번 강의에서 수정 (귓속말 전달)

chat_client/
├── protocol.py          ← 유지
├── client.py            ← 유지
└── main.py              ← ✏️ 이번 강의에서 수정 (귓속말 버튼·전송)

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

 

이번 강의에서 다루는 메시지 타입입니다. whisper가 새로 추가됩니다.

메시지 타입 역할
chat 전체 채팅 메시지 — 모두에게 전달
whisper 귓속말 — 특정 사용자 한 명에게만 전달 (이번 강의 추가)
error 귓속말 실패 등 오류 안내

 

1. 실습 준비하기

→ 전체 채팅과 귓속말의 차이를 코드 수준으로 확인하고, 이번 강의에서 정할 규칙을 정리합니다.

더보기

1.1 전체 채팅과 귓속말의 차이

두 메시지의 차이는 형태와 전달 범위입니다. 귓속말에는 받을 대상을 지정하는 target 키가 있습니다.

# 전체 채팅 — 모두에게
{"type": "chat", "content": "전체 채팅입니다."}

# 귓속말 — target 한 명에게만
{"type": "whisper", "target": "('127.0.0.1', 52345)", "content": "귓속말입니다."}
전체 채팅: A → 서버 → B, C 모두 전달
귓속말:   A → 서버 → B에게만 전달, C에게는 전달하지 않음

 

1.2 이번 강의의 귓속말 규칙

규칙 내용
대상 선택 28강에서 만든 selected_usertarget으로 사용
서버 전달 targetclient["address"]가 일치하는 클라이언트에게만 전송
보낸 사람 표시 [귓속말 보냄] 상대: 내용 — GUI에서 직접 표시
받는 사람 표시 [귓속말 받음] 보낸사람: 내용 — 서버가 전달한 whisper 메시지를 GUI가 표시

보낸 사람 화면에는 GUI가 직접 [귓속말 보냄]을 표시하고, 서버에서 별도로 "보냈습니다" 시스템 메시지를 보내지 않습니다. 한 동작에 두 줄이 겹치는 중복을 피하기 위해서입니다.

✔ 확인 기준: 귓속말은 broadcast()가 아니라 target 한 명의 소켓에만 보내며, selected_usertarget이 된다는 연결을 말할 수 있으면 완료.

 

2. 서버 - 귓속말 전송 함수 만들기

→ target으로 클라이언트를 찾는 함수와 그 한 명에게만 보내는 함수를 추가합니다.

더보기

2.1 대상 찾기 함수

27강에서 clients{"socket", "address"} 딕셔너리 리스트로 바꿔 둔 덕분에, 식별자 문자열로 대상 클라이언트를 찾을 수 있습니다.

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

 

27강 기본 코드 기준(주소 문자열)으로 구현하되, 27강 과제로 닉네임을 도입했다면 client["nickname"]과 비교하도록 함께 확인하는 방식을 사용합니다.

27강 과제로 닉네임을 추가하지 않았다면 client.get("nickname")은 항상 None을 반환하므로 동작에 문제가 없습니다. 닉네임을 추가한 경우에는 목록에 닉네임이 표시되어 selected_user가 닉네임 문자열이 되므로, 이 두 번째 비교 줄이 필요합니다.

 

2.2 귓속말 전송 함수

대상을 찾았다면 그 소켓으로만 메시지를 보냅니다. 성공 여부를 True/False로 돌려주어, 호출한 쪽이 보낸 사람에게 실패 결과를 알려 줄 수 있게 합니다.

def send_whisper(sender_identifier, target_identifier, content):   # ➕ 대상 한 명에게만 전송
    target_client = find_client_by_identifier(target_identifier)

    if target_client is None:                       # 대상이 없으면 실패
        return False

    send_message(target_client["socket"], {         # broadcast가 아니라 그 소켓에만
        "type": "whisper",
        "sender": sender_identifier,
        "content": content
    })
    return True

✔ 확인 기준: send_whisper()broadcast()를 쓰지 않고 target_client["socket"] 하나에만 보내며, 대상이 없을 때 False를 반환하면 완료.

💡 강사 팁 — "왜 서버가 보낸 사람에게 확인 메시지를 안 보내나요?"

귓속말을 보낸 뒤 보낸 사람 화면에 [귓속말 보냄]을 표시하는 방법은 두 가지입니다.

방식 흐름 문제점
서버가 보내는 방식 전송 성공 → 서버가 system 메시지를 보낸 사람에게 발송 화면에 두 줄이 겹침 — GUI의 append와 서버 메시지가 동시에 표시됨
GUI가 직접 표시 (현재 방식) 전송 직후 on_whisper_clicked()에서 바로 append 없음 — 보낸 사람만 볼 내용이므로 GUI가 처리하는 것이 자연스럽습니다

"보낸 사람만 볼 메시지는 GUI가 직접 처리하고, 다른 사람에게 전달할 메시지만 서버를 거친다"는 원칙을 기억하면 됩니다. 30강에서 귓속말 오류 처리를 설계할 때도 같은 원칙이 적용됩니다.

 

3. 서버 - whisper 메시지 처리 연결하기

→ handle_client()의 메시지 분기에 whisper 처리를 추가합니다.

더보기

handle_client()의 메시지 타입 분기에서 chat 처리 옆에 whisper 분기를 추가합니다. 대상과 내용을 검사한 뒤 send_whisper()로 전달하고, 실패 시에만 보낸 사람에게 오류를 알립니다.

                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

                    success = send_whisper(address_text, target, content)

                    if not success:                              # 실패 시에만 오류 알림
                        send_error(client_socket, "귓속말 대상을 찾을 수 없습니다.")
                    continue

성공 시에는 서버에서 별도 메시지를 보내지 않습니다. 보낸 사람 GUI가 직접 [귓속말 보냄]을 표시하기 때문입니다. 서버가 성공 메시지까지 보내면 보낸 사람 화면에 두 줄이 중복됩니다. 서버 귓속말 처리 순서를 정리하면 다음과 같습니다.

whisper 메시지 수신
        ↓
target 확인 → content 확인
        ↓
find_client_by_identifier(target)
        ↓
대상 있음 → 그 소켓에만 whisper 전송 (보낸 사람에겐 별도 메시지 없음)
대상 없음 → 보낸 사람에게 error 전송   ← 이번 강의의 성공 지점

✔ 확인 기준: handle_client() 분기에 message_type == "whisper"가 있고, 성공 시 서버가 추가 메시지를 보내지 않으며, 마지막에 continue가 있으면 완료.

 

4. 클라이언트 - 귓속말 버튼과 전송 연결하기

→ 귓속말 버튼을 배치하고, on_whisper_clicked()와 수신 표시를 연결합니다.

더보기

4.1 귓속말 버튼 추가하기

입력 영역에 "귓속말" 버튼을 더합니다. 연결 상태에 따라 버튼을 켜고 꺼야 하므로 set_connected_state()에도 한 줄 추가합니다.

        self.send_button = QPushButton("보내기")
        self.whisper_button = QPushButton("귓속말")              # ➕ 귓속말 버튼

        input_layout = QHBoxLayout()
        input_layout.addWidget(self.message_input)
        input_layout.addWidget(self.send_button)
        input_layout.addWidget(self.whisper_button)             # ➕ 입력 영역에 배치
        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)   # ➕ 클릭 → 귓속말
    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("선택한 상대: 없음")

 

4.2 귓속말 전송 함수 만들기

on_whisper_clicked()on_send_clicked()와 비슷하지만, 28강에서 만든 selected_user가 있는지 먼저 확인하고 target에 넣습니다. 전송에 성공하면 GUI에서 직접 [귓속말 보냄]을 표시합니다. 서버가 별도로 확인 메시지를 보내지 않으므로 이 한 줄이 유일한 전송 확인 표시입니다.

    def on_whisper_clicked(self):                               # ➕ 귓속말 보내기
        text = self.message_input.text().strip()

        if not text:
            return

        if self.client_socket is None:
            self.chat_display.append("[오류] 서버에 먼저 접속하세요.")
            return

        if self.selected_user is None:                          # ➕ 대상 선택 확인
            self.chat_display.append("[오류] 귓속말 상대를 선택하세요.")
            return

        try:
            send_message(self.client_socket, {
                "type": "whisper",
                "target": self.selected_user,                   # 선택한 상대가 target
                "content": text
            })

            self.chat_display.append(f"[귓속말 보냄] {self.selected_user}: {text}")
            self.message_input.clear()

        except Exception as error:
            self.chat_display.append(f"[오류] 귓속말 전송 실패: {error}")
            self.disconnect_from_server()

 

4.3 귓속말 수신 표시 추가하기

display_message()whisper 분기를 추가해, 받은 귓속말을 전체 채팅과 구분되게 표시합니다.

        elif message_type == "whisper":                         # ➕ 귓속말 수신 표시
            sender = message.get("sender", "unknown")
            content = message.get("content", "")
            self.chat_display.append(f"[귓속말 받음] {sender}: {content}")
메시지 타입 GUI 표시
chat 보낸사람: 내용
whisper (받음) [귓속말 받음] 보낸사람: 내용
whisper (보냄) [귓속말 보냄] 상대: 내용 — GUI에서 직접 표시
system [시스템] 내용
error [오류] 내용

✔ 확인 기준: 사용자를 선택한 뒤 귓속말 버튼을 누르면 내 화면에 [귓속말 보냄]이 표시되고, 선택한 상대 GUI에만 [귓속말 받음]이 표시되면 완료.

 

5. 실행 결과 확인하기

→ GUI 클라이언트 3개를 실행해 귓속말이 대상에게만 전달되는지 확인합니다.

더보기


귓속말이 대상에게만 가는지 확인하려면 클라이언트를 3개 띄우는 것이 좋습니다. 서버 1개, GUI 클라이언트 3개를 각 터미널에서 실행합니다.

python chat_server/server.py
python chat_client/main.py   # 터미널 2 — 보내는 사람
python chat_client/main.py   # 터미널 3 — 받는 사람
python chat_client/main.py   # 터미널 4 — 무관한 사람

세 GUI 모두 "서버 접속"을 누른 뒤, 첫 번째 GUI에서 두 번째 사용자를 목록에서 선택하고 메시지를 입력한 다음 "귓속말" 버튼을 누릅니다.

보낸 사람(첫 번째 GUI) 화면:

[귓속말 보냄] ('127.0.0.1', 52345): 이건 귓속말입니다

받는 사람(두 번째 GUI) 화면:

[귓속말 받음] ('127.0.0.1', 52344): 이건 귓속말입니다

세 번째 GUI에는 아무것도 표시되지 않아야 합니다. 이것이 귓속말이 전체 채팅과 다른 점입니다.

✔ 확인 기준: 선택한 대상에게만 귓속말이 표시되고 세 번째 클라이언트에는 보이지 않으면 완료. 모두에게 보이면 서버가 broadcast()가 아닌 send_whisper()로 보내는지 확인하세요.

 

5.1 실행이 안 될 때 확인할 것

오류 상황 원인 및 해결 방법
귓속말이 모든 클라이언트에게 보인다 broadcast()로 보냈습니다. send_whisper()로 대상 소켓에만 보냅니다
귓속말 버튼이 비활성화돼 있다 set_connected_state()self.whisper_button.setEnabled(connected)를 추가합니다
상대를 선택했는데 "대상 없음" 오류 target 문자열과 client["address"] 또는 client["nickname"] 형식이 다릅니다. 목록에 표시된 문자열과 서버 저장 값이 일치하는지 확인합니다
내 화면엔 보이는데 상대에게 안 간다 handle_client()message_type == "whisper" 분기가 없습니다. 서버 분기를 확인합니다
선택 없이 귓속말 버튼을 눌러도 반응이 없다 if self.selected_user is None: 조건으로 안내 메시지를 표시합니다

 

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

    send_message(target_client["socket"], {
        "type": "whisper",
        "sender": sender_identifier,
        "content": content
    })
    return True

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

                        success = send_whisper(address_text, target, content)

                        if not success:
                            send_error(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:
        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.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 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("선택한 상대: 없음")

    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 on_whisper_clicked(self):
        text = self.message_input.text().strip()

        if not text:
            return

        if self.client_socket is None:
            self.chat_display.append("[오류] 서버에 먼저 접속하세요.")
            return

        if self.selected_user is None:
            self.chat_display.append("[오류] 귓속말 상대를 선택하세요.")
            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.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 == "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 최종 확인 표

확인할 코드 의미
server.py  
find_client_by_identifier(identifier) 주소 또는 닉네임으로 대상 클라이언트 찾기
send_whisper(...) 대상 소켓 하나에만 전송, 성공 여부 반환
message_type == "whisper" 분기 target · content 검사 후 전달, 실패 시에만 오류 안내
main.py  
self.whisper_button = QPushButton("귓속말") 귓속말 버튼 생성·배치
whisper_button.setEnabled(connected) 연결 상태에 따라 버튼 관리
on_whisper_clicked() selected_usertarget으로 귓속말 전송, GUI에서 직접 [귓속말 보냄] 표시
elif message_type == "whisper": 받은 귓속말을 [귓속말 받음]으로 표시

메시지 타입은 whisper, 대상 키는 target으로 서버와 클라이언트가 동일하게 맞춰야 합니다. 전체 채팅과 귓속말이 한 화면에서 구분되어 보이면 이번 강의의 목표를 달성한 것입니다.

→ 다음 강의 (30강): 귓속말이 동작하니, 이제 오류 상황을 더 친절하게 다듬습니다. 상대를 고르지 않은 경우, 상대가 방금 퇴장한 경우, 자기 자신을 선택한 경우를 구분해 GUI에 이해하기 쉬운 안내를 표시합니다.