네트워크 프로그래밍을 공부하고 서버 엔진 구조를 짜보면서 공부한 필수 내용들을 정리해보았다.
물론 이 문서는 Windows 기반으로 winsock 라이브러리를 이용하여 진행되었다.
소켓 통신
소켓통신에 앞서, 소켓(Socket)이란 프로세스(어플리케이션 계층)에서 네트워크 프로그래밍을 사용하기 위한 인터페이스 정도라고 생각하면 된다. 흔히들 TCP, UDP라고 하는 전송 계층(Transport Layer)과 프로그래머가 개발하고자하는 응용 계층(Application Layer) 사이의 인터페이스로 소켓을 이용하면 간단하게 네트워크 프로그래밍을 할 수 있다.
TCP vs UDP
위에서 말했듯이 전송 계층의 TCP와 UDP 프로토콜에 대해 간단하게 정리해보면
TCP
- 연결 지향 방식으로 패킷을 교환 - 논리적인 경로로 연결을 함(3 way hadnshake + 4 way handshake)
- 전송 순서가 보장된다
- 패킷이 분실되거나 실패하면 다시 전송해줌
- 흐름/혼잡제어
- 고려할 것이 많기 때문에 상대적으로 속도가 떨어짐
UDP
- 연결이라는 개념이 없다
- 전송순서가 보장되지 않고 분실에 대한 책임을 지지 않음
- 일단 보내고 생각함
- 단순하기 때문에 속도가 상대적으로 빠름
- 데이터 경계(boundary)가 있음
- 데이터 경계가 있다는 것은 데이터를 보낼 때 서버에서 전송한 데이터의 크기 만큼만을 보내준다는 뜻.
- TCP는 데이터를 정해진 크기 없이 받을 수 있지만 UDP는 반드시 보내준 크기만큼의 데이터를 받아야 한다. 즉 TCP에서는 데이터를 보낸 횟수 ≠ 받은 횟수 이지만 UDP에서는 보낸 횟수 = 받은 횟수이다.
ex) “HELLO”, “WORLD”를 보낸다고 하면, TCP에서는 “HELLOWO”, “LD” 이런식으로 데이터를 받을 수있지만, UDP에서는 반드시 “WORLD”, “HELLO”의 경계를 가진 데이터를 받게 된다. 물론 순서는 보장되지 않는다.
위와 같은 특징때문에 TCP는 순서 + 데이터의 전송이 확실해야 하는 부분에 사용한다. 따라서 우리가 사용하는 대부분의 웹서비스는 TCP로 만들어진다. 반대로 UDP는 주로 FPS 게임에서 총알의 위치나 동영상 스트리밍과 같이 데이터 전송이 빠르고 일부 데이터가 소실되고 상관없는 경우에 사용하게 된다.
만약 MMORPG 서버를 개발한다고 하면 MMORPG에서는 속도보다는 많은 사람들의 데이터를 정확하게 받는 것이 더 중요하므로 TCP를 사용하는 경우가 많을 것 같다. 하지만 속도가 중요한 부분에서는 UDP를 사용해야 하는데 UDP를 사용하려면 결국 보정처리 + 어플리케이션 단에서 분실에 대한 처리를 반드시 해주어야 안정적인 서비스를 만들 수 있을 것이다.
쉽게 생각하면 TCP는 정석적인 반면 UDP는 그저 상남자다..
소켓 프로그래밍
소켓을 이용하여 네트워크 프로그래밍을 해보자.
TCP 흐름
- 서버 : 소켓 생성(socket) → 주소/포트 번호 설정(bind) → 바인딩된 포트를 열고 클라이언트 대기(listen) → 클라이언트 접속 허용 + 클라이언트 소켓 확인(accept) → 클라이언트와 통신(recv, send)
- 클라이언트 : 소켓 생성(socket) → 서버에 연결 요청(connect) → 서버와 통신(recv, send)
소켓을 생성한뒤, 클라이언트와 연결이 되면 해당 소켓을 통해서만 클라이언트와 통신하게 된다.
UDP 흐름
- 서버 : 소켓 생성(socket) → 주소/포트 설정(bind) → 클라와 통신(recvfrom, sendto)
- 클라이언트 : 소켓 생성(socket) → 서버와 통신(recv, sendto)
소켓을 생성한뒤, 상남자 식으로 한명의 클라이언트가 아닌 해당 소켓에 보낸 모든 클라이언트들과 통신할 수 있다.
UDP 서버 예제
간단하게 클라이언트로 부터 데이터를 받고, 받은 데이터를 서버에서 그대로 뿌려주는 에코서버 방식으로 만들어 보았다.
서버 예제
WSADATA wsaData; // 소켓에 대한 데이터 구조체
if (::WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) // 운영체제에 소켓통신을 시작할 것을 알림
return 0;
// 1) 소켓 생성
SOCKET listenSocket = ::socket(AF_INET, SOCK_DGRAM, 0);
// 2) 주소/포트 번호 설정(bind)
SOCKADDR_IN serverAddr;
::memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = ::htonl(INADDR_ANY);
serverAddr.sin_port = ::htons(7777);
if (::bind(listenSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
return 0;
// recvform, sendto
while (true)
{
// RECV
while (true)
{
char recvBuffer[200];
SOCKADDR_IN clientAddr;
int clientSize = sizeof(clientAddr);
int recvLen = ::recvfrom(listenSocket, recvBuffer, sizeof(recvBuffer), 0, (sockaddr*)&clientAddr, &clientSize);
if (recvLen == SOCKET_ERROR)
{
exit(0);
}
cout << "recv Data Len = " << recvLen << endl;
cout << "recv Data = " << recvBuffer << endl;
::this_thread::sleep_for(3s);
}
}
클라이언트 예제
WSADATA wsaData;
if (::WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
return 0;
// 1) 소켓 생성
SOCKET clientSocket = ::socket(AF_INET, SOCK_DGRAM, 0);
if (clientSocket == INVALID_SOCKET) return 0;
// 2) 연결하고자 하는 서버에 연결 (connect)
SOCKADDR_IN serverAddr;
::memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
::inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr);
serverAddr.sin_port = ::htons(7777);
// sendto
while (true) // connect - 실패할수도있으므로 while문으로 묶어줌
{
char sendBuffer[50] = "Hello world!";
int sendSize = sendto(clientSocket, sendBuffer, sizeof(sendBuffer), 0,
(struct sockaddr*)&serverAddr, sizeof(serverAddr));
// 패킷송신시 에러처리
if (sendSize != sizeof(sendBuffer))
{
cout << "sendto() error!" << endl;
exit(0);
}
this_thread::sleep_for(1s);
}
::closesocket(clientSocket);
::WSACleanup();
여기서 UDP에는 데이터의 경계가 있다는 것을 알 수 있는데, 해당 코드에서 client는 1초마다 50byte의 데이터를 보내고 있고, server는 이를 3초마다 체크하고 있다. 만약 데이터의 경계가 없다면 이상적인 상황에서는 client가 1초 틱을 3번을 돌때, server는 3초 틱을 1번 돌게 될 것이다.
이렇게 되면 클라이언트 측에서는 총 150byte의 데이터가 오게 되고 200byte의 버퍼를 가지고 있는 서버측에서는 한번에 이 150byte의 데이터를 받을 수도 있다. 하지만 구동해보면 한번의 입력을 받을 때, 클라이언트에서 한번에 보내준 50바이트의 데이터만 받아온다는 것을 알 수 있다.
TCP 서버 예제
마찬가지로 간단한 에코서버 방식으로 만들어 보았다.
UDP와 달리 서버에서는 클라이언트의 연결을 기다리는 listen, accept 함수와 클라이언트에서는 서버 소켓에 연결하는 connect 함수가 추가되었다.
서버 예제
WSADATA wsaData; // 소켓에 대한 데이터 구조체
if (::WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) // 운영체제에 소켓통신을 시작할 것을 알림
return 0;
// 1) 소켓 생성
SOCKET listenSocket = ::socket(AF_INET, SOCK_STREAM, 0);
// 2) 주소/포트 번호 설정(bind)
SOCKADDR_IN serverAddr;
::memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = ::htonl(INADDR_ANY);
serverAddr.sin_port = ::htons(7777);
if (::bind(listenSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
return 0;
// 3) listen
if (::listen(listenSocket, SOMAXCONN) == SOCKET_ERROR)
return 0;
// 4) accept + recv,send
while (true)
{
SOCKADDR_IN clientAddr;
::memset(&clientAddr, 0, sizeof(clientAddr));
int addrLen = sizeof(clientAddr);
SOCKET clientSocket = ::accept(listenSocket, (SOCKADDR*)&clientAddr, &addrLen); // 응답을 받을때까지 블로킹
// RECV
while (true)
{
char recvBuffer[200];
int recvLen = ::recv(clientSocket, recvBuffer, sizeof(recvBuffer), 0);
if (recvLen == SOCKET_ERROR)
{
exit(0);
}
cout << "recv Data Len = " << recvLen << endl;
::this_thread::sleep_for(3s);
}
}
클라이언트 예제
WSADATA wsaData;
if (::WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
return 0;
// 1) 소켓 생성
SOCKET clientSocket = ::socket(AF_INET, SOCK_STREAM, 0);
if (clientSocket == INVALID_SOCKET) return 0;
// 2) 연결하고자 하는 서버에 연결 (connect)
SOCKADDR_IN serverAddr;
::memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
::inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr);
serverAddr.sin_port = ::htons(7777);
if (::connect(clientSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
return 0;
// sendto
while (true)
{
char sendBuffer[50] = "Hello world!";
int sendSize = ::send(clientSocket, sendBuffer, sizeof(sendBuffer), 0);
// 패킷송신시 에러처리
if (sendSize == SOCKET_ERROR)
return 0;
this_thread::sleep_for(1s);
}
::closesocket(clientSocket);
::WSACleanup();
이제 UDP때 처럼 server는 3초, client는 1초마다 실행하게 되면 서버 측 로그에서 다음과 같은 결과를 얻을 수 있다.
결과를 보면 알 수 있지만, 50을 받아오기도하고 100, 200 150 등 다양한 크기의 데이터를 recv 함수가 호출될때마다 읽어온다는 것을 알 수 있다.
즉, TCP에서는 서버측에서 보낸 데이터를 recv함수가 호출되었을때 상황에 맞게 읽어들인다는 것을 알 수 있다. 이처럼 TCP에서는 데이터에 경계가 존재하지 않고, 읽어들일때에 버퍼에 담긴 데이터를 보고 읽어들인다는 것을 알 수 있다!
그럼 소켓 통신은 완벽한가?
그냥 보면 매우 합리적으로 작동하는 것 같지만 위의 코드에는 몇가지 문제가 존재한다.
그 문제는 바로 기본적으로 listen, accept, recv, send 등이 모두 Blocking 함수라는 것이다.
실제 TCP 동작에서 소켓은 각각 send, recv buffer를 들고 있는데 send()를 호출하게 되면 해당 소켓의 send buffer에 임시 저장했다가 상대 소켓의 receive buffer에 보내주는 방식이다.
하지만 send buffer가 꽉차게 되면 비어질 때 까지는 블로킹된다.
반대로 recv()는 receive buffer에 데이터가 있으면 호출되므로 기본적으로 비어있을 때까지는 블로킹함수로 작동한다.
그렇다면 논블로킹 소켓으로 선언한다면 어떻게 될까
논블로킹 소켓
기본 소켓 통신에서 (accept, recv) 등은 블로킹 방식으로 작동하게 되어 스레드가 낭비되는 일이 발생하고 있다. 따라서 논블로킹 방식으로 소켓을 받으면 이를 어느정도 보완할 수 있게 된다.
생각보다 논블로킹 소켓으로 소켓을 만드는 방법은 간단한데 window 에서는 ioctlsocket() 함수를 이용하여 소켓을 논블로킹 소켓으로 선언해주면 된다.
msdn 문서를 읽어보면 ioctlsocket을 통해 소켓에 여러 설정(cmd)를 해줄 수 있는데 논블로킹 모드는 FIONBIO를 매개 변수로 넣어주면 된다고 한다.
참고) ioctlsocket함수
https://learn.microsoft.com/ko-kr/windows/win32/api/winsock/nf-winsock-ioctlsocket
NonBlocking 소켓 서버 예제
차이가 있다고 한다면 accept에서 논블로킹으로 listen 후에 연결이 도중에 실패할 경우에 대비해 accept 작동 자체를 while문으로 묶어 주어 구현했다는 것이다.
SOCKET listenSocket= ::socket(AF_INET, SOCK_STREAM, 0);
if (clientSocket == INVALID_SOCKET) return 0;
u_long on = 1;
if (::ioctlsocket(clientSocket, FIONBIO, &on) == INVALID_SOCKET) // 논블로킹 소켓으로 선언
return 0;
// 소켓을 생성 + 바인드 + listen까지는 방식이 똑같음
// accept - 논블로킹이므로 연결이 성공할수도 실패할수도 있으므로 while문으로 루프를 돌아줌
while (true)
{
int addrLen = sizeof(serverAddr);
SOCKET clientSocket = ::accept(listenSocket, (SOCKADDR*)&serverAddr, &addrLen);
if (clientSocket == SOCKET_ERROR)
{
// accept는 논블로킹으로 작동
if (::WSAGetLastError() == WSAEWOULDBLOCK)
continue;
break;
}
cout << "Client Conneted!" << endl;
// RECV
while (true)
{
char recvBuffer[100];
int recvLen = ::recv(clientSocket, recvBuffer, sizeof(recvBuffer), 0);
if (recvLen == SOCKET_ERROR)
{
if (::WSAGetLastError() == WSAEWOULDBLOCK)
{
continue;
}
break;
}
cout << "recv Data Len = " << recvLen << endl;
cout << "recv Data = " << recvBuffer << endl;
::this_thread::sleep_for(1s);
}
}
NonBlocking 소켓 클라이언트 예제
SOCKET clientSocket = ::socket(AF_INET, SOCK_STREAM, 0);
if (clientSocket == INVALID_SOCKET) return 0;
u_long on = 1;
if (::ioctlsocket(clientSocket, FIONBIO, &on) == INVALID_SOCKET) // 논블로킹 소켓으로 선언
return 0;
while (true) // connect - 실패할수도있으므로 while문으로 묶어줌
{
if (::connect(clientSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
{
// 연결이 되지 않았을때
if (::WSAGetLastError() == WSAEWOULDBLOCK)
continue;
// 이미 연결된 상태라면?
if (::WSAGetLastError() == WSAEISCONN)
break;
}
}
// SEND
while (true)
{
char sendBuffer[100] = "Hello !! i am Client!";
if (::send(clientSocket, sendBuffer, sizeof(sendBuffer), 0) == SOCKET_ERROR)
{
if (::WSAGetLastError() == WSAEWOULDBLOCK)
{
continue;
}
break;
}
this_thread::sleep_for(1s);
}
하지만 논블로킹으로 작동하더라도 결국 데이터가 없을 때 read를 하게되므로 자원낭비가 발생하게 된다.
이에 대한 해결책은 다음 게시글에 작성해보도록 하겠다..
'서버' 카테고리의 다른 글
[C++] 메모리 풀(Memory Pool) 구현 (1) | 2024.09.24 |
---|---|
[서버구조분석] NodeJS 프레임워크 기본구조 분석( 부제 : 멀티스레드 vs 싱글스레드 ) (0) | 2024.04.11 |
[서버구조분석] Spring 프레임워크 기본구조 분석 (0) | 2024.03.26 |
락 프리(Lock-Free) 알고리즘 (4) | 2024.03.13 |
소켓 통신 프로그래밍(2) (1) | 2024.03.06 |