
온라인 게임 프로그래밍에서 소켓은 파일 핸들 방식과는 약간 다르다.
- 게임 서버에서는 다루어야 하는 소켓 개수가 많다. TCP를 이용해 통신하는 경우 클라이언트 개수만큼 소켓이 있어야 한다.
- 파일 핸들을 하는 동안 스레드가 대기하는 일이 없어야 한다. 디스크를 읽거나 쓸 때 사용하는 read(), write() 함수는 호출 후 실행이 완료될 때까지 리턴하지 않는다. 소켓을 이용해서 읽기/쓰기 함수를 호출했는데, 즉시 리턴하지 않으면 이들을 호출한 메인 스레드는 사용자 입장에서 일시 정지하는 것처럼 보인다. 그러면 부드러운 애니메이션을 보여줄 수 없다.
위와 같은 이유 때문에 네트워크 프로그래밍에서 소켓은 보통 비동기 입출력(Asynchronous I/O) 상태로 다룬다. 소켓을 비동기 입출력으로 다루는 방식에는 크게 Non-blocking Socket 방식과 Overlapped I/O 방식이 있다. 그리고 이 방식들을 발전시킨 epoll, IOCP(I/O Completion Port) 방식이 많이 활용된다.
이해를 돕기 위해 우선 동기 입출력 방식부터 알아보자. 소켓에 대한 동기 입출력 방식을 블로킹 소켓이라고 한다.
1. 블로킹 소켓
디바이스에 처리 요청을 걸어 놓고 응답을 대기하는 함수를 호출할 때 스레드에서 발생하는 대기 현상을 블로킹이라 한다. 소켓뿐만 아니라 파일 핸들에 대한 함수를 호출했을 때도 이러한 대기 현상이 발생하는 것을 모두 블로킹이라 한다.
블로킹이 발생하는 스레드에서는 CPU 연산을 하지 않아 CPU 사용량이 0%가 된다. 즉 스레드는 대기 상태이다. 스레드가 대기 상태일 동안 파일이나 소켓의 실제 처리는 디바이스에서 이뤄진다. 파일에 기록하는 함수를 호출했다면 기록하려는 데이터가 디스크에 완전히 기록될 때까지 대기 상태를 유지한다. 파일에서 읽기 함수 호출했다면 디스크 읽기가 완전히 끝날 때까지 대기 상태를 유지한다. 파일 읽기 쓰기가 완전히 끝나면 다시 실행 상태로 바뀌고, 함수는 리턴하며 다음 명령어를 실행한다.

