Project

[파이썬] 채팅 프로그램

hyobinside 2022. 1. 1. 20:10

미루고 미루고 또 미루다 해가 바뀌고 나서야 채팅 프로그램 포스팅을 하게 되었다.

 

작년 5월경, 자대 배치받고 환경에 슬슬 적응해나갈 때쯤 한 가지 불편한 점이 생겼다. 사무실에서 일하다 보면 다른 사무실 사람들과의 소통이 필수적이다. 하지만 각자 다른 사무실에 있고 사무실 간 거리가 있기 때문에 소통수단이 필요하다. 내선 전화가 있긴 하지만 교장 관리나 작업 등으로 한쪽이 자리를 비워 엇갈리는 경우가 정말 빈번하다. 부재중 전화가 와있어 다시 전화하면 80% 확률로 상대방이 부재중이다... 결국 상대 사무실까지 걸어가서 쪽지를 남긴다. 상당히 비효율적이고 불편했다. 

 

그래서 채팅 프로그램을 만들었다. 소켓 통신 관련 이론 지식을 얻기 위해 도서관에 있던 '윤성우의 TCP/IP 열혈 프로그래밍' 책을 읽고 사무실에서 남는 시간에 파이썬으로 코딩을 했다. 책에서는 c언어 기반으로 설명하지만 사무실 컴퓨터로는 Jupyter Notebook만 사용할 수 있어서 파이썬 메소드를 다시 찾아보며 만들었다. 파이썬이 2년 만이라 어색했다. 어찌저찌 구현을 다 하고 배포하여 다른 사람들과 실제로 사용하였다. 꽤 유용했다.

 

기능은 간단하다. 채팅, 말 그대로 메시지를 주고 받는 프로그램이다. 여러 명의 클라이언트들과 채팅할 수 있는 단톡이다. 한 컴퓨터에서 server를 실행하고 해당 서버 ip에 클라이언트들이 연결하는 구조이며 필요한 기능과 요소만을 구현한 기본에 충실한 프로그램이다.

 

------------------------------------------------------------

2022/04/07 코드 수정 및 보완했습니다

+ 가장 먼저 접속한 클라이언트에게 메시지 전송 안되는 에러 해결

+ 특정 상황에서 연결 끊어지는 에러 해결

+ 귓속말 기능 추가

+ 프로그램 정상 작동 확인

+ 코드 수정 및 보완

+ 주석 추가


서버, 클라이언트 코드

# Server

from socket import *
from threading import *
from queue import *
import sys
import datetime

#------------- 서버 세팅 -------------
HOST = '127.0.0.1' # 서버 ip 주소 .
PORT = 9190 # 사용할 포트 번호.
#------------------------------------

s=''
s+='\n  -------------< 사용 방법 >-------------'
s+='\n   연결 종료 : !quit 입력 or ctrl + c    '
s+='\n   참여 중인 멤버 보기 : !member 입력      '
s+='\n   귓속말 보내기 : /w [상대방이름] [메시지]   '
s+='\n'
s+='\n     이 프로그램은 Jupyter Notebook에  '
s+='\n            최적화되어 있습니다.             '
s+='\n  --------------------------------------\n\n'

def now_time():
    now = datetime.datetime.now()
    time_str=now.strftime('[%H:%M] ')
    return time_str

def send_func(lock):
    while True:
        try:
            recv = received_msg_info.get()

            if recv[0]=='!quit' or len(recv[0])==0:  
                msg=str('[SYSTEM] '+now_time()+left_member_name)+'님이 연결을 종료하였습니다.'

            elif recv[0]=='!enter' or recv[0]=='!member':
                now_member_msg='현재 멤버 : '
                for mem in member_name_list:
                    if mem!='-1':
                        now_member_msg+='['+mem+'] '
                recv[1].send(now_member_msg.encode())
                if(recv[0]=='!enter'):
                     msg=str('[SYSTEM] '+now_time()+member_name_list[recv[2]])+'님이 입장하였습니다.'
                else:
                    recv[1].send(now_member_msg.encode())
                    continue
                
            elif recv[0].find('/w')==0: # 귓속말 기능
                split_msg=recv[0].split()
                if split_msg[1] in member_name_list:
                    msg=now_time()+'(귓속말) '+member_name_list[recv[2]] +' : '
                    msg+=recv[0][len(split_msg[1])+4:len(recv[0])]
                    idx=member_name_list.index(split_msg[1])
                    whisper_list[idx]=recv[2] # 귓속말을 받은 상대에게 보낸 사람 count값 저장
                    socket_descriptor_list[idx].send(msg.encode())
                else:
                    msg='해당 사용자가 존재하지 않습니다.'
                    recv[1].send(msg.encode())
                continue 
                
            elif recv[0].find('/r')==0: # 귓속말 답장 기능
                whisper_receiver=whisper_list[recv[2]]
                if whisper_receiver!=-1:
                    msg=now_time()+'(귓속말) '+member_name_list[recv[2]] +' : '
                    msg+=recv[0][3:len(recv[0])]
                    socket_descriptor_list[whisper_receiver].send(msg.encode())
                    whisper_list[whisper_receiver]=recv[2]
                else:
                    msg='귓속말 대상이 존재하지 않습니다.'
                    recv[1].send(bytes(msg.encode()))
                continue
                
            else:
                msg = str(now_time() + member_name_list[recv[2]]) + ' : ' + str(recv[0])

            for conn in socket_descriptor_list:
                if conn =='-1': # 연결 종료한 클라이언트 경우.
                    continue
                elif recv[1] != conn: #자신에게는 보내지 않음.
                    conn.send(msg.encode())
                else:
                    pass
            if recv[0] =='!quit':
                recv[1].close()
        except:
            pass

