Game Development, 게임개발/게임네트워크

Socket으로 통신 라이브러리 만들기

게임이 더 좋아 2021. 10. 2. 20:21
반응형
728x170

**읽고 외운다기보다 그냥 이런 식으로 진행되며

각 라인을 의미를 파악하는 것이 중요

 

라이브러리를 만드는 이유는 간단하다.

지속적으로 통신을 유지하기 위함이다.

 

통신의 기본은 접속, 송신, 수신, 접속 종료로 나눌 수 있다.

이를 실행하려면 Socket 클래스의 인스턴스를 참조해야 하므로

송수신이 이루어지는 곳마다 Socket 클래스의 인스턴스를 참조하는 코드를 작성해야 한다.

 

하지만 게임 프로그램 전체로 보면 Socket 클래스의 인스턴스는

게임 처리와 별도로 관리하고 싶다는 생각이 든다.

게임 프로그램에는 다른 단말과 통신만 하면 되는 것이지

굳이 Socket 클래스의 인스턴스를 다뤄야 하는가? 라는 의문이 생긴다.

 

그래서 모든 통신 방식이 비슷하다면 게임마다 따로 작성할 필요 없이

라이브러리에서 가져와 활용하는 것이 좋다고 판단된다.

그래서 게임 프로그램에서 Socket 클래스의 인스턴스를 숨기고

통신의 기본적인 처리를 제공하는 라이브러리를 만들어 볼까한다.

 


 

API, Application Programming Interface를 살펴보자

Socket 클래스를 래핑하는 간단한 라이브러리부터 시작해보자

게임 프로그램과 통신 라이브러리 간에 인터페이스가 무엇이 필요한가?

1. 대기시작, 대기 종료

2. 접속, 접속 종료

3. 송신, 수신

즉, 이와 같은 처리를 하는 함수인 Socket 클래스의 인스턴스를 외부에서 참조할 수 없게 작성해보자

 


 

우선 대기 시작, 종료부터 만들어보자

 

대기하는 Bind 함수, Listen 함수에는 각각 엔드포인트와 최대 접속 수가 필요하다.

엔드 포인트에는 IP주소와 Port 번호가 필요하다.

여기서 Bind 함수에서 할당할 IP 주소는 IPAddress.Any를 사용하므로

게임 어플리케이션에서는 포트 번호와 접속할 수 있는 클라이언트 수를 지정한다.

 

//대기 시작
public bool StartServer(int port, int connectionNum);

//대기 종료
public bool StopServer();

Close로 대기 종료를 하도록 만든다.

 


 

그 다음은 접속과 접속 종료다

 

Socket 클래스에서 Connect 함수로 접속한다.

Connect 함수는 리모트 단말의 엔드 포인트가 필요하다.

플레이어에게서 접속할 리모트 단말의 IP 주소를 입력받도록 하고 IP 주소를 문자열로 받게 하자

 

// 접속
public bool Connect(string address, int port);

//접속 종료
public bool Disconnect();

 

ShutDown(TCP에서) 함수, Close 함수로 접속을 종료하게 한다.

접속을 종료할 때 각각의 함수를 호출하므로 게임 어플리케이션에다 접속 종료를 요청하면 된다.

 


 

다음은 송신과 수신이다.

 

Socket 클래스에서 TCP 통신에선 Send와 Receive 함수를 사용한다.

UDP에서는 SendTo 와 ReceiveFrom을 사용한다.

 

//송신
public int Send(byte[] data, int size);

//수신
public int Receive(ref byte[] buffer, int size);

 

위의 인터페이스로 Socket 클래스를 wrapping한다.

 


 

게임에서 Socket 클래스를 직접 다룬다면 

상대의 접속이나 통신 중에 발생한 오류 이벤트를 게임 프로그램 자체에서

검출해서 대처할 수 있도록 해야 한다.

 

하지만 Socket 클래스를 은닉해서

게임 프로그램에서는 Socket 클래스를 참조하지 못해서 접속이나 오류를 검출할 수 없다.

그렇게 되면 라이브러리에서 이벤트를 검출해서 게임 프로그램에게 알려주도록 해야 한다.

델리게이트를 사용하여 접속, 접속 종료, 오류 발생 이벤트를 게임 프로그램에 알려주도록 한다.

 

