C++ winsock2 멀티스레드(1:N) 서버 구현 

Brie·2023년 11월 7일
0

C++

목록 보기
4/9

개요

C++ 언어를 사용하여 winsock2 라이브러리를 활용해 비동기 처리 1:N 서버를 구현해보았습니다. 기존의 1:1 서버 코드에서 개선한 형태가 됩니다.

개발 환경

  • Language: C++
  • IDE: Visual Studio 2022

ChatTestHeader.h

#pragma once
#define _WINSOCK_DEPRECATED_NO_WARNINGS // winsock c4996 처리

#include <iostream>
#include <winsock2.h>
#include <thread>
#include <vector>
using namespace std;

#pragma comment(lib,"ws2_32.lib") // ws2_32.lib 라이브러리를 링크
#define PACKET_SIZE 1024 // 송수신 버퍼 사이즈 1024로 설정

include, pragma, namespace 등의 설정을 위해 Header를 분리하였다.

ChatTest_Server_th.cpp 코드에서 해당 헤더를 include하여 사용하게 된다.

ChatTest_Server_th.cpp


#include "ChatTestHeader.h"
SOCKET server_socket;

struct ClientInfo { // 클라이언트 정보 필드
	SOCKET socket;
	sockaddr_in clientAddress;
};

std::vector<HANDLE> clientThreads;
std::vector<ClientInfo> clientPool;

unsigned int __stdcall HandleClient(void* data);
void client_accept(){
}

int main() {
}

unsigned int __stdcall HandleClient(void* data) {
	
}

ChatTest_Server_th.cpp에서는 가장 먼저 ChatTestHeader.h를 include 해준다.

구조체(struct) ClientInfo는 Server에서 연결된 클라이언트들의 정보를 관리하는 구조체이다. 클라이언트의 소켓과 sockaddr_in이 멤버 변수로써 들어가게 된다.

그리고 vector 타입을 2개 선언하는데, 각각 Thread의 Handle과 ClientInfo 구조를 담기 위한 것이다.

HandleClient()는 클라이언트의 연결이 확인되면 스레드에서 실행될 스레드 함수이다.

client_accept()는 서버 소켓이 listen 상태인 경우, 스레드에서 비동기적으로 동작하면서 클라이언트가 연결 시도를 하는 경우 작업해주는 함수이다.

int main() {
	WSADATA wsa; // Windows 소켓 구현에 대한 구조체 생성

	// WSAStartup을 통해 wsadata 초기화
	// MAKEWORD(2,2) 매개 변수는 시스템에서 Winsock 버전 2.2를 요청
	// WSAStartup은 성공시 0, 실패시 SOCKET_ERROR를 리턴하므로
	// 리턴값이 0인지 검사하여 에러 여부 확인
	if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) {
		std::cout << "WSAStartup failed" << endl;
		return 1;
	}

	// 서버가 클라이언트 연결을 수신 대기할 수 있도록 소켓 생성
	server_socket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

	// 주소 패밀리, IP 주소 및 포트 번호에 대한 정보를 보유할 sockaddr 구조체 생성
	SOCKADDR_IN addr = {};
	addr.sin_family = AF_INET; // IPv4 기반의 TCP, UDP 프로토콜 사용
	addr.sin_port = htons(4444);
	addr.sin_addr.s_addr = htonl(INADDR_ANY);

	// bind 함수를 통해 생성된 소켓 및 sockaddr 구조를 매개 변수로 전달
	if (bind(server_socket, (SOCKADDR*)&addr, sizeof(addr)) == SOCKET_ERROR) {
		std::cerr << "바인딩 실패: " << WSAGetLastError() << std::endl; // 바인딩 에러 처리
		closesocket(server_socket);
		WSACleanup();
		return 1;
	}

	if (listen(server_socket, SOMAXCONN) == SOCKET_ERROR) { // 소켓에서 수신 대기
		// SOMAXCONN은 Winsock 공급자에게 큐에 있는 최대 적정 수의 보류 중인 연결을 허용하도록 지시하는 특수 상수
		std::cerr << "수신 대기 모드 설정 실패: " << WSAGetLastError() << std::endl; // listen 에러 처리
		closesocket(server_socket);
		WSACleanup();
		return 1;
	}

}

WSADATA 구조체를 생성 및 초기화 후 서버 소켓을 생성하여 listen 상태로 만드는 코드이다.

