본문 바로가기
CS 기초/Network

Winsock2 라이브러리를 이용하여 간단한 TCP/IP 서버와 클라이언트를 구현해보자 | C++

by woohyeon 2020. 5. 4.
반응형

참고

네트워크에 대해 처음 접하거나 소켓을 모른다면  이 포스팅을 보고오는 것을 추천


[Winsock이란?]
Winsock은 Windows Sockets API(WSA)라는 Windows API이다. 인터넷 네트워크 및 소켓과 관련된 함수들을 제공해준다. Winsock은 버전1과 버전2가 있으며 Winsock1에 여러 기능이 좀 더 추가된 것이 Winsock2라고 한다. 여기선 버전 2를 사용한다.

[서버와 클라이언트의 기본 로직]
코드로 구현하기 전에 서버와 클라이언트의 각 로직을 알면 더 구현하기 쉽다. 참고로 UDP가 아닌 TCP/IP 프로토콜을 사용한다.

우선 서버측의 로직 먼저 살펴보자. 서버는 클라이언트의 연결을 받기 위해 크게 다음과 같은 과정을 거친다.

  1. Winsock을 사용할 수 있도록 초기화(Initialize)한다. 
  2. 리스닝 소켓을 생성(create)한다. 
  3. IP 주소와 PORT 번호와 같은 정보를 소켓에 묶어(bind)준다.
  4. 서버가 클라이언트의 요청을 받을 수 있는 상태(listen)가 되도록 한다.
  5. 클라이언트의 요청이 들어오면 받는다(accept).
  6. 해당 서버에선 1개의 클라이언트만 받을 것이기 때문에 현재 listening 중인 서버측 소켓을 닫는다(close).
    => 모든 통신에 적용되는 단계는 아님
  7. send/recv와 같은 함수를 통해 클라이언트와 데이터를 주고 받는다. (Do something loop)
  8. 7번 동작이 끝나면, 클라이언트와 연결된 소켓을 닫는다(close).
  9. Winsock의 사용을 종료(cleanup, terminate)한다. (1번과 대칭되는 단계)
    => terminate라고도 한다. 

 

 

다음은 클라이언트 측의 로직이다.

  1. Winsock을 초기화(Initialize)한다. 
  2. 소켓을 생성(create)한다.
  3. 소켓, IP주소, PORT 번호와 함께 서버에 연결(connect)한다.
    => 연결을 제외하면 서버의 3번의 bind와 비슷한 기능 소켓과 IP주소, PORT를 묶어줌
  4. send/recv와 같은 함수를 통해 서버와 데이터를 주고 받는다. (Do something)
  5. 클라이언트의 소켓을 닫는다.
  6. Winsock의 사용을 종료(cleanup)한다. 

 

서버와 클라이언트의 로직을 그림으로 표현하면 다음과 같다.

서버와 클라이언트

위에서 설명한 단계와 모두 같진 않지만 위 그림은 핵심적인 단계만 그렸다.

[서버와 클라이언트 1:1 채팅 미리보기]

왼쪽: 클라이언트, 오른쪽: 서버

 : 서버와 연결된 후 클라이언트 측에서 메세지를 보내면 서버측에서 받고 출력한다. 그리고 다시 메세지를 클라이언트에게 보내면, 클라이언트 측에서 받고 서버에서 온 메세지로 표시(SERVER>)하여 출력한다. 

[서버 코드]
단계별로 나누어 첨부. 

첫 코드는 main함수 외의 전처리 부분. WS2tcpip.h라는 헤더를 선언하며, winsock2와 비슷한 헤더인데 몇가지 기능때문에 WS2tcpip를 사용.

/*
	Server
*/

#include <iostream>
#include <WS2tcpip.h>

// Winsock을 사용하는 애플리케이션은 ws2_32.lib와 연결(link)해주어야 합니다.
#pragma comment (lib, "ws2_32.lib")

using std::cout; using std::cerr;
using std::endl;

