0. 학습 순서

 

1. 학습 목표

더보기

(1) 1~3단계에서 무엇이 준비되었나

  • 1단계에서 확보한 것 (개념/구조)
    • 클라이언트/서버 모델
    • IP/Port/Socket
    • Listen → Accept → Send/Receive 실행 흐름
    • PySide6에서의 대응 구조
      • 서버: QTcpServer
      • 클라이언트/연결: QTcpSocket
      • 이벤트: newConnection, readyRead, disconnected
  • 2단계에서 확보한 것 (송수신 동작)
    • readyRead가 “데이터가 도착했음”을 알려줌
    • readAll()로 들어온 데이터를 꺼냄
    • write()로 데이터를 전송함
    • 에코 서버는 결국 “수신 후 같은 소켓에 재전송”
  • 3단계에서 확보한 것 (다중 클라이언트 처리 구조)
    • client_socket 하나로는 다중 연결을 처리할 수 없음
    • 서버는 “연결된 소켓들을 저장”해야 함
    • 서버는 “어떤 소켓에서 readyRead가 발생했는지”로 발신자를 식별함
    • 브로드캐스트: 저장된 소켓 전체에 write()
    • 유니캐스트: 특정 소켓 1개에만 write()

 

(2) 4단계 학습 목표

  • 채팅은 결국 아래 3가지를 합친 구조입니다.
    • 여러 클라이언트가 서버에 접속한다 (다중 연결)
    • 누가 보냈는지 구분한다 (식별: 닉네임)
    • 메시지를 전달한다
  • 전체 전달: 브로드캐스트
  • 개인 전달: 유니캐스트

 

2. 서버 설계

더보기

(1) 서버가 들고 있는 자료구조

  • clients: set[QTcpSocket]
    연결된 소켓 목록
  • buffers: dict[QTcpSocket, bytearray]
    소켓별 수신 버퍼(라인 단위 처리용)
  • nicknames: dict[QTcpSocket, str]
    소켓별 닉네임(식별자)

 

(2) 서버 이벤트 흐름

  • newConnection
    • 소켓 생성 nextPendingConnection()
    • 자료구조에 저장
    • 기본 닉네임 부여
    • 시그널 연결(readyRead, disconnected)
  • readyRead(socket)
    • 해당 소켓 버퍼에 데이터 누적
    • "\n" 기준으로 한 줄씩 꺼내 처리
    • 명령이면 처리 (NICK, TO)
    • 아니면 브로드캐스트
  • disconnected(socket)
    • 자료구조에서 제거
    • 전체 공지(퇴장)

 

3. 서버

더보기
# chat_server.py
import sys
from PySide6.QtWidgets import QApplication, QMainWindow, QWidget, QVBoxLayout, QTextEdit
from PySide6.QtNetwork import QTcpServer, QTcpSocket, QHostAddress


class ChatServerWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("채팅 서버")
        self.resize(560, 420)

        # UI
        central = QWidget(self)
        self.setCentralWidget(central)
        layout = QVBoxLayout(central)

        self.log = QTextEdit(self)
        self.log.setReadOnly(True)
        layout.addWidget(self.log)

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

        # 3단계(소켓 관리) 핵심: 다중 소켓 저장 구조
        self.clients = set()
        self.buffers = {}     # socket -> bytearray
        self.nicknames = {}   # socket -> nickname

        host = QHostAddress("127.0.0.1")
        port = 8888

        if self.server.listen(host, port):
            self.append_log(f"서버 시작: {host.toString()}:{port}")
        else:
            self.append_log("서버 시작 실패")

    def append_log(self, text: str):
        self.log.append(text)

    # 1) 새 연결 수락
    def on_new_connection(self):
        sock = self.server.nextPendingConnection()
        if sock is None:
            return

        self.clients.add(sock)
        self.buffers[sock] = bytearray()

        # 기본 닉네임(학습용: 소켓 디스크립터로 유니크 값 부여)
        default_nick = f"Guest-{int(sock.socketDescriptor())}"
        self.nicknames[sock] = default_nick

        # 중요: 어떤 소켓이 readyRead를 발생시키는지 구분해야 한다
        sock.readyRead.connect(lambda s=sock: self.on_ready_read(s))
        sock.disconnected.connect(lambda s=sock: self.on_disconnected(s))

        peer = f"{sock.peerAddress().toString()}:{int(sock.peerPort())}"
        self.append_log(f"접속: {default_nick} ({peer})")

        # 접속자에게 안내
        self.send_to(sock, "시스템: 닉네임 설정 예) NICK 홍길동")
        self.broadcast(f"시스템: {default_nick} 님이 입장했습니다.")

    # 2) 데이터 수신 처리 (라인 단위)
    def on_ready_read(self, sock: QTcpSocket):
        data = bytes(sock.readAll())
        if not data:
            return

        buf = self.buffers.get(sock)
        if buf is None:
            self.buffers[sock] = bytearray()
            buf = self.buffers[sock]

        buf.extend(data)

        # "\n" 기준으로 한 줄씩 처리
        while b"\n" in buf:
            line, _, rest = buf.partition(b"\n")
            self.buffers[sock] = rest
            buf = rest

            text = line.decode("utf-8", errors="replace").strip()
            if not text:
                continue

            self.handle_line(sock, text)

    # 3) 프로토콜 처리
    def handle_line(self, sock: QTcpSocket, text: str):
        # 닉네임 변경: NICK <name>
        if text.upper().startswith("NICK "):
            new_nick = text[5:].strip()
            if not new_nick:
                self.send_to(sock, "시스템: 닉네임이 비어있습니다.")
                return

            old = self.nicknames.get(sock, "Unknown")
            self.nicknames[sock] = new_nick
            self.append_log(f"닉네임 변경: {old} -> {new_nick}")
            self.broadcast(f"시스템: {old} 님이 {new_nick} 님으로 변경했습니다.")
            return

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

            target_nick = parts[1].strip()
            msg = parts[2].strip()
            if not target_nick or not msg:
                self.send_to(sock, "시스템: 대상 또는 메시지가 비어있습니다.")
                return

            sender_nick = self.nicknames.get(sock, "Unknown")
            target_sock = self.find_socket_by_nick(target_nick)

            if target_sock is None:
                self.send_to(sock, f"시스템: 대상 '{target_nick}' 을(를) 찾을 수 없습니다.")
                return

            # 대상에게만 전송
            self.send_to(target_sock, f"(개인) {sender_nick} -> {target_nick}: {msg}")
            # 발신자에게도 확인 메시지(학습용)
            self.send_to(sock, f"(개인 전송 완료) {sender_nick} -> {target_nick}: {msg}")
            return

        # 일반 메시지: 브로드캐스트
        sender_nick = self.nicknames.get(sock, "Unknown")
        self.append_log(f"수신: {sender_nick}: {text}")
        self.broadcast(f"{sender_nick}: {text}")

    # 닉네임으로 소켓 찾기
    def find_socket_by_nick(self, nickname: str):
        for s, nick in self.nicknames.items():
            if nick == nickname:
                return s
        return None

    # 브로드캐스트
    def broadcast(self, message: str):
        payload = (message + "\n").encode("utf-8")
        dead = []
        for s in list(self.clients):
            try:
                s.write(payload)
            except Exception:
                dead.append(s)

        # 예외 소켓 정리(안전)
        for s in dead:
            self.cleanup_socket(s)

    # 유니캐스트(소켓 1개)
    def send_to(self, sock: QTcpSocket, message: str):
        try:
            sock.write((message + "\n").encode("utf-8"))
        except Exception:
            self.cleanup_socket(sock)

    # 연결 종료 처리
    def on_disconnected(self, sock: QTcpSocket):
        nick = self.nicknames.get(sock, "Unknown")
        peer = f"{sock.peerAddress().toString()}:{int(sock.peerPort())}"
        self.append_log(f"퇴장: {nick} ({peer})")

        self.cleanup_socket(sock)
        self.broadcast(f"시스템: {nick} 님이 퇴장했습니다.")

    def cleanup_socket(self, sock: QTcpSocket):
        if sock in self.clients:
            self.clients.remove(sock)
        self.buffers.pop(sock, None)
        self.nicknames.pop(sock, None)
        sock.deleteLater()


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

 