def recv_func(conn, count, lock):

    if socket_descriptor_list[count]=='-1':
        return -1
    while True:
        global left_member_name
        data = conn.recv(1024).decode()
        received_msg_info.put([data, conn, count]) 

        if data == '!quit' or len(data)==0:
            # len(data)==0 은 해당 클라이언트의 소켓 연결이 끊어진 경우에 대한 예외 처리임.
            lock.acquire()
            print(str(now_time()+ member_name_list[count]) + '님이 연결을 종료하였습니다.')
            left_member_name=member_name_list[count] # 종료한 클라이언트 닉네임 저장.
            socket_descriptor_list[count]= '-1'
            for i in range(len(whisper_list)):
                if whisper_list[i]==count:
                    whisper_list[i]=-1
            member_name_list[count]='-1'
            lock.release()
            break
    conn.close()
    
print(now_time()+'서버를 시작합니다')
server_sock=socket(AF_INET, SOCK_STREAM)
server_sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) # Time-wait 에러 방지.
server_sock.bind((HOST, PORT))
server_sock.listen()

count = 0
socket_descriptor_list=['-1',] # 클라이언트들의 소켓 디스크립터 저장.
member_name_list=['-1',] # 클라이언트들의 닉네임 저장, 인덱스 접근 편의를 위해 0번째 요소 '-1'로 초기화.
whisper_list=[-1,]

received_msg_info = Queue()
left_member_name=''
lock=Lock()

while True:
    count = count +1
    conn, addr = server_sock.accept()
    # conn과 addr에는 연결된 클라이언트의 정보가 저장된다.
    # conn : 연결된 소켓
    # addr[0] : 연결된 클라이언트의 ip 주소
    # addr[1] : 연결된 클라이언트의 port 번호

    while True:
        client_name=conn.recv(1024).decode()

        if not client_name in member_name_list:
            conn.send('yes'.encode())
            break
        else:
            conn.send('overlapped'.encode())

    member_name_list.append(client_name)
    socket_descriptor_list.append(conn)
    whisper_list.append(-1)
    print(str(now_time())+client_name+'님이 연결되었습니다. 연결 ip : '+ str(addr[0]))

    if count>1:
        sender = Thread(target=send_func, args=(lock,))
        sender.start()
        pass
    else:
        sender=Thread(target=send_func, args=(lock,))
        sender.start()
    receiver=Thread(target=recv_func, args=(conn, count, lock))
    receiver.start()

server_sock.close()
# Client

from socket import *
from threading import *
import os
import datetime
import time

#--------------클라이언트 세팅 -----------------------
Host='127.0.0.1' # 서버의 IP주소를 입력하세요.
Port = 9190 # 사용할 포트 번호. 
#---------------------------------------

def now_time(): 
    now = datetime.datetime.now()
    time_str=now.strftime('[%H:%M] ')
    return time_str

def send_func():
    while True:
        send_data=input('당신 : ')
        client_sock.send(send_data.encode('utf-8'))
        if send_data=='!quit':
            print('연결을 종료하였습니다.')
            break 
    client_sock.close()
    os._exit(1)

def recv_func():
    while True:
        try:
            recv_data=(client_sock.recv(1024)).decode('utf-8')
            if len(recv_data)==0:
                print('[SYSTEM] 서버와의 연결이 끊어졌습니다.')
                client_sock.close()
                os._exit(1)
        except Exception as e:
            print('예외가 발생했습니다.', e) # 예외처리중
            print('[SYSTEM] 메시지를 수신하지 못하였습니다.')
        else:
            print(recv_data)
            pass

client_sock=socket(AF_INET, SOCK_STREAM)
try:
    client_sock.connect((Host,Port))

except ConnectionRefusedError:
    print('서버에 연결할 수 없습니다.')
    print('1. 서버의 ip주소와 포트번호가 올바른지 확인하십시오.')
    print('2. 서버 실행 여부를 확인하십시오.')
    os._exit(1)

except:
    print('프로그램을 정상적으로 실행할 수 없습니다. 프로그램 개발자에게 문의하세요.')

else:
    print('[SYSTEM] 서버와 연결되었습니다.')

