상세 컨텐츠

본문 제목

C#게임서버, C# 서버 통신 관련, 왜 C#으로 서버를 작성하려 하나? C# 서버 관련

프로그래밍 관련/네트워크, 통신

by AlrepondTech 2020. 9. 17. 06:38

본문

반응형

 

 

 

=======================

=======================

=======================

 

 

 

 

 

 

 

출처: http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Lecture_series&no=62

C#으로 게임 서버 만들기 - 1. 네트워크 기반 코드 작성

 

■ C#으로 게임서버 만들기 강좌

C#으로 게임서버 만들기 강좌를 시작합니다.

.Net 3.5버전부터 추가된 SocketAsyncEventArgs클래스를 이용하여 TCP서버를 구현할 것입니다.

(이전 버전에서는 Begin~End매커니즘을 사용했었습니다)

네트워크 모듈 구현이 완료되면 이것을 바탕으로 실시간 네트워크 게임을 개발해보면서

실제 게임에 어떤식으로 적용되는지 알아보도록 하겠습니다.

 

* 저도 공부하면서 연구한 내용을 공유하는 것이기 때문에 오류가 있을 수 있으니 잘못된 부분은 지적 부탁드립니다.

* 강좌에 표시된 소스코드에는 리소스 해제등의 처리가 생략되어 있을 수 있습니다.

* 연구하면서 인터넷에 있는 오픈소스들을 많이 참고하였는데 그분들께 감사드립니다.

 

 

■ 차례

-1장. 네트워크 기반-

네트워크 기본 모듈 작성

클라이언트의 접속 처리하기

스레드를 통한 Accept처리

클라이언트 접속 이후의 처리

 

-2장. TCP에서의 메시지 처리 방법-

메시지 경계 처리

패킷 구조 설계

 

-3장. 실전-

서버, 클라이언트 테스트 코드 구현

유니티 엔진과 연동하는 방법

실시간 네트워크 게임 개발

 

-1장 시작-

 

■ 네트워크 기본 모듈 작성

네트워크 통신을 하기 위한 기반 코드를 작성해 보겠습니다.

강좌 목표에 집중하기 위해서 이 강좌에서는 TCP소켓만 지원하겠습니다.

 

CNetworkService클래스에 네트워크 통신에 필요한 기반이 될 코드들을 넣어볼것입니다.

일단 멤버 변수들 부터 알아보도록 하죠.

 

public class CNetworkService  
{  
    // 클라이언트의 접속을 받아들이기 위한 객체입니다.  
    CListener client_listener;  
  
    // 메시지 수신, 전송시 필요한 오브젝트입니다.  
    SocketAsyncEventArgsPool receive_event_args_pool;  
    SocketAsyncEventArgsPool send_event_args_pool;  
  
    // 메시지 수신, 전송시 .Net 비동기 소켓에서 사용할 버퍼를 관리하는 객체입니다.  
    BufferManager buffer_manager;  
  
    // 클라이언트의 접속이 이루어졌을 때 호출되는 델리게이트 입니다.  
    public delegate void SessionHandler(CUserToken token);  
    public SessionHandler session_created_callback { get; set; }}  
  
    ...

 

클라이언트의 접속을 받아들이는 Listener객체가 선언되어 있습니다.

소켓 프로그래밍을 배워보신 분이라면 bind -> listen -> accept 순서를 알고 계실겁니다.

.Net이 최신 기술이긴 하지만 TCP와 관련된 부분은 옛부터(?) 내려오고 있는 흐름을 거의 그대로 따라갑니다.

다른 언어로 구현한다고 해도 이 부분은 크게 변하지 않죠.

 

그 다음줄을 보면 생소한 객체가 보일겁니다.

SocketAsyncEventArgs 라는 클래스는 .Net비동기 소켓에서 사용하는 개념으로

비동기 소켓 매소드를 호출할 때 항상 필요한 객체입니다.

이전에 사용하던 Begin~End계열의 API를 쓸 때보다 더 발전된 부분이라고 하는데요,

매번 IAsyncResult를 생성하지 않고 저렇게 풀링하여 사용할 수 있기 때문에 메모리 재사용이 가능한것이 장점입니다.

MSDN의 문서중에는 객체를 풀링하여 쓰는것이 기존 native c++에서 하는 풀링만큼의 

효율을 가져오지 않을 수 있다고 나와있습니다.

 

하지만 저는 풀링하여 쓰는 쪽을 선택했습니다. 

어차피 서버가 살아있는 동안에는 계속 사용할 메모리이기 때문에 풀링하는게 낫겠다 싶었습니다.

 

그 다음줄에 BufferManager가 보입니다.

이름에서도 알 수 있듯이 데이터 송,수신할 때 사용할 버퍼를 관리하는 매니저 객체 입니다.

소켓에 관련된 책을 보면 송,수신 버퍼라는 얘기를 자주 듣게 됩니다.

TCP에서 데이터를 보내고 받을 때 소켓마다 버퍼라는것이 할당됩니다.

이건 OS딴에서 구현되어 있는 부분이라 우리가 신경쓸 필요는 없습니다.

우리는 이 소켓 버퍼로부터 메시지를 복사해 오고(수신) 밀어넣는(전송) 작업을 할 때 사용할 버퍼를 설정해주면 됩니다.

이 버퍼 역시 네트워크 통신이 지속되는 동안에 계속해서 사용하는 메모리가 되겠습니다.

따라서 이것도 풀링하여 메모리를 재사용 할 수 있도록 해야겠죠.

Net환경에서는 가비지 컬렉션이 작동되므로 꼭 풀링하지 않아도 되지만 
버퍼라는건 거의 매순간 쓰인다고 봐도되니 그냥 풀링하기로 합시다.

 

그 다음에는 델리게이트가 정의되어 있는데 클라이언트가 접속했을 때 어딘가로 통보해주기 위한 수단입니다.

 

■ 클라이언트의 접속 처리하기

이제 클라이언트의 접속을 처리하기 위한 코드를 작성해 보겠습니다.

위에서 말씀드렸듯이 TCP서버 구현의 흐름은 bind -> listen -> accept순으로 진행됩니다.

 

class CListener  
{  
    // 비동기 Accept를 위한 EventArgs.  
    SocketAsyncEventArgs accept_args;  
  
    // 클라이언트의 접속을 처리할 소켓.  
    Socket listen_socket;  
  
    // Accept처리의 순서를 제어하기 위한 이벤트 변수.  
    AutoResetEvent flow_control_event;  
  
    // 새로운 클라이언트가 접속했을 때 호출되는 콜백.  
    public delegate void NewclientHandler(Socket client_socket, object token);  
    public NewclientHandler callback_on_newclient;  
  
    public CListener()  
    {  
        this.callback_on_newclient = null;  
    }  
  
    public void start(string host, int port, int backlog)  
    {  
        // 소켓을 생성합니다.  
        this.listen_socket = new Socket(AddressFamily.InterNetwork,  
            SocketType.Stream, ProtocolType.Tcp);  
  
        IPAddress address;  
        if (host == "0.0.0.0")  
        {  
            address = IPAddress.Any;  
        }  
        else  
        {  
            address = IPAddress.Parse(host);  
        }  
        IPEndPoint endpoint = new IPEndPoint(address, port);  
  
        try  
        {  
            // 소켓에 host정보를 바인딩 시킨뒤 Listen매소드를 호출하여 준비를 합니다.  
            this.listen_socket.Bind(endpoint);  
            this.listen_socket.Listen(backlog);  
  
            this.accept_args = new SocketAsyncEventArgs();  
            this.accept_args.Completed += new EventHandler<SocketAsyncEventArgs>(on_accept_completed);  
  
            // 클라이언트가 들어오기를 기다립니다.  
            // 비동기 매소드 이므로 블로킹 되지 않고 바로 리턴됩니다.  
            // 콜백 매소드를 통해서 접속 통보를 처리하면 됩니다.  
            this.listen_socket.AcceptAsync(this.accept_args);  
        }  
        catch (Exception e)  
        {  
            //Console.WriteLine(e.Message);  
        }  
    }  
  
    ...  

Listen처리 코드의 일부분 입니다. 생소한 코드가 많이 보이겠지만 중요한 부분은 몇군데 밖에 없습니다.

CListener라는 클래스를 선언하였습니다. 앞서 보여드린 CNetworkService클래스에 통합하지 않고

코드를 따로 분리하였는데 그 이유는 Listener를 여러개 두는 구조로 설계하였기 때문입니다.

경우에 따라서 클라이언트의 접속을 받아들이는 Listener, 

서버간 통신을 위해서 다른 서버의 접속을 받아들이는 Listener등 여러개의 Listener가 존재할 수 있습니다.

이런 부분을 대비하기 위해서 위와 같이 분리된 구조를 채택한 것입니다.

 

하나의 클래스에 여러가지 기능을 다 넣어도 상관없지만 코딩하다 보면 분리할 필요성을 느낄 때가 있습니다.
처음 작성을 시작했을 때는 CNetworkService클래스에 Listen코드까지 포함되어 있었습니다.
하지만 여러가지 테스트를 해보고 다른사람이 작성한 코드도 살펴보니 대부분 분리되어 있더군요.
이 강좌를 읽으시는 분들도 그냥 따라오지만 마시고 왜 이렇게 설계를 했는지
더 좋은 방법은 없는지 생각해보시면 좋을것 같습니다.

 

IPEndPoint 라는 객체는 끝점이라고 말할 수 있는데 도착지점으로 이해하시면 됩니다.

클라이언트가 도착할 지점은 서버가 됩니다. 서버의 IP, Port정보로 IPEndPoint가 구성됩니다.

이 서버가 Host가 되며 클라이언트는 Peer라고 말할 수 있습니다.

 

그 다음줄은 bind -> listen순으로 진행됩니다.

소켓 프로그래밍 책에서 많이 보셨을 겁니다. 서버 정보를 소켓에 bind시키고 listen을 호출하여 준비작업을 마칩니다.

 

this.accept_args = new SocketAsyncEventArgs();  
this.accept_args.Completed += new EventHandler<SocketAsyncEventArgs>(on_accept_completed);  

드디어 SocketAsyncEventArgs라는 객체를 사용할 때가 왔습니다.

Completed프로퍼티에 이벤트 핸들러 객체를 연결시켜 주고 AcceptAsync호출시 파라미터로 넘겨주기만 하면 됩니다.

Completed라는 이름에서 알 수 있듯이 accept처리가 완료되었을 때 호출되는 델리게이트 입니다.

.Net비동기 소켓에서는 이처럼 매소드 호출 -> 완료 통지 개념으로 이루어 집니다.

 

this.listen_socket.AcceptAsync(this.accept_args);  

이제 accept처리 부분입니다. 이 강좌에서는 비동기 매소드를 사용하기로 하였으므로 AcceptAsync를 호출합니다.

이 매소드는 호출한 직후 바로 리턴되며 accept결과에 대해서는 콜백 매소드로 통보가 옵니다.

따라서 프로그램이 블로킹 되지 않고 통보를 기다리며 다른 일을 할 수 있게 되죠.

 

AcceptAsync까지 호출하면 클라이언트의 접속을 받아들일 수 있는 상태가 됩니다.

간단하지만 방금 우리는 서버를 하나 만들었습니다!

온라인 게임, 트위터, 유튜브등 엄청난 서버들도 다 이런 작은 코드로부터 시작되었을 겁니다.

 

 

■ 스레드를 통한 Accept처리

위에서 AcceptAsync호출 부분을 스레드를 통해 처리하도록 바꿔볼 것입니다.

꼭 스레드를 통하지 않고도 accept처리는 가능합니다.

하지만 특정OS버전에서 콘솔 입력이 대기중일 때 accept처리가 안된다는 버그가 있다는 문서를 발견한 적이 있습니다.

(http://stackoverflow.com/questions/12464185/windows-8-net-tcp-acceptasync-callback-not-firing-blocked-by-console-readlin)

메인 스레드가 입력을 위해 대기 상태에 있다고 하더라도 accept처리를 별도의 스레드에서 돌아가도록 구성 한다면

위 문제를 회피할 수 있을 것이므로 이렇게 구현하려고 하는것입니다.

 

this.listen_socket.AcceptAsync(this.accept_args);  

이 부분을 아래처럼 바꿔보겠습니다.

Thread listen_thread = new Thread(do_listen);  
listen_thread.Start();  

스레드를 생성하고 do_listen이라는 매소드를 스레드에서 처리하도록 합니다.

void do_listen()  
{  
    // accept처리 제어를 위해 이벤트 객체를 생성합니다.  
    this.flow_control_event = new AutoResetEvent(false);  
  
    while (true)  
    {  
        // SocketAsyncEventArgs를 재사용 하기 위해서 null로 만들어 준다.  
        this.accept_args.AcceptSocket = null;  
  
        bool pending = true;  
        try  
        {  
            // 비동기 accept를 호출하여 클라이언트의 접속을 받아들입니다.  
            // 비동기 매소드 이지만 동기적으로 수행이 완료될 경우도 있으니  
            // 리턴값을 확인하여 분기시켜야 합니다.  
            pending = listen_socket.AcceptAsync(this.accept_args);  
        }  
        catch (Exception e)  
        {  
            Console.WriteLine(e.Message);  
            continue;  
        }  
  
        // 즉시 완료 되면 이벤트가 발생하지 않으므로 리턴값이 false일 경우 콜백 매소드를 직접 호출해 줍니다.  
        // pending상태라면 비동기 요청이 들어간 상태이므로 콜백 매소드를 기다리면 됩니다.  
        // http://msdn.microsoft.com/ko-kr/library/system.net.sockets.socket.acceptasync%28v=vs.110%29.aspx  
        if (!pending)  
        {  
            on_accept_completed(null, this.accept_args);  
        }  
  
        // 클라이언트 접속 처리가 완료되면 이벤트 객체의 신호를 전달받아 다시 루프를 수행하도록 합니다.  
        this.flow_control_event.WaitOne();  
    }  
}  

 

this.flow_control_event = new AutoResetEvent(false);  

스레드의 시작 부분에는 이벤트 객체를 생성하는 코드가 들어있습니다.

하나의 접속 처리가 완료된 이후 그 다음 접속 처리를 수행하기 위해서 스레드의 흐름을 제어할

필요가 있는데 그때 사용되는 이벤트 객체입니다.

 

while (true)  
{  
    ...  
}  

루프를 돌며 클라이언트의 접속을 받아들입니다.

 

pending = listen_socket.AcceptAsync(this.accept_args);  

AcceptAsync의 리턴값에 따라 즉시 완료처리를 할 것인지

통보가 오기를 기다릴 것인지 구분해 줘야 합니다.

 

accept처리가 동기적으로 수행이 완료될 경우에는 콜백매소드가 호출되지 않고 false를 리턴합니다.

따라서 이 경우에는 완료처리를 담당하는 매소드를 직접 호출해줘야 합니다.

그 외의 경우(true를 리턴)에는 .Net에서 콜백 매소드를 호출해주기 때문에 직접 호출할 필요가 없습니다.

아마 대부분 후자의 경우가 발생하지 않을까 하네요.

 

Net비동기 매소드를 호출할 때 주의할 점은 즉시 완료될 경우와 그렇지 않을 경우가 있다는 것입니다.
매소드의 리턴값이 true/false중 어느것으로 나오는가에 따라서 구분해주면 됩니다.
this.flow_control_event.WaitOne();  

 

AcceptAsync를 통해서 하나의 클라이언트가 접속되기를 기다린 후 이벤트 객체를 이용하여 스레드를 잠시 대기상태로 둡니다.

 

이벤트 객체에 대해서 잘 모르시는 분들은 인터넷에 이해하기 쉽게 설명된 자료가 많으니
꼭 찾아서 읽어보시길 바랍니다.
이벤트 객체의 종류에는 두가지가 있는데
AutoResetEvent는 한번 Set이 된 이후 자동으로 Reset상태로 만들어주며,
ManualResetEvent는 직접 Reset매소드를 호출하지 않는 한 계속 Set상태로 남아있습니다.
위 두가지 이벤트 객체는 이러한 차이점이 있으니 상황에 맞게 골라쓰면 됩니다.
void on_accept_completed(object sender, SocketAsyncEventArgs e)  
{  
    if (e.SocketError == SocketError.Success)  
    {  
        // 새로 생긴 소켓을 보관해 놓은뒤~  
        Socket client_socket = e.AcceptSocket;  
  
        // 다음 연결을 받아들인다.  
        this.flow_control_event.Set();  
  
        // 이 클래스에서는 accept까지의 역할만 수행하고 클라이언트의 접속 이후의 처리는  
        // 외부로 넘기기 위해서 콜백 매소드를 호출해 주도록 합니다.  
        // 이유는 소켓 처리부와 컨텐츠 구현부를 분리하기 위함입니다.  
        // 컨텐츠 구현부분은 자주 바뀔 가능성이 있지만, 소켓 Accept부분은 상대적으로 변경이 적은 부분이기 때문에  
        // 양쪽을 분리시켜주는것이 좋습니다.  
        // 또한 클래스 설계 방침에 따라 Listen에 관련된 코드만 존재하도록 하기 위한 이유도 있습니다.  
        if (this.callback_on_newclient != null)  
        {  
            this.callback_on_newclient(client_socket, e.UserToken);  
        }  
  
        return;  
    }  
    else  
    {  
        //Accept 실패 처리.  
        Console.WriteLine("Failed to accept client.");  
    }  
  
    // 다음 연결을 받아들인다.  
    this.flow_control_event.Set();  
}  

AcceptAsync호출 결과를 통보받을 매소드를 구현해 봤습니다.

파라미터로 넘어온 값을 비교하여 성공, 실패에 대한 처리를 구현해 주면 됩니다.

성공했을 경우에는 자동으로 소켓이 하나 생성되는데 이 소켓을 잘 보관해 놓았다가

클라이언트와 통신할 때 사용하도록 합니다.

그리고 콜백 매소드를 호출하여 성공했음을 알려준 뒤

또다른 연결을 받아들이기 위해서 이벤트 객체를 Set상태로 만들어 줍니다.

코드 흐름을 따라가 보면 잠시 대기해 있던 스레드가 진행되면서 다시 AcceptAsync매소드를 호출하게 됩니다.

이렇게 하나의 접속을 처리하고 다음 접속을 처리하기 위해 무한 루프를 돌며 accept를 수행하는 방식으로 구현되어 있습니다.

 

하나의 스레드에서 순차적으로 accept를 처리하는 구현 방식과 여러 스레드에서 동시에 처리하는 구현 방식중
어느 방식이 더 성능이 좋은가 하는 부분은 저도 확실히 테스트하지 못한 부분입니다.
따라서 이 강좌에서는 하나의 accept를 완료한 뒤 다음 accept를 처리하는 순차적인 방식으로 설명하였습니다.

 

다음 강좌에서는 접속 이후의 로직에 대해서 설명하도록 하겠습니다.

 

* 게임코디님에 의해서 게시물 이동되었습니다 (2014-10-05 16:22)

 

----------------------------------------------------------------------------------------------------------------------------------------------------------------------------

 

^^ 유익한 강좌 잘 봤습니다. 한 가지... 궁금해서 그러는데요.
"클라이언트의 접속 처리하기"의 코드에 사용된 on_accept_completed 함수의 경우에는 this.accept_args.Completed 이벤트에 반응한 다음 그 내부에서 다시 this.listen_socket.AcceptAsync(this.accept_args); 호출을 해야 하는 거 아닌가요?
2014-09-30
21:47:50
쿠퍼님/on_accept_completed함수 9번째 라인을 보시면 이벤트 객체를 Set해주는 부분이 있습니다.
이 이벤트 신호에 의해서 do_listen함수의 while루프 안에서 WaitOne으로 대기해 있던 스레드가 깨어나
AcceptAsync가 다시 호출되는 구조입니다.

2014-09-30
22:03:14
아... 저는 2개의 방식이 따로 구현된 것인줄 알았습니다. "하나의 스레드에서 순차적으로 accept를 처리하는 구현 방식과 여러 스레드에서 동시에 처리하는 구현 방식중"이라고 하셔서 "클라이언트의 접속 처리하기" 절의 소스는 후자를 위한 기반 코드를 맛배기로 보여주고, "스레드를 통한 accept처리"절의 소스는 전자를 구현한 것이라고 생각했습니다. ^^ 2014-10-01
10:28:26

 

이 강좌를 보고 서버를 만들어보려고 하는데 플랫폼은 visual studio인가요? 2015-04-14
14:12:50

 

ㄴ 네. visual studio 2013버전을 사용했습니다. 2015-04-14
21:58:17

 

안녕하세요 사용하신 서버를 이용해서 클라이언트 접속 요청을 해보니250개의 클라이언트 접속 후에 클라이언트 쪽에서 접속하려고 소켓을 열면 "대상 컴퓨터에서 연결을 거부 했으므로 연결하지 못했습니다"라는 메시지와 함께 접속이 되지 않습니다.
서버의 방화벽에서 해당포트가 열려있고 서버실행시에 Listen으로 갯수를 지정을 250개 이상했는데도 동일한 현상이 있습니다.
혹시 해결방법을 아시는지요?
2015-08-11
12:00:16

 

와.. 진짜 시중에 나와있는 책보다 내용이 훨씬 좋습니다.

한가지 궁굼한게 결국은 순차적으로 accept()되는거라면 비동기식 accept()말고 동기식 accept()로 하면 이렇게 번거롭게 작업할 필요 없지 않나요?


제가 C언어로만 서버 만들어보고 C#으로는 만들지 않아봐서 궁굼하네요 ^^
2015-08-28
03:38:33
건앤로즈님/제가 테스트 해봤을 때는 250개 이상의 연결도 이상없이 수행되었습니다. 수천개까지도 테스트를 했었는데 말씀하신 증상은 나타나지 않았었거든요. 특정 상황에 따른 문제인듯 한데 한번 살펴보고 이상이 있으면 알려드리겠습니다.


김민상님/
(지금은 한빛미디어에 이북으로도 출판되어 있습니다.^^)
특별히 비동기식 accept로 처리한 이유가 있는것은 아니고요 
비동기식api를 사용하여 작성하기로 계획하고 연구를 했기 때문에 이렇게 작성된 것입니다.
성능 비교는 아직 해보지 않아서 어떤방식이 더 좋은지는 모르겠네요.^^
오류가 없고 성능만 잘 나온다면 동기식도 문제 없을거라 생각합니다.
2015-08-28
10:48:04

 

          그냥 궁금해서 질문을 드립니다.
C#에서는 수백명의 유저의 패킷을 처리 할때 스레드 컨텍스트 스위칭 문제는 어떻게 해결 하시나요??
워크 쓰레드 수를 제한 할수 있나요??
2015-08-30
19:30:16 

 

 

 

 

 

=======================

=======================

=======================

 

 

 

 

 

 

출처: http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Lecture_series&page=1&sn1=&divpage=1&sn=off&ss=on&sc=on&select_arrange=hit&desc=asc&no=61

C#으로 게임 서버 만들기 - 2. 접속 처리 및 버퍼 풀링 기법

 

■ 접속 이후의 처리

 

CNetworkService세부 구현 내용

Accept처리가 완료되었을 때 on_new_client델리게이트를 호출해주는 부분까지가 CListener클래스의 역할이었습니다.

이제 CNetworkService클래스에 대해서 좀 더 자세히 들어가보도록 하겠습니다.

앞서 설명드린 대로 CNetworkService클래스에는 네트워크 기반이 되는 코드가 들어가게 됩니다.

대표적인 기능은 다음과 같습니다.

- CListener를 생성하여 클라이언트의 접속을 처리

- SocketAsyncEventArgs객체를 풀링하여 재사용 가능하도록 구현

- 메시지 송수신 버퍼를 풀링하는 매니저 클래스 구현

 

listen처리

public void listen(string host, int port, int backlog)  
{  
    CListener listener = new CListener();  
    listener.callback_on_newclient += on_new_client;  
    listener.start(host, port, backlog);  
}  

 

listener생성 부분 입니다.

서버의 host, port, backlog값을 전달 받아 listener를 생성한 뒤 start매소드로 접속을 기다립니다.

 

SocketAsyncEventArgs풀링 구현

소켓별로 두개의 SocketAsyncEventArgs가 필요합니다.

하나는 전송용, 다른 하나는 수신용 입니다.

그리고 SocketAsyncEventArgs마다 버퍼를 필요로 하는데 결국 하나의 소켓에 전송용 버퍼 한개, 수신용 버퍼 한개

총 두개의 버퍼가 필요하게 됩니다.

먼저 SocketAsyncEventArgs를 어떻게 풀링하여 사용하는지 알아보겠습니다.

 

public class CNetworkService  
{  
    // 메시지 수신용 풀.  
    SocketAsyncEventArgsPool receive_event_args_pool;  
  
    // 메시지 전송용 풀.  
    SocketAsyncEventArgsPool send_event_args_pool;  
  
    ...  

 

전송용, 수신용 풀 객체를 각각 선언합니다.

풀링에 사용되는 클래스는 SocketAsyncEventArgsPool입니다.

이 코드는 msdn에 있는 샘플 코드를 그대로 가져왔습니다.

 

// Represents a collection of reusable SocketAsyncEventArgs objects.    
class SocketAsyncEventArgsPool  
{  
    Stack<SocketAsyncEventArgs> m_pool;  
  
    // Initializes the object pool to the specified size  
    //  
    // The "capacity" parameter is the maximum number of   
    // SocketAsyncEventArgs objects the pool can hold  
    public SocketAsyncEventArgsPool(int capacity)  
    {  
        m_pool = new Stack<SocketAsyncEventArgs>(capacity);  
    }  
  
    // Add a SocketAsyncEventArg instance to the pool  
    //  
    //The "item" parameter is the SocketAsyncEventArgs instance   
    // to add to the pool  
    public void Push(SocketAsyncEventArgs item)  
    {  
        if (item == null) { throw new ArgumentNullException("Items added to a SocketAsyncEventArgsPool cannot be null"); }  
        lock (m_pool)  
        {  
            m_pool.Push(item);  
        }  
    }  
  
    // Removes a SocketAsyncEventArgs instance from the pool  
    // and returns the object removed from the pool  
    public SocketAsyncEventArgs Pop()  
    {  
        lock (m_pool)  
        {  
            return m_pool.Pop();  
        }  
    }  
  
    // The number of SocketAsyncEventArgs instances in the pool  
    public int Count  
    {  
        get { return m_pool.Count; }  
    }  
}  

코드 자체는 굉장히 간단합니다.

객체를 담을 수 있는 Stack을 생성하여 Pop / Push매소드를 통해서 꺼내오고 반환하는 작업을 수행합니다.

 

this.receive_event_args_pool = new SocketAsyncEventArgsPool(this.max_connections);  
this.send_event_args_pool = new SocketAsyncEventArgsPool(this.max_connections);  

최대 동접 수치만큼 생성합니다.

 

for (int i = 0; i < this.max_connections; i++)  
{  
    CUserToken token = new CUserToken();  
  
    // receive pool  
    {  
        //Pre-allocate a set of reusable SocketAsyncEventArgs  
        arg = new SocketAsyncEventArgs();  
        arg.Completed += new EventHandler<SocketAsyncEventArgs>(receive_completed);  
        arg.UserToken = token;  
  
        // add SocketAsyncEventArg to the pool  
        this.receive_event_args_pool.Push(arg);  
    }  
  
  
    // send pool  
    {  
        //Pre-allocate a set of reusable SocketAsyncEventArgs  
        arg = new SocketAsyncEventArgs();  
        arg.Completed += new EventHandler<SocketAsyncEventArgs>(send_completed);  
        arg.UserToken = token;  
  
        // add SocketAsyncEventArg to the pool  
        this.send_event_args_pool.Push(arg);  
    }  
}  

설정해놓은 최대 동접 수치만큼 SocketAsyncEventArgs객체를 미리 생성하여 풀에 넣어놓는 코드입니다.

간단한 로직이라 더 설명할 필요가 없을듯 하네요.

 

 

 

■ 송, 수신 버퍼 풀링 기법

BufferManager

다음으로 버퍼 관리에 대한 구현을 보겠습니다.

SocketAsyncEventArgs마다 버퍼가 하나씩 필요하다고 설명드렸습니다.

이 버퍼라는것은 바이트 배열로 이루어진 메모리 덩어리입니다.

 

BufferManager buffer_manager;
this.buffer_manager = new BufferManager(this.max_connections * 
this.buffer_size * this.pre_alloc_count, this.buffer_size);

버퍼 매니저를 선언하고 생성하는 코드 입니다.

 

버퍼의 전체 크기는 아래 공식으로 계산됩니다.

버퍼의 전체 크기 = 최대 동접 수치 * 버퍼 하나의 크기 * (전송용, 수신용)

 

전송용 한개, 수신용 한개 총 두개가 필요하기 때문에 pre_alloc_count = 2로 설정해 놨습니다.

그럼 이제 버퍼 매니저의 코드를 살펴보겠습니다.

 

/// <summary>  
/// This class creates a single large buffer which can be divided up and assigned to SocketAsyncEventArgs objects for use  
/// with each socket I/O operation.  This enables bufffers to be easily reused and gaurds against fragmenting heap memory.  
///   
/// The operations exposed on the BufferManager class are not thread safe.  
/// </summary>  
internal class BufferManager  
{  
  
    int m_numBytes;                 // the total number of bytes controlled by the buffer pool  
    byte[] m_buffer;                // the underlying byte array maintained by the Buffer Manager  
    Stack<int> m_freeIndexPool;     //   
    int m_currentIndex;  
    int m_bufferSize;  
  
    public BufferManager(int totalBytes, int bufferSize)  
    {  
        m_numBytes = totalBytes;  
        m_currentIndex = 0;  
        m_bufferSize = bufferSize;  
        m_freeIndexPool = new Stack<int>();  
    }  
  
    /// <summary>  
    /// Allocates buffer space used by the buffer pool  
    /// </summary>  
    public void InitBuffer()  
    {  
        // create one big large buffer and divide that out to each SocketAsyncEventArg object  
        m_buffer = new byte[m_numBytes];  
    }  
  
    /// <summary>  
    /// Assigns a buffer from the buffer pool to the specified SocketAsyncEventArgs object  
    /// </summary>  
    /// <returns>true if the buffer was successfully set, else false</returns>  
    public bool SetBuffer(SocketAsyncEventArgs args)  
    {  
        if (m_freeIndexPool.Count > 0)  
        {  
            args.SetBuffer(m_buffer, m_freeIndexPool.Pop(), m_bufferSize);  
        }  
        else  
        {  
            if ((m_numBytes - m_bufferSize) < m_currentIndex)  
            {  
                return false;  
            }  
            args.SetBuffer(m_buffer, m_currentIndex, m_bufferSize);  
            m_currentIndex += m_bufferSize;  
        }  
        return true;  
    }  
  
    /// <summary>  
    /// Removes the buffer from a SocketAsyncEventArg object.  This frees the buffer back to the   
    /// buffer pool  
    /// </summary>  
    public void FreeBuffer(SocketAsyncEventArgs args)  
    {  
        m_freeIndexPool.Push(args.Offset);  
        args.SetBuffer(null, 0, 0);  
    }  
}  

이 코드도 msdn에 있는 샘플을 가져온 것입니다.

InitBuffer매소드에서 하나의 거대한 바이트 배열을 생성합니다.

그리고 SetBuffer(SocketAsyncEventArgs args) 매소드에서 SocketAsyncEventArgs객체에 버퍼를 설정해 줍니다.

하나의 버퍼를 설정한 다음에는 index값을 증가시켜 다음 버퍼 위치를 가리킬 수 있도록 처리합니다.

넓은 땅에 금을 그어서 이건 내꺼, 저건 니꺼 하는 식으로 나눈다고 생각하면 되겠네요.

마지막에는 사용하지 않는 버퍼를 반환시키기 위한 FreeBuffer매소드가 보입니다.

이 매소드는 아마 쓰이지 않게 될 것 같습니다.

왜냐하면 프로그램 시작시 최대 동접 수치만큼 버퍼를 할당한 뒤 중간에 해제하지 않고 계속 물고있기 때문입니다.

SocketAsyncEventArgs만 풀링하여 재사용할 수 있도록 처리해 놓으면 이 객체에 할당된 버퍼도 같이 따라가게 되니까요.

 

이전에 SocketAsyncEventArgs를 생성하여 풀링처리 했던 부분을 다시 보겠습니다.

for (int i = 0; i < this.max_connections; i++)  
{  
    CUserToken token = new CUserToken();  
  
    // receive pool  
    {  
        //Pre-allocate a set of reusable SocketAsyncEventArgs  
        arg = new SocketAsyncEventArgs();  
        arg.Completed += new EventHandler<SocketAsyncEventArgs>(receive_completed);  
        arg.UserToken = token;  
  
        // 추가된 부분.  
        // assign a byte buffer from the buffer pool to the SocketAsyncEventArg object  
        this.buffer_manager.SetBuffer(arg);  
  
        // add SocketAsyncEventArg to the pool  
        this.receive_event_args_pool.Push(arg);  
    }  
  
  
    // send pool  
    {  
        //Pre-allocate a set of reusable SocketAsyncEventArgs  
        arg = new SocketAsyncEventArgs();  
        arg.Completed += new EventHandler<SocketAsyncEventArgs>(send_completed);  
        arg.UserToken = token;  
  
        // 추가된 부분.  
        // assign a byte buffer from the buffer pool to the SocketAsyncEventArg object  
        this.buffer_manager.SetBuffer(arg);  
  
        // add SocketAsyncEventArg to the pool  
        this.send_event_args_pool.Push(arg);  
    }  
}  

this.buffer_manager.SetBuffer(arg); 라는 코드가 보이시나요?

이 부분이 바로 SocketAsyncEventArgs객체에 버퍼를 설정하는 코드 입니다.

SetBuffer내부를 보면 범위를 잡아서 arg.SetBuffer를 호출해주게끔 되어있죠.

m_currentIndex += m_bufferSize; 이런식으로 버퍼 크기만큼 인덱스값을 늘려서 서로 겹치지 않게 해주고 있는겁니다.

 

다시 정리해 보면 하나의 거대한 버퍼를 만들고

버퍼 사이즈만큼 범위를 잡아 SocketAsyncEventArgs객체에 하나씩 설정해줍니다.

이런 구현 방식으로 SocketAsyncEventArgs객체와 버퍼 메모리를 풀링하여 사용하게 됩니다.

 

첫번째 강좌에서도 말씀드렸듯이 닷넷에는 가비지 컬렉터가 작동되므로 풀링하지 않아도

객체 참조 관계만 잘 끊어주면 알아서 메모리를 정리해 줍니다.

저도 시험삼아 풀링하지 않고 필요할때 new하는 방식으로 돌려봤는데 메모리 사용량이 엄청나게 늘어나더군요.

가비지 컬렉터가 작동되는 시점이 우리가 만들 서버에 잘 들어맞는다는 보장은 없기에 저는 그냥 맘편하게

풀링하는 쪽을 선택했습니다.

서버가 살아있는동안 거의 매순간 쓰게될 메모리라는것도 풀링을 선택하는데 기준이 되었던것 같습니다.

 

 

 

■ 유저의 접속 처리하기

SocketAsyncEventArgs와 버퍼 풀링까지 준비해 놨으면 이제 접속된 클라이언트를 처리할 준비가 완료된것입니다.

단순한 에코서버라면 이런 준비과정이 필요없을 수 있지만 수천의 동접을 처리하는 게임서버라면

조금 번거롭더라도 이런 작업들을 잘 만들어 놔야 합니다.

또한 만들어 놨다고 끝난것이 아니라 그때부터 시작일지도 모릅니다.

처음 설계한대로 이쁘게 코딩을 해놨어도 실제 테스트해보면 부족한 부분이 보일때가 있기 때문이죠.

디버깅 하면서 뭔가 개운하지 못한 느낌이 든다면 설계를 다시 할 각오도 해야합니다.

(가끔 귀찮을때는 땜질코딩으로 넘어가기도 하죠.^^)

 

클라이언트의 접속이 이루어진 후 호출되는 콜백 매소드인 on_new_client를 살펴보겠습니다.

void on_new_client(Socket client_socket, object token)  
{  
    // 플에서 하나 꺼내와 사용한다.  
    SocketAsyncEventArgs receive_args = this.receive_event_args_pool.Pop();  
    SocketAsyncEventArgs send_args = this.send_event_args_pool.Pop();  
  
    // SocketAsyncEventArgs를 생성할 때 만들어 두었던 CUserToken을 꺼내와서   
    // 콜백 매소드의 파라미터로 넘겨줍니다.  
    if (this.session_created_callback != null)  
    {  
        CUserToken user_token = receive_args.UserToken as CUserToken;  
        this.session_created_callback(user_token);  
    }  
  
    // 이제 클라이언트로부터 데이터를 수신할 준비를 합니다.  
    begin_receive(client_socket, receive_args, send_args);  
}  

accept처리가 완료된 후 생성된 새로운 소켓을 파라미터로 넘겨주게 했습니다.

앞으로 이 소켓으로 클라이언트와 메시지를 주고받으면 됩니다.

풀링해 놨던 SocketAsyncEventArgs객체도 드디어 사용할 때가 왔습니다.

SocketAsyncEventArgs receive_args = this.receive_event_args_pool.Pop();  
SocketAsyncEventArgs send_args = this.send_event_args_pool.Pop();  

메시지 수신용, 전송용 각각 하나씩 꺼냅니다.

 

CUserToken user_token = receive_args.UserToken as CUserToken;  
this.session_created_callback(user_token);  

위에서 설명하진 않았지만 SocketAsyncEventArgs를 생성할 때 CUserToken이라는 클래스도 같이 생성하였습니다.

원격지 클라이언트 하나당 한개씩 매칭되는 유저객체라고 생각하면 됩니다.

SocketAsyncEventArgs의 UserToken변수에 참조하게끔 설정해놨었죠.

 

begin_receive(client_socket, receive_args, send_args);

클라이언트가 접속한 이유는 무언가를 주고 받기 위함이니 이제 메시지 수신을 위한 작업을 시작합니다.

전송을 위한 작업은 서버에서 메시지를 보낼 시점에 일어나므로 아직 준비할 필요는 없습니다.

begin_receive의 코드를 보겠습니다.

 

void begin_receive(Socket socket, SocketAsyncEventArgs receive_args, SocketAsyncEventArgs send_args)  
{  
    // receive_args, send_args 아무곳에서나 꺼내와도 된다. 둘다 동일한 CUserToken을 물고 있다.  
    CUserToken token = receive_args.UserToken as CUserToken;  
    token.set_event_args(receive_args, send_args);  
  
    // 생성된 클라이언트 소켓을 보관해 놓고 통신할 때 사용한다.  
    token.socket = socket;  
  
    // 데이터를 받을 수 있도록 소켓 매소드를 호출해준다.  
    // 비동기로 수신할 경우 워커 스레드에서 대기중으로 있다가 Completed에 설정해놓은 매소드가 호출된다.  
    // 동기로 완료될 경우에는 직접 완료 매소드를 호출해줘야 한다.  
    bool pending = socket.ReceiveAsync(receive_args);  
    if (!pending)  
    {  
        process_receive(receive_args);  
    }  
}  

 

비동기 소켓 매소드는 사용법이 다 비슷합니다. xxxAsync매소드 호출 뒤 리턴값을 확인하여

콜백 매소드를 기다릴지, 직접 완료 처리를 할지 결정해주는 부분은 이전에 accept처리 부분과 흡사합니다.

 

먼저 CUserToken객체에 수신용, 전송용 SocketAsyncEventArgs를 설정해 줍니다.

메시지 송,수신시 항상 이 SocketAsyncEventArgs가 따라다니게 되기 때문에 

작업 편의를 위해서 CUserToken객체에도 설정해 주도록 하였습니다.

 

마지막으로 ReceiveAsync매소드를 호출해주면 해당 소켓으로부터 메시지를 수신받을 수 있게 됩니다.

비동기 매소드이니 리턴값을 확인해야겠죠?

 

강좌를 보시면서 이해가 잘 안되는 부분도 많을겁니다.
특히 CListener부터 SocketAsyncEventArgsPool, BufferManager, UserToken등등
많은 클래스들이 서로 관계를 맺도록 구조가 잡혀있는데 이런 시스템이 한번에 이해되긴 힘듭니다.
저도 처음 라이브러리를 설계할 때 모두 완성해놓고 코딩한것은 아닙니다.
하나의 클래스에 단순하게 구현해본 뒤 점점 확장/분리할 필요성을 느끼게 되면서 
다시 설계하여 리팩토링 하는 작업을 수십번 반복한 결과입니다.
따라서 처음에는 그냥 대충 이런 흐름이구나 하고 넘어가신뒤 직접 코딩해 보면서 다시 참고하는 방법을 권해드립니다.

다음은 ReceiveAsync이후에 콜백으로 호출되는 매소드 입니다.

 

void receive_completed(object sender, SocketAsyncEventArgs e)  
{  
    if (e.LastOperation == SocketAsyncOperation.Receive)  
    {  
        process_receive(e);  
        return;  
    }  
  
    throw new ArgumentException("The last operation completed on the socket was not a receive.");  
}  

오류가 났을 때 예외를 던져주는 부분만 빼면 그냥 process_receive매소드를 호출해주는것 밖에 없습니다.

사실 e.LastOperation이 Receive가 아닌 경우는 발생하지 않을것 같습니다.

이 코드는 메시지 송,수신 완료 처리를 하나의 콜백 매소드에서 분기하여 처리하려고 할 때 들어갔던 코드거든요.

 

이제 process_receive매소드를 보겠습니다.

(메시지 하나 받는데 참 많은 매소드를 거치게 되는군요)

 

// This method is invoked when an asynchronous receive operation completes.   
// If the remote host closed the connection, then the socket is closed.    
//  
private void process_receive(SocketAsyncEventArgs e)  
{  
    // check if the remote host closed the connection  
    CUserToken token = e.UserToken as CUserToken;  
    if (e.BytesTransferred > 0 && e.SocketError == SocketError.Success)  
    {  
        // 이후의 작업은 CUserToken에 맡긴다.  
        token.on_receive(e.Buffer, e.Offset, e.BytesTransferred);  
  
        // 다음 메시지 수신을 위해서 다시 ReceiveAsync매소드를 호출한다.  
        bool pending = token.socket.ReceiveAsync(e);  
        if (!pending)  
        {  
            process_receive(e);  
        }  
    }  
    else  
    {  
        Console.WriteLine(string.Format("error {0},  transferred {1}", e.SocketError, e.BytesTransferred));  
        close_clientsocket(token);  
    }  
}  

이제 SocketAsyncEventArgs라는 객체가 어느정도 눈에 익었을겁니다.

이번에도 역시 이 객체를 사용하게 되는데요.

버퍼도 물고 있고, 유저 객체도 하나 갖고 있고...

클라이언트 하나와 통신하는데 필요한 최소한의 재료들(?)을 담고있는 녀석이라고 생각하기로 합시다.

 

CUserToken을 얻어와서 on_receive매소드를 호출해 줍니다.

 

token.on_receive(e.Buffer, e.Offset, e.BytesTransferred);

이부분이 정말 정말 중요합니다.

나중에 패킷 처리할 때 더 자세히 알아보겠지만 지금 보이는 저 세개의 파라미터가 패킷 수신 처리의 핵심이거든요.

e.Buffer에는 클라이언트로부터 수신된 데이터들이 들어있습니다.

e.Offset은 수신된 버퍼의 포지션입니다.

e.BytesTransferred는 이번에 수신된 데이터의 바이트 수를 나타냅니다.

대충 감은 오시겠지만 더 자세한건 다음 강좌 때 설명해 드리겠습니다.

 

// 다음 메시지 수신을 위해서 다시 ReceiveAsync매소드를 호출한다.  
bool pending = token.socket.ReceiveAsync(e);  
if (!pending)  
{  
    process_receive(e);  
}  

데이터를 한번 수신한 뒤에는 ReceiveAsync를 다시 호출해줘야 합니다.

그래야 계속해서 데이터를 받을 수 있게 되죠. 한번의 ReceiveAsync로 모든 데이터를 다 받지는 못하기 때문입니다.

클라이언트가 잠시 쉬었다 보낼 수도 있고, 한번에 보낸것도 네트워크 환경에 따라서 여러번에 걸쳐 받을 수도 있거든요.

스트림 기반 프로토콜인 TCP의 특징이죠.

 

비동기 매소드를 호출한 뒤에는 항상 리턴값을 확인했었습니다. ReceiveAsync매소드도 동일합니다.

pending상태가 아니면 메시지 수신 처리를 진행할 수 있도록 직접 process_receive를 호출해줍시다.

저는 여기서 한가지 의문이 들었는데요.

굉장히 자주 pending = false상태가 된다면 ReceiveAsync -> process_receive -> ReceiveAsync -> process_receive ...

이렇게 끊임없이 호출하다가 스택 오버플로우가 나진 않을까 하는 의문이었습니다.

code project에 있는 어떤 외국사람이 만든 소스코드를 봐도 위와 같은 구조로 되어 있더군요.

그곳 게시판에도 이런 의문을 가진사람이 있었는데 자세히 읽어보진 못했습니다.

다른 설계를 사용하여 저런 무한 호출 구조를 회피한것 같더군요.

 

대부분의 경우에는 pending상태가 될것이기 때문에 별 문제 없겠지 라고 넘어가기엔 약간 개운하지가 못하네요.

좀 더 테스트 해보고 연구해서 개선된 방향을 찾으면 다시 알려드리겠습니다.

 

네트워크 기반 코드 구현은 여기 까지 입니다.
c#에서 비동기 소켓 프로그래밍을 어떻게 구현하는지에 대한 부분이 주를 이루었는데 
눈에 보이는게 없어서 다소 지루할 수 있는 부분이었던것 같습니다.
이론적으로 알고있던 부분이 c#에서 어떻게 코드로 표현되는지 이해하는 정도로 받아들이시면 될것 같습니다.
실제 코딩하고 디버깅 해보며 배우는게 더 재밌겠죠.
다음 강좌에서는 tcp에서 메시지를 처리하는 방법과 함께 패킷 구조를 설계하여
서버와 클라이언트 사이에 어떻게 메시지 처리가 이루어지는지에 대한 내용을 다루겠습니다.

의문점이나 피드백이 있으면 댓글 부탁드립니다.

감사합니다.

 

 

-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

 

 

    >> 저는 여기서 한가지 의문이 들었는데요.
>> 굉장히 자주 pending = false상태가 된다면 ReceiveAsync -> process_receive -> ReceiveAsync -> process_receive ...
>> 이렇게 끊임없이 호출하다가 스택 오버플로우가 나진 않을까 하는 의문이었습니다.


pending = false 인 상태라는 것은, 비동기 수신을 걸었는데, 바로 뙇하고 수신이 끝나버린 것을 의미합니다. 계속계속 뙇 하고 수신이 끝난다면 말씀하신대로 무한루프가 될 수도 있겠지만, 중간에 한번이라도 패킷을 받을게 없어서 쉬는 시간이 되면 스택은 모두 해제되겠죠. 걱정하지 않아도 될겁니다.     
2015-04-04
03:37:24
    궁금한게있는데요

void on_new_client(Socket client_socket, object token)  
{  
    // 플에서 하나 꺼내와 사용한다.  
    SocketAsyncEventArgs receive_args = this.receive_event_args_pool.Pop();  
    SocketAsyncEventArgs send_args = this.send_event_args_pool.Pop();  


클라이언트가 접속하면 폴에서 SendAsyncEventArgs 컴포넌트를 꺼내서 사용한다고 되어있는데
그럼 만약에 여러개의 클라이언트가 서버에 동시 접속해있는 상황에서 서버를 닫을때는 접속된 여러개의 클라이언트 소켓을 어떻게 닫아주나요?
 
클라이언트가 접속할때마다 폴에서 하나 꺼낸다음에 또 접속한 클라이언트 소켓은 따로 배열에 차곡차곡 저장하고 나중에 서버를 닫을때 한거번에 소켓을 닫는 방식으로 하신건가요?
2015-05-16
11:52:49
    컨넥션 타임아웃은 어떻게 처리될가요? 2015-08-19
15:03:20
    http://www.codeproject.com/Articles/83102/C-SocketAsyncEventArgs-High-Performance-Socket-Cod와거의 비슷한것 같습니다. 2015-08-19
15:06:01
    동시에 여러 클라이언트에서 데이터를 받았을 때 데이터 수신부에서 100% 문제가 발생할 듯 합니다.Synchronize 부분이 빠져있는 듯 합니다. 2015-08-19
15:08:08
    지금까지의 강의에서 CUserToken에 대한 소스를 작성하지 않았는데 우선 CUserToken이 무엇일거라고 추상적으로만 상상하면서 보는게 맞지요? 2015-08-29
01:21:37
    권기대님어느 부분이 동기화가 안되어 있는지요?
제가 보기에 동기화 해야할 부분이 있다면 SocketAsyncEventArgs의 buffer 부분일 것 같은데요.
이 부분은 진작에 클라이언트 최대 접속수 만큼 생성되어있고 각각의 클라이언트마다 자신의 SocketAsyncEventArgs 안에 있는 buffer를 쓰기때문에 상관이 없는데
어디 부분이 동기화가 안되어 있다는지 궁굼하네요.
혹시 이 글 보신다면 알려주세요~ 
2015-08-29
04:25:10
    this.receive_event_args_pool = new SocketAsyncEventArgsPool(this.max_connections);을 어디에 집어넣어야 하나요?ㅜㅜ 보면서 차례대로 하고 있는데 ㅠㅠ

 

 

 

=======================

=======================

=======================

 

 

 

 

 

 

출처: http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Lecture_series&no=63

C#으로 게임 서버 만들기 - 3. tcp패킷 수신 처리

 

■ 메시지 경계 처리

tcp는 메시지의 경계가 없는 프로토콜입니다. 연속된 바이트들을 보내고 받을 뿐이죠.

경계가 없다는 것은 전송과 수신이 1:1로 이루어지지 않는다는 뜻입니다.

보내는 곳에서는 한번에 보내도 받는 곳에서는 두번에 걸쳐 받을 수 있다는 말이죠.

 

클라이언트가 "Hello" "World" 라는 문자열을 두번에 걸쳐서 서버로 보냈다고 합시다.

서버에서 수신할 때는 아래의 모든 상황이 다 발생할 수 있습니다.

 

Hello

World

두번에 걸쳐서 받는 경우.

 

He

llo

World

He를 먼저 받고 그 다음에 llo, World를 받아 총 세번에 걸쳐 받는 경우

 

HelloWorld

한번에 "Hello" "World" 전체를  받는 경우.

 

 

소켓에서는 우리가 보낸 데이터가 어떤 의미를 갖는지 알 수 없습니다. 그것은 어플리케이션 내의 구현 영역인것이죠.

단지 바이트화된 데이터들을 네트워크 선로를 따라 목적지까지 보낼뿐입니다.

이 바이트들이 무엇을 의미하는지 소켓에서는 전혀 신경쓰지 않죠.

그렇다면 He + llo 이런식으로 데이터가 잘려서 올 경우, 또는

HelloWorld 처럼 붙어서 올 경우에는 어떻게 "Hello" "World"라는 문자열로 이쁘게 만들어 낼 수 있을까요?

 

소켓 버퍼로부터 데이터를 수신할 때 몇개의 부가정보를 파라미터로 받을 수 있다는것을 기억하시는지요?

이전 강좌에서 봤던 ReceiveAsync매소드의 콜백 처리 부분을 다시한번 보겠습니다.

 

private void process_receive(SocketAsyncEventArgs e)  
{  
    // check if the remote host closed the connection  
    CUserToken token = e.UserToken as CUserToken;  
    if (e.BytesTransferred > 0 && e.SocketError == SocketError.Success)  
    {  
        // 이후의 작업은 CUserToken에 맡긴다.  
        token.on_receive(e.Buffer, e.Offset, e.BytesTransferred);  
  
        // 다음 메시지 수신을 위해서 다시 ReceiveAsync매소드를 호출한다.  
        bool pending = token.socket.ReceiveAsync(e);  
        if (!pending)  
        {  
            process_receive(e);  
        }  
    }  
    else  
    {  
        Console.WriteLine(string.Format("error {0},  transferred {1}", e.SocketError, e.BytesTransferred));  
        close_clientsocket(token);  
    }  
}  

 

token.on_receive(e.Buffer, e.Offset, e.BytesTransferred);

이 코드를 보면 CUserToken.on_receive매소드를 호출하면서 세개의 파라미터를 전달하는것을 알 수 있습니다. 

각각 의미하는 내용은 아래와 같습니다.

 

e.Buffer

소켓으로부터 수신된 데이터가 들어있는 바이트 배열.

 

e.Offset

데이터의 시작 위치를 나타내는 정수값.

 

e.BytesTransferred

수신된 데이터의 바이트 수.

 

콜백 매소드가 호출될 때 마다 새로운 데이터가 소켓 버퍼를 통해 들어오게 됩니다.

우리가 할일은 이 버퍼에 들어있는 데이터를 어플리케이션 버퍼로 복사하여 패킷이라는 의미있는 메시지로 만드는것입니다.

그러기 위해서는 데이터의 시작 위치를 가리키는 Offset과 수신된 데이터의 크기를 나타내는 BytesTransferred값을 이용해야 합니다.

이 정보들을 사용해서 하나의 데이터가 잘려서 오거나, 여러개의 데이터가 붙어서 올 경우에 대한 로직을 만들면 됩니다.

 

좀 더 단순하게 풀기 위해 소켓이라는 개념을 잠시 내려놓고 알고리즘 측면에서 접근해 봅시다.

제가 만든 "Hello" "World" 만들기 문제 입니다.

 

1) "HelloWorld" 라는 문자열이 있는데 이것을 "Hello", "World"로 분리 시키기.

2) "He", "llo", "World"를 "Hello", "World"로 만들기.

3) "Hel", "loWor", "ld"를 "Hello", "World"로 만들기.

 

이제 각각의 경우에 대해서 로직을 세워보겠습니다.

1번) "Hello"는 다섯 글자 라는것을 알 수 있으니 문자열의 첫 다섯 글자만 잘라서 하나를 만들고,