** C#의 Delegate를 사용한다. 메서드를 참조한다고 생각하면 되고 이벤트와 많이 사용된다.

함숫값의 형과 인수의 파라미터가 일치하는 함수를 등록하여

등록한 함수의 파라미터를 전달해서 호출하는 방식이다.

 

**여기서 일어날 이벤트는 뭐 접속, 접속 종료, 송신 오류, 수신 오류 정도가 있겠다.

 

 

이와 같은 이벤트가 발생했다면 게임프로그램에게 알려야 한다.

public enum NetEventType
{
    Connect = 0,
    Disconnect,
    SendError,
    ReceiveError,
}

 

이벤트 결과를 아래와 같이 정의한다.

// 이벤트 결과.
public enum NetEventResult
{
	Failure = -1,	// 실패.
	Success = 0,	// 성공.
}

// 이벤트 상태 통지.
public class NetEventState
{
    public NetEventType     type;	// 이벤트 타입.
    public NetEventResult   result;	// 이벤트 결과.
}

 

Delegate를 이용해서 이 정보를 전달하도록 만든다.

public delegate void 	EventHandler(NetEventState state);

private EventHandler	m_handler;

 

즉, 이 EventHandler라는 delegate 변수가

state라는 객체를 받겠다.

 

게임 어플리케이션에서 델리게이트를 등록하여 이벤트가 발생할 때 호출되도록 한다.

RegisterEventHandler 함수와 UnregisterEventHandler 함수로

게임 어플리케이션에서 호출할 이벤트 함수를 delegate의 변수 m_handler에 등록하고 삭제하자.

 

 

이벤트가 발생했을 때 이벤트 함수를 호출할 수 있도록 해보자

예를 들어 접속이 되었다면

델리게이트에 등록한 함수를 호출하여 이벤트를 알려야 한다.

 

TCP 통신 서버에 클라이언트가 접속했다하면

접속 이벤트를 알려보자

Access 함수로부터 처리가 돌아오면 클라이언트와 통신하는 새로운 Socket 클래스의 인스턴스를 받고

Accept 함수에서 인스턴스를 가져왔다면 클라이언트가 접속했다는 뜻이므로

접속 이벤트를 발생시킨다.

NetEventType.Connect를 델리게이트에 건내줌으로써 어플리케이션에 접속 이벤트를 알리는 것이다.

void AcceptClient()
{
    if(m_listener != null && m_listener.Poll(0, SelectMode.SelectRead))
    {
        //클라이언트 접속 (서버가 클라이언트의 요청을 받음, Accept())
        m_socket = m_listener.Accept(); // Accept함수는 socket 인스턴스 반환
        m_isConnected = true;
        
        //이벤트 발생
        if(m_handler != null)
        {
            NetEventState state = new NetEventState(); //이벤트 상태 인스턴스 생성
            state.type = NetEventType.Connect; // 해당 타입의 attribute 설정
            state.result = NetEventResult.Success;
                m_handler(state); // 인스턴스 전달
        }
    }
}

 


 

게임 어플리케이션에서 통신을 처리하면 게임 자체를 처리하는데 부하가 생긴다.

그래서 통신을 하다가 게임이 버벅거리기도 한다. 

통신쪽 부하가 높아져 수신 버퍼가 처리하는 것보다 많아져 넘치게 되면 패킷이 유실되기도 한다.

그래서 통신을 게임과 별도로 스레드로 실행하는 경우가 있다.

메인 스레드에 영향을 주지 않고 통신하는 방법이다.

스레드가 어려워보이지만

통신 처리를 스레드로 만드는 작업은 그다지 어렵지 않다.

메인 스레드와 통신 스레드는 데이터를 주고받는 버퍼를 공유할 뿐이다.

이 버퍼에 접근할 때만 주의하면

메인 스레드는 통신 스레드에서 이루어지는 데이터 송수신 타이밍에 신경쓰지 않고 게임을 처리하면 된다.

 

버퍼에 접근할 때는 배타 제어를 해야한다.

수신한 데이터는 큐로 관리한다.

통신 스레드에서는 수신 버퍼에 들어온 데이터를 곧바로 큐로 옮기고

메인 스레드가 수신한 데이터를 사용할 때는 큐의 맨 앞의 데이터를 사용하면 된다.

 

