Class GameDev* SheepAdult

Multi Thread를 활용한 간단한 1:N 채팅 서버 본문

C++

Multi Thread를 활용한 간단한 1:N 채팅 서버

SheepAdult 2023. 12. 30. 02:57

 인턴십을 진행하면서, 그리고 면접들을 보면서 멀티스레드 프로그래밍 경험과 채팅 서버 만들어본 경험에 대해서 질문을 받았다. 인턴십과 면접이 클라이언트가 아닌 게임 프로그래밍(클라 + 서버) 직무여서 그런지 이러한 질문을 받은 것 같다. 그래서 이참에 멀티스레드를 활용한 채팅 서버를 만들어 봤다.

깃허브: https://github.com/YangSeongIn/ChatServerWithMultiThread

 

GitHub - YangSeongIn/ChatServerWithMultiThread: ChatServer With MultiThread

ChatServer With MultiThread. Contribute to YangSeongIn/ChatServerWithMultiThread development by creating an account on GitHub.

github.com

 

개요

 서버는 클라이언트로부터 받은 요청만 처리를 하고 텍스트를 작성하지 않는다. 클라이언트는 텍스트를 작성한 후 서버로 보낸다. 서버는 여러 클라이언트로부터 데이터를 받고 처리를 하기 때문에 동기화에 신경써야 하므로, 서버의 데이터를 수정하거나 읽을 경우 lock을 건다. 클라이언트로부터 받는 요청에 대한 작업은 스레드로 분리 시키며, 접속된 각 클라이언트 또한 스레드를 할당하여 멀티 스레드 환경을 구축한다.

Chat Server Structure

그냥 켜자마자 대화하는 방식은 아니고 명령어로 방을 들어가고 나가고 할 수 있으며, 현재 방 목록과 현재 내가 위치한 방 이름, 로그인과 로그아웃(닉네임으로만 간단하게) 기능을 만들어 조금 더 채팅방스럽게(?) 만들었다.

 

주요 코드

 Message Class

 클라이언트가 서버에게 보내는 요청의 종류를 구분하기 위해 enumerator로 메시지 타입을 정의했다. 그리고 패킷의 헤더를 보고 정보를 판단하는 것을 모티브하여 Message 클래스와 MessageHeader 클래스를 정의하여 Message에 MessageHeader를 담아 서버로 전송한다. Message는 Message Header와 텍스트인 buffer를 담고 있으며, Message Header는 소켓, 메시지 타입, 전송 클라이언트의 id를 담아 보낸다.

// Message.h
enum MESSAGE_TYPE
{
	eSendMessage,
	eLogin,
	eLogout,
	eDisconnected,
	eSetSocketID,
	eReadRoom,
	eEnterRoom,
	eLeaveRoom,
	eReadUser,
	eMAX
};

// include message info
class MessageHeader
{
public:
	MESSAGE_TYPE type;
	SOCKET client_socket;
	char senderID[32];

	MessageHeader() {}
	MessageHeader(const MESSAGE_TYPE _type, const SOCKET& _client_socket, const std::string& _senderID)
		: type(_type), client_socket(_client_socket)
	{
		strcpy_s(senderID, _senderID.c_str());
	}
};

// include message content
class Message
{
public:
	MessageHeader header;
	char buffer[PACKET_SIZE];

	Message() {}

	Message(const MessageHeader& _header, const std::string& _buffer)
		: header(_header)
	{
		strcpy_s(buffer, _buffer.c_str());
	}
};

 

Server Class

접속 클라이언트를 확인하는 스레드와 각 클라이언트에 할당하는 스레드를 생성한다.

int main()
{
	Server server;
	// 들어오는 클라이언트 요청 처리
	server.AddClientThread();
	// 접속 요청 수락 - 1:N
	while (1)
	{
		SOCKET client_socket = server.Accept();
		assert(client_socket >= 0);
		// 요청 받은 클라이언트 전용 recv thread 추가
		server.AddClientReceiver(client_socket);
		...
	}
	...
	return 0;
}

각 클라이언트 스레드 할당 코드는 아래와 같다.

void Server::AddClientReceiver(const SOCKET& client_socket)
{
	std::thread receiver(&Server::ProccessReceivedMessage, this, client_socket);
	receiver.detach();
}

detach는 직역하는 "떼어 놓는다"는 뜻으로, 스레드 너 혼자 알아서 돌아가라고 하는 느낌이다. 떼어 놓고 신경쓰지신경 쓰지 않는다. 쉽게 말하면 프로세스가 종료되면 해당 스레드의 작업이 끝났든 끝나지 않았든 실행을 종료시킨다. 이를 막으려면 Join함수를 사용하면 되는데, Join은 해당 스레드의 작업이 종료되면 호출되기 때문에 스레드의 안전한(?) 종료를 보장할 수 있다. 해당 서버가 종료되면 할당이고 뭐고 신경 쓰지 않아도 될 것 같아서 detach를 사용했다.

ProcessReceivedMessage함수는 아래와 같다.

