0. 학습 순서

 

1. 학습 목표

더보기

이번 단계의 목적은 서버가 여러 클라이언트를 동시에 처리하기 위해 반드시 해야 하는 관리 작업을의 구조를 이해하고, 

앞으로 어떤 방법으로 구분할 것인지 선택하고 이를 로직으로 구현해야할지 고려한다.

  • 서버는 여러 클라이언트를 어떻게 구분하는가?
  • 연결된 소켓을 어떻게 저장해야 하는가?
  • 한 클라이언트의 메시지를 모든 클라이언트에게 보내려면 서버는 무엇을 해야 하는가?

 

3-1 단계: 소켓 역할 구분 복습

더보기

서버에는 두 종류의 소켓이 존재한다

 

 

(1) 서버 소켓 (Server Socket)

server = QTcpServer(self)
server.newConnection.connect(self.on_new_connection)

server 객체의 역할은 새로운 연결 요청이 들어왔음을 newConnection 시그널을 통해 알려주는 것 단 하나이다.

 

서버 소켓 특징은

  • 클라이언트 IP, Port, 닉네임과 같은 식별자를 모른다.
  • 메시지를 전송하지 않는다
  • 메시지를 수신하지 않는다
  • 오직 newConnection 시그널만 발생시킨다.
  • 내 전화번호, 내 주소 역할

 

 

(2) 연결된 소켓 (Connected Socket)

client_socket = self.server.nextPendingConnection()

client_socket 이라는 연결된 소켓의 역할은 서버와 클라이언트가 데이터를 주고 받을 때
이 client_socket 하나로 연결이 끝날 때까지 데이터를 주고받는다.

 

연결된 소켓 특징은

  • 클라이언트마다 하나씩 생성됨
  • 메시지를 전송하거나, 소켓 연결 종료 시그널 세팅
  • 메시지를 수신한다.
  • 통화 중 회선 역할 

 

3-2 단계:  다중 클라이언트 문제 상황 파악

더보기

(1) "학습 2-2 단계" 까지의 상태

 

2단계 실습에서는 네트워크 통신의 기본 동작 구조를 이해하는 것이 목적이었다.
따라서 서버와 클라이언트의 구성은 매우 단순하다.

  • 서버에 접속한 클라이언트: 1명
  • 서버 코드:
    • self.client_socket 하나만 사용
  • 데이터 흐름:
    • 클라이언트 → 서버
    • 서버 → 클라이언트(에코)

이 구조는 단일 클라이언트 환경에서
네트워크 프로그래밍의 핵심 개념과 실행 흐름을 학습하기 위해

“동작 원리 이해 중심”이다.

 

 

(2) 다중 클라이언트가 접속하면 발생하는 문제

client_socket = self.server.nextPendingConnection()

 

하지만 서버에 두 명 이상의 클라이언트가 동시에 접속하는 순간,
이 구조는 더 이상 정상적으로 동작하지 않고 다음과 같은 문제가 즉시 발생한다.

  • 두 번째 클라이언트가 접속하면
    → client_socket이 새로운 소켓으로 덮어써진다
  • 그 결과
    → 첫 번째 클라이언트의 소켓 참조를 잃어버린다

 

이 상태에서 서버는 다음을 수행할 수 없게 된다.

  • 누가 메시지를 보냈는지 구분할 수 없다
  • 특정 클라이언트에게만 응답할 수 없다
  • 모든 클라이언트에게 메시지를 전송할 수 없다

 

즉, 서버 입장에서
“연결된 클라이언트가 여러 명이라는 사실 자체를 인식하지 못하는 구조”가 된다.

 

 

(3) 해결 방법: 소켓 관리 구조 구현

 

이 문제를 해결하기 위해서는
서버의 구조 자체를 한 단계 확장해야 한다.

다중 클라이언트 서버에서는 반드시 다음이 필요하다.

  • 접속한 모든 클라이언트의 연결 소켓을 저장
  • 각 소켓을 구분 가능한 식별자로 관리
  • 연결 종료 시 소켓을 정리(remove)

즉, 다중 클라이언트 서버에서는 “연결된 소켓을 저장하고 관리하는 구조”가 반드시 필요하다.

이것을 이행하고 구현하는 것이 3단계: 소켓 관리의 학습 목표이다.


 

3-3 단계: 문제 해결 구조 이해

더보기

(1) 기본 전제 구조

