3단계: 소켓 관리

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