소켓도 마찬가지이다. 스레드에서 네트워크 수신 함수를 호출하면, 수신할 수 있는 데이터가 생길 때까지 스레드는 대기 상태, 즉 블로킹이 발생한다. 데이터를 수신할 함수를 호출했으나, 상대방 컴퓨터에서 아무런 데이터를 보내지 않고 있다면 영원히 블로킹이 발생할 것이다.
1. 네트워크 연결 및 송신
TCP는 연결 지향형 프로토콜이며 1:1 통신만 허락한다. 따라서 TCP 소켓 1개는 오직 endpoint 1개 하고만 통신할 수 있다. 한 번 TCP 소켓을 이용하여 통신하는 프로그램을 살펴보자.
// 주소가 11.22.33.44인 기기에서 실행한다.
int main()
{
Socket s(SocketType::Tcp); // 1
s.Bind(Endpoint::Any); // 2
s.Connect(Endpoint("127.0.0.1", 5959)); // 3
const char* data = "hello";
s.Send(data, strlen(data) + 1); // 4
std::this_thread::sleep_for(1s);
s.Close(); // 5
return 0;
}
- TCP 소켓 핸들을 생성한다. 아직 s는 아무것도 할 수 없다.
- localhost 안에 있는 65,535개의 포트 중 사용 가능한 빈 포트를 바인딩한다. 빈 포트가 없을 경우 이미 다른 곳에서 점유한 포트를 공유한다. TCP 통신을 하려면 상대방 endpoint를 알아야 할 뿐만 아니라 자신의 endpoint도 알아야 하므로 반드시 호출해야 한다.
- 상대방 endpoint를 향해 TCP 연결을 시도하며 블로킹이 발생한다. TCP 연결이 완료될 때까지 블로킹을 유지하다가 상대방이 연결을 수락하면 함수는 리턴한다. 상대방이 연결을 거절하거나 상대방이 존재하지 않는 경우 마찬가지로 함수는 리턴한다. 함수가 리턴한 후 연결이 성공했는지 실패했는지 알 수 있다.
- 상대방 endpoint를 향해 데이터를 전송한다. 본인 컴퓨터의 OS에서 상대방 컴퓨터로 데이터를 전송하는 처리가 완료되면 리턴한다. 함수가 리턴했다고 해서 상대방이 데이터를 성공적으로 수신했음을 보장하는 것이 아님에 주의하라.
- TCP 소켓을 닫으며 연결을 해제한다.
안타깝게도 이 의사 코드는 의도와 다르게 동작한다. send() 호출 시 블로킹 없이 즉시 리턴할 것이며, TCP 수신 측에서 데이터를 가끔 수신하지 못할 것이다. 왜 그럴까?
송신 버퍼
소켓 각각은 송신 버퍼(Send Buffer)와 수신 버퍼(Receive Buffer)를 하나씩 가지고 있다. 송신 버퍼는 일련의 Byte Array로, 버퍼 크기가 고정되어 있으나 변경 가능하다.
송신 버퍼는 FIFO(선입선출) 형태로 작동한다. send()를 호출하면 데이터는 일단 송신 버퍼에 채워진다. 송신 버퍼에 채워진 데이터는 잠시 후 통신 선로를 통해 점차적으로 빠져나간다. 따라서 송신 버퍼는 데이터가 채워지더라도 곧 빈 상태가 된다. 한 번 크기가 5byte인 송신 버퍼를 가진 소켓을 예로 들어보자.