해당 코드의 설명에 대해선 이전에 작성한 글을 참조하면 될 것 같다.

void client_accept() { // 비동기적으로 동작하면서 클라이언트 연결을 수락하고 스레드로 처리하는 함수
	while (1) {
		SOCKADDR_IN client = {};
		int client_size = sizeof(client);

		// 클라이언트에서 연결을 수락하기 위해 ClientSocket이라는 임시 소켓 개체 생성
		SOCKET client_socket = accept(server_socket, (SOCKADDR*)&client, &client_size);
		if (client_socket == INVALID_SOCKET) {
			std::cerr << "클라이언트 연결 실패: " << WSAGetLastError() << std::endl;
			closesocket(client_socket);
		}

		// 연결 시 클라이언트 정보 출력
		if (!WSAGetLastError()) {
			std::cout << "연결 완료" << endl;
			std::cout << "Client IP: " << inet_ntoa(client.sin_addr) << endl;
			std::cout << "Port: " << ntohs(client.sin_port) << endl;
		}

		ClientInfo* clientData = new ClientInfo;
		clientData->socket = client_socket;
		clientData->clientAddress = client;

		// 클라이언트 처리를 위한 스레드 시작
		HANDLE hThread = (HANDLE)_beginthreadex(NULL, 0, HandleClient, clientData, 0, NULL);
		// _beginthreadex는 스레드 핸들을 반환하여 스레드의 상태를 추적하고 제어할 수 있음
		clientThreads.push_back(hThread);

		ClientInfo newClient;
		newClient.socket = client_socket;
		newClient.clientAddress = client; // 필요한 클라이언트 정보 설정

		clientPool.push_back(newClient);
	}
}

int main() {
	……

	thread client_listen(client_accept);
}

client_accept()는 스레드 안에서 동작하면서 클라이언트의 연결 요청을 수락하기 위한 함수이다.

코드를 보면 while(1)문에 의해 반복되면서 accept() 함수로 클라이언트의 요청을 수락하고 있다.

연결이 완료되면, 클라이언트의 IP 주소와 Port Number 정보를 출력한다. 그리고 ClientInfo 구조체를 동적 메모리 영역을 할당받아 생성하고 이 구조체의 socket과 clientAddress 정보를 현재 연결된 클라이언트의 정보로 갱신한다.

ClientInfo 구조체의 작성이 완료되면 _beginthreadex() 함수를 통해 새로운 스레드를 시작한다. 그리고 해당 스레드의 함수 모듈로 HandleClient, 스레드 인자로 clientData를 전달한다.

ClientInfo가 동적 메모리 영역을 할당받은 이유가 여기에 있다.

  • 스레드는 기본적으로 스택(Stack) 영역을 공유하지 않는다. 따라서 한 스레드에서 선언한 변수는 다른 스레드에서 직접 접근할 수 없다. 그렇기 때문에 스레드 간 데이터 공유를 위해 메모리를 동적으로 할당한 후, 스레드 함수에 매개변수로 전달하는 것이 필요하다.
  • 동적 할당된 메모리는 수동으로 해제해야 한다. 스레드 함수가 실행을 마치면 해당 스레드의 스택 영역에 있는 로컬 변수들은 소멸하므로, 이 스택 영역에 있는 변수를 매개변수로 전달하면 스레드 함수가 종료될 때 해당 변수는 더 이상 유효하지 않을 수 있다. 그러나 힙 영역에 할당한 메모리는 스레드 함수가 종료되어도 유효하므로, 스레드 함수에서 이 메모리를 사용할 수 있다.

_beginthreadex()함수를 시작할 때 HANDLE 타입으로 형변환하여 vector<HANDLE> clientThreads에 저장해주는데, 이는 스레드 핸들을 통해 main()에서 스레드를 관리 및 제어하기 위함이다.

그리고 ClientInfo 구조체를 하나 더 만들어 clientPool.push_back(newClient); 구문을 통해 vector에 추가하게 된다. 이는 연결된 클라이언트의 리스트를 관리하기 위함이다.