그 다음 다섯 글자를 잘라서 또 하나를 만들면 "Helo", "World"로 분리 됩니다.

 

2번) "He"는 아직 다섯 글자에 못미치니 다음 데이터를 붙여 봅니다.

"He" + "llo" = "Hello" 다섯 글자 하나가 완성되었네요.

그 다음 "World"도 다섯 글자이니 또 하나 완성.

 

3번) "Hel"은 아직 다섯 글자에 못미치니 다음 데이터를 붙여 봅니다.

"Hel" + "loWor" = "HelloWor" 다섯 글자가 넘어서 버리는군요. 이럴땐 다섯 글자 까지만 잘라서 하나를 만듭니다.

잘려진 나머지 데이터 "Wor"에 대해서 다시 다섯 글자를 만들어 봅시다.

뒤에 "ld"를 붙이면 "World"가 완성됩니다.

 

이 문제에서 봤던 문자열을 소켓에서 넘어온 데이터라고 가정하면 됩니다.

물론 글자 크기에 맞게 자르고 붙이고 남은 데이터는 잠시 보관해 놓는등의 작업들이 귀찮긴 하겠지만

tcp에서 패킷을 주고 받으려면 반드시 필요한 부분입니다.

 

 

 

■ 패킷 설계

앞에서 설명한 문제에서는 문자열이라고 말했는데 이제는 바이트라는 개념으로 바꿔서 생각하겠습니다.

사람이 보기에는 문자, 숫자이지만 컴퓨터로 표현되는 것은 그냥 바이트의 나열일 뿐입니다.

그리고 "Hello"가 다섯 글자라고 말했지만 이제부터는 5바이트 라고 생각하도록 합시다.

(유니코드 utf-8로 인코딩 했을때 5바이트가 됩니다)

 

여기서 잠시 이런 의문을 가져 봅시다.

"Hello", "World"는 각각 다섯글자. utf-8로 인코딩 하면 5바이트 라는 것을 우리는 눈으로 봐서 알 수 있습니다.

그래서 로직을 세울 때도 5바이트 만큼 잘라서 하나의 데이터를 완성하였죠.

그런데 만약 "Hello"(5바이트) "World!!"(7바이트) 이렇게 데이터의 크기가 모두 다를 경우에는 어떻게 처리해야 될까요?

 

1) 패킷의 크기를 모두 고정시킨다.
2) 데이터 앞에 크기를 나타내는 값을 넣어서 같이 전송한다.
3) 데이터의 끝을 나타내는 특수 문자를 삽입한다.

tcp를 설명한 책에 보면 이렇게 세가지 예시가 나오더군요.

1번은 너무 제한적입니다. 게임에서 주고 받는 패킷의 크기가 모두 같을 순 없죠. 최대 크기로 만든다면 낭비도 심하고요.

3번은 효율이 안좋을것 같습니다.

바이트를 하나하나 비교해가며 데이터의 끝인지 아닌지 체크해야 하기 때문에 성능이 많이 떨어지겠네요.

또, 어떤 문자를 써야 하는지도 애매하고요.

2번이 기가 막힌 아이디어 같습니다. 데이터 앞에 크기를 써 넣고 그 크기 만큼만 뽑아서 하나의 패킷을 완성합니다.

다음 데이터도 마찬가지로 계속 반복하면 되겠죠.

그렇다면 2번 방법으로 로직을 세워봅시다.

 

앞에서 나왔던 "Hello" World"문제를 약간 바꿔서 "Hello" World!!"라고 바꿔보겠습니다.

그리고 각각의 데이터 앞에 데이터의 크기를 나타내는 값을 넣어봅시다.

 

"5Hello", "7World!!"

소켓으로부터 데이터가 넘어오면 제일 처음 해야 할 일은 데이터 크기가 몇 바이트인지 알아내는 것입니다.

첫부분을 보면 5 라고 되어 있네요. 5바이트 만큼만 앞의 로직대로 처리하여 하나의 패킷을 만듭니다.

그 다음에는 7바이트 이므로 "World!!"를 만들어서 또 하나의 패킷을 완성합니다.

 

근데 너무 간단해 보여서 뭔가 허전하군요.

우리가 한가지 놓친것이 있습니다. 데이터 크기가 맨 앞에 붙어있다는 것은 알았는데 이 크기를 나타내는 값은

도대체 몇 바이트인 것일까요?

이제 간단하지만 아주 쓸모 있는 패킷 헤더를 설계해 볼 시간입니다.

헤더에는 이 데이터가 몇 바이트 인지 나타내주는 값이 들어가게 됩니다.

 

[헤더][데이터][헤더][데이터]...

 

바로 이런 모습이 되도록 패킷을 만들어 볼 것입니다. [헤더]에는 데이터의 크기가 들어가는데 이 헤더의 바이트는

2바이트로 고정하겠습니다.

데이터를 읽어올 때 처음 2바이트는 무조건 헤더라고 생각하면 됩니다. 그리고 헤더에 나타난 데이터 크기 만큼

읽어와서 하나의 패킷을 만들어주면 되죠.

 

그렇다면 "Hello"는 5바이트라는 것을 하나의 패킷으로 만들어 봅시다.

 

short size = 5;  
string data = "Hello";  

헤더를 2바이트로 고정한다고 말씀드렸기 때문에 2바이트 자료형인 short를 사용했습니다.

이것의 의미는 우리가 보낼 데이터의 크기가 short자료형의 최대값을 넘지 않는다는 뜻입니다.

short자료형이 표현할 수 있는 최대값은 32,767이기 때문에 게임에서 쓰기에는 충분할것 같아서 이렇게 결정했습니다.

더 큰 데이터를 보내고 싶으시면 short대신 int(4바이트)등을 사용하면 되겠죠.

아니면 unsigned short를 써서 같은 2바이트 범위이지만 더 큰 수치를 표현해도 됩니다.

데이터 크기는 -값이 올 수 없으므로 unsigned를 써도 괜찮은 방법이겠네요.

하지만 저는 그냥 short를 쓰도록 하겠습니다. 어차피 32,767만으로도 충분하기 때문이죠.^^

그리고 unsigned자료형이 없는 언어와의 호환성도 맞출 수 있고요(자바가 그렇다네요).

 

short size = 5;  
string data = "Hello";  
  
byte[] header = BitConverter.GetBytes(size);  
byte[] body = Encoding.UTF8.GetBytes(data);  
  
byte[] packet = new byte[1024];  
  
Array.Copy(header, 0, packet, 0, header.Length);  
Array.Copy(body, 0, packet, header.Length, body.Length);  

헤더와 데이터를 하나의 패킷으로 만드는 코드입니다.

BitConverter.GetBytes매소드는 데이터를 바이트 배열로 변환해주는 역할을 수행합니다.

short자료형으로 되어 있는 헤더를 바이트로 변환하여 byte header[] 배열에 채워 넣습니다.

다음으로 문자열 데이터도 바이트로 변환하여 넣는데 인코딩 방식은 utf-8을 사용했습니다.

그리고 byte[] packet = new byte[1024]; 라는 패킷 버퍼를 만들어서

"[헤더][데이터]"의 구조로 패킷을 구성합니다.

Array.Copy매소드는 원본 배열 데이터를 다른 곳으로 복사할 때 사용하는 매소드 입니다.

 

여기 까지 하면 하나의 패킷이 완성된 것입니다.

header, body, packet의 내용을 디버거로 확인해 보면 다음과 같이 채워져 있는것을 볼 수 있습니다.

 

header
	[0]	5
	[1]	0

body
	[0]	72
	[1]	101
	[2]	108
	[3]	108
	[4]	111

packet
	[0]	5
	[1]	0
	[2]	72
	[3]	101
	[4]	108
	[5]	108
	[6]	111
	[7]	0	
	[8]	0	
	[9]	0	
	.
	.
	[1023] 0

header는 short자료형이므로 2바이트를 차지하며 내용은 5라는 정수값입니다.

body는 string을 바이트로 변환한 데이터이며 "Hello"에 해당하는 유니코드 문자열 코드값이 들어가 있습니다.

utf-8로 인코딩 하였으므로 크기는 5바이트만큼 차지합니다.

packet은 header와 body를 합쳐놓은 것이기 때문에 [header][data]가 차례대로 들어가게 됩니다.

뒤에 0으로 채워진 데이터는 비어있는 공간입니다.

실제로 원격지에 전송할 때는 이 빈 공간을 제외한 "헤더 + 데이터"의 크기 만큼만 소켓 버퍼로 전송해야 합니다.

 

 

■ 패킷 수신

tcp게임서버 구현시 패킷 처리하는 방법에 대해서 이론적으로 알아봤습니다.

이제 본격적으로 패킷 수신 코드를 작성해 보겠습니다.

 

지금까지 단편적인 소스코드를 통해서 설명드렸기 때문에 서버 라이브러리의 전체적인 모습은 잘 그려지지 않을겁니다.
먼저 작은 부분들을 이해한 뒤 이런 코드 조각들이 어떻게 조화를 이루게 되는지 설명드리도록 하겠습니다.
전체적인 설계는 프로그래머에 따라서 달라질 수 있기 때문에 처음부터 전체 모습을 보여드리면
혼란이 올 듯 하여 소주제 위주로 설명해 나가는 방식을 선택했습니다.
private void process_receive(SocketAsyncEventArgs e)  
{  
    ...  
    // 이후의 작업은 CUserToken에 맡긴다.  
    token.on_receive(e.Buffer, e.Offset, e.BytesTransferred);  
    ...  
}  

다시 process_receive매소드로 돌아와 봅시다. token.on_receive매소드로 넘기는 세개의 파라미터에 대해서는

위에서 이미 설명드렸습니다.

이제 저 파라미터들을 사용하여 바이트 덩어리를 패킷이라는 의미있는 데이터로 만드는 방법에 대해서 알아보겠습니다.

 

public void on_receive(byte[] buffer, int offset, int transfered)  
{  
    this.message_resolver.on_receive(buffer, offset, transfered, on_message);  
}  

 

CUserToken.on_receive매소드의 내용 입니다.

매소드 안에서 message_resolver의 on_receive를 또 호출해 주고 있네요.

이것은 설계하는 사람에 따라 다를 수 있는 부분인데요, CUserToken클래스에서 직접 데이터를 해석하지 않고

message_resolver를 따로 둔 이유는 다음과 같습니다.

 

 

추후 확장성을 고려하여 다른 resolver를 만들 때 CUserToken의 코드 수정을 최소화 하기 위해서

강좌에서는 tcp메시지 경계 처리라는 목표에 집중하기 위해서 "[헤더][데이터]" 구조로 이루어진 방식을 채택했지만

추후에 다른 방식으로 구성된 메시지를 사용하게 될 경우도 생길 수 있습니다.

따라서 데이터 해석은 message_resolver가 처리하고 CUserToken클래스는 유저객체 본연의 임무만 수행하도록 한 것입니다.

 

다음은 message_resolver의 on_receive매소드 입니다.

/// <summary>  
/// 소켓 버퍼로부터 데이터를 수신할 때 마다 호출된다.  
/// 데이터가 남아 있을 때 까지 계속 패킷을 만들어 callback을 호출 해 준다.  
/// 하나의 패킷을 완성하지 못했다면 버퍼에 보관해 놓은 뒤 다음 수신을 기다린다.  
/// </summary>  
/// <param name="buffer"></param>  
/// <param name="offset"></param>  
/// <param name="transffered"></param>  
public void on_receive(byte[] buffer, int offset, int transffered, CompletedMessageCallback callback)  
{  
    // 이번 receive로 읽어오게 될 바이트 수.  
    this.remain_bytes = transffered;  
  
    // 원본 버퍼의 포지션값.  
    // 패킷이 여러개 뭉쳐 올 경우 원본 버퍼의 포지션은 계속 앞으로 가야 하는데 그 처리를 위한 변수이다.  
    int src_position = offset;  
  
    // 남은 데이터가 있다면 계속 반복한다.  
    while (this.remain_bytes > 0)  
    {  
        bool completed = false;  
  
        // 헤더만큼 못읽은 경우 헤더를 먼저 읽는다.  
        if (this.current_position < Defines.HEADERSIZE)  
        {  
            // 목표 지점 설정(헤더 위치까지 도달하도록 설정).  
            this.position_to_read = Defines.HEADERSIZE;  
  
            completed = read_until(buffer, ref src_position, offset, transffered);  
            if (!completed)  
            {  
                // 아직 다 못읽었으므로 다음 receive를 기다린다.  
                return;  
            }  
  
            // 헤더 하나를 온전히 읽어왔으므로 메시지 사이즈를 구한다.  
            this.message_size = get_body_size();  
  
            // 다음 목표 지점(헤더 + 메시지 사이즈).  
            this.position_to_read = this.message_size + Defines.HEADERSIZE;  
        }  
  
        // 메시지를 읽는다.  
        completed = read_until(buffer, ref src_position, offset, transffered);  
  
        if (completed)  
        {  
            // 패킷 하나를 완성 했다.  
            callback(new Const<byte[]>(this.message_buffer));  
  
            clear_buffer();  
        }  
    }  
}  
  
  
/// <summary>  
/// 목표지점으로 설정된 위치까지의 바이트를 원본 버퍼로부터 복사한다.  
/// 데이터가 모자랄 경우 현재 남은 바이트 까지만 복사한다.  
/// </summary>  
/// <param name="buffer"></param>  
/// <param name="offset"></param>  
/// <param name="transffered"></param>  
/// <param name="size_to_read"></param>  
/// <returns>다 읽었으면 true, 데이터가 모자라서 못 읽었으면 false를 리턴한다.</returns>  
bool read_until(byte[] buffer, ref int src_position, int offset, int transffered)  
{  
    if (this.current_position >= offset + transffered)  
    {  
        // 들어온 데이터 만큼 다 읽은 상태이므로 더이상 읽을 데이터가 없다.  
        return false;  
    }  
  
    // 읽어와야 할 바이트.  
    // 데이터가 분리되어 올 경우 이전에 읽어놓은 값을 빼줘서 부족한 만큼 읽어올 수 있도록 계산해 준다.  
    int copy_size = this.position_to_read - this.current_position;  
  
    // 앗! 남은 데이터가 더 적다면 가능한 만큼만 복사한다.  
    if (this.remain_bytes < copy_size)  
    {  
        copy_size = this.remain_bytes;  
    }  
  
    // 버퍼에 복사.  
    Array.Copy(buffer, src_position, this.message_buffer, this.current_position, copy_size);  
  
  
    // 원본 버퍼 포지션 이동.  
    src_position += copy_size;  
  
    // 타겟 버퍼 포지션도 이동.  
    this.current_position += copy_size;  
  
    // 남은 바이트 수.  
    this.remain_bytes -= copy_size;  
  
    // 목표지점에 도달 못했으면 false  
    if (this.current_position < this.position_to_read)  
    {  
        return false;  
    }  
  
    return true;  
}  

코드가 좀 길군요. 주석에도 설명을 달았지만 이해하기 쉽게 다시한번 설명하겠습니다.

이 클래스에서 가장 중요한 매소드인 on_receive와 read_until매소드 입니다.

on_receive는 소켓 버퍼로부터 데이터 수신이 발생할 때 마다 호출되게끔 구조가 잡혀 있습니다.

read_until은 목표지점으로 설정된 데이터를 소켓 버퍼로부터 유저 객체의 패킷 버퍼로 복사하는 역할을 합니다.

위에서 이론적으로 알아봤던 메시지 경계 처리 부분이 이 매소드를 통해 구현됩니다.

그리 좋은 알고리즘은 아니지만 강좌에 사용하는데는 무리 없으리라 판단하여 코드를 오픈합니다.^^

 

로직은 아래와 같은 흐름으로 세웠습니다.

 

on_receive
1) 최초로 헤더를 읽는다.
2) 헤더를 다 못읽었다면 수신된 바이트 만큼만 복사한 뒤 다음 수신을 기다린다.
3) 헤더를 다 읽었다면 헤더로부터 데이터 사이즈를 구한뒤 데이터를 읽는다.
4) 수신된 데이터가 사이즈보다 적게 들어왓다면 수신된 만큼만 복사한 뒤 다음 수신을 기다린다.
5) 데이터를 모두 읽었다면 하나의 패킷이 완성된 것으로 보고 1)부터 다시 반복한다.

구현해야 할 규칙은 명확합니다.

byte[]배열에서 n만큼 복사해오는 것입니다.

여기에는 1바이트가 들어있을 수도 있고, 100바이트가 들어있을 수도 있습니다.

그것을 나타내주는 파라미터가 Offset, ByteTransferred값이며 이 값을 통해서 정확한 양만큼

데이터를 가져오면 되는 것입니다.

 

직접 구현해보시면 더 이해가 빠를겁니다.

저는 일단 로직을 종이에 그려본 뒤 그 내용대로 코딩을 진행합니다.

코딩하면서 이상한 느낌이 들면 설계를 다시 살펴봅니다. 분명히 모든 상황을 다 대비해 놓은것 같은데

한두군데씩 빠진 부분이 생기더군요.

잘못된 부분이 있으면 설계를 조금 수정해서 다시 코딩에 들어갑니다.

이렇게 90%정도 완성되었다고 생각했을 때 더이상 머리가 안돌아가게 됩니다.

이 때부터 각종 상황을 만들어 놓은 뒤 브레이크 포인트를 걸고 디버깅을 하며 코드를 완성해 나갑니다.

최초 설계 했을 때 구멍난 부분이 디버깅을 해보면서 모두다 드러나는군요.

실력이 부족해서인지 아직까지 한방에 설계하고 한방 코딩으로 끝나는 경우는 경험하지 못했습니다.(ㅠ_ㅠ)

 

잘못된 데이터가 들어가 있는 경우, 데이터가 아예 없는 경우등 오류 처리까지 해줘야 완벽하게 작동될겁니다.
하지만 이 강좌에서는 이런 오류 처리까지는 구현하지 않겠습니다.

message_resolver의 전체 소스코드입니다.

class Defines  
{  
    public static readonly short HEADERSIZE = 2;  
}  
  
/// <summary>  
/// [header][body] 구조를 갖는 데이터를 파싱하는 클래스.  
/// - header : 데이터 사이즈. Defines.HEADERSIZE에 정의된 타입만큼의 크기를 갖는다.  
///             2바이트일 경우 Int16, 4바이트는 Int32로 처리하면 된다.  
///             본문의 크기가 Int16.Max값을 넘지 않는다면 2바이트로 처리하는것이 좋을것 같다.  
/// - body : 메시지 본문.  
/// </summary>  
class CMessageResolver  
{  
    public delegate void CompletedMessageCallback(Const<byte[]> buffer);  
  
    // 메시지 사이즈.  
    int message_size;  
  
    // 진행중인 버퍼.  
    byte[] message_buffer = new byte[1024];  
  
    // 현재 진행중인 버퍼의 인덱스를 가리키는 변수.  
    // 패킷 하나를 완성한 뒤에는 0으로 초기화 시켜줘야 한다.  
    int current_position;  
  
    // 읽어와야 할 목표 위치.  
    int position_to_read;  
  
    // 남은 사이즈.  
    int remain_bytes;  
  
    public CMessageResolver()  
    {  
        this.message_size = 0;  
        this.current_position = 0;  
        this.position_to_read = 0;  
        this.remain_bytes = 0;  
    }  
  
    /// <summary>  
    /// 목표지점으로 설정된 위치까지의 바이트를 원본 버퍼로부터 복사한다.  
    /// 데이터가 모자랄 경우 현재 남은 바이트 까지만 복사한다.  
    /// </summary>  
    /// <param name="buffer"></param>  
    /// <param name="offset"></param>  
    /// <param name="transffered"></param>  
    /// <param name="size_to_read"></param>  
    /// <returns>다 읽었으면 true, 데이터가 모자라서 못 읽었으면 false를 리턴한다.</returns>  
    bool read_until(byte[] buffer, ref int src_position, int offset, int transffered)  
    {  
        if (this.current_position >= offset + transffered)  
        {  
            // 들어온 데이터 만큼 다 읽은 상태이므로 더이상 읽을 데이터가 없다.  
            return false;  
        }  
  
        // 읽어와야 할 바이트.  
        // 데이터가 분리되어 올 경우 이전에 읽어놓은 값을 빼줘서 부족한 만큼 읽어올 수 있도록 계산해 준다.  
        int copy_size = this.position_to_read - this.current_position;  
  
        // 앗! 남은 데이터가 더 적다면 가능한 만큼만 복사한다.  
        if (this.remain_bytes < copy_size)  
        {  
            copy_size = this.remain_bytes;  
        }  
  
        // 버퍼에 복사.  
        Array.Copy(buffer, src_position, this.message_buffer, this.current_position, copy_size);  
  
  
        // 원본 버퍼 포지션 이동.  
        src_position += copy_size;  
  
        // 타겟 버퍼 포지션도 이동.  
        this.current_position += copy_size;  
  
        // 남은 바이트 수.  
        this.remain_bytes -= copy_size;  
  
        // 목표지점에 도달 못했으면 false  
        if (this.current_position < this.position_to_read)  
        {  
            return false;  
        }  
  
        return true;  
    }  
  