enum ePort { SERVER_PORT = 54000 };

#pragma 라인에 대한 설명은 다음과 같다.

Ensure that the build environment links to the Winsock Library file Ws2_32.lib. Applications that use Winsock must be linked with the Ws2_32.lib library file. The #pragma comment indicates to the linker that the Ws2_32.lib file is needed.

 

// 여기부터 main()

1. Winsock 동적 라이브러리(dll)를 활성화(초기화)합니다.

WSAStartup(): Windows에서 사용하는 소켓 라이브러리(winsock)는 동적 링크 라이브러리(DLL)를 필요로 하는 기능이 많습니다. 해당 dll을 사용하기 위해선 초기화가 필요하며 해당 함수를 통해 수행합니다. 종료 시 마무리 작업도 해주어야 합니다. 접두사 WSA는 Windows Sockets API를 뜻함. 첫 번째 인자로 버전(version)을 받고, 두 번째 인자로 윈도우 소켓 구현과 관련된 정보를 저장하는 구조체를 받습니다. 첫 번째 인자는 WORD(2byte) 타입으로 하위 바이트에 주 버전 번호, 상위 바이트에 부 버전 번호가 저장됩니다. 현재 Winsock의 최신 버전은 2.2입니다. 한편, 초기화된 정보는 두 번째 인자에 저장됩니다. 성공 시 0을 반환하며, 실패하면 여러가지 값을 반환합니다.

WSADATA wsaData;
int iniResult = WSAStartup(MAKEWORD(2, 2), &wsaData);
if (iniResult != 0)
{
	cerr << "Can't Initialize winsock! Quitiing" << endl;
	return -1;
}

MAKEWORD(lowbyte, highbyte) -> VersionRequested: for WSAStartup function 

위 매크로는 아래와 같은 데이터를 생성합니다.

 


2. 리스닝 소켓을 생성합니다. 

SOCKET listeningSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); // 3번 째 인자 0도 OK
if (listeningSock == INVALID_SOCKET)
{
	cerr << "Can't create a socket! Quitting" << endl;
	WSACleanup();
	return -1;
}

: TCP 통신은 상대 호스트와 1:1로 연결이 된 상태에서 데이터를 주고 받아야 합니다. 1:1 연결의 이론은 유명한 3-way handshaking입니다. 이는 상대방과 통신을 하기 위해 초기에 상대방의 소켓과 내 자신의 소켓 사이의 길을 뚫어 주는 것을 말합니다. 그리고 길을 뚫어 주는 역할을 리스닝 소켓이 합니다. 리스닝 소켓은 상대방과 연결될 소켓이 아니며, 오직 길을 뚫어주는 역할을 하는 제 3자의 소켓입니다. 따라서 데이터를 전송할 때 사용되는 소켓은 리스닝 소켓과 전혀 관계가 없으며, 애초에 리스닝 소켓은 어디와도 연결된 소켓이 아니기 때문에 데이터를 보낼 수 없습니다.   

socket(): 소켓을 생성. 실패 시 INVALID_SOCKET를 반환

- 매개변수

  • af(address family) 
    => AF_INET: IPv4를 사용, IPv6는 AF_INET6
  •  type 
    => SOCK_STREAM: 연결지향의 TCP/IP 프로토콜 사용, UDP/IP는 SOCK_DGRAM (데이터그램) 
  • protocol 
    => IPPROTO_TCP: TCP 프로토콜을 사용, UDP는 IPPROTO_UDP, 0으로 설정 시 자동으로 알맞게 설정 

 

3. 소켓에 IP 주소와 Port 번호를 바인딩합니다.

sockaddr_in hint {}; // 기본 초기화 권장
hint.sin_family = AF_INET;
hint.sin_addr.S_un.S_addr = htonl(INADDR_ANY);
hint.sin_port = htons(SERVER_PORT);