unsigned int __stdcall HandleClient(void* data) {
	ClientInfo* clientData = static_cast<ClientInfo*>(data);
	SOCKET clientSocket = clientData->socket;
	sockaddr_in clientAddr = clientData->clientAddress;
	delete clientData;

	char buffer[1024];
	int recvSize;

	// 클라이언트 메시지 수신 및 에코
	do {
		ZeroMemory(&buffer, 1024); // 버퍼 초기화
		recvSize = recv(clientSocket, buffer, sizeof(buffer), 0);
		if (recvSize > 0) {
			cout << inet_ntoa(clientAddr.sin_addr) << ":" << ntohs(clientAddr.sin_port) << "로부터 받은 메세지: [" << buffer << "]를 echo합니다." << endl;
			send(clientSocket, buffer, recvSize, 0);
		}
	} while (recvSize > 0);

	//std::cout << "클라이언트 연결 종료" << std::endl;
	for (size_t i = 0; i < clientPool.size(); ++i) {
		if (clientPool[i].socket == clientSocket) {
			// 클라이언트 정보 삭제
			cout << "클라이언트와 연결이 종료되었습니다: " << inet_ntoa(clientPool[i].clientAddress.sin_addr) << ":" << ntohs(clientPool[i].clientAddress.sin_port) << endl;
			clientPool.erase(clientPool.begin() + i);
			break;
		}
	}
	closesocket(clientSocket);
	_endthreadex(0);
	return 0;
}

스레드 함수인 HandleClient()는 클라이언트와의 통신을 처리하고, 클라이언트 정보를 관리하는 역할을 한다. 클라이언트로부터 데이터를 수신하면 이를 echo하고, 클라이언트의 연결이 종료되면 해당 클라이언트 정보를 clientPool에서 삭제한다.

ClientInfo* clientData = static_cast<ClientInfo*>(data); 구문을 보면, datavoid* 타입의 매개변수로 전달된 포인터이다. 이 포인터를 ClientInfo* 타입으로 형변환하여 clientData 변수에 저장한다.

여기서 void* 타입으로 매개 변수를 받아서 형변환을 해주는 이유는 스레드 함수가 다양한 타입의 인자를 전달받기 위한 일종의 방법이기 때문이다.

  • Windows의 _beginthreadex() 함수와 같은 스레드 생성 함수는 스레드 함수에 단일 인자만을 받을 수 있다. 이때 void* 포인터를 사용하면 다양한 타입의 데이터를 스레드 함수로 전달할 수 있다. 이 포인터를 스레드 함수 내에서 원래의 타입으로 형변환하고 해당 데이터를 사용할 수 있게 된다.

data의 형변환을 완료했다면, clientData 구조체 내의 멤버들을 스레드 함수의 로컬 변수로 저장하고 쓰임새를 다한 clientData는 동적 메모리 영역을 할당받았으므로 메모리 누수를 막기 위해 삭제한다.

do { ... } while (recvSize > 0); 구문은 루프를 수행하면서 클라이언트로부터 메시지를 받아 에코하는 부분이다. recv() 함수로 데이터를 수신하며, recvSize에 수신한 데이터의 크기를 저장한다. 만약 recvSize가 0보다 크다면, 클라이언트가 메시지를 보낸 것이므로 에코 동작을 수행한다.

for (size_t i = 0; i < clientPool.size(); ++i) { ... }구문은 클라이언트 연결 종료 메세지를 출력하고 clientPool에서 해당 클라이언트의 정보를 삭제하기 위한 부분이다. clientPool을 순회하면서 해당 스레드의 클라이언트와 동일한 정보를 가진 소켓이 있는 경우 삭제하게 된다.

이후 클라이언트와의 소켓 연결을 닫고, 스레드 함수를 종료하고 반환한다.

int main() {
	……

	char msg[PACKET_SIZE] = { 0 }; // 메세지 입력받을 버퍼 생성
	while (1) {
		ZeroMemory(&msg, PACKET_SIZE); // 버퍼 초기화하고, 입력받은 메세지를 클라이언트에게 send
		cin >> msg;
		if ((string)msg == "list") { // "list" 입력 시 서버에서 연결된 클라이언트 목록 출력
			cout << "연결된 Client IP Address 목록" << endl;
			for (ClientInfo c : clientPool) {
				cout << inet_ntoa(c.clientAddress.sin_addr) << ":" << ntohs(c.clientAddress.sin_port) << endl;
			}
		}
		else {
			for (ClientInfo c : clientPool) {
				send(c.socket, msg, strlen(msg), 0);
			}
		}
	}

	// 스레드 종료 대기
	client_listen.join();
	for (HANDLE hThread : clientThreads) {
		WaitForSingleObject(hThread, INFINITE); // 스레드가 종료될 때 까지 대기함, INFINITE는 무한 대기
		CloseHandle(hThread);
	}

	closesocket(server_socket);

	WSACleanup(); // 리소스 해제
	return 0;
}

