[C#/Unity] 게임 서버 #5 - 네트워크 기초, 통신 모델, 소켓 프로그래밍

Donghee·2024년 8월 31일
0

본 게시물은 Rookiss님의 '[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버' 강의를 듣고 정리한 내용임을 미리 알립니다.

네트워크 기초

이제 본격적인 서버를 만들어볼건데, 먼저 간단한 네트워크의 구조를 살펴보자. 빨간색 컴퓨터가 우리가 쓰는 PC, 즉 단말이라고 생각하자. 같은 네트워크 안에 있는 단말들은 스위치를 통해 통신을 진행한다. 한 단말이 같은 네트워크의 다른 단말에게 통신을 요청하면, 우선 스위치가 그 요청을 받아 네트워크 안에 그 단말이 있는지 확인하며 연결해주는 역할을 한다. 외부에 있는 네트워크로 통신을 요청할 때는 어떻게 이루어질까?

그때에는 스위치가 라우터에게 요청해 외부 네트워크에게 연결해달라고 요청한다. 그림을 보면 알겠지만 IP의 마지막 영역뿐만 아니라 앞의 영역도 서로 다른 것을 볼 수 있다. 라우터는 다시 도착하는 스위치에 통신을 요청하고, 이번에는 반대로 스위치가 통신 대상 단말을 찾아 전달한다.

통신 모델

통신은 여러 단계로 구분되어, 일종의 가이드 역할을 하는 모델이 존재한다. 우리가 게임 서버를 0부터 구축하기 위해서는, 모델의 단계에 따라 방식을 정해가며 만들어야한다. 다음 사진을 보자.

왼쪽은 TCP/IP 모델, 오른쪽은 OSI 7계층 모델이다. 모두 통신 모델의 종류지만, TCP/IP 모델은 실제 통신에 활용되는 모델이고, OSI 7계층 모델은 이론적으로 역할을 이해하기 쉽게 구분해놓은 모델이라고 생각하면 된다.

이 중 TCP/IP 모델을 살펴보자.

어플리케이션

어플리케이션은 유저 인터페이스를 정의하는 단계다. 통신의 유저 인터페이스라고 하면 다소 생소하지만, 예를 들어보자. 웹 통신을 하기 위해선 HTTP/HTTPS 프로토콜을 사용한다. 혹은 파일 전송 통신을 하기 위해서는 FTP 프로토콜을 사용한다. (프로토콜은 통신을 진행하는 규칙이라고 생각하자)
즉, 어플리케이션 단계는 처음 통신을 진행할 때 통신되는 정보를 포장하는 방식(포맷팅, 데이터 생성 등)을 정하는 단계이다. 우리가 MMORPG 서버를 구현한다면 별도의 프로토콜을 정의해서 사용한다고 한다.

트랜스포트

트랜스포트는 어플리케이션 단계에서 포장된 데이터를 어떤 식으로 전송할지, 전송의 속도나 신뢰성, 오류 처리 방식 등이 정해지는 단계이다. 대표적으로 TCP와 UDP 프로토콜이 존재한다. 간단하게 설명하면, TCP는 확실하게 전송을 보장하는 대신 속도가 느리고, UDP는 뭉탱이로 빠르게 전송하는 대신 전송 순서나 오류 발생 시의 신뢰성이 없다.
게임에서는 장르에 따라 활용하는 프로토콜이 다른데, 순서가 확실하게 보장되어야하는 MMORPG같은 경우는 TCP, 일단 빠르게 정보들이 실시간 갱신되어야하는 FPS같은 경우는 UDP를 사용하는 경우가 많다고 한다. 물론 각자의 게임에 맞게 별도로 커스텀된 것을 사용하는 경우도 많아 참고만 하도록 하자.

네트워크

네트워크는 트랜스포트에서 전송 방식까지 정해진 통신의 경로를 설정하는 단계이다. 이에는 IPv4, IPv6같은 프로토콜이 존재한다. 이 프로토콜은 각 단말의 주소를 정의해주고, 데이터가 대상의 주소까지 가게끔 최적의 경로를 설정해준다. 이 단계에서는 라우터가 관여한다.

데이터 링크

네트워크 단계까지 수행해서 데이터가 통신 대상의 네트워크까지 전송이 되었다면, 데이터 링크 단계를 통해 네트워크 내에서의 경로를 설정해줘야 한다. 다시 말하면 네트워크 단계는 네트워크끼리의 경로를, 데이터 링크 단계는 네트워크 내에서의 경로를 설정해주는 것이다. 주요 프로토콜로는 이더넷(Ethernet), PPP가 존재한다. 네트워크 내의 통신에 관여하는 단계이므로, 위에서 본 것처럼 스위치가 관여한다.

피지컬

데이터 링크단계까지 수행하면, 원하는 단말까지의 경로가 다 설정된 것이라고 보면 된다. 이제 피지컬 단계를 통해 물리적인 신호로 변환해 데이터를 전달한다. 여기에는 허브, 케이블(선) 등이 관여한다.

OSI 7계층 모델

OSI 7계층 모델은 TCP/IP 모델에서 어플리케이션 단계를 더 세분화한 것으로 생각하면 된다. 우리가 실질적으로 받아들이는 도메인은 7계층 주소, 네트워크 단계에서 사용하는 IP는 3계층 주소, 데이터 링크 단계에서 사용하는 단말의 고유 주소인 MAC은 2계층 주소로 불린다. 또한 3계층(네트워크 단계)에 관여하는 3계층 장치(주로 라우터)와 2계층(데이터 링크 단계)에 관여하는 2계층 장치(주로 스위치)가 존재한다는 사실을 기억하자.

또한, 결국 여러 계층을 거치며 다양한 통신 정보들이 데이터에 덕지덕지 붙어서 전송이 된다는 사실을 기억하자. 우리의 입장에서는 10바이트 데이터를 전송해도, 전송되는 입장에서는 더 많은 정보가 전달되는 것이다. 따라서 한 패킷(데이터 전송 단위)에 많은 정보를 붙여서 보내는 것이 이득이다.

소켓 프로그래밍

우리는 소켓을 이용해 통신을 진행할 것이다. 소켓은 프로그램이 통신할 수 있게 하는 일종의 통로이다. 소켓을 통해 데이터를 송신하고, 또 수신받는다. 간단하게 서버-클라이언트 소켓 통신의 과정을 살펴보자.

클라이언트 입장


먼저 클라이언트 입장에서의 통신 과정이다. 소켓 하나를 준비해 서버 주소로 Connect 요청을 하면, 서버가 그에 대한 응답을 해준다. 이후 클라이언트의 소켓은 서버에서 제공해준 Session 소켓을 통해 패킷의 송수신이 가능하다.

서버 입장


서버 입장에서의 통신 과정이다. 서버는 클라이언트의 Connect 요청을 받는 Listener 소켓을 하나 준비한다. 그리고 이 소켓에 주소/포트 등을 연동하는 Bind 과정을 진행한다. 이후부터는 클라이언트의 Connect 요청을 Listen한다. 만약 접속이 가능해 Accept 응답을 날리면, 클라이언트에게 Session 소켓을 부여한다.

구현

C# 코드로 구현해보자.

서버

namespace ServerCore
{
    class Program
    {
        static void Main(string[] args)
        {
            // 호스트 이름을 가져와서 IP 주소를 가져옴
            string host = Dns.GetHostName();
            IPHostEntry ipHost = Dns.GetHostEntry(host);
            IPAddress ipAddr = ipHost.AddressList[0];
            IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);

            // Listen 소켓 생성
            Socket listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);

            try
            {
                // 주소와 포트 바인딩
                listenSocket.Bind(endPoint);

                // backlog(대기열) 설정
                listenSocket.Listen(10);

                while (true)
                {
                    Console.WriteLine("Listening...");

                    // Accept를 통해 클라이언트가 접속하면 새로운 소켓을 생성
                    Socket clientSocket = listenSocket.Accept();

                    // 클라이언트에게서 데이터를 받음
                    byte[] recvBuff = new byte[1024];
                    int recvBytes = clientSocket.Receive(recvBuff);
                    // 받은 데이터를 UTF8로 디코딩
                    string recvData = Encoding.UTF8.GetString(recvBuff, 0, recvBytes);

                    Console.WriteLine($"[From Client] {recvData}");

                    // 클라이언트에게 데이터를 보냄(UTF8로 인코딩)
                    byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to Server!");
                    clientSocket.Send(sendBuff);

                    // 소켓을 닫음
                    clientSocket.Shutdown(SocketShutdown.Both);
                    clientSocket.Close();
                }
            }
            catch(Exception e)
            {
                Console.WriteLine(e.ToString());
            }
        }
    }
}