int bindResult = bind(listeningSock, reinterpret_cast<sockaddr*>(&hint), sizeof(hint));
if (bindResult == SOCKET_ERROR)
{
	cerr << "Can't bind a socket! Quitting" << endl;
	closesocket(listeningSock);
	WSACleanup();
	return -1;
}

: 운영체제에 어떤 소켓이 특정 주소와 전송 계층 포트를 쓰겠다고 알려주는 절차를 통틀어 바인딩이라고 합니다. 하나의 시스템에는 네트워크를 사용하는 여러 개의 프로세스가 존재할 수 있습니다. 만약 상대 호스트가 내 서버로 연결 요청을 합니다. 운영체제는 상대방의 연결 요청을 받아 여러 개의 프로세스 중 적절한 프로세스로 전달해야 합니다. 이때 어떤 프로세스로 전달할지 구분하기 위해 필요한 식별자가 포트 번호입니다. 각 프로세스는 당연하게도 중복된 포트 번호를 사용할 수 없습니다. 바인딩은 인자로 받은 소켓과 주소 및 포트를 한 쌍으로 묶어 줍니다. 이 의미는 운영체제가 받은 연결 요청에 적힌 포트가 바인딩에 사용된 포트와 동일하다면 해당 소켓으로 요청을 전달하겠다는 의미입니다. 하지만 2번에도 설명했듯이 이 리스닝 소켓은 상대 호스트와 1:1로 연결된 소켓이 아닙니다. 바인딩이라는 과정을 통해 리스닝 소켓은 이제 상대 호스트가 보낸 연결 요청을 받을 수 있는 상태가 된 것 뿐입니다. 실질적으로 1:1 연결이 될 소켓은 잠시 후 살펴볼 listen과 accept를 거쳐 생성됩니다.

bind(): 소켓에 <IP주소 및 포트 번호>를 바인딩합니다. 첫 번째 인자로 바인딩 될 소켓을 받습니다. 두 번째 인자는 소켓에 바인딩 할 <IP 주소 및 포트 번호>를 sockaddr 타입의 주소로 받습니다. 여기에 저장되는 IP 주소는 이 소켓을 통해 패킷이 외부로 나갈 때 패킷에 적히는 발신자 주소를 의미합니다. 하나의 호스트로부터 발송되는 패킷이지만, 하나의 호스트에 NIC가 여러개 존재할 수 있기 때문에 발신자 주소를 지정할 수 있습니다. 그러나 보통 INADDR_ANY 매크로 값(0.0.0.0과 동일)을 이용해도 충분합니다.

세 번째 인자는 두 번째 인자로 넘긴 <sockaddr 타입의 크기>를 인자로 받습니다. sockaddr 구조체에 IP 주소와 port번호를 직접 저장하는 것이 복잡하기 때문에, 보다 쉽게 저장이 가능한 sockaddr_in 구조체에 저장 후 sockaddr 타입으로 캐스팅하여 전달합니다. 이러한 구조체 변수는 모든 멤버를 사용하지 않을 수 있습니다. 만약 초기화를 하지 않으면 사용하지 않는 값은 쓰레기 값을 가지게 되는데, 이 경우 다른 플랫폼에서 값이 이상하게 쓰이는 일이 발생할 수 있습니다. 따라서 초기화를 통해 모든 멤버의 값을 기본 값(대부분 0)으로 만들어 놓는 것이 좋습니다.

만약 이미 사용 중인 포트에 바인딩을 시도한다면 함수는 실패하고 에러를 반환합니다. 이를 위해 포트를 0으로 지정하면 라이브러리가 알아서 사용 중이지 않은 포트를 골라 바인딩해 줍니다. 이때 사용되는 포트는 동적 포트(dynamic port)로 [49152, 65535] 범위 내에서 할당됩니다. 만약 특정한 포트를 지정하고 싶다면 [1024, 49151] 범위 내에서 사용해야 합니다. 서버는 보통 고정된 포트 번호를 사용해야 하므로 직접 지정해 주는 것이 좋습니다.

