C++ winsock2를 이용한 소켓 통신 구현 (Server)

Brie·2023년 11월 6일
0

C++

목록 보기
2/8

개요

해당 문서에서는 C++에서 winsock2 라이브러리를 통해 Server/Client 간 소켓 통신을 구현하는 방법에 대해 설명한다.

winsock2

Winsock2는 Windows 운영 체제에서 네트워크 프로그래밍을 수행하는 데 사용되는 API(응용 프로그램 프로그래밍 인터페이스)의 한 부분이다.

Winsock2는 "Windows 소켓"의 약어로, 네트워크 통신을 구현하고 관리하기 위한 도구를 제공한다.

Winsock2에 대한 레퍼런스는 해당 문서에서 자세하게 다루고 있다.

또한 Winsock2에서 사용할 수 있는 헤더 및 함수에 대한 정보는 해당 문서에서 확인할 수 있다.

환경 설정

개발 환경은 다음과 같은 운영체제와 IDE를 사용하였다.

  • OS: Windows 11 Home 22H2
  • IDE: Visual Studio 2022

winsock2 사용 설정하기

winsock2를 사용하는데 필요한 ws2 라이브러리는 Windows Kit나 Visual Studio에 포함되어 있다. 따라서 vcpkg나 NuGet 등을 이용해 패키지를 설치해야 할 필요는 없다.

그러나 winsock2는 표준 라이브러리에는 포함되어 있지 않기 때문에 바로 include하여 사용할 수는 없으며 추가 종속성 설정이 필요하다. 추가 종속성 설정에 대해서는 해당 문서에 작성해놓았다.

ChatTest.cpp (Server)

Server 코드를 작성하기 위해 ChatTest.cpp라는 소스 파일을 생성하였다.

#define _WINSOCK_DEPRECATED_NO_WARNINGS // winsock c4996 처리

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

#pragma comment(lib,"ws2_32.lib") // ws2_32.lib 라이브러리를 링크

헤더 파일을 include하며 namespace를 설정하고 ws2_32.lib 라이브러리에 대해 추가 종속성을 설정했다.

그리고 이후 사용할 inet_ntoa() 함수가 Deprecated된 함수이기 때문에, 프로젝트를 정상적으로 빌드할 수 있도록 #define _WINSOCK_DEPRECATED_NO_WARNINGS 처리를 해주었다.

이 부분에 대해서 좀 더 자세히 설명하자면, inet_ntoa() 함수는 더 확장된 기능을 지원하는 inet_ntop() 함수의 존재에 의해 Deprecated 처리되었다.

그런데 inet_ntoa() 함수의 사용이 더 간편한 부분이 있는데다가 scanf() 등 보안상의 위험성으로 인해 사용 금지된 함수는 아닌 관계로 해당 문서에서는 inet_ntoa() 함수를 사용하였다.

#define PACKET_SIZE 1024 // 송수신 버퍼 사이즈 1024로 설정
SOCKET server_socket, client_socket;

데이터를 수신하거나 송신할 버퍼 사이즈에 대해 1024로 define해주었다.

그리고 서버가 클라이언트 연결에 대해 수신 대기하는데 사용할 server_socket과 클라이언트와 연결을 맺는데 사용할 client_socket을 생성하였다.


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;
	}

	……

	WSACleanup();
	return 0;
}

main 함수 안에서는 가장 처음으로 WSADATA 구조체 변수, 즉 WSADATA 객체를 선언해준다. WSADATA는 Winsock2(Windows 소켓 2) 라이브러리를 초기화하고 네트워크 통신을 설정하기 위한 구조체이다.

WSAStartup 함수는 Winsock2 라이브러리를 초기화하는 데 사용된다. 이 함수는 Winsock 라이브러리를 사용하기 전에 반드시 호출해야 한다. WSAStartup 함수는 다양한 초기화 매개변수와 WSADATA 구조체를 전달하여 Winsock2를 초기화하고 초기화 정보를 WSADATA에 저장한다.

  	// 서버가 클라이언트 연결을 수신 대기할 수 있도록 소켓 생성
	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);