    /// <summary>  
    /// 소켓 버퍼로부터 데이터를 수신할 때 마다 호출된다.  
    /// 데이터가 남아 있을 때 까지 계속 패킷을 만들어 callback을 호출 해 준다.  
    /// 하나의 패킷을 완성하지 못했다면 버퍼에 보관해 놓은 뒤 다음 수신을 기다린다.  
    /// </summary>  
    /// <param name="buffer"></param>  
    /// <param name="offset"></param>  
    /// <param name="transffered"></param>  
    public void on_receive(byte[] buffer, int offset, int transffered, CompletedMessageCallback callback)  
    {  
        // 이번 receive로 읽어오게 될 바이트 수.  
        this.remain_bytes = transffered;  
  
        // 원본 버퍼의 포지션값.  
        // 패킷이 여러개 뭉쳐 올 경우 원본 버퍼의 포지션은 계속 앞으로 가야 하는데 그 처리를 위한 변수이다.  
        int src_position = offset;  
  
        // 남은 데이터가 있다면 계속 반복한다.  
        while (this.remain_bytes > 0)  
        {  
            bool completed = false;  
  
            // 헤더만큼 못읽은 경우 헤더를 먼저 읽는다.  
            if (this.current_position < Defines.HEADERSIZE)  
            {  
                // 목표 지점 설정(헤더 위치까지 도달하도록 설정).  
                this.position_to_read = Defines.HEADERSIZE;  
  
                completed = read_until(buffer, ref src_position, offset, transffered);  
                if (!completed)  
                {  
                    // 아직 다 못읽었으므로 다음 receive를 기다린다.  
                    return;  
                }  
  
                // 헤더 하나를 온전히 읽어왔으므로 메시지 사이즈를 구한다.  
                this.message_size = get_body_size();  
  
                // 다음 목표 지점(헤더 + 메시지 사이즈).  
                this.position_to_read = this.message_size + Defines.HEADERSIZE;  
            }  
  
            // 메시지를 읽는다.  
            completed = read_until(buffer, ref src_position, offset, transffered);  
  
            if (completed)  
            {  
                // 패킷 하나를 완성 했다.  
                callback(new Const<byte[]>(this.message_buffer));  
  
                clear_buffer();  
            }  
        }  
    }  
  
    int get_body_size()  
    {  
        // 헤더에서 메시지 사이즈를 구한다.  
        // 헤더 타입은 Int16, Int32두가지가 올 수 있으므로 각각을 구분하여 처리한다.  
        // 사실 헤더가 바뀔 경우는 없을테니 그냥 한가지로 고정하는편이 깔끔할것 같다.  
  
        Type type = Defines.HEADERSIZE.GetType();  
        if (type.Equals(typeof(Int16)))  
        {  
            return BitConverter.ToInt16(this.message_buffer, 0);  
        }  
  
        return BitConverter.ToInt32(this.message_buffer, 0);  
    }  
  
    void clear_buffer()  
    {  
        Array.Clear(this.message_buffer, 0, this.message_buffer.Length);  
  
        this.current_position = 0;  
        this.message_size = 0;  
    }  
}  
이번 강좌에서는 tcp에서 데이터 경계를 구분하여 메시지를 수신하는 로직을 구현해 봤습니다.
다음 강좌에서는 데이터 전송 부분에 대해서 알아보겠습니다.
긴 글 읽어주셔서 감사합니다.

 

 

-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

    좋은 내용입니다!! 츄천!!

개인적으로 질문이랑 다른분들이 퍼가서 사용 할 수 있게 예제 추가 되었으면..

1. read_until 
- async_read_until 처럼 비동기로 처리되도록
- read_until에서 delimiter 랑 regex 를 받아 처리 할 수 있도록
- read_until 에서 성공/실패 시 처리 할 콜백을 받을 수 있도록
- read_until 진행 중 소켓이 끊어 졌을 때 처리도 있으면 좋지 않을까요 -ㅁ-

2. MessageResolver
- CheckValidation 처리 및 ErrorString 처리 부

3. Header
- 옛날 게임들이야 header 가 length 2바이트로만 구성해서 서비스한 것들이 많고, 호불호도 갈리지만
header에 프로토콜 ID, encryptionType, SeqNumber, CRC (필요 없으려나 -ㅁ-) 등의 정보도 들어가는 것은 어떻게 생각하세요?

4. OnReceive
callback 처리 부에서 OnCallBackStarted 및 OnCallBackComplete 등도 들어가면 좋을듯..

좋은 글에 죽자사자 달려드는 건 아니고; 저런것도 있으면 개인적으로 좋을것 같아요 :)
2014-10-08
11:38:00

 

    smileeagle님/조언 감사합니다. 아직 실력이 부족한지라 구현하려면 연구를 좀 더 해야될듯 합니다.(제 지식의 깊이가 얕아서ㅎㅎ)
말씀하신 내용의 필요성은 저도 강력히 공감합니다.
혹시 관련된 내용에 대해서 참고할만한 자료가 있으면 공유 부탁드립니다~
직접 강좌를 써주시면 더욱더 좋을것 같네요~^^)/

 

 

 

=======================

=======================

=======================

 

 

 

 

 

 

출처: http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Lecture_series&page=1&sn1=&divpage=1&sn=off&ss=on&sc=on&select_arrange=hit&desc=asc&no=64

C#으로 게임 서버 만들기 - 4. 데이터 전송.

 

■ 데이터 전송

 

□ 전송큐 사용

SendAsync라는 비동기 전송 매소드를 사용하여 데이터 전송을 구현해보도록 하겠습니다.

데이터 수신에 비해 전송 구현은 간단한 편이지만 몇가지 주의할 점이 있습니다.

이 강좌에서는 하나의 전송이 완료된 후 다음 전송을 보내는 흐름으로 코드를 작성하도록 하겠습니다.

이런 방식을 1-send라고 호칭하는 경우도 봤는데 공식용어는 아닌것 같습니다.

 

/// <summary>  
/// 패킷을 전송한다.  
/// 큐가 비어 있을 경우에는 큐에 추가한 뒤 바로 SendAsync매소드를 호출하고,  
/// 데이터가 들어있을 경우에는 새로 추가만 한다.  
///   
/// 큐잉된 패킷의 전송 시점 :  
///     현재 진행중인 SendAsync가 완료되었을 때 큐를 검사하여 나머지 패킷을 전송한다.  
/// </summary>  
/// <param name="msg"></param>  
public void send(CPacket msg)  
{  
    lock (this.cs_sending_queue)  
    {  
        // 큐가 비어 있다면 큐에 추가하고 바로 비동기 전송 매소드를 호출한다.  
        if (this.sending_queue.Count <= 0)  
        {  
            this.sending_queue.Enqueue(msg);  
            start_send();  
            return;  
        }  
  
        // 큐에 무언가가 들어 있다면 아직 이전 전송이 완료되지 않은 상태이므로 큐에 추가만 하고 리턴한다.  
        // 현재 수행중인 SendAsync가 완료된 이후에 큐를 검사하여 데이터가 있으면 SendAsync를 호출하여 전송해줄 것이다.  
        this.sending_queue.Enqueue(msg);  
    }  
}  

이 매소드는 CUserToken클래스에 포함되어 있습니다.

사실 어느 클래스에 들어 있는지는 크게 중요하지 않기 때문에 내용에만 집중하여 설명하겠습니다.

전송 로직은 다음과 같은 흐름으로 진행됩니다.

 

1. 패킷을 만들어 전송큐에 추가합니다.
2. 큐가 비어 있다면 즉시 비동기 전송 매소드를 호출합니다.
3. 전송 오퍼레이션이 완료 된 후 다시 큐를 검사합니다.
4. 데이터가 존재할 경우 비동기 전송 매소드를 호출합니다.
5. 데이터가 없을 경우 더이상 아무런 작업을 하지 않고 리턴합니다.

 

원래 제가 알고 있던 데이터 전송 로직은 스레드를 이용하는 방법이었습니다.

큐에 추가하는것 까지는 지금 강좌에서 설명드린 내용과 비슷하지만

큐에서 꺼내와 전송 매소드를 호출하는 부분이 별도의 스레드를 통해 구현되었던 것입니다.

스레드 하나에서 루프를 돌며 큐를 감시하다가 데이터를 하나씩 빼내어 전송하는 방식이죠.

하지만 가만히 생각해보니 스레드를 하나 더 사용하는것이 웬지 내키지 않았습니다.

스레드를 어디에 둬야 하는지 부터 명쾌하지 않았으며,

과연 성능이 더 좋게 나올까 하는 의구심도 들었기 때문이죠.

또한 스레드를 쓰면서 생기는 여러가지 복잡함도 제거해 버리고 싶었습니다.

 

그래서 이번 강좌에서는 다른 방식을 생각해보기로 했습니다.

하나의 스레드에서 전송큐에 추가하고 닷넷 비동기 매소드인 SendAsync매소드까지 호출하는 방식입니다.

전송 요청시 바로 SendAsync매소드를 호출하지 말고 큐가 비어있는지 체크한 뒤 

비어있다면 큐에 추가하고 SendAsync매소드를 호출해줍니다.

비어있지 않다면 큐에 추가만 한 뒤 그냥 리턴합니다.

이 데이터는 앞서 큐에 추가된 데이터의 전송이 완료된 후 또한번 큐를 체크하여 SendAsync매소드를 호출할 때

보내지게 될 것입니다.

만약 하나의 전송이 완료된 후 큐가 비어 있다면 새로 추가된 데이터가 없다는 뜻이므로 그냥 리턴시킵니다.

그 이후에 또다른 데이터가 큐에 추가된다면 위에서 설명한 부분을 다시 반복하여 수행하도록 처리해 줍니다.

 

이 로직을 구현하고 간단히 테스트를 해봤는데 별다른 문제점은 발견하지 못했습니다.

하지만 정밀하게 테스트해본것은 아니기 때문에 더 좋다고 확신할 수는 없습니다.

일단 이 강좌에서는 이 방식으로 진행하도록 하겠습니다. 잘못된게 있으면 나중에 로직을 바꾸면 되니까요.

애써 코딩해 놓은것을 바꾸는것에 대해 큰 부담감을 갖을 필요는 없습니다.

언제든 코드를 바꿀 수 있다는 생각으로 코딩하면 중간 중간 어떠한 결정을 하는데 머뭇거림이 줄어들게 될겁니다.

물론 그렇다고 대충 설계하고 코딩하는건 안좋은 방법입니다. 고민은 많이 하되 판단은 빠르게 하는게 좋다고 생각합니다.

 

 

□ Socket.SendAsync

이제 닷넷의 비동기 매소드를 호출하는 부분을 살펴보겠습니다.

 

/// <summary>  
/// 비동기 전송을 시작한다.  
/// </summary>  
void start_send()  
{  
    lock (this.cs_sending_queue)  
    {  
        // 전송이 아직 완료된 상태가 아니므로 데이터만 가져오고 큐에서 제거하진 않는다.  
        CPacket msg = this.sending_queue.Peek();  
  
        // 헤더에 패킷 사이즈를 기록한다.  
        msg.record_size();  
  
        // 이번에 보낼 패킷 사이즈 만큼 버퍼 크기를 설정하고  
        this.send_event_args.SetBuffer(this.send_event_args.Offset, msg.position);  
        // 패킷 내용을 SocketAsyncEventArgs버퍼에 복사한다.  
        Array.Copy(msg.buffer, 0, this.send_event_args.Buffer, this.send_event_args.Offset, msg.position);  
  
        // 비동기 전송 시작.  
        bool pending = this.socket.SendAsync(this.send_event_args);  
        if (!pending)  
        {  
            process_send(this.send_event_args);  
        }  
    }  
}  

 

전송큐에 데이터를 넣은 뒤 호출되는 매소드 입니다.

일단 전송할 데이터 하나를 큐에서 가져옵니다. 여기서 주의할 부분은 Dequeue매소드를 사용하여 가져오지 말고 

Peek매소드를 사용하여 큐에 데이터를 유지시켜 놔야 한다는 점입니다.

왜냐하면 아직 데이터 전송이 완료된것이 아니기 때문입니다.

앞에서 한번에 하나의 전송을 처리하고 그 전송이 완료된 이후에 다음 전송을 처리한다고 설명해 드렸습니다.

이 규칙을 깨지 않게 하려면 데이터 전송이 완료될 때 까지는 큐에 유지시켜 놔야 합니다.

만약 Dequeue매소드를 사용하여 데이터를 꺼내오는것과 동시에 큐에서 제거해 버린다면 SendAsync매소드가

연속으로 호출될 가능성이 있습니다.

다음과 같은 시나리오를 예상해 봅시다.

1. 전송 요청 -> 큐에 데이터 추가.
2. 큐에서 데이터를 하나 꺼내온다(Peek대신 Dequeue사용!!)
3. Dequeue를 사용했으므로 큐는 empty상태임.
4. SendAsync호출 -> 비동기 매소드이기 때문에 다른 스레드에서 전송 작업이 진행됨.
5. 또 다른 데이터를 전송 요청함.
6. 큐가 비어 있기 때문에 즉시 start_send매소드를 호출하여 그 안에서 SendAsync를 호출하게 됨.
7. 첫번째로 호출된 SendAsync가 아직 완료되지 않은 상태에서 또 SendAsync가 호출됨!!

바로 이런 시나리오가 가능할 수 있기 때문에 위와같이 큐를 통해서 흐름을 제어하게끔 구현한 것입니다.

그렇다면 SendAsync매소드가 이중으로 호출되면 안되는 이유라도 있는걸까요?

이부분은 저도 이론적으로 아직 완벽히 정립하지 못한 부분이지만 연구하면서 알게된 몇가지 사실들이 있습니다.

 

SendAsync매소드를 중복하여 호출하는것 자체는 문제가 없습니다.

하지만 우리가 구현한 코드를 보면 SocketAsyncEventArgs를 

SendAsync매소드 호출시 파라미터로 넘겨주는것을 볼 수 있습니다.

// 비동기 전송 시작.  
bool pending = this.socket.SendAsync(this.send_event_args);  

바로 이 부분입니다. this.send_event_args 변수는 SocketAsyncEventArgs타입의 변수인데

비동기 전송 매소드를 호출할 때 매번 사용됩니다.

현재 진행중인 비동기 작업이 완료되지 않은 상태에서 이 변수를 재활용 하려고 하면 에러가 리턴됩니다.

무슨 에러인지는 잘 기억이 안나는데 하여튼 아직 완료되지 않은 오퍼레이션이라서 안된다 뭐 그런 뜻이었던 것으로 기억합니다.

앗, 그렇다면 SocketAsyncEventArgs를 여러개 생성해놓고 사용하자! 하고 생각하시는 분들도 계실것 같습니다.

저도 비슷한 생각을 해봤었는데 도대체 몇개의 SocketAsyncEventArgs를 사용해야 할까요?

예상하기 힘드니 이것도 풀링하여 처리하면 될까요?

아니면 설정값을 정해놓고 그 한도 내에서만 처리할 수 있도록 구현해 볼까요?

이런 고민들 속에서 저는 한번에 하나의 전송을 처리하도록 하는 방법을 택한 것입니다.

SendAsync매소드를 동시 다발적으로 호출하게 될 경우 전송 순서가 꼬일 수 있지 않을까 하는 생각도 해봤습니다.

이럴 경우에는 애써 tcp서버를 구현해 놓고 전송 순서가 뒤죽박죽 되는 바보같은 경험을 하게 될지도 모르겠네요.

닷넷 내부에서 어떻게 구현해놨는지 까지는 파악하지 못했기 때문에 뭐라 확답을 드릴수는 없습니다.

이런 상황에서는 최대한 안전하게 처리하는 방법이 좋겠죠?

의심되면 테스트 코드 만들어서 뻗을때까지 뺑뺑이 돌리면 되니까요.^^

 

 

□ Socket.SendAsync 완료 처리

SendAsync매소드 역시 비동기 매소드 입니다. 지겹도록 들은 비동기 매소드...

이번에도 역시 완료시 호출되는 콜백 매소드가 존재합니다.

public void process_send(SocketAsyncEventArgs e)  
{  
    ...  
  
    // 전송 완료된 패킷을 큐에서 제거한다.  
    CPacket packet = this.sending_queue.Dequeue();  
    CPacket.destroy(packet);  
  
    // 아직 전송하지 않은 대기중인 패킷이 있다면 다시한번 전송을 요청한다.  
    if (this.sending_queue.Count > 0)  
    {  
        start_send();  
    }  
  
    ...  
}  

코드의 주요 부분만 보면 이렇게 구현되어 있습니다.

앞서 설명한 코드를 보면 큐에서 데이터를 꺼내올 때 Peek를 사용하여 큐의 상태를 유지시켜 줬는데요,

여기서 Dequeue매소드를 사용하여 큐에서도 제거해 주도록 했습니다.

그리고 해당 패킷을 반환해주는 처리까지 들어갑니다.

CPacket클래스는 풀링하여 사용하게끔 처리해 놨기 때문에 반드시 반환 처리를 해줘야 메모리가 새지 않습니다.

마지막으로 큐에 데이터가 존재하는지 체크하여 계속 전송처리를 수행하도록 합니다.

데이터가 없다면 그냥 리턴하면 끝.

 

process_send매소드에서 처리해줘야 할 일은 몇가지 더 있습니다.

에러코드도 확인해야 하고, 일부만 전송했을 경우 나머지 데이터를 재전송 해주는등의 처리까지 구현해야 됩니다.

재전송 처리를 꼭 해줘야 하는가에 대해서는 저도 잘 모르겠지만 

다른 서버 커뮤니티에서 토론한 내용을 보면 발생할 수도 있는것 같더군요.

이 강좌에서는 일단 흐름 위주로 진행하기 때문에 세세한 부분은 건너뛰겠습니다.

(이렇게 애매한 부분은 고수분들이 직접 테스트해서 올려주시면 참 좋을것 같아요^^;)

 

마지막으로 lock처리 한 부분에 대해서 설명하겠습니다.

제가 이번 강좌에서 언급한 내용중 한번에 하나의 전송을 처리하기 위해서 

별도의 스레드를 사용하지 않고 구현하겠다고 말씀 드렸습니다.

그럼에도 불구하고 큐에 넣고 뺄 때 lock처리가 되어 있습니다.

닷넷 비동기 소켓의 완료 처리시 호출되는 콜백 매소드에서 이 큐를 참조하기 때문에 lock으로 감싸준 것입니다.

전송 요청을 할 때 큐에 추가하는 부분과 실제로 SendAsync를 호출하는 코드는 동일한 스레드에서 이루어 집니다.

따라서 이 부분까지는 lock이 필요 없습니다.

하지만 SendAsync매소드의 완료 콜백 처리는 닷넷의 스레드풀에서 관리하는 스레드중 하나에서 호출됩니다.

 

전송 요청 = A스레드
완료 콜백 = B스레드

이렇게 두개의 스레드에서 동일한 큐에 접근하기 때문에 lock으로 감싸놓은 것입니다.

스레드를 안쓴다고 해놓고 lock을 써서 구현했으니 뭐 더 나아진건지 아닌건지 모르겠네요.ㅎㅎ

비동기라 어쩔 수 없을것 같습니다.

완료 콜백 매소드인 process_send가 수행되는 중에 또다른 전송 요청이 이루어질 수 있으니까요.

혹시 더 좋은 구현 방법이 있다면 공유 부탁드립니다^^

 

이제 네트워크 관련 코드는 볼만큼 본것 같습니다.
소켓 초기화, 버퍼 풀링, 패킷 설계, 데이터 전송, 수신 처리가 대부분이니까요.
다음 강좌에서는 지금까지의 라이브러리 코드를 사용하여 테스트 서버/클라이언트를 만들어 보겠습니다.
더미 테스트를 통해서 오류가 없는지 검증하는 방법도 알아 보도록 하죠.
감사합니다.

 

 

 

=======================

=======================

=======================

 

 

 

 

 

 

 

출처: http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Lecture_series&no=65

C#으로 게임 서버 만들기 - 5. 서버, 클라이언트 구현(에코 서버, 클라이언트)

■ 서버, 클라이언트 테스트 코드 구현

지금까지 작성한 네트워크 라이브러리를 이용하여 에코 서버를 구현해 보겠습니다.

클라이언트가 던져주는 메시지를 다시 되돌려 주는 아주 간단한 서버이지만 지금까지 공부한 라이브러리를

활용하는데 아주 좋은 예제가 될 것이라 생각됩니다.

 

□ 에코 서버

윈도우 콘솔 프로그램으로 에코 서버를 만들어 보도록 하겠습니다.

비주얼스튜디오를 이용하여 CSampleServer라는 이름으로 새프로젝트를 하나 생성합니다.

프로그램의 기본 골격이 되는 코드가 생성이 되며 Program클래스의 Main함수가 보일겁니다.

여기서 만들 서버도 윈도우 프로그램중 하나이므로 Main함수가 시작점이 됩니다.

static void Main(string[] args)  
{  
    CPacketBufferManager.initialize(2000);  
    userlist = new List<CGameUser>();  
  
    CNetworkService service = new CNetworkService();  
  
    // 콜백 매소드 설정.  
    service.session_created_callback += on_session_created;  
  
    // 초기화.  
    service.initialize();  
    service.listen("0.0.0.0", 7979, 100);  
  
    Console.WriteLine("Started!");  
    while (true)  
    {  
        System.Threading.Thread.Sleep(1000);  
    }  
  
    Console.ReadKey();  
}  

 

첫번째로 CPacketBufferManager를 초기화 하여 패킷을 미리 생성해 놓습니다.

파라미터로 넘기는 값은 적당한 수치를 넣어주면 되는데 여기서는 2000으로 설정하였습니다.

동시에 처리할 수 있는 패킷 클래스의 인스턴스가 최대 2000개까지 가능하다는 뜻입니다.

사용이 끝나 반환된 패킷은 초기화 하여 재사용되기 때문에 개수가 무한정 늘어나지는 않습니다.

단, 너무 많이 설정할 경우 필요 이상의 메모리를 잡아먹을 수 있으니 테스트 후 적당한 수치를 넣어주는것이 좋습니다.

 

다음으로 접속할 유저들을 관리할 리스트를 생성합니다.

그 아랫줄 부터는 네트워크 초기화를 위한 코드 입니다.

CNetworkService객체를 생성한 뒤 클라이언트가 접속할 때 마다 호출될 콜백 매소드를 설정해 줍니다.

클라이언트 한명이 접속 성공할 때 마다 지정된 콜백매소드가 호출되게 됩니다.

그리고 CNetworkService초기화를 수행한 뒤 listen매소드를 호출하여 클라이언트의 접속을 기다리는 상태로 만들어 줍니다.

listen매소드의 파라미터는 host ip, port, backlog값으로 구성되어 있습니다.

host ip는 서버의 IP주소를 의미하며 "0.0.0.0"으로 넣어주면 모든 데이터를 다 받아들입니다.

서버에서 여러개의 IP주소를 설정하여 사용할 경우도 있는데 이 설정과 관계없이 

어떤 IP주소라도 해당 포트로 들어오는 데이터는 모두다 수신하겠다는 뜻입니다.

 

포트는 서버 어플리케이션에서 사용할 포트 번호입니다.

이미 알려진 포트(21, 80등등)는 제외하고 사용해야 다른 어플리케이션과 충돌하지 않습니다.

마지막으로 backlog값을 넣어줍니다. 이 값은 accept처리 도중 대기 시킬 연결 개수를 의미합니다.

accept처리가 아직 끝나지 않은 상태에서 또 다른 연결 요청이 들어온다면 backlog로 설정된 값 만큼

대기 큐에 대기시켜 놓습니다. accept처리가 끝나면 대기큐에서 하나씩 빼내어 다음 연결 처리를 진행시켜 주게 됩니다.

이 값을 무턱대고 크게 설정하면 리소스를 잡아먹을 수도 있으니 테스트 후 적당한 값을 넣는것이 좋겠죠.

 

네트워크 초기화가 완료되면 프로그램이 중지되지 않도록 무한 루프를 돌려줍니다.

메인 스레드가 블러킹 되면 안되기 때문에 Sleep을 통해서 적당히 쉬어주는 코드도 넣어줬습니다.

 

보시다시피 Main함수의 내용은 특별할것이 없습니다.

나머지 코드들도 간단하기 그지없습니다.

 

/// <summary>  
/// 클라이언트가 접속 완료 하였을 때 호출됩니다.  
/// n개의 워커 스레드에서 호출될 수 있으므로 공유 자원 접근시 동기화 처리를 해줘야 합니다.  
/// </summary>  
/// <returns></returns>  
static void on_session_created(CUserToken token)  
{  
    CGameUser user = new CGameUser(token);  
    lock (userlist)  
    {  
        userlist.Add(user);  
    }  
}  
  
public static void remove_user(CGameUser user)  
{  
    lock (userlist)  
    {  
        userlist.Remove(user);  
    }  
}  

CNetworkService의 콜백 매소드로 설정해준 on_session_created매소드 입니다.

클라이언트의 접속이 성공할 때 마다 네트워크 라이브러리에서 호출해 주게 되죠.

CGameUser클래스의 인스턴스를 하나 생성해 주는데 이 클래스는 에코 서버에서 사용할 간단한 유저 객체를 나타냅니다.

그리고 현재 접속하고 있는 유저를 관리하기 위해서 앞서 만든 userlist에 새로운 유저를 추가합니다.

유저의 접속이 끊길 때 호출할 remove_user매소드에서도 userlist관리를 위한 코드만 들어가 있습니다.

에코 서버는 클라이언트가 보내온 메시지를 그대로 돌려주는 일만 수행하기 때문에 유저와 관련된 내용은 복잡할게 없습니다.

 

단, 여기서 주의할 점은 userlist에 변화가 생길 때 lock으로 묶어줘야 한다는 것입니다.

userlist는 여러 스레드에서 사용하는 공유 자원이기 때문에 반드시 lock처리를 해줘야 리스트가 깨지지 않습니다.

물론 아주 운이 좋다면(실제로는 나쁜경우지만) lock처리를 해제하여도 잘 돌아가는 경우가 있을겁니다.

하지만 멀티 스레드 환경에서는 한두번 잘 돌아간다고 계속 잘 되리라는 보장은 절대 없습니다.

코드의 흐름을 잘 파악하여 lock이 필요한 부분은 데이터가 깨지지 않도록 보호 조치를 해주는것이 반드시 필요합니다.

 

 

□ 유저 객체

Main함수 구현 부분에서 CGameUser리스트를 생성하여 관리하는 코드를 보셨을 겁니다.

클라이언트와 1:1관계로 매칭되는 유저 객체라고 생각하시면 됩니다.

동시 접속자가 1,000명이면 CGameUser인스턴스도 1,000개 생성되는 것입니다.

CGameUser클래스는 길이가 얼마 되지 않으니 전체 코드를 살펴보도록 하겠습니다.

 

namespace CSampleServer  
{  
    using GameServer;  
  
    /// <summary>  
    /// 하나의 session객체를 나타낸다.  
    /// </summary>  
    class CGameUser : IPeer  
    {  
        CUserToken token;  
  
        public CGameUser(CUserToken token)  
        {  
            this.token = token;  
            this.token.set_peer(this);  
        }  
  
        void IPeer.on_message(Const<byte[]> buffer)  
        {  
            // ex)  
            CPacket msg = new CPacket(buffer.Value, this);  
            PROTOCOL protocol = (PROTOCOL)msg.pop_protocol_id();  
            Console.WriteLine("------------------------------------------------------");  
            Console.WriteLine("protocol id " + protocol);  
            switch (protocol)  
            {  
                case PROTOCOL.CHAT_MSG_REQ:  
                    {  
                        string text = msg.pop_string();  
                        Console.WriteLine(string.Format("text {0}", text));  
  
                        CPacket response = CPacket.create((short)PROTOCOL.CHAT_MSG_ACK);  
                        response.push(text);  
                        send(response);  
                    }  
                    break;  
            }  
        }  
  
        void IPeer.on_removed()  
        {  
            Console.WriteLine("The client disconnected.");  
  
            Program.remove_user(this);  
        }  
  
        public void send(CPacket msg)  
        {  
            this.token.send(msg);  
        }  
  
        void IPeer.disconnect()  
        {  
            this.token.socket.Disconnect(false);  
        }  
  
        void IPeer.process_user_operation(CPacket msg)  
        {  
        }  
    }  
}  

생성자에서는 메시지 송,수신시 사용할 CUserToken객체를 멤버변수로 보관해 놓습니다.

그리고 CUserToken객체에 IPeer인터페이스를 구현한 자기 자신의 인스턴스를 넘겨줍니다.

네트워크 모듈에서 클라이언트의 접속 요청, 종료등의 처리시

해당 인터페이스를 통해서 CGameUser의 매소드를 호출해주기 위함입니다.

네트워크 모듈에서 이런 저런 처리를 한 뒤 어플리케이션으로 그 사실을 통보해줄 때 필요한 부분입니다.

 

IPeer.on_message매소드는 클라이언트로 메시지가 수신되었을 때 호출됩니다.

파라미터로 byte배열이 넘어오기 때문에 이것을 패킷 객체로 변환하여 사용하는것이 좋습니다.

프로토콜 아이디를 읽어온 뒤 해당 아이디에 맞는 로직으로 분기시켜 줍니다.

여기서는 에코 서버 이므로 PROTOCOL.CHAT_MSG_REQ 프로토콜에 대한 처리를 해주게 됩니다.

 

string text = msg.pop_string();  
Console.WriteLine(string.Format("text {0}", text));  
  
CPacket response = CPacket.create((short)PROTOCOL.CHAT_MSG_ACK);  
response.push(text);  
send(response);  

에코 서버는 클라이언트가 전송한 내용을 그대로 돌려주는 역할 이기 때문에

pop_string()으로 꺼내온 데이터를 그대로 다시 push하여 응답해 줍니다.

이 때 프로토콜은 CHAT_MSG_REQ에 대응하는 CHAT_MSG_ACK를 사용하여 클라이언트에서 인지할 수 있도록

처리해 줍니다.

 

그 다음으로 클라이언트와의 연결이 끊겼을 때 호출되는 on_removed와 데이터 전송시 사용할

send매소드가 있습니다.

on_removed는 네트워크 모듈에서 자동으로 호출되는 매소드 이므로 우리는 통보 사실을 전달받아

어플리케이션의 로직 처리만 수행해주면 됩니다.

send매소드는 생성자에서 보관해 놓은 CUserToken객체의 send매소드를 호출하여 데이터 전송을 요청합니다.

 

코드를 보시다가 CUserToken클래스와 CGameUser클래스 두가지가 왜 구분되어 있는지 궁금해 하실수도 있을겁니다.

그 이유는 각각의 역할 분담을 명확히 하기 위해서 입니다.

CUserToken은 네트워크 모듈에 속해 있는 클래스 이며 소켓API와 좀 더 가까운 컨셉으로 설계된 클래스입니다.

반면 CGameUser는 네트워크 모듈보다는 어플리케이션 로직과 가까운 클래스 이며 

구현하려는 서버마다 각기 다른 내용으로 채워져 있을 수 있습니다.

이 말은 그만큼 변경사항이 많이 생길 수 있다는 말이기도 합니다. 따라서 데이터 송,수신등의 처리에 필요한

CUserToken클래스와는 별도로 구성하는 것이 바람직 하겠죠.

 

IPeer.disconnect() 매소드는 서버에서 클라이언트의 연결을 강제로 끊을 때 사용합니다.

매소드 내용도 아주 간단하죠.

 

void IPeer.process_user_operation(CPacket msg) 매소드는 에코 서버에서는 사용되지 않는 매소드 입니다.

이 매소드는 실전 예제 강좌에서 활용하도록 하겠습니다. 일단은 비워둡시다.

 

여기 까지 에코 서버의 구현이 모두 완료되었습니다.

네트워크 처리 부분은 모두 라이브러리로 구현되어 있기 때문에 어플리케이션에서는 별로 복잡한 내용이 없습니다.

다음으로 클라이언트 구현을 보도록 하겠습니다.

 

 

□ 클라이언트

클라이언트 역시 간단합니다. 서버에 접속한 뒤 키보드 입력을 받아 전송하고 수신된 데이터를 화면에 출력해주면 끝입니다.

CSampleClient라는 이름으로 새로운 프로젝트를 생성합니다.

먼저 Main함수를 살펴보죠.

 

static void Main(string[] args)  
{  
    CPacketBufferManager.initialize(2000);  
    // CNetworkService객체는 메시지의 비동기 송,수신 처리를 수행한다.  
    // 메시지 송,수신은 서버, 클라이언트 모두 동일한 로직으로 처리될 수 있으므로  
    // CNetworkService객체를 생성하여 Connector객체에 넘겨준다.  
    CNetworkService service = new CNetworkService();  
  
    // endpoint정보를 갖고있는 Connector생성. 만들어둔 NetworkService객체를 넣어준다.  
    CConnector connector = new CConnector(service);  
    // 접속 성공시 호출될 콜백 매소드 지정.  
    connector.connected_callback += on_connected_gameserver;  
    IPEndPoint endpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 7979);  
    connector.connect(endpoint);  
  
    while (true)  
    {  
        Console.Write("> ");  
        string line = Console.ReadLine();  
        if (line == "q")  
        {  
            break;  
        }  
  
        CPacket msg = CPacket.create((short)PROTOCOL.CHAT_MSG_REQ);  
        msg.push(line);  
        game_servers[0].send(msg);  
    }  
  
    ((CRemoteServerPeer)game_servers[0]).token.disconnect();  
  
    Console.ReadKey();  
}  

서버 구현 내용에서 보았듯이 패킷 객체를 미리 생성해 놓습니다.

사실 에코 클라이언트에서는 1~2개로 설정해놔도 아무 무리가 없습니다.

 

클라이언트도 CNetworkService객체를 생성해야 합니다. 이렇게 생성한 객체를 CConnector의

생성자로 넘겨줍니다.

CConnector클래스는 원격지 서버에 접속을 수행하기 위해 구현된 클래스 입니다.

접속하려는 서버의 IP주소와 포트번호를 입력하고 접속 성공시 호출될 콜백 매소드를 설정한 뒤

connect매소드를 호출해줍니다.

 

접속 요청을 한 뒤에는 키보드 입력을 받을 수 있도록 ReadLine매소드를 호출해주고

입력된 텍스트를 서버에 전송합니다.

"q" 한글자를 입력할 경우에는 프로그램을 종료 합니다.

 

/// <summary>  
/// 접속 성공시 호출될 콜백 매소드.  
/// </summary>  
/// <param name="server_token"></param>  
static void on_connected_gameserver(CUserToken server_token)  
{  
    lock (game_servers)  
    {  
        IPeer server = new CRemoteServerPeer(server_token);  
        game_servers.Add(server);  
        Console.WriteLine("Connected!");  
    }  
}  

서버에 접속이 완료되면 CConnector클래스에 설정해 놓은 콜백 매소드가 호출됩니다.

IPeer인터페이스를 구현한 CRemoteServerPeer객체를 생성하여 서버 리스트에 추가해 줍니다.

클라이언트 입장에서 보면 서버도 데이터 송,수신의 대상이 되는 객체로 볼 수 있습니다.

따라서 CRemoteServerPeer의 구현은 서버에서 클라이언트 객체를 구현한 CGameUser클래스와 비슷합니다.

CRemoteServerPeer의 전체 소스 코드 입니다.

 

namespace CSampleClient  
{  
    using GameServer;  
  
    class CRemoteServerPeer : IPeer  
    {  
        public CUserToken token { get; private set; }  
  
        public CRemoteServerPeer(CUserToken token)  
        {  
            this.token = token;  
            this.token.set_peer(this);  
        }  
  
        void IPeer.on_message(Const<byte[]> buffer)  
        {  
            CPacket msg = new CPacket(buffer.Value, this);  
            PROTOCOL protocol_id = (PROTOCOL)msg.pop_protocol_id();  
            switch (protocol_id)  
            {  
                case PROTOCOL.CHAT_MSG_ACK:  
                    {  
                        string text = msg.pop_string();  
                        Console.WriteLine(string.Format("text {0}", text));  
                    }  
                    break;  
            }  
        }  
  
        void IPeer.on_removed()  
        {  
            Console.WriteLine("Server removed.");  
        }  
  
        void IPeer.send(CPacket msg)  
        {  
            this.token.send(msg);  
        }  
  
        void IPeer.disconnect()  
        {  
            this.token.socket.Disconnect(false);  
        }  
  
        void IPeer.process_user_operation(CPacket msg)  
        {  
        }  
    }  
}  

서버에서와 마찬가지로 IPeer인터페이스를 구현하였으며 생성자에서 하는 일은 동일합니다.

단, 여기서의 CUserToken객체는 클라이언트가 아닌 서버를 의미한다는 것이 다를 뿐입니다.

따라서 당연하게도 메시지가 수신되면 on_message매소드가 호출됩니다.

CHAT_MSG_ACK 프로토콜 아이디에 대한 코드로서 수신된 내용을 화면에 출력해 주는것이 전부입니다.

이 코드를 통해서 내가 보낸 데이터가 제대로 돌아오는지 확인할 수 있겠죠.

그 외 다른 부분은 에코 서버의 CGameUser클래스와 동일합니다.

 

이제 에코 서버, 클라이언트를 모두 구현했습니다. 실행된 모습을 보도록 하죠.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

   [클라이언트가 여러개 붙어도 잘 작동됩니다]

 

네트워크 라이브러리의 전체 소스코드와
에코 서버, 클라이언트의 전체 소스코드를 첨부하였습니다.
아직 개발중인 버전이므로 추후 내용이 변경될 수 있으니 참고 바랍니다.

 

네트워크 라이브러리와 이것을 활용한 에코 서버, 클라이언트를 만들어 봤습니다.
이제 온라인 게임을 개발하기 위한 첫 걸음을 내딛은 것입니다.
상용으로 서비스 하기에는 아직 갈길이 멀지만 첫술에 배부를 수는 없겠죠.
한줄 한줄 코딩해 나가다 보면 자신만의 라이브러리와 서버가 만들어져 있을 겁니다.
다음 강좌부터는 유니티 엔진과 연동하여 안드로이드 환경에서 돌아가는 
실시간 온라인 게임을 개발해보도록 하겠습니다.
감사합니다.

 

-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

 

    잘봤습니다. 

코드를 보는중 궁금한게 하나 있는데 byte[] 버퍼를 Const 라는 구조체로 감싸는 이유는 무엇인가요?
2014-11-25
10:51:11

 

    이락님/
네트워크로 전달된 패킷의 내용이 바이트 버퍼에 들어가는데 코딩 실수등으로 버퍼 내용이 변경되는것을 방지하기 위함입니다.

 

 

 

=======================

=======================

=======================

 

 

 

 

 

 

출처: http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Lecture_series&no=66

C#으로 게임 서버 만들기 - 6. 유니티 버전 에코 클라이언트 구현.

■ 유니티 엔진과 연동하기

 

 

앞에서 만들어본 에코 클라이언트의 유니티 버전을 만들어 보겠습니다. c#을 이용하였기 때문에

유니티 엔진에서도 우리가 만든 네트워크 라이브러리를 그대로 사용할 수 있습니다.

유니티에서는 모노 런타임으로 c#코드를 수행시키기 때문에 마이크로 소프트에서 제공하는 

