I/O 멀티플렉싱 기능이 있는 채팅 프로그램

채팅 프로그램은 입사 후 처음 커미션으로 받은 파일럿 프로젝트로, 리팩토링을 하면서 기회를 삼아 다시 봤습니다. 6개월 동안 국비로 머신러닝과 데이터 분석을 배웠기 때문에 그 당시에는 개발에 대해 전혀 몰랐습니다. 제가 이 분야에서 잘하는 것이 무엇인지도 모르고, 마주치면 조심조심 동참하지만, 지금 생각해보면 그 무한한 용기가 어디서 나온 것인지 대단합니다. 요컨대 일을 하면서 배울 점이 많고 다양한 경험을 했기 때문에 좋은 선택이었다고 생각한다.

자세히 알아보고 요점을 파악하는 이 게시물 여러 클라이언트가 서버에서 대화를 주고받을 수 있는 채팅 프로그램채팅 서버가 클라이언트의 요청을 비동기적으로 실행하는 방법에 대한 기사입니다. 서버가 클라이언트의 요청을 동기식으로 처리하는 경우 클라이언트 1의 요청을 처리하는 동안 클라이언트 2의 요청을 처리할 수 없습니다.

채팅 서버를 열고 여러 개의 client.py 파일을 실행하여 여러 클라이언트가 통신할 수 있는 구조입니다.

(서버.py)

# -*- coding: utf8 -*-
import socket
import sys
import select
import datetime
import json

class OverlappedError(Exception):
	def __init__(self):
		super().__init__("\n※ 이미 사용중인 닉네임입니다. 다시 입력해주세요.\n")	

class ChatRoom(object):
	global connectionStatus
	
	def find_conn(self, connection_dict, nick_val):
		return next(conn for conn, nick in connection_dict.items() if nick_val == nick)
	
	def find_nick(self, connection_dict, conn_val):
		return next(nick for conn, nick in connection_dict.items() if conn_val == conn)

	def find_room_num(self, connection_status, conn_val):
		return next(room_num for room_num, connection_dict in connection_status.items() if conn_val in connection_dict)

	def manage_client(self, conn, data):
		self.nickname = data('client_nick')
		self.room_num = data('room_num')
		# 클라이언트가 신규 채팅방을 입력한 경우
		if self.room_num not in connectionStatus:
			connectionStatus(self.room_num) = {}
			connectionStatus(self.room_num)(conn) = self.nickname
			conn.send('Y'.encode())
			print(f"(INFO) (room_num_{self.room_num}) {self.nickname}님 접속")
			for sock in connectionStatus(self.room_num):
				if sock != conn: 
					sock.send(f"(INFO) {self.nickname}님 접속".encode())
		# 클라이언트가 기존 채팅방을 입력한 경우
		else:  															
			try:
				if self.nickname not in connectionStatus(self.room_num).values(): # 같은 채팅방 안에 중복 닉네임이 있는지 여부 판단 		
					connectionStatus(self.room_num)(conn) = self.nickname
					conn.send('Y'.encode())	# 클라이언트에게 닉네임 등록 메시지 전달
					print(f"(INFO) (room_num_{self.room_num}) {self.nickname}님 접속")
					for sock in connectionStatus(self.room_num):
						if sock != conn: 
							sock.send(f"(INFO) {self.nickname}님 접속".encode())
				else:
					raise OverlappedError # 중복닉네임 있으면 에러메시지 전달
			except OverlappedError as e:
				conn.send(f'{e}'.encode())

	def manage_message(self, data):
		global connectionStatus
		room_num = self.find_room_num(connectionStatus, conn)
		connection_dict = connectionStatus(room_num)

		if data.split(' ')(1) == '!whisper':
			sender_conn = conn
			receiver = data.split(' ')(2)
			if receiver in connection_dict.values():
				receiver_conn = self.find_conn(connection_dict, receiver)
				msg = data.split(' ')(3:)
				msg = ' '.join(msg)
				sender_nick = self.find_nick(connection_dict, sender_conn)  # conn으로 nick 찾기
				receiver_conn.send(f"(귓속말){sender_nick}{time_str}: {msg}".encode())
			else:
				receiver_conn.send(f"입력하신 닉네임은 존재하지 않습니다.".encode())

		elif data.split(' ')(1) == '!change_nick':
			changed_nick = data.split(' ')(2)
			original_conn = conn
			original_nick = self.find_nick(connection_dict, original_conn)  # conn으로 nick 찾기
			connection_dict(original_conn) = changed_nick  # conn의 value에 새로운 nick로 갱신
			msg = {'changed_nick': changed_nick}
			original_conn.send(json.dumps(msg).encode())
			for sock in connection_dict.keys():
				if sock != original_conn:   # 닉네임을 바꾼 클라이언트를 제외한 채팅방 멤버에게 메시지 전달
					sock.send(f"(INFO) {original_nick}님이 {changed_nick}로 닉네임 변경".encode())

		elif data.split(' ')(1) == '!member':
			member_list = list(connection_dict.values())
			conn.send(f'{member_list}'.encode())

		else: 
			msg = data
			for sock in connection_dict:
				sock.send(msg.encode())
				print(f'(MESSAGE) {data}')