void Server::ProccessReceivedMessage(const SOCKET& client_socket)
{
	while (1)
	{
		char buffer[PACKET_SIZE] = { 0, };
		int read_byte = recv(client_socket, buffer, sizeof(buffer), 0);
		assert(read_byte > 0);

		if (read_byte == 0)
		{
			MessageHeader message_header(MESSAGE_TYPE::eDisconnected, client_socket, GetId(client_socket));
			Message message(message_header, "");
			PushMessageToQ(message);
			closesocket(client_socket);
			break;
		}

		Message received_message;
		memcpy(&received_message, buffer, sizeof(received_message));
        // 여러 작업을 처리하기 위해 queue에 넣는다.
		PushMessageToQ(received_message);
	}
}

여러 클라이언트로부터 작업이 들어오면 순차적으로 진행하기 위해 server에 queue 컨테이너를 선언한다. 여기서 넣은 Message는 앞서 선언한 AddClientThread에서 생성한 스레드에 바인딩된 함수에서 pop하여 메시지를 처리한다.

void Server::ProcessReceivedClient()
{
	while (1)
	{
		if (message_q.empty()) continue;
        // 메시지를 하나씩 pop하여 처리한다.
		const Message cur_message = PopMessageFromQ();
		ProcessReceivedMessage(cur_message);
	}
}

void Server::ProcessReceivedMessage(const Message& message)
{
	const MESSAGE_TYPE message_type = message.header.type;
	// Message 타입에 따라 다르게 처리한다.
	switch (message_type)
	{
	case MESSAGE_TYPE::eSendMessage:
		BroadcastMessage(message);
		break;
	case MESSAGE_TYPE::eSetSocketID:
		SendSocketId(message);
		break;
	case MESSAGE_TYPE::eReadRoom:
		SendRoomList(message);
		break;
	...
	default:
		break;
	}
}

여기서 queue는 서버에 선언된 컨테이너이며 여러 클라이언트에서 동시에 접근할 가능성이 있으므로 lock을 적절히 걸어둬야 한다. 아래는 예시인 pop함수이다.

Message Server::PopMessageFromQ()
{
	// message_q_lock은 .h에 아래와 같이 선언된다.
    // std::shared_mutex message_q_lock;
	std::unique_lock<std::shared_mutex> lock(message_q_lock);
	Message message = message_q.front();
	message_q.pop();

	return message;
}

unique_lock은 exclusive lock이라고 생각하면된다. 값을 수정할 때 걸어두는 락이다. 읽기만 할 경우는 shared_lock을 걸면 된다.

메시지를 보내고 방 리스트를 확인하고 이러한 코드는 단순 구현이니 패스하겠다.

 

Client Class

서버와 구조가 얼추 비슷해서 간단하게 작성한다. 클라이언트도 서버에게 전송받는 스레드를 하나 할당하고 받는 메시지를 처리하는 함수를 작성한다.

void Client::AddServerReceiver()
{
	std::thread receiver(&Client::ReceiveFromServer, this);
	receiver.detach();
}

void Client::ReceiveFromServer()
{
	while (1)
	{
		Message buffer({ MessageHeader({MESSAGE_TYPE::eMAX, client_socket, ""}), "" });
		int read_byte = recv(client_socket, (char*)&buffer, sizeof(buffer), 0);
		assert(read_byte > 0);
		if (read_byte == 0)
		{
			std::cout << "read_byte is 0. Server Error\n";
			exit(0);
		}
		std::cout << buffer.header.senderID << ": " << buffer.buffer << std::endl;
	}
}

그리고 간단한 명령어를 통해 채팅을 한다. main함수에서 루프를 돌리며 입력을 기다린다. 아래는 명령어를 구분하고 실행하는 함수이다. EnterRoom 같은 경우 parser를 통해 깔끔하게 짤 수 있지만 멀티스레드가 메인이니 약간의 하드코딩으로 진행했다.

void Client::HandleUserInput(const std::string& user_input)
{
	if (user_input == "/Login") Login();
	else if (user_input == "/Logout") Logout();
	else if (user_input.substr(0, 6) == "/Enter") EnterRoom(user_input.substr(6));
	else if (user_input == "/Leave") LeaveRoom();
	else if (user_input == "/Exit") Exit();
	else if (user_input == "/RoomList") RequestRoomList();
	else if (user_input == "/UserList") RequestUserList();
	else SendMessages(user_input);
}

 

실행결과

아래는 실행 결과이다.

 

 

배운점

인턴하면서 서버단 데이터를 제어할 경우 lock사용했던 경험은 있어 어느정도 이해도가 있었다. 직접 스레드를 할당하여 멀티 스레드 환경을 구축(이라고 하기에는 부끄러운 규모이긴 하다)하는 것은 처음이라 함수 사용과 이해에 처음에는 애를 먹었지만 개념적으로는 알고 있던 내용이라 어려운 부분은 크게 없던 것 같다(하지만 단순한 블로킹 소켓으로 만들었기에 소켓 프로그래밍에 있어서는 향만 맡아본 느낌이다). 이제 채팅 서버 구현해봤냐는 질문에 "예
"를 할 수 있게 되었다.

'C++' 카테고리의 다른 글

[C++] 복사 생성자  (0) 2022.09.03