while(1) { ... } 구문은 서버에서 메세지 입력 시 모든 클라이언트들에게 메세지를 send하는 부분이다. 단, “list”를 입력한 경우 vector<ClientInfo> clientPool 안의 값들을 통해 연결된 Client IP Address 목록을 출력한다.

for (HANDLE hThread : clientThreads) { … } 구문은 vector<HANDLE> clientThreads를 순회하면서 보유한 모든 클라이언트 관리 스레드에 대해 종료될 때 까지 대기하고 스레드 핸들을 닫고 있다.

이는 Server가 종료되기 전에 실행되고 있는 모든 클라이언트 스레드를 종료하기 위함이다. 스레드 핸들을 닫음으로써 해당 리소스를 해제하고, 스레드와 관련된 자원들을 정리한다.

전체 코드

ChatTestHeader.h

#pragma once
#define _WINSOCK_DEPRECATED_NO_WARNINGS // winsock c4996 처리

#include <iostream>
#include <winsock2.h>
#include <thread>
#include <vector>
using namespace std;

#pragma comment(lib,"ws2_32.lib") // ws2_32.lib 라이브러리를 링크
#define PACKET_SIZE 1024 // 송수신 버퍼 사이즈 1024로 설정

ChatTest_Server_th.cpp

#include "ChatTestHeader.h"
SOCKET server_socket;

struct ClientInfo { // 클라이언트 정보 필드
	SOCKET socket;
	sockaddr_in clientAddress;
};

std::vector<HANDLE> clientThreads;
std::vector<ClientInfo> clientPool;

unsigned int __stdcall HandleClient(void* data);

void client_accept() { // 비동기적으로 동작하면서 클라이언트 연결을 수락하고 스레드로 처리하는 함수
	while (1) {
		SOCKADDR_IN client = {};
		int client_size = sizeof(client);

		// 클라이언트에서 연결을 수락하기 위해 ClientSocket이라는 임시 소켓 개체 생성
		SOCKET client_socket = accept(server_socket, (SOCKADDR*)&client, &client_size);
		if (client_socket == INVALID_SOCKET) {
			std::cerr << "클라이언트 연결 실패: " << WSAGetLastError() << std::endl;
			closesocket(client_socket);
		}

		// 연결 시 클라이언트 정보 출력
		if (!WSAGetLastError()) {
			std::cout << "연결 완료" << endl;
			std::cout << "Client IP: " << inet_ntoa(client.sin_addr) << endl;
			std::cout << "Port: " << ntohs(client.sin_port) << endl;
		}

		ClientInfo* clientData = new ClientInfo;
		clientData->socket = client_socket;
		clientData->clientAddress = client;

		// 클라이언트 처리를 위한 스레드 시작
		HANDLE hThread = (HANDLE)_beginthreadex(NULL, 0, HandleClient, clientData, 0, NULL);
		// _beginthreadex는 스레드 핸들을 반환하여 스레드의 상태를 추적하고 제어할 수 있음
		clientThreads.push_back(hThread);

		ClientInfo newClient;
		newClient.socket = client_socket;
		newClient.clientAddress = client; // 필요한 클라이언트 정보 설정

		clientPool.push_back(newClient);
	}
}