닷넷 프레임워크 런타임과는 약간 다르게 작동합니다. 하지만 대부분의 기능들이 호환되기 때문에

별다른 코드 수정 없이 유니티 엔진으로 연동하는것이 가능합니다.

 

준비작업

먼저 dll파일을 생성하여 유니티 프로젝트에 추가해 보도록 하겠습니다.

FreeNet프로젝트는 기본적으로 Class Library로 설정되어 있기 때문에 빌드를 하면 dll파일이 생성됩니다.

빌드할 때 주의할점이 있는데 Target Framework의 닷넷 버전을 3.5로 맞춰야 한다는 점입니다.

유니티4버전에서 사용하는 모노 런타임이 닷넷 3.5까지 지원하기 때문입니다. 혹시나 이 부분이 4.0이상으로

되어 있다면 유니티 프로젝트에 dll파일을 추가하였을 때 컴파일 에러가 발생할 수 있습니다.

 

 

 

 

생성된 dll파일을 유니티 프로젝트에 추가해 보도록 하겠습니다.

FreeNetUnitySample이라는 이름으로 유니티 프로젝트를 하나 생성 합니다.

Asset/FreeNet 이라는 폴더를 생성한 뒤 빌드해놓은 FreeNet.dll파일을 이 폴더로 복사합니다.

 

 

 

 

이제 준비 작업을 마쳤으니 본격적으로 코드를 작성해 보도록 하겠습니다.

 

 

프로젝트 구성

유니티 엔진과 연동할 때 한가지 알아두어야 할 부분은 메인 스레드에서 작업이 수행되도록 코딩해야 한다는 것입니다.

유니티 엔진에서 제공되는 대부분의 기능들은 메인 스레드에서만 작동되도록 설계되어 있기 때문입니다.

그런데 우리가 작성한 네트워크 라이브러리는 메인 스레드와는 별도로 작동되는 워커 스레드에서 소켓 송수신 처리가 이루어 집니다.

그렇다면 메인 스레드에서 작동되도록 코드를 다시 작성해야 하는 것일까요?

그렇지 않습니다.

소켓 송수신 처리는 네트워크 라이브러리에서 작성한 내용 그대로 워커 스레드에서 처리하되

메인 스레드로 건내주어 처리할 수 있도록 시스템을 구성하면 됩니다.

그럼 앞부분에서 만들었던 에코 클라이언트 프로그램을 유니티 프로젝트로 변경하는 작업을 통해

네트워크 라이브러리가 어떤 방식으로 유니티 엔진과 연동 되는지 알아보도록 하죠.

 

프로젝트는 다음과 같이 구성됩니다.

 

FreeNet/
FreeNet.dll
	네트워크 라이브러리 파일

CFreeNetUnityService.cs
	네트워크 라이브러리와 유니티 프로젝트를 연결해주는 기능

CFreeNetEventManager.cs
	네트워크 라이브러리에서 발생되는 이벤트들을 관리하는 기능

CRemoteServerPeer.cs
	클라이언트에서 통신을 수행할 대상이 되는 서버 객체


Resources/scripts/
CGameMain.cs
	화면 UI를 담당하는 스크립트

CNetworkManager.cs
	로직 처리 부분에서 서버 접속, 이벤트 핸들링, 메시지 핸들링을 수행하는 객체

protocol.cs
	서버와 통신에 필요한 프로토콜이 정의되어있는 파일

유니티 프로젝트에서 우리가 추가한 네트워크 라이브러리를 사용하기 위해서는 메인 스레드에서

작동되도록 구성해야 한다고 했습니다. 그 역할을 담당하는 클래스가 CFreeNetUnityService입니다.

이 클래스는 MonoBehaviour를 상속받았기 때문에 유니티 프로젝트내에서 자유롭게 사용 가능한 클래스 입니다.

또한 네트워크 라이브러리에 접근하여 송수신된 데이터를 관리하기도 합니다.

네트워크 라이브러리에서 발생되는 모든 이벤트들은 이 클래스를 통해서 유니티 프로젝트로 전달됩니다.

 

public class CFreeNetUnityService : MonoBehaviour  
{  
    CFreeNetEventManager event_manager;  
  
    // 연결된 게임 서버 객체.  
    IPeer gameserver;  
  
    // TCP통신을 위한 서비스 객체.  
    CNetworkService service;  
  
    // 네트워크 상태 변경시 호출되는 델리게이트. 어플리케이션에서 콜백 매소드를 설정하여 사용한다.  
    public delegate void StatusChangedHandler(NETWORK_EVENT status);  
    public StatusChangedHandler appcallback_on_status_changed;  
  
    // 네트워크 메시지 수신시 호출되는 델리게이트. 어플리케이션에서 콜백 매소드를 설정하여 사용한다.  
    public delegate void MessageHandler(CPacket msg);  
    public MessageHandler appcallback_on_message;  

먼저 MonoBehaviour를 상속받은 CFreeNetUnityService클래스를 만듭니다.

네트워크에서 발생되는 이벤트들을 관리하기 위하여 CFreeNetEventManager객체를 선언하였습니다.

이 객체는 네트워크 라이브러리에서 발생되는 이벤트들과 수신된 데이터를 보관해놓는 역할을 합니다.

자세한 내용은 나중에 설명드리겠습니다.

 

IPeer gameserver;

접속할 게임서버 객체입니다. 에코 클라이언트 콘솔 프로그램에서 사용했던것과 같은 개념입니다.

 

CNetworkService service;

네트워크 라이브러리의 기능을 수행하는 서비스 객체입니다.

 

그 다음으로는 네트워크 상태 변경과 메시지 수신을 통보 받을 델리게이트를 선언해 줍니다.

여기까지는 이전에 보았던 에코 클라이언트 콘솔 프로그램과 개념적으로 큰 차이가 없습니다.

이제 유니티 프로젝트에서 어떻게 네트워크 이벤트들을 받아 처리할 수 있는지 살펴보겠습니다.

 

void Awake()  
{  
    CPacketBufferManager.initialize(10);  
    this.event_manager = new CFreeNetEventManager();  
}  

유니티 엔진에서 초기화를 담당하는 코드는 Awake매소드에 작성하게 됩니다.

에코 클라이언트 콘솔 프로그램에서 작성했던것 처럼 CPacketBufferManager를 초기화 해줍니다.

CFreeNetEventManager클래스는 MonoBehaviour를 상속받은 클래스가 아니므로 new를 통해 생성합니다.

public void connect(string host, int port)  
{  
    // CNetworkService객체는 메시지의 비동기 송,수신 처리를 수행한다.  
    this.service = new CNetworkService();  
  
    // endpoint정보를 갖고있는 Connector생성. 만들어둔 NetworkService객체를 넣어준다.  
    CConnector connector = new CConnector(service);  
    // 접속 성공시 호출될 콜백 매소드 지정.  
    connector.connected_callback += on_connected_gameserver;  
    IPEndPoint endpoint = new IPEndPoint(IPAddress.Parse(host), port);  
    connector.connect(endpoint);  
}  

서버에 접속하기 위한 매소드 입니다.

CNetworkService와 CConnector를 생성하여 서버에 접속을 수행합니다.

 

/// <summary>  
/// 접속 성공시 호출될 콜백 매소드.  
/// </summary>  
/// <param name="server_token"></param>  
void on_connected_gameserver(CUserToken server_token)  
{  
    this.gameserver = new CRemoteServerPeer(server_token);  
    ((CRemoteServerPeer)this.gameserver).set_eventmanager(this.event_manager);  
  
    // 유니티 어플리케이션으로 이벤트를 넘겨주기 위해서 매니저에 큐잉 시켜 준다.  
    this.event_manager.enqueue_network_event(NETWORK_EVENT.connected);  
}  

서버에 접속이 성공했을 때 호출되는 콜백 매소드 입니다.

서버 객체를 생성해 주고 Awake매소드에서 생성한 CFreeNetEventManager객체를 설정해 줍니다.

이 작업을 통해서 유니티 프로젝트와 네트워크 라이브러리간의 연결 통로가 구성되는 것입니다.

이후에도 설명하겠지만 CFreeNetEventManager클래스는 네트워크 라이브러리에서 발생되는

이벤트들과 송수신된 메시지들을 담고 있는 컨테이너들로 구성된 클래스 입니다.

메인 스레드와 워커 스레드에서 모두 사용되는 객체이기 때문에 lock으로 동기화 처리를 구현해 놓았습니다.

 

접속 완료 통보를 받으면 이 사실을 유니티 프로젝트에서 감지할 수 있도록 해줘야 하는데

CFreeNetEventManager.enqueue_network_event매소드를 통해서 이루어 집니다.

미리 정의된 enum값인 NETWORK_EVENT.connected를 새로운 이벤트로 큐잉 시켜줍니다.

 

여기서 한가지 의문점을 가져볼 수 있는데요,
이 매소드에서 직접 유니티 엔진으로 접속 완료 통보를 호출하지 않고 
왜 번거롭게 큐잉처리를 하여 한단계 더 거치도록 구현해놓은 것일까요?
게다가 이 클래스는 MonoBehaviour까지 상속받고 있는것을 보니 유니티 엔진 내부의 매소드들도
마음껏 호출할 수 있을텐데 말이죠.

그럼 on_connected_gameserver가 어떻게 호출되는지 다시한번 생각해 봅시다.
CConnector객체를 생성하고 connector.connected_callback델리게이트에 설정해주면 자동으로 호출되죠.
어디서 호출되나요? 네트워크 라이브러리 내에서 접속 완료 통보가 들어왔을 때 호출됩니다.
네트워크 라이브러리는 비동기 소켓 매소드를 사용하여 작성되었다는 것을 기억하시나요?
접속 완료 통보 역시 메인 스레드와는 별도로 수행되는 워커 스레드에서 호출됩니다.
따라서 on_connected_gameserver매소드는 워커 스레드에서 호출되는 매소드라고 볼 수 있죠.
이 매소드에서 유니티 엔진의 매소드를 직접 호출하거나 게임 로직 처리를 담당하는 클래스의 매소드를
호출해 버리면 메인 스레드가 아니라서 사용할 수 없다는 에러 메시지가 출력되거나
로직을 담당하는 데이터가 깨지게 되겠죠.
따라서 별도의 컨테이너를 통해서 큐잉 처리하여 간접적으로 처리하도록 구성할 필요가 있습니다.

아직까지는 유니티 프로젝트의 로직 내에서 이 이벤트를 어떻게 빼오는지 모릅니다.

다음에 나올 Update매소드에서 이 부분을 어떻게 처리하는지 살펴보겠습니다.

/// <summary>  
/// 네트워크에서 발생하는 모든 이벤트를 클라이언트에게 알려주는 역할을 Update에서 진행한다.  
/// FreeNet엔진의 메시지 송수신 처리는 워커스레드에서 수행되지만 유니티의 로직 처리는 메인 스레드에서 수행되므로  
/// 큐잉처리를 통하여 메인 스레드에서 모든 로직 처리가 이루어지도록 구성하였다.  
/// </summary>  
void Update()  
{  
    // 수신된 메시지에 대한 콜백.  
    if (this.event_manager.has_message())  
    {  
        CPacket msg = this.event_manager.dequeue_network_message();  
        if (this.appcallback_on_message != null)  
        {  
            this.appcallback_on_message(msg);  
        }  
    }  
  
    // 네트워크 발생 이벤트에 대한 콜백.  
    if (this.event_manager.has_event())  
    {  
        NETWORK_EVENT status = this.event_manager.dequeue_network_event();  
        if (this.appcallback_on_status_changed != null)  
        {  
            this.appcallback_on_status_changed(status);  
        }  
    }  
}  

Update매소드는 유니티 엔진에서 매 프레임마다 호출되는 매소드입니다.

이 매소드에서 CFreeNetEventManager객체에 접근하여 네트워크 이벤트와 수신된 메시지를 가져오게 됩니다.

먼저 has_message매소드를 통해 수신된 데이터가 있는지 확인한 후 true가 리턴되면

dequeue_network_message매소드를 통해서 메시지를 하나 꺼내옵니다.

그리고 미리 지정해 놓은 델리게이트를 호출하여 유니티 프로젝트내의 로직 처리 부분으로 통보해 줍니다.

네트워크 이벤트에 대해서도 동일한 로직으로 구성합니다.

 

바로 이 부분이 네트워크 라이브러리와 유니티 프로젝트와의 연결 고리가 되는 부분입니다.

지금 설명드리고 있는 CFreeNetUnityService클래스는 MonoBehaviour를 상속받아 생성하였기 때문에

유니티 프로젝트내의 다른 소스코드들과 자연스럽게 어우러질 수 있습니다.

그렇다면 네트워크 라이브러리에 접근하여 각종 네트워크 이벤트들과 수신된 메시지를 가져오는 저 코드는 문제가 없을까요?

분명히 데이터 송수신 처리는 메인 스레드와는 별도로 작동된다고 알고 있는데 말이죠.

CFreeNetEventManager클래스의 소스코드를 통해서 이부분이 어떻게 구현되어 있는지 살펴보겠습니다.

using System;  
using System.Collections;  
using System.Collections.Generic;  
using FreeNet;  
  
namespace FreeNetUnity  
{  
    public enum NETWORK_EVENT : byte  
    {  
        // 접속 완료.  
        connected,  
  
        // 연결 끊김.  
        disconnected,  
  
        // 끝.  
        end  
    }  
  
    /// <summary>  
    /// 네트워크 엔진에서 발생된 이벤트들을 큐잉시킨다.  
    /// 워커 스레드와 메인 스레드 양쪽에서 호출될 수 있으므로 스레드 동기화 처리를 적용하였다.  
    /// </summary>  
    public class CFreeNetEventManager  
    {  
        // 동기화 객체.  
        object cs_event;  
  
        // 네트워크 엔진에서 발생된 이벤트들을 보관해놓는 큐.  
        Queue<NETWORK_EVENT> network_events;  
  
        // 서버에서 받은 패킷들을 보관해놓는 큐.  
        Queue<CPacket> network_message_events;  
  
        public CFreeNetEventManager()  
        {  
            this.network_events = new Queue<NETWORK_EVENT>();  
            this.network_message_events = new Queue<CPacket>();  
            this.cs_event = new object();  
        }  
  
        public void enqueue_network_event(NETWORK_EVENT event_type)  
        {  
            lock (this.cs_event)  
            {  
                this.network_events.Enqueue(event_type);  
            }  
        }  
  
        public bool has_event()  
        {  
            lock (this.cs_event)  
            {  
                return this.network_events.Count > 0;  
            }  
        }  
  
        public NETWORK_EVENT dequeue_network_event()  
        {  
            lock (this.cs_event)  
            {  
                return this.network_events.Dequeue();  
            }  
        }  
  
  
        public bool has_message()  
        {  
            lock (this.cs_event)  
            {  
                return this.network_message_events.Count > 0;  
            }  
        }  
  
        public void enqueue_network_message(CPacket buffer)  
        {  
            lock (this.cs_event)  
            {  
                this.network_message_events.Enqueue(buffer);  
            }  
        }  
  
        public CPacket dequeue_network_message()  
        {  
            lock (this.cs_event)  
            {  
                return this.network_message_events.Dequeue();  
            }  
        }  
    }  
} 

코드가 짧은 편이라 전체 소스코드를 보면서 설명 드리겠습니다.

CFreeNetEventManager클래스는 네트워크 라이브러리에서 수신된 메시지들과 이벤트들을 보관해 놓는

큐로 구성되어 있습니다. 이 큐에 데이터를 넣고 빼올때 동기화 처리를 적용하였기 때문에

유니티 엔진이 작동되는 메인 스레드에서 호출하여도 충돌나지 않을 수 있는 것이죠.

 

이제 이렇게 구성한 요소들을 더 편리하게 사용할 수 있도록 네트워크 매니저를 만들어 보겠습니다.

 

public class CNetworkManager : MonoBehaviour {  
  
    CFreeNetUnityService gameserver;  
  
    void Awake()  
    {  
        // 네트워크 통신을 위해 CFreeNetUnityService객체를 추가합니다.  
        this.gameserver = gameObject.AddComponent<CFreeNetUnityService>();  
  
        // 상태 변화(접속, 끊김등)를 통보 받을 델리게이트 설정.  
        this.gameserver.appcallback_on_status_changed += on_status_changed;  
  
        // 패킷 수신 델리게이트 설정.  
        this.gameserver.appcallback_on_message += on_message;  
    }  
  
    // Use this for initialization  
    void Start()  
    {  
        connect();  
    }  
  
    void connect()  
    {  
        this.gameserver.connect("127.0.0.1", 7979);  
    }  
  
    /// <summary>  
    /// 네트워크 상태 변경시 호출될 콜백 매소드.  
    /// </summary>  
    /// <param name="server_token"></param>  
    void on_status_changed(NETWORK_EVENT status)  
    {  
        switch (status)  
        {  
                // 접속 성공.  
            case NETWORK_EVENT.connected:  
                {  
                    Debug.Log("on connected");  
  
                    CPacket msg = CPacket.create((short)PROTOCOL.CHAT_MSG_REQ);  
                    msg.push("Hello!!!");  
                    this.gameserver.send(msg);  
                }  
                break;  
  
                // 연결 끊김.  
            case NETWORK_EVENT.disconnected:  
                Debug.Log("disconnected");  
                break;  
        }  
    }  
  
    void on_message(CPacket msg)  
    {  
        // 제일 먼저 프로토콜 아이디를 꺼내온다.  
        PROTOCOL protocol_id = (PROTOCOL)msg.pop_protocol_id();  
  
        // 프로토콜에 따른 분기 처리.  
        switch (protocol_id)  
        {  
            case PROTOCOL.CHAT_MSG_ACK:  
                {  
                    string text = msg.pop_string();  
                    GameObject.Find("GameMain").GetComponent<CGameMain>().on_receive_chat_msg(text);  
                }  
                break;  
        }  
    }  
  
    public void send(CPacket msg)  
    {  
        this.gameserver.send(msg);  
    }  
}  

중요하게 봐야 할 부분은 CFreeNetUnityService객체를 생성하고 

패킷 수신시 호출될 콜백 매소드를 설정하는 부분입니다.

CFreeNetUnityService클래스는 네트워크 라이브러리와 어느정도 연관이 있는 부분이기 때문에

이 클래스에서 바로 로직 처리까지 구현하는 방법은 권장하지 않습니다.

따라서 CNetworkManager클래스의 델리게이트를 통해 네트워크 라이브러리에서 발생된 이벤트들을 

로직 처리 부분까지 전달하여 사용할 수 있도록 구성하는것이 좋습니다.

 

시스템을 이렇게 구성하였을때 얻는 이점으로는 게임 로직 처리 부분에서 네트워크 라이브러리가 하는 일에

신경쓰지 않고 핸들링된 메시지 처리에 집중할 수 있다는 부분입니다.

또한 게임 로직이 어떻게 구성되어 있던 네트워크 라이브러리와 연결된 클래스에서는

메시지를 받아서 넘겨주는 역할만 충실히 수행하면 되기 때문에 서로간의 연결이 느슨해지는 결과가 나타납니다.

결국 또다른 프로젝트에서도 재사용하기 쉬운 형태가 되며

로직 구현부의 코드를 수정하다가 네트워크 관련 코드를 건드릴 일도 없기 때문에 

버그가 발생할 가능성도 줄어들게 되죠.

 

다음으로 화면 UI를 구성하는 스크립트를 통해서 에코 클라이언트의 유니티 버전을 완성해 보도록 하겠습니다.

화면 UI는 CGameMain클래스에서 담당합니다.

 

public class CGameMain : MonoBehaviour {  
  
    string input_text;  
    List<string> received_texts;  
    CNetworkManager network_manager;  
  
    Vector2 currentScrollPos = new Vector2();  
  
    void Awake()  
    {  
        this.input_text = "";  
        this.received_texts = new List<string>();  
        this.network_manager = GameObject.Find("NetworkManager").GetComponent<CNetworkManager>();  
    }  
  
    public void on_receive_chat_msg(string text)  
    {  
        this.received_texts.Add(text);  
        this.currentScrollPos.y = float.PositiveInfinity;  
    }  
  
    void OnGUI()  
    {  
        // Received text.  
        GUILayout.BeginVertical();  
        currentScrollPos = GUILayout.BeginScrollView(currentScrollPos,   
            GUILayout.MaxWidth(Screen.width), GUILayout.MinWidth(Screen.width),  
            GUILayout.MaxHeight(Screen.height - 100), GUILayout.MinHeight(Screen.height - 100));  
  
        foreach (string text in this.received_texts)  
        {  
            GUILayout.BeginHorizontal();  
            GUI.skin.label.wordWrap = true;  
            GUILayout.Label(text);  
            GUILayout.EndHorizontal();  
        }  
  
        GUILayout.EndScrollView();  
        GUILayout.EndVertical();  
  
  
        // Input.  
        GUILayout.BeginHorizontal();  
        this.input_text = GUILayout.TextField(this.input_text, GUILayout.MaxWidth(Screen.width - 100), GUILayout.MinWidth(Screen.width - 100),  
            GUILayout.MaxHeight(50), GUILayout.MinHeight(50));  
  
        if (GUILayout.Button("Send", GUILayout.MaxWidth(100), GUILayout.MinWidth(100), GUILayout.MaxHeight(50), GUILayout.MinHeight(50)))  
        {  
            CPacket msg = CPacket.create((short)PROTOCOL.CHAT_MSG_REQ);  
            msg.push(this.input_text);  
            this.network_manager.send(msg);  
  
            this.input_text = "";  
        }  
        GUILayout.EndHorizontal();  
    }  
}  

on_receive_chat_msg매소드는 CNetworkManager클래스에서 새로운 메시지를 수신했을 때

호출되는 매소드 입니다. 서버로부터 텍스트를 수신 받으면 스트링 리스트에 추가하여

OnGUI매소드에서 화면 출력시 사용합니다.

OnGUI매소드의 내용은 유니티 GUI관련 코드로 구성되어 있습니다.

수신된 메시지를 스크롤뷰를 통해서 보여주고 서버에 전송할 메시지를 입력받을 TextField가 존재합니다.

 

에코 서버와 유니티 버전 에코 클라이언트를 구동시킨 모습입니다.

 

 

 

 

 

 

 

 

 

에코 클라이언트 유니티 버전의 프로젝트 구성요소

네트워크 라이브러리와 연동되는 부분을 위주로 설명하다 보니 유니티쪽의 설명이 부족한것 같아 내용을 추가하여 설명 드립니다.

사용된 유니티 엔진의 버전은 4.5.5f1입니다.

윈도우 에디터 환경에서는 무료 버전으로도 테스트가 가능하지만

안드로이드 폰으로 빌드를 하려면 Android Pro버전이 있어야 소켓 기능을 사용할 수 있습니다.

 

 

 

씬의 구성요소 입니다.

Main Camera는 기본으로 생성된 카메라를 그대로 사용합니다.

GameObject -> Create Empty 메뉴를 통해서 NetworkManager와 GameMain오브젝트를

생성해 줍니다. 

다른 컴포넌트는 추가할 필요 없이 트랜스폼만 갖고 있는 가장 기본적인 오브젝트들로 구성합니다.

 

 

 

프로젝트뷰의 모습입니다.

Assets/FreeNet폴더에 FreeNet.dll파일과 연동에 필요한 스크립트들이 존재합니다.

 

 

 

게임 로직을 구성하는 스크립트들이 위치하는 Assets/Resrouces/scripts폴더의 모습입니다.

CLogManager, CSingleton클래스는 에코 클라이언트에서 사용하지 않는 코드이지만

게임 개발시 유용하게 사용될 수 있기에 미리 만들어 놓은 것입니다.

소스코드 압축파일에 포함되어 있습니다.

 

 

 

NetworkManager오브젝트의 Inspector화면 입니다.

CNetworkManager.cs스크립트를 연결시켜 줍니다.

화면에 출력되는 오브젝트가 아니기 때문에 Transform값은 의미가 없습니다.

 

 

 

GameMain오브젝트의 Inspector화면 입니다.

CGameMain.cs스크립트가 연결되어 있으며 Transform값은 의미가 없습니다.

 

콘솔 프로그램으로 작성된 에코 클라이언트를 유니티 버전으로 만들어 보면서
유니티 엔진과 네트워크 라이브러리가 어떻게 연동되는지 알아봤습니다.
다음 강좌에서는 유저들끼리 실시간 대전이 가능한 온라인 퍼즐게임을 만들어보면서
온라인 게임의 로직이 어떻게 구성되는지 더 깊게 들어가 보도록 하겠습니다.
이번 강좌에서 작성된 유니티 버전 에코 클라이언트의 소스코드는 첨부파일(FreeNetUnitySample.zip)로 올렸습니다.
감사합니다.

 

FreenetUnitySample.zip
0.05MB

------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

    에스피님/ 네. 지금 e-book 진행중에 있습니다.^^ 2014-11-20
14:00:03
    책 나오면 이 강좌는 사라지나요 ? ...  아니겠죠  ? ㅋㅋ 2014-11-20
17:00:34
    책이 나와도 강좌는 사라지지 않습니다.^^
2014-11-20
17:31:01
    지금 사용하는 freenet dll 이랑 전에 올려주신 freenet 버전이랑 조금 다른것 같은데 최신 버전도 올려주시면 안되나요 ? 2015-01-12
17:16:36
    5용 패키지 올렸습니다 2015-05-11
14:14:00
    dll은 어떻게 만들 수 있는지 알 수 있을까요??

 

 

 

=======================

=======================

=======================

 

 

 

 

 

 

반응형

 

 

728x90

 

 

출처: http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Lecture_series&page=1&sn1=&divpage=1&sn=off&ss=on&sc=on&select_arrange=hit&desc=asc&no=68

 

(개인적인 일로 한동안 못 쓰고 있다가 이제야 올립니다.^^)

C#으로 게임 서버 만들기 - 7. 온라인 세균전 게임 만들기(초기 설계, 기능 구현)

■ 온라인 세균전 게임 만들기

 

지금까지 만들면서 배워왔던 기술들을 모두 종합하여 온라인 세균전 게임을 개발해 보도록 하겠습니다.

이번 장에서는 아래 내용들을 다룹니다.

- 세균전 게임의 초기 설계

- 게임방의 골격

- 각 기능들의 상세 구현

---------------------------------(이번 강좌의 내용)

 

- 유저의 접속과 매칭

- 플레이어들이 같은 공간에 있도록 만들기

- 클라이언트와의 연동

- 안드로이드 apk빌드

 

 

세균전 게임의 초기 설계

※ 이번 강좌에 나오는 내용들은 모두 서버측 코드 입니다.

 

세균전 게임의 룰은 다음과 같습니다.

경기는 8*8로 이루어진 사각형의 보드판에서 이루어 집니다.
승리조건은 보드판을 다 채울때 까지 상대방보다 더 많은 수의 세균을 번식하는 것입니다.
현재 자신의 위치로부터 한칸은 복제, 두칸은 이동입니다.
복제나 이동을 끝마친 후 주위 8칸 이내에 상대방의 세균이 존재한다면
자신의 세균으로 전염시킬 수 있습니다.

게임의 룰을 처리하는 코드는 서버에서 처리될 것이며 클라이언트는 유저의 입력을 받아들이고

화면에 그래픽을 출력하는 역할만 담당할 것입니다.

대부분의 온라인 게임은 클라이언트의 해킹을 방지하기 위해 서버에서 핵심 로직을 처리합니다.

클라이언트 소스코드에 로직이 존재할 경우 암호화나 난독화등을 거친다 하더라도

악의적인 유저의 조작에서 완전히 벗어날 수 없기 때문입니다.

 

코딩하기 전에 게임이 어떤 식으로 진행될 것인지 흐름을 정리해 보는 시간이 필요합니다.

각 부분별 작업 예상 시간과 필요한 리소스들을 이 시점에서 잘 파악해놓는것이 중요합니다.

 

게임 시작 -> 로고 화면 출력 -> 서버에 접속 -> 메인 화면 ->
대전 신청 -> 유저 매칭 -> 게임 진행 -> 게임 결과 -> 다시 메인 화면으로 복귀

 

전체적으로 위와 같은 흐름으로 이루어지게 되며 여기서 게임 진행 부분을 더 세분화 해 보겠습니다.

 

맵 초기화 -> 1P시작 -> 턴 종료 -> 2P시작 -> 턴 종료 -> 게임 결과 체크 -> 다시 1P시작

 

상용게임이 아니라 예제 수준이므로 비교적 단순하게 세워봤습니다.

실무에서는 이보다 더 세분화되고 상세한 작업 계획을 세운 뒤 개발에 들어가게 됩니다.

 

 

게임방의 골격

게임방이 어떻게 구성되는지 커다란 골격을 잡아볼것입니다. 여기에 앞서 유저가 어떻게 접속하고

매칭은 어떻게 이루어지는지 궁금하시겠지만 일단 게임의 로직 처리부분 부터 들어가도록 하겠습니다.

 

지금부터 작성되는 코드는 모두 서버쪽에서 수행되는 코드입니다. 서버쪽에 로직이 모여 있으니

일단 서버쪽 부터 설명한 뒤 클라이언트를 연동하는 방식으로 진행하도록 하겠습니다.

/// <summary>  
/// 게임 방 하나를 구성한다. 게임의 로직이 처리되는 핵심 클래스이다.  
/// </summary>  
class CGameRoom  
{  
}  

아직은 아무 내용도 없지만 이 클래스에서 게임의 핵심 로직이 모두 처리 될 것입니다.

 

 

로딩 완료 요청

클라이언트에서는 게임 리소스들을 불러 들여 화면에 표시해 준 뒤 서버에 로딩 완료 요청을 보냅니다.

/// <summary>  
/// 클라이언트에서 로딩을 완료한 후 요청함.  
/// 이 요청이 들어오면 게임을 시작해도 좋다는 뜻이다.  
/// </summary>  
/// <param name="sender">요청한 유저</param>  
public void loading_complete(CGameUser sender)  
{  
    // 모든 유저가 준비 상태인지 체크한다.  
  
    // 아직 준비가 안된 유저가 있다면 대기한다.  
  
    // 모두 준비 되었다면 게임을 시작한다.  
    battle_start();  
}  
  
  
/// <summary>  
/// 게임을 시작한다.  
/// </summary>  
void battle_start()  
{  
    // 게임을 새로 시작할 때 마다 초기화해줘야 할 것들.  
    reset();  
  
    // 1P부터 시작.  
}  

모든 클라이언트에게 로딩 완료 요청을 받으면 서버는 게임을 시작합니다.

화면에 보이는 부분은 모두 클라이언트의 역할 이지만 실제로 게임을 진행하고 처리하는 부분은

서버에서 이루어 집니다. 따라서 서버가 게임 상태를 변경할 모든 권한을 갖고 있습니다.

클라이언트는 단지 무엇을 해달라고 요청을 보낼 뿐이며 이에 대한 응답으로 화면을 갱신하는 일만 합니다.

 

이동 요청

세균전 게임에서 클라이언트가 할 수 있는 행동은 '이동 요청' 단 한가지 입니다.

'1번 세균을 2번 위치로 이동해주세요'라고 서버에 요청하면 서버는 클라이언트의 요청이

정상적인 상태인지 확인한 뒤 실제 이동을 허락합니다.

이 과정에서 비정상적인 요청이라고 판단되면 이동을 처리하지 않고 해킹된 클라이언트로 간주하여

접속을 끊는다던지 턴을 넘긴다던지 하는 결정을 내리게 됩니다.

클라이언트에서 보내오는 요청에 대해서는 반드시 이런 유효성 검사를 거쳐야 해킹으로부터 안전할 수 있습니다.

만약 이러한 과정을 거치지 않는다면 임의로 조작된 클라이언트에 의해서 게임의 룰이 파괴되는

불상사를 겪게 될지도 모릅니다.

그럼 어떠한 확인 과정을 거쳐야 하는지 살펴봅시다.

 

/// <summary>  
/// 클라이언트의 이동 요청.  
/// </summary>  
/// <param name="sender">요청한 유저</param>  
/// <param name="begin_pos">시작 위치</param>  
/// <param name="target_pos">이동하고자 하는 위치</param>  
public void moving_req(CGameUser sender, byte begin_pos, byte target_pos)  
{  
    // sender차례인지 체크.  
    // 체크 이유 : 현재 자신의 차례가 아님에도 불구하고 이동 요청을 보내온다면 게임의 턴이 엉망이 되어 버릴 것입니다.  
  
    // begin_pos에 sender의 캐릭터가 존재하는지 체크.  
    // 체크 이유 : 없는 캐릭터를 이동하려고 하면 당연히 안되겠죠?  
  
    // target_pos가 이동 또는 복제 가능한 범위인지 체크.  
    // 에크 이유: 이동할 수 없는 범위로는 갈 수 없도록 처리해야 합니다.  
  
  
    // 모든 체크가 정상이라면 이동을 처리한다.  
  
    // 세균을 이동하여 로직 처리를 수행한다. 전염시킬 상대방 세균이 있다면 룰에 맞게 전염시킨다.  
  
    // 최종 결과를 모든 클라이언트들에게 전송 한다.  
  
    // 턴을 종료 한다.  
    turn_end();  
}  
  
/// <summary>  
/// 턴을 종료한다. 게임이 끝났는지 확인하는 과정을 수행한다.  
/// </summary>  
void turn_end()  
{  
    // 보드판 상태를 확인하여 게임이 끝났는지 검사한다.  
  
    // 아직 게임이 끝나지 않았다면 다음 플레이어로 턴을 넘긴다.  
} 

모든 검사가 완료되면 서버에서 이동 처리를 한 뒤 주의에 있는 상대방 세균을 전염시킵니다.

그리고 변경된 보드판의 상태를 모든 클라이언트들에게 전송 합니다.

마지막으로 턴을 종료하여 다음 플레이어의 차례로 만들어 줍니다.

보드판에 세균이 다 차서 더이상 움직일 공간이 없을 때 까지 번갈아가며 게임을 진행합니다.

아직 각 내용들의 코드는 작성하지 않았지만 게임이 어떻게 진행될 것인지 큰 흐름을 파악해 봤습니다.

이제 조금 더 들어가서 각 내용들을 하나하나 구현해 보도록 하겠습니다.

 

 

각 기능들의 상세 구현

 

CGameRoom멤버 변수 구성

이제 각 기능들을 하나하나 코딩해 보도록 하겠습니다. 

CGameRoom의 멤버변수들은 이렇게 구성됩니다.

class CGameRoom  
{  
    enum PLAYER_STATE : byte  
    {  
        // 방에 막 입장한 상태.  
        ENTERED_ROOM,  
  
        // 로딩을 완료한 상태.  
        LOADING_COMPLETE  
    }  
  
    // 게임을 진행하는 플레이어. 1P, 2P가 존재한다.  
    List<CPlayer> players;  
  
    // 플레이어들의 상태를 관리하는 변수.  
    Dictionary<byte, PLAYER_STATE> player_state;  
  
    // 현재 턴을 진행하고 있는 플레이어의 인덱스.  
    byte current_turn_player;  
  
    ...  
}  

PLAYER_STATE는 플레이어들의 각 상태를 나타내는 enum값입니다. 아직은 두가지 상태밖에 없지만

로직을 작성하면서 점점 늘어나게 될 것입니다.

상태가 과도하게 늘어나게 되면 디버깅 할 때 귀찮아질 수 있으니 꼭 필요한 상태만 정의해 놔야 합니다.

 

List<CPlayer> players;

플레이어를 구성하는 클래스입니다. 실제 클라이언트는 CGameUser클래스와 매칭되지만

게임 플레이를 위해서는 CGameUser클래스를 직접 사용하기 보다는 플레이 전용으로 사용할 수 있는

CPlayer클래스를 별도로 만드는것이 좋습니다.

왜냐하면 CGameUser클래스에는 유저의 계정 정보, 소켓 핸들등의 정보가 들어 있는데

이런 정보들은 플레이 할 때 필요없는 것들이기 때문입니다. 사용하지 않아도 되는 정보들은

숨겨놓고 아예 접근하지 않도록 하는 것이 더 바람직한 코딩 습관입니다.

 

그렇다고 아예 모든 정보를 차단시켜버리면 곤란해지므로 CGameUser와 CPlayer간에

최소한의 연결 고리는 만들어 줘야 합니다. CGameUser와 CPlayer는 1:1관계로 이어지며

클라이언트에게 패킷을 전송하는데 CGameUser클래스가 꼭 필요하므로 CPlayer의 생성자에서

이를 넘겨받아 보관해 놓도록 합시다. 대신 CGameRoom클래스에서는 오로지 CPlayer클래스에만

접근하여 처리하도록 규칙을 정해놓겠습니다. 다음과 같은 구성이 됩니다.

[CGameRoom ---- CPlayer ---- CGameUser]
CGameRoom은 CGameUser의 존재를 알지 못합니다. 오직 CPlayer에만 접근할 수 있습니다.

CPlayer클래스를 살펴보겠습니다.

class CPlayer  
{  
    CGameUser owner;  
    public byte player_index { get; private set; }  
  
    public CPlayer(CGameUser user, byte player_index)  
    {  
        this.owner = user;  
        this.player_index = player_index;  
    }  
  
