I'm FanJae.

[C++ Server] Day 2. Thread 적용 및 Multi Chatting Room 구현 본문

Toy Project/Multi Room Cheating Server

[C++ Server] Day 2. Thread 적용 및 Multi Chatting Room 구현

FanJae 2024. 9. 25. 21:02

0.설계

 

0-1. 쓰레드 추가시 고려가 필요한 사항

 

쓰레드가 실행해야 하는 영역의 구분이 필요하다.

- 어떤 부분부터 쓰레드가 실행해야 하는지 구분해서 이를 처리해야 한다.

자원 동시 접근 방지

- 쓰레드는 서로 동시에 실행되는 것처럼 보이지만 실제로는 수 많은 Context Switching이 일어난다.

- 이 과정에서 어떤 쓰레드를 먼저 실행할지는 CPU의 스케줄링에 따라 달려있다.

- 따라서 이를 적당히 동기화(synchronization) 해주는 작업이 필요하다.

 

0-2. Multi Cheatting Room의 구현 기법

1) 과거 나의 생각

- 소켓 프로그래밍의 개념을 완전히 잘못 이해하던 시절에 짠 코드가 존재한다.

- 그 시절에는 이게 최선이라고 생각했고 내 나름대로 잘짰다고 생각했다....

void sendto_all(int *serv_sock, char *message, int size, client_list *user,struct sockaddr_in *clnt_adr,int room_number) // 모든 인원에게 메시지 전달.
{
	int senduser = target_user(user,clnt_adr);
	char temp[BUF_SIZE];
	if((strcmp(message,"0000000000exit\n") == 0) || (strcmp(message,"0000000000exit") == 0)) {
		room[room_number].clnt_cnt--;
		user[senduser-1].clnt_adr.sin_addr.s_addr = 0;
		user[senduser-1].clnt_adr.sin_port = 0;
		user[senduser-1].connect = 0;
		sprintf(temp,"0000000600System : %dp is logout\n",senduser);
		printf("log : %dp logout\n",senduser);
	}
	else
		sprintf(temp,"0000000600%dp : %s",senduser,message);
	
	for(int i = 0; i < 4; i++) {
		int clnt_adr_user_sz = sizeof(user[i].clnt_adr);

		if(user[i].connect == 1) {
			printf("Log : Ok It's Send it. ip : %s port : %d, temp : %s\n",inet_ntoa(user[i].clnt_adr.sin_addr),user[i].clnt_adr.sin_port,temp);
			sendto(*serv_sock,temp,BUF_SIZE,0,(struct sockaddr*)&user[i].clnt_adr,clnt_adr_user_sz);	
		}
	}
}

※ 이 당시의 코드는 다시봐도 알아보기가 힘들다.. 서버를 C로 짰느냐 아니냐의 문제와 별개로 상당히 난해하게 짰다.

당시 코드의 풀버전은 해당 링크를 통해서 확인할 수 있지만 굳이 권장하지 않는다.

 

※ 어쨌든 그 당시 내가 고전했던 이유는 다음과 같았다.

① Multi Room을 논리적으로 구현하는 방법 자체가 잘못되었음.

- 이것이 가장 치명적이였다. Multi Room이라는 이유로 Room 하나당 한 포트를 할당했다.

- 이 방식으로 처리하면서 메시지 처리를 하는 것 자체가 상당히 어려워졌다.

- 가장 좋은 방법은 같은 방에 있는 인원에 대한 소켓 정보를 받아두는 것으로 간단히 해결 가능하다.

② 방을 만들고 처리하는 방법이 비효율적

- ①의 연장선이다. 위와 같이 처리 했으니 방을 만드는 것이 얼마나 복잡했겠는가...

 

2) 개선 사항

C++ std::thread 활용

- 기존에 짠 코드는 C로 작성된 코드이다. 따라서 STL에서 제공하는 라이브러리는 대부분 사용할 수 없었다.

- C++에서는 이를 활용해서 작성해보려고 한다.

Multi Room을 나누는 로직 수정

- 채팅방에 입장할 때, 해당 인원의 소켓 정보만 가지고 있어도 문제되지 않는다. 이를 전제하여 구현하려고 한다.


※ 동기화는 우선 처리하지 않았다. 어느정도 로직을 확정낸 이후 동기화 처리를 하는것이 낫다고 판단했기 때문이다.

 

2. 쓰레드 추가에 따른 처리

 

2-1. 클라이언트 처리 함수 추가

#include <iostream>
#include <WinSock2.h>
#include <thread>
#include <map>
#include <set>

std::map<std::string, std::set<SOCKET>> chatRooms;

