학습 내용
저번 시간에는 서버와 클라이언트의 역할을 이해하고 TCP 서버에서 어떻게 통신하는지 알아보았다. 서버와 클라이언트가 데이터를 주고 받는 실시간 채팅 프로그램을 만들기 위해 오늘은 서버와 클라이언트의 통신 과정 중 서버 코드를 구현하면서 어떤 함수를 쓰고, 각 함수가 어떻게 작동하는지 알아보자.
서버 구현 과정
서버 구현 순서를 간단히 이야기하자면, 소켓을 생성하고 bind, listen, accept, recv, send, close 등의 순서로 진행된다. 그런데 여기에서 알아두어야 할 것은, 소켓이 총 2번 생성된다는 것이다. 클라이언트의 connect 요청을 받아들이는 역할을 하는 소켓과 실제로 '통신'하는데 쓰이는 소켓이 있다. 그래서 마지막 단계에서 closesocket 함수도 각 소켓을 인자로 받아 한 번씩 2회 호출한다.
헤더 파일
#include <iostream>
#include <WinSock2.h>
#pragma comment(lib, "ws2_32")
#define PORT 포트번호
#define PACKET_SIZE 1024
코드 구현 전 추가해야 할 헤더파일에는 WinSock2.h가 있다. 소켓을 활용하기 위해 추가하며, "ws2_32"라는 lib 파일도 함께 추가해 줘야 한다. #pragma comment로 작성해주면 컴파일 시 "ws2_32"가 링크된다.
서버 구현에는 포트 번호와 버퍼의 크기도 할당해 줘야 하기 때문에 위와 같이 PORT, PACKET_SIZE라는 예약어를 활용했다.
WSADATA 구조체와 WSAStartup, WSACleanup 함수
int main()
{
WSADATA wsaData; //구조체 생성
WSAStartup(MAKEWORD(2,2), &wsaData); //초기화
...
WSACleanup(); //WSAStartup 시 설정한 데이터 지워주기
return 0;
}
지금부터 작성하는 코드는 모두 메인 함수 내부에 작성된다. 이 코드는 WSADATA 구조체 생성과 WSAStartup, WSACleanup의 사용 방법을 보여주기 위해 위와 같이 작성했다. 이 뒤부터 시작되는 bind, listen, ... 등의 코드는 모두 WSAStartup과 WSACleanup 사이에 작성된다. 그럼 WSADATA 구조체부터 어떤 역할을 하는지 알아보자.
- WSADATA
WSADATA wsaData;
WSADATA는 소켓 구현에 대한 정보를 포함하는 구조체이다. WORD 타입의 버전 정보와 char 타입의 시스템 상태 등이 포함되어 있다. 소켓 구현을 위해 가장 먼저 생성하는 구조체라고 생각하면 된다.
- WSAStartup
int WSAStartup(WORD wVersionRequired, LPWSADATA lpWSAData);
WSAStartup 함수는 WSACleanup 함수와 쌍을 이뤄 시작과 끝을 의미한다. Winsock DLL 사용을 위한 버전 정보 확인과 초기화 작업을 수행한다. 그래서 첫 번째 인자로 버전 정보가 들어가고, 두 번째 인자로 WSADATA 구조체 포인터가 들어간다. 성공할 경우 0을 반환하고, 그렇지 않으면 오류 코드를 반환한다.
첫 번째 인자의 경우 사용자가 사용 가능한 가장 높은 버전 정보를 입력하는데, 가장 흔히 쓰는 2.2 버전을 넣어준다. 데이터 타입을 맞춰주기 위해 MAKEWORD(2, 2)와 같이 작성한다. 또는 0x0202와 같이 입력해되 된다. 그리고 두 번째 인자는 소켓 구현 세부 정보를 저장하기 위해 넣어 준다. WSAStartup 함수를 실행하고 나서 WSADATA 구조체의 여러 가지 변수를 출력해보면 데이터가 저장된 것을 확인할 수 있다.
- WSACleanup
int WSAAPI WSACleanup();
WSAStartup 호출 시 DLL이 필요한데, 실행했던 DLL 파일 사용을 종료하는 함수이다. 성공할 경우 0을 반환하고, 그렇지 않으면 오류 코드를 반환한다. 참고로 멀티 스레드 환경에서는 모든 스레드에 대한 Windows 소켓 작업을 종료한다.
Socket 생성
SOCKET serSock; //소켓 핸들 생성
serSock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP); //소켓 생성
소켓 핸들에 IPv4체계의 TCP 소켓을 생성하여 저장해주는 코드이다.
- socket
SOCKET WSAAPI socket(int af, int type, int protocol);
socket 함수는 소켓을 생성하는 함수이므로 새 소켓을 참조하는 설명자를 반환한다. 그렇지 않으면 INVALID_SOCKET 값이 반환된다.
첫 번째 인자는 주소체계인데, AF_INET은 IPv4, AF_INET6은 IPv6를 의미한다. 그런데 대부분의 코드에서는 PF_INET이라는 '프로토콜 체계'를 작성해 IPv4를 할당하고 있다.
두 번째 인자는 소켓의 형식 사양이다. SOCK_STREAM은 TCP를 사용하며, UDP를 사용하고 싶다면 SOCK_DGRAM으로 작성하면 된다.
세 번째 인자는 프로토콜이다. TCP를 사용하고 싶다면 IPPROTO_TCP, UDP를 사용하고 싶다면 IPPROTO_UDP를 작성한다.
전송 주소와 포트 할당
SOCKADDR_IN serAddr = {};
serAddr.sin_family = AF_INET;
serAddr.sin_port = htons(PORT);
serAddr.sin_addr.s_addr = htonl(INADDR_ANY);
SOCKADDR_IN 구조체를 생성하여 AF_INET 주소 패밀리에 대한 전송 주소와 포트를 할당한다.
- sin_family : 항상 AF_INET으로 설정해야 한다. TCP, UDP 등의 정보를 가리키는 데이터이기 때문이다.
- sin_port : PORT 정보를 저장한다.
- sin_addr.s_addr : IP 주소를 저장한다. 직접 입력해줘도 되지만, INADDR_ANY로 입력하면 현재 사용중인 네트워크 주소를 자동 할당한다.
- htons와 htonl은 무엇일까?
네트워크 바이트 순서에는 리틀 엔디안과 빅 엔디안이 있다. 각각 데이터를 저장하는 순서가 하위값 먼저인지 상위값 먼저인지에 따라서 나누어진다. 같은 데이터를 다른 순서로 처리하면 오류가 발생하기 때문에 일관적인 규칙이 필요했고, 여기에서는 빅 엔디안으로 처리한다. 그러므로 이 함수를 사용해서 값을 저장하면 빅 엔디안으로 저장되는 것이다.
연결 요청 대기
bind(serSock, (SOCKADDR*)&serAddr, sizeof(serAddr));
listen(serSock, SOMAXCONN);
위에서 생성한 소켓에 주소 정보를 연결하고 접속 요청 대기 상태로 만드는 코드이다.
- bind
int WSAAPI bind(SOCKET s, const sockaddr *name, int namelen);
bind 함수는 생성한 소켓에 주소 정보를 연결한다. 첫 번째 인자로 위에서 생성한 소켓 정보를 넣고, 두 번째 인자로 소켓에 할당할 주소 정보를 담고 있는 구조체 포인터를 넣는다. 세 번째 인자는 name이 가리키는 값의 바이트 단위 길이를 넣는다. 정상적으로 주소 정보가 소켓에 할당 되면 0을 반환하고, 그렇지 않으면 SOCKET_ERROR를 반환한다.
- listen
int WSAAPI listen(SOCKET s, int backlog);
Client의 연결 요청을 대기하는 상태로 만드는 함수이다. 이 함수가 정상적으로 실행 되기 전에 Client의 연결 요청이 오면 오류가 발생한다. 첫 번째 인자로 소켓을 넣고, 두 번째인자로 연결 요청 대기 큐를 저장 할 최대 크기를 넣는다. SOMAXCONN으로 설정하면 적절한 최댓값을 자동으로 할당한다. accept 함수 실행 시 가장 먼저 들어온 연결 요청부터 하나씩 꺼내서 처리한다.
이 함수는 연결 요청을 기다리는 것이기 때문에 listen 함수 실행 전 client가 connect 요청을 하면 오류가 발생한다. 서버 측 코드를 클라이언트 코드보다 먼저 실행해야 하는 이유이기도 하다.
통신을 위한 소켓 생성과 연결 요청 허용
SOCKADDR_IN clntAddr = {};
int clntSize = sizeof(clntAddr);
SOCKET clntSock = accept(serSock, (SOCKADDR*)&clntAddr, &clntSize);
accept 함수 실행에 앞서 클라이언트의 주소 정보를 담기 위한 구조체 변수를 생성한다. 그리고 클라이언트와 통신 할 소켓을 생성해서 accept 함수의 결과를 저장한다.
- accept
SOCKET WSAAPI accept(SOCKET s, sockaddr *addr, int *addrlen);
클라이언트의 연결 요청을 허용하는 함수이다. 연결 요청 대기 큐에 저장된 요청을 꺼내서 처리한다. 첫 번째 인자로 서버 소켓을 넣고, 두 번째 인자로 accept 함수 성공 시 클라이언트 주소와 포트 정보가 담길 구조체 포인터를 넣어 준다. 세 번째 인자로는 구조체의 크기를 넣어 준다. 함수가 정상적으로 실행되면 세 번째 인자에는 클라이언트의 주소 정보 길이가 바이트 단위로 저장된다.
여기에서 왜 소켓이 2개나 생기냐면, 첫 번째로 생성한 소켓은 클라이언트의 connect 요청을 받아들이기 위한 것이다. 위에서는 serSock으로 이름을 지었다. 두 번째로 생성한 소켓은 클라이언트와 직접적인 데이터 통신을 하기 위한 것이다. 이 소켓이 accept의 결과를 저장한 clntSock이다. 그럼 여기에서 알 수 있는 것은 accept의 결과르 SOCKET 형식이 반환된다는 것이다.
데이터 송신과 수신
char recvMsg[PACKET_SIZE] = {};
recv(clntSock, recvMsg, PACKET_SIZE, 0);
cout << "recv : " << recvMsg << endl;
char sendMsg[] = "Send Msg";
send(clntSock,sendMsg, strlen(sendMsg), 0);
recv 함수와 send 함수로 데이터를 수신하고 송신한다. recv의 경우 통시늘 위한 소켓, 데이터 버퍼 포인터, 데이터 버퍼의 바이트 단위 크기, 옵션 등이 들어간다. 정상적으로 데이터를 수신하면 데이터 버퍼에 담긴 메세지를 출력하도록 구현한 코드이다. send 함수도 마찬가지로 소켓, 버퍼, 버퍼 크기, 옵션 순서로 인자를 담아 호출한다. 이 두 함수는 정상적으로 실행되었을 때 보내거나 받은 데이터 크기를 반환하고, 그렇지 않을 경우에는 SOCKET_ERROR를 반환한다.
- recv / send
int WSAAPI recv(SOCKET s, char *buf, int len, int flags);
int WSAAPI send(SOCKET s, const char *buf, int len, int flags);
recv 함수의 경우 첫 번째인자로 연결된 소켓을, 두 번째인자로 수신 데이터를 저장 할 버퍼 포인터를, 세 번째인자로 버퍼의 바이트 단위 크기를, 네 번째 인자로 옵션을 넣는다. send 함수의 경우에는 두 번째인자가 전송할 데이터를 포함한 버퍼 포인터가 들어간다. 정상적으로 수신/송신 되었을 경우 데이터 크기를 반환한다.
소켓 닫기
closesocket(serSock);
closesocket(clntSock);
소켓을 생성하고 모든 통신이 종료된 후에는 다시 소켓을 닫아줘야 한다. 위에서 클라이언트의 연결 요청을 받아들일 소켓과 직접적인 데이터 송수신을 할 소켓을 생성했으므로 2개의 소켓을 모두 닫아서 소켓 리소스를 반환한다.
- closesocket
int closesocket(SOCKET s);
닫을 소켓을 인자로 넣어 준다. 정상적으로 소켓이 닫히면 0을 반환하고, 그렇지 않으면 SOCKET_ERROR 값이 반환된다.
서버 구현 과정 요약
서버 측 코드를 구현할 때 먼저 클라이언트의 연결 요청을 받기 위한 소켓을 생성한다. 그리고 bind를 통해 포트 번호와 주소를 할당받고 listen으로 접속 대기 상태를 만든다. 이 때 메세지 큐의 크기를인자로 받아 크기 만큼의 접속 요청을 대기시킬 수 있다. 이렇게 클라이언트의 연결 요청을 받기 위한 대기 상태가 된다.
accept 함수가 실행되면 대기 큐에 있던 요청을 받아 주소 정보, 주소 정보 길이 등을 두 번째 인자와 세 번째 인자에 저자하고, SOCKET 형태의 값을 반환한ㄷ. 이 때 생기는 소켓이 바로 클라이언트와 데이터를 주고 받는 통신을 하는 역할이다. 그런데 이 때 대기 큐에 저장된 연결 요청이 없다면, 요청이 생길 때까지 blocking 상태로 대기한다. 다음 코드를 진행하지 않는 것이다.
클라이언트의 연결 요청을 수신하여 허용하고, connect가 완료되면 send와 recv 함수로 데이터를 주고 받은 후 closesocket 함수로 소켓 리소스를 반환한다. 이 때 연결 요청을 받아 들이기 위한 소켓과 데이터 통신을 위한 소켓 2개를 모든 close 해줘야 하는 것을 숙지하자!
'소켓프로그래밍' 카테고리의 다른 글
[C/C++] 소켓 프로그래밍 - 서버와 클라이언트 (2) (0) | 2024.06.25 |
---|---|
[C/C++] 소켓 프로그래밍 - 서버와 클라이언트 (1) (0) | 2024.06.25 |