* 소켓에 IP 주소와 포트 번호를 바인딩 하는 것은 다음과 같은 의미를 가집니다. 우선 패킷엔 <발신지 주소 및 포트>와 <목적지 주소 및 포트> 정보가 포함되어 있다는 것을 알아야 합니다.

  • 첫 번째 의미: 만약 수신한 패킷의 <목적지 주소 및 포트>가 바인딩에 사용 된 <IP 주소 및 포트>와 일치한다면 운영체제는 해당 패킷을 바인딩 된 소켓으로 넘겨줍니다.
  • 두 번째 의미: (바인딩 된 소켓을 통해) 패킷을 다른 호스트에게 전송하게 되면, 그 패킷의 <발신지 주소 및 포트>는 이 소켓에 바인딩 되었던 <IP 주소 및 포트>로 기록됩니다. 


- sockaddr_in 구조체

  1.  sin_family: 소켓 생성 시 인자로 주었던 af를 전달합니다. (AF_INET)
  2.  sin_addr.S_un.S_addr: IP 주소 ( INADDR_ANY: 모든 NIC의 IP 주소에 바인딩합니다. )
  3.  sin_port: Port 번호 (클라이언트가 접속하게 될 포트번호) 

    htonl: host to network long
    htons: host to network short 
    => 필요한 바이트 순서는 big endian인데, 전달되는 데이터가 little endian일 수 있습니다. 
    이를 위해 해당 함수를 사용하면 big endian으로 변경됩니다.

 

4. 소켓이 연결을 accept 할 수 있는 상태가 되도록 합니다. (listen)

int listenResult = listen(listeningSock, SOMAXCONN);
if (listenResult == SOCKET_ERROR)
{
	cerr << "Can't listen a socket! Quitting" << endl;
	closesocket(listeningSock);
	WSACleanup();
	return -1;
}

: listen() 함수를 통해 리스닝 상태에 들어간 소켓은 외부에서 들어오는 클라이언트 요청을 받을 수 있게 됩니다. 커널(kernel)은 리스닝 소켓을 통해 들어오는 클라이언트의 요청을 connection queue에 저장합니다. 두 번째 인자는 connection queue에 대기할 수 있는 클라이언트 요청의 수입니다. 특별한 제약이 없다면, SOMAXCONN으로 채웁니다.

 

5. 클라이언트의 연결 요청이 들어오면 accept 함수를 통해 연결을 수락합니다.

sockaddr_in clientSockInfo;
int clientSize = sizeof(clientSockInfo);

// connection queue의 가장 앞에 있는 클라이언트 요청을 accept하고, client 소켓을 반환합니다.
SOCKET clientSocket = accept(listeningSock, reinterpret_cast<sockaddr*>(&clientSockInfo), &clientSize);
if (clientSocket == INVALID_SOCKET)
{
	cerr << "Can't accept a socket! Quitting" << endl;
	closesocket(listeningSock);
	WSACleanup();
	return -1;
}

: accept의 첫 번째 인자는 리스닝 모드의 소켓입니다. 이 소켓을 통해 들어오는 클라이언트의 요청을 accept 합니다. accept()가 성공하면 두 번째 인자에 클라이언트의 데이터가 저장됩니다. 그리고 내부적으로 클라이언트와의 통신에 사용할 수 있는 소켓을 만들고 반환합니다. 앞으로 해당 클라이언트와의 통신은 반환된 소켓을 통해서만 해야 합니다. 리스닝 소켓의 역할은 여기까지이며, 더 이상 다른 클라이언트와의 연결을 원하지 않으면 6번과 같이 리스닝 소켓을 close 해 줍니다. 물론 리스닝 소켓을 닫아도 이미 연결된 클라이언트와는 반환된 소켓을 이용하여 통신이 가능합니다. 만약 다른 클라이언트의 요청도 계속해서 받고 싶을 경우 listen과 bind는 반복할 필요가 없으며, accept()를 호출하여 대기하면 됩니다.