- 송신 버퍼 크기가 5byte인 소켓이 준비되어 있다.
- send(A)가 실행되어 송신 버퍼 맨 앞에 A가 채워진다.
- 잠시 후 OS로 A가 네트워크 선로를 통해 송출된다. 따라서 버퍼가 빈 상태가 된다.
- send(B)를 실행해 버퍼 맨 앞에 B가 채워진다. 곧바로 send(C)도 실행되어 버퍼의 B 뒤에 C가 채워진다.
- OS로 B, C가 빠져나간다. 버퍼는 다시 비워진다.
- send(DEFG)를 호출해 버퍼에 채워진다.
- OS로 D가 송출된다. 버퍼에서 D가 사라진다.
- send(HI)를 호출한다. 함수가 즉시 리턴되었고, 버퍼가 가득 찼다.
- send(J)를 호출했는데, 송신 버퍼가 가득 차 블로킹이 발생했다.
- send(J)가 블로킹된 상황에서, OS가 버퍼의 F를 Pop 한다.
- 버퍼에 빈 공간이 생겨 J가 버퍼에 Push 되고 send()의 블로킹이 해제된다.
- OS가 버퍼에 있는 모든 데이터를 송출하고 버퍼가 다시 빈 상태가 된다.
// 주소가 11.22.33.44인 기기에서 실행한다.
int main()
{
Socket s(SocketType::Tcp); // 1
s.Bind(Endpoint::Any); // 2
s.Connect(Endpoint("127.0.0.1", 5959)); // 3
const char* data = "hello";
s.Send(data, strlen(data) + 1); // 4
std::this_thread::sleep_for(1s);
s.Close(); // 5
return 0;
}
다시 이 코드에서 Send() 함수를 확인하자. 송신 버퍼는 기본적으로 수천 바이트를 담을 수 있어, send 함수는 즉시 리턴한다.
2. 네트워크 연결받기 및 수신
이번에는 네트워크 연결을 수락하고 데이터를 받는 방법에 대해 알아보자.
// 주소가 55.66.77.88인 기기에서 실행한다.
int main()
{
Socket s(SocketType::Tcp); // 1
s.Bind(Endpoint("0.0.0.0", 5959)); // 2
s.Listen(); // 3
Socket s2;
string ignore;
s.Accept(s2, ignore); // 4
cout << s2.GetPeerAddr().ToString() << endl; // 5
while(true)
{
int retval = s2.Receive(); // 6
if(retval <= 0) // 7
{
break;
}
cout << s2.m_receiveBuffer << endl;
}
s2.Close(); // 8
return 0;
}
- TCP 소켓을 생성한다.
- TCP 포트 5959번을 바인딩한다. 이미 사용 중인 경우 실패한다.
- TCP 연결을 받는 역할을 시작하여 리스닝 소켓이 되었다. listen 함수는 즉시 리턴한다.
- TCP 연결이 들어올 때까지 대기한다. 상대방 컴퓨터가 55.66.77.88:5959로 TCP 연결을 하면 이 함수는 리턴하며, 새로운 TCP 소켓 핸들을 준다. 새로운 TCP 소켓은 5959번 이외에 다른 포트를 사용한다. 리스닝 소켓은 연결을 수락하는 역할만 할 뿐 데이터를 주고받는 용도로 사용되지 않는 점에 주의하라.
- 4. 에서 받은 새로운 소켓 핸들을 이용해 상대방 endpoint와 통신을 수행한다. 상대방 endpoint 주소를 한 번 출력해 본다.
- 새로운 소켓에서 데이터를 수신한다. Receive()는 수신 결과에 대한 결과를 반환하는데, 수신할 수 있는 데이터가 없으면 블로킹이 일어난다. 수신할 수 있는 데이터가 있을 때까지 블로킹이 유지된다.
- TCP 소켓에 대해 수신 함수를 호출했을 때 받은 정수가 0이라면 상대방이 연결을 끝냈음을 의미해 더 이상 수신을 시도하지 않는다. 참고로 연결이 갑자기 끊어지는 등 오류가 발생하면 음수를 리턴한다.
- 새로운 소켓의 연결이 끊어졌다. 이제 더 이상 사용할 수없어 소켓을 닫는다.
수신 버퍼
수신 버퍼는 기본적으로 송신 버퍼와 구조는 유사하지만 동작 순서가 반대이다. 송신 버퍼에서는 사용자가 push()하고 운영체제가 pop()하는 반면, 수신 버퍼에서는 운영체제가 push()하고 사용자가 pop()한다.
운영체제는 네트워크를 통해 수신한 데이터를 수신 버퍼에 계속 채워 넣는다. 이 버퍼를 사용자가 오랫동안 비우지 않고 방치하면 결국 가득 차게 되고, 이 상태에서는 더 이상 데이터를 받을 수 없다. 사용자는 데이터를 수신하는 함수를 호출하여 수신 버퍼에서 데이터를 꺼낼 수 있으며, 버퍼가 완전히 비어 있으면 해당 함수는 블로킹된다.
TCP 수신 함수는 수신 버퍼에 1바이트라도 데이터가 있으면 즉시 리턴하며, 그렇지 않으면 데이터가 도착할 때까지 블로킹된다. 반대로, 수신 버퍼가 꽉 찬 경우 송신 측의 send() 함수는 버퍼에 공간이 생길 때까지 블로킹된다. 극단적인 상황에서는 수신 측에서 데이터를 전혀 꺼내지 않으면 송신 측은 계속 블로킹되며, 이 경우 통신은 정지된 상태지만 연결 자체는 유지된다.
결국, 송신 속도가 아무리 빨라도 수신 측에서 데이터를 꺼내는 속도가 느리면 그에 맞춰 전송 속도가 조절된다. 이는 TCP가 신뢰성 있는 연결 지향 프로토콜로 불리는 이유 중 하나이다.

이번에는 UDP 소켓에 대해 알아보자. UDP 소켓은 데이터그램이 최소 1개 도착해 있으면 즉시 리턴한다. 그렇지 않으면 데이터그램이 1개 도착할 때까지 블로킹된다.
네트워크 선로로 UDP 데이터그램 A가 도착했지만, 수신 버퍼가 데이터그램 A를 담을 여유 공간이 없으면 그냥 버려진다. 이때, TCP와 달리 송신 함수 SendTo()의 블로킹은 발생하지 않는다. 또한 송신 측의 송신 활동이 일시적으로 멈추지 않는다.
정리하자면, UDP 송신 함수로 송신 버퍼에 데이터를 쌓는 속도보다 수신 함수로 수신 버퍼에서 데이터를 꺼내는 속도가 느리면 데이터그램 유실이 발생한다. 결국 받는 쪽에서는 일부 데이터그램을 놓치는 결과를 초래하게 되는 것이다.