while True:
    name = input('사용하실 닉네임을 입력하세요 :')     
    if ' ' in name:
        print('공백은 입력이 불가능합니다.')
        continue
    client_sock.send(name.encode())
    is_possible_name=client_sock.recv(1024).decode()
    
    if is_possible_name=='yes':
        print(now_time()+ '채팅방에 입장하였습니다.')
        client_sock.send('!enter'.encode())
        break

    elif is_possible_name=='overlapped':
        print('[SYSTEM] 이미 사용중인 닉네임입니다.')

    elif len(client_sock.recv(1024).decode())==0:
        print('[SYSTEM] 서버와의 연결이 끊어졌습니다.')
        client_sock.close()
        os._exit(1)

sender=Thread(target=send_func, args=())
receiver=Thread(target=recv_func, args=())
sender.start()
receiver.start()

while True:
    time.sleep(1)
    pass

client_sock.close()

사용 방법

간단하다.

 

1. 서버를 실행한 후, 클라이언트를 실행한다.

2. 서버의 ip와 port번호를 입력하면 서버와 연결되고 닉네임까지 입력하면 다른 클라이언트들과 통신이 가능하다.

(서버 역할을 할 pc를 고정시켜놨기 때문에 client 코드에서 ip와 port를 입력받는 부분은 지웠다. 클라이언트 코드에 서버의 ip와 port를 저장해놓으면 된다. 실행할 때마다 입력하는 게 귀찮다는 피드백이 있어서 수정한 부분이다.)

 

커맨드 입력을 통해 기능을 수행할 수 있다. 

  • !member를 입력하면 현재 접속 중인 멤버의 목록을 출력할 수 있고,
  • !quit를 입력하면 연결을 끊을 수 있다.
  • '/w name msg' 을 입력하면 해당 name을 가진 유저에게만 msg를 전송한다. 
  • 귓속말을 할때마다 '/w name'을 치는 게 귀찮아서 '/r'만 치면 가장 최근에 귓속말했던 상대에게 보낼 수 있도록 추가했다.

(리그 오브 레전드 게임과 동일하게 구현한 귓속말 기능이다.)

 

추가 기능

입장할 때에는 채팅방에서 사용할 닉네임을 입력하게 되는데 사용자들 간 구분을 위해 최소한의 두 가지 제약을 뒀다. 

첫 번째로, 공백을 허용하지 않고,

두 번째로, 채팅방에 먼저 입장한 사람의 닉네임과 같을 수 없다. 즉, 중복을 불허한다.
  
클라이언트들이 입장하고 퇴장할 때 카톡에서 '~~님이 입장하였습니다.' 와 같은 메시지를 다른 클라이언트들에게 뿌려준다.

다수의 클라이언트 간 통신을 하기 위해 멀티쓰레딩을 활용하였다.

 

 

전체적인 기능 자체는 단순하기 때문에 특별히 구상하거나 고심할 필요는 없었지만 예외 처리하는데 생각보다 시간이 많이 걸렸다. 모든 에러를 정리하진 못했지만 겪었던 문제들을 아래 글에 정리하였다.

 

[파이썬] 소켓 기반 채팅 프로그램 제작하면서 겪었던 에러

1. WinError 10038 [WinError 10038] 소켓 이외의 개체에 작업을 시도했습니다. 발생 원인 :  데이터 송수신 완료 전에 소켓을 close하면 발생한다. 해결 방법 : close() 함수를 사용한 위치 확인하기. 적절한

hyobn.tistory.com

 

프로그램을 만들면서 공부했던 내용도 따로 정리해보았다.

https://hyobn.tistory.com/32

 

[파이썬] 채팅 프로그램 공부했던 내용

파이썬 멀티스레딩(Multi Threading) from threading import * x = Thread(target=yhb, args=('A',)) x.start() target : 쓰레드가 실행할 함수를 지정. args : target으로 지정한 함수에 넘길 인자. start() 함수..

hyobn.tistory.com

 

파이썬에서 서버-클라이언트 간에 소켓 통신이 이루어지는 과정을 간단히 정리해보았다.

https://hyobn.tistory.com/36

 

[파이썬] 소켓 통신 과정

서버와 클라이언트의 통신 과정을 간단히 정리하면 다음과 같다. 5단계에 걸쳐 진행된다. 서버를 열고 열린 서버에 클라이언트가 연결을 요청해야 하므로 서버 파일을 실행한 후 클라이언트 파

hyobn.tistory.com


후기

gui도 구현하고 대화 내용 저장 등 기능들을 많이 추가하고 싶었지만 군생활을 제 기간에 안전하게 끝마치고 싶었으며 시간이 갈수록 업무와 일과 등 환경적인 측면이 많이 바뀌어 이 프로그램의 필요성이 많이 줄어들었.. 사실 거의 없어졌다. 아쉬웠지만 꽤나 유용하게, 재밌게 잘 사용했다.

서버, 클라이언트 코드도 체계적으로 잘 짜고 싶었는데 파이썬 문법이 가물가물한 상태로 인터넷 검색이 안되는 환경에서 주로 개발을 하다 보니 계획했던 형식에서 많이 벗어나게 되고 스파게티 코드가 된 것 같다.

짧은 기간이었지만 내가 만든 프로그램을 공유하여 사람들과 직접 사용하고 피드백을 받으며 보완하고 재미있는 경험이었다.

반응형