void ConnectClient(SOCKET clientSocket)
{
	char buffer[1024];
	std::string roomName;

	int recv_length = recv(clientSocket, buffer, sizeof(buffer), 0);
	if (recv_length <= 0)
	{
		closesocket(cClientSocket);
		return;
	}
	buffer[recv_length] = '\0'; // 문자열 끝.
	roomName = buffer;

	chatRooms[roomName].insert(clientSocket);

	while (true)
	{
		memset(buffer, 0, sizeof(buffer));
		recv_length = recv(clientSocket, buffer, sizeof(buffer), 0);
		if (recv_length <= 0)
		{
			break;
		}

		for (SOCKET socket : chatRooms[roomName])
		{
			if (socket != clientSocket)
			{
				send(socket, buffer, recv_length, 0);
			}
		}

	}

	chatRooms[roomName].erase(clientSocket);
	if (chatRooms[roomName].empty()) {
		chatRooms.erase(roomName);
	}

	closesocket(clientSocket);
}

- 이 코드 하나로 내가 과거에 처리해야 했던 비효율적인 작업 대부분이 해결된다.

 

2-1-1. 채팅방 입장

int recv_length = recv(clientSocket, buffer, sizeof(buffer), 0);
if (recv_length <= 0)
{
	closesocket(cClientSocket);
	return ;
}
buffer[recv_length] = '\0'; // 문자열 끝.
roomName = buffer;

chatRooms[roomName].insert(clientSocket);

- 채팅방에 소켓 정보를 담을때는 std::map을 활용하였다.

- key값은 중복되지 않기 때문에 적합한 자료형이다. Key에 해당하는 값을 찾을때 시간 복잡도도 거의 없다.

 

2-1-2. 해당 방 인원에게 메시지 전달.

while (true)
{
	memset(buffer, 0, sizeof(buffer));
	recv_length = recv(clientSocket, buffer, sizeof(buffer), 0);
	if (recv_length <= 0)
	{
		break;
	}

	for (SOCKET socket : chatRooms[roomName])
	{
		if (socket != clientSocket)
		{
			send(socket, buffer, recv_length, 0);
		}
	}
}

- 채팅방 연결에 성공했다면, 본인을 제외한 모든 연결된 인원에게 메시지를 보낸다.

※ 이 로직은 GUI 에서는 수정하고자 한다.

 

2-1-3. 채팅방 인원 제거.

chatRooms[roomName].erase(clientSocket);
if (chatRooms[roomName].empty()) {
	chatRooms.erase(roomName);
}

closesocket(clientSocket);

- 채팅방에서 빠져나온 인원은 자연스럽게 삭제하고, 만약 채팅방에 아무도 없으면 채팅방도 제거한다.

 

2-2. 서버 로직 수정

std::cout << "Server Start\n" << std::endl;
while (true)
{
	SOCKET clientSocket;
	SOCKADDR_IN clientAddr;
	int clientAddrsize = sizeof(clientAddr);

	clientSocket = accept(serverSocket, (SOCKADDR*)&clientAddr, &clientAddrsize);
	if (clientSocket == INVALID_SOCKET)
	{
		std::cerr << "accept() Error" << std::endl;
		continue;
	}
	std::thread worker(ConnectClient, clientSocket);
	worker.detach();
}

- accept 되었다면, server는 해당 소켓에 대한 thread를 생성하여 처리한다. 


3. 테스트용 클라이언트 (C++ CUI)

- 서버의 구성이 이전보다 복잡해지긴 했지만, 테스트 클라이언트는 이전에서 극히 일부 내용만 추가된다.

 

3-1. 연결 이후 처리 변경

std::string roomName;
std::cout << "참여할 채팅방 이름을 입력하세요: ";
std::getline(std::cin, roomName);
send(clientSocket, roomName.c_str(), roomName.length(), 0);

std::thread receiveThread(receiveMessages, clientSocket);
receiveThread.detach();

std::string message;
while (true)
{
    std::getline(std::cin, message);
    if (message == "/exit") 
    {
        break;
    }
    send(clientSocket, message.c_str(), message.length(), 0);
}

- 첫번째 연결에서 보내는 메시지는 채팅방 입장 관련 메시지를 보내도록 한다.

- 이후 보내는 메시지는 다른 인원에게 보내는 메시지로 처리한다.

- 이때, receiveMessages라는 함수를 쓰레드로 실행시켜, 다른 인원이 보내는 메시지를 받는건 별개로 처리한다.

 

3-2. receiveThread

void receiveMessages(SOCKET clientSocket) 
{
    char buffer[1024];
    while (true) 
    {
		
	   int recv_length = recv(clientSocket, buffer, sizeof(buffer) - 1, 0);
	   if (recv_length <= 0) {
	       std::cerr << "recv() Error" << std::endl;
	       break;
       }
       buffer[recv_length] = '\0';
       std::cout << "받은 메시지: " << buffer << std::endl;
    }
}

- 이와 같이 구성한다.


4. 테스트

- 서버를 틀고 프로그램 3개를 실행해서 2개는 fanjae라는 채팅방에 입장하였다.

- 한명은 fanjae2라는 채팅방에 입장한다. fanjae에 있는 인원끼리는 채팅이 공유된다.

- 하지만, fanjae2에 있는 인원은 공유되지 않는다.


5. TODO

1) Server

- 채팅방 기능 구현 및 동기화 처리 필요

2) Client

- C# Windows Forms을 이용한 GUI 구현 필요

Comments