만약 리스닝 소켓으로 들어온 클라이언트 요청이 없을 경우 accept를 호출한 스레드는 block 됩니다. 즉 클라이언트의 요청이 들어오거나 시간이 초과될 때까지 스레드는 더 이상 코드를 진행하지 않으니 참고하세요.  

 

6. 연결이 되었으면 listening 중인 소켓을 닫습니다. (계속해서 다른 클라이언트의 연결을 받는다면 해당 단계를 생략합니다.)

// close listening socket
int closeResult = closesocket(listeningSock);

 

7. 클라이언트의 요청을 받고 수행할 동작을 구현합니다.

char host[NI_MAXHOST];	         // 클라이언트의 host 이름
char service[NI_MAXHOST];        // 클라이언트의 PORT 번호
ZeroMemory(host, NI_MAXHOST);    // memset(host, 0, NI_MAXHOST)와 동일
ZeroMemory(service, NI_MAXHOST);

// clientSockInfo에 저장된 IP 주소를 통해 도메인 정보를 얻습니다. host 이름은 host에, 포트 번호는 service에 저장됩니다.
// getnameinfo()는 성공 시 0을 반환합니다. 실패 시 0이 아닌 값을 반환합니다.
if (getnameinfo((sockaddr*)&clientSockInfo, sizeof(clientSockInfo), host, NI_MAXHOST, service, NI_MAXSERV, 0) == 0)
{
	cout << host << " connected ON port " << service << endl;
}
else
{
	inet_ntop(AF_INET, &clientSockInfo.sin_addr, host, NI_MAXHOST);
	cout << host << " connected on port " << ntohs(clientSockInfo.sin_port) << endl;
}


// While loop: 클라이언트의 메세지를 받아서 출력 후 클라이언트에 다시 보냅니다.
enum eBufSize { BUF_SIZE = 4096 };
char buf[BUF_SIZE];

while (true)
{
	ZeroMemory(buf, BUF_SIZE);

	// Wait for client to send data
    // 메세지를 성공적으로 받으면 recv 함수는 메세지의 크기를 반환한다.
	int bytesReceived = recv(clientSocket, buf, BUF_SIZE, 0);
	if (bytesReceived == SOCKET_ERROR)
	{
		cerr << "Error in recv(). Quitting" << endl;
		break;
	}

	if (bytesReceived == 0)
	{
		cout << "Client disconnected " << endl;
		break;
	}

	// Echo message back to client
	cout << buf << endl;
	send(clientSocket, buf, bytesReceived + 1, 0);
}

recv() 함수는 클라이언트로부터 받을 데이터가 없는 경우 블로킹되므로 참고하세요. host,service, getnameinfo와 같은 것들은 서버에 연결 시 호스트 이름과 포트 번호를 표시하지 않을 것이라면 필요 없습니다.

 

8. 클라이언트 소켓을 닫고 Winsock을 종료합니다.

// Close the client socket
closesocket(clientSocket);

// Cleanup winsock <-> WSAStartup
WSACleanup();

 

[클라이언트 코드]
서버와 비슷하므로 풀코드로 첨부.

/*
	Client
*/

#include <iostream>
#include <string>
#include <WS2tcpip.h>

#pragma comment(lib, "ws2_32.lib")

