Programming - cpueblo.com

[웹글] 네트워크 프로그래밍 기초 가이드


서버 프로그래밍의 경험은 거의 없지만, 입문자들이 보기 쉽도록 보통 업계 클라이언트 프로그래머들이 아는 수준에서, 서버 프로그래밍과 플라이언트 네트워크 프로그래밍을 정리해 봅니다.

글쓴이 : 유광희 날짜 : 2007-09-19 (수) 15:45 조회 : 37003
서버 프로그래밍의 경험은 거의 없지만, 입문자들이 보기 쉽도록 보통 업계 클라이언트 프로그래머들이 아는 수준에서, 서버 프로그래밍과 플라이언트 네트워크 프로그래밍을 정리해 봅니다. _ xml_ns="http://www.w3.org/1999/xhtml"> gamecode :: 네트워크 프로그래밍 기초 가이드
| Front | Old Site | Resources | Guestbook |

www.gamecode.org
김성익 개인 홈페이지

<script type="text/javascript"> // <script type="text/javascript" src="/tt/script/EAF2.js"> <script type="text/javascript" src="/tt/script/common2.js"> <script type="text/javascript" src="/tt/script/gallery.js" > <script type="text/javascript"> // 0) password = passwords; } if (!password) return; document.cookie = "GUEST_PASSWORD=" + escape(password.value) + ";path=/tt"; window.location.href = window.location.href; } //]]>

서버 프로그래밍의 경험은 거의 없지만, 입문자들이 보기 쉽도록 보통 업계 클라이언트 프로그래머들이 아는 수준에서, 서버 프로그래밍과 플라이언트 네트워크 프로그래밍을 정리해 봅니다.

1. 가이드 소개

네트워크 프로그래밍이라 함은 보통 서로 다른 컴퓨터, 혹은 서로 다른 프로세스간에 통신을 얘기하며, 소켓 프로그래밍이 가장 일반적인 형태라고 생각됩니다. 기본적으로 정리하는 것도 이 소켓을 다루는 것에 대한 내용입니다.

