파이썬으로 구현한 채팅 서버앱
이번에 만든 주제는
파이썬 다자간 채팅 프로그램(Chat Server)
입니다.
구글 검색을 통해 찾아봐도 파이썬 소켓 모듈을 이용한 간단한 예제(에코 서버)는 있지만, 쓸만한(?) 코드는 찾기 힘들어 Python + PyQt5를 이용해 직접 만들어 보았습니다.
먼저 채팅 프로그램은 채팅 Server(접속 대기)와 채팅 Client(접속 하는 자)로 나누어 제작하고, 이번 포스팅에서는 서버에 대한 내용을 다룹니다.
대학시절, 네트워크 프로그래밍에 관심이 많아 C++을 이용해 여러가지 (네트워크 빙고게임, 채팅, 동영상 전송 등)를 제작하며 소켓 프로그래밍에 입문하였고, 벌써 20년이라는 세월이 지났네요.
파이썬으로 소켓을 다루어 본 것은 이번이 처음입니다만 역시 파이썬... 쉽습니다.
20년이란 세월이 흘렀지만, 소켓은 크게 변한게 없습니다.
소켓은 버클리 대학에서 만들어 버클리 소켓이라고도 합니다.
Socket은 복잡한 컴퓨터 네트워크 구조 (OSI 7 Layer)를 모르더라도 (즉, 우편을 보낼때, 받는주소와 내용을 적어 우체통에만 넣으면 전달되는 것처럼) 프로그래머가 소켓이라는 소프트웨어 도구를 이용해 보내고자 하는 내용을 받는 IP주소로 편리하게 전달해 주는 역할을 합니다.
우리는 우편 시스템 (보내는 사람->우체통->우체국->이동->도착지 우체국->전달완료) 을 몰라도 받는 사람의 주소만 알면 편리하게 내용을 전달하는 원리와 같습니다.
이제 본격적으로 채팅 서버를 살펴 보겠습니다. 먼저 채팅서버를 실행한 화면입니다.
채팅서버의 주요 기능은 아래와 같습니다.
1. 서버 IP 주소, Port 설정
2. 접속자 정보 창
3. 받은 메시지 보기, 메시지 전송 기능
4. 다자간 채팅 기능 (여러명 접속 가능)
아래는 다음 포스팅에 살펴볼 클라이언트 실행화면 입니다.
채팅 서버는 server.py , window.py 2개의 파이썬 파일로 구성되어 있으며, 먼저 소켓을 클래스로 구현한 server.py 파일을 살펴보겠습니다.
1. server.py 파일 소스코드 설명
그럼 전체 코드를 살펴보겠습니다.
1~3번 라인은 파이썬 모듈을 불러오는 코드입니다.
서버소켓의 지속적인 접속 대기 및 클라이언트 접속시 해당 처리용 Thread를 생성하기 위한 쓰레드 모듈, 소켓, PyQt관련 모듈 등 입니다.
5번 라인의 ServerSocket 클래스는 위에서 설명한 버클리 소켓을 멤버 변수로 갖는 네트워크 관련 클래스 입니다.
10번 라인의 생성자 함수에서 부모위젯을 전달 받는데, 그 이유는 클라이언트 접속, 접속종료, 메시지 수신시 전송되는 사용자 정의 시그널에 부모 윈도우의 함수를 슬롯으로 등록해 클래스간 신호를 전달하기 위함입니다.
이어서 해당 클래스에 객체 멤버 변수를 선언하고 있습니다.
순서대로,
1번라인 부터 각종 모듈을 불러오는 코드입니다.
8번 라인은 4k해상도 모니터를 위한 고해상도 설정이며, port 번호는 기본으로 5614입니다.
12번 라인부터 Qt의 QWidget에서 상속받은 CWidget클래스를 만드는 코드가 시작됩니다.
13번 라인은 생성자 함수이며, 상속구조이므로 QWidget(부모)의 생성자를 호출하고 앞서 설명한 ServerSocket 클래스를 변수로 갖습니다.
20번 라인은 생성자에서 호출되는 initUI()함수이며, 각종 컨트롤을 생성하는 역할을 담당합니다.
23~46번 라인은 서버설정 관련 컨트롤을 배치하는 코드입니다.
Qt의 그룹박스, 라벨, 라인에디트, 버튼 클래스를 생성하고 QBoxLayout을 사용해 배치합니다.
컨트롤을 윈도우에 배치하는 방법은 QDesigner를 통한 방법과, 저처럼 직접 코드를 통해 배치하는 2가지 방법이 있는데 전 후자를 선호합니다.
QDesigner를 통한 배치는 QML을 이용하는 방식이며, ui 파일로 변경해 코드에서 불러들이는 방식인데, 이는 컨트롤에 수정사항이 생기면 매번 ui파일을 새로 만들어야 하므로 저는 불편해서 잘 사용하지 않습니다.
48~61번 라인은 접속자 정보 표시 컨트롤을 배치하는 부분이며, 아래 그림에 해당합니다.
MFC의 리스트 컨트롤과 유사한 QTableWidget 을 이용해 접속한 클라이언트의 IP주소와 port번호를 표시합니다.
63~93번 라인은 채팅창과 관련한 컨트롤을 생성, 배치하는 코드이며 아래 그림에 해당합니다.
받은 메시지는 QListWidget , 보낼 메시지는 QLineEidt, 그리고 2개의 QPushButton 으로 구성되어 있습니다.
97~102번 라인은 위에서 생성한 서버설정, 접속자정보, 채팅창 컨트롤의 LayoutBox를 배치하고 show()함수를 이용해 윈도우 창을 띄우는 코드입니다.
104번 라인은 toggleButton()함수로 '서버실행' 버튼을 누르면 호출되는 함수이며, 서버의 실행과 종료역할을 담당합니다.
서버 실행시 ip와 port번호를 ServerSocket클래스로 전달해 리슨소켓을 생성, 실행하는 역할이며, 종료시 서버 소켓 클래스의 close()함수를 호출해 서버소켓을 닫는 동작을 수행합니다.
115번 라인의 updateClient()함수는 ServerSocket에 클라이언트가 접속한 경우, 클라이언트의 정보를 접속자 정보표시 컨트롤에 보여주는 역할을 담당합니다.
129번 라인의 updateMsg() 함수는 연결된 클라이언트로 부터 메시지를 수신한 경우 호출되는 함수이며, 수신 메시지를 표시하고 해당 메시지에 포커스를 주는 동작을 수행합니다.
133번 라인의 sendMsg() 함수는 보낼 메시지에 글을 적고 보내기 버튼을 눌렀을때 그 내용을 ServerSocket으로 전달해 모든 클라이언트에게 메시지는 송신하는 역할입니다.
143번 라인의 closeMsg() 함수는 채팅창 지움 버튼을 눌렀을때, 내용을 지우는 역할을 수행합니다.
146번 라인의 closeEvent() 함수는 QWidget클래스에서 재정의된 함수이며, 윈도우 창을 닫는 경우 서버를 종료하고 창이 닫기도록 합니다.
149번 라인은 프로그램의 메인함수이며, CWidget을 생성하고 ServerSocket을 만들어 프로그램이 시작되도록 합니다.
이상으로 채팅 서버 프로그램의 설명을 마칩니다.
다음에는 클라이언트를 분석해 보도록 하겠습니다.
감사합니다.
pyinstaller로 제작한 파일을 제 윈도우 10 디펜더가 바이러스로 오해 (오탐지)를 하는 문제가 있습니다만 코드를 보시면 전~혀 그런 의도는 없으며, 소켓프로그래밍을 하다 보면 자주 겪는 현상임을 알려드립니다.
서버가 포트를 열고 대기하는 행동 때문인지 트로이 목마라고 나오네요. ㅎ
만약 서버가 정상적으로 대기(리슨) 중인 상태에서 클라이언트의 접속이 이루어지지 않는다면, 방화벽 해지 or 예외로 등록후 시도하기 바랍니다.
pyinstaller 로 제작한 실행파일 : 채팅서버
구글 검색을 통해 찾아봐도 파이썬 소켓 모듈을 이용한 간단한 예제(에코 서버)는 있지만, 쓸만한(?) 코드는 찾기 힘들어 Python + PyQt5를 이용해 직접 만들어 보았습니다.
먼저 채팅 프로그램은 채팅 Server(접속 대기)와 채팅 Client(접속 하는 자)로 나누어 제작하고, 이번 포스팅에서는 서버에 대한 내용을 다룹니다.
대학시절, 네트워크 프로그래밍에 관심이 많아 C++을 이용해 여러가지 (네트워크 빙고게임, 채팅, 동영상 전송 등)를 제작하며 소켓 프로그래밍에 입문하였고, 벌써 20년이라는 세월이 지났네요.
파이썬으로 소켓을 다루어 본 것은 이번이 처음입니다만 역시 파이썬... 쉽습니다.
20년이란 세월이 흘렀지만, 소켓은 크게 변한게 없습니다.
소켓은 버클리 대학에서 만들어 버클리 소켓이라고도 합니다.
Socket은 복잡한 컴퓨터 네트워크 구조 (OSI 7 Layer)를 모르더라도 (즉, 우편을 보낼때, 받는주소와 내용을 적어 우체통에만 넣으면 전달되는 것처럼) 프로그래머가 소켓이라는 소프트웨어 도구를 이용해 보내고자 하는 내용을 받는 IP주소로 편리하게 전달해 주는 역할을 합니다.
우리는 우편 시스템 (보내는 사람->우체통->우체국->이동->도착지 우체국->전달완료) 을 몰라도 받는 사람의 주소만 알면 편리하게 내용을 전달하는 원리와 같습니다.
이제 본격적으로 채팅 서버를 살펴 보겠습니다. 먼저 채팅서버를 실행한 화면입니다.
[채팅 서버 화면] |
채팅서버의 주요 기능은 아래와 같습니다.
1. 서버 IP 주소, Port 설정
2. 접속자 정보 창
3. 받은 메시지 보기, 메시지 전송 기능
4. 다자간 채팅 기능 (여러명 접속 가능)
아래는 다음 포스팅에 살펴볼 클라이언트 실행화면 입니다.
[채팅 클라이언트 화면] |
채팅 서버는 server.py , window.py 2개의 파이썬 파일로 구성되어 있으며, 먼저 소켓을 클래스로 구현한 server.py 파일을 살펴보겠습니다.
1. server.py 파일 소스코드 설명
그럼 전체 코드를 살펴보겠습니다.
from threading import Thread from socket import * from PyQt5.QtCore import Qt, pyqtSignal, QObject class ServerSocket(QObject): update_signal = pyqtSignal(tuple, bool) recv_signal = pyqtSignal(str) def __init__(self, parent): super().__init__() self.parent = parent self.bListen = False self.clients = [] self.ip = [] self.threads = [] self.update_signal.connect(self.parent.updateClient) self.recv_signal.connect(self.parent.updateMsg) def __del__(self): self.stop() def start(self, ip, port): self.server = socket(AF_INET, SOCK_STREAM) try: self.server.bind( (ip, port)) except Exception as e: print('Bind Error : ', e) return False else: self.bListen = True self.t = Thread(target=self.listen, args=(self.server,)) self.t.start() print('Server Listening...') return True def stop(self): self.bListen = False if hasattr(self, 'server'): self.server.close() print('Server Stop') def listen(self, server): while self.bListen: server.listen(5) try: client, addr = server.accept() except Exception as e: print('Accept() Error : ', e) break else: self.clients.append(client) self.ip.append(addr) self.update_signal.emit(addr, True) t = Thread(target=self.receive, args=(addr, client)) self.threads.append(t) t.start() self.removeAllClients() self.server.close() def receive(self, addr, client): while True: try: recv = client.recv(1024) except Exception as e: print('Recv() Error :', e) break else: msg = str(recv, encoding='utf-8') if msg: self.send(msg) self.recv_signal.emit(msg) print('[RECV]:', addr, msg) self.removeClient(addr, client) def send(self, msg): try: for c in self.clients: c.send(msg.encode()) except Exception as e: print('Send() Error : ', e) def removeClient(self, addr, client): # find closed client index idx = -1 for k, v in enumerate(self.clients): if v == client: idx = k break client.close() self.ip.remove(addr) self.clients.remove(client) del(self.threads[idx]) self.update_signal.emit(addr, False) self.resourceInfo() def removeAllClients(self): for c in self.clients: c.close() for addr in self.ip: self.update_signal.emit(addr, False) self.ip.clear() self.clients.clear() self.threads.clear() self.resourceInfo() def resourceInfo(self): print('Number of Client ip\t: ', len(self.ip) ) print('Number of Client socket\t: ', len(self.clients) ) print('Number of Client thread\t: ', len(self.threads) )
1~3번 라인은 파이썬 모듈을 불러오는 코드입니다.
서버소켓의 지속적인 접속 대기 및 클라이언트 접속시 해당 처리용 Thread를 생성하기 위한 쓰레드 모듈, 소켓, PyQt관련 모듈 등 입니다.
5번 라인의 ServerSocket 클래스는 위에서 설명한 버클리 소켓을 멤버 변수로 갖는 네트워크 관련 클래스 입니다.
10번 라인의 생성자 함수에서 부모위젯을 전달 받는데, 그 이유는 클라이언트 접속, 접속종료, 메시지 수신시 전송되는 사용자 정의 시그널에 부모 윈도우의 함수를 슬롯으로 등록해 클래스간 신호를 전달하기 위함입니다.
이어서 해당 클래스에 객체 멤버 변수를 선언하고 있습니다.
순서대로,
- self.parent : 부모윈도우를 저장하는 변수
- self.bListen : 서버 소켓이 리슨(접속 대기) 상태인지 아닌지 저장
- self.client : 접속한 클라이이언트들을 저장할 리스트 변수
- self.ip : 접속한 클라이언트의 IP주소를 저장할 변수
- self.thread : 리슨 및 클라이언트 접속시 마다 생성되는 실행흐름 저장 리스트
- self.conn, self.recv : 클라이언트 접속, 데이터 수신시 보내는 시그널
객체 변수 선언에 이어 사용자 정의 시그널에 부모윈도우 함수를 슬롯에 연결하는
코드가 어어집니다.
21번 라인은 ServerSocket 클래스 객체가 파괴될때 호출되는 소멸자 입니다. stop()
함수를 호출해 대기중인 서버 소켓을 종료합니다.
24번 라인의 start() 함수는 부모 윈도우의 서버 실행 버튼을 누르면 호출되는
함수이며, 전달 인자로 IP 주소와 port 번호를 전달 받습니다.
잠시 IP와 port에 대해 설명드리자면,
IP는 네크워크에서 해당 기기의 인터넷 주소를 의미하며, port는 해당 기기 내의
연결 번호(?)를 의미합니다.
우리는 컴퓨터를 사용할 때 IP주소 하나로 인터넷을 하며, 게임, 파일 다운로드를
동시에 진행하는 경우가 종종 있는데, 이는 IP주소는 하나이지만 각각 서로 다른
포트 번호를 통해 연결되어 있기 때문에 가능합니다.
예를 들면 FTP(파일 전송 프로토콜)는 21번 포트, 인터넷 웹페이지는 80번 포트
번호 등 0~65535 (2의 16승) 까지 포트 번호 사용이 가능합니다.
우리 채팅 프로그램에서는 5614번 포트를 사용할 계획입니다. 특별한 의미는 없고
학원 전화번호 뒷자리입니다. ㅎㅎ
계속해서 start()함수의 설명을 이어가 보겠습니다.
self.server = socket(AF_INET, SOCK_STREAM)
25번 라인의 바로 이 코드가 버클리 소켓을 생성하고 self.server 객체 변수에
저장하는 구문입니다.
AF_INET는 주소패밀리 (IPv4)를 뜻하며, SOCKET_STREAM은 연결지향형 소켓으로 만들어 달라는 말입니다.
이어서 try 오류처리 구문을 통해 만들어진 소켓을 전달인자로 넘어온 ip, port 번호와 bind() 합니다.
try구문을 쓴 이유는 이미 사용중인 포트번호일 수도 있고, 또 다른 다양한 이유로 bind()함수가 실패할 수도 있기 때문에 오류처리하는 부분입니다.
만약 bind()에서 오류가 발생하면 except 구문으로 들어와 오류 메시지를 띄웁니다.
bind() 함수가 이상이 없다면 try->else 구문으로 들어와 서버소켓을 리슨상태로 대기하기 위한 쓰레드를 생성하고 실행합니다.
40번 라인의 stop()함수는 서버 소켓을 닫는 함수 입니다.
46번 라인의 listen() 함수는 쓰레드에서 호출되는 함수이며, 생성된 서버 소켓을 리슨 상태(접속대기)로 만든 후 accept() 함수를 호출해 블럭(정지) 상태에 빠집니다.
accept()는 클라이언트가 접속할 때까지 무한 대기하게 됩니다.
만약 클라이언트가 접속한 경우, accept()함수를 탈출하게 되며 클라이언트 소켓, IP 주소를 객체 리스트 변수에 저장하고 부모윈도우에 접속을 알린 후 접속한 클라이언트와 데이터 수신을 위한 쓰레드를 생성합니다.
listen()함수는 위 행동을 무한 반복하며 수행해, 클라이언트가 접속할 때 마다 새로운 연결을 반복적으로 생성하는 역할을 합니다.
사실, 클라이언트가 접속할 때 마다 쓰레드를 생성하는 행위는 좋은 방법은 아닙니다. 왜냐하면 쓰레드의 갯수는 한계가 존재합니다. 또한, 다수의 클라이언트마다 쓰레드를 1:1로 생성하면 쓰레드의 갯수가 지나치게 많아지게 되고, 쓰레드간 Context Switching (CPU의 작업 스케줄 변경) 을 처리하는데 많은 자원이 소모되기 때문입니다.
따라서 쓰레드 풀(Thread Pool)을 이용해 쓰레드의 갯수를 제한해 처리하는 방식이 더 효율적이지만 본 예제의 범위를 벗어나기 때문에 간략히 1:1로 구현하도록 하겠습니다.
65번 라인의 receive()함수는 클라이언트가 접속할 때 마다 생성되는 쓰레드에 의해 실행되는 함수입니다.
이제 서버와 클라이언트간 1:1연결이 이루어진 상태이므로, recv()함수를 호출해 블럭 상태로 진입한 후 클라이언트가 보내는 메시지를 수신하기 위해 대기합니다.
만약 데이터를 수신하였다면 recv()함수를 빠져나와 수신 메시지를 utf-8 문자열로 만들고 부모 윈도우로 전달한 후 send()함수를 호출해 연결된 모든 클라이언트에게도 broadcasting하는 구조입니다.
이후 다시 무한 반복하며 recv()함수를 호출해 다음 메시지 수신을 대기합니다.
82번 라인의 send()함수는 클라이언트가 보낸 데이터 수신 시, 연결된 모든 클라이언트에게 해당 메시지를 보내는 역할(broadcast)을 담당합니다.
89번 라인의 removeClient()함수를 클라이언트의 연결이 끊어진 경우, 부모 윈도우로 이를 알리고 해당 연결에 사용되는 쓰레드를 종료하는 역할을 담당합니다.
105번 라인의 removeAllClients()함수는 위에서 설명한 removeClient()와 유사하나 모든 클라이언트의 접속을 끊는 역할입니다. 주로 서버를 종료하는 경우이겠죠.
118번 라인의 resourceInfo()함수는 몇 개의 서버-클라이언트간 연결이 생성되었는지 콘솔창에 정보를 출력하는 함수입니다.
이제 서버 관련 클래스의 설명을 마치고, 실제 윈도우 창을 생성하는 window.py 파일을 살펴보겠습니다.
2. windows.py 파일 소스코드 설명
windows.py 파일을 실제 눈에 보이는 윈도우 창을 생성하고, 버튼, 입력창, 대화창 등을 표시하는 역할을 담당합니다.
아래는 해당 파일의 전체 코드입니다.
코드가 얼핏 보기에 복잡해 보이지만, 그냥 윈도우 창을 만들고, ip, port번호 입력, 채팅 내용 표시를 수행하는 역할을 담당할 뿐 네트워크와 관련된 코드는 없습니다.
AF_INET는 주소패밀리 (IPv4)를 뜻하며, SOCKET_STREAM은 연결지향형 소켓으로 만들어 달라는 말입니다.
이어서 try 오류처리 구문을 통해 만들어진 소켓을 전달인자로 넘어온 ip, port 번호와 bind() 합니다.
try구문을 쓴 이유는 이미 사용중인 포트번호일 수도 있고, 또 다른 다양한 이유로 bind()함수가 실패할 수도 있기 때문에 오류처리하는 부분입니다.
만약 bind()에서 오류가 발생하면 except 구문으로 들어와 오류 메시지를 띄웁니다.
bind() 함수가 이상이 없다면 try->else 구문으로 들어와 서버소켓을 리슨상태로 대기하기 위한 쓰레드를 생성하고 실행합니다.
40번 라인의 stop()함수는 서버 소켓을 닫는 함수 입니다.
46번 라인의 listen() 함수는 쓰레드에서 호출되는 함수이며, 생성된 서버 소켓을 리슨 상태(접속대기)로 만든 후 accept() 함수를 호출해 블럭(정지) 상태에 빠집니다.
accept()는 클라이언트가 접속할 때까지 무한 대기하게 됩니다.
만약 클라이언트가 접속한 경우, accept()함수를 탈출하게 되며 클라이언트 소켓, IP 주소를 객체 리스트 변수에 저장하고 부모윈도우에 접속을 알린 후 접속한 클라이언트와 데이터 수신을 위한 쓰레드를 생성합니다.
listen()함수는 위 행동을 무한 반복하며 수행해, 클라이언트가 접속할 때 마다 새로운 연결을 반복적으로 생성하는 역할을 합니다.
사실, 클라이언트가 접속할 때 마다 쓰레드를 생성하는 행위는 좋은 방법은 아닙니다. 왜냐하면 쓰레드의 갯수는 한계가 존재합니다. 또한, 다수의 클라이언트마다 쓰레드를 1:1로 생성하면 쓰레드의 갯수가 지나치게 많아지게 되고, 쓰레드간 Context Switching (CPU의 작업 스케줄 변경) 을 처리하는데 많은 자원이 소모되기 때문입니다.
따라서 쓰레드 풀(Thread Pool)을 이용해 쓰레드의 갯수를 제한해 처리하는 방식이 더 효율적이지만 본 예제의 범위를 벗어나기 때문에 간략히 1:1로 구현하도록 하겠습니다.
65번 라인의 receive()함수는 클라이언트가 접속할 때 마다 생성되는 쓰레드에 의해 실행되는 함수입니다.
이제 서버와 클라이언트간 1:1연결이 이루어진 상태이므로, recv()함수를 호출해 블럭 상태로 진입한 후 클라이언트가 보내는 메시지를 수신하기 위해 대기합니다.
만약 데이터를 수신하였다면 recv()함수를 빠져나와 수신 메시지를 utf-8 문자열로 만들고 부모 윈도우로 전달한 후 send()함수를 호출해 연결된 모든 클라이언트에게도 broadcasting하는 구조입니다.
이후 다시 무한 반복하며 recv()함수를 호출해 다음 메시지 수신을 대기합니다.
82번 라인의 send()함수는 클라이언트가 보낸 데이터 수신 시, 연결된 모든 클라이언트에게 해당 메시지를 보내는 역할(broadcast)을 담당합니다.
89번 라인의 removeClient()함수를 클라이언트의 연결이 끊어진 경우, 부모 윈도우로 이를 알리고 해당 연결에 사용되는 쓰레드를 종료하는 역할을 담당합니다.
105번 라인의 removeAllClients()함수는 위에서 설명한 removeClient()와 유사하나 모든 클라이언트의 접속을 끊는 역할입니다. 주로 서버를 종료하는 경우이겠죠.
118번 라인의 resourceInfo()함수는 몇 개의 서버-클라이언트간 연결이 생성되었는지 콘솔창에 정보를 출력하는 함수입니다.
이제 서버 관련 클래스의 설명을 마치고, 실제 윈도우 창을 생성하는 window.py 파일을 살펴보겠습니다.
2. windows.py 파일 소스코드 설명
windows.py 파일을 실제 눈에 보이는 윈도우 창을 생성하고, 버튼, 입력창, 대화창 등을 표시하는 역할을 담당합니다.
아래는 해당 파일의 전체 코드입니다.
from PyQt5.QtGui import * from PyQt5.QtCore import * from PyQt5.QtWidgets import * import sys import socket import server QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) port = 5614 class CWidget(QWidget): def __init__(self): super().__init__() self.s = server.ServerSocket(self) self.initUI() def initUI(self): self.setWindowTitle('서버') # 서버 설정 부분 ipbox = QHBoxLayout() gb = QGroupBox('서버 설정') ipbox.addWidget(gb) box = QHBoxLayout() label = QLabel('Server IP') self.ip = QLineEdit(socket.gethostbyname(socket.gethostname())) box.addWidget(label) box.addWidget(self.ip) label = QLabel('Server Port') self.port = QLineEdit(str(port)) box.addWidget(label) box.addWidget(self.port) self.btn = QPushButton('서버 실행') self.btn.setCheckable(True) self.btn.toggled.connect(self.toggleButton) box.addWidget(self.btn) gb.setLayout(box) # 접속자 정보 부분 infobox = QHBoxLayout() gb = QGroupBox('접속자 정보') infobox.addWidget(gb) box = QHBoxLayout() self.guest = QTableWidget() self.guest.setColumnCount(2) self.guest.setHorizontalHeaderItem(0, QTableWidgetItem('ip')) self.guest.setHorizontalHeaderItem(1, QTableWidgetItem('port')) box.addWidget(self.guest) gb.setLayout(box) # 채팅창 부분 gb = QGroupBox('메시지') infobox.addWidget(gb) box = QVBoxLayout() label = QLabel('받은 메시지') box.addWidget(label) self.msg = QListWidget() box.addWidget(self.msg) label = QLabel('보낼 메시지') box.addWidget(label) self.sendmsg = QLineEdit() box.addWidget(self.sendmsg) hbox = QHBoxLayout() self.sendbtn = QPushButton('보내기') self.sendbtn.clicked.connect(self.sendMsg) hbox.addWidget(self.sendbtn) self.clearbtn = QPushButton('채팅창 지움') self.clearbtn.clicked.connect(self.clearMsg) hbox.addWidget(self.clearbtn) box.addLayout(hbox) gb.setLayout(box) # 전체 배치 vbox = QVBoxLayout() vbox.addLayout(ipbox) vbox.addLayout(infobox) self.setLayout(vbox) self.show() def toggleButton(self, state): if state: ip = self.ip.text() port = self.port.text() if self.s.start(ip, int(port)): self.btn.setText('서버 종료') else: self.s.stop() self.msg.clear() self.btn.setText('서버 실행') def updateClient(self, addr, isConnect = False): row = self.guest.rowCount() if isConnect: self.guest.setRowCount(row+1) self.guest.setItem(row, 0, QTableWidgetItem(addr[0])) self.guest.setItem(row, 1, QTableWidgetItem(str(addr[1]))) else: for r in range(row): ip = self.guest.item(r, 0).text() # ip port = self.guest.item(r, 1).text() # port if addr[0]==ip and str(addr[1])==port: self.guest.removeRow(r) break def updateMsg(self, msg): self.msg.addItem(QListWidgetItem(msg)) self.msg.setCurrentRow(self.msg.count()-1) def sendMsg(self): if not self.s.bListen: self.sendmsg.clear() return sendmsg = self.sendmsg.text() self.updateMsg(sendmsg) print(sendmsg) self.s.send(sendmsg) self.sendmsg.clear() def clearMsg(self): self.msg.clear() def closeEvent(self, e): self.s.stop() if __name__ == '__main__': app = QApplication(sys.argv) w = CWidget() sys.exit(app.exec_())
코드가 얼핏 보기에 복잡해 보이지만, 그냥 윈도우 창을 만들고, ip, port번호 입력, 채팅 내용 표시를 수행하는 역할을 담당할 뿐 네트워크와 관련된 코드는 없습니다.
1번라인 부터 각종 모듈을 불러오는 코드입니다.
8번 라인은 4k해상도 모니터를 위한 고해상도 설정이며, port 번호는 기본으로 5614입니다.
12번 라인부터 Qt의 QWidget에서 상속받은 CWidget클래스를 만드는 코드가 시작됩니다.
13번 라인은 생성자 함수이며, 상속구조이므로 QWidget(부모)의 생성자를 호출하고 앞서 설명한 ServerSocket 클래스를 변수로 갖습니다.
20번 라인은 생성자에서 호출되는 initUI()함수이며, 각종 컨트롤을 생성하는 역할을 담당합니다.
23~46번 라인은 서버설정 관련 컨트롤을 배치하는 코드입니다.
[서버 설정 관련 컨트롤] |
Qt의 그룹박스, 라벨, 라인에디트, 버튼 클래스를 생성하고 QBoxLayout을 사용해 배치합니다.
컨트롤을 윈도우에 배치하는 방법은 QDesigner를 통한 방법과, 저처럼 직접 코드를 통해 배치하는 2가지 방법이 있는데 전 후자를 선호합니다.
QDesigner를 통한 배치는 QML을 이용하는 방식이며, ui 파일로 변경해 코드에서 불러들이는 방식인데, 이는 컨트롤에 수정사항이 생기면 매번 ui파일을 새로 만들어야 하므로 저는 불편해서 잘 사용하지 않습니다.
[QDesigner를 이용한 방법] |
48~61번 라인은 접속자 정보 표시 컨트롤을 배치하는 부분이며, 아래 그림에 해당합니다.
[접속자 정보 표시 컨트롤] |
MFC의 리스트 컨트롤과 유사한 QTableWidget 을 이용해 접속한 클라이언트의 IP주소와 port번호를 표시합니다.
63~93번 라인은 채팅창과 관련한 컨트롤을 생성, 배치하는 코드이며 아래 그림에 해당합니다.
[채팅창 표시 컨트롤] |
받은 메시지는 QListWidget , 보낼 메시지는 QLineEidt, 그리고 2개의 QPushButton 으로 구성되어 있습니다.
97~102번 라인은 위에서 생성한 서버설정, 접속자정보, 채팅창 컨트롤의 LayoutBox를 배치하고 show()함수를 이용해 윈도우 창을 띄우는 코드입니다.
104번 라인은 toggleButton()함수로 '서버실행' 버튼을 누르면 호출되는 함수이며, 서버의 실행과 종료역할을 담당합니다.
서버 실행시 ip와 port번호를 ServerSocket클래스로 전달해 리슨소켓을 생성, 실행하는 역할이며, 종료시 서버 소켓 클래스의 close()함수를 호출해 서버소켓을 닫는 동작을 수행합니다.
115번 라인의 updateClient()함수는 ServerSocket에 클라이언트가 접속한 경우, 클라이언트의 정보를 접속자 정보표시 컨트롤에 보여주는 역할을 담당합니다.
129번 라인의 updateMsg() 함수는 연결된 클라이언트로 부터 메시지를 수신한 경우 호출되는 함수이며, 수신 메시지를 표시하고 해당 메시지에 포커스를 주는 동작을 수행합니다.
133번 라인의 sendMsg() 함수는 보낼 메시지에 글을 적고 보내기 버튼을 눌렀을때 그 내용을 ServerSocket으로 전달해 모든 클라이언트에게 메시지는 송신하는 역할입니다.
143번 라인의 closeMsg() 함수는 채팅창 지움 버튼을 눌렀을때, 내용을 지우는 역할을 수행합니다.
146번 라인의 closeEvent() 함수는 QWidget클래스에서 재정의된 함수이며, 윈도우 창을 닫는 경우 서버를 종료하고 창이 닫기도록 합니다.
149번 라인은 프로그램의 메인함수이며, CWidget을 생성하고 ServerSocket을 만들어 프로그램이 시작되도록 합니다.
이상으로 채팅 서버 프로그램의 설명을 마칩니다.
다음에는 클라이언트를 분석해 보도록 하겠습니다.
감사합니다.
pyinstaller로 제작한 파일을 제 윈도우 10 디펜더가 바이러스로 오해 (오탐지)를 하는 문제가 있습니다만 코드를 보시면 전~혀 그런 의도는 없으며, 소켓프로그래밍을 하다 보면 자주 겪는 현상임을 알려드립니다.
서버가 포트를 열고 대기하는 행동 때문인지 트로이 목마라고 나오네요. ㅎ
만약 서버가 정상적으로 대기(리슨) 중인 상태에서 클라이언트의 접속이 이루어지지 않는다면, 방화벽 해지 or 예외로 등록후 시도하기 바랍니다.
pyinstaller 로 제작한 실행파일 : 채팅서버
- 개발 환경
- 운영체제 : MS Windows 10 Pro
- 개발 언어 : Python 3.7 (32bit), PyQt5 (5.11.3)
- 개발 도구 : MS Visual Studio 2017 Pro
안녕하세요, 게시물 따라 실습해보고 있는 학생입니다. 비주얼 스튜디오 2019에서 Client라는 프로젝트 파일을 만들고 그 안에 server.py 코드와 windows.py 코드를 똑같이 따라 만들었는데요, 이제 windows.py 파일에서 Ctrl F5를 누르면 올려주신 실행파일처럼 같은 그림이 나오는 것을 기대했습니다만, 계속하려면 아무키나 누르라는 화면만 뜨고 gui 그림이 나오지 않습니다. 두 파이썬 파일 모두 빌드는 오류없이 잘 됩니다. 이렇게 실행시키는것이 아닌가요?
답글삭제VS의 솔루션 탐색기에서 window.py 파일을 마우스 우클릭하면 '시작파일로 설정' 이라는 메뉴가 있습니다.
삭제windows.py가 살짝 굵은 글씨체로 표시되며 시작파일로 지정되어야만 main함수 역할을 하게 되어 프로그램이 시작, 실행되게 됩니다.
server.py 파일이 시작파일이거나, 아니면 둘 다 시작파일이 아니어서 그렇습니다.
안녕하세요, 윈도우에서는 해당 코드실행후, gui의 서버실행/종료 버튼을 누르면 소켓 close가 정상적으로 되어서, 종료한 후, 다시 서버실행하면 정상적으로 bind가 되는데,
답글삭제우분투에서는 안됩니다. 혹시 어떤이유로 인해 안되는지 아시나요? ㅠㅠ
재 접속 시 bind() 구문의 오류 내용을 살펴보아야 합니다.
삭제만약 bind()의 문제가 맞다면 try 구문의 except 절로 빠져 오류가 출력(print)되는데 이 때 오류 코드를 살펴보면 문제를 찾을 수 있습니다.
안녕하세요~ 혹시, 클라이언트에서 서버에 접속할 때 뜨는 port 는 임의적으로 56xxx 번대 포트가 자동으로 세팅이 되는 건가요?
답글삭제소스코드를 보다보니 클라이언트 쪽 포트를 정해주는 부분이 없는 것 같아서 질문 글 남깁니다!
네 맞습니다.
삭제TCP 서버 소켓은 소켓 생성 후 bind 시 ip, port를 지정하는 과정(이후 해당 포트번호로 listen 해야함)을 거쳐야합니다. 왜냐하면 클라이언트의 접속을 해당 포트를 통해 받아들이기 때문입니다.
반면 클라이언트 소켓은 생성 후 바로 connect (bind X)로 이어지고, 포트번호는 OS가 빈 포트를 자동으로 할당하게 됩니다.
안녕하세요.
답글삭제제가 이 코드를 가지고 같은 공유기에 접속된 상태에 있는 두개의 노트북으로 접속할 때는 잘 접속이 되는데요.
서로 다른 공유기에 접속된 상태에서 클라이언트가 되는 노트북이 서버의 노트북의 ip주소와 포트넘버를 입력해서 접속하려하니까 접속이 안됩니다. 혹시 다른 lan상에 있는 클라이언트도 접속할 방법이 있나요?
공유기에 접속된 기기들은 가상(사설)IP 를 할당받아 사용하게 됩니다. (192.XXX)
삭제따라서 외부 네트워크에서 접속이 불가능(내부만 O)합니다.
외부네트워크와 통신하기 위해 서버는 아래 두 방법중 하나를 만족해야 합니다.
1. 서버측 프로그램의 사설 IP, PORT 주소를 공유기에서 포트포워딩 설정.
2. 서버측 프로그램의 IP를 공인 IP를 할당받아 사용.
제가 인터넷 검색하여 많은 것을 테스트 해보았습니다.
답글삭제스레딩 사용한 소켓관련 올라온 프로그램중에 세계최고 인 것 같습니다.
그런데 궁긍한 것이 있어서 문의 드립니다.
# is_alive() in python verson 3.9
#if not t.isAlive():
if not t.is_alive():
del(self.threads[i])
이것이 잘동작하지 않습니다.
즉 alive상태로 계속 나와서.... 문의드립니다.
현재는 이것 대신에 쓰레딩이 무한 루프를 나와 리턴되면,
내부 쓰레드가 자동으로 삭제되는 방식으로 하고 있습니다.
아무튼 훌륭한 자료 공개에 고개숙여 감사드립니다.
안녕하세요. 과찬의 말씀입니다.
삭제Thread 의 is_alive관련한 문제점은 제가 코드를 잘 못짜서 생긴 버그입니다.
원본 게시물의 코드를 수정해 업데이트해 두었습니다.
몇가지 서버측 문제점을 수정하였으며 아래와 같습니다.
1. 클라이언트 접속 종료시 접속이 끊어진 클라이언트용 쓰레드를 정확히 찾아 삭제.
2. 메인 위젯에 클라이언트 연결, 연결끊김이 정확히 표시되지 않는 문제
버그를 찾아 주셔서 감사합니다.
혹시 TCP 소켓을 이용한 프로그램인가요?
답글삭제네 TCP 소켓을 이용한 채팅 서버이며, 클라이언트는 게시물의 링크 참조 바랍니다.
삭제