미루고 미루고 또 미루다 해가 바뀌고 나서야 채팅 프로그램 포스팅을 하게 되었다.
작년 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'만 치면 가장 최근에 귓속말했던 상대에게 보낼 수 있도록 추가했다.
(리그 오브 레전드 게임과 동일하게 구현한 귓속말 기능이다.)
추가 기능
입장할 때에는 채팅방에서 사용할 닉네임을 입력하게 되는데 사용자들 간 구분을 위해 최소한의 두 가지 제약을 뒀다.
첫 번째로, 공백을 허용하지 않고,
두 번째로, 채팅방에 먼저 입장한 사람의 닉네임과 같을 수 없다. 즉, 중복을 불허한다.
클라이언트들이 입장하고 퇴장할 때 카톡에서 '~~님이 입장하였습니다.' 와 같은 메시지를 다른 클라이언트들에게 뿌려준다.
다수의 클라이언트 간 통신을 하기 위해 멀티쓰레딩을 활용하였다.
전체적인 기능 자체는 단순하기 때문에 특별히 구상하거나 고심할 필요는 없었지만 예외 처리하는데 생각보다 시간이 많이 걸렸다. 모든 에러를 정리하진 못했지만 겪었던 문제들을 아래 글에 정리하였다.
프로그램을 만들면서 공부했던 내용도 따로 정리해보았다.
파이썬에서 서버-클라이언트 간에 소켓 통신이 이루어지는 과정을 간단히 정리해보았다.
후기
gui도 구현하고 대화 내용 저장 등 기능들을 많이 추가하고 싶었지만 군생활을 제 기간에 안전하게 끝마치고 싶었으며 시간이 갈수록 업무와 일과 등 환경적인 측면이 많이 바뀌어 이 프로그램의 필요성이 많이 줄어들었.. 사실 거의 없어졌다. 아쉬웠지만 꽤나 유용하게, 재밌게 잘 사용했다.
서버, 클라이언트 코드도 체계적으로 잘 짜고 싶었는데 파이썬 문법이 가물가물한 상태로 인터넷 검색이 안되는 환경에서 주로 개발을 하다 보니 계획했던 형식에서 많이 벗어나게 되고 스파게티 코드가 된 것 같다.
짧은 기간이었지만 내가 만든 프로그램을 공유하여 사람들과 직접 사용하고 피드백을 받으며 보완하고 재미있는 경험이었다.
'Project' 카테고리의 다른 글
[Spring] TipMI 프로젝트 정리 (1) | 2023.02.27 |
---|---|
[파이썬] 채팅 프로그램 제작하며 공부했던 내용 (2) | 2022.03.13 |
[파이썬] 소켓 기반 채팅 프로그램 제작하면서 겪었던 에러 (0) | 2021.09.28 |