즉, 통신 스레드에서는 수신 버퍼를 처음에 받아서 큐로 옮기고

메인 스레드에서는 수신 버퍼를 건드리는 대신 통신 스레드가 옮겨놓은 큐에 접근해서 데이터를 처리한다.

 

각 스레드에서 비동기로 큐에 접근하면 타이밍에 따라서는 엉망으로 작동할 수 있기 때문에

lock을 이용해서 exclusive control을 하게 한다.

즉, 어떤 스레드가 실행 중에서는 다른 스레드가 해당 자원을 접근하지 못하게 하는 것이다.

 

멀티 스레드라고 락을 사용해서 다른 스레드의 실행 타이밍을 고려하지 않고 큐에 접근할 수 있다.

**락으로 멀티 스레드를 편리하게 제어할 수는 있지만 락은 최대한 지양해야 한다.

 

이처럼 스레드를 다중으로 이용할 경우에는 배타 제어를 확실히 해야 한다.

락을 사용해서 큐에 접근을 하게 한다면 통신 스레드와 메인 스레드로 나누어 데이터를 송수신 하므로

게임과 데이터 송수신에 대해 독립해서 실행이 가능해서

게임쪽 부하가 높더라도 통신 스레드는 독립되어 있으므로

데이터를 정기적으로 가져다 주는 작업을 할 수 있다.

 


 

이러한 패킷 큐는 어떻게 구현하느냐?

 

//데이터 추가
public int Enqueue(byte[] data, int size);

//데이터 반환
public int Dequeue(ref byte[] data, int size);

 

송수신할 byte[] 형 데이터는 MemoryStream 클래스에서 버퍼링한다.

 

1. 패킷을 통신 스레드에서 받아서 메모리 스트리밍을 이용하여 큐에 추가시키고

2. 메인스레드에서는 메모리 스트림 클래스를 이용해 큐의 맨 앞부터 가져온다.

 

MemoryStream 클래스는 데이터의 끊김이 없어서 패킷으로는 다룰 수 없고

큐에 추가하는 패킷의 크기와 저장 장소를 나타내는 오프셋으로 구조체로 만든 패킷 정보를 별도로 관리한다.

패킷 정보는 List 클래스로 관리하고 패킷이 추가되면 리스트의 맨 끝에 패킷 정보를 추가한다.

 

큐에서 패킷을 추출할 때는 앞에서부터 가져온다.

패킷 정보를 앞에서부터 꺼내고 그 패킷 크기만큼의 데이터를 MemoryStream 에서 가져오는 것이다.

 

public class PacketQueue
{
    //패킷 저장 정보
    struct PacketInfo
    {
        public int offset; // 저장장소를 나타냄
        public int size; // 크기
    };
    
    //데이터를 보존할 버퍼
    private MemoryStream m_streamBuffer;
    
    //패킷 정보 관리 리스트
    private List<PacketInfo> m_offsetList;
    
    //메모리 배치 오프셋
    private int m_offset = 0;
    
    //락 오브젝트
    private Object lockObj = new Object();
    
    //생성자 (버퍼 생성, 패킷 정보를 담을 리스트 생성)
    public PacketQueue()
    {
        m_streamBuffer = new MemoryStream();
        m_offsetList = new List<PacketInfo>();
    }
    
   // 큐를 추가-> 패킷 인포 구조체를 만들고 m_offset을 이용해 저장장소 갱신
	public int Enqueue(byte[] data, int size)
	{
		PacketInfo	info = new PacketInfo();
	
		info.offset = m_offset;
		info.size = size;
			
		lock (lockObj) {
			// 패킷 저장 정보를 보존.
			m_offsetList.Add(info);
			
			// 패킷 데이터를 보존.
			m_streamBuffer.Position = m_offset;
			m_streamBuffer.Write(data, 0, size);
			m_streamBuffer.Flush();
			m_offset += size; // 패킷의 저장위치 갱신
		}
		
		return size; // 패킷 크기 반환
	}
	