주석을 보면 알겠지만, 위에 설명한 과정을 간단하게 재현한 것이다. 추가된 점은 Dns 서버를 이용해 호스트의 주소를 가져와 Listen 소켓을 생성한다는 것이다. 이후엔 바인딩을 진행하고, Listen 명령을 내린다. 그 후 Accept을 통해 클라이언트가 들어왔다면 연결을 허용한다. Accept 메소드는 블로킹 함수, 즉 클라이언트가 들어오기 전까지 그 라인에서 기다리는 함수이기 때문에 클라이언트가 들어오지 않는다면 여기서 대기한다. 이후 데이터를 받고, UTF-8로 디코딩한다. 이후 Send 함수를 통해 인코딩한 문자열을 전송하고 소켓을 닫는다.

클라이언트

namespace DummyClient
{
    class Program
    {
        static void Main(string[] args)
        {
            string host = Dns.GetHostName();
            IPHostEntry ipHost = Dns.GetHostEntry(host);
            IPAddress ipAddr = ipHost.AddressList[0];
            IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);

            // 클라이언트 소켓 생성
            Socket socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);

            try
            {
                socket.Connect(endPoint);
                Console.WriteLine($"Connected To {socket.RemoteEndPoint.ToString()}");

                // 데이터 송수신
                byte[] sendBuff = Encoding.UTF8.GetBytes("Hello World!");
                socket.Send(sendBuff);

                byte[] recvBuff = new byte[1024];
                int recvBytes = socket.Receive(recvBuff);
                string recvData = Encoding.UTF8.GetString(recvBuff, 0, recvBytes);

                socket.Shutdown(SocketShutdown.Both);
                socket.Close();
            }
            catch (Exception e)
            {
                Console.WriteLine(e.ToString());
            }
        }
    }
}

