미니난투 온라인 - Google Play 앱

[ 게임 소개 ] 미니난투 온라인은 실시간으로 다른 유저들 대결을 할 수 있는 공간입니다. 전략적으로 장비를 선택해, PvP 전투를 준비하세요. 현재 개인전, 팀전이 준비 돼 있습니다.(추후 새로운 모드 업데이트 예정) [ 핵심 컨텐츠 ] - 미니난투 : 6명이서 개인전 난투. - 팀전 : 3:3 전투 - 장비 : 무기, 방어구, 신발, 악세서리 - 아이템 : 포션, 폭탄, 토템 등 [ 전략요소 ] - 장비마다 스킬이 포함 돼 있어, 전략적으로 전투를 할

play.google.com

 

 

 지난 포스팅에서 패킷을 만들고, 클라이언트에서 패킷을 전송하는 것에 대해 살펴봤다. 이번에는 서버에서 그 패킷을 받아 응답하는 것을 하려고 한다.

2019/12/17 - [게임을 만들자/C# 서버] - c# 실시간 게임 서버 만들기 1 - 패킷

2019/12/19 - [게임을 만들자/C# 서버] - c# 실시간 게임 서버 만들기 2 - 클라이언트

 

1. 유저 접속 대기

 서버 프로그램을 시작하면 우선적으로, 서버 소켓을 열고, 그 소켓에서 유저의 접속을 받는 것이다.  아래 코드에서 start함수부터 살펴 보면 된다.

 유의할 점은, 유저 접속을 기다리는 부분이 메인 스레드가 아닌 서브 스레드에서 일어난 다는 것이다. '왜?'라는 생각이 들 수 있는데, 나는 [접속, 로직, db, http통신] 등에 대해서는 스레드를 나눠서 사용하고 있다. 이부분은 스타일의 차이이므로 절대적이지는 않다.

 

using System;
using System.Net;
using System.Net.Sockets;
using System.Threading;

public class jdListener
{
    SocketAsyncEventArgs    m_accept_args;
    Socket                  m_listen_socket;
    AutoResetEvent          m_flow_control_event;
    bool                    m_thread_live{ get; set; }


    public delegate void NewClientHandler(Socket client_socket, object token);
    public NewClientHandler m_callback_on_new_client;

    public jdListener()
    {
        m_callback_on_new_client    = null;
        m_thread_live               = true;
    }

    public void start(string host, int port, int backlog)
    {

        //소켓을 연다.
        m_listen_socket = new Socket(AddressFamily.InterNetwork, 
                                     SocketType.Stream, 
                                     ProtocolType.Tcp);
        m_listen_socket.NoDelay = true;

        IPAddress address;

        if (host == "0.0.0.0")
        {
            address = IPAddress.Any;
        }
        else
        {
            address = IPAddress.Parse(host);
        }
        IPEndPoint endpoint = new IPEndPoint(address, port);

        try
        {  
            //서버 ip에 소켓을 연결하고, 수신상대로 만든다.
            m_listen_socket.Bind(endpoint);
            m_listen_socket.Listen(backlog);

            //유저가 연결 되었을 때, 발생할 이벤트를 등록한다.
            m_accept_args = new SocketAsyncEventArgs();
            m_accept_args.Completed += new EventHandler<SocketAsyncEventArgs>(onAcceptComplected);

            //별도의 스레드에서 
            Thread listen_thread = new Thread(doListen);
            listen_thread.Start();
        }
        catch(Exception e)
        {
            LogManager.Debug(e.Message);
        }
    }

    void doListen()
    {
        //하나의 유저를 받고 처리를 기다리도록 하기 위해 사용.
        m_flow_control_event = new AutoResetEvent(false);

        //서버가 살아있는 동안, 유저를 계속 기다립니다.
        while (m_thread_live)
        {
            //초기화 부분
            m_accept_args.AcceptSocket = null;
            bool pending = true;

            try
            {
                //비동기 함수로 Accept 합니다.
                //pending 부분에 대해서는 이전 포스팅 참고.
                //if do sync that return false else return true
                //유저가 접속되면, onAcceptComplected함수가 실행된다.
                pending = m_listen_socket.AcceptAsync(m_accept_args);
            }
            catch(Exception e)
            {
                LogManager.Debug(e.Message);
                continue;
            }


            //바로 Accept이 이뤄진 경우
            if(!pending)
            {
                onAcceptComplected(null, m_accept_args);
            }

            //스레드를 대기 시킵니다. m_flow_control_event.Set()이 발생하면 재개
            m_flow_control_event.WaitOne();
        }
    }


    void onAcceptComplected(object sender, SocketAsyncEventArgs e)
    {
        if(e.SocketError == SocketError.Success)
        {
            //새로운 유저가 접속 했을 때,
            Socket client_socket = e.AcceptSocket;

            //접속 처리 부분
            NetworkManager.Instance.OnNewClient(client_socket, e);
        }
        else
        {
            //실패 
        }

        //위의 스레드를 재개 시켜, 다음 유저 접속을 기다립니다.
        m_flow_control_event.Set();
    }

    //서버를 닫느 경우
    public void Close()
    {
        m_listen_socket.Close();
    }

}

 

 start()함수를 보면, 유저를 받을 소켓을 열고, 한번에 한명씩 유저를 받고 있다(AutoResetEvent 사용). Accept는 비동기함수인 AcceptAsync함수를 사용하고 WaitOne()로 대기 합니다., 유저 접속이 일어나면 onAcceptComplected 콜백함수가 호출되고, AutoResetEvent.Set()으로 스레드를 재개 시킵니다.

NetworkManager.Instance.OnNewClient 부분은 유저 접속 후, 해당 유저와 메시지를 주고 받을 수 있도록, 설정하는 부분이다. 이 부분에 대해서는 아래에서 자세히 다룰 예정이다.

 

2. 유저 접속 처리

public void OnNewClient(Socket client_socket, object event_args)
{
    //jdUserToken은 유저가 연결 됐을 때 해당 유저의 소켓을 저장하고,
    //메시지를 주고 받을 때 사용하는 기능들을 담고 있다.
    jdUserToken token 			= new jdUserToken();
    token.Init();

    //jdUser객체는 db에서 가져온 데이트를 저장하는 객체이다. 말 그대로 접속한 유저의 정보를 가지고 있다
    jdUser user  				= UserPool.Instance.Pop();
    //jdUserToken을 set한다.
    user.Init(token);
    token.User					= user;


    //아까 위에서 생성한 토근에, 연결된 유저 소켓을 연결한다.
    user.UserToken.Socket 		= client_socket;

    //연결된 소켓의 옵션을 설정한다.
    user.UserToken.Socket.NoDelay = true;
    user.UserToken.Socket.ReceiveTimeout = 60 * 1000;
    user.UserToken.Socket.SendTimeout = 60 * 1000;

    //연결된 소켓으로부터 메시지를 받기 시작한다.
    user.UserToken.StartReceive();


    //로직을 담당하는 스레드로 넘긴다
    //이부분은 무시해도 된다.
    UserManager.Instance.AddCommonRequestData(new CommonRequestData(CommonRequestType.REQUEST_USER_MANAGER_ENTER_NEW_USER,
                                                                    user,
                                                                    token));


}

 

 위의 함수는 서버에 접속 요청이 들어왔을 때, onAcceptComplected 콜백함수가 발생하면서, 그에 대한 처리를 하는 부분이다. 연결이 성공하면, 해당 유저와의 소켓 생기는데, 앞으로 이 소켓으로 서버와 클라가 1:1로 패킷을 주고 받게 된다.

 jdUserToken에서는 이 소켓 객체의 레퍼런스를 가지고, 패킷을 전송하고 받는 로직을 가지고 있다. 이부분은 이전 포스팅에서 클라이언트에서 서버로 패킷을 보내고, 받는 부분과 동일하다.

 

3. 응답 처리(jdUserToken객체)

아래 부분 [jdUserToken]에서 메모리릭 문제가 있어, 해당 부분을 변경했습니다. 아래 링크를 참고해 주세요.

2021.03.20 - [게임을 만들자/게임 서버(C#)] - c#, SocketAsyncEventArgs 메모리 릭 현상

 

 

public class jdUserToken
{
    jdUser m_user; //유저 정보 저장
    SocketAsyncEventArgs m_receive_event_args; //메시지 받을 때 사용
    jdMessageResolver m_message_resolver; //받은 데이터를 jdPacket객체로 만들 때 
    Socket m_socket; // 연결된 소켓

    List<jdPacket> m_packet_list = new List<jdPacket>(5);
    object m_mutext_packet_list = new object();


    public jdUserToken()
    {
        //받은 byte배열의 데이터를 jdPacket으로 만드는 부분
        //이전 포스팅 참고
        m_message_resolver = new jdMessageResolver();
    }


    public void Init()
    {
        m_receive_event_args = new SocketAsyncEventArgs();
        m_receive_event_args.Completed += onReceiveComplected;
        m_receive_event_args.UserToken = this;

        //byte배열을 크게 미리 세팅하고, 그 배열을 재사용한다.
        //args.SetBuffer(m_buffer, m_current_index, m_buffer_size);
        //아래 따로 코트 첨부
        BufferManager.Instance.SetBuffer(m_receive_event_args);
    }



    

    public void StartReceive()
    {
        //유저로 부터 패킷이 오는 것을 기다린다.
        //패킷이 오면, onReceiveComplected콜백 함수 호출
        //onReceiveComplected에서 jdMessageResolver를 통해, 전송된 byte배열을 jdPacket으로 만든다.
        bool pending = Socket.ReceiveAsync(m_receive_event_args);
        if (!pending)
            onReceiveComplected(this, m_receive_event_args);
    }

    public void Send(jdPacket packet)
    {
        //서버에서 유저로 즉 클라이언트 패킷을 전송하는 경우
        //SocketAsyncEventArgs를 재사용하기 위해 풀을 만들어서 사용
        SocketAsyncEventArgs send_event_args    = SocketAsyncEventArgsPool.Instance.Pop();
        if(send_event_args == null)
        {
            LogManager.Debug("SocketAsyncEventArgsPool::Pop() result is null");
            return;
        }

        //전송이 완료 됐을 때, onSendComplected콜백함수가 호출 된다.
        send_event_args.Completed               += onSendComplected;
        send_event_args.UserToken               = this;

        //전송할 패킷 데이터를 버퍼에 저장
        byte[] send_data = packet.GetSendBytes();
        send_event_args.SetBuffer(send_data,0,send_data.Length);

        //비동기 함수로 전송
        bool pending = Socket.SendAsync(send_event_args);
        if (!pending)
            onSendComplected(null, send_event_args);	

    }

    //유저로부터 패킷이 전송 됐을 때 호출
    void onReceiveComplected(object sender, SocketAsyncEventArgs e)
    {
        // check if the remote host closed the connection
        if (e.BytesTransferred > 0 && e.SocketError == SocketError.Success)
        {
            //정상적인 메시지가 전송 됐을 때,
            //read message
            m_message_resolver.OnReceive(e.Buffer, e.Offset, e.BytesTransferred, onMessageComplected);

            //receive message
            StartReceive();
        }
        else
        {
            // 잘못된 메시지가 오거나, 연결이 끊겼을 때
            //유저 종료 처리
            ....
        }
    }

    void onSendComplected(object sender, SocketAsyncEventArgs e)
    {
        if(e.SocketError == SocketError.Success)
        {
            //전송 성공
        }
        else
        {
            //전송 실패
        }
        e.Completed -= onSendComplected;

        //사용한 SocketAsyncEventArgs 객체를 다시 풀에 넣는다.
        SocketAsyncEventArgsPool.Instance.Push(e);
    }

    //jdMessageResolver에서 byte[]배열로 jdPacket을 완성 했을 때, 발생
    void onMessageComplected(jdPacket packet)
    {
        //완성된 패킷을 리스트에 넣는다.
        AddPacket(packet);
    }
    
    public void AddPacket(jdPacket packet)
    {
        //jdMessageResolver에서 만들어진 패킷을 리스트에 넣는다.
        //처리 부분과 패킷을 넣는 부분의 스레드가 다르다. 때문에 락을 걸어준다.
        lock(m_mutext_packet_list)
        {
            m_packet_list.Add(packet);
        }
    }
    
    
    

    //다른 스레드에서 호출된다.
    //때문에 패킷 리스트에 넣고, 처리하기 전에 락을 걸어준다.
    public void Update()
    {

        //완성된 패킷을 매 루프 처리해 준다.
        if(m_packet_list.Count > 0)
        {
            lock (m_mutext_packet_list)
            {
                try
                {
                    foreach (jdPacket packet in m_packet_list)
                        m_user.ProcessPacket(packet);
                    m_packet_list.Clear();
                }
                catch (Exception e)
                {
                    //잘못된 패킷이 들어온 경우 처리
                    //
                }
            }
        }
    }
    
    
    //종료
    public void Close()
    {
        try{
            //소켓 종료
            if (Socket != null)
                Socket.Shutdown(SocketShutdown.Both);
        }catch(Exception e)
        {
            LogManager.Debug(e.ToString());
        }
        finally
        {
            if(Socket != null)
                Socket.Close();
        }


        Socket = null;
        User = null;
        m_message_resolver.ClearBuffer();


        //버퍼를 재사용하도록, 버퍼를 비워준다.
        BufferManager.Instance.FreeBuffer(m_receive_event_args);

        if (m_receive_event_args != null)
            m_receive_event_args.Dispose();
        m_receive_event_args = null;
    }
}

 

 코드가 길어보이지만, Init()부터 살펴보자. Init함수에서 메시지를 받을 때 사용할 SocketAsyncEventArgs초기화 해준다. BufferManager라는 곳에서 이 객체에서 사용할 메모리를 설정해 준다. 

StartReceive함수에서 유저의 데이터 전송을 기다린다. 전송된 데이터가 생기면, onReceiveComplected콜백함수가 호출되고, jdMessageResolver객체로 byte배열의 데이터를 넘긴다. 그리고 다시  StartReceive함수를 호출해 다음 데이터를 기다린다.

 jdMessageResolver 객체는 이전 포스팅에서 살펴본 것과 동일하다. jdPacket이 완성되면 onMessageComplected를 호출하고, 패킷을 리스트에 넣는다.

 Update함수는 다른 스레드에서 매 틱마다 호출한다. 그리고 완성된 패킷이 있으면, 해당 패킷을 처리하고, 리스트를 비워준다. 앞서 말했듯이 본인은 [게임 로직, db, 접속, http통신]등으로 스레드를 생성해서 쓰고 있는데, Update함수는 로직을 담당하는 스레드에서 호출한다.

Send함수는 클라이언트 부분과 동일하다. jdPacket을 byte 배열로 만들고, 이것을 비동기함수로 전송한다.

 

2019/12/17 - [게임을 만들자/C# 서버] - c# 실시간 게임 서버 만들기 1 - 패킷

2019/12/19 - [게임을 만들자/C# 서버] - c# 실시간 게임 서버 만들기 2 - 클라이언트

여기까지 간단하게 클라-서버 간 패킷을 주고 받는 것에 대해 살펴봤다. 다음 포스팅에서 어떤 구조로 게임을 설계했는지 살펴보도록 하자.

ps. BufferManger 코드

using System;
using System.Collections.Generic;
using System.Net.Sockets;

public class BufferManager : Singleton<BufferManager>
{
    int             m_num_bytes;                // 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_free_index_pool; 
    int             m_current_index;
    int             m_buffer_size;

    public BufferManager()
    {}

    override protected void init()
    {
       //전체 버퍼 크기
        m_num_bytes         = Constants.MAX_CONNECTION*jdCommonDefine.SOCKET_BUFFER_SIZE*2;
        m_current_index     = 0;
        
        //버터 하나당 크기
        m_buffer_size       = jdCommonDefine.SOCKET_BUFFER_SIZE;
        m_free_index_pool   = new Stack<int>();
        m_buffer            = new byte[m_num_bytes];
    }

    public bool SetBuffer(SocketAsyncEventArgs args)
    {
        if (m_free_index_pool.Count > 0)
        {
            args.SetBuffer(m_buffer, m_free_index_pool.Pop(), m_buffer_size);
        }
        else
        {
            if (m_num_bytes < (m_current_index + m_buffer_size))
            {
                return false;
            }
            args.SetBuffer(m_buffer, m_current_index, m_buffer_size);
            m_current_index += m_buffer_size;
        }
        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)
    {
        if (args == null)
            return;
        m_free_index_pool.Push(args.Offset);
        args.SetBuffer(null, 0, 0);
    }
}
이세계 용병 온라인

댓글을 달아 주세요

  1. 뿡뿡이

    좋은 글 감사합니다 ^^
    가능하시면 예제 파일도 Github에 올려주세요 ^^;;

    그리고 Array.copy를 BlockCopy로 교체하시면 성능이 10%정도 상승합니다^^
    (제꺼 프로젝트 기준...)