    public void send(CPacket msg)  
    {  
        this.owner.send(msg);  
    }  
}  

생성자에서 CGameUser와 플레이어 인덱스를 넘겨 받아 멤버 변수로 저장해 놓는 코드가 보입니다.

패킷을 전달할 때 CGameUser클래스의 인스턴스를 참조하여 처리하기 위함입니다.

이 클래스는 private로 선언하여 외부에서는 CGameUser를 절대로 건드리지 못하도록 하였습니다.

 

그 외 플레이어의 상태를 관리하는 변수와 현재 턴을 관리하는 변수들이 선언되어 있습니다.

 

 

게임 입장 처리와 로딩 동기화 하기

public CGameRoom()  
{  
    this.players = new List<CPlayer>();  
    this.player_state = new Dictionary<byte, PLAYER_STATE>();  
    this.current_turn_player = 0;  
}  
  
/// <summary>  
/// 매칭이 성사된 플레이어들이 게임에 입장한다.  
/// </summary>  
/// <param name="player1"></param>  
/// <param name="player2"></param>  
public void enter_gameroom(CGameUser user1, CGameUser user2)  
{  
    // 플레이어들을 생성하고 각각 1번, 2번 인덱스를 부여해 준다.  
    CPlayer player1 = new CPlayer(user1, 1);        // 1P  
    CPlayer player2 = new CPlayer(user2, 2);        // 2P  
    this.players.Clear();  
    this.players.Add(player1);  
    this.players.Add(player2);  
  
    // 플레이어들의 초기 상태를 지정해 준다.  
    this.player_state.Clear();  
    change_playerstate(player1, PLAYER_STATE.ENTERED_ROOM);  
    change_playerstate(player2, PLAYER_STATE.ENTERED_ROOM);  
  
    // 로딩 시작메시지 전송.  
    CPacket msg = CPacket.create((Int16)PROTOCOL.START_LOADING);  
    broadcast(msg);  
}  
  
/// <summary>  
/// 클라이언트에서 로딩을 완료한 후 요청함.  
/// 이 요청이 들어오면 게임을 시작해도 좋다는 뜻이다.  
/// </summary>  
/// <param name="sender">요청한 유저</param>  
public void loading_complete(CPlayer player)  
{  
    // 해당 플레이어를 로딩완료 상태로 변경한다.  
    change_playerstate(player, PLAYER_STATE.LOADING_COMPLETE);  
  
    // 모든 유저가 준비 상태인지 체크한다.  
    if (!allplayers_ready(PLAYER_STATE.LOADING_COMPLETE))  
    {  
        // 아직 준비가 안된 유저가 있다면 대기한다.  
        return;  
    }  
  
    // 모두 준비 되었다면 게임을 시작한다.  
    battle_start();  
}  
  
  
/// <summary>  
/// 모든 유저들에게 메시지를 전송한다.  
/// </summary>  
/// <param name="msg"></param>  
void broadcast(CPacket msg)  
{  
    this.players.ForEach(player => player.send(msg));  
    CPacket.destroy(msg);  
}  
  
/// <summary>  
/// 플레이어의 상태를 변경한다.  
/// </summary>  
/// <param name="player"></param>  
/// <param name="state"></param>  
void change_playerstate(CPlayer player, PLAYER_STATE state)  
{  
    if (this.player_state.ContainsKey(player.player_index))  
    {  
        this.player_state[player.player_index] = state;  
    }  
    else  
    {  
        this.player_state.Add(player.player_index, state);  
    }  
}  
  
/// <summary>  
/// 모든 플레이어가 특정 상태가 되었는지를 판단한다.  
/// 모든 플레이어가 같은 상태에 있다면 true, 한명이라도 다른 상태에 있다면 false를 리턴한다.  
/// </summary>  
/// <param name="state"></param>  
/// <returns></returns>  
bool allplayers_ready(PLAYER_STATE state)  
{  
    foreach(KeyValuePair<byte, PLAYER_STATE> kvp in this.player_state)  
    {  
        if (kvp.Value != state)  
        {  
            return false;  
        }  
    }  
  
    return true;  
}  

enter_gameroom 매소드에서 게임에 입장하는 유저들을 넘겨 받습니다.

아직 이 매소드가 어디서 어떻게 호출되는지는 모릅니다. 일단 어디선가 호출해 준다고 가정하고

코딩을 진행 합시다. 지금은 개별적인 모듈을 만들어 나가는 단계이기 때문에 다른 부분과 연결되는 작업은

아직 들어가지 않은 상태입니다.

각 플레이어들에게 고유한 인덱스를 부여하고 로딩을 시작하라는 메시지를 보냅니다.

이 때 broadcast라는 매소드를 사용하였는데 이것의 의미는 방안에 들어와 있는 모든

유저들에게 메시지를 전송하겠다는 뜻입니다.

로딩을 시작하라는 메시지는 모든 유저들에게 똑같이 전달되어야 하므로 broadcast를 사용한 것입니다.

특정 유저에게만 전달해야 하는 메시지는 send라는 매소드를 별도로 만들어 사용할 것입니다.

 

loading_complete 매소드는 로딩을 완료한 클라이언트가 보내오는 메시지 입니다.

이 매소드 역시 어디서 어떤 구조로 호출되는지 아직 알 수 없지만 그렇다고 가정하고 작업하도록 합시다.

혼자 진행 하는 게임에서는 로딩이 완료되면 바로 시작해도 상관 없지만

온라인 게임에서는 모든 유저들의 로딩이 완료되었는지 확인한 후 게임을 시작해야 합니다.

상대방이 어떤 사양의 PC나 모바일 기기를 사용하는지 알 수 없으므로 로딩 시간은 기기마다 제각각일 것입니다.

따라서 조금 시간이 걸릴지라도 모든 유저들의 로딩이 완료된 후 게임을 시작하도록 코딩해야 합니다.

만약 이 룰을 지키지 않게 될 경우 한명은 게임을 시작하였지만 또다른 한명은 아직 로딩중일 수 있어

동기화가 깨지는 일이 발생하게 됩니다.

 

 

게임 시작과 초기화

/// <summary>  
/// 게임을 시작한다.  
/// </summary>  
void battle_start()  
{  
    // 게임을 새로 시작할 때 마다 초기화해줘야 할 것들.  
    reset_gamedata();  
  
    // 게임 시작 메시지 전송.  
    CPacket msg = CPacket.create((short)PROTOCOL.GAME_START);  
    // 플레이어들의 세균 위치 전송.  
    msg.push((byte)this.players.Count);  
    this.players.ForEach(player =>  
    {  
        msg.push(player.player_index);      // 누구인지 구분하기 위한 플레이어 인덱스.  
  
        // 플레이어가 소지한 세균들의 전체 개수.  
        byte cell_count = (byte)player.viruses.Count;  
        msg.push(cell_count);  
        // 플레이어의 세균들의 위치정보.  
        player.viruses.ForEach(position => msg.push(position));  
    });  
    // 첫 턴을 진행할 플레이어 인덱스.  
    msg.push(this.current_turn_player);  
    broadcast(msg);  
}  

모든 클라이언트의 로딩이 완료되었으면 게임을 시작하는 battle_start매소드를 호출합니다.

게임 데이터를 초기화 해준 뒤 클라이언트들에게 게임 시작 메시지를 전송해 줍니다.

이 때 필요한 정보는 전체 플레이어의 수, 각 플레이어들의 인덱스 정보, 세균들의 위치 정보등이 있습니다.

이 정보를 토대로 클라이언트에서는 보드판 위에 플레이이어들의 세균을 배치하게 됩니다.

 

게임 보드판의 변경 권한은 서버만 갖고 있도록 약속하였기 때문에

세균들의 위치 정보나 플레이어 인덱스와 같은 중요한 내용들은 서버에서 결정한 뒤 클라이언트에게

일방적으로 통보 하는 방식으로 작업이 이루어 집니다.

따라서 클라이언트에세 임의로 세균들의 정보를 조작하여도 서버에는 절대로 반영되지 않기 때문에

클라이언트 해킹을 무력화 시킬 수 있는 것이죠.

앞으로도 게임의 핵심적인 내용을 코딩할 때는 무조건 서버에 존재하는 정보를 토대를 작업할 것입니다.

 

/// <summary>  
/// 게임 데이터를 초기화 한다.  
/// 게임을 새로 시작할 때 마다 초기화 해줘야 할 것들을 넣는다.  
/// </summary>  
void reset_gamedata()  
{  
    // 보드판 데이터 초기화.  
    for (int i = 0; i < this.gameboard.Count; ++i)  
    {  
        this.gameboard[i] = 0;  
    }  
    // 1번 플레이어의 세균은 왼쪽위(0,0), 오른쪽위(0,7) 두군데에 배치한다.  
    put_virus(1, 0, 0);  
    put_virus(1, 0, 7);  
    // 2번 플레이어는 세균은 왼쪽아래(7,0), 오른쪽아래(7,7) 두군데에 배치한다.  
    put_virus(2, 7, 0);  
    put_virus(2, 7, 7);  
  
    // 턴 초기화.  
    this.current_turn_player = 1;   // 1P부터 시작.  
}  

게임 데이터의 초기화를 진행합니다.

보드판 정보를 빈공간을 뜻하는 0으로 채워 넣습니다. 그리고 각 플레이어들의 세균을 두개씩 배치합니다.

 

 

이동 처리와 세균 감염

이 게임에서 제일 핵심적인 부분인 세균의 이동을 처리해보도록 하겠습니다.

로딩이 완료되고 서버로부터 게임 시작 메시지를 받으면 클라이언트에서 유저의 입력을 받아

그 내용을 서버로 전송합니다.

서버가 없이 클라이언트 단독으로 진행하는 게임은 서버의 허락을 받을 필요 없이

클라이언트 내에서 직접 이동 처리를 진행하지만 서버가 존재하는 게임은 서버의 허락을 요청하는 과정을 거치도록

작성해야 합니다.

다른 플레이어의 이동 뿐만 아니라 본인의 이동 역시 마찬가지 입니다.

클라이언트에서 이동 요청이 들어왔을 때 수행되는 매소드의 코드입니다.

/// <summary>  
/// 클라이언트의 이동 요청.  
/// </summary>  
/// <param name="sender">요청한 유저</param>  
/// <param name="begin_pos">시작 위치</param>  
/// <param name="target_pos">이동하고자 하는 위치</param>  
public void moving_req(CPlayer sender, short begin_pos, short target_pos)  
{  
    // sender차례인지 체크.  
    if (this.current_turn_player != sender.player_index)  
    {  
        // 현재 턴이 아닌 플레이어가 보낸 요청이라면 무시한다.  
        // 이런 비정상적인 상황에서는 화면이나 파일로 로그를 남겨두는것이 좋다.  
        return;  
    }  
  
    // begin_pos에 sender의 세균이 존재하는지 체크.  
    if (this.gameboard[begin_pos] != sender.player_index)  
    {  
        // 시작 위치에 해당 플레이어의 세균이 존재하지 않는다.  
        return;  
    }  
  
    // 목적지는 0으로 설정된 빈 공간이어야 한다.  
    // 다른 세균이 자리하고 있는 곳으로는 이동할 수 없다.  
    if (this.gameboard[target_pos] != 0)  
    {  
        // 목적지에 다른 세균이 존재한다.  
        return;  
    }  
  
    // target_pos가 이동 또는 복제 가능한 범위인지 체크.  
    short distance = CHelper.get_distance(begin_pos, target_pos);  
    if (distance > 2)  
    {  
        // 2칸을 초과하는 거리는 이동할 수 없다.  
        return;  
    }  
  
    if (distance <= 0)  
    {  
        // 자기 자신의 위치로는 이동할 수 없다.  
        return;  
    }  
  
    // 모든 체크가 정상이라면 이동을 처리한다.  
    if (distance == 1)      // 이동 거리가 한칸일 경우에는 복제를 수행한다.  
    {  
        put_virus(sender.player_index, target_pos);  
    }  
    else if (distance == 2)     // 이동 거리가 두칸일 경우에는 이동을 수행한다.  
    {  
        // 이전 위치에 있는 세균은 삭제한다.  
        remove_virus(sender.player_index, begin_pos);  
  
        // 새로운 위치에 세균을 놓는다.  
        put_virus(sender.player_index, target_pos);  
    }  
  
    // 목적지를 기준으로 주위에 존재하는 상대방 세균을 감염시켜 같은 편으로 만든다.  
    CPlayer opponent = get_opponent_player();  
    infect(target_pos, sender, opponent);  
  
    // 최종 결과를 broadcast한다.  
    CPacket msg = CPacket.create((short)PROTOCOL.PLAYER_MOVED);  
    msg.push(sender.player_index);      // 누가  
    msg.push(begin_pos);                // 어디서  
    msg.push(target_pos);               // 어디로 이동 했는지  
    broadcast(msg);  
}  

begin_pos와 target_pos는 유저가 선택한 지점과 이동하려는 지점을 나타내는 값입니다.

이 값은 클라이언트에서 보내온 값이므로 무조건 적으로 신뢰하면 안됩니다.

항상 잘못된 값이 들어올 수 있다고 가정한 뒤 코딩을 해야 합니다.

또한 패킷 캡쳐툴 등을 이용해 자신의 차례가 아님에도 불구하고 패킷을 보내올 수 있으므로

서버에 저장되어 있는 현재 플레이어의 턴과 비교하여 자신의 턴이 아닌 플레이어가 보내온 패킷은

무시해 버리는등의 조치가 필요합니다.

begin_pos와 target_pos에 대해서는 서버에 존재하는 보드판 정보를 토대로

정상적으로 허용 가능한 범위의 이동인지에 대해서 체크합니다. 이동할 수 없는 위치까지

이동하겠다고 요청이 들어온 경우에는 해킹으로 간주하여 처리하지 말아야 합니다.

 

이 코드에서는 매소드를 리턴시키고 끝냈지만 제대로 만드려면 오류 로그를 찍고 뒷처리 까지
말끔하게 끝내야 합니다.
해당 플레이어를 패배 처리 한다던지, 아니면 입력을 무시하고 다시 받게 한다던지 하는 등의
과정을 말하는 것이죠. 그렇지 않으면 해킹을 시도하지 않은 상대방 플레이어는 무슨 일이 일어나는건지
알지 못한 채로 가만히 기다릴 수 밖에 없기 때문이죠.
그런 처리까지 다 적용하려면 코드가 너무 방대해지기 때문에 이정도 까지만 작성하도록 하겠습니다.

모든 체크 사항이 정상적이라면 본격적인 이동을 진행합니다.

클라이언트에서는 화면에 보여지는 처리 위주로 코드가 구성되지만 서버에서는 데이터 위주로

코드가 구성 됩니다. 따라서 이동 처리라고 하여도 실제로 캐릭터가 이동하는 모습을 구현하는 것이 아니라

데이터의 변화만 생길 뿐입니다.

게임의 룰에 따라 한칸을 이동할 경우에는 자기 자신을 복제 하여 세균이 하나 더 생기게 되며, 

두칸일 경우에는 목적지로 이동 하도록 처리 해야 합니다.

/// <summary>  
/// 보드판에 플레이어의 세균을 배치한다.  
/// </summary>  
/// <param name="player_index"></param>  
/// <param name="position"></param>  
void put_virus(byte player_index, short position)  
{  
    this.gameboard[position] = player_index;  
    get_player(player_index).add_cell(position);  
}  

플레이어의 세균을 배치하는 put_virus매소드 입니다. 보드판에 세균을 배치하면서

해당 플레이어 객체에도 추가해 줍니다. 추후에 어느 플레이어가 몇마리의 세균을 보유했는지등을

계산할 때 좀 더 쉽게 하기 위해서 플레이어 객체에도 세균 정보를 추가하도록 한 것입니다.

 

이동을 할 경우에는 현재 위치의 세균을 삭제한 뒤 새로운 위치에 세균을 배치하게 됩니다.

배치된 세균을 삭제하는 매소드인 remove_virus의 코드 입니다.

 

/// <summary>  
/// 배치된 세균을 삭제한다.  
/// </summary>  
/// <param name="player_index"></param>  
/// <param name="position"></param>  
void remove_virus(byte player_index, short position)  
{  
    this.gameboard[position] = 0;  
    get_player(player_index).remove_cell(position);  
}  

이동이 완료된 후에는 상대방 세균을 감염 시킬 수 있습니다.

감염 대상은 최종 목적지에서 한칸 반경에 있는 상대방 세균들 입니다.

상대방 세균을 감염 시키는 infect매소드의 코드 입니다.

 

/// <summary>  
/// 상대방의 세균을 감염 시킨다.  
/// </summary>  
/// <param name="basis_cell"></param>  
/// <param name="attacker"></param>  
/// <param name="victim"></param>  
public void infect(short basis_cell, CPlayer attacker, CPlayer victim)  
{  
    // 방어자의 세균중에 기준위치로 부터 1칸 반경에 있는 세균들이 감염 대상이다.  
    List<short> neighbors = CHelper.find_neighbor_cells(basis_cell, victim.viruses, 1);  
    foreach (short position in neighbors)  
    {  
        // 방어자의 세균을 삭제한다.  
        remove_virus(victim.player_index, position);  
  
        // 공격자의 세균을 추가하고,  
        put_virus(attacker.player_index, position);  
    }  
}  

find_neighbor_cells라는 매소드를 통해서 주위 한칸 반경에 있는 상대방 세균들의 위치를 구합니다.

해당 위치에 존재하고 있던 방어자의 세균을 삭제하고 공격자의 세균으로 교체합니다.

find_neighbor_cells매소드는 세균들의 위치 계산에 도움을 주는 CHelper클래스에 속해 있습니다.

이 클래스의 내용들은 이 장의 뒷 부분에서 모두 모아 설명해 드리도록 하겠습니다.

 

마지막으로 모든 클라이언트들에게 플레이어의 이동을 알려주는 패킷을 전송합니다.

// 최종 결과를 broadcast한다.  
CPacket msg = CPacket.create((short)PROTOCOL.PLAYER_MOVED);  
msg.push(sender.player_index);      // 누가  
msg.push(begin_pos);                // 어디서  
msg.push(target_pos);               // 어디로 이동 했는지  
broadcast(msg);  

앞서 처리한 내용은 많지만 전송 내용은 세가지면 충분합니다.

해당 플레이어의 인덱스, 시작 위치, 목적지 위치가 끝입니다.

나머지는 클라이언트에서 서버와 동일한 로직을 갖고 처리하도록 할 것입니다.

변경된 보드판의 내용을 전부 전송해야 하지 않겠냐고 생각하실 수도 있지만

서버와 클라이언트가 동일한 로직을 갖고 입력되는 파라미터를 동일하게 맞춘다면

결과 또한 서로 같을 것이기 때문에 패킷 내용은 단순하게 구성하여도 문제 되지 않습니다.

만약 클라이언트에서 로직을 조작하여 서버와 다른 데이터를 갖게 된다고 하더라도

서버의 데이터가 변경되는 것은 아니기 때문에 아무 의미 없는 작업이 되겠죠.

 

이렇게 해서 세균의 이동과 감염 처리 까지 모두 완료 되었습니다.

서버에서는 데이터의 변경만 처리하면 되기 때문에 화면 갱신등을 신경쓸 필요가 없습니다.

최종 결과를 만들고 이 정보들을 클라이언트에게 전송한 뒤 다시 무언가 요청이 올 때 까지 기다리고 있으면 됩니다.

클라이언트는 자신이 요청한 내용에 대해서 응답을 받아 그 내용을 토대로 각종 연출을 첨가하여

화면 출력과 에니메이션 처리등을 수행한 뒤 서버에 모든 동작이 끝났다는것을 알려주게 됩니다.

 

마치 서버와 클라이언트가 서로 대화하듯 처리가 이루어 지는것을 볼 수 있습니다.

클라이언트는 소심한 성격을 가진것 처럼 항상 서버에 물어본 뒤 처리하는것이 좋습니다.

반대로 서버는 대범하게 잘못된 클라이언트의 요청에 대해서 무시하거나 훈계를 할 수도 있는 위치입니다.

 

하지만 만약 반응성이 중요한 액션 게임이라면 얘기가 달라집니다.

유저가 연속 콤보를 사용했는데 매 순간순간 서버에 물어보고 처리하느라 반응이 늦어진다면 오히려 독이 되겠지요.

그럴때는 일단 클라이언트에서 먼저 처리를 한 뒤 추후에 서버의 검증을 거치는 방식이 필요합니다.

또한 실시간 통신이 필요하지 않은 단순한 퍼즐 게임 같은 경우에도 게임 로직처리는 클라이언트에서

단독으로 진행한 뒤 마지막에 점수 계산만 서버에 맡기는 식으로 구현하기도 합니다.

 

 

 

=======================

=======================

=======================

 

 

 

 

 

 

출처: http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Lecture_series&page=2&sn1=&divpage=1&sn=off&ss=on&sc=on&select_arrange=hit&desc=asc&no=69

C#으로 게임 서버 만들기 - (강좌7에 이어서)턴 종료 처리

(앞의 강좌에 다 안올라가서 이어서 올립니다)

 

턴 종료 처리

/// <summary>  
/// 클라이언트에서 턴 연출이 모두 완료 되었을 때 호출된다.  
/// </summary>  
/// <param name="sender"></param>  
public void turn_finished(CPlayer sender)  
{  
    change_playerstate(sender, PLAYER_STATE.CLIENT_TURN_FINISHED);  
  
    if (!allplayers_ready(PLAYER_STATE.CLIENT_TURN_FINISHED))  
    {  
        return;  
    }  
  
    // 턴을 넘긴다.  
    turn_end();  
}  
  
  
/// <summary>  
/// 턴을 종료한다. 게임이 끝났는지 확인하는 과정을 수행한다.  
/// </summary>  
void turn_end()  
{  
    // 보드판 상태를 확인하여 게임이 끝났는지 검사한다.  
    if (!CHelper.can_play_more(this.gameboard, get_current_player(), this.players))  
    {  
        // game over.  
        return;  
    }  
  
    // 아직 게임이 끝나지 않았다면 다음 플레이어로 턴을 넘긴다.  
    if (this.current_turn_player < this.players.Count - 1)  
    {  
        ++this.current_turn_player;  
    }  
    else  
    {  
        // 다시 첫번째 플레이어의 턴으로 만들어 준다.  
        this.current_turn_player = this.players[0].player_index;  
    }  
  
    // 턴을 시작한다.  
    start_turn();  
}  

서버로부터 세균의 이동 통보를 받은 클라이언트들은 서로 각자의 화면에서 세균의 이동과 에니메이션

등을 처리하게 됩니다. 모든 처리가 끝난 클라이언트는 서버에 처리 완료 되었다는 메시지를 보내야 합니다.

서버에서는 모든 클라이언트의 화면 처리가 끝났는지 체크한 뒤 턴 종료 처리를 수행합니다.

 

이 부분에서도 클라이언트는 단독으로 턴을 넘기지 않고 서버에 요청한 뒤 허락이 떨어지기만을 기다리고 있게 됩니다.

서버는 이 때 최대한 빨리 처리를 하여 클라이언트들에게 다음 작업을 진행하라고 말해줘야 하는 것이지요.

이 처리 시간이 길어질수록 유저는 렉을 느끼게 되고 서버가 안좋다는등의 판단을 하게 됩니다.

따라서 서버의 로직을 작성할 때는 최대한 빠른 시간 안에 수행될 수 있도록 최적화 하는 것이 중요합니다.

클라이언트에서는 화면의 버벅거림을 해소하기 위한 최적화를 진행 하지만

서버에서는 유저들의 대기 시간을 최소한으로 줄여주기 위한 최적화를 진행 하게 되는 것입니다.

물론 이런 상황에서도 안전성이 제일 중요하다는 것은 말할 필요가 없겠지요.

반응시간을 줄인다고 꼭 필요한 체크를 안하고 넘어간다면 해킹에 무방비가 되어

결국엔 또 다시 체크하는 코드를 넣을 수 밖에 없게 됩니다.

 

 

위치 계산에 도움되는 매소드 구현

게임의 보드판은 8*8형식의 격자 형태로 구성되어 있습니다. 하지만 CGameRoom에 정의된 보드판

데이터는 리스트 형식으로 된 변수만 존재할 뿐입니다.

따라서 리스트 형식을 격자형식으로, 격자 형식을 리스트 형식으로 변환하는 매소드들을 만들어 두면 

코딩할 때 편리하게 작업할 수 있습니다.

이런 매소드들을 CHelper라는 클래스에 모아 놨습니다. 코딩에 도움을 준다는 의미에서 지은 이름입니다.

이 클래스의 매소드들은 특정 클래스에 엮여 있는 것이 아닌 계산의 편의를 위해 만들어 졌기 때문에

static으로 선언하여 언제 어디서든 호출 가능하도록 구현해 놨습니다.

 

public static class CHelper  
{  
    static byte COLUMN_COUNT = 8;  
  
    /// <summary>  
    /// 포지션을 (row,col)형식의 좌표로 변환한다.  
    /// </summary>  
    /// <param name="cell"></param>  
    /// <returns></returns>  
    static Vector2 convert_to_xy(short position)  
    {  
        return new Vector2(calc_row(position), calc_col(position));  
    }  
  
  
    /// <summary>  
    /// (row, col)형식의 좌표를 포지션으로 변환한다.  
    /// </summary>  
    /// <param name="row"></param>  
    /// <param name="col"></param>  
    /// <returns></returns>  
    public static short get_position(byte row, byte col)  
    {  
        return (short)(row * COLUMN_COUNT + col);  
    }  
  
  
    /// <summary>  
    /// 포지션으로부터 세로 인덱스를 구한다.  
    /// </summary>  
    /// <param name="cell"></param>  
    /// <returns></returns>  
    public static short calc_row(short position)  
    {  
        return (short)(position / COLUMN_COUNT);  
    }   
  
  
    /// <summary>  
    /// 포지션으로부터 가로 인덱스를 구한다.  
    /// </summary>  
    /// <param name="cell"></param>  
    /// <returns></returns>  
    public static short calc_col(short position)  
    {  
        return (short)(position % COLUMN_COUNT);  
    }  
  
  
    /// <summary>  
    /// cell 인덱스를 넣으면 둘 사이의 거리값을 리턴해 준다.  
    /// 한칸이 차이나면 1, 두칸이 차이나면 2  
    /// </summary>  
    /// <param name="from"></param>  
    /// <param name="to"></param>  
    /// <returns></returns>  
    public static short get_distance(short from, short to)  
    {  
        Vector2 pos1 = convert_to_xy(from);  
        Vector2 pos2 = convert_to_xy(to);  
        return get_distance(pos1, pos2);  
    }  
  
    public static short get_distance(Vector2 pos1, Vector2 pos2)  
    {  
        Vector2 distance = pos1 - pos2;  
  
        short x = (short)Math.Abs(distance.x);  
        short y = (short)Math.Abs(distance.y);  
  
        // x,y중 큰 값이 실제 두 위치 사이의 거리를 뜻한다.  
        return Math.Max(x, y);  
    }  
  
    public static byte howfar_from_clicked_cell(short basis_cell, short cell)  
    {  
        short row = (short)(basis_cell / COLUMN_COUNT);  
        short col = (short)(basis_cell % COLUMN_COUNT);  
        Vector2 basic_pos = new Vector2(col, row);  
  
        row = (short)(cell / COLUMN_COUNT);  
        col = (short)(cell % COLUMN_COUNT);  
        Vector2 cell_pos = new Vector2(col, row);  
  
        Vector2 distance = (basic_pos - cell_pos);  
        short x = (short)Math.Abs(distance.x);  
        short y = (short)Math.Abs(distance.y);  
        return (byte)Math.Max(x, y);  
    }  
  
  
    /// <summary>  
    /// 주위에 있는 셀의 위치를 찾아서 리스트로 리턴해 준다.  
    /// </summary>  
    /// <param name="basis_cell"></param>  
    /// <param name="targets"></param>  
    /// <param name="gap"></param>  
    /// <returns></returns>  
    public static List<short> find_neighbor_cells(short basis_cell, List<short> targets, short gap)  
    {  
        Vector2 pos = convert_to_xy(basis_cell);  
        return targets.FindAll(obj => get_distance(pos, convert_to_xy(obj)) <= gap);  
    }  
  
  
    /// <summary>  
    /// 게임을 지속 할 수 있는지 체크한다.  
    /// </summary>  
    /// <param name="board"></param>  
    /// <param name="players"></param>  
    /// <param name="current_player_index"></param>  
    /// <returns></returns>  
    public static bool can_play_more(List<short> board, CPlayer current_player, List<CPlayer> all_player)  
    {  
        foreach (short cell in current_player.viruses)  
        {  
            if (CHelper.find_available_cells(cell, board, all_player).Count > 0)  
            {  
                return true;  
            }  
        }  
        return false;  
    }  
  
  
    /// <summary>  
    /// 이동 가능한 셀을 찾아서 리스트로 돌려준다.  
    /// </summary>  
    /// <param name="basis_cell"></param>  
    /// <param name="total_cells"></param>  
    /// <param name="players"></param>  
    /// <returns></returns>  
    public static List<short> find_available_cells(short basis_cell, List<short> total_cells, List<CPlayer> players)  
    {  
        List<short> targets = find_neighbor_cells(basis_cell, total_cells, 2);  
  
        players.ForEach(obj =>  
        {  
            targets.RemoveAll(number => obj.viruses.Exists(cell => cell == number));  
        });  
  
        return targets;  
    }  
}  
세균전 게임을 설계 하고 상세한 구현 부분 까지 코딩해 봤습니다.
서버측 로직이라 화면에 보이는 것이 없어 조금 지루했을지도 모르겠습니다.
아직 점수 계산 등의 처리는 구현이 안되어 추후 클라이언트 연동 작업 할 때 다시 진행하도록 하겠습니다.
다음 강좌에서는 유저의 접속과 매칭 시스템을 구현하고 멀리 떨어진 유저들이 어떻게
게임방 안에 들어가 함께 게임을 즐길 수 있는지 그 원리를 파헤쳐 보도록 하겠습니다.
감사합니다.

 

 

 

=======================

=======================

=======================

 

 

 

 

 

 

 

출처: http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Lecture_series&page=1&sn1=&divpage=1&sn=off&ss=on&sc=on&select_arrange=hit&desc=asc&no=78

C#으로 게임 서버 만들기 - 8. 온라인 세균전 게임 만들기(유저 매칭, 클라이언트 연동-I)

유저 매칭

 

매칭 요청과 게임방 입장

클라이언트의 매칭 요청은 CGameUser클래스의 process_user_operation매소드에서 처리 합니다.

process_user_operation매소드는 클라이언트가 보내온 모든 메시지들을 처리하는 매소드 입니다.

서버에 접속한 이후 클라이언트는 서버와 수많은 메시지들을 주고 받는데 모든 메시지 처리의

시작 부분이 바로 process_user_operation매소드라고 할 수 있죠.

 

매칭 요청은 클라이언트가 PROTOCOL.ENTER_GAME_ROOM_REQ프로토콜을 보내오면서 시작 됩니다.

이 매소드에서 메시지를 핸들링 하여 CGameServer클래스의 matching_req매소드를 호출합니다.

void IPeer.process_user_operation(CPacket msg)  
{  
    PROTOCOL protocol = (PROTOCOL)msg.pop_protocol_id();  
    Console.WriteLine("protocol id " + protocol);  
    switch (protocol)  
    {  
        case PROTOCOL.ENTER_GAME_ROOM_REQ:  
            Program.game_main.matching_req(this);  
            break;  
    }  
}  

일단 매칭 요청이 오면 대기 리스트에 해당 유저를 추가 합니다.

세균전 게임은 1:1로 진행 되기 때문에 두명의 유저가 매칭 요청을 해야 하나의 게임방을

생성할 수 있습니다. 따라서 대기 리스트에 두명이 채워지기 전 까지는 일단 대기상태로 있습니다.

matching_req매소드의 소스 코드를 보겠습니다.

/// <summary>  
/// 유저로부터 매칭 요청이 왔을 때 호출됨.  
/// </summary>  
/// <param name="user">매칭을 신청한 유저 객체</param>  
public void matching_req(CGameUser user)  
{  
    // 매칭 대기 리스트에 추가.  
    this.matching_waiting_users.Add(user);  
  
    // 2명이 모이면 매칭 성공.  
    if (this.matching_waiting_users.Count == 2)  
    {  
        // 게임 방 생성.  
        this.room_manager.create_room(this.matching_waiting_users[0], this.matching_waiting_users[1]);  
  
        // 매칭 대기 리스트 삭제.  
        this.matching_waiting_users.Clear();  
    }  
}  

두명이 매칭 요청을 하면 게임 방을 생성하고 대기 리스트에서 삭제 합니다.

이후 또다른 매칭 요청이 오면 같은 방식으로 게임방을 생성하여 유저들을 차례대로 입장 시킵니다.

 

room_manager.create_room매소드를 호출할 때 현재 대기 리스트에 들어있는 유저 두명을

파라미터로 넘겨주게 되는데 이 순간이 바로 하나의 방으로 유저들을 입장시키는 순간이 됩니다.

아래는 room_manager에서 두명의 유저들을 받아 하나의 게임방을 생성하는 코드 입니다.

class CRoomManager  
{  
...  
  
    /// <summary>  
    /// 매칭을 요청한 유저들을 넘겨 받아 게임 방을 생성한다.  
    /// </summary>  
    /// <param name="user1"></param>  
    /// <param name="user2"></param>  
    public void create_room(CGameUser user1, CGameUser user2)  
    {  
        // 게임 방을 생성하여 입장 시킴.  
        CGameRoom battleroom = new CGameRoom();  
        battleroom.enter_gameroom(user1, user2);  
  
        // 방 리스트에 추가 하여 관리한다.  
        this.rooms.Add(battleroom);  
    }  
  
...  
}  

new연산자를 통해서 논리적인 게임방 객체 하나를 만든 뒤 유저들을 플레이어로 참가시켜 게임방 안으로 입장시킵니다.

이 작업이 이루어진 후 부터 각 유저들은 상대방의 존재를 알게 되고 서로 통신을 하며 게임을 진행할 수 있게 됩니다.

여기까지의 호출 과정을 다시 한번 정리해 보겠습니다.

 

- 게임 서버에 접속

- 매칭 요청(PROTOROL.ENTER_GAME_ROOM_REQ)

- CGameUser클래스에서 프로토콜 핸들링

- CGameServer클래스의 matching_req매소드 호출

- 대기 리스트에 추가

- CRoomManager클래스의 create_room매소드 호출

- new CGameRoom() 으로 게임방 객체 생성

- CGameRoom클래스의 enter_gameroom매소드 호출

[클라이언트가 게임방에 입장하는 과정]

 

이러한 과정을 거치면 멀리 떨어진 클라이언트들이 하나의 방 안에서 동시에 게임을 진행할 수 있게 됩니다.

단순히 생각해 보면 클라이언트는 여러 소켓들중 하나일 뿐이지만 CGameRoom이라는 논리적인 공간을 통해서

그룹화 되고 같은 그룹 내에 존재하는 유저들끼리 패킷을 송,수신 하도록 제어해 주면 마치 

하나의 공간 안에 있는 듯한 느낌을 받게 되는 것입니다.

 

 

 

클라이언트와의 연동

이제 부터는 클라이언트측의 소스 코드를 작성해 보면서 서버에서 작업했던 내용들과 연동해 보도록 하겠습니다.

서버 작업은 데이터 위주로 이루어 졌지만 클라이언트는 화면에 보여지는 부분이 대부분을 차지 합니다.

 

먼저 유저 인터페이스 구현을 해 본 뒤, 이를 통해서 서버에 요청하는 부분의 코드를 작성해 볼 것입니다.

그리고 서버의 응답에 따라 클라이언트의 화면을 갱신하여 실시간으로 서버와 통신하는 게임의 모습을 만들어 나가도록 하겠습니다.

비록 게임의 크기는 작지만 실시간 온라인 게임 개발의 원리를 이해하는데에는 충분하리라 생각 됩니다.

 

유니티 프로젝트 구성

유니티 엔진을 실행한 뒤 VirusWarClient라는 이름으로 프로젝트를 생성 합니다.

 

 

 

위 그림대로 씬을 구성합니다.

GameObject -> Create Empty 메뉴를 이용하여 씬을 구성하는 오브젝트들을 생성 합니다.

이름은 각각 NetworkManager, MainTitle, BattleRoom으로 하며 transform값은 기본 값으로 설정 합니다.

지금 생성하는 오브젝트들은 화면에 보이지 않고 스크립트 수행을 위한 용도로만 사용되기 때문에

위치값이나 회전값등은 아무 의미가 없으니 기본 설정으로 둡니다.

다음으로 FreeNet라이브러리 파일을 연동해 보는 과정을 살펴 보겠습니다.

유니티 버전의 에코 클라이언트 프로젝트를 만들었을때 했던 과정과 비슷합니다.

 

 

FreeNet라이브러리 파일 구성

 

Assets폴더 밑에 FreeNet폴더를 생성한 뒤 FreeNet.dll파일을 복사해 옵니다.

나머지 스크립트 파일들은 이름에 맞게 생성 해 줍니다. FreeNet폴더에서 마우스 오른쪽 버튼을 눌러

Create -> C# Script메뉴를 이용하여 스크립트를 생성 해 줍니다. 아직 내용은 작성하지 않아도 됩니다.

 

다음으로 Resources/images폴더를 생성 하여 아래 그림과 같이 이미지 파일들을 복사해 옵니다.

 

 

이미지 폴더 구성

 

Resources/scripts폴더를 생성 하여 아래 그림과 같이 스크립트 파일들을 만듭니다.

 

스크립트 폴더 구성

 

마지막으로 씬에 있는 게임 오브젝트에 스크립트 파일을 연결 시킵니다.

NetworkManager오브젝트에는 CNetworkManager.cs

MainTitle오브젝트에는 CMainTitle.cs

BattleRoom오브젝트에는 CBattleRoom.cs 파일을 각각 연결 합니다.

연결 방법은 scripts폴더에서 cs파일을 마우스로 드래그 한 뒤 Hierarchy창의 

게임 오브젝트 이름 위에 올려 놓으면 됩니다.

 

 

 

 

 

 

게임 오브젝트에 스크립트들이 정상적으로 연결된 모습

 

 

서버 접속

유니티 프로젝트의 구성이 완료 되었으면 이제 소스 코드 작성에 들어가 보겠습니다.

제일 먼저 NetworkManager오브젝트에 연결되어 있는 CNetworkManager.cs스크립트의 소스 코드 입니다.

using UnityEngine;  
using System;  
using System.Collections;  
using FreeNet;  
using FreeNetUnity;  
using VirusWarGameServer;  
  
public class CNetworkManager : MonoBehaviour {  
  
    CFreeNetUnityService gameserver;  
    string received_msg;  
  
    public MonoBehaviour message_receiver;  
  
    void Awake()  
    {  
        this.received_msg = "";  
  
        // 네트워크 통신을 위해 CFreeNetUnityService객체를 추가한다.  
        this.gameserver = gameObject.AddComponent<CFreeNetUnityService>();  
  
        // 상태 변화(접속, 끊김등)를 통보 받을 델리게이트 설정.  
        this.gameserver.appcallback_on_status_changed += on_status_changed;  
  
        // 패킷 수신 델리게이트 설정.  
        this.gameserver.appcallback_on_message += on_message;  
    }  
  