int main() {
	WSADATA wsa; // Windows 소켓 구현에 대한 구조체 생성

	// WSAStartup을 통해 wsadata 초기화
	// MAKEWORD(2,2) 매개 변수는 시스템에서 Winsock 버전 2.2를 요청
	// WSAStartup은 성공시 0, 실패시 SOCKET_ERROR를 리턴하므로
	// 리턴값이 0인지 검사하여 에러 여부 확인
	if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) {
		std::cout << "WSAStartup failed" << endl;
		return 1;
	}

	// 서버가 클라이언트 연결을 수신 대기할 수 있도록 소켓 생성
	server_socket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

	// 주소 패밀리, IP 주소 및 포트 번호에 대한 정보를 보유할 sockaddr 구조체 생성
	SOCKADDR_IN addr = {};
	addr.sin_family = AF_INET; // IPv4 기반의 TCP, UDP 프로토콜 사용
	addr.sin_port = htons(4444);
	addr.sin_addr.s_addr = htonl(INADDR_ANY);

	// bind 함수를 통해 생성된 소켓 및 sockaddr 구조를 매개 변수로 전달
	if (bind(server_socket, (SOCKADDR*)&addr, sizeof(addr)) == SOCKET_ERROR) {
		std::cerr << "바인딩 실패: " << WSAGetLastError() << std::endl; // 바인딩 에러 처리
		closesocket(server_socket);
		WSACleanup();
		return 1;
	}

	if (listen(server_socket, SOMAXCONN) == SOCKET_ERROR) { // 소켓에서 수신 대기
		// SOMAXCONN은 Winsock 공급자에게 큐에 있는 최대 적정 수의 보류 중인 연결을 허용하도록 지시하는 특수 상수
		std::cerr << "수신 대기 모드 설정 실패: " << WSAGetLastError() << std::endl; // listen 에러 처리
		closesocket(server_socket);
		WSACleanup();
		return 1;
	}
	
	thread client_listen(client_accept);

	char msg[PACKET_SIZE] = { 0 }; // 메세지 입력받을 버퍼 생성
	while (1) {
		ZeroMemory(&msg, PACKET_SIZE); // 버퍼 초기화하고, 입력받은 메세지를 클라이언트에게 send
		cin >> msg;
		if ((string)msg == "list") { // "list" 입력 시 서버에서 연결된 클라이언트 목록 출력
			cout << "연결된 Client IP Address 목록" << endl;
			for (ClientInfo c : clientPool) {
				cout << inet_ntoa(c.clientAddress.sin_addr) << ":" << ntohs(c.clientAddress.sin_port) << endl;
			}
		}
		else {
			for (ClientInfo c : clientPool) {
				send(c.socket, msg, strlen(msg), 0);
			}
		}
	}

	// 스레드 종료 대기
	client_listen.join();
	for (HANDLE hThread : clientThreads) {
		WaitForSingleObject(hThread, INFINITE); // 스레드가 종료될 때 까지 대기함, INFINITE는 무한 대기
		CloseHandle(hThread);
	}

	closesocket(server_socket);

	WSACleanup(); // 리소스 해제
	return 0;
}

unsigned int __stdcall HandleClient(void* data) {
	ClientInfo* clientData = static_cast<ClientInfo*>(data);
	SOCKET clientSocket = clientData->socket;
	sockaddr_in clientAddr = clientData->clientAddress;
	delete clientData;

	char buffer[1024];
	int recvSize;

	// 클라이언트 메시지 수신 및 에코
	do {
		ZeroMemory(&buffer, 1024); // 버퍼 초기화
		recvSize = recv(clientSocket, buffer, sizeof(buffer), 0);
		if (recvSize > 0) {
			cout << inet_ntoa(clientAddr.sin_addr) << ":" << ntohs(clientAddr.sin_port) << "로부터 받은 메세지: [" << buffer << "]를 echo합니다." << endl;
			send(clientSocket, buffer, recvSize, 0);
		}
	} while (recvSize > 0);

	//std::cout << "클라이언트 연결 종료" << std::endl;
	for (size_t i = 0; i < clientPool.size(); ++i) {
		if (clientPool[i].socket == clientSocket) {
			// 클라이언트 정보 삭제
			cout << "클라이언트와 연결이 종료되었습니다: " << inet_ntoa(clientPool[i].clientAddress.sin_addr) << ":" << ntohs(clientPool[i].clientAddress.sin_port) << endl;
			clientPool.erase(clientPool.begin() + i);
			break;
		}
	}
	closesocket(clientSocket);
	_endthreadex(0);
	return 0;
}

문제점이 있는데 이 글에서 작성한 코드는 Windows API를 다수 사용하여 Windows 시스템을 기반으로 동작한다. 그런데 대다수의 서버 시스템이 Linux/Unix 기반 운영체제에서 동작한다는 것을 생각하면 그다지 효율적인 방법으로 보이지는 않는다. 서버 개발을 지향한다면 Linux/Unix에서 동작할 수 있도록 코드를 짜는게 올바른 방향이라는 것을 깨달았다. 그래도 무의미한 공부는 아니었던 것 같다.

0개의 댓글