생성해놓았던 server_socket을 socket 함수의 반환값으로 초기화한다.

인자값들을 보면, PF_INET, SOCK_STREAM, IPPROTO_TCP 가 순서대로 들어가 있는데, 각각 네트워크 주소체계로 IPv4를 사용하고, 소켓 타입으로 Stream을 사용(TCP를 사용하는 방식)하고, TCP 프로토콜을 사용한다는 것을 의미한다.

sockaddr_in을 통해 인터넷 프로토콜을 사용하는 소켓 주소 객체를 생성한다. 그리고 해당 객체에 대해 IPv4, Port 번호를 넣어준다.

포트는 4444번을 사용하였으며, htons() 함수를 사용하여 호스트 바이트 순서(Host Byte Order)에서 네트워크 바이트 순서(Network Byte Order)로 변환하였다. 이는 네트워크 프로그래밍에 있어 서로 다른 엔디안(Endian) 시스템 간의 호환성을 유지하기 위함이다.

IPv4 주소는 INADDR_ANY를 사용하였는데 사용할 수 있는 랜카드의 IP주소 중 현재 사용 가능한 IP주소를 선택하게 된다.

	// bind 함수를 통해 생성된 소켓 및 sockaddr 구조를 매개 변수로 전달
	bind(server_socket, (SOCKADDR*)&addr, sizeof(addr));
	listen(server_socket, SOMAXCONN); // 소켓에서 수신 대기
	// SOMAXCONN은 Winsock 공급자에게 큐에 있는 최대 적정 수의 보류 중인 연결을 허용하도록 지시하는 특수 상수

	SOCKADDR_IN client = {};
	int client_size = sizeof(client);
	
	// 클라이언트에서 연결을 수락하기 위해 ClientSocket이라는 임시 소켓 개체 생성
	client_socket = accept(server_socket, (SOCKADDR*)&client, &client_size);

서버와 클라이언트를 연결하기 위해 bind 함수를 이용하여 서버 소켓에 필요한 정보를 할당해준다. 그리고 listen 함수를 사용하면 서버 소켓은 클라이언트로부터 접속 요청이 들어오는 것을 대기하게 된다.

그리고 클라이언트에서 연결을 수락하기 위해 client_socket이라는 임시 SOCKET 개체를 생성하였다. 클라이언트와 통신을 하기 위해 클라이언트 주소 정보를 담을 client_addr 객체를 생성해 그 크기와 같이 accept 함수로 넘기게 되면, 클라이언트와 연결을 진행하게 된다.

	if (!WSAGetLastError()) {
		std::cout << "연결 완료" << endl;
		std::cout << "Client IP: " << inet_ntoa(client.sin_addr) << endl;
		std::cout << "Port: " << ntohs(client.sin_port) << endl;
	}

그리고 클라이언트와 연결이 완료된 경우 연결된 클라이언트의 IP Address와 Port 번호를 출력해주는 코드를 추가하였다.

	char msg[PACKET_SIZE] = { 0 };
	while (!WSAGetLastError()) {
		ZeroMemory(&msg, PACKET_SIZE);
		cin >> msg;
		send(client_socket, msg, strlen(msg), 0);
	}

그리고 연결된 클라이언트에게 메세지를 보내기 위해, 메세지를 입력받을 버퍼인 msg[]를 생성하고, msg에 값을 입력받아 client_socket을 향해 send()함수를 사용하여 메세지를 전송한다.

send() 함수는 다음과 같이 구성되어 있다.

int WSAAPI send(
  [in] SOCKET     s,
  [in] const char *buf,
  [in] int        len,
  [in] int        flags
);