기능적으로 다루는 것은 소켓 프로그래밍이 전부 일수도 있지만, 적절하게 활용하기 위해선 운영체제의 수행 파이프라인이라던가, 다중 프로세스/쓰레드에 대한 적절한 지식과 경험이 필요하다고 생각합니다. (그래서 게임 서버 프로그래밍은 전통적으로 전산과 출신의 프로그래머가 극 강세가 아닐까 생각됩니다. )
(정리에는 포함하지만 아주 부족하므로, 이 부분은 능동적으로 자료를 찾고 실험을 해보시면 시행착오를 많이 줄이실 수 있을 거 같습니다.

- 소켓프로그래밍개요
- TCP 소켓 프로그래밍
- UDP 소켓 프로그래밍
- Multiplex IO
- 동기화
- 네크워트 프로그래밍 이슈들

단순히 네트워크의 전송 처리는 사실상 아주 베이스에 불과하며 실제적으로 게임에서 얘기하는 네트워크에는 게임 동기화, 패킷 디자인, DB연동 등의 게임의 많은 부분이 포함된다고 생각됩니다. 이 부분은 개발자 스스로 설계를 하는 것이 맞다고 생각되며, 아주 일반적인 형태의 서버는 다루는 글이나 책들을 먼저 참고한다면 시행착오를 줄이는데 도움은 될 거라 생각됩니다.

(기타 실제적으로 어플리케이션 레벨에서는 항상 알아야 하는 것은 아니지만 네트워크 토폴로지(network topology), OSI 네트워크 모델, IP 주소체계, 라우팅등 네트웍의 기본에 해당되는 주제들은 관심을 가지고 살펴볼 필요는 있습니다.)

2. 소켓 프로그래밍 개요

기본적으로 소켓은 통신을 위한 일종의 통로라고 생각할 수 있습니다. 기본적으로 소켓은 상대방에게 데이터를 보내거나 받는 역할을 하며, 연결을 수동적으로 기다리느냐, 능동적으로 연결을 하느냐로 서버 / 클라이언트냐를 구분할 수 있겠습니다. (기능상 그렇다는 것이고 서버 / 클라이언트의 정의라고는 볼 수 없겠습니다.)

실용적인 관점에서 소켓은 TCP(Tansmission Control Protocol)와 UDP(User Datagram Protocol)로 구분할 수 있습니다. 약간은 상반되는 장점과 단점을 가지고 있으며, 어떤 것을 적절하게 활용하느냐가 전체적인 성능에 큰 영향을 주게 됩니다. 간단하게 요약하면 TCP는 신뢰할 수 있는 통신을, UDP는 몇 가지 신뢰도는 포기하되 좀 더 직접적인 통신을 한다는 가지고 있습니다.

그리고 다른 관점에서 소켓 함수는 동기모드(블록킹)/비동기 모드(넌블록킹)로 동작합니다. 차이점은 만약 데이터가 도착하지 않는 상태에서 recv로 데이터를 수신하고자 했을 때 데이터가 올 때까지 대기(block) 하느냐, 그냥 수신된 데이터가 없다는 정보만 리턴하고 넘어가느냐 입니다. 실제로 대기한다는 의미는 시스템을 멈추고 기다린다는 것이 아니라 다른 쓰레드나 프로세스(process)로 실행 권을 넘기는 것이기 때문에 프로세서(processor)는 항상 적절한 동작을 하게 됩니다. 비동기 모드로 데이터가 올 때까지 폴링(polling)하면서 대기하는 것과는 기다린다는 의미에서는 동일하지만 프로세서를 활용한다는 면에서는 하늘과 땅 차이라고 할 수 있겠습니다. 이런 병렬적인 처리에 대한 고려가 필요하게 됩니다.

그리고 직접적인 소켓 통신을 처리하는 함수는 아니지만 소켓 처리에 대해서 Multiplex 처리(하나의 쓰레드, 혹은 적은 수의 쓰레드에서 여러 개의 소켓을 처리)를 해주는 select, epoll, IOCP 같은 기능적인 함수군도 염두해 두어야 겠습니다.

3. TCP 소켓 프로그래밍

실제적인 TCP의 특징을 먼저 살펴보겠습니다. 가장 큰 장점은 아래 두가지가 되겠습니다.

- 전송되는 데이터의 순서가 바뀌지 바뀌지 않는다.
- 내부적인 에러 정정 기능이 있기 때문에 신뢰할 수 있다. (중간에 데이타를 분실하지 않는다.)

안정적으로 데이터를 주고 받을 수 있다는 점이 최대 장점입니다만, 내부적인 확인을 위해 ack 신호를 주고 받으며, 순서(sequence number)정보나 체크섬(checksum)정보등의 추가적인 헤더가 포함되기 때문에 추가적인 네트웍 지연(latency)가 생길 수 있습니다.
스트림 형태의 데이터는 신뢰할 수 있지만, 주의할 것은 데이터를 ABCD로 보냈을 경우 받는 쪽에서는 ABC, D로 나눠서 받아질 수도 있다는 점입니다. 즉, 패킷의 크기를 받아진 크기로 처리하면 안되며, 논리적으로 구할 수 있어야 합니다.

TCP의 다른 특징은 연결 지향적이라는 것으로, 각 서버, 클라이언트 사이드에서는 연결된 소켓을 통해서 통신을 하게 됩니다.

서버쪽에서의 소켓의 연결 흐름은 아래와 같습니다.
- socket 함수로 소켓 생성하기
- bind 함수로 특정 포트에 bind 하기
- listen 함수로 소켓을 외부 연결 대기 상태로 세팅하기
- accept 함수로 연결된 소켓 얻어오기
- 연결된 소켓으로 통신 (send, recv)

클라이언트의 연결흐름은 좀 더 간단합니다.
- socket 함수로 소켓 생성하기
- connect 함수로 특정 ip, port 의 서버에 연결하기
- 소켓으로 통신 (send, recv)

실제로 데이터를 보내고 받는 함수는 send, recv 함수로 파일의 read, write 인터페이스와 유사합니다.

가장 기본적인 형태로 echo 서버 / 클라이언트를 구성해보도록 하겠습니다. (주의할 것은 아래 예제의 서버는 여러 개의 입력을 받을 수 없다는 것으로 단순한 기능을 확인하기 위한 예제입니다.)

* TCP ECHO Server

1234 포트를 열고 기다렸다가 연결되면 그 소켓으로 받은 데이터를 그대로 보내주는 간단한 echo 서버 예제입니다. (편의상 예제는 win32 콘솔 프로젝트로 작성하며, wsock32.lib 를 link 리스트에 추가해줘야 합니다.)

#include
#include

#define PORT 1234

void main()
{
WSADATA WSAData;
SOCKADDR_IN addr;
SOCKET server;
SOCKET client;
char buffer[1024];
int readbytes;
int addrlen;

if (WSAStartup(MAKEWORD(1,1), &WSAData) != 0)
return;

server = socket(AF_INET, SOCK_STREAM, 0);

if (server == INVALID_SOCKET)
return ;

addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
addr.sin_addr.S_un.S_addr = htonl(INADDR_ANY);

if (bind(server, (struct sockaddr *)&addr, sizeof(addr)) == SOCKET_ERROR)
return ;

if (listen(server, SOMAXConN) == SOCKET_ERROR)
return ;

printf("wait for connecting\\n");

addrlen = sizeof(addr);
client = accept(server, (struct sockaddr*)&addr, &addrlen);

printf("connected (%x) %d.%d.%d.%d:%d\\n", client,
addr.sin_addr.S_un.S_un_b.s_b1, addr.sin_addr.S_un.S_un_b.s_b2,
addr.sin_addr.S_un.S_un_b.s_b3, addr.sin_addr.S_un.S_un_b.s_b4,
ntohs(addr.sin_port));

while(1)
{
printf("wait for reading\\n");

readbytes = recv(client, buffer, sizeof(buffer), 0);

if (readbytes > 0)
{
printf("read bytes = %d\\n", readbytes);
send(client, buffer, readbytes, 0);
} else
{
printf("error detected (%d)\\n", WSAGetLastError());
break;
}
}

closesocket(client);
closesocket(server);
}

* TCP ECHO Client

편의상 local에서 테스트한다고 가정하고 IP는 127.0.0.1로 설정했으며, 입력한 한 라인의 내용을 서버로 보내고, 그 내용이 오기를 기다리는 매우 간단한 echo 클라이언트 예제입니다.

#include
#include

#define PORT 1234
#define IP "127.0.0.1"

void main()
{
WSADATA WSAData;
SOCKADDR_IN addr;
SOCKET s;
char buffer[1024];
int readbytes;
int i, len;

if (WSAStartup(MAKEWORD(1,1), &WSAData) != 0)
return;

s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

if (s == INVALID_SOCKET)
return;

addr.sin_family = AF_INET;
addr.sin_port = htons(0);
addr.sin_addr.S_un.S_addr = htonl(INADDR_ANY);

if (bind(s, (struct sockaddr *)&addr, sizeof(addr)) == SOCKET_ERROR)
{
closesocket(s);
return;
}

addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
addr.sin_addr.S_un.S_addr = inet_addr(IP);

if (connect(s, (struct sockaddr *)&addr, sizeof(addr)) == SOCKET_ERROR)
{
printf("fail to connect\\n");
closesocket(s);
return;
}

while(1)
{
printf("enter messages\\n");

for(i=0; 1; i++)
{
buffer[i] = getchar();
if (buffer[i] == '\\n')
{
buffer[i++] = '';
break;
}
}

len = i;

printf("send messages (%d bytes)\\n", len);
send(s, buffer, len, 0);

for(readbytes=0; readbytes readbytes += recv(s, buffer+readbytes, len-readbytes, 0);

printf("recv messages = %s\\n", buffer);
}

closesocket(s);
}

참고로 소켓을 넌블럭모드로 설정하고자 할 경우 아래처럼 작성하면 됩니다.

unsigned long m = 1;
ioctlsocket(sock, FIONBIO, &m);

4. UDP 소켓 프로그래밍

UDP은 TCP의 아쉬운 부분을 보완해줄 수 있는데, 기본적으로 에러 정정이 없고, 붙은 헤더의 크기가 작아서 속도가 빠르다는 점이 가장 큰 장점입니다. (네트워크 하위 레벨에서 TCP나 UDP나 같이 때문에 속도가 빠르다라는 말은 약간 모순이 있지만, 반응하는 속도로 보자면 더 빠릅니다.)
다만 주의해야 될 점이 몇 가지 있습니다.

- 보낸 패킷이 도착하지 않을 수 있다
- 보낸 순서대로 도착자지 않을 수 있다
- 같은 내용이 반복해서 전송 될 수도 있다
- 보낸 내용과 받은 내용이 100% 일치하지 않을 수도 있다

위의 요소들은 적절하게 구현하지 않는다면 아주 치명적인 문제를 일으킬 수 있는 부분입니다. 그리고 다른 특징은

- 패킷은 잘려서 전송되지 않는다. (보낸 패킷이 두개로 나뉘어서 받아지는 경우는 없다)
- 비 연결성이다

아마 느낌이 오시겠지만 편하게 사용하기엔 위험하고, 각종 에러 판단이나 에러 정정 코드를 직접 작성해야 한다는 부담이 생깁니다만, 이점을 충분히 고려하더라도 어플리케이션 레벨에서의 latency를 줄일 수 있다는 엄청난 장점은 포기할 수 없는 매력이라고 생각됩니다.

비연결(connectionless)적인 UDP의 통신 과정은 연결지향 (connection-oriented) 적인 TCP와는 조금 다릅니다.

UDP 소켓 서버 기본 흐름은
- socket 함수로 SOCK_DGRAM 형태로 생성하기
- bind 함수로 소켓을 대기할 port로 바인드하기
- 소켓으로 통신 (recvfrom, sendto)

UDP 클라이언트는 특정 포트에 바인드 하는 것을 제외하면 동일합니다
- socket 함수로 SOCK_DGRAM 형태로 생성하기
- bind 함수로 아무 로컬주소로나 바인드하기
- 소켓으로 통신 (recvfrom, sendto)

과정은 훨씬 간소화 되었지만 TCP는 연결 시에만 주소를 설정하면 되는데, UDP는 데이터를 보낼 때마다 도착점의 주소(ip, port)를 세팅해 주어야 합니다.
다만 서버 구성해서 편한 것은 해당 port의 모든 서비스를 하나의 소켓으로 처리하기 때문에 select, iocp 같은 별도의 API 없이도 하나의 쓰레드로 모든 서비스를 관리할 수 있습니다. 서버나 클라이언트에서 recvfrom으로 데이터가 도착 했을 때 어디서 보냈는지는 주소로 직접 판단해야 하기 때문에 여러 가지로 까다로운 면이 있습니다.
(보통 TCP를 메인 통신수단으로 사용하고, 필요한 부분에서만 UDP를 사용하는데, IP만으로 쉽게 매핑시킨다면 한 IP에서는 한명 밖에 게임을 못하는 등의 문제가 발생할 수 있습니다. &#8211; 이 경우 통신 내용으로 매핑을 해야 겠죠.)

* UDP ECHO Server

UDP의 경우 소켓 하나로 통신하기 때문에 별도의 처리 없이도 여러 개의 클라이언트에 대해서도 기능을 수행합니다.

#include
#include

#define PORT 1234

void main()
{
WSADATA WSAData;
SOCKADDR_IN addr;
SOCKET server;
char buffer[1024];
int readbytes, addrlen;

if (WSAStartup(MAKEWORD(1,1), &WSAData) != 0)
return;

server = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);

if (server == INVALID_SOCKET)
return ;

addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
addr.sin_addr.S_un.S_addr = htonl(INADDR_ANY);

if (bind(server, (struct sockaddr *)&addr, sizeof(addr)) == SOCKET_ERROR)
return ;

while(1)
{
addrlen = sizeof(addr);
readbytes = recvfrom(server, buffer, sizeof(buffer), 0, (struct sockaddr*) &addr, &addrlen);
printf("read bytes = %d (from %d.%d.%d.%d:%d)\\n", readbytes,
addr.sin_addr.S_un.S_un_b.s_b1, addr.sin_addr.S_un.S_un_b.s_b2,
addr.sin_addr.S_un.S_un_b.s_b3, addr.sin_addr.S_un.S_un_b.s_b4,
ntohs(addr.sin_port));
sendto(server, buffer, readbytes, 0, (struct sockaddr*) &addr, addrlen);
}

closesocket(server);
}

* UDP ECHO Client

#include
#include

#define PORT 1234
#define IP "127.0.0.1"

void main()
{
WSADATA WSAData;
SOCKADDR_IN addr;
SOCKET s;
char buffer[1024];
int readbytes, addrlen;
int i;

if (WSAStartup(MAKEWORD(1,1), &WSAData) != 0)
return;

s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);

if (s == INVALID_SOCKET)
return;

addr.sin_family = AF_INET;
addr.sin_port = htons(0);
addr.sin_addr.S_un.S_addr = htonl(INADDR_ANY);

if (bind(s, (struct sockaddr *)&addr, sizeof(addr)) == SOCKET_ERROR)
{
closesocket(s);
return;
}

while(1)
{
printf("enter messages\\n");

for(i=0; 1; i++)
{
buffer[i] = getchar();
if (buffer[i] == '\\n')
{
buffer[i++] = '';
break;
}
}

memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
addr.sin_addr.S_un.S_addr = inet_addr(IP);

printf("send messages (%d bytes)\\n", i);
sendto(s, buffer, i, 0, (struct sockaddr *)&addr, sizeof(addr));

addrlen = sizeof(addr);
readbytes = recvfrom(s, buffer, sizeof(buffer), 0, (struct sockaddr *)&addr, &addrlen);

printf("recv messages = %s (%d bytes) : from %d.%d.%d.%d:%d\\n", buffer, readbytes,
addr.sin_addr.S_un.S_un_b.s_b1, addr.sin_addr.S_un.S_un_b.s_b2,
addr.sin_addr.S_un.S_un_b.s_b3, addr.sin_addr.S_un.S_un_b.s_b4,
ntohs(addr.sin_port));
}

closesocket(s);
}

5. I/O Multiplexing

앞의 TCP echo 서버의 경우 하나의 소켓에 대해서만 처리되도록 구성되어 있습니다. 하지만 실제로 서버의 구성에서는 많은 수의 소켓에 대한 처리를 필요로 합니다.
다수의 소켓을 다루는 방법은 보통 두 가지 방법으로 접근합니다. 하나는 각 커낵션 별로 처리하는 쓰레드를 생성하는 것이고, 다른 하나는 하나, 혹은 소수개의 쓰레드에서 많은 수의 커낵션을 처리하는 방법입니다. (물론 쓰레드 하나에 모두 넌블록모드로 설정하고 자체적인 폴링처리를 하는 것도 가능은 합니다만, 프로세서 낭비가 심하겠죠.)
흔히 unix 계열 소켓함수에서 fork로 프로세스 복사 생성하는 방식이 전자의 경우인데, 많은 프로세스 / 쓰레드가 할당될 경우 메모리의 손실이 크고 (각 쓰레드별로 스택이 할당되어야 하므로), 쓰레드 전환(thread context switching)으로 인한 처리 비용이나 효율이 떨어져서 많은 커낵션을 처리하는 소켓서버(scalable socket server)에서는 많이 사용하지 않습니다.
I/O Multiplexing 이라 불리는 후자의 방법을 많이 사용하며, 이 중에서 select 와 윈도우환경의 IOCP에 대해서 정리해 보도록 하겠습니다.

I/O Multiplexing 함수의 가장 대중적인 예가 select 함수입니다. (거의 표준 기능이기 때문에 대부분의 플렛폼에서 지원합니다.) 설정된 소켓들 중에서 이벤트가 발생하는 소켓을 검사해주며, 이벤트가 발생하지 않을 때는 블록킹 되기 때문에 프로세서를 효율적으로 사용할 수 있습니다.

사용은 간단한 편으로, FD_ZERO로 테이블을 초기화한 후에 FD_SET으로 소켓을 추가하거나 FD_CLR로 삭제하면 되며, select 함수로 각 소켓의 상태를 받으면 됩니다. (참고로 select의 경우 리눅스와 윈도우의 결과 인터페이스가 조금 다릅니다. 아래는 win32 기준입니다.)

* ECHO Server (using SELECT)

이 서버는 앞의 하나의 연결만 가능했던 것과 비교해보면 쉽게 이해할 수 있으며, 이 서버의 경우 여러 개의 클라이언트가 붙어도 작동을 합니다. (단, 기본적으로 winsock.h 에서는 select시 최대 소켓수는 64이나, #include 보다 앞에 #define FD_SETSIZE 1024 식으로 미리 설정하면 그 값으로 소켓을 관리할 수도 있습니다. )

#include
#include

#define PORT 1234

void main()
{
WSADATA WSAData;
SOCKADDR_IN addr;
SOCKET server;
SOCKET client, s;
unsigned long readbytes;
fd_set fd, fdset;
int addrlen;

if (WSAStartup(MAKEWORD(1,1), &WSAData) != 0)
return;

server = socket(AF_INET, SOCK_STREAM, 0);

if (server == INVALID_SOCKET)
return;

addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
addr.sin_addr.S_un.S_addr = htonl(INADDR_ANY);

if (bind(server, (struct sockaddr *)&addr, sizeof(addr)) == SOCKET_ERROR)
return ;

if (listen(server, SOMAXConN) == SOCKET_ERROR)
return;

FD_ZERO(&fdset);
FD_SET(server, &fdset);

do {
fd = fdset;
if (select(FD_SETSIZE, (fd_set*)&fd, (fd_set*)0, (fd_set*)0, NULL) != SOCKET_ERROR)
{
for(unsigned int i=0; i {
s = fd.fd_array[i];
if (s == server)
{
addrlen = sizeof(addr);
client = accept(s, (struct sockaddr*)&addr, &addrlen);
FD_SET(client, &fdset);
printf("connected (%x) %d.%d.%d.%d:%dn", client,
addr.sin_addr.S_un.S_un_b.s_b1, addr.sin_addr.S_un.S_un_b.s_b2,
addr.sin_addr.S_un.S_un_b.s_b3, addr.sin_addr.S_un.S_un_b.s_b4,
ntohs(addr.sin_port));
} else
{
char buffer[1024];
ioctlsocket(s, FIONREAD, &readbytes);

if (readbytes == 0)
{
printf("disconnected (%x)n", s);
FD_CLR(s, &fdset);
} else
{
readbytes = recv(s, buffer, sizeof(buffer), 0);
send(s, buffer, readbytes, 0);
}
}
}
}
} while(1);

closesocket(server);
}

Select만으로 처리할 경우에는 특별히 데이터 동기화를 고려하지 않고도 작업을 할 수 있지만, 실제 메시지를 처리하는 것이 하나이기 때문에 만약 처리 중에 DB억세스나, 파일 억세스등의 I/O 작업이 있다고 하면, 그것을 완료할 때까지 다른 네트워크 메시지들이 대기하고 있어야 하므로 프로세서 자원 활용에 빈틈이 생기게 됩니다.
이를 처리하는 방법으로 윈도우에서 제공하는 IOCP(I/O Completion Port)가 있습니다. 기본 컨셉은 읽거나 쓰는 처리를 비동기로 처리하되, 읽히거나 쓴 결과는 작업 쓰레드(Worker Thread) 들 중의 하나에서 처리를 한다는 것입니다.

IOCP의 흐름을 요약하면 다음과 같습니다
- CreateIoCompletionPort 함수로 IOCP 개체 생성하기
- 다수의 워커 쓰레드 생성하기 (쓰레드풀 Thread Pool)
- 각 워커 쓰레드는 GetQueuedCompletionStatus함수로 Wait 상태로 대기하다가 자신에게 할당되는 IO처리가 생기면 Wakeup해서 결과에 대해 처리하기
- 처리하고자 하는 소켓(혹은 IO핸들)은 CreateIoCompletionPort로 기본 IOCP 개체에 추가 (이 경우 핸들별로 임의의 키 값을 설정 가능하며, GetQueuedCompletionStatus에서 결과로 키 값이 리턴됩니다.)
- 소켓에 ReadFile(혹은 WSARecv), WriteFile(WSASend)로 비동기 전송처리하기 (반드시 비동기 전송 처리를 해야 큐에 올라갑니다.) 비동기 처리할 때 넘기는 OVERLAPPED 구조체의 주소가 GetQueuedCompletionStatus에서 결과로 리턴됩니다.
- 소켓을 닫으면 IOCP 관련 설정이 없어짐

IOCP를 사용할 경우 만약 메시지 처리 중에 IO가 처리가 있어서 블록킹 되더라도 다른 쓰레드가 다음 메시지를 처리할 수 있게 되므로 아주 효과적으로 처리를 할 수 있습니다.

IOCP가 소켓 메시지 수신 시 메모리 버퍼의 간접 복사로 인한 부하를 줄인다는 측면에서도 이득이 있다는 의견이 있지만 개인적으로는 큰 장점은 안될 것으로 생각되며, 여러 가지로 오해를 살만한 부분이 있다고 생각합니다.

* TCP ECHO Server (using IOCP)

기본적인 IOCP를 이용한 ECHO 셈플로 서버에서 클라이언트로 수신내용을 리턴할 때 불필요한(?) 이벤트 발생을 줄이기 위해서 기본 send 함수를 이용해서 데이터를 전송했습니다. WSARead와 ReadFile을 사용한 예를 넣었으며, MSDN에서는 WSARead 사용하기를 권장하고 있습니다. 이 경우 wsock32.lib 대신 ws2_32.lib을 링크 리스트에 추가해야 합니다.

#include
#include

#define _MAX_THREAD 5

HANDLE g_hIocp = 0;
HANDLE g_hThread[_MAX_THREAD];

struct _CONNECT {
SOCKET socket;
OVERLAPPED op;
char buffer[1024];
} ;

unsigned long __stdcall _WorkThread(void *param)
{
unsigned long readbytes;
unsigned long dwCompKey;
OVERLAPPED * pOverlap;

while( 1 )
{
GetQueuedCompletionStatus(g_hIocp, &readbytes, &dwCompKey, (LPOVERLAPPED *)&pOverlap, INFINITE);

_CONNECT * con = (_CONNECT*) dwCompKey;

printf("%dn", readbytes);

if (readbytes == 0) // 접속 끊김
{
printf("disconnected (%x)n", con->socket);
closesocket(con->socket);
delete con;
continue;
} else
{
printf("read bytes = %d (%x)n", readbytes, con->socket);
send(con->socket, con->buffer, readbytes, 0);
}

#ifndef _USING_WSA
if (ReadFile((HANDLE)con->socket, con->buffer, sizeof(con->buffer), &readbytes, &con->op) == FALSE)
#else
WSABUF b[1];
unsigned long flags = 0;
b[0].buf = con->buffer;
b[0].len = sizeof(con->buffer);
if (WSARecv(con->socket, b, 1, &readbytes, &flags, &con->op, NULL) == SOCKET_ERROR)
#endif
{
if (GetLastError() != ERROR_IO_PENDING)
{
printf("disconnected (%x) : error %d\\n", con->socket, GetLastError());
closesocket(con->socket);
delete con;
}
}
}
}

#define PORT 1234

void main()
{
WSADATA WSAData;
SOCKADDR_IN addr;
SOCKET server;
SOCKET client;
_CONNECT * con;
int addrlen;
unsigned long readbytes;

if (WSAStartup(MAKEWORD(1,1), &WSAData) != 0)
return;

server = socket(AF_INET, SOCK_STREAM, 0);

if (server == INVALID_SOCKET)
return;

addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
addr.sin_addr.S_un.S_addr = htonl(INADDR_ANY);

if (bind(server, (struct sockaddr *)&addr, sizeof(addr)) == SOCKET_ERROR)
return ;

if (listen(server, SOMAXConN) == SOCKET_ERROR)
return;

DWORD threadid;
int i;

g_hIocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);

for(i=0; i<_MAX_THREAD; i++) <br /> g_hThread[i] = CreateThread(NULL, 0, _WorkThread, 0, 0, &threadid);


do {
addrlen = sizeof(addr);
client = accept(server, (struct sockaddr*)&addr, &addrlen);

if (client != INVALID_SOCKET)
{
printf("connected (%x) %d.%d.%d.%d:%dn", client,
addr.sin_addr.S_un.S_un_b.s_b1, addr.sin_addr.S_un.S_un_b.s_b2,
addr.sin_addr.S_un.S_un_b.s_b3, addr.sin_addr.S_un.S_un_b.s_b4,
ntohs(addr.sin_port));

con = new _CONNECT;
memset(con, 0, sizeof(_CONNECT));
con->socket = client;

printf("connected(%x)n", con->socket);

g_hIocp = CreateIoCompletionPort((HANDLE)client, g_hIocp, (DWORD)con, 0);

#ifndef _USING_WSA
ReadFile((HANDLE)con->socket, con->buffer, sizeof(con->buffer), &readbytes, &con->op);
#else
WSABUF b[1];
unsigned long flags = 0;
b[0].buf = con->buffer;
b[0].len = sizeof(con->buffer);
WSARecv(con->socket, b, 1, &readbytes, &flags, &con->op, NULL);
#endif
}
} while(1);

closesocket(server);
}


6. 동기화

아주 간단하게 서로 다른 쓰레드가 한 데이타를 공유했을 때 일어날 수 있는 문제를 생각해보겠습니다.

#include
#include
#include

volatile int g_cnt = 0;

void _cdecl _threadmain(void *)
{
while(1)
{
g_cnt = 1;
if (g_cnt != 1)
printf("errorn");
g_cnt = 0;
}
}

void main()
{
_beginthread(_threadmain, 0, 0);
_beginthread(_threadmain, 0, 0);
_beginthread(_threadmain, 0, 0);

Sleep(INFINITE);
}

위는 g_cnt라는 변수를 세개의 쓰레드에서 접근하는 셈플입니다. _threadmain 만을 봤을 때는 error 를 출력하는 상황은 발생하지 않는다고 볼 수 있겠습니다. 하나의 쓰레드일 경우에는 절대로 error 가 출력되지 않지만, 만약 쓰레드가 2개 이상인 경우에는 error 가 출력되는 일이 발생합니다. 즉, 처리중에 공유 데이타가 변경되는 경우에는 여러가지로 치명적인 문제가 발생할 수 있다는 얘기입니다. (더 자세한 상황은 운영체제 관련 책을 한권 읽어보시길 권합니다.)

이를 막기 위해선 동기화 객체를 이용해야 하는데, 개인적으로 많이 사용하는 것은 윈도우의 Event객체와 CreaticalSection 이며, 이 두가지를 이용해서 동기화 문제를 해결해보도록 하겠습니다.

먼저 CriticalSection 처리를 간단하게 살펴보겠습니다. Critical Section은 일종의 상호배제(Mutual Exclusion)의 일종으로 한 프로세스에서밖에 사용하지 못하지만 Mutex나 Semaphore 계열 함수보다 빠른 처리를 해줍니다. 기본적으로는 코드의 영역에 동시에 접근하지 못하도록, 즉, 공유 자원을 보호하는 역할을 합니다. EnterCriticalSection 함수로 보호를 하며 LeaveCriticalSection 함수로 Lock을 풀어줍니다. 만약 A 쓰레드에서 EnterCriticalSection으로 보호 받는 코드를 실행하다가, B 쓰레드로 Switching 되고, B 쓰레드가 그 같은 객체로 EnterCriticalSection 으로 접근하려고 하면 B쓰레드는 블럭상태가 되고, 즉시 대기중인 다른 쓰레드로 실행 권한을 넘기게 됩니다.

#include
#include
#include

volatile int g_cnt = 0;

CRITICAL_SECTION g_cs;

void _cdecl _threadmain(void *)
{
while(1)
{
EnterCriticalSection(&g_cs);
g_cnt = 1;
if (g_cnt != 1)
printf("errorn");
g_cnt = 0;
LeaveCriticalSection(&g_cs);
}
}

void main()
{
InitializeCriticalSection(&g_cs);

_beginthread(_threadmain, 0, 0);
_beginthread(_threadmain, 0, 0);
_beginthread(_threadmain, 0, 0);

Sleep(INFINITE);

DeleteCriticalSection(&g_cs);
}

간단히 위처럼 작성한다면 error 가 출력되는 일은 절대 발생하지 않을 것입니다. 아래는 Event객체를 사용하는 예제로 단순 동기화로는 쉽게 사용할 수 있습니다. 그리고 비슷한 방법으로 Mutex 오브젝트로 사용할 수 있습니다. (주석 처리된 부분과 비교)

#include
#include
#include

volatile int g_cnt = 0;

HANDLE g_event;
//HANDLE g_mutex;

void _cdecl _threadmain(void *)
{
while(1)
{
WaitForSingleObject(g_event, INFINITE);
// WaitForSingleObject(g_mutex, INFINITE);
g_cnt = 1;
if (g_cnt != 1)
printf("errorn");
g_cnt = 0;
SetEvent(g_event);
// ReleaseMutex(g_mutex);
}
}

void main()
{
g_event = CreateEvent(NULL, FALSE, TRUE, NULL);
// g_mutex = CreateMutex(NULL, FALSE, NULL);

_beginthread(_threadmain, 0, 0);
_beginthread(_threadmain, 0, 0);
_beginthread(_threadmain, 0, 0);

Sleep(INFINITE);

CloseHandle(g_event);
// CloseHandle(g_mutex);
}

위는 Unnamed Mutex나 Unnamed Event 에 대해서 다룬 것인데, 이름을 이용하면 서로 다른 프로세스(process)에서의 동기화도 조절할 수 있습니다. 기타 Timer나 Semaphore 등의 동기화 객체들도 있으나, 딱 역시 동기화가 주 논점은 아니고, 다른 상황에 사용되는 것이므로 생략합니다. (하지만 간단하게 숙지해두신다면 필요한 상황에서 어렵게 고생하는 일을 줄일 수도 있을 거 같습니다.)


특히나 IOCP처럼 쓰레드풀을 사용하는 방식에서는 동기화 문제로 치명적인 문제가 생길 수도 있으며, 잘못된 동기화 설정으로 프로세서를 최대한 활용하지 못하는 경우도 생길 수 있습니다.

7. 기타 네트워크 이슈

몇 가지 큰 이슈에 대해서만 짚어보겠습니다. (아는 게 없어서 수박 겉핥기식입니다.)

대역폭 (bandwidth)
서로 교환하는 데이터의 크기와 관련된 내용입니다. 대역폭이 초당 1mb가 한계라면 커넥션당 5 kb정도로 주고 받도록 설계되면 200명 정도밖에 소화할 수 없게 됩니다. 그 이상이 되면 실제적인 네트워크 latency 보다 더 많은 시간을 기다리는 일이 생기게 되므로 반드시 피해야 될 상황이라고 하겠습니다.
이상적으로는 데이터를 줄일 수 있는 만큼 줄이는 것이 좋으며, 연산 가능하거나 예측가능한 부분은 제거하고, 중요도가 낮은 데이터의 전송 빈도수 조절하는 식으로 가능한 모든 방법을 동원해서 줄여주는 것이 좋습니다. 전송량의 일부분은 프로그래밍의 수고와 관련된 부분이 많기 때문에 충분히 노력하면 많은 효과를 볼 수 있다고 생각합니다.
패킷 구성에 대한 개인적인 정책은 ‘빈도수가 높은 패킷은 절대로 다이어트 하되, 빈도수가 아주 낮은 패킷은 크게 신경 안 쓴다.’ 입니다.

네트워크딜레이 (latency)
대역폭과는 다른 문제로 레이턴시(latency)는 네트워크 정보가 클라이언트에서 보내서 서버에 도착하는 시간, 반대로 서버에서 보내서 클라이언트에 도착하는 시간으로 게임의 반응과 판단에 직접적인 영향을 주는 요소입니다.
서버로부터 도착한 정보는 지금의 정보가 아니라 일정 시간이 지난 정보가 되는 것이므로 이 부분을 고려해서 프로그래밍을 해야 됩니다만, 물리적으로 피할 수 없는 부분이고, 딜레이 자체도 상수 값이라고 볼 수 없기 때문에 더욱 힘든 부분인 거 같습니다. 데드 레커닝(dead reckoning) 관련된 주제를 찾아보시면 도움이 될 것입니다.
기본적으로 TCP보다는 UDP를 사용하는 것이 레이턴시를 줄이는 가장 쉬운 방법이지만, UDP를 사용하기 위해선 프로그래밍적으로 견고하게 구성을 해야 합니다. (저는 그냥 쉽게 처리하기 위해서 신뢰도가 높은 패킷은 TCP로, 반드시 전송되어야 하는 것은 아니지만 속도가 빠르면 좋은 패킷은 UDP로, 신뢰도도 높아야 하지만 빠를 필요도 있는 패킷은 TCP, UDP 모두 보내서 먼저 받는 걸로 ? 즉 UDP로 전송하되, TCP는 보험으로... ^^ - 처리하고 있습니다.)
개인적으로는 이 부분은 프로그래밍적으로 해결하는 것에는 한계가 있으며, 게임 디자인적으로도 고려되어야 만족할 수준으로 해결할 수 있다고 생각합니다.

보안
아마 게임 서비스에 직접적인 영향을 주는 요소가 아닐까 생각됩니다. 업계 분들의 얘기를 들어보면 해킹 하려는 유저들의 의지가 강하고 방법도 버라이어티해서 완벽하게 막는 다른 것은 불가능해 보입니다.
제 개인적인 정책은 책이나 게시판에 나온 수준의 쉽게 따라서 할 수 있는 흔한 해킹은 막아보자는 정도이며, 기본적으로는 패킷을 수정하거나, 클라이언트 데이터를 수정해도 크게 도움이 안되도록 서버/클라이언트를 구성하는 것이 최선의 해킹 방지라는 업계의 불문율을 따르고 있습니다. (나름대로의 규칙이 얼마나 잘 지켜지고 얼마나 효과가 있는 지는 조만간 게임을 서비스해보면 확인할 수 있겠죠. 물론 인기가 없으면 다른 의미로 보안문제가 해결되겠지만요. T_T)

네트웍 상황
이 부분도 경험이 부족한 관계로 규정할 수 없지만 IP공유기를 사용한다거나, 유동 IP를 환경이라던가, 여러 가지 네트워크 상황에서도 게임 플레이가 되려면 여러 가지 시나리오를 작성해서 프로그래밍을 해야 할 것입니다. 일부를 Peer2peer로 처리한다고 했을 때 양쪽 다 파이어월(firewall) 환경이라 외부에서 연결하는 것이 안 된다면, 특정 포트로 게임포트를 정해버려서 IP공유기 환경에서는 게임이 한명 밖에 안 된다면, 플레이어 중에 중계 서버 역할을 주려는데, 모든 유저가 파이어월로 막혀 있다면, 등등 프로그래머가 많이 고려하면 할수록 다양한 환경에서 게임이 원활하게 진행될 거라고 생각됩니다.
그리고 중간에 연결이 끊기는 상황, 악의적으로 연결을 차단하는 상황 등 유동적으로 바뀌는 상황도 놓쳐서는 안될 거 같습니다.


8. 레퍼런스

- Design Issues When Using IOCP in a Winsock Server
http://support.microsoft.com/default.aspx?scid=http://support.microsoft.com:80/support/kb/articles/Q192/8/00.ASP&NoWebContent=1

- Windows Sockets 2.0: Write Scalable Winsock Apps Using Completion Ports
http://msdn.microsoft.com/msdnmag/issues/1000/winsock/

- WinSock Development Information
http://www.sockets.com/

- Targeting A variation of dead reckoning
http://www.gamedev.net/reference/articles/article1370.asp

- Dead Reckoning: Latency Hiding for Networked Games
http://www.gamasutra.com/features/19970919/aronson_01.htm

트랙백 주소 :: http://gamecode.org/tt/trackback/59

댓글을 달아 주세요

  1. pSoul 2006/09/20 21:35 댓글주소 수정/삭제 댓글쓰기

    좋은 글 많이 읽고 갑니다.

  2. 워니 2007/02/23 17:29 댓글주소 수정/삭제 댓글쓰기

    아아, 덕분에 어떻게 공부해야할지 가닥이 잡히는 것 같네요 - 감사합니다

<form method="post" action="/tt/comment/add/59" onsubmit="return false" style="margin: 0">

</form>