[ QTcpServer ]   ← 서버 소켓 (연결 요청 전용)
       |
       +-- QTcpSocket A  (클라이언트 A)
       |
       +-- QTcpSocket B  (클라이언트 B)
       |
       +-- QTcpSocket C  (클라이언트 C)
  • 서버 소켓(QTcpServer)은 항상 하나
  • 클라이언트가 접속할 때마다 연결된 소켓(QTcpSocket) 이 하나씩 생성됨
    이 연결된 소켓이 서버와 클라이언트 간의 실제 통신을 담당한다.
  • 서버는 이 연결된 소켓들을 구분해서 관리해야 함

 

 

(2) 가장 기본적인 구분 방법

socket_A
 ├─ IP:Port = 127.0.0.1:52344
 ├─ 수신 버퍼
 └─ 닉네임 = "홍길동"

socket_B
 ├─ IP:Port = 127.0.0.1:52345
 ├─ 수신 버퍼
 └─ 닉네임 = "Guest-1"

서버는 소켓 자체를 기준으로 각 클라이언트의 정보를 함께 관리한다.

 

 

(3) 서버 내부 관리 구조 예시

clients = { socket_A, socket_B }

buffers = {
    socket_A: buffer_A,
    socket_B: buffer_B
}

nicknames = {
    socket_A: "홍길동",
    socket_B: "Guest-1"
}

 

  • clients
    → 현재 연결된 모든 클라이언트 소켓 목록
  • buffers
    → 소켓별 데이터 수신 상태 관리
  • nicknames
    → 소켓별 사용자 식별 정보 관리

 

3-4 단계: 일반적인 서버–클라이언트 구조 설계

더보기

(1) Socket 식별 구조 (발신자 구분)

 

상황

  • 서버의 readyRead 시그널 발생
  • 발신자를 즉시 식별해야 한다.
# 사용 구조
어떤 socket에서 readyRead 발생
→ 이 socket이 곧 "보낸 클라이언트"
→ socket을 키로 상태 정보 조회


# 예시
# socket_A 에서 readyRead 발생

nicknames[socket_A] → "홍길동"
buffers[socket_A]   → buffer_A

서버는 시그널을 발생시킨 socket 자체가, 클라이언트의 정체이며, 이를 키로 즉시 식별할 수 있다.

 

 

 

 

(2) 브로드캐스트(Broadcast) 구조

 

상황

  • 한 클라이언트가 메시지를 보냈다
  • 서버가 모든 클라이언트에게 전달해야 한다
# 사용 구조
for 모든 연결된 소켓:
    소켓.write(메시지)

# 예시
for socket in clients:
    socket.write(메시지)

 

실행 형태

clients = { socket_A, socket_B, socket_C }

→ socket_A.write()
→ socket_B.write()
→ socket_C.write()

서버가 모든 소켓을 저장해서, 알고 있기 때문에 가능하다.

 

브로드캐스트의 핵심 개념

  • 브로드캐스트는 “특별한 네트워크 기능”이 아니다
  • 저장된 소켓들을 순회하며 write()하는 것이 전부이다
  • 따라서 소켓 관리 구조가 없으면 브로드캐스트는 불가능하다

 

 

 

 

(3) 유니캐스트(Unicast) 구조

 

상황

  • 특정 클라이언트에게만 응답해야 하는 경우
  • 예: 요청을 보낸 클라이언트에게만 처리 결과 전송
# 사용 구조
readyRead를 발생시킨 socket = socket_A
→ socket_A.write(응답)

# 예시
socket_A.write("처리 완료")

 

확장 기능

  • 닉네임 기반 유니캐스트
  • ID 기반 유니캐스트
  • 특정 조건 필터링 후 전송

 

3-5단계: 브로드케스트 구현 

더보기

브로드캐스트 서버 코드

import sys
from PySide6.QtWidgets import QApplication, QMainWindow, QLabel
from PySide6.QtNetwork import QTcpServer, QTcpSocket, QHostAddress


class ServerWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Broadcast Server")
        self.resize(400, 300)

        self.label = QLabel("Waiting...", self)
        self.label.setGeometry(50, 50, 300, 50)

        self.server = QTcpServer(self)
        self.server.newConnection.connect(self.on_new_connection)

        # [확장 1] 단일 소켓 → 여러 소켓 저장 구조
        self.clients = set()

        if not self.server.listen(QHostAddress("127.0.0.1"), 8888):
            self.label.setText("Listen failed")

    def on_new_connection(self):
        # 새로 접속한 클라이언트의 연결 소켓
        socket = self.server.nextPendingConnection()

        # [확장 2] 소켓 저장
        self.clients.add(socket)

        # 소켓별 시그널 연결
        socket.readyRead.connect(lambda s=socket: self.on_ready_read(s))
        socket.disconnected.connect(lambda s=socket: self.on_disconnected(s))

        self.label.setText(f"Client connected ({len(self.clients)})")

    def on_ready_read(self, sender: QTcpSocket):
        data = sender.readAll()
        message = bytes(data).decode(errors="replace")

        print(message, end="")
        self.label.setText(f"Received: {message}")

        # [확장 3] Echo → Broadcast
        self.broadcast(data)

    def broadcast(self, data):
        for client in self.clients:
            client.write(data)

    def on_disconnected(self, socket: QTcpSocket):
        self.clients.remove(socket)
        socket.deleteLater()
        self.label.setText(f"Client disconnected ({len(self.clients)})")


if __name__ == "__main__":
    app = QApplication(sys.argv)
    w = ServerWindow()
    w.show()
    sys.exit(app.exec())

 

 

브로드캐스트 클라이언트 코드

2-3단계 에코 클라이언트 그대로 사용

 

3-6단계: 유니캐스트 구현

더보기

유니캐스트 서버 코드

import sys
from PySide6.QtWidgets import QApplication, QMainWindow, QLabel
from PySide6.QtNetwork import QTcpServer, QTcpSocket, QHostAddress


class ServerWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Unicast Server (Nickname-based)")
        self.resize(520, 220)

        self.label = QLabel("Waiting...", self)
        self.label.setGeometry(20, 20, 480, 160)
        self.label.setWordWrap(True)

        self.server = QTcpServer(self)
        self.server.newConnection.connect(self.on_new_connection)

        # 3-5단계: 다중 소켓 저장
        self.clients = set()

        # [3-6 확장 1] 소켓별 닉네임 저장
        self.nicknames = {}  # key: QTcpSocket, value: str

        if not self.server.listen(QHostAddress("127.0.0.1"), 8888):
            self.label.setText("Listen failed")

    def on_new_connection(self):
        socket = self.server.nextPendingConnection()
        self.clients.add(socket)

        # 기본 닉네임(중복 방지용: 소켓 descriptor 사용)
        default_nick = f"Guest-{int(socket.socketDescriptor())}"
        self.nicknames[socket] = default_nick

        socket.readyRead.connect(lambda s=socket: self.on_ready_read(s))
        socket.disconnected.connect(lambda s=socket: self.on_disconnected(s))

        self.label.setText(
            f"Client connected: {default_nick}\n"
            f"접속자 수: {len(self.clients)}\n"
            f"명령: NICK <닉>, TO <닉> <메시지>"
        )

        # 접속 안내(본인에게만)
        self.send_to(socket, "시스템: 닉네임 설정 예) NICK 홍길동")
        self.send_to(socket, "시스템: 유니캐스트 예) TO 홍길동 안녕하세요")

        # 입장 알림(전체)
        self.broadcast(f"시스템: {default_nick} 님이 입장했습니다.")

    def on_ready_read(self, sender: QTcpSocket):
        data = sender.readAll()
        text = bytes(data).decode("utf-8", errors="replace").strip()
        if not text:
            return

        sender_nick = self.nicknames.get(sender, "Unknown")

        # 1) 닉네임 설정: NICK <nickname>
        if text.upper().startswith("NICK "):
            new_nick = text[5:].strip()
            if not new_nick:
                self.send_to(sender, "시스템: 닉네임이 비어있습니다.")
                return

            old_nick = sender_nick
            self.nicknames[sender] = new_nick

            self.send_to(sender, f"시스템: 닉네임이 '{new_nick}' 으로 설정되었습니다.")
            self.broadcast(f"시스템: {old_nick} 님이 {new_nick} 님으로 변경했습니다.")
            return

        # 2) 유니캐스트: TO <nickname> <message>
        if text.upper().startswith("TO "):
            # 예: "TO 홍길동 안녕하세요"
            parts = text.split(" ", 2)  # ["TO", "홍길동", "안녕하세요"]
            if len(parts) < 3:
                self.send_to(sender, "시스템: 형식 오류. 예) TO 홍길동 안녕하세요")
                return

            target_nick = parts[1].strip()
            msg_body = parts[2].strip()

            if not msg_body:
                self.send_to(sender, "시스템: 보낼 메시지가 비어있습니다.")
                return

            ok = self.unicast_by_nickname(target_nick, f"(개인){sender_nick}: {msg_body}")
            if not ok:
                self.send_to(sender, f"시스템: 대상 닉네임을 찾을 수 없습니다. ({target_nick})")
            else:
                # 보낸 사람에게도 확인 메시지(선택)
                self.send_to(sender, f"시스템: {target_nick} 님에게 개인 메시지를 보냈습니다.")
            return

        # 3) 그 외는 브로드캐스트
        self.broadcast(f"{sender_nick}: {text}")

    # 3-5단계 기능: 전체 전송
    def broadcast(self, message: str):
        data = (message + "\n").encode("utf-8")
        for client in list(self.clients):
            client.write(data)

    # [3-6 확장 2] 닉네임 기반 유니캐스트: 특정 1명에게만 전송
    def unicast_by_nickname(self, nickname: str, message: str) -> bool:
        target_socket = None
        for sock, nick in self.nicknames.items():
            if nick == nickname:
                target_socket = sock
                break

        if target_socket is None:
            return False

        self.send_to(target_socket, message)
        return True

    # 특정 소켓에게만 전송(기본 유니캐스트)
    def send_to(self, socket: QTcpSocket, message: str):
        socket.write((message + "\n").encode("utf-8"))

    def on_disconnected(self, socket: QTcpSocket):
        nick = self.nicknames.get(socket, "Unknown")

        if socket in self.clients:
            self.clients.remove(socket)
        self.nicknames.pop(socket, None)

        socket.deleteLater()

        self.broadcast(f"시스템: {nick} 님이 퇴장했습니다.")
        self.label.setText(f"Client disconnected: {nick}\n접속자 수: {len(self.clients)}")