라우터는 여러 곳에서 도착하는 패킷을 처리한다. 라우터가 초당 처리할 수 있는 패킷양을 넘어서는 패킷이 라우터에 도착할 때 라우터는 패킷을 늦게 처리하거나 버린다.
라우터에 연결된 한 곳 A에서 도착하는 패킷이 압도적으로 많으면, 라우터는 A에서 도착하는 패킷을 처리하느라 A 이외의 곳에서 오는 패킷을 원활하게 처리하지 못하기도 한다. 이 경우 A 이외 다른 곳의 네트워킹은 원활한 속도를 내지 못한다. 즉 네트워킹 경쟁에서 밀리게 된다.
TCP는 송신자가 초당 보내는 데이터양이 수신자가 수신할 수 있는 데이터양보다 많을 때, 송신자 측 운영체제가 알아서 초당 송신량을 줄인다. 따라서 송신자와 수신자 간 다른 네트워킹 경쟁에서 밀리지 않는다.
반면 UDP에는 이런 혼잡 제어 기능이 없다. 따라서 UDP를 속도 제한 없이 마구 송신하면 주변 네트워킹이 경쟁에서 밀린다. 이 때문에 주변 네트워킹이 두절되기도 한다.
2. 논블록 소켓
1:1 네트워킹 프로그램을 개발할 때는 블로킹 소켓 방식으로도 큰 문제가 없다. 하지만 동시에 여러 대상과 통신해야 하는 상황이라면, 접근 방식을 달리해야 한다.
가장 먼저 떠오르는 방법은 네트워킹 대상 수만큼 스레드를 생성하는 것이다. 각 스레드는 개별 대상과 데이터를 송수신한다. 대상 수가 적을 때는 이 방식도 효율적이다.
하지만 수백, 수천 개의 네트워킹 대상이 있다면 상황은 달라진다. 예를 들어, 스레드가 1,000개라면 각 스레드가 사용하는 호출 스택이 1MB씩 총 1GB에 달한다. 더 큰 문제는 스레드들이 데이터를 기다리는 동안 반복적으로 잠들고 깨는 과정에서 대량의 컨텍스트 스위칭이 발생한다는 점이다. 이는 자원 낭비로 이어진다.
void BlockSocketOperation()
{
s = Socket(TCP);
...
s.connect(...);
...
while(true)
{
s.send(data);
}
}
블로킹 소켓을 이용해 데이터를 보내는 의사코드다. 송신 함수는 계속 데이터를 보내려 하지만, 수신 측의 처리 속도가 느리면 결국 송신 버퍼가 가득 차게 된다. 이 경우, 버퍼에 빈 공간이 생길 때까지 블로킹된다. 공간이 생기면 그제야 함수는 리턴된다.
1. 개념
대부분의 운영체제는 소켓 함수가 블로킹되지 않도록 설정하는 기능을 제공하며, 이를 논블로킹 소켓(Non-blocking Socket)이라고 한다.
- 소켓을 논블로킹 모드로 전환한다.
- 일반적인 방식대로 송수신, 연결 함수 등을 호출한다.
- 함수 호출은 항상 즉시 리턴되며, 결과는 성공 또는 would block 오류로 구분된다.
would block: 원래는 블로킹이 발생해야 하지만, 논블로킹이기 때문에 아무 작업도 하지 않고 바로 돌아왔음을 의미한다.
void NonBlockSocketOperation()
{
s = socket(TCP);
...
s.connect(...);
s.SetNonBlocking(true); // 논블록 소켓으로 변경
while(true)
{
// 1.
r = s.send(dest, data);
if(r == EWOULDBLOCK)
{
// 블로킹 걸릴 상황이었다. 송신을 안 했다.
continue;
}
if(r == OK)
{
// 송신 성공에 대한 처리
}
else
{
// 송신 실패에 대한 처리
}
// 2. 이 부분에서 CPU 사용량이 늘어나는 문제가 존재.
}
}
논블록 소켓을 사용하는 코드로 바꿔보았다. 논블로킹 모드에서 송신 함수를 호출하면 언제나 즉시 리턴된다. 리턴하는 데이터는 아래와 같다.
- 송신 함수가 성공적으로 소켓 송신 버퍼에 데이터를 넣었으면, 송신 함수는 보낸 데이터 크기를 리턴한다.
- would block이라면 아무것도 송신하지 않은 상태이다. 이 경우 송신 함수 호출을 나중에 다시 해주어야 한다.
- 그 외에는 다른 문제가 생긴 것으로, 소켓 API를 확인해 무엇이 잘못되었는지 체크해야 한다.
논블록 소켓을 이용하면 한 스레드에서 여러 소켓을 한 번에 다룰 수 있다. 예를 들어 반복문을 통해 100개의 소켓에 수신 요청을 한다면, 대부분의 소켓은 당장 읽을 데이터가 없어 블로킹이 발생할 것이다. 따라서 매우 비효율적인 프로그램이 될 것이다. 받은 데이터를 제때 처리하지 못하기 때문이다.
List<Socket> sockets;
void NonBlockSocketOperation()
{
foreach(s in sockets)
{
result, data = s.receive();
if(data.length > 0)
{
print(data);
}
else if(result != EWOULDBLOCK)
{
// would block이 아니라면 오류가 발생한 것
...
}
}
}
이를 논블록 소켓으로 처리하면 블로킹이 난무하는 문제가 사라지는데, 반복문을 돌며 수신 데이터가 있으면 꺼내서 처리하고, 없다면 would block 코드만 리턴하고 즉시 다음 작업으로 넘어간다. 따라서 많은 수의 소켓을 지연 없이 처리할 수 있다.
2. connect()
상대방에게 TCP 접속 역할을 하는 connect() 함수를 논블로킹으로 쓸 때는 블로킹으로 쓸 때와 조금 다르다. 송수신 함수가 would block을 리턴할 때는 아무 일도 일어나지 않는다. 그러나 connect() 함수가 would block을 리턴했다면 이미 TCP 연결 시도가 진행 중인 상태이다. 상대방의 endpoint로 연결을 시도하다가 would block이 된 것이기 때문이다.
따라서 이 상태에서 connect()를 다시 호출하는 대신, '0바이트 송신'을 통해 현재 소켓 상태를 확인해야 한다. TCP는 스트림 기반 프로토콜이므로 0바이트를 보내는 것은 사실상 아무것도 하지 않는 것과 같다. 그리고 0바이트를 보내려는 시도를 하면 TCP 소켓이 현재 어떤 상태인지 알 수 있다.
- 송신 성공 -> 연결 성공
- ENOTCONN(소켓이 연결되지 않음) -> 연결 진행 중
- 기타 오류 -> 연결 실패
코드로 표현하면 다음과 같다.
void NOnBlockSocketOperation()
{
result = s.connect();
if(result == EWOULDBLOCK)
{
while(true)
{
byte emptyData[0];
result = s.send(emptyData);
if(result == OK)
{
// 성공 처리
}
else if(result == ENOTCONN)
{
// 아직 연결 진행 중
}
else
{
// 실패 처리
}
}
}
}
would block 상태에서 반복문을 돌며 계속 송수신을 시도하면, CPU는 계속해서 루프를 돌며 바쁘게 일한다. 결과적으로 CPU 코어 하나가 100%를 차지하게 된다.
게임 클라이언트의 경우 어차피 메인 스레드가 게임 루프를 돌며 업데이트와 렌더링을 처리하기 때문에 큰 문제가 되지 않는다. 하지만 서버는 달라야 한다. 서버는 유휴 상태일 때 가능한 CPU를 쉬게 하여 여유를 확보해야 한다.
List<Socket> sockets;
void NonBlockSocketOperation()
{
while(true)
{
foreach(s in sockets)
{
// 1. 논블록 수신
result, data = s.receive();
if(data.length > 0)
{
print(data);
}
else if(result != EWOULDBLOCK)
{
// 소켓 오류 처리
}
// 2.
}
}
}
2. 에서 CPU 사용량 폭주 문제를 해결하기 위해 아래 일을 수행하는 함수를 추가하고자 한다.
- 여러 소켓 중 하나라도 would block이었던 상태에 변화가 일어나면(송신 버퍼에 빈 공간이 생기거나 수신 버퍼에 뭔가가 들어온다면) 그 상황을 알려주는 함수
- 혹은 그것을 알려주기 전까지 블로킹 중이어서 CPU 사용량 폭주를 해결하는 함수
이러한 기능을 제공하는 함수가 select()와 poll()이다. 아래와 같이 동작한다.
- 관심 있는 소켓 리스트를 입력한다.
- 리스트에서 I/O 처리가 가능한 소켓이 생기는 순간까지 블로킹한다.
- 블로킹이 끝나면 어떤 소켓이 I/O 처리를 할 수 있는지 알려준다.
이때 블로킹의 타임아웃을 지정할 수 있다.
- 무한대를 입력하면 I/O 처리를 할 수 있는 소켓이 생길 때까지 영원히 기다린다.
- 지정한 시간(ms 단위)을 입력하면 해당 시간이 될 때까지 기다린다.
- 0초를 입력하면 블로킹 없이 결과를 리턴한다.
한 번 select() 함수를 적용해 보자.
List<Socket> sockets;
void NonBlockSocketOperation()
{
while(true)
{
// 1. 100ms 대기
// 1개라도 I/O 처리할 수 있는 상태가 되면 그 전에 리턴
select(Sockets, 100ms);
foreach(s in sockets)
{
// 2. 논블록 수신
result, data = s.receive();
if(data.length > 0_
{
print(data);
}
else if(result != EWOULDBLOCK)
{
// 소켓 오류 처리
}
}
}
}
1. 에서 select()를 호출한다. sockets에 I/O 처리가 가능한 소켓이 하나라도 있을 경우 즉시 리턴한다. 그렇지 않으면 100ms까지 블로킹한다. 그전에라도 조건을 만족하면 즉시 리턴한다.
I/O 가능 이벤트: 해당 소켓에 대해 소켓 함수를 호출하면 would block이 아닌 다른 결과가 나온다는 것
- 송신 버퍼에 여유 공간이 있는 경우
- 수신 버퍼에 데이터가 있는 경우
select() 함수가 리턴한 후에는 sockets의 각 소켓에 대한 논블록 I/O 처리 함수를 호출하면 된다. 성공하는 것도 실패하는 것도 있겠지만, 최소한 하나는 would block이 아닌 다른 결과로 나올 것이다.
3. accept()
블로킹 모드의 경우 리스닝 소켓에 대해 accept()을 호출하면 accept()은 블로킹이 걸린다. 그리고 TCP 연결이 들어오면 리턴을 하는데, 이때 accept()의 리턴 값은 새 TCP 연결에 대한 소켓 핸들이다.
리스닝 소켓이 논블록 소켓인 경우 TCP 연결이 아직 들어오지 않았으면 accept()은 블로킹 대신 would block 오류 코드를 준다. 이는 논블록 소켓이 수신할 데이터가 없을 때 recv()가 블로킹 대신 would block 오류 코드를 주는 것과 마찬가지다. select()를 갖고 리스닝 소켓에서 I/O 가능 이벤트가 감지되면 accept()을 호출하라. 그러면 들어온 TCP 연결에 대한 소켓 핸들을 얻게 된다.
void NonBlockSocketOperation()
{
s = Socket(TCP);
s.SetNonBlocking(true);
s.listen(5000);
while(true)
{
socket, result = s.accept();
if(result == EWOULDBLOCK)
{
// 블로킹 걸릴 상황. TCP 연결이 안들어옴
continue;
}
if(result == OK)
{
// TCP 연결 잘 받음
}
else
{
// 리스닝 소켓에 무슨 문제가 생김
}
}
}'Game Development > Server' 카테고리의 다른 글
| 논블로킹 소켓 (6) | 2025.08.14 |
|---|---|
| TCP 서버 (2) | 2025.07.28 |
| 소켓 프로그래밍 기초 (5) | 2025.07.22 |
| 게임 서버와 클라이언트 (3) | 2025.06.18 |
| 온라인 게임 프로그래밍에서 소켓 핸들 방식 2 (4) | 2025.06.15 |