즉, send() 함수는 매개 변수로 연결된 소켓, 전송할 데이터를 포함하는 버퍼, 버퍼에 대한 데이터의 길이(바이트), 특수 옵션(플래그 값)을 사용한다. 여기서 플래그 값은 데이터가 라우팅의 대상이 되어서는 안 되도록 지정하거나, 특정 타입의 데이터만 보내도록 설정하는 옵션 등이 있는데 지금은 해당되는 사항이 없으므로 ‘0’을 사용했다.

void proc_recvs() {
	char buffer[PACKET_SIZE] = { 0 };

	while (!WSAGetLastError()) {
		ZeroMemory(&buffer, PACKET_SIZE); // 버퍼 초기화
		recv(client_socket, buffer, PACKET_SIZE, 0);
		cout << "받은 메세지: " << buffer << endl;
	}
}

int main() {
	……

	thread proc2(proc_recvs);
	char msg[PACKET_SIZE] = { 0 };
	while (!WSAGetLastError()) {
		ZeroMemory(&msg, PACKET_SIZE);
		cin >> msg;
		send(client_socket, msg, strlen(msg), 0);
	}
	proc2.join();

	……
}

클라이언트로부터 데이터를 수신할 수 있도록 proc_recvs()라는 함수를 작성하였다. 메세지가 담길 버퍼를 생성하고, recv()로 해당 버퍼에 데이터를 반환받아 출력해준다.

그리고 해당 함수를 thread를 통해 실행하여 main()의 클라이언트에게 메세지를 전송하는 구문과 동기적으로 작업이 이루어 질 수 있도록 구성하였다.

int main() {
	……

	closesocket(client_socket);
	closesocket(server_socket);

	WSACleanup();
	return 0;
}

소스의 마지막 부분에는 closesocket() 함수를 통해 각각 클라이언트와 서버의 소켓을 닫고, WSACleanup() 함수를 통해 설정된 리소스를 해제할 수 있도록 해주었다.

Full Code

#define _WINSOCK_DEPRECATED_NO_WARNINGS // winsock c4996 처리

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

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

void proc_recvs() {
	char buffer[PACKET_SIZE] = { 0 };

	while (!WSAGetLastError()) {
		ZeroMemory(&buffer, PACKET_SIZE); // 버퍼 초기화
		recv(client_socket, buffer, PACKET_SIZE, 0);
		cout << "받은 메세지: " << buffer << endl;
	}
}

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 구조를 매개 변수로 전달
	bind(server_socket, (SOCKADDR*)&addr, sizeof(addr));
	listen(server_socket, SOMAXCONN); // 소켓에서 수신 대기
	// SOMAXCONN은 Winsock 공급자에게 큐에 있는 최대 적정 수의 보류 중인 연결을 허용하도록 지시하는 특수 상수

	SOCKADDR_IN client = {};
	int client_size = sizeof(client);
	//ZeroMemory(&client, client_size);

	// 클라이언트에서 연결을 수락하기 위해 ClientSocket이라는 임시 소켓 개체 생성
	client_socket = accept(server_socket, (SOCKADDR*)&client, &client_size);

	// 연결 시 클라이언트 정보 출력
	if (!WSAGetLastError()) {
		std::cout << "연결 완료" << endl;
		std::cout << "Client IP: " << inet_ntoa(client.sin_addr) << endl;
		std::cout << "Port: " << ntohs(client.sin_port) << endl;
	}
	
	// client로부터 메세지를 수신하는 함수를 thread에 등록
	thread proc(proc_recvs);

	char msg[PACKET_SIZE] = { 0 }; // 메세지 입력받을 버퍼 생성
	while (!WSAGetLastError()) {
		ZeroMemory(&msg, PACKET_SIZE); // 버퍼 초기화하고, 입력받은 메세지를 클라이언트에게 send
		cin >> msg;
		send(client_socket, msg, strlen(msg), 0);
	}

	proc.join(); // 실행중인 thread의 작업 완료될때까지 대기

	closesocket(client_socket); // client, server socket 닫기
	closesocket(server_socket);

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

문서가 길어져 Server와 Client를 분리합니다.

0개의 댓글