    // Use this for initialization  
    void Start()  
    {  
        connect();  
    }  
  
    void connect()  
    {  
        this.gameserver.connect("127.0.0.1", 7979);  
    }  
  
    /// <summary>  
    /// 네트워크 상태 변경시 호출될 콜백 매소드.  
    /// </summary>  
    /// <param name="server_token"></param>  
    void on_status_changed(NETWORK_EVENT status)  
    {  
        switch (status)  
        {  
                // 접속 성공.  
            case NETWORK_EVENT.connected:  
                {  
                    CLogManager.log("on connected");  
                    this.received_msg += "on connected\n";  
  
                    GameObject.Find("MainTitle").GetComponent<CMainTitle>().on_connected();  
                }  
                break;  
  
                // 연결 끊김.  
            case NETWORK_EVENT.disconnected:  
                CLogManager.log("disconnected");  
                this.received_msg += "disconnected\n";  
                break;  
        }  
    }  
  
    void on_message(CPacket msg)  
    {  
        this.message_receiver.SendMessage("on_recv", msg);  
    }  
  
    public void send(CPacket msg)  
    {  
        this.gameserver.send(msg);  
    }  
}  

NetworkManager는 게임이 시작되면 제일 먼저 서버에 접속하는 일을 담당 합니다.

정상적으로 접속이 완료 되면 MainTitle오브젝트의 on_connected매소드를 호출 하여 접속 사실을 통보 합니다.

또한 서버로부터 메시지를 수신하면 현재 설정되어 있는 MessageReceiver객체의 on_recv매소드를 호출 합니다.

수신된 패킷 객체를 파라미터로 넘겨 주어 해당 오브젝트에서 패킷을 처리 할 수 있도록 만들어 줍니다.

NetworkManager는 유니티 엔진에서 수행되는 스크립트들과 게임 서버 사이의 

중간 역할을 담당하는 스크립트라고 생각 하면 됩니다.

그렇기 때문에 게임 로직에 대한 코드는 들어가지 않고 이벤트를 통보 해주는 등의 일반적인 일들을 수행 합니다.

이렇게 설계해 놓으면 추후 다른 게임을 개발할 때도 NetworkManager는 크게 수정하지 않고 재사용 할 수 있게 됩니다.

 

 

접속 이후 화면 처리

서버에 접속이 완료되면 MainTitle오브젝트에 연결되어 있는 CMainTitle.cs스크립트의 on_connected매소드가

수행 됩니다. on_connected매소드에서는 접속 완료를 뜻하는 is_connected플래그를 true로 설정한 뒤

접속 이후의 로직을 처리할 after_connected코루틴을 수행 시킵니다.

/// <summary>  
/// 서버에 접속이 완료되면 호출됨.  
/// </summary>  
public void on_connected()  
{  
    this.is_connected = true;  
  
    StartCoroutine("after_connected");  
}  
  
  
/// <summary>  
/// 서버에 접속된 이후에 처리할 루프.  
/// 마우스 입력이 들어오면 ENTER_GAME_ROOM_REQ프로토콜을 요청하고   
/// 중복 요청을 방지하기 위해서 현재 코루틴을 중지 시킨다.  
/// </summary>  
/// <returns></returns>  
IEnumerator after_connected()  
{  
    while (true)  
    {  
        if (this.is_connected)  
        {  
            if (Input.GetMouseButtonDown(0))  
            {  
                CPacket msg = CPacket.create((short)PROTOCOL.ENTER_GAME_ROOM_REQ);  
                this.network_manager.send(msg);  
  
                StopCoroutine("after_connected");  
            }  
        }  
  
        yield return 0;  
    }  
}  
  
      
void OnGUI()  
{  
    if (this.is_connected)  
    {  
        GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), this.bg);  
    }  
}  

접속이 완료되면 is_connected플래그가 true가 되며 OnGUI에서 is_connected플래그의 상태를 체크하여

true일 경우 화면에 배경 이미지를 출력합니다. 이 이미지가 정상적으로 출력된다면 서버에 접속이

성공 했다는 뜻입니다. 만약 이미지가 출력되지 않고 까만 화면면 나온다면 서버와의 연결에 문제가 있다는 뜻입니다.

 

그 뒤 StartCoroutine("after_connected"); 를 통해서 after_connected라는 이름의 코루틴을 수행시킵니다.

이 코루틴에서는 마우스 입력을 받아 서버에 게임방 입장을 요청하는 ENTER_GAME_ROOM_REQ라는 패킷을 보내게 됩니다.

패킷 요청을 한 뒤에는 중복해서 요청이 전송되는것을 막기 위하여 현재 코루틴을 종료하여 

더이상 코루틴 내의 로직이 수행되지 않도록 처리 합니다.

같은 일을 수행하는 코드를 Update매소드에서 처리할 수도 있지만 코루틴을 사용하면 시작과 중지등의

제어를 편하게 할 수 있기 때문에 코루틴을 사용하여 구현한 것입니다.

만약 Update매소드를 사용하여 코딩 한다면 별도의 플래그 변수를 또 둬야 하는 등의 번거로움이 생기겠죠.

 

다음 강좌에서 클라이언트 연동-II 가 계속 됩니다.

 

 

 

=======================

=======================

=======================

 

 

 

 

 

 

 

출처: http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Lecture_series&no=79

C#으로 게임 서버 만들기 - 8. 세균전 게임-클라이언트 연동-II

패킷 수신

ENTER_GAME_ROOM_REQ패킷을 서버에 요청한 뒤에는 서버로부터 응답이 오기만을 기다리면 됩니다.

세균전 게임은 1:1로 진행되는 게임이기 때문에 내가 게임방 입장을 요청했다고 바로 게임을 할 수 있는것은 아닙니다.

상대방 누군가가 나와 똑같은 패킷을 요청하여 서로 매칭이 이루어 져야 비로소 게임을 시작할 수 있는 것이죠.

서버측 소스코드를 작성할 때 ENTER_GAME_ROOM_REQ패킷을 핸들링 하여 게임방을 생성 하고 클라이언트에게

응답을 보내주던 부분이 기억 나시는지요? 

최소한 두명의 클라이언트가 ENTER_GAME_ROOM_REQ패킷을 요청하면 

서버는 매칭을 성사시키고 두명의 클라이언트에게 각각 START_LOADING패킷을 전달 합니다.

그렇다면 START_LOADING패킷을 수신하는 코드가 클라이언트에 존재해야 겠죠.

서버로부터 이 패킷을 전달받았다면 매칭이 성사된 것으로 생각하고 게임방에 입장하도록 처리해 봅시다.

CMainTitle.cs스크립트의 on_recv매소드 입니다.

/// <summary>  
/// 패킷을 수신 했을 때 호출됨.  
/// </summary>  
/// <param name="protocol"></param>  
/// <param name="msg"></param>  
public void on_recv(CPacket msg)  
{  
    // 제일 먼저 프로토콜 아이디를 꺼내온다.  
    PROTOCOL protocol_id = (PROTOCOL)msg.pop_protocol_id();  
  
    switch (protocol_id)  
    {  
        case PROTOCOL.START_LOADING:  
            {  
                byte player_index = msg.pop_byte();  
                Debug.Log(player_index);  
  
                this.battle_room.gameObject.SetActive(true);  
                this.battle_room.start_loading();  
                gameObject.SetActive(false);  
            }  
            break;  
    }  
}  

프로토콜 아이디를 꺼내와 START_LOADING패킷이 맞다면 게임방에 입장하도록 처리 합니다.

먼저 서버에서 할당해준 자기 자신의 플레이어 인덱스를 꺼내 옵니다.

서버에서 START_LOADING패킷을 만들 때 입력한 순서대로 데이터를 꺼내와야 합니다.

CPacket msg = CPacket.create((Int16)PROTOCOL.START_LOADING);  
msg.push(player.player_index);  // 본인의 플레이어 인덱스를 알려준다.  
player.send(msg);  

[START_LOADING패킷을 만드는 서버측 코드]

 

 

서버에서 byte타입의 player_index를 넣어서 보내왔으므로 클라이언트에서도 msg.pop_byte매소드를 이용하여 

byte타입의 player_index를 꺼내옵니다. 

그 뒤 battle_room오브젝트를 활성화 시키고 start_loading매소드를 호출하여 게임방에 입장합니다.

마지막으로 현재 자신의 게임 오브젝트를 비활성화 시켜서 더이상 작동되지 않도록 합니다.

 

 

수신된 패킷의 전달

패킷을 수신하는 on_recv매소드에서 게임방 입장 처리가 이루어 지는것을 확인하였습니다.

그렇다면 이 on_recv매소드는 어떤 과정을 거쳐서 호출되는 것일까요?

CMainTitle.cs스크립트를 초기화 하는 Start매소드를 통해서 그 과정을 파헤쳐 보도록 하겠습니다.

CNetworkManager network_manager;  
bool is_connected;  
  
// Use this for initialization  
void Start () {  
    this.is_connected = false;  
    this.bg = Resources.Load("images/title_blue") as Texture;  
    this.battle_room = GameObject.Find("BattleRoom").GetComponent<CBattleRoom>();  
    this.battle_room.gameObject.SetActive(false);  
  
    this.network_manager = GameObject.Find("NetworkManager").GetComponent<CNetworkManager>();  
    this.network_manager.message_receiver = this;  
}  

CMainTitle.cs스크립트의 Start매소드 입니다. Start매소드는 유니티 엔진에서 자동으로 호출해 주며

주로 스크립트의 초기화를 담당하는 코드가 들어갑니다.

여기서도 마찬가지로 각종 리소스들을 초기화 하는 코드가 들어갑니다. 그중에서 NetworkManager를 얻어와서

message_receiver를 설정해 주는 부분을 유심히 볼 필요가 있습니다.

 

this.network_manager = GameObject.Find("NetworkManager").GetComponent<CNetworkManager>();  
this.network_manager.message_receiver = this;  

먼저 GameObject.Find매소드를 통해서 CNetworkManager.cs스크립트를 얻어 옵니다.

NetworkManager는 씬을 구성할 때 만들어 놓은 오브젝트이며 CNetworkManager.cs스크립트를

연결 시켜 놓았던 것을 기억하실 겁니다.

이 스크립트를 얻어온 뒤 message_receiver = this 를 통해서 CMainTitle.cs스크립트를

message_receiver로 설정해주는 코드가 들어 갑니다.

이렇게 message_receiver를 설정 해 주면 서버로부터 수신된 패킷을 CNetworkManager.cs스크립트가

수신 처리 하고 CNetworkManager.cs의 on_message매소드에서 SendMessage를 통하여 해당 receiver의

on_recv매소드가 호출되는 것입니다.

 

서버에서 패킷 전송 - CNetworkManager.cs의 on_message호출 - message_receiver.SendMessage("on_recv", msg) 호출

이런 과정을 통해서 서버로부터 전송되어 온 메시지가 NetworkManager를 거쳐

해당 스크립트의 on_recv매소드 까지 호출될 수 있는 것입니다.

NetworkManager의 message_receiver는 MonoBehaviour타입으로 되어 있기 때문에

우리가 생성한 CMainTitle.cs스크립트의 인스턴스를 참조로 받아올 수 있는 것입니다.

 

서버가 보내온 패킷을 처음 수신 하는 부분은 CNetworkManager.cs의 on_message매소드 입니다.

이 매소드에서 바로 프로토콜 아이디를 꺼내와 로직 처리를 수행할 수도 있지만 SendMessage매소드를 통해서

다른 오브젝트로 전달해주는 이유는 네트워크 코드와 로직 처리 코드를 분리시키기 위함입니다.

CNetworkManager.cs스크립트는 다른 프로젝트에서도 재사용할 수 있도록 설계되었기 때문에

게임 로직에 관련된 코드는 최대한 적게 들어가도록 하는 것이 좋습니다.

SendMessage매소드를 통해서 패킷 전달이 한단계 더 거쳐서 처리되는 단점도 있지만

이정도의 처리는 순식간에 진행되므로 깔끔한 설계와 구현을 위해서 충분히 희생할 수 있는 부분입니다.

 

using UnityEngine;  
using System.Collections;  
using System.Collections.Generic;  
using FreeNet;  
using VirusWarGameServer;  
  
public class CMainTitle : MonoBehaviour {  
      
    Texture bg;  
    CBattleRoom battle_room;  
  
    CNetworkManager network_manager;  
    bool is_connected;  
  
    // Use this for initialization  
    void Start () {  
        this.is_connected = false;  
        this.bg = Resources.Load("images/title_blue") as Texture;  
        this.battle_room = GameObject.Find("BattleRoom").GetComponent<CBattleRoom>();  
        this.battle_room.gameObject.SetActive(false);  
  
        this.network_manager = GameObject.Find("NetworkManager").GetComponent<CNetworkManager>();  
        this.network_manager.message_receiver = this;  
    }  
  
  
    /// <summary>  
    /// 서버에 접속된 이후에 처리할 루프.  
    /// 마우스 입력이 들어오면 ENTER_GAME_ROOM_REQ프로토콜을 요청하고   
    /// 중복 요청을 방지하기 위해서 현재 코루틴을 중지 시킨다.  
    /// </summary>  
    /// <returns></returns>  
    IEnumerator after_connected()  
    {  
        while (true)  
        {  
            if (this.is_connected)  
            {  
                if (Input.GetMouseButtonDown(0))  
                {  
                    CPacket msg = CPacket.create((short)PROTOCOL.ENTER_GAME_ROOM_REQ);  
                    this.network_manager.send(msg);  
  
                    StopCoroutine("after_connected");  
                }  
            }  
  
            yield return 0;  
        }  
    }  
  
      
    void OnGUI()  
    {  
        if (this.is_connected)  
        {  
            GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), this.bg);  
        }  
    }  
  
  
    /// <summary>  
    /// 서버에 접속이 완료되면 호출됨.  
    /// </summary>  
    public void on_connected()  
    {  
        this.is_connected = true;  
  
        StartCoroutine("after_connected");  
    }  
  
  
    /// <summary>  
    /// 패킷을 수신 했을 때 호출됨.  
    /// </summary>  
    /// <param name="protocol"></param>  
    /// <param name="msg"></param>  
    public void on_recv(CPacket msg)  
    {  
        // 제일 먼저 프로토콜 아이디를 꺼내온다.  
        PROTOCOL protocol_id = (PROTOCOL)msg.pop_protocol_id();  
  
        switch (protocol_id)  
        {  
            case PROTOCOL.START_LOADING:  
                {  
                    byte player_index = msg.pop_byte();  
                    Debug.Log(player_index);  
  
                    this.battle_room.gameObject.SetActive(true);  
                    this.battle_room.start_loading();  
                    gameObject.SetActive(false);  
                }  
                break;  
        }  
    }  
}  

[CMainTitle.cs의 전체 소스 코드]

 

 

 

게임서버와 클라이언트의 실행 모습.

 

 

 

[서버 실행 모습]

 

 

 

 

 

 

[클라이언트 실행 모습]

여기까지 구현된 서버와 클라이언트를 실행한 모습입니다.
아직 게임방에 입장하여 플레이 하는 로직은 구현되지 않았기 때문에 ENTER_GAME_ROOM_REQ패킷을 요청하여도
클라이언트에서는 아무 변화가 없는것 처럼 보입니다.
하지만 서버 소스코드에 브레이크 포인트를 걸어서 디버깅을 해보면 ENTER_GAME_ROOM_REQ패킷을 수신하고
매칭 처리 로직이 수행되는것을 확인하실 수 있을 겁니다.
다음시간에는 클라이언트의 입력과 서버쪽에 코딩된 게임 로직을 연동하는 부분을 살펴보겠습니다.
감사합니다.
첨부파일 안내
chapter8.zip 압축 파일을 풀면 현재까지 구현된 FreeNet라이브러리, 서버, 클라이언트의 소스 코드를 볼 수 있습니다.
아직 개발해 나가는 과정에 있기 때문에 불필요한 부분도 포함되어 있을 수 있으니 참고 용도로만 봐주세요.^^

 

 

 

=======================

=======================

=======================

 

 

 

 

 

 

 

 

출처: http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Lecture_series&page=2&sn1=&divpage=1&sn=off&ss=on&sc=on&select_arrange=hit&desc=asc&no=80

C#으로 게임 서버 만들기 - 9. 세균전-클라이언트 구현

지난 시간에는 게임방 입장 패킷을 요청하는 부분까지 작성해 봤습니다.
이번 시간에는 본격적으로 클라이언트 쪽의 게임 로직을 구현해 보도록 하겠습니다.
마치 새하얀 도화지에 게임의 모습을 색칠해 나가는 시간이 될것 같습니다.

게임방 입장

클라이언트에서 서버로 게임방 입장 패킷을 보내면 서버는 두명의 클라이언트를 모은 뒤

START_LOADING패킷을 각각의 클라이언트에게 전달합니다.

이 패킷을 받은 클라이언트에서는 게임방에 입장하도록 처리해 줍니다.

 

 

두명의 클라이언트로부터 ENTER_GAME_ROOM_REQ 패킷을 받으면 서버는 START_LOADING패킷을 모든 클라이언트들에게 전달해 준다.

 

 

 

 

 

 

 

 

 

클라이언트는 게임방 입장 패킷을 요청한 후 상대방을 기다린다.

this.battle_room.gameObject.SetActive(true);  
this.battle_room.start_loading(player_index);  
gameObject.SetActive(false);  

클라이언트의 게임방 입장 코드

 

서버로부터 START_LOADING패킷을 전달 받은 뒤 게임방이 구현된 오브젝트를 활성화 시키고 

start_loading매소드를 호출하여 로딩을 시작하는 코드 입니다.

현재 오브젝트인 MainTitle오브젝트는 사용할 필요가 없으므로 SetActive(false)를 통하여 비활성화 시켜서 코드가 수행되지 않도록 처리 합니다.

 

 

 

상대방이 게임방 입장 요청을 하게 되면 서버로부터 START_LOADING패킷을 전달받아 게임방에 입장하게 된다.

 

using UnityEngine;  
using System.Collections;  
using System.Collections.Generic;  
using FreeNet;  
using VirusWarGameServer;  
  
public class CBattleRoom : MonoBehaviour {  
  
    enum GAME_STATE  
    {  
        READY = 0,  
        STARTED  
    }  
  
    // 가로, 세로 칸 수를 의미한다.  
    public static readonly int COL_COUNT = 7;  
  
    List<CPlayer> players;  
  
    // 진행중인 게임 보드판의 상태를 나타내는 데이터.  
    List<short> board;  
  
    // 0~49까지의 인덱스를 갖고 있는 보드판 데이터.  
    List<short> table_board;  
  
    // 공격 가능한 범위를 나타낼 때 사용하는 리스트.  
    List<short> available_attack_cells;  
  
    // 현재 턴을 진행중인 플레이어 인덱스.  
    byte current_player_index;  
  
    // 서버에서 지정해준 본인의 플레이어 인덱스.  
    byte player_me_index;  
  
    // 상황에 따른 터치 입력을 처리하기 위한 변수.  
    byte step;  
  
    // 게임 종료 후 메인으로 돌아갈 때 사용하기 위한 MainTitle객체의 레퍼런스.  
    CMainTitle main_title;  
  
    // 네트워크 데이터 송,수신을 위한 네트워크 매니저 레퍼런스.  
    CNetworkManager network_manager;  
  
    // 게임 상태에 따라 각각 다른 GUI모습을 구현하기 위해 필요한 상태 변수.  
    GAME_STATE game_state;  
  
    // OnGUI매소드에서 호출할 델리게이트.  
    // 여러 종류의 매소드를 만들어 놓고 상황에 맞게 draw에 대입해주는 방식으로 GUI를 변경시킨다.  
    delegate void GUIFUNC();  
    GUIFUNC draw;  
  
    // 승리한 플레이어 인덱스.  
    // 무승부일때는 byte.MaxValue가 들어간다.  
    byte win_player_index;  
  
    // 점수를 표시하기 위한 이미지 숫자 객체.  
    // 선명하고 예쁘게 표현하기 위해 폰트 대신 이미지로 만들어 사용한다.  
    CImageNumber score_images;  
  
    // 현재 진행중인 플레이어를 나타내는 객체.  
    CBattleInfoPanel battle_info;  
  
    // 게임이 종료되었는지를 나타내는 플래그.  
    bool is_game_finished;  
  
    // 각종 이미지 텍스쳐들.  
    List<Texture> img_players;  
    Texture background;  
    Texture blank_image;  
    Texture game_board;  
  
    Texture graycell;  
    Texture focus_cell;  
  
    Texture win_img;  
    Texture lose_img;  
    Texture draw_img;  
    Texture gray_transparent;  
  
    void Awake()  
    {  
        this.table_board = new List<short>();  
        this.available_attack_cells = new List<short>();  
  
        this.graycell = Resources.Load("images/graycell") as Texture;  
        this.focus_cell = Resources.Load("images/border") as Texture;  
          
        this.blank_image = Resources.Load("images/blank") as Texture;  
        this.game_board = Resources.Load("images/gameboard") as Texture;  
        this.background = Resources.Load("images/gameboard_bg") as Texture;  
        this.img_players = new List<Texture>();  
        this.img_players.Add(Resources.Load("images/red") as Texture);  
        this.img_players.Add(Resources.Load("images/blue") as Texture);  
          
        this.win_img = Resources.Load("images/win") as Texture;  
        this.lose_img = Resources.Load("images/lose") as Texture;  
        this.draw_img = Resources.Load("images/draw") as Texture;  
        this.gray_transparent = Resources.Load("images/gray_transparent") as Texture;  
          
        this.board = new List<short>();  
  
        this.network_manager = GameObject.Find("NetworkManager").GetComponent<CNetworkManager>();  
  
        this.game_state = GAME_STATE.READY;  
  
        this.main_title = GameObject.Find("MainTitle").GetComponent<CMainTitle>();  
        this.score_images = gameObject.AddComponent<CImageNumber>();  
  
        this.win_player_index = byte.MaxValue;  
        this.draw = this.on_gui_playing;  
        this.battle_info = gameObject.AddComponent<CBattleInfoPanel>();  
    }  

Awake매소드에서 게임에 필요한 리소스들을 로딩합니다.

리소스 로딩은 게임 시작 이후 한번만 수행되면 되기 때문에 Awake매소드에서 처리한 것입니다.

이 매소드는 해당 스크립트가 최초로 활성화 될 때 단 한번만 호출되기 때문입니다.

void reset()  
{  
    this.board.Clear();  
    this.table_board.Clear();  
    for (int i = 0; i < COL_COUNT * COL_COUNT; ++i)  
    {  
        this.board.Add(short.MaxValue);  
        this.table_board.Add((short)i);  
    }  
  
    this.players.ForEach(obj =>  
    {  
        obj.cell_indexes.ForEach(cell =>  
        {  
            Debug.Log("cell " + cell);  
            this.board[cell] = obj.player_index;  
        });  
    });  
}  
  
  
void clear()  
{  
    this.current_player_index = 0;  
    this.step = 0;  
    this.draw = this.on_gui_playing;  
    this.is_game_finished = false;  
}  
  
/// <summary>  
/// 게임방에 입장할 때 호출된다. 변수 초기화등 게임 플레이를 위한 준비 작업을 진행한다.  
/// </summary>  
public void start_loading(byte player_me_index)  
{  
    clear();  
  
    this.network_manager.message_receiver = this;  
    this.player_me_index = player_me_index;  
  
    CPacket msg = CPacket.create((short)PROTOCOL.LOADING_COMPLETED);  
    this.network_manager.send(msg);  
}  

clear매소드는 클라이언트에서 유저의 입력을 받는데 필요한 데이터들을 초기화 하는 역할을 담당합니다.

본격적인 게임 진행이 이루어지기 이전에 유저의 불필요한 입력을 방지하고 방에 입장한 직후 표시될 

GUI를 구성하는 코드가 들어 있습니다.

 

start_loading매소드는 게임방에 입장한 뒤 바로 호출됩니다.

이 매소드에서는 서버에서 지정해준 플레이어 인덱스를 보관해 놓고 LOADING_COMPLETED패킷을 서버로 보냅니다.

LOADING_COMPLETED패킷을 보내면 서버에서는 해당 클라이언트가 정상적으로 게임방에 입장을 하였다는 뜻으로 해석하게 됩니다.

만약 로딩 중간에 네트워크가 끊기는등 오류가 발생하여 LOADING_COMPLETED패킷을 서버로 전달하지 못한 경우에는

서버에서 해당 클라이언트의 로딩 완료 처리를 수행하지 않으며 아직 게임을 진행하지 않도록 구현되어 있습니다.

클라이언트에서는 이런 상황들을 직접 판단하지 않고 서버에 요청한 뒤 응답이 오는것을 기다렸다가 처리하면 됩니다.

 

/// <summary>  
/// 클라이언트에서 로딩을 완료한 후 요청함.  
/// 이 요청이 들어오면 게임을 시작해도 좋다는 뜻이다.  
/// </summary>  
/// <param name="sender">요청한 유저</param>  
public void loading_complete(CPlayer player)  
{  
    // 해당 플레이어를 로딩완료 상태로 변경한다.  
    change_playerstate(player, PLAYER_STATE.LOADING_COMPLETE);  
  
    // 모든 유저가 준비 상태인지 체크한다.  
    if (!allplayers_ready(PLAYER_STATE.LOADING_COMPLETE))  
    {  
        // 아직 준비가 안된 유저가 있다면 대기한다.  
        return;  
    }  
  
    // 모두 준비 되었다면 게임을 시작한다.  
    battle_start();  
}  

LOADING_COMPLETED패킷을 처리하는 서버측 코드

 

서버는 클라이언트에서 보내온 LOADING_COMPLETED패킷을 수신하여 CGameRoom 클래스의 loading_complete매소드를 호출합니다.

이 매소드에서는 두명의 유저가 모두 LOADING_COMPLETED패킷을 전송하였는지 체크한 뒤 게임을 시작하게 됩니다.

아직 LOADING_COMPLETED패킷을 보내오지 않은 유저가 남아 있다면 아무 일도 하지 않고 대기 합니다.

 

 

게임 시작

클라이언트의 로딩 완료 요청 이후 서버에서는 GAME_START패킷으로 게임의 시작을 알려 줍니다.

on_game_start매소드에서 게임 시작을 위한 준비작업을 수행합니다.

void on_game_start(CPacket msg)  
{  
    this.players = new List<CPlayer>();  
  
    byte count = msg.pop_byte();  
    for (byte i = 0; i < count; ++i)  
    {  
        byte player_index = msg.pop_byte();  
  
        GameObject obj = new GameObject(string.Format("player{0}", i));  
        CPlayer player = obj.AddComponent<CPlayer>();  
        player.initialize(player_index);  
        player.clear();  
  
        byte virus_count = msg.pop_byte();  
        for (byte index = 0; index < virus_count; ++index)  
        {  
            short position = msg.pop_int16();  
            player.add(position);  
        }  
  
        this.players.Add(player);  
    }  
  
    this.current_player_index = msg.pop_byte();  
    reset();  
  
    this.game_state = GAME_STATE.STARTED;  
}  

서버에서 보내온 정보를 토대로 플레이어들을 생성하고 초기 위치들을 설정합니다.

첫턴을 진행할 플레이어 인덱스도 서버에서 받아온 값으로 설정해 놓습니다.

모든 정보들의 설정이 완료 되었다면 게임 상태를 GAME_STATE.STARTED로 변경하여 게임을 시작합니다.

 

이처럼 게임 진행에 필요한 핵심 정보들은 모두 서버에서 받아서 처리해야 합니다.
즉 클라이언트에 보관되어 있는 변수들의 값은 단순히 서버에서 보내온 값을 복사하는 수준이며
크래킹등을 통해 이 값을 변경한다고 하더라도 서버에는 절대로 반영되지 않기 때문에
상대방 클라이언트를 속이는 행위는 허용될 수 없는 것입니다.
또한 클라이언트에서 서버로 요청을 보내는 경우에도 게임 상태를 변경하라는 명령이 되면 안됩니다.
단순히 무엇을 해달라고 허락을 구하는 모습이 되어야 합니다. 서버에서는 해당 요청이 현재 게임 상황에 비추어볼 때
정상적인지 판단한 뒤 그때서야 게임 로직에 변경을 가하게 되는 것입니다.
만약 이 룰을 벗어나는 로직이 있을 경우에는 클라이언트가 마음 먹은대로 게임을 바꿀 수 있는 길을
열어주는 꼴이 되기 때문에 요청 패킷 하나 하나를 작성할 때 반드시 주의해야 합니다.

 

유저의 입력 처리

게임이 시작되었다면 첫번째 플레이어부터 턴을 시작합니다.

먼저 자신의 세균을 선택하고 이동할 곳을 선택합니다. 그 뒤 게임 룰에 따라 상대방의 세균이 주위에 있으면

감염시켜 자신의 세균으로 만듭니다.

다음 코드는 유저가 보드판을 터치하였을 때 호출되는 on_click매소드 입니다.

void on_click(short cell)  
{  
    // 자신의 차례가 아니면 처리하지 않고 리턴한다.  
    if (this.player_me_index != this.current_player_index)  
    {  
        return;  
    }  
  
    switch(this.step)  
    {  
    case 0:  
        if (validate_begin_cell(cell))  
        {  
            this.selected_cell = cell;  
            this.step = 1;  
                  
            refresh_available_cells(this.selected_cell);  
        }  
        break;  
  
    case 1:  
        {  
            // 자신의 세균을 터치하였을 경우에는 다시 공격 범위를 계산하여 출력해 준다.  
            if (this.players[this.current_player_index].cell_indexes.Exists(obj => obj == cell))  
            {  
                this.selected_cell = cell;  
                refresh_available_cells(this.selected_cell);  
                break;  
            }  
  
            // 게임룰에 따라서 다른 플레이어의 세균은 선택할 수 없도록 처리한다.  
            foreach (CPlayer player in this.players)  
            {  
                if (player.cell_indexes.Exists(obj => obj == cell))  
                {  
                    return;  
                }  
            }  
  
  
            // 2칸을 초과하는 거리는 이동할 수 없도록 한다.  
            if (CHelper.get_distance(this.selected_cell, cell) > 2)  
            {  
                return;  
            }  
  
  
            // 모든 검사가 정상이므로 서버에 이동 요청을 보낸다.  
            CPacket msg = CPacket.create((short)PROTOCOL.MOVING_REQ);  
            msg.push(this.selected_cell);  
            msg.push(cell);  
            this.network_manager.send(msg);  
  
            this.step = 2;  
        }  
        break;  
    }  
}  

유저의 입력은 두 단계로 나뉩니다. 자신의 세균을 선택하는 단계를 0번, 이동할 곳을 선택하는 단계를 1번이라고 하겠습니다.

먼저 0번 단계에서는 선택한 곳이 유효한 곳인지 확인하는 과정을 거칩니다.

서버에서도 같은 처리가 이루어지지만 클라이언트에서 미리 체크한다면 불필요한 오류 상황을 막을 수 있기에

여기서도 검증 루틴을 포함시켰습니다.

정상이라고 판단되면 step = 1로 설정하여 다음번에 on_click매소드가 호출되었을 때 1번 단계로 넘어갈 수 있도록 해줍니다.

1번 단계에서는 이동할 곳을 선택했을때의 처리가 이루어 집니다.

여기서도 역시 검증루틴을 거친 뒤 정상이라고 판단되면 그때서야 서버로 MOVING_REQ패킷을 전송합니다.

이 때 시작 위치와 이동할 위치를 패킷에 넣어서 서버로 요청하게 됩니다.

요청한 이후에는 step = 2로 설정하여 또 다시 터치하더라도 아무 처리가 이루어지지 않도록 합니다.

 

서버에서 MOVING_REQ패킷을 받은 뒤에 모든 데이터가 정상적이라면 클라이언트로 PLAYER_MOVED패킷을 전달합니다.

PLAYER_MOVED패킷을 전달하는 서버측 코드를 살펴보겠습니다.

// 최종 결과를 broadcast한다.  
CPacket msg = CPacket.create((short)PROTOCOL.PLAYER_MOVED);  
msg.push(sender.player_index);      // 누가  
msg.push(begin_pos);                // 어디서  
msg.push(target_pos);               // 어디로 이동 했는지  
broadcast(msg);  

이동하게 되는 플레이어의 인덱스와 시작 위치, 이동한 위치를 모든 클라이언트에게 보내줍니다.

앞서 클라이언트에서 보낸 정보와 크게 다를것 없어 보이지만 서버의 검증을 거친 데이터라는 점에서 큰 차이가 있습니다.

 

 

세균의 이동과 복제

void on_player_moved(CPacket msg)  
{  
    byte player_index = msg.pop_byte();  
    short from = msg.pop_int16();  
    short to = msg.pop_int16();  
  
    StartCoroutine(on_selected_cell_to_attack(player_index, from, to));  
}  
  
IEnumerator on_selected_cell_to_attack(byte player_index, short from, short to)  
{  
    byte distance = CHelper.howfar_from_clicked_cell(from, to);  
    if (distance == 1)  
    {  
        // 이동 거리가 한칸 이라면 자기 자신을 복제 한다.  
        yield return StartCoroutine(reproduce(to));  
    }  
    else if (distance == 2)  
    {  
        // 이동 거리가 두칸 이라면 해당 위치로 이동 한다.  
        this.board[from] = short.MaxValue;  
        this.players[player_index].remove(from);  
        yield return StartCoroutine(reproduce(to));  
    }  
  
    // 이동 처리가 다 끝나면 턴을 종료해달라는 패킷을 보낸다.  
    CPacket msg = CPacket.create((short)PROTOCOL.TURN_FINISHED_REQ);  
    this.network_manager.send(msg);  
  
    yield return 0;  
}  

서버에서 받은 데이터로 세균의 이동을 시작합니다.

on_selected_cell_to_attack매소드를 호출하여 세균이 이동하는 모습을 구현합니다.

게임룰에 따라 이동한 거리가 한칸이라면 자기 자신을 복제하고, 두칸이라면 이동을 합니다.

세균의 복제 처리를 구현해 놓은 reproduce매소드는 코루틴으로 되어 있으므로 StartCoroutine명령어를 사용하여 호출해 줍니다.

이 매소드를 코루틴으로 구성한 이유는 세균들의 이동 모습을 구현하는데 아주 편리한 코딩 수단을 제공해 주기 때문입니다.

다음은 reproduce매소드의 코드 입니다.

 

IEnumerator reproduce(short cell)  
{  
    CPlayer current_player = this.players[this.current_player_index];  
    CPlayer other_player = this.players.Find(obj => obj.player_index != this.current_player_index);  
          
    clear_available_attacking_cells();  
  
    // cell을 현재 플레이어의 위치에 추가한다.       
    this.board[cell] = current_player.player_index;  
    current_player.add(cell);  
  
    // 0.5초 대기.  
    yield return new WaitForSeconds(0.5f);  
  
    // 주위에 상대방의 세균이 있다면 감염 시킨다.  
    List<short> neighbors = CHelper.find_neighbor_cells(cell, other_player.cell_indexes, 1);  
    foreach (short obj in neighbors)  
    {  
        this.board[obj] = current_player.player_index;  
        current_player.add(obj);  
              
        other_player.remove(obj);  
  
        // 하나의 세균을 감염시키고 0.2초 대기한 뒤 다시 다음 세균의 감염을 처리한다.  
        yield return new WaitForSeconds(0.2f);  
    }  
}  

yield return new WaitForSeconds() 라는 코드가 보이는데

이 코드는 현재 위치에서 몇초동안 대기한 후 다음 루틴을 수행하라는 의미입니다.

이동하고 상대방 세균을 잡아먹는 모습은 이처럼 딜레이를 두어 차례대로 진행되도록 구현되어 있습니다.

만약 코루틴을 사용하지 않고 이와같은 시나리오를 구현하려면 별도의 타이머 시스템을 구축해야 할 것입니다.

이런 기법은 실제 상용 게임을 개발할 때도 유용하게 사용되므로 꼭 기억해 두시기 바랍니다.

 

클라이언트에서 세균의 이동 처리가 완료되면 턴을 끝내달라는 요청을 서버로 전송 합니다.

CPacket msg = CPacket.create((short)PROTOCOL.TURN_FINISHED_REQ);  
this.network_manager.send(msg);  

TURN_FINISHED_REQ패킷을 서버로 전달하면 서버에서는 다음 플레이어로 턴을 넘긴 뒤 

클라이언트에게 START_PLAYER_TURN패킷을 전달합니다.

void on_start_player_turn(CPacket msg)  
{  
    phase_end();  
  
    this.current_player_index = msg.pop_byte();  
}  
  
void phase_end()  
{  
    this.step = 0;  
    this.available_attack_cells.Clear();  
}  

서버로부터 START_PLAYER_TURN패킷을 받은 뒤 처리되는 매소드 입니다.

먼저 phase_end매소드를 호출하여 턴 진행을 위한 변수들을 초기화 한 뒤,

current_player_index의 값을 서버로부터 받은 정보로 갱신합니다.

만약 current_player_index의 값이 본인의 플레이어 인덱스와 같다면 자신의 차례인 것이고

다르다면 상대방의 차례인 것이므로 이 값에 따라서 게임 진행을 제어하도록 처리 합니다.

 

 

 

게임 결과 화면 그리기

이런 순서로 승부가 날 때까지 게임을 계속 진행하게 됩니다. 게임 종료 여부를 판단하는 부분은 서버에서 처리합니다.

게임이 종료되면 서버로부터 GAME_OVER패킷을 전달받게 되며 승리한 플레이어의 정보도 같이 담겨 옵니다.

이 내용을 토대로 게임 결과 화면을 출력해 보겠습니다.

void on_game_over(CPacket msg)  
{  
    this.is_game_finished = true;  
    this.win_player_index = msg.pop_byte();  
    this.draw = this.on_gui_game_result;  
}  

서버로부터 승리한 플레이어의 인덱스를 받아 this.win_player_index변수에 보관합니다.

/// <summary>  
/// 결과 화면 그리기.  
/// </summary>  
void on_gui_game_result()  
{  
    on_gui_playing();  
  
    GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), this.gray_transparent);  
    GUI.BeginGroup(new Rect(Screen.width / 2 - 173, Screen.height / 2 - 84,   
        this.win_img.width, this.win_img.height));  
    {  
        if (this.win_player_index == byte.MaxValue)  
        {  
            GUI.DrawTexture(new Rect(0, 0, this.draw_img.width, this.draw_img.height), this.draw_img);  
        }  
        else  
        {  
            // win, lose이미지 출력.  
            if (this.player_me_index == this.win_player_index)  
            {  
                GUI.DrawTexture(new Rect(0, 0, 346, 169), this.win_img);  
            }  
            else  
            {  
                GUI.DrawTexture(new Rect(0, 0, 346, 169), this.lose_img);  
            }  
        }  
  
        // 자기 자신의 플레이어 이미지 출력.  
        Texture character = this.img_players[this.player_me_index];  
        GUI.DrawTexture(new Rect(28, 43, character.width, character.height), character);  
    }  
    GUI.EndGroup();  
}  