HOST = '127.0.0.1'
PORT = 9111
ADDR = (HOST, PORT)

server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)   # AF_INET = IPv4, SOCK_STREAM = TCP 통신
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind(ADDR)

server_sock.listen()

print("==============================================")
print(f"채팅 서버를 시작합니다. {PORT} 포트로 접속을 기다립니다.")
print("==============================================")

connectionStatus = {}  # 채팅방별 클라이언트 소켓, 닉네임 저장  ex) {1: {conn1: nick1, conn2: nick2, ...}, 2: {conn1: nick1, conn2:nick2, ...}, ...}

chat_room = ChatRoom()

connection_list = (server_sock)

while True:
	now = datetime.datetime.now()
	time_str = now.strftime('(%H:%M)')
	try:
		read_sockets, write_sockets, error_sockets = select.select(connection_list, (), (), 30)
		print("클라이언트 요청 대기...")
		for sock in read_sockets:
			if sock == server_sock:   # 새로운 클라이언트의 소켓이라면 connection_list에 추가
				newsock, addr = server_sock.accept()
				connection_list.append(newsock)
			else:    # 이미 접속한 클라이언트의 소켓이라면 클라이언트가 보낸 메시지 수신
				conn = sock
				data = conn.recv(1024).decode()

				if 'room_num' not in data:  # 이미 접속한 클라이언트의 메시지 수신
					try:
						chat_room.manage_message(data)  # 클라이언트의 메시지에 command가 있으면 해당 내용 수행, 없으면 메시지 자체를 broadcast
					except Exception as e:
						connection_list.remove(conn)

				else:  						# "최초" 접속한 클라이언트의 정보 수신
					login_info = json.loads(data)  # json 문자열인 data를 -> json.loads(data) -> 파이썬 객체(dict)
					try:
						chat_room.manage_client(conn, login_info)
					except Exception as e:
						print(e)

	except Exception as e:
		print(e)
		server_sock.close()
		sys.exit()

(클라이언트.py)

# -*- coding: utf8 -*-
import socket
import sys
import datetime
import select
import json

HOST = '127.0.0.1'
PORT = 9111
ADDR = (HOST, PORT)

# socket 객체 생성
connection_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# 서버와의 연결을 시도
try:
    connection_sock.connect(ADDR)

except Exception as e:
    print(e)

else:
    print(f"채팅서버 {HOST}:{PORT}에 연결되었습니다")

# client info
try:
    room_num = int(input("\n◽ 입장할 채팅방 번호를 입력하세요: "))
    while True:
        client_nick = input("◽ 사용하실 닉네임을 입력하세요: ")
        login_info = {'room_num': room_num, 'client_nick': client_nick}
        connection_sock.send(json.dumps(login_info).encode())  # 클라이언트 정보 송신
        is_possible_nick = connection_sock.recv(1024).decode()  # 닉네임 중복 여부 수신

        if is_possible_nick != 'Y':  # 중복 닉네임인 경우 overlappedError 메시지 수신
            print(is_possible_nick)
            continue
                            # 중복닉네임이 아닌 경우 채팅방 입장
        print(f"\n  닉네임({client_nick}) 생성 완료! :-)")
        break

except Exception as e:
    print(type(e), e)

else:
    s = ""
    s += "\n ------------< 추가기능 사용하기 >------------"
    s += "\n 1. 귓속말 보내기"
    s += "\n   : !whisper (상대방 닉네임) (메시지) 입력"
    s += "\n 2. 닉네임 변경하기"
    s += "\n   : !change_nick (바꿀 닉네임) 입력"
    s += "\n 3. 참여 중인 멤버 목록 보기"
    s += "\n   : !member 입력"
    s += "\n -----------------------------------------"
    s+="\n"
    print(s)

    while True:
        now = datetime.datetime.now()
        time_str=now.strftime('(%H:%M)')
        
        try:
            # 클라이언트의 IN 동작을 파악할 수 있도록 read_sockets에 sys.stdin도 포함 
            connection_list = (sys.stdin, connection_sock)
            read_sockets, write_sockets, error_sockets = select.select(connection_list, (), (), 3)

            for sock in read_sockets:
                # 서버에서 받은 메시지인 경우    
                if sock == connection_sock:
                    data = sock.recv(4096).decode()
                    if 'changed_nick' in data:    # 닉네임이 바뀐 경우 {"changed_nick": changed_nick}의 dictionary가 전달됨
                        changed_info = json.loads(data)
                        client_nick = changed_info('changed_nick').replace('\n', "")
                    else:
                        print(data)

                # 클라이언트가 터미널에서 입력한 메시지인 경우
                else:
                    message = sys.stdin.readline()  # 클라이언트가 입력한 문자열을 읽어서
                    message = message.replace('\n', '')
                    # message    
                    connection_sock.send(f'{client_nick}{time_str}: {message}'.encode())   # 서버에 전송

        except Exception as e:
            print(e)
            connection_sock.close()
            sys.exit()