int main()
{
	/*
		서버의 IP주소와 PORT번호를 저장합니다.
	*/
	const char* serverIpAddr = "127.0.0.1";
	enum ePort { PORT = 54000 };

	/* 
		1. WinSock을 초기화합니다.
		: WSAStartup() 함수 사용
	*/
	WSADATA wsData;
	int wsResult = WSAStartup(MAKEWORD(2, 2), &wsData);
	if (wsResult != 0)
	{
		std::cerr << "Can't start Winsock, Err #" << wsResult << std::endl;
		return -1;
	}

	/*
		2. 소켓을 생성합니다.
		: socket() 함수 사용
	*/
	SOCKET sock = socket(AF_INET, SOCK_STREAM, 0);
	if (sock == INVALID_SOCKET)
	{
		std::cerr << "Can't create socket, Err #" << WSAGetLastError() << std::endl;
		WSACleanup();
		return -1;
	}

	/* 
		3. sockaddr_in 구조체 변수 생성
		
		- inet_pton(): char* 타입의 IP 주소를 바이트 타입으로 변환하여 3번째 인자에 저장합니다.
	*/
	sockaddr_in hint;
	hint.sin_family = AF_INET;
	hint.sin_port = htons(PORT);
	int convResult = inet_pton(AF_INET, serverIpAddr, &hint.sin_addr);
	if (convResult != 1)
	{
		std::cerr << "Can't convert IP address, Err #" << convResult << std::endl;
		WSACleanup();
		return -1;
	}

	/*
		4. 서버에 연결(server의 bind와 비슷한 개념)
		: connect() 함수 사용, 소켓에 IP주소와 PORT번호와 같은 정보를 추가합니다.
	*/
	int connResult = connect(sock, reinterpret_cast<sockaddr*>(&hint), sizeof(hint));
	if (connResult == SOCKET_ERROR)
	{
		std::cerr << "Can't connect to server, Err #" << WSAGetLastError() << std::endl;
		closesocket(sock);
		WSACleanup();
		return -1;
	}

	/*
		서버에 연결 후 Do somthing 
	*/

	enum eBufSize { BUF_SIZE = 4096 };
	char buf[BUF_SIZE];
	std::string userMsg;

	// 유저의 메세지 입력을 반복하여 처리
	do
	{
		std::cout << "> ";
		std::getline(std::cin, userMsg);

		if (userMsg.size() > 0)
		{
			// 입력한 메세지를 서버에 전송합니다.
			int sendResult = send(sock, userMsg.c_str(), userMsg.size() + 1, 0);
			if (sendResult != SOCKET_ERROR)
			{
				// 서버로부터 전송된 메세지를 receive 합니다. (recv())
				ZeroMemory(buf, BUF_SIZE);
				int bytesReceived = recv(sock, buf, BUF_SIZE, 0);
				if (bytesReceived > 0)
				{
					// 서버로부터 받은 메세지를 출력합니다.
					std::cout << "SERVER> " << buf << std::endl;
				}
			}
		}

	} while (userMsg.size() > 0);

	// 소켓을 닫고 종료합니다.
	closesocket(sock);
	WSACleanup();

	return 0;
}

: send 함수로 문자열을 전송할 때 주의할 점이 두 가지가 있습니다. 우선 세 번째 인자인 길이를 신경써 주어야 합니다. 예를 들어 "abcd"라는 문자열을 보낼 때 길이를 4로 적게 되면 정확히 "abcd"만 전달됩니다. 이를 recv로 받아보면 이상한 문자열이 출력될 것입니다. 그 이유는 문자열의 끝을 표시해 주는 널('\0')문자가 제외되었기 때문입니다. 따라서 "abcd\0"을 전달해 주기 위해 널문자까지 생각해서 길이를 정해야 합니다.  

그리고 두 번째로 주의할 점은 send 함수는 성공 시 전송한 문자열의 길이를 반환하는데, send 함수가 양수를 반환했다고 항상 데이터 전송 완료로 생각하면 안됩니다. TCP는 별개의 전송용 버퍼를 관리하는데, send 함수가 전송한 메세지는 전송용 버퍼에 등록되고, 소켓 라이브러리가 적당한 시기에 전송합니다. 따라서 send 함수의 반환값이 양수라면 전송용 버퍼에 등록되었다 정도로 이해하는 것이 좋습니다. 이와 관련된 내용은 네이글 알고리즘에 대해 검색해 보세요.

혹은

soonsin.com/82

또는

docs.microsoft.com/en-us/windows/win32/api/winsock/nf-winsock-setsockopt

위 링크에서 TCP_NODELAY 옵션 검색




댓글