클라이언트도 Connect를 통해 서버에 입장 시도한다는 점을 제외하면 서버와 크게 다르지 않다. 서버와 동일하게 소켓을 생성해 연결하고, 데이터를 송신 후 수신한다.

클라이언트와 서버의 실행 결과는 각각 위와 같다. 이 구현에서 쓰인 Receive, Accept 메소드들은 블로킹 함수이므로, 계속 서버가 굴러가야하는 게임 서버에는 적절하지 않다. 이는 단순 예시로만 생각하자.

Listener

여기서 Listen을 수행하는 Listener 소켓을 서버쪽에서 따로 클래스를 분리해 구현해보자.

namespace ServerCore
{
    public class Listener
    {
        Socket _listenSocket;
        // 클라이언트가 접속했을 때 호출할 콜백 함수
        Action<Socket> _onAcceptHandler;

        public void Init(IPEndPoint endPoint, Action<Socket> onAcceptHandler)
        {
            _listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
            _onAcceptHandler += onAcceptHandler;

            _listenSocket.Bind(endPoint);

            _listenSocket.Listen(10);

            // RegisterAccept가 끝나면 OnAcceptCompleted가 호출됨
            SocketAsyncEventArgs args = new SocketAsyncEventArgs();
            args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted);
            RegisterAccept(args);
        }

        void RegisterAccept(SocketAsyncEventArgs args)
        {
            args.AcceptSocket = null;

            bool pending = _listenSocket.AcceptAsync(args);

            // 동기적으로 완료되었을 때(즉시 완료되었을 때)
            if (pending == false)
            {
                OnAcceptCompleted(null, args);
            }
        }

        void OnAcceptCompleted(object sender, SocketAsyncEventArgs args)
        {
            // 새로운 클라이언트가 접속했을 때
            if (args.SocketError == SocketError.Success)
            {
                _onAcceptHandler.Invoke(args.AcceptSocket);
            }
            else
            {
                Console.WriteLine(args.SocketError.ToString());
            }

            // 다음 클라이언트 접속을 받기 위해 다시 Accept를 호출
            RegisterAccept(args);
        }
    }
}

여기서 중요한 점은 Accept를 Async로, 즉 비동기로 처리한다는 점이다. 천천히 살펴보자.
처음 Listener 클래스를 생성하고 Init을 해주면, 소켓을 하나 생성한다. 이후 _onAcceptHandler 액션에 접속되었다면 호출할 행동을 바인딩하고, 소켓 바인딩을 진행한다. 이후 RegisterAccept을 진행하는데, 여기에는 SocketAsyncEventArgs가 인자로 들어간다. 이 클래스는 소켓의 비동기 동작들의 상황을 표현해주는 클래스로, 우리는 SocketAsyncEventArgs.Completed라는 이벤트에 비동기 행위가 끝났을 시의 동작을 추가해줄 것이다. 이로 인해 AcceptAsync가 아무리 늦게 끝나도, 우리는 콜백 호출로 OnAcceptCompleted가 실행돼 클라이언트 접속 시의 상황을 제어할 수 있게 되었다.

namespace ServerCore
{
    class Program
    {
        static Listener _listener = new Listener();

        static void OnAcceptHandler(Socket clientSocket) 
        {
            try
            {
                // 클라이언트에게서 데이터를 받음
                byte[] recvBuff = new byte[1024];
                int recvBytes = clientSocket.Receive(recvBuff);
                // 받은 데이터를 UTF8로 디코딩
                string recvData = Encoding.UTF8.GetString(recvBuff, 0, recvBytes);

                Console.WriteLine($"[From Client] {recvData}");

                // 클라이언트에게 데이터를 보냄(UTF8로 인코딩)
                byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to Server!");
                clientSocket.Send(sendBuff);

                // 소켓을 닫음
                clientSocket.Shutdown(SocketShutdown.Both);
                clientSocket.Close();
            }
            catch(Exception e)
            {
                Console.WriteLine(e.ToString());
            }
        }

        static void Main(string[] args)
        {
            // 호스트 이름을 가져와서 IP 주소를 가져옴
            string host = Dns.GetHostName();
            IPHostEntry ipHost = Dns.GetHostEntry(host);
            IPAddress ipAddr = ipHost.AddressList[0];
            IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);

            _listener.Init(endPoint, OnAcceptHandler);

            while (true)
            {
                ;
            }
        }
    }
}

서버 쪽 메인 클래스도 이와 같이 바꿨다. 전에는 listenSocket을 직접 생성해주었지만, 이젠 Listener 클래스를 활용해 Listen을 그쪽에게 온전히 맡기고, 여기서는 클라이언트 접속 시 행위만 지정해주어 전달해준다.

profile
마포고개발짱

0개의 댓글