	// 큐를 꺼냅니다.
	public int Dequeue(ref byte[] buffer, int size) {
		
        //꺼낼 패킷이 없다면
		if (m_offsetList.Count <= 0) {
			return -1;
		}
        
		int recvSize = 0;
		lock (lockObj) {	
			PacketInfo info = m_offsetList[0]; // 맨 앞에 있는 패킷의 주소 가져옴.
		
			// 버퍼로부터 해당하는 패킷 데이터를 가져옴
			int dataSize = Math.Min(size, info.size);
			m_streamBuffer.Position = info.offset; // 맨 앞의 주소 가져옴
			recvSize = m_streamBuffer.Read(buffer, 0, dataSize); // 해당 버퍼에 있는 패킷 읽음
			
			// 큐 데이터를 꺼냈으므로 선두 요소 삭제.
			if (recvSize > 0) {
				m_offsetList.RemoveAt(0);
			}
			
			// 모든 큐 데이터를 꺼냈을 때는 스트림을 클리어해서 메모리를 절약
			if (m_offsetList.Count == 0) {
				Clear();
				m_offset = 0;
			}
		}
		
		return recvSize; // 패킷 반환
	}

	// 큐를 클리어
	public void Clear()
	{
		byte[] buffer = m_streamBuffer.GetBuffer();
		Array.Clear(buffer, 0, buffer.Length);
		
		m_streamBuffer.Position = 0;
		m_streamBuffer.SetLength(0);
	}
}

 

패킷 큐를 송신용과 수신용으로 각각 준비해서

메인 스레드와 통신 스레드가 사용한다.

 


 

통신스레드에서 실행할 처리와 스레드를 시작하는 방법을 알아보자

통신 스레드는 메인 스레드로부터 송신 데이터가 등록되면 Socket 클래스의 Send 함수로 데이터를 송신한다.

그 다음 Receive 함수를 사용하여 수신 데이터를 Socket 클래스의 수신 버퍼에서 메인 스레드와 주고 받는 큐로 옮긴다.

이 과정을 스레드가 종료할 때까지 지속한다.

이 루프는 연속으로 실행하면 다른 스레드로 처리 제어가 되지 않으므로

일정 시간마다 스레드를 실행할 수 있도록 한다.

 

// 스레드 측의 송수신 처리.
    public void Dispatch()
	{
		Debug.Log("Dispatch thread started.");

		while (m_threadLoop) {
			// 클라이언트로부터의 접속을 대기 
			AcceptClient();

			// 클라이언트와의 송수신을 처리
			if (m_socket != null && m_isConnected == true) {

	            // 송신처리.
	            DispatchSend();

	            // 수신처리.
	            DispatchReceive();
	        }

			Thread.Sleep(5); //5ms 대기
		}

		Debug.Log("Dispatch thread ended.");
    }
    
    
    // 스레드 측 송신처리(클라이언트에게 송신)
    void DispatchSend()
	{
        try {
            // 송신처리.
            if (m_socket.Poll(0, SelectMode.SelectWrite)) {
				byte[] buffer = new byte[s_mtu]; // 버퍼를 만듬

                int sendSize = m_sendQueue.Dequeue(ref buffer, buffer.Length); // 패킷 큐에서 통신스레드로 가져올 것
                //클라이언트에게 보냄 (패킷 큐에 남아있으면)
                while (sendSize > 0) {
                    m_socket.Send(buffer, sendSize, SocketFlags.None); // 클라이언트에게 송신
                    sendSize = m_sendQueue.Dequeue(ref buffer, buffer.Length); // 패킷 큐에서 다 꺼낼 때까지 반복
                }
            }
        }
        catch {
            return;
        }
    }

	// 스레드 측의 수신처리.
    void DispatchReceive()
	{
        // 수신처리.
        try {
            while (m_socket.Poll(0, SelectMode.SelectRead)) {
				byte[] buffer = new byte[s_mtu];
				//클라이언트에게서 데이터를 받아옴.
                int recvSize = m_socket.Receive(buffer, buffer.Length, SocketFlags.None);
                //받을 데이터가 없으면
                if (recvSize == 0) {
                    // 끊기.
                    Debug.Log("Disconnect recv from client.");
                    Disconnect();
                }
                else if (recvSize > 0) {
                	//받은 데이터를 패킷 큐에 넣음
                    m_recvQueue.Enqueue(buffer, recvSize);
                }
            }
        }
        catch {
            return;
        }
    }

 


 

통신 스레드 시작은 아래와 같이 한다.