on_gui_game_result매소드는 게임 결과화면을 출력하는 코드로 구성되어 있습니다.

첫번째 줄에서 on_gui_playing매소드를 호출해주는 이유는 게임 플레이 화면을 밑 바닥에 깔고

그 위에 결과 화면을 출력해주기 위함 입니다. 게임은 끝났지만 보드판의 상태가 어떤 모습이었는지

계속 보고 싶을 수 있기 때문이죠. 물론 원한다면 on_gui_playing매소드 호출은 제거해도 상관 없는 부분입니다.

 

 

 

 

 

 

 

화면 전체를 어두운 배경으로 덮은 뒤 승패 여부에 따라 승리, 패배 이미지를 출력해 줍니다.

결과 화면에서 나올 수 있는 상황은 총 세가지 입니다.

 

첫째 무승부.

둘째 본인의 승리.

세째 본인의 패배(상대방의 승리).

 

만약 this.win_player_index의 값이 byte.MaxValue와 같다면 무승부라는 뜻입니다.

승리한 플레이어가 없으니 플레이어 인덱스로 사용된 byte자료형의 최대값을 넣어서 무승부를 표현한 것입니다.

만약 this.win_player_index의 값이 this.player_me_index와 같다면 본인이 승리한 경우 입니다.

위 두 조건이 아닌 경우에는 상대방의 승리 입니다.

승패에 따라서 결과 이미지를 출력해준 뒤 자기 자신의 캐릭터를 그 위에 출력하여 내가 승리했는지 패배했는지 알 수 있도록 해줍니다.

 

 

 

 

메인 화면으로 복귀

게임의 승패를 확인한 뒤에는 다시 메인화면으로 복귀할 수 있도록 처리해줘야 합니다.

void Update()  
{  
    if (this.is_game_finished)  
    {  
        if (Input.GetMouseButtonDown(0))  
        {  
            back_to_main();  
        }  
    }  
}  
  
void back_to_main()  
{  
    this.main_title.gameObject.SetActive(true);  
    this.main_title.enter();  
  
    gameObject.SetActive(false);  
}  

Update매소드에서 게임이 종료된 상태일 경우 터치 입력을 감지하여 메인 화면으로 복귀하도록 해줍니다.

back_to_main매소드에서는 MainTitle오브젝트를 활성화 시키고 현재 오브젝트(BattleRoom)를 비활성화 하여

게임방에서 퇴장하도록 처리합니다.

 

여기 까지 클라이언트 로직 구현을 살펴봤습니다.
다음 시간에는 서버쪽 로직 구현을 통해서 클라이언트의 요청을 서버에서 어떻게 처리하는지 알아보도록 하겠습니다.
이제 거의 끝이 보이는 군요.

※ 첨부파일 : FreeNet라이브러리 최종 소스 코드. 세균전 서버, 클라이언트 소스 코드.

 

chapter9.zip
1.40MB

 

 

 

 

=======================

=======================

=======================

 

 

 

 

 

 

 

 

 

출처: http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Lecture_series&no=81&z=

C#으로 게임 서버 만들기 - 10. 세균전-서버 구현 I

이제 서버를 구현할 차례 입니다. 앞장에서 초기 설계와 일부 로직 부분을 작성해봤지만 세세한 부분까지는 살펴보지 못했습니다.

먼저 게임 서버와 게임방의 클래스 사이의 관계를 살펴보고 유저의 메시지가 게임방 객체로 전달되기 까지 어떤 과정을 거치는지 

알아보도록 하겠습니다. 그 뒤 세균전 게임의 상세 로직을 작성해 보도록 하겠습니다.

곧바로 게임 로직을 코딩하기 보다 메시지가 처리되는 과정을 이해하는것이 더 중요하다고 생각되었기 때문에 이런 순서로 구성하였습니다.

밑바탕에 깔려 있는 과정들을 어느정도 이해하고 나면 다른 게임을 만들거나 타인의 소스코드를 분석할 때도 큰 도움이 될것입니다.

 

 

핵심 클래스들의 구성

 

 

위 그림은 세균전 게임 서버에서 굵직한 역할을 차지하는 클래스들을 나타낸 그림입니다.

제일 위에 Program 클래스는 게임 서버의 시작점이 되는 클래스 입니다. 프로그램이 시작되면 제일 먼저

Program클래스의 Main매소드 부터 수행됩니다.

Program클래스는 CGameServer객체를 멤버변수로 갖고 있습니다. 이 객체가 하나의 게임 서버를 뜻합니다.

게임의 심장 부분을 맡고 있다고 할 수 있으며 각 게임 로직을 담당하는 객체들에게 메시지를 전달해주는 역할을 합니다.

마치 심장이 혈액을 온몸으로 내보내듯이 CGameServer클래스는 유저의 패킷을 게임방으로 전달해 줍니다.

 

CGameServer는 게임방들을 관리하는 CBattleRoomManager객체를 멤버로 갖고 있습니다. 게임 서버 하나에 수많은 게임방들이

존재하는데 이 방들을 생성하고 삭제하는 역할을 CBattleRoomManager객체가 담당합니다.

CBattleRoomManager에는 당연히 CBattleRoom객체가 존재하며 게임 로직에 관련된 부분은 모두 CBattleRoom클래스에 담겨 있습니다.

 

CBattleRoom클래스에는 크게 두 가지 중요한 요소가 있습니다. 하나는 플레이어 객체이며 다른 하나는 게임 로직입니다.

게임 로직은 CBattleRoom클래스 전반에 걸쳐서 작성되어 있으며 서버에서 처리된 내용들을 플레이어들에게 전달해 주고

클라이언트의 입력을 받아들여 게임 로직에 반영하는 코드들이 들어갑니다.

게임 서버 입장에서 볼 때 유저들은 단순히 소켓과 매칭된 객체일 뿐이지만 게임방 안에 그룹을 지어주면 하나의 방 안에

들어와 있는 플레이어들로 여겨집니다. 이 때부터 방 안에 있는 플레이어들과 서로 실시간으로 정보를 주고 받으며

게임을 플레이 할 수 있게 되는 것입니다.

 

 

 

게임 패킷의 전달 과정

클라이언트가 네트워크를 통해 게임 서버에 데이터를 전송하면 게임 서버 객체는 게임방으로 다시 그 데이터를 전송해 줍니다.

그리고 게임방은 게임 서버 객체로부터 전달받은 데이터를 분석하여 유저의 요청에 따라 게임 로직을 처리하게 됩니다.

이러한 과정들을 좀 더 상세히 살펴보도록 하겠습니다.

 

 

클라이언트에서 보내온 메시지가 CBattleRoom객체 까지 전달 되는 과정을 나타낸 그림 입니다. 클라이언트가 메시지를 전송하면

네트워크 선로를 통해 게임 서버가 존재하는 머신으로 도착하게 됩니다. 게임 서버에서 사용하는 포트를 통해 서버 어플리케이션으로

데이터가 도착하면 우리가 첫부분에서 구축했던 네트워크 라이브러리 모듈을 통해 CUserToken객체로 클라이언트의 데이터가 전달됩니다.

CUserToken객체는 소켓에 관련된 정보들로 구성되어 있어 게임과는 관련이 적은 비교적 순수한 객체이기 때문에

좀더 게임성을 띠는 CGameUser객체로 데이터를 넘겨 주게 됩니다.

 

CGameUser객체는 이 데이터를 게임에서 사용할 수 있는 패킷으로 만들어 처리 합니다. 

네트워크 소켓을 통해 받은 데이터는 순수한 바이트 덩어리들로 이루어져 있는데

이 바이트들을 게임에서 사용할 수 있도록 가공하여 잘 포장해 놓은것이 게임 패킷 객체라고 할 수 있습니다.

우리가 만든 게임 서버에서는 CPacket클래스로 표현되며 여기에는 프로토콜 번호와 클라이언트에서 보내온 정보들이 들어 있습니다.

 

CGameUser객체는 이 패킷을 CGameServer객체의 메시지큐로 밀어 넣습니다. 그리고 다시 CGameUser객체의 메시지 처리 매소드를

수행하여 해당 유저가 들어가 있는 게임방 객체로 패킷을 전달해 줍니다. 이 게임방 객체에서는 어떤 유저의 요청인지 구분하여

게임 로직을 처리한 뒤 게임방 내의 유저들에게 해당 내용들을 전달해 주기도 합니다.

하나의 요청에 대한 로직 처리가 종료되면 다시 CGameServer객체의 메시지큐를 검사하여 또 다른 요청이 있는지 확인한뒤

위와 같은 과정을 반복 합니다. 각 패킷에는 어떤 유저가 보내온 것인지 기록되어 있기 때문에 해당 유저가 존재하는

게임방 까지 정확하게 패킷을 전달 할 수 있는 것입니다.

 

 

 

CGameServer의 메시지큐 처리

CGameUser객체 에서 만든 패킷을 CGameServer객체의 메시지큐로 밀어준다고 설명해 드렸습니다.

패킷을 받은 즉시 처리 하지 않고 메시지큐에 넣어서 처리하는 이유는 무엇일까요?

CGameUser객체는 자신이 속해 있는 게임방객 체를 알고 있기 때문에 굳이 CGameServer객체를 거치지 않고

곧바로 게임방 객체로 접근할 수 있음에도 말이죠.

물론 직접 게임방 객체로 전달하여 처리하는것도 아무 문제가 없습니다. 단, 스레드간의 동기화 처리만 정확히 해준다면 말이죠.

 

 

 

클라이언트가 보내온 메시지가 게임방 까지 전달되는 과정을 다시 한번 살펴 보겠습니다.

위 그림에서 CUserToken - CGameUser까지 연결 되는 과정은 워커 스레드에서 처리 되는 부분 입니다.

닷넷 프레임워크의 비동기 소켓 매커니즘을 이용하면 자동으로 처리되는 과정이죠. 이 스레드는 몇개가 될지 모르지만

여러개의 스레드에서 동시에 처리될 수 있다는 것은 알 수 있습니다.

게임방 객체는 여러 유저들이 공유하는 객체이기 때문에 CGameUser객체에서 데이터를 받은 즉시 게임방에 접근해 버리면

여러개의 스레드에서 동시에 게임방 객체를 건드리는 셈이 되는 것입니다.

이렇게 처리할 경우에는 스레드간의 동기화 처리를 섬세하게 해줘야 합니다. 게임방 객체에서 게임 로직을 처리할 때 마다 

lock으로 보호해 주어 스레드의 접근을 제어해 줘야 겠죠.

게임방에 속해 있는 매소드 하나 하나에 모두 저런 lock처리를 두게 된다면 코딩하기도 귀찮아질 뿐만 아니라

실수로 빠뜨리는 경우에는 어느 순간 데이터가 꼬이거나 서버가 죽는 경우가 생기게 될지도 모릅니다.

 

그렇다면 아예 로직 처리를 단일 스레드로 처리하면 어떨까요?

멀티코어가 대세인 요즘에 무슨 소리나며 펄쩍 뛰는 분들이 계실지 모르겠지만 멀티스레드에서 발생되는 버그를 경험해본 

분들이라면 아마 고개를 끄덕이고 계실 겁니다.

 

 

 

위 그림을 보면 워커 스레드에서 처리된 패킷들이 CGameServer의 메시지큐에 차곡 차곡 들어가는 모습을 볼 수 있습니다.

이 패킷들을 큐에 넣는 작업은 각각의 워커 스레드에서 처리 되며 큐에 담긴 패킷을 빼내어 게임방 까지 전달하는 부분은

CGameServer객체의 로직 스레드에서 처리 됩니다. 여기서도 최소한 두개 이상의 스레드가 하나의 큐에 접근하게 되기 때문에

큐에 넣고 빼오는 부분은 lock으로 보호해 주어야 합니다.

이렇게 큐에 들어간 패킷은 로직 스레드에서 하나씩 꺼내와 게임방까지 전달해 주게 되며 게임방 객체에서는 하나의 스레드만

작업하고 있으므로 스레드간 동기화에 신경쓰지 않고 코딩할 수 있습니다.

이런 기법을 사용하는 것은 멀티 코어를 활용하는데 아쉬움이 있을 수 있겠지만 유저들끼리 서로를 참조하며 진행되는 

게임 로직을 구현하는데 아주 편리하기 때문에 실무에서도 사용되는 구성입니다.

또한 이 예제에서는 등장하지 않지만 실제 상용 게임 서버에는 유저들의 게임 로직 뿐만 아니라 인공지능, 데이터베이스 처리등

시간이 오래 걸리는 작업들이 많이 존재하기 때문에 로직처리를 단일 스레드로 돌리는 것이 효율적이지 않다 라고만 볼 수는 없을 것입니다.

 

이번 장에서는 클라이언트에서 보내온 메시지들이 게임방 까지 전달 되는 과정에 대해서 알아봤습니다.
게임 로직이 처리되기 까지 어떤 과정들을 거치는지 이해 한다면 개발도중 버그가 발생했을 때 좀 더 쉽게 해결 방법에
접근할 수 있게 될겁니다.
다음 시간에는 게임 로직의 세부 구현에 대해서 작성해 보겠습니다.
감사합니다.

// ebook원고 작업도 거의 마무리 되어 가고 있습니다.^^

 

 

 

=======================

=======================

=======================

 

 

 

 

 

 

 

 

출처: http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Lecture_series&no=82

C#으로 게임 서버 만들기 - 11. 세균전-서버 구현 II(게임 서버 로직)

지난 시간에는 클라이언트측 로직과 유저들이 요청한 패킷이 게임방으로 전달 되는 과정에 대해서 알아 봤습니다.

이번 시간에는 게임의 로직을 만들어 보겠습니다.

최종적으로 게임의 모습을 완성시키는 과정이 될 것입니다.

 

 

유저의 요청 처리 하기

유저의 요청을 처리하는 부분은 CGameUser클래스의 process_user_operation매소드에서 수행됩니다.

CUserToken클래스를 통해서 들어온 메시지는 패킷으로 포장되어 CGameServer클래스의 메시지큐로 들어온다는 것을

지난시간에 설명 드렸습니다. 메시지큐에 들어있는 패킷들은 CGameServer클래스의 로직스레드에서 하나씩 빼내어

CGameUser의 process_user_operation매소드로 전달 됩니다. 따라서 process_user_operation매소드는 하나의

스레드만 접근하게 되며 여기서부터 처리되는 내용들은 모두 스레드 동기화에 신경쓸 필요 없이 작업하면 됩니다.

다음은 process_user_operation매소드의 코드 입니다.

void IPeer.process_user_operation(CPacket msg)  
{  
    PROTOCOL protocol = (PROTOCOL)msg.pop_protocol_id();  
    Console.WriteLine("protocol id " + protocol);  
    switch (protocol)  
    {  
        case PROTOCOL.ENTER_GAME_ROOM_REQ:  
            Program.game_main.matching_req(this);  
            break;  
  
        case PROTOCOL.LOADING_COMPLETED:  
            this.battle_room.loading_complete(player);  
            break;  
  
        case PROTOCOL.MOVING_REQ:  
            {  
                short begin_pos = msg.pop_int16();  
                short target_pos = msg.pop_int16();  
                this.battle_room.moving_req(this.player, begin_pos, target_pos);  
            }  
            break;  
  
        case PROTOCOL.TURN_FINISHED_REQ:  
            this.battle_room.turn_finished(this.player);  
            break;  
    }  
}  

클라이언트로부터 요청 받아 처리하는 패킷들은 위와 같습니다. 이제 이 패킷들을 하나 하나 처리해 보도록 하겠습니다.

 

 

 

게임방 입장 요청

클라이언트가 게임을 실행하고 제일 먼저 하는 일은 게임방 입장 요청 입니다. 이 예제는 1:1 실시간으로 진행되는 게임이기 때문에 

상대방이 존재해야 게임을 즐길 수 있습니다. 게임방 입장 요청이 오면 CGameServer클래스의 matching_req매소드를 호출 합니다.

/// <summary>  
/// 유저로부터 매칭 요청이 왔을 때 호출됨.  
/// </summary>  
/// <param name="user">매칭을 신청한 유저 객체</param>  
public void matching_req(CGameUser user)  
{  
    // 대기 리스트에 중복 추가 되지 않도록 체크.  
    if (this.matching_waiting_users.Contains(user))  
    {  
        return;  
    }  
  
    // 매칭 대기 리스트에 추가.  
    this.matching_waiting_users.Add(user);  
  
    // 2명이 모이면 매칭 성공.  
    if (this.matching_waiting_users.Count == 2)  
    {  
        // 게임 방 생성.  
        this.room_manager.create_room(this.matching_waiting_users[0], this.matching_waiting_users[1]);  
  
        // 매칭 대기 리스트 삭제.  
        this.matching_waiting_users.Clear();  
    }  
}  

먼저 대기 리스트에 요청한 유저를 추가하고 기다립니다. 추후 또다른 유저에게 입장 요청이 올 경우

대기 리스트에 추가한 뒤 2명이 모이면 게임방을 생성합니다. 방으로 입장한 유저들은 대기 리스트에서 삭제하고

게임방으로 유저들의 관리를 넘기게 됩니다.

게임방을 생성하는 CGameRoomManager클래스의 create_room매소드로 넘어가 보겠습니다.

/// <summary>  
/// 매칭을 요청한 유저들을 넘겨 받아 게임 방을 생성한다.  
/// </summary>  
/// <param name="user1"></param>  
/// <param name="user2"></param>  
public void create_room(CGameUser user1, CGameUser user2)  
{  
    // 게임 방을 생성하여 입장 시킴.  
    CGameRoom battleroom = new CGameRoom();  
    battleroom.enter_gameroom(user1, user2);  
  
    // 방 리스트에 추가 하여 관리한다.  
    this.rooms.Add(battleroom);  
}  

매칭을 요청한 유저 두명을 파라미터로 넘겨서 방을 생성합니다. new연산자를 통해 게임방 객체를 하나 만들고

enter_gameroom매소드를 호출하여 유저들을 게임방으로 입장 시킵니다.

하나의 게임방을 나타내는 CGameRoom객체는 별도로 풀링처리 하지 않았으므로 그냥 new연산자로 생성합니다.

만들어진 게임방 객체는 방 리스트를 관리하는 컨테이너에 보관해 놓고 나중에 게임이 종료되었을 때

컨테이너에서 삭제하여 가비지 컬렉터에서 메모리를 회수할 수 있도록 처리해 줄 것입니다.

 

public void enter_gameroom(CGameUser user1, CGameUser user2)  
{  
    // 플레이어들을 생성하고 각각 0번, 1번 인덱스를 부여해 준다.  
    CPlayer player1 = new CPlayer(user1, 0);        // 1P  
    CPlayer player2 = new CPlayer(user2, 1);        // 2P  
    this.players.Clear();  
    this.players.Add(player1);  
    this.players.Add(player2);  
  
    // 플레이어들의 초기 상태를 지정해 준다.  
    this.player_state.Clear();  
    change_playerstate(player1, PLAYER_STATE.ENTERED_ROOM);  
    change_playerstate(player2, PLAYER_STATE.ENTERED_ROOM);  
  
    // 로딩 시작메시지 전송.  
    this.players.ForEach(player =>  
    {  
        CPacket msg = CPacket.create((Int16)PROTOCOL.START_LOADING);  
        msg.push(player.player_index);  // 본인의 플레이어 인덱스를 알려준다.  
        player.send(msg);  
    });  
  
    user1.enter_room(player1, this);  
    user2.enter_room(player2, this);  
}  

CGameRoom클래스의 enter_gameroom매소드의 코드 입니다. 게임방에서는 CGameUser객체를 사용하지 않고

CPlayer객체를 사용하므로 new를 통해 플레이어를 생성합니다. CGameUser객체는 소켓 정보를 포함하고 있는데

게임방 내에서는 이 정보에 직접적으로 접근할 필요가 없기 때문에 CPlayer라는 객체로 감싸서 사용하는 것입니다.

코딩중 실수를 예방하기 위해서라도 필요하지 않은 정보는 최소한으로 접근하도록 만들 필요가 있습니다.

 

플레이어들의 초기 상태를 ENTERED_ROOM(방에 입장한 상태)으로 설정해 줍니다. 로직을 처리할 때 이 상태에 따라서

해당 요청이 허용 가능한지 여부를 판단하게 됩니다.

다음으로 각각의 플레이어에게 로딩을 시작하라는 START_LOADING패킷을 전송하고 CGameUser클래스의 enter_room매소드를 호출하여

게임방 입장 처리를 완료합니다.

CGameUser클래스의 enter_room매소드에서는 현재 생성된 게임방 객체를 멤버변수로 보관해 놓은 뒤 

유저로부터 게임 로직과 관련된 패킷이 왔을 때 해당 게임방으로 패킷을 넘겨주는데 사용합니다.

 

 

 

로딩 완료 요청

서버로부터 START_LOADING패킷을 받은 클라이언트는 각자 로딩을 시작합니다. 디바이스마다 사양이 다르고 네트워크

속도가 모두 다를 수 있기 때문에 로딩을 완료한 클라이언트는 로딩이 끝난 직후 LOADING_COMPLETED패킷을 전송해야 합니다.

모든 클라이언트로부터 로딩 완료 요청이 들어오면 게임을 시작합니다.

/// <summary>  
/// 클라이언트에서 로딩을 완료한 후 요청함.  
/// 이 요청이 들어오면 게임을 시작해도 좋다는 뜻이다.  
/// </summary>  
/// <param name="sender">요청한 유저</param>  
public void loading_complete(CPlayer player)  
{  
    // 해당 플레이어를 로딩완료 상태로 변경한다.  
    change_playerstate(player, PLAYER_STATE.LOADING_COMPLETE);  
  
    // 모든 유저가 준비 상태인지 체크한다.  
    if (!allplayers_ready(PLAYER_STATE.LOADING_COMPLETE))  
    {  
        // 아직 준비가 안된 유저가 있다면 대기한다.  
        return;  
    }  
  
    // 모두 준비 되었다면 게임을 시작한다.  
    battle_start();  
}  

클라이언트로부터 LOADING_COMPLETED패킷을 받으면 CGameRoom클래스의 loading_complete매소드를 호출 합니다.

먼저 해당 유저의 상태를 로딩 완료 상태로 변경합니다.

그리고 아직 모든 유저가 로딩 완료 상태가 아니라면 return하여 대기상태로 있습니다.

잠시 후 다른 유저로부터 LOADING_COMPLETED패킷을 받으면 해당 유저를 로딩 완료 상태로 변경한 뒤

battle_start매소드를 호출하여 게임을 시작하도록 합니다.

/// <summary>  
/// 게임을 시작한다.  
/// </summary>  
void battle_start()  
{  
    // 게임을 새로 시작할 때 마다 초기화해줘야 할 것들.  
    reset_gamedata();  
  
    // 게임 시작 메시지 전송.  
    CPacket msg = CPacket.create((short)PROTOCOL.GAME_START);  
    // 플레이어들의 세균 위치 전송.  
    msg.push((byte)this.players.Count);  
    this.players.ForEach(player =>  
    {  
        msg.push(player.player_index);      // 누구인지 구분하기 위한 플레이어 인덱스.  
  
        // 플레이어가 소지한 세균들의 전체 개수.  
        byte cell_count = (byte)player.viruses.Count;  
        msg.push(cell_count);  
        // 플레이어의 세균들의 위치정보.  
        player.viruses.ForEach(position => msg.push_int16(position));  
    });  
    // 첫 턴을 진행할 플레이어 인덱스.  
    msg.push(this.current_turn_player);  
    broadcast(msg);  
}  

battle_start매소드에서는 게임에 관련된 변수들을 초기화 하고 플레이어들의 위치를 설정한 뒤

GAME_START패킷을 클라이언트에게 전달하여 게임을 시작할 수 있도록 해줍니다.

이제 클라이언트로부터 요청이 오기만을 기다리면 됩니다.

 

 

 

이동 요청

게임 서버로부터 GAME_START패킷을 받은 클라이언트는 그 때부터 게임을 진행할 수 있는 상태가 됩니다.

세균전 게임의 룰에 따라 이동할 세균을 선택합니다. 선택한 이후의 게임 로직은 서버에서 처리 되므로

클라이언트에서는 어느 세균을 어느 위치로 이동했다고 알려주면 되겠죠.

void IPeer.process_user_operation(CPacket msg)  
{  
    PROTOCOL protocol = (PROTOCOL)msg.pop_protocol_id();  
    Console.WriteLine("protocol id " + protocol);  
    switch (protocol)  
    {  
        case PROTOCOL.ENTER_GAME_ROOM_REQ:  
            Program.game_main.matching_req(this);  
            break;  
  
        case PROTOCOL.LOADING_COMPLETED:  
            this.battle_room.loading_complete(player);  
            break;  
  
        case PROTOCOL.MOVING_REQ:  
            {  
                short begin_pos = msg.pop_int16();  
                short target_pos = msg.pop_int16();  
                this.battle_room.moving_req(this.player, begin_pos, target_pos);  
            }  
            break;  
  
        case PROTOCOL.TURN_FINISHED_REQ:  
            this.battle_room.turn_finished(this.player);  
            break;  
    }  
}  

MOVING_REQ패킷을 통해 클라이언트로부터 이동 요청이 들어오면 시작 위치와 목적지를 패킷으로부터 뽑아냅니다.

begin_postarget_pos변수에 각각 어디에서 어디로 이동 하겠다는 클라이언트의 요청값이 들어오게 됩니다.

이 값을 파라미터로 하여 CGameRoom클래스의 moving_req매소드를 호출해 줍니다.

 

/// <summary>  
/// 클라이언트의 이동 요청.  
/// </summary>  
/// <param name="sender">요청한 유저</param>  
/// <param name="begin_pos">시작 위치</param>  
/// <param name="target_pos">이동하고자 하는 위치</param>  
public void moving_req(CPlayer sender, short begin_pos, short target_pos)  
{  
    // sender차례인지 체크.  
    if (this.current_turn_player != sender.player_index)  
    {  
        // 현재 턴이 아닌 플레이어가 보낸 요청이라면 무시한다.  
        // 이런 비정상적인 상황에서는 화면이나 파일로 로그를 남겨두는것이 좋다.  
        return;  
    }  
  
    // begin_pos에 sender의 세균이 존재하는지 체크.  
    if (this.gameboard[begin_pos] != sender.player_index)  
    {  
        // 시작 위치에 해당 플레이어의 세균이 존재하지 않는다.  
        return;  
    }  
  
    // 목적지는 EMPTY_SLOT으로 설정된 빈 공간이어야 한다.  
    // 다른 세균이 자리하고 있는 곳으로는 이동할 수 없다.  
    if (this.gameboard[target_pos] != EMPTY_SLOT)  
    {  
        // 목적지에 다른 세균이 존재한다.  
        return;  
    }  
  
    // target_pos가 이동 또는 복제 가능한 범위인지 체크.  
    short distance = CHelper.get_distance(begin_pos, target_pos);  
    if (distance > 2)  
    {  
        // 2칸을 초과하는 거리는 이동할 수 없다.  
        return;  
    }  
  
    if (distance <= 0)  
    {  
        // 자기 자신의 위치로는 이동할 수 없다.  
        return;  
    }  
  
    // 모든 체크가 정상이라면 이동을 처리한다.  
    if (distance == 1)      // 이동 거리가 한칸일 경우에는 복제를 수행한다.  
    {  
        put_virus(sender.player_index, target_pos);  
    }  
    else if (distance == 2)     // 이동 거리가 두칸일 경우에는 이동을 수행한다.  
    {  
        // 이전 위치에 있는 세균은 삭제한다.  
        remove_virus(sender.player_index, begin_pos);  
  
        // 새로운 위치에 세균을 놓는다.  
        put_virus(sender.player_index, target_pos);  
    }  
  
    // 목적지를 기준으로 주위에 존재하는 상대방 세균을 감염시켜 같은 편으로 만든다.  
    CPlayer opponent = get_opponent_player();  
    infect(target_pos, sender, opponent);  
  
    // 최종 결과를 broadcast한다.  
    CPacket msg = CPacket.create((short)PROTOCOL.PLAYER_MOVED);  
    msg.push(sender.player_index);      // 누가  
    msg.push(begin_pos);                // 어디서  
    msg.push(target_pos);               // 어디로 이동 했는지  
    broadcast(msg);  
}  

유저의 이동 요청을 처리하는 매소드 입니다. 여기서 주의할 점은 유저의 패킷으로부터 뽑아온 begin_pos, target_pos의 값을

검증해야 한다는 점입니다. 서버에서 관리하고 서버에서 변경하는 데이터는 검증을 안거쳐도 상관 없지만

유저로부터 온 데이터는 반드시 조작 유무를 검증하여 말도 안되는 상황이 발생된다면 로직을 처리하지 않도록 해야 합니다.

이동 요청 패킷에서 검증해야 할 부분들은 어떤 것들이 있는지 살펴보겠습니다.

 

- 플레이어의 턴이 맞는지?
- begin_pos에 플레이어의 세균이 존재 하는지?
- target_pos에 이미 다른 세균이 존재 하는지?
- target_pos가 이동 또는 복제 가능한 범위인지?
- 자기 자신의 위치로 이동하려는 것은 아닌지?

 

위 내용들을 모두 체크하여 하나라도 이상한 경우가 있다면 로직을 처리하지 말고 해당 플레이어의 접속을 끊거나

불이익을 주는등의 조치를 취해야 합니다. 이 예제에서는 조작이 없다고 가정하였기 때문에 리턴처리만 하고 끝냈지만

상용 게임을 개발할 때는 저런 어뷰징 상황에서의 뒷처리까지 깔끔하게 구현해 놓아야 합니다.

모든 검사가 정상이라면 해당 위치로 이동 또는 복제한 뒤 상대방 세균을 감염시키는 루틴을 수행합니다.

/// <summary>  
/// 상대방의 세균을 감염 시킨다.  
/// </summary>  
/// <param name="basis_cell"></param>  
/// <param name="attacker"></param>  
/// <param name="victim"></param>  
public void infect(short basis_cell, CPlayer attacker, CPlayer victim)  
{  
    // 방어자의 세균중에 기준위치로 부터 1칸 반경에 있는 세균들이 감염 대상이다.  
    List<short> neighbors = CHelper.find_neighbor_cells(basis_cell, victim.viruses, 1);  
    foreach (short position in neighbors)  
    {  
        // 방어자의 세균을 삭제한다.  
        remove_virus(victim.player_index, position);  
  
        // 공격자의 세균을 추가하고,  
        put_virus(attacker.player_index, position);  
    }  
}  

infect매소드에서는 공격자와 방어자 플레이어를 입력 받아 상대방 세균을 감염 시키는 일을 수행 합니다.

게임의 룰에 따라 공격자의 기준 위치로부터 한칸 이내에 있는 방어자의 세균들이 모두 감염 대상입니다.

해당 위치의 세균을 제거하는 remove_virus와 새로운 세균을 생성하는 put_virus매소드를 통해

세균의 감염을 처리 합니다.

감염 처리가 완료되었으면 클라이언트에게 PLAYER_MOVED패킷을 전달하여 각자의 클라이언트에서

서버와 동일한 로직을 수행하도록 합니다.

// 최종 결과를 broadcast한다.  
CPacket msg = CPacket.create((short)PROTOCOL.PLAYER_MOVED);  
msg.push(sender.player_index);      // 누가  
msg.push(begin_pos);                // 어디서  
msg.push(target_pos);               // 어디로 이동 했는지  
broadcast(msg);  

이 때 전송하는 내용은 공격자 클라이언트에서 보내온 begin_pos, target_pos를 그대로 돌려 줍니다.

세균의 감염 처리는 이미 서버에서 완료 되었으며 클라이언트에서는 서버와 동일한 룰을 갖고 자체적으로

감염처리를 진행하도록 할 것이기 때문에 보드판의 내용을 모두 보낼 필요는 없습니다.

물론 코딩하는 사람에 따라서 디자인이 달라질 수 있으니 반드시 이것과 똑같이 작성해야 하는것은 아닙니다.

 

 

턴 종료 요청

PLAYER_MOVED패킷을 받은 클라이언트는 각자 세균의 감염 처리를 진행한 뒤 서버에게 턴을 종료한다는

패킷을 보내야 합니다. 이 패킷을 받은 뒤 서버는 다음 플레이어로 턴을 넘겨 게임을 진행하게 됩니다.

TURN_FINISHED_REQ패킷을 받아 호출하는 CGameRoom클래스의 turn_finished매소드 입니다.

/// <summary>  
/// 클라이언트에서 턴 연출이 모두 완료 되었을 때 호출된다.  
/// </summary>  
/// <param name="sender"></param>  
public void turn_finished(CPlayer sender)  
{  
    change_playerstate(sender, PLAYER_STATE.CLIENT_TURN_FINISHED);  
  
    if (!allplayers_ready(PLAYER_STATE.CLIENT_TURN_FINISHED))  
    {  
        return;  
    }  
  
    // 턴을 넘긴다.  
    turn_end();  
}  

두 클라이언트중 한쪽은 턴 연출이 모두 완료되었지만 다른 한쪽은 아직 진행중일 수 있기 때문에

모든 클라이언트로부터 패킷을 받았다는것을 확인한 뒤 다음 턴으로 넘겨야 합니다. 이렇게 동기화 처리를

해주지 않는다면 내 턴이 진행중일 때 상대방의 공격이 들어오는 경우가 생겨 클라이언트의 화면 처리가 엉망이 될 수 있습니다.

화면에 보이는 것이 없는 서버라 하더라도 서버에서만 돌아가는 게임이 아닌 이상 클라이언트와 서로 동기화를 맞추는 작업은 꼭 필요합니다.

/// <summary>  
/// 턴을 종료한다. 게임이 끝났는지 확인하는 과정을 수행한다.  
/// </summary>  
void turn_end()  
{  
    // 보드판 상태를 확인하여 게임이 끝났는지 검사한다.  
    if (!CHelper.can_play_more(this.table_board, get_opponent_player(), this.players))  
    {  
        game_over();  
        return;  
    }  
  
    // 아직 게임이 끝나지 않았다면 다음 플레이어로 턴을 넘긴다.  
    if (this.current_turn_player < this.players.Count - 1)  
    {  
        ++this.current_turn_player;  
    }  
    else  
    {  
        // 다시 첫번째 플레이어의 턴으로 만들어 준다.  
        this.current_turn_player = this.players[0].player_index;  
    }  
  
    // 턴을 시작한다.  
    start_turn();  
}  

턴을 종료하는 turn_end매소드의 코드 입니다. 다른 플레이어로 턴을 넘기기 전에 게임이 종료되었는지를 검사합니다.

보드판을 모두 채우거나 더이상 진행할 수 없는 상태라면 게임을 종료시키고 결과 처리를 진행해 줍니다.

아직 게임이 끝나지 않았다면 다음 플레이어로 턴을 넘겨줍니다.

/// <summary>  
/// 턴을 시작하라고 클라이언트들에게 알려 준다.  
/// </summary>  
void start_turn()  
{  
    // 턴을 진행할 수 있도록 준비 상태로 만든다.  
    this.players.ForEach(player => change_playerstate(player, PLAYER_STATE.READY_TO_TURN));  
  
    CPacket msg = CPacket.create((short)PROTOCOL.START_PLAYER_TURN);  
    msg.push(this.current_turn_player);  
    broadcast(msg);  
}  

플레이어의 상태를 준비 상태로 변경한 뒤 START_PLAYER_TURN패킷을 전송하여 또 다시 유저의 이동 요청을 기다립니다.

 

 

게임 종료 처리

턴을 넘기기 전 게임 종료 여부를 검사하는 부분이 있었습니다. 게임이 종료되면 턴을 넘기지 않고 점수를 계산한 뒤

유저들에게 게임 결과를 전송해 줍니다.

void game_over()  
{  
    // 우승자 가리기.  
    byte win_player_index = byte.MaxValue;  
    int count_1p = this.players[0].get_virus_count();  
    int count_2p = this.players[1].get_virus_count();  
  
    if (count_1p == count_2p)  
    {  
        // 동점인 경우.  
        win_player_index = byte.MaxValue;  
    }  
    else  
    {  
        if (count_1p > count_2p)  
        {  
            win_player_index = this.players[0].player_index;  
        }  
        else  
        {  
            win_player_index = this.players[1].player_index;  
        }  
    }  
  
  
    CPacket msg = CPacket.create((short)PROTOCOL.GAME_OVER);  
    msg.push(win_player_index);  
    msg.push(count_1p);  
    msg.push(count_2p);  
    broadcast(msg);  
  
    //방 제거.  
    Program.game_main.room_manager.remove_room(this);  
}  

게임 결과에 따라서 동점일 경우에는 win_player_index에 byte.MaxValue를 넣어서 동점이라는 표시를 해줍니다.

우승자가 있을 경우에는 win_player_index에 우승자의 인덱스를 넣어 주고 유저들에게 GAME_OVER패킷을 전달 합니다.

이 패킷을 받은 유저들은 각자의 화면에 게임 결과 화면을 출력해주고 더이상의 게임 진행이 안되도록 처리 합니다.