4. 클라이언트

더보기
# chat_client.py
import sys
from PySide6.QtWidgets import (
    QApplication, QMainWindow, QWidget,
    QVBoxLayout, QHBoxLayout,
    QTextEdit, QLineEdit, QPushButton
)
from PySide6.QtNetwork import QTcpSocket


class ChatClientWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("채팅 클라이언트")
        self.resize(560, 460)

        # UI
        central = QWidget(self)
        self.setCentralWidget(central)
        root = QVBoxLayout(central)

        self.chat = QTextEdit(self)
        self.chat.setReadOnly(True)
        root.addWidget(self.chat)

        # 닉네임 설정 라인
        row1 = QHBoxLayout()
        self.nick_input = QLineEdit(self)
        self.nick_input.setPlaceholderText("닉네임 입력 (예: 홍길동)")
        self.btn_nick = QPushButton("닉네임 설정", self)
        self.btn_nick.clicked.connect(self.send_nick)
        row1.addWidget(self.nick_input)
        row1.addWidget(self.btn_nick)
        root.addLayout(row1)

        # 유니캐스트 대상 라인
        row2 = QHBoxLayout()
        self.to_input = QLineEdit(self)
        self.to_input.setPlaceholderText("유니캐스트 대상 닉네임 (예: 김철수)")
        self.btn_to = QPushButton("Unicast 전송", self)
        self.btn_to.clicked.connect(self.send_unicast)
        row2.addWidget(self.to_input)
        row2.addWidget(self.btn_to)
        root.addLayout(row2)

        # 메시지 입력 라인(브로드캐스트)
        row3 = QHBoxLayout()
        self.msg_input = QLineEdit(self)
        self.msg_input.setPlaceholderText("메시지 입력 (일반 전송은 브로드캐스트)")
        self.btn_send = QPushButton("Send", self)
        self.btn_send.clicked.connect(self.send_broadcast)
        row3.addWidget(self.msg_input)
        row3.addWidget(self.btn_send)
        root.addLayout(row3)

        # Network
        self.socket = QTcpSocket(self)
        self.socket.connected.connect(self.on_connected)
        self.socket.disconnected.connect(self.on_disconnected)
        self.socket.readyRead.connect(self.on_ready_read)

        self.buffer = bytearray()
        self.socket.connectToHost("127.0.0.1", 8888)

    def log(self, text: str):
        self.chat.append(text)

    def on_connected(self):
        self.log("시스템: 서버에 연결되었습니다.")

    def on_disconnected(self):
        self.log("시스템: 서버 연결이 종료되었습니다.")

    # 브로드캐스트 전송 (일반 메시지)
    def send_broadcast(self):
        text = self.msg_input.text().strip()
        if not text:
            return
        self.socket.write((text + "\n").encode("utf-8"))
        self.msg_input.clear()

    # 닉네임 설정
    def send_nick(self):
        nick = self.nick_input.text().strip()
        if not nick:
            return
        self.socket.write((f"NICK {nick}\n").encode("utf-8"))

    # 유니캐스트 전송: TO <대상> <메시지>
    def send_unicast(self):
        target = self.to_input.text().strip()
        msg = self.msg_input.text().strip()
        if not target or not msg:
            return
        self.socket.write((f"TO {target} {msg}\n").encode("utf-8"))
        self.msg_input.clear()

    # 수신 처리(라인 단위)
    def on_ready_read(self):
        data = bytes(self.socket.readAll())
        self.buffer.extend(data)

        while b"\n" in self.buffer:
            line, _, rest = self.buffer.partition(b"\n")
            self.buffer = rest

            text = line.decode("utf-8", errors="replace").strip()
            if text:
                self.log(text)


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

 

5. 과제

더보기

 

  • 닉네임 추가하기
  • 서버에서 접속자 수 표시
  • 특정 단어 금지 필터
  • 서버에서만 공지 메시지 보내기
  • 시간 표시 추가