if __name__ == "__main__":
    app = QApplication(sys.argv)
    w = ServerWindow()
    w.show()
    sys.exit(app.exec())

 

 

유니캐스트 클라이언트 코드

import sys
from PySide6.QtWidgets import (
    QApplication, QMainWindow,
    QPushButton, QLineEdit, QTextEdit
)
from PySide6.QtNetwork import QTcpSocket


class ClientWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Unicast Client")
        self.resize(420, 360)

        # 메시지 입력
        self.line = QLineEdit(self)
        self.line.setGeometry(20, 20, 260, 30)
        self.line.setPlaceholderText("메시지 입력")

        # 일반 전송(브로드캐스트)
        btn_send = QPushButton("Send", self)
        btn_send.setGeometry(300, 20, 80, 30)
        btn_send.clicked.connect(self.send_message)

        # 닉네임 설정
        self.nick_line = QLineEdit(self)
        self.nick_line.setGeometry(20, 60, 180, 30)
        self.nick_line.setPlaceholderText("닉네임")

        btn_nick = QPushButton("Set Nick", self)
        btn_nick.setGeometry(220, 60, 160, 30)
        btn_nick.clicked.connect(self.set_nickname)

        # 유니캐스트 대상
        self.to_line = QLineEdit(self)
        self.to_line.setGeometry(20, 100, 180, 30)
        self.to_line.setPlaceholderText("대상 닉네임")

        btn_to = QPushButton("Unicast", self)
        btn_to.setGeometry(220, 100, 160, 30)
        btn_to.clicked.connect(self.send_unicast)

        # 수신 로그
        self.log = QTextEdit(self)
        self.log.setGeometry(20, 150, 360, 180)
        self.log.setReadOnly(True)

        # 소켓 연결
        self.socket = QTcpSocket(self)
        self.socket.readyRead.connect(self.on_ready_read)
        self.socket.connectToHost("127.0.0.1", 8888)

    # 브로드캐스트 전송
    def send_message(self):
        text = self.line.text().strip()
        if not text:
            return
        self.socket.write((text + "\n").encode())
        self.line.clear()

    # 닉네임 설정
    def set_nickname(self):
        nick = self.nick_line.text().strip()
        if not nick:
            return
        cmd = f"NICK {nick}\n"
        self.socket.write(cmd.encode())

    # 유니캐스트 전송
    def send_unicast(self):
        target = self.to_line.text().strip()
        msg = self.line.text().strip()
        if not target or not msg:
            return
        cmd = f"TO {target} {msg}\n"
        self.socket.write(cmd.encode())
        self.line.clear()

    # 서버로부터 수신
    def on_ready_read(self):
        data = self.socket.readAll()
        text = bytes(data).decode("utf-8", errors="replace")
        self.log.append(text.strip())


if __name__ == "__main__":
    app = QApplication(sys.argv)
    w = ClientWindow()
    w.show()
    sys.exit(app.exec())