서버에서는 현재 방을 더이상 유지할 필요가 없으므로 CGameRoomManager클래스의 remove_room매소드를 호출하여

게임방을 서버에서 삭제해 줍니다.

 

이렇게 해서 서버 로직도 모두 작성해 봤습니다. 세균전 게임은 클라이언트의 요청이 네개 밖에 없는 단순한 편이지만
그 속에 온라인 게임의 기초적인 모습은 들어 있다고 볼 수 있습니다.
아직 개선할 점은 많지만 이 예제 소스 코드가 추후 더 큰 게임을 만들 때 발판이 될 수 있을것이라 생각합니다.

이상으로 C#으로 게임 서버 만들기 강좌를 마칩니다.

첨부파일에 모든 소스 코드의 최종 버전이 들어 있습니다(chapter9.zip)

긴글 읽어주셔서 감사합니다. 이제 ebook원고 작업 진행하러 가겠습니다~^^

 

 

chapter9 (1).zip
1.40MB

 

    ㄴ 로직을 어떻게 구성하셨는지 알아야 도움을 드릴 수 있을것 같습니다.라이브러리 구조상 특별히 문제될건 없겠지만 반응성 테스트는 안해봤기 때문에 라이브러리 문제일지도 모르겠네요. 2015-04-09
17:40:27
    답변 감사합니다.

로직은 캐릭터 이동 Input이 발생하면 서버로 이동패킷 요청을 날리고 서버에서 검증 후 다시 클라이언트로 이동패킷결과를 알려주게 하는 방식입니다. 그럼 검증된 패킷을 받고 클라이언트에서 화면에 출력하게 됩니다. 


내부적으로는 세균전 클라이언트 구현과 같이 FreeNet을 연동시켜 놓았습니다.
CFreeNetUnityService에서 Update를 돌며 큐에 메시지가 있으면 콜백함수를 호출하고 콜백함수(CNetworkManager안에 있는)에서 
SendMessage를 이용하여 다른 오브젝트(제가 만든 씬매니져)에 "on_recv" 함수로 보내게 했습니다.
그럼 이 씬매니저 오브젝트에서 받아서 다시 해당 플레이어 객체로 SendMessage로 패킷정보를 전달하고 플레이어 객체에서 이제 받은 패킷으로 플레이어를 움직입니다. 


단순 이동 패킷만 전송 시에는 살짝 버벅이는 정도로 캐릭터가 부드럽게 움직입니다.그런데 이동패킷 외에 다른 패킷(마우스방향, 총)까지 쏟아져 들어오면 너무 이동이 불가능하게 버벅입니다.





2015-04-09
18:52:17
    혼자 이것저것 실험해보니 서버에서 클라이언트로 브로드캐스팅 다 보내고 쉬고있는데 

클라이언트에서는 받은 패킷을 아직도 처리하고있네요.


받은 패킷을 큐에 넣고 다시 빼서 처리하는데에서 시간이 많이 걸리는거 같은데... 확실하게는 모르겠네요...


나중에 시간 나시면 해결방안좀 알려주세요 ㅠㅠ

2015-04-09
23:56:03
    ㄴ 네. 저도 샘플을 만들어서 테스트 해보고 알려드리겠습니다.^^ 2015-04-10
10:10:58
 
    태풍님! 제 로직상 문제였네요!(매 프레임 당 패킷을 하나씩만 뽑아서 사용해서;;) 테스트 안하셔도 될 것 같습니다!

수고스럽게 해서 죄송합니다!꾸벅(__)
2015-04-10
22:14:43
    좋은 글 감사합니다. 2015-04-27
10:29:34
   
   if (host == "0.0.0.0")
   {
    address = IPAddress.Any;
   }

디버그를 해보니 address가 0.0.0.0이 나오네요.. 
이건 왜이런 건가요?
2015-05-05
15:55:01
    \chapter9\FreeNet_chapter9\VirusWarGameServer\bin\Release
에서 VirusWarGameServer.exe을 실행하고

VirusWarClient_chapter9 을 유니티에서 열고 Run을 한다..
이렇게 되면, 게임이 진행되어야 하는거 아닌가요?

컴퓨터와 휴대폰 버전 두개를 실행시켜서 2인환경을 만들어봤는데, 반응이없어서 질문 드립니다!

제가 뭐 설정을 잘못 한 거일까요..?^^
2015-05-05
16:03:20
    정말 감사합니다ㅠㅠ 혹시 ebook 어디서 살 수 있을까요?? 2015-05-07
15:22:35
    noQue님.0.0.0.0은 서버의 모든 IP주소에 대해서 포트를 오픈하겠다는 뜻입니다.
0.0.0.0대신 IP주소를 직접 입력하셔도 됩니다.


질문님.
IP, PORT는 정확히 설정 하셨는지요?
어떤 오류가 나는지 로그를 올려주시면 확인해 보도록 하겠습니다.


라비님.
내일 나온답니다. 한빛미디어에서요. 확인되면 광고할께요.^^

2015-05-07
16:00:03
    클라이언트에서 서버와 Disconnect를 할 수 있는 기능이 있나요?
Connector에서 Disconnect 처리기능을 추가했는데 다시 Connect가 잘 안되서 ㅠ
시간 되신다면 도움 부탁드립니다.
2016-01-20
16:04:46

 

 

 

 

=======================

=======================

=======================

 

 

 

 

 

 

 

출처: http://www.gamecodi.com/board/zboard.php?id=GAMECODI_Talkdev&no=3572

C#으로 서버를 만드면 느려서 서비스 못한다는데... 어떻게 대응할까요?

안녕하세요.

 

제목대로 현재 모바일게임을 만들고 있고 C# + WCF + MongoDB + RedisDB조합으로 서버를 제작중에 있습니다.

 

현재로썬 제가 판단하에 효율적인 조합이라 생각하고 있는데, 경영 관련분이 오셔선 C#을 쓰면 대용량 서비스를 할 때

문제가 발생하여 절대로 안된다고 하네요.

 

하지만 전 이 구성으로 전 프로젝트를 런칭했고 동접 4만명 수준을 유지했습니다. 매우 안정적이였구요.

 

혹시 C#으로 만들어진 순위권 게임들이 있을까요?

 

비개발자라 이런 저런 얘기해봐야 이해못할것 같고 순위권 드는게임중에 C#으로 만들어진 서버를 알려주면 좀 가라앉을것 같아서요

 

마영전 서버가 C#으로 개발되었습니다.

마영전 서버 세션 정리
http://www.scribd.com/doc/57921356/ndc2011-%EB%A7%88-%EB%B9%84-%EB%85%B8-%EA%B8%B0-%EC%98%81-%EC%9B%85-%EC%A0%84-%EC%84%9C-%EB%B2%84-%EA%B4%80-%EB%A0%A8-%EC%84%B8-%EC%85%98-%EC%A0%95-%EB%A6%AC


C#으로 게임 서버를 개발 할 경우 성능에 대한 글
http://rosagigantea.tistory.com/408


NDC 참관기: 마비노기 영웅전 자이언트 서버의 비밀
https://rein.kr/blog/archives/2671


게임코디 쓰레드
http://www.gamecodi.com/board/zboard.php?id=GAMECODI_Talkdev&no=1792

2015-09-14
17:32:49
    경영하시는분이 능력자시군요.ㅎㅎ그러니까 모바일에서 동접4만명을 넘길자신이 있다는 말인가요?


뭘로 개발할지는 개발팀이 정하는건데 경영쪽에서 개발언어까지 제한하는건 아닌것같은데요.
저만 그렇게 생각하는건가요?

2015-09-14
18:19:53
 
    어쩌면, 그 경영 관련분이 "개발자" 출신일지도 모르죠. 그들에겐 전가의 보도고 있습니다. "나도 옛날에 개발자여서 좀 아는데" 2015-09-14
18:36:50

 
    그분이 개발자 출신이 아닌이상 

어디서 주워 들은걸로 아는척 하는것뿐이 안되겠네요...


실제 개발경험 있는사람보다 잘 알리가 없을텐데 ㅎㅎ
2015-09-14
20:17:15

    jhlee님 자세한 댓글 감사합니다~
제 생각에도 어디서 주워듣고 먼가 아는척 하는 것 같아서 대응을 안하려고 하는데 꽤나 심각하네 물고 늘어져서요. 이왕 대응하는 것 정확한 데이터를 제시해서 더이상 길어지지 않도록 할 생각입니다.
이미 C++도 mmorpg 경력만 10년이상 되었고 node.js로도 서비스 런칭한 경험이 있지만 나름의 C#를 선택한 이유가 있는데, 이해가 안가네요

2015-09-14
22:53:09

    게임서버를 이중화 해서 1개 머신이 죽었을 때도 문제 없이 돌아가야 한다고 주장하는 사람도 답답해요
블리자드 게임들도 그렇게 안하는데
어디서 웹서비스 쪽 책한권 보고 와서 그러는거 같은데 ;;
게임서버는 다운되면... 다시키면 됩니다 ㅇㅇ



2015-09-15
01:58:13
    요즘은 헤더 랑 cpp 파일 나누는 것도 어섹하더라는...
그래서 헤더에 다 썼는데 컴파일이 안되는 일이...
2015-09-15
10:00:21

    개발사에서 개발자를 믿지 못하면 정말 가슴이 아프죠....

솔직히 그분에게 제 경력을 못믿으시냐고 물어보시라고 하고 싶어요 ㅎㅎ;
2015-09-15
11:11:08

    노코드 님//블리자드는 블레이드 서버가 워낙 비싸서 이중화를 안한게 아닐까 의심됩니다. 와우 같은게 다 블레이드 서버인데...꽤나 많이 쓴다고 들었거든요. 최근에는 사용자가 줄고 그래서 서버 이용량이 주는 바람에 이중화 했다는 이야기도 들은 것 같긴 한데요... 2015-09-15
11:40:49

    저도 비슷한 경험이 있어요 

c#이라 느려서 못쓴다고 막 그러셔서 


전에 만든 서버 


실 서비스 lantancy 보여 줬더니 


C++로 하면 초당 1억건을 처리 할수 있다고 하시고 


대화하기 어려운 분들이 계신거 같습니다 ㅜㅜ

2015-09-15
12:02:03
    아이아빠 // 정말 말도 안되는 소리라고 생각합니다.. c++로 짠다고 해서 초당 1억건 처리라니...프로그래밍 언어에 따라서 절대적으로 판가름 할 수 있는 수치는 아닐텐데요. 특히 네트워크단에선...

2015-09-15
13:48:55
    뭣도 모르면서 어디서 주워들은걸로 아는척 하고 개발자가 조목조목 따지면 "나도 옛날에 개발자여서 좀 아는데" 시전ㅋ
2015-09-15
15:05:06
    latency보다는 through-put 문제인거같은데.. latency는 뭐.. 아무리 간단한 코드를 짜도 c++이 좀 더 빠를거 같고요. network 데이터양이 CPU가 처리할 한계 이상으로 들어오지 않는 한... (그 전에 scale-out을 하겠지만...) 어떤 언어로 짜도 이 언어로 짜서 처리량이 작아 문제가 생긴다라고 말할 수는 없지 않나요.

latency가 중요한 FPS나 MMORPG같은 경우는 C++로 가자고 하는게 신빙성이 있어보이긴 하는데.. 
허스키 온라인은 C#으로 짜서 MMORPG 서비스 한 게임 아닌가요..?
2015-09-15
16:50:19

    C#이 느려서 안쓴다기보다는,
윈도우 서버를 사용해야 한다는 제약과,
실제로는 어떤지 잘 모르겠으나 C# 국내 인력이 대체로 SI쪽에 몰려있는 것 같기에,
게임 서버쪽으로 C# 인력을 찾기 힘들지 않을까..
게다가 찾을수 있는 자료도 gcc가 됐는 vc가 됐든 c++ 자료나
java 쪽 자료가 많이 있지 않나..
그래서 C#쪽으로 인력구하기가 좀 어렵지 않을까라는 생각이.
2015-09-15
17:13:03

    C#으로 다양한 벤치마킹을 해보면서 느끼는 건 느리긴 합니다만 
어차피 모바일 서버는 Scale-Out이 손쉬우니 큰 문제가 될 일은 없을텐데 말입니다.

만약 모바일 게임인데 MMORPG처럼 하나의 월드에 많은 사용자를 넣어야하는 경우나
서버 구조 상 Scale-Out이 힘든 부분이 있다거나 복잡한 몹AI처리 같은 로직이 많은 서버라면 그런 주장도 이해는 갑니다만 C#이 많이 느리긴 합니다.

하지만 일반적인 모바일 게임이라면 그냥 Scale-Out하면 될테니 느려서 못쓸 정도가 될 일은 없지 않을까 합니다.
더군다나 서버 비용도 보통 퍼브리셔가 부담할테니 Scale-Out이 부담되는 일은 아닐텐데 말입니다.

다만 C#은 서버 프로그래밍이 복잡해질 경우 C++만큼 다양한 기교를 부리기 힘든 부분이 있어서 프로그래밍이 좀 더 까다롭고 힘들 수 있고...

메모리 사용 문제 같이 조심해야 하는 부분들이 좀 있긴 하죠. 특히 고성능 고부하를 대비한다면 말입니다.
(또 안정성 면도 C#서버가 좀 그시기 하긴 합니다.)
개인적으로 이런 부분이유로 왠만하면 서버는 C#보다 C++서버를 선호합니다.

하지만 C#서버의 가장 큰 장점은 클라이언트 개발자들이 보통 C#으로 코딩하기 때문에 그냥 서버 클라 같이 개발하도록 할 수 있다는 점이죠.
 모바일 서버에서 복잡하고 고성능 요구하며 민감한 부분은 C++로 제작하고 Scale-Out이 손쉬운 프론트엔드 단의 게임 서버는 그냥 클라자가 C#으로 개발하게 할 수도 있다는 것이죠.
현재 프로젝트 하나도 이런 형태로 진행하고 있습니다. 



2015-09-15
21:46:11
    의견 감사합니다.C++로 주로 개발하다, C#으로 개발해보니 장점이 많습니다. 특히나 모바일쪽으로는요


1. 리플렉션 기능을 활용가능하다.
 - 생각보다 이 기능은 대단히 편합니다. 구조체나 클래스의 멤버 타입이랑 이름을 가져올 수 있다면 좀더 편리한 작업이 가능합니다. 직관성도 뛰어나구요.


2. 컴파일 효율성
 - 사실 클라에 비해 서버 빌드속도가 차지하는 시간대가 적긴 하지만, 아키텍쳐 타입별로 라이브러리관리나 링크문제에서는 C++이 따라 올 수 없습니다. 특히나 헤더 파일 중첩문제는 한번꼬이면 풀기 매우 힘들죠. 


3. 코드 공유
 - 유니티로 개발할시 코드를 공유할 수 있습니다. 다시말해 서버 구현코드를 임베딩하여 싱글모드로도 제작할 수 있습니다.
서버개발자는 잘 체험이 안되지만 클라개발자나 기획, 디자이너가 접근하기에는 매우 편합니다.


4. 생각보다 안정성이 좋습니다. 저 역시 C++로 개발할 시  C#에 의구심을 가지고 있었는데요. 실제 서비스화 까지 하면서 이러한 의구심은 거의 사라졌고, C++에서 발생하는 if(var = 1) 논리에러 문제도 사전에 잡아주기때문에 에러 발생율이 적습니다. 특히나 스택 오버플로우나 메모리 침범문제는 C++의 경우 매우 잡기 어려운 문제 중에 하나지만, C#은 문제가 아닙니다.


5. Database나 써드파티 라이브러리 활용하기가 용이합니다. 코드 구조자체가 간결하고 깔끔합니다.
- C++은 boost만 봐도 간단하지는 않지요. 하지만 C#은 boost에 있는 기능 대부분이 이미 포함되어 있습니다.


실제로 C#이 C++보다는 처리량이 적다는 통계는 있습니다만, 현재같이 scale-out이 더 중요한 시기에 퍼블리셔들은 cpu 20%도 못넘기게 하는게 현실입니다. 한 대에 10만명을 받겠다는 생각을 하지 않는 이상 처리량은 문제 되지 않습니다.


저 역시 아직까지는 C++로 MMORPG로 만들어야 겠다는 생각은 있습니다. 하지만 모바일에서는 C#이 낫다고 생각합니다.


결론적으로는 처리량은 C++보다는 C# 이 15% 적지만 개발 속도는 100%이상이라고 생각합니다.

2015-09-16
12:05:49
   



컴투스에서 서비스중인 "소울시커" 요게임이 C# 입니다~
2015-09-16
16:27:34

    좋은 글 쭉 읽었습니다. 감사합니다.그런데 C#의 보안 문제를 지적 하시는 분은 없네요?


제가 게임 개발 분야는 아니라서, 그런지 몰라도 민감한 정보는 C# 코드에 직접 담기가 애매 하고, 한번 난독화를 거쳐야 하긴 하지만, 번거럽기도 하고 디버깅도 손이 많이 가더군요.... 


.. 아 애초에 본문은 서버 질문이죠? ㅎㅎ ;


클라 이야기도 나와서 한번 덧붙여 봅니다.
2015-09-16
22:52:58

    물치/
---------------------------------
처리량은 C++보다는 C# 이 15% 적지만
---------------------------------



음... 제가 알기로는 성능 차이는 C#과 C++서버... 생각하시는 것보다는 훨씬 더 많이 납니다. 
15%정도 차이라면 성능차이가 난다고 말하지도 않았을 겁니다.
적어도 5배이상의 성능차이는 나는 것으로 생각합니다. 상황에 따라 많게는 100배 정도....
구체적인 벤치마킹한 케이스와 수치 등 자료는 제가 시간나는 대로 정리해서 한번 올려 보겠습니다. 

근데 API함수류는 C++이든 C#이든 어떤 언어를 사용하든간에 거의 비슷한 비용이 들수 밖에 없는데 서버의 경우 이런 함수의 (네트워크 I/O처리)  호출이 빈번한데다 처리 시간마져 로직처리 같은 것에 비해 상당히 크다보니 C++과 C#의 성능 격차가 많이 무모화되기도 하고 이런 함수의 처리 효율성에 따라 성능이 역전되는 경우도 있긴 합니다.

근데 
네트워크 I/O, DB처리, File I/O 같은 부분이 제대로 설계되고 최적화된 C++과 C#의 성능차는 상당히 많이 납니다. 

또 로직부분이 다수를 처지하는 서버의 경우 많은 차이가 납니다.

C#이 JIT로 동작하는 것 때문에 컴파일 언어와 동일한 수준으로 최적화된다고 생각하는 경우가 많치만 사실 C#과 C++의 컴파일 최적화 수준이 하늘과 땅만큼 차이가 납니다. 





---------------------------------
개발 속도는 100%이상이라고 생각합니다.
---------------------------------

사실 개발속도는 언어의 차이보다 개발자의 숙련도 혹은 어떤 언어에 더 익숙한가게 더 큰 영향을 미치므로 속단하기는 힘들다고 봅니다. 
 C#이 생산성이 좋다던지 하던 것은 사실 Modern C++이 나오기 전까지는 어느 정도까지 설득력이 있었지만...
사실 Modern C++이 나온 이후에는 언어적 차원에서만 보면 C++이 더 우수다하다고 볼수도 있다고 봅니다. 
 여전히 C#의 장점이 있긴 하지만 여러 제약상황 때문에 같은 코드를 작성하게 된다면 C++이 훨씬 짧은 경우가 많습니다.
예를 들자면... C#은 define같은 것으로 대체정의가 불가능합니다. 그래서 코드 부분은 모두 직접 써줘야합니다. 
또 C++에 있어서 많은 편리함을 주는 template부분도 C#은 상당히 제한이 많습니다. 
생성자와 소멸자의 호출이 명시적이지 않기 때문에 그로 인한 많은 기법들을 사용할수 없습니다. 
일명 RAII류 기법들인데... 이를 사용하는 것으로 인해 C++은 신경써야 한는 코드나 작성부분이 대폭 줄어듭니다. 
근데 이런 것들은 C#의 경우 직접제공하는 lock을 제외하고는 대부분 일일이 작성해 줘야하는 경우가 많기 때문에 이런 부분에서 차이가 많이 납니다. 
구조가 복잡해지면 이런 것들로 인한 생산성차이가 확연하게 납니다. 

C#은포인터를 사용하지 않는다는 것을 주요 장점을 내세우시기도 하는데 포인터에 익숙치 않은 분은 분명 큰 차이로 느껴질수 있긴 있다고 봅니다만...
기본적으로 C#이 포인터를 쓰지 않는다는 오해에서 발생하는 문제라고 봅니다. 
C#이 포인터를 쓰지 않는 것이 아니라 문법적 선택을 지원하지 않는다뿐 사실은 거의 모두 포인터로 동작합니다. 
struct나 기본형을 제외한 나머지.. 즉 class로 정의한건 선택의 여지 없이 모두 c++의 포인터로 동작하는 셈입니다. 
그로 인해서 c++이 가끔식 내는 null포인터로 인한 문제 역시 C#거의 그대로 가지고 있습니다. 
또 C++의 경우도 template 등의 많은 발전으로 인해 그냥 맨 포인터를 그대로 쓰는 경우가 많지 않기 때문에 포인터로 인한 문제가 옛날처럼 그렇게 큰 문제이지는 않고 오히려 더 편리한 면도 있습니다.

또 C#이 C++에 비해서 강력한 우위였던 delegate나 event같은 처리 방식이 C++의 lambda함수들이 나오면서 많이 무모화되었습니다.

또 C#의 경우 다중상속이 되지 않는 것으로 인해 코딩의 유연성문제나 편의성이 좀 많이 문제인 부분이 많습니다. 
C#으로 작성할 경우 다중상속이 지원되지 않아서 좀 번거럽게 delegation처리를 해야하는 구조로 만들어야 하는 경우가 많아서.. 복잡한 서버의 경우 이런 것들도 많은 번거러움을 만듭니다. 

기타 여러 소소한 부분에서도 C#읜 제약이 많습니다. 
예를 들어 collection들의 iteration문제들... reverse iteration을 하려면... 좀 문제죠.. 
특히 정방향으로 돌다가 그걸 다시 역방향으로 돌아야하는 경우.. 정말 골치아픕니다. 
기타 collection 등의 처리에 있어서도 C++에 비해서 너무 제약이 많아서 불편하거나 더 많은 코드를 작성해야하는 부분이 많습니다.

물론 앞으로 c#도 자신의 방향대로 더 발전해 나가겠지만 단순히 언어차원에서 생산성을 논하는 것은 좀 무리라고 생각합니다. C#을 사용해서 작성했을 때 간단히 작성할 수 있는 부분도 있긴 합니다만... 
현재로써 제가 느끼긴 복잡한 서버를 제작할 때는 오히려 C#으로 작성하는 것이 더 생산성이 떨어지지 않나 합니다. 

---------------------------------
 if(var = 1) 논리에러 문제도 사전에 잡아주기때문에 에러 발생율이 적습니다.

---------------------------------
이런 부분은 요즈음 C++에서도 다 제공해줍니다. 
잘못된 포인터의 접근이나 초기화안된 값으로 인한 오류 가능성 부분까지도 모두 컴파일러에서 인지해서 경고를 내주죠. 

사실 이런 사소한 문제는 큰차이가 없습니다만 C#이 코딩할 때 여러 정보 표시 기능이 더 많은 것은 사실인 듯합니다.



하여튼 두서없이 작성을 했는데... 
C++과 C#의 성능차이 그리고 코드 작성의 차이 등등에 대해서 시간나면 한번 자세히 써보도록 하겠습니다. 
하여튼 C++과 C#의 성능 차이는 생각하시는 것보다 훨씬 더 많이 납니다.

다만 모바일 게임의 경우는 C#보다더 훨씬 느린 웹서버로도 Scale-out이 손쉬워서 서비스하는데 지장이 없는 마당에 c#이 게임서버로 느려터져서 못쓴다할 정도는 아니란 것이죠. 그냥 scale-out하면 되니까요. 

하지만 로직이 많은 부분 scale-out 처리가 힘든 구조를 가진 서버의 경우 c#은 많이 느리긴 합니다. 
그 분께서는 그런 경험을 하신 것이 아닌가 하는 군요. 
더군다나 c#의 속성을 잘 모르는 초보자분들께서 서버를 작성을 하시면 성능문제는 더 심각해지는 경우가 많습니다.

안정성 문제는... C#이 저의 경험상으로는 아직 좀 문제라고 봅니다.
메모리 사용량이 급격하게 늘어나는 문제나 가비지 콜렉션 문제나... 느닷없이 죽는 문제 역시 없지 않구요.
navtive가 아니면 그런 것들의 제어가 쉽지않다는 것도 문제인 듯합니다.

하여튼 다음에 기회가 되면 구체적인 사례를 들어 좀 더 자세히 기술해 보도록 하겠습니다.


2015-09-17
07:44:58
    c# 성능과 관련해서 많이 배우고 있습니다.
제가 c# 서버 개발을 해 본적이 없어서 궁금한 점이 있는데요.

아마, c#에서는 Exception이 발생해서 프로그램이 죽는 경우가 없을 듯 합니다.
그래도, 다음과 같은 경우 문제가 발생할 텐데요.


첫번째, 메모리를 free하지 않아서, 사용 메모리가 계속 늘어나는 경우의 디버깅은 어떻게 하나요?

둘째, Critical Section등을 잘못 사용하여  deadlock이 발생했거나,
아니면, 데드록까지는 아니더라도 심하게 느려지는 경우에 발생 원인을 찾는 방법이 있나요?

c++ 서버의 경우, minidump 라는 파일을 만들어서, 나중에 그 파일을 분석해서 문제를 해결하는 경우가 대부분인데요.
c#에서도 minidump 가 있나요? 
2015-09-17
08:35:12

    @행복한나그네.
C#에서도 익셉션이 발생하면 서버 다운되요. 아무도 catch 하지 않으면 그렇죠. 보통 프로토콜 핸들러단에서 catch 하면 거희 죽을 일이 없긴 하죠. 이건 C++도 마찬가지.
C#에서는 메모리를 free 하는것 자체가 없어요. 이게 C#의 최대 장점!! free 하지 않는만큼 골치아픈 일이 줄어들고 생산성이 엄청나게 향상되었어요.
그래도 메모리가 계속 늘어나는 경우가 있는데 객체를 컨테이너에 넣어 놓고 remove 하지 않는 경우에요. 저는 컨테이너들의 사이즈를 일일히 로그에 남도록 해두고 계속 증가하는 컨테이너가 발견되면 remove 처리 하도록 했더니... 메모리 증가할 일이 없었어요.
CS잘못써서 데드락 발생해서 서버가 멈춘것 같으면, 작업관리자에서 프로세스 우클릭해서 덤프 만들어서 까보면, 쓰레드 어디서 멈춰있나 볼수 있어요.
미니덤프같은건 인터넷 검색해보면 dbghelp.dll 의 MiniDumpWriteDump 함수를 가져와서 호출 하는 방법이 나오는데... 지금은 안써요 이유가 정확히 기억은 안나지만..(안되서 안쓴건지 불편해서 안쓴건지)
대신 StackTrace라는거 써서 익셉션 발생한 위치의 콜스택을 스트링으로 추출해 로그로 남겨버려요. 저는 필요한건 뻑난 위치 뿐이라서ㅋ



2015-09-17
10:08:32
    작년에 게임 오픈하던날.. 퇴근시간에 1시간정도 눈치보다가 별일 없어서 퇴근할 수 있었었어요 ㅋㅋ
C# 덕분이라고 할수 있어요!! ㅋㅋ



2015-09-17
10:18:12
    요즘은 성능은 하드웨어만 약간 돈 들이면 되니까 큰 이슈는 아니지 않을까요?빠르게 개발하고 편하게 안정화할 수 있는 것을 선택하는 게 더 좋은 것 같습니다.

2015-09-17
16:27:03
    저는 C#의 태생적 한계를 IL과 가비지컬렉션이라고 생각하고 있는데요...IL과 완전 컴파일의 성능한계를 간과하기 어렵고
가비지컬렉션의 예측불허한 동작을 감당할 수 있다면
C#서버는 '익숙한 도구를 선택한다'이외의 이유는 불필요하다고 생각합니다. (C#서버 괜찮다는 말입니다.)


그런데 비전문가가 아니라 어중간한 전문가를 이 문제로 설득하려면 사실 좀 답없죠...
사람의 선택은 대체로 감정적이라고 생각합니다.

2015-09-17
17:06:15
    https://msdn.microsoft.com/ko-kr/kr-kr/library/ee851764(v=vs.100).aspx#TimeInGC
위 링크에서 "가비지 수집  기간을 확인하려면" 설명 내용대로 체크를 하면 (방법이 좀 난해하긴합니다)
저의 경우 대부부 0.1ms 이하로 측정되었고, 튀어봤자 1ms 였었습니다.
1ms 이하라면 서버에서 쓰기엔 무리 없지 않을까요?ㅋ (이건 사바사? )
2015-09-17
17:15:39

    ㄴ 좀 논외의 이야기이긴 하지만, 같은 GC 형태의 언어인 Java 의 경우 상용 JVM 인 Azul Systems 의 Jing 이 성능 효과가 좋아 미 금융권에서도 일부 사용된다고 합니다. C# 도 이런 형태의 런타임이 개발된다면 더 활발하게 사용되리라 기대합니다. 2015-09-17
17:56:05

    제가 아는 게임은 4:33에서 서비스 하는 활이 c# 서버입니다. 2015-09-17
18:36:08

    ㄴㄴ 오타났습니다. Jing 이 아니고 Zing 입니다. Azul Zing 2015-09-17
18:48:58

    자기가 가장 익숙하고 잘 다루는 언어를 쓰는 것이 정답입니다. 실제로 서비스하는 게임에서만큼은 필수입니다.F-15가 익숙하다고 F-22를 한두번 타고 바로 실전에 투입되는 전투기 조종사의 운명과도 같습니다.


C#이 익숙하시면 느리고 빠르고를 떠나서 C#을 쓰시는 것이 백번 옳다고 생각합니다. C++로 꼭 짜야 한다고 경영진이 주장한다면, 그러면 C++로 실전 경험 풍부한 서버개발자를 뽑으라고 하심 됩니다.

2015-09-21
12:45:01
ㄴ 내용 정정: F-15가 익숙한데도 불구하고 F-22가 좋다고 F-22를 한두번 타고 바로 실전에 투입되는 전투기 조종사의 운명과도 같습니다. 2015-09-21
12:56:54

요새 서버개발 언어가 참 다양해졌죠. 많이 쓰는 것들 보면 C++,C#,Java,Javascript,Python이 골고루 뒤섞여 있고, 어느게 더 많은 비중을 차지하는지는 종잡기 어렵습니다. 공개 투표를 해봐야 알 수 있을 것 같아요.

 

 

 

 

=======================

=======================

=======================

 

 

 

 

 

 

 

출처: http://lacti.me/2014/06/30/why-implements-csharp-server/

 

본 글은 동아리 친구의 질문인 '왜 게임 서버를 c++이 아닌 c#으로 작성하려 하냐?'에 대한 답변이다.

간단히 c++과 c#의 차이를 통해 답변하면 이렇다.

  • c++은 속도가 빠르다.
  • c#은 기본 라이브러리가 풍부하다.
  • c#은 표현력이 좋다. linq나 reflection의 도움을 받을 수도 있다.
  • c#은 native에서 벌어지는 access violation 등으로부터 다소 안전하다.

즉, c#으로 프로그래밍할 경우 c++로 할 때에 비해서 보다 편하게, 보다 안전하게 프로그래밍을 할 수 있다고 생각한다. 그렇기 때문에 c#으로 작성한다고 답변한 것이다. (물론 도메인에 의한 판단이 우선이다. 속도가 중요한 서버인데 c#으로 짜라는 고집을 부리지는 않는다.)

게임 서버를 구현한다고 해보자. 게임 서버는 게임 + 서버이므로, 클라이언트를 처리하는 서버적 기능과 게임이적 요소를 포함하면 되겠다. 대충 다음과 같이 분류할 수 있을 것 같다.

  • network: 클라이언트의 요청을 처리해야 한다.
  • persistence: 클라이언트의 정보를 저장해야 한다.
  • logging: 클라이언트의 행적을 기록하고 운영 대응을 해야 한다.
  • logic: 게임 내용을 위한 요소들(npc, 시야, 전투, 커뮤니티, 기타 컨텐츠 등)

요소별로 생각해보자.

  • network 코드는 기반 network 코드와 message handler 코드로 나눌 수 있다.
    • 기반 network 코드는 message를 주고받는 부분이나, byte stream을 암호화하는 부분 등으로 나눌 수 있다.
    • message handler는 message를 설계하고 handler 코드를 구현/등록하는 부분으로 나눌 수 있다.
  • persistence는 게임 object에 대한 crud에 대한 코드가 있다.
  • logging은 게임 object 혹은 content에 대해 기록을 남기는 코드일 것이다.
  • logic 코드는 데이터를 읽거나 상황을 판단해서 content 별로 적절히 처리하는 코드일 것이다.

network나 message 쪽 코드는 워낙 generator도 많고 좋은 추상화된 라이브러리도 많아서 c++이나 c#이나 크게 차이가 없을 수 있겠다. c++도 protobuf에 asio 붙이면 코드가 그렇게 끔찍하지는 않다고 생각한다. 하지만 c#에서는 딱히 라이브러리 안 붙여도 core 코드를 적은 줄에 쉽게 작성할 수 있다. (약간의 성능을 포기하고 async/await을 쓰면 더 짧아진다.)

persistence 쪽 코드나 logging 코드는 (경험상) bolierplate 코드가 많았기 때문에 무의미한 반복 코딩을 하게되는 경우가 많았다. 하지만 이 쪽도 c++아니 c#이나 ORM이나 code generation 등으로 어느 정도 귀찮음을 줄일 수 있으므로 큰 차이가 없다고 생각할 수도 있겠다. (개인적으로는 reflection이 있기 때문에 c# 쪽이 더 편리한 점이 많다고 생각한다.)

하지만 위 부분들은 모두 전체 서버 코드에 큰 비율을 차지하지 않는다. 가장 많이 작성해야 하는 부분은 logic 코드 부분이다. 이 부분에서는 대부분 데이터를 탐색하거나, 연산을 하거나, 객체를 가져와서 변경하는 작업을 주로한다. 이러한 코드를 작성함에 있어 과도할 정도로 표현력이 풍부한 c#이 아무래도 c++보다 코딩하기 낫다고 생각하는 것이다.

요약하면 그냥 c#으로 두 줄 작성하면 되는 것을 c++로 작성하려면 열 줄, 스무 줄로 늘어나니 귀찮다는 것. 그래서 가능하다면 c++보다는 c#으로 작성할 것이다.

성능?

약간 다른 이야기지만 c++과 c#의 성능 비교 이야기를 해보자. 현재 내가 알고 있는 범위에서 c#이 느린 부분은 다음과 같다.

  • native에 비해 기본 연산이 느리다. 물론 그렇겠지만 JIT가 돌아가는 마당이니 큰 차이는 없다.
  • async/await가 느리다. DefaultTaskScheduler가 좀 대충 만들어져서 느린데 고쳐서 쓰거나 그냥 AsyncIO를 쓰면 어느 정도 회피할 수 있기는 하다.
  • gc가 돌면 세상이 멈춘다.

c++에서 하던 식으로 모든 객체를 메모리에 올려두는 식으로 프로그래밍하다 보면 당연히 gen2에 쌓이는 객체가 많아진다. 때문에 gen2를 탐색하는 gc가 수행될 때 서버의 전체적인 throughput이 크게 떨어지는 문제가 발생할 수도 있다. 물론 concurrent gc를 사용하거나, server gc를 잘 튜닝해서 사용하면 문제를 어느 정도 회피할 수 있다고는 하지만 간단해 보이지는 않는다.

gen2로 가는 객체의 수를 줄이는 것이 관건인데 이게 또 간단하지 않다.

  • 동접이 5,000명이라면 적어도 유저 객체 5,000개를 메모리에 올려놔야 한다는 것인데, 각 유저 객체마다 Dictionary나 List를 갖기 시작하면 그 내에서도 파편화된 수 많은 객체들이 존재할 수 있기 때문이다.
  • 또한 게임 서버에서 사용되는 데이터들에 대해서도 서버 구동 시 미리 읽어두는 경우가 많은데 이 객체들이 모두 gen2로 넘어가게 된다.

이들을 최적화하는 방법을 찾아야 좀 제대로된 서버를 만들 수 있을 것이다. 이 때문에 c++/cli 영역에서 메모리를 할당한 후 그것을 사용하는 사람도 있었고, 아니면 단순히 python으로 갈아타는 사람도 있었다.

위 문제를 열심히 고민하고 있는데 적절한 해결책을 찾지 못했다. 좀 더 고민해봐야 겠다.

----------------------------------------------------------------------------------------------------------------------------------------

 

"꼭 C#으로 서버를 작성해야 하나?" 라는 질문은 일단 제외하는 건가요?

 

-----------------------------------------------------------------------------------------------------------------------------------------

 

gc문제도 있고, 결론내지 못한 network 쪽 문제( http://lacti.me/2014/02/14/sec... )도 있기 때문에, 성능이 중요한 도메인에서조차 c#을 꼭 써야한다는 주장하기는 어려울 것 같습니다. 하게 된다면 c++ 단일 서버보다 c# 분산 서버가 더 의미가 있다는 쪽으로 이야기를 해야할 것 같은데 제가 제대로 된 구현체를 만든 적이 없어서 강하게 주장은 못 할 것 같습니다 [...]

혹은 gc 문제나 성능 문제를 해결하기 위해 c++/cli를 사용하여 일부 코드를 native에 두거나, io 함수들을 직접 pinvoke로 wrapping해서 사용해볼 수도 있겠지만, 그렇게 될 경우 코드를 c++과 c# 양 쪽으므로 모두 작성해야 하니 c#으로 서버를 작성한다고 하기는 어려울 것 같습니다. 그리고 한 번 c++로 작성할 수 있는 여지를 만들어놓으면 c++쪽 코드가 계속 증식할 것 같네요.

 

 

 

=======================

=======================

=======================

 

 

 

 

 

 

반응형


관련글 더보기

댓글 영역