// 스레드 실행 함수.
	bool LaunchThread()
	{
		try {
			// Dispatch용 스레드 시작.
			m_threadLoop = true;
			m_thread = new Thread(new ThreadStart(Dispatch));
			m_thread.Start();
		}
		catch {
			Debug.Log("Cannot launch thread.");
			return false;
		}
		
		return true;
	}

 

 

 


 

다음은

서버에서의 대기 시작과 종료를 알아보자

// 대기 시작.
	public bool StartServer(int port, int connectionNum)
	{
        Debug.Log("StartServer called.!");

        // 리스닝 소켓을 생성
        try {
			// 소켓을 생성
			m_listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
			// 사용할 포트 번호를 할당
			m_listener.Bind(new IPEndPoint(IPAddress.Any, port));
			// 대기
			m_listener.Listen(connectionNum);
        }
        catch {
			Debug.Log("StartServer fail");
            return false;
        }

        m_isServer = true;

        return LaunchThread(); // 스레드 실행
    }

	// 대기
    public void StopServer()
    {
		m_threadLoop = false; // 통신 스레드 종료
        if (m_thread != null) {
            m_thread.Join();
            m_thread = null;
        }

        Disconnect();

        if (m_listener != null) {
            m_listener.Close();
            m_listener = null;
        }

        m_isServer = false; //대기 종료

        Debug.Log("Server stopped.");
    }

대기 중인 서버에 접속하는 Connect 함수는 Socket 클래스의 Connect 함수를 호출하는 래퍼함수임

Connect 함수가 블로킹되는 상태에서 사용되므로 접속 처리가 간단.

 

통신을 끊을 때는 Disconnect 함수 호출

Disconnect는 함수로 통신을 차단하고 소켓을 close함

 


 

다음은 접속과 접속 종료다.

 // 접속.
    public bool Connect(string address, int port)
    {
        Debug.Log("TransportTCP connect called.");

        if (m_listener != null) {
            return false;
        }
		//접속해서 각 값 할당
        try {
            m_socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            m_socket.NoDelay = true;
            m_socket.SendBufferSize = 0;
            m_socket.Connect(address, port);
			ret = LaunchThread();
		}
        catch {
            m_socket = null;
        }
		//이벤트 결과 알림
        if (m_handler != null) {
            // 접속 결과를 통지합니다. 
			NetEventState state = new NetEventState();
			state.type = NetEventType.Connect;
			state.result = (m_isConnected == true) ? NetEventResult.Success : NetEventResult.Failure;
            m_handler(state);
			Debug.Log("event handler called");
        }

        return m_isConnected;
    }
    
    
    // 끊기.
    public void Disconnect() {
        m_isConnected = false;

        if (m_socket != null) {
            // 소켓 클로즈.
            m_socket.Shutdown(SocketShutdown.Both);
            m_socket.Close();
            m_socket = null;
        }

        // 끊기를 통지합니다.
        if (m_handler != null) {
			NetEventState state = new NetEventState();
			state.type = NetEventType.Disconnect;
			state.result = NetEventResult.Success;
			m_handler(state);
        }
    }

통신용 스레드에서 데이터를 송수신함

패킷큐에 데이터 등록하는 Send

패킷큐에서 데이터 가져오는 Receive

 


 

다음으로는 송수신에 대해서다.

// 송신처리.
    public int Send(byte[] data, int size)
	{
		if (m_sendQueue == null) {
			return 0;
		}

        return m_sendQueue.Enqueue(data, size);
    }

    // 수신처리.
    public int Receive(ref byte[] buffer, int size)
	{
		if (m_recvQueue == null) {
			return 0;
		}

        return m_recvQueue.Dequeue(ref buffer, size);
    }

 

마지막으로 이벤트를 처리하는 기능이다.

	public delegate void 	EventHandler(NetEventState state);

	private EventHandler	m_handler;
    
    ...
    
    ...
    
    
    // 이벤트 통지함수 등록.
    public void RegisterEventHandler(EventHandler handler)
    {
        m_handler += handler;
    }

	// 이벤트 통지함수 삭제.
    public void UnregisterEventHandler(EventHandler handler)
    {
        m_handler -= handler;
    }

 

위의 Disconnect( )함수를 보면 이벤트를 알리는 과정도 나와있다.

 

728x90
반응형
그리드형