[ 섹션 3. 패킷 직렬화 ]

# Serialization #1

- 직렬화란 객체를 저장 가능한 상태 또는 전송 가능한 상태로 변환하는 것을 뜻한다.

 ~> 즉, 패킷 직렬화란 메모리 상에 존재하는 데이터를 패킷에 차곡차곡 쌓은 뒤 이를 하나의 바이트 배열로 만드는 것이다.

- 역직렬화란 특정 포맷 상태의 데이터를 다시 객체로 변환하는 것을 뜻한다.

- Session은 추후에 다양하게 존재할 수 있기 때문에 Session의 이름을 정확하게 지어주는 것이 중요하다.

  (예를 들어 분산서버인 경우 각 다른 부분을 관리하는 서버의 대리자 역할을 하는 Session이 여러개 존재한다.)

- 우선 Serialization 의 흐름만 이해한 뒤 추후에 자동화 할 예정이다.

DummyClient에 ServerSession Class 생성 후 DummyClient의 Program Class 와 내용 분리
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace DummyClient
{
    class Packet
    {
        public ushort size; // ushort는 2Byte
        public ushort packetId; // ushort는 2Byte
    }

    class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것
    {
        public long playerId;
    }

    class PlayerInfoOk : Packet // Server에서 Client로 요청에 대한 답변을 전달하는 것
    {
        public int hp;
        public int attack;
    }

    public enum PacketID
    {
        PlayerInfoReq = 1,
        PlayerInfoOk = 2,
    }

    class ServerSession : Session
    {
        public override void OnConnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnConnected : {endPoint}");

            PlayerInfoReq packet = new PlayerInfoReq() { packetId = (ushort)PacketID.PlayerInfoReq, playerId = 1001 };

            for (int i = 0; i < 5; i++)
            {
                ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);

                // 아래 부분 추가 (2번의 단계를 거쳐야 하는 것을 TryWriteBytes를 통해 1번의 단계를 거치도록 수정)
                bool success = true;
                ushort count = 0; // 지금까지 몇 Byte를 Buffer에 밀어 넣었는가?
                count += 2;
                success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), packet.packetId);
                count += 2;
                success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), packet.playerId);
                count += 8;

                success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count), count); // size는 모든 작업이 끝난 뒤 초기화

                // 아래 부분 삭제
                //byte[] size = BitConverter.GetBytes(packet.size); 
                //byte[] packetId = BitConverter.GetBytes(packet.packetId); 
                //byte[] playerId = BitConverter.GetBytes(packet.playerId);
                //Array.Copy(size, 0, openSegment.Array, openSegment.Offset + count, 2);
                //count += 2;
                //Array.Copy(packetId, 0, openSegment.Array, openSegment.Offset + count, 2);
                //count += 2;
                //Array.Copy(playerId, 0, openSegment.Array, openSegment.Offset + count, 8);
                //count += 8;

                ArraySegment<byte> sendBuff = SendBufferHelper.Close(count);

                if (success) // success시 Send
                    Send(sendBuff);
            }
        }

        public override void OnDisconnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnDisconnected : {endPoint}");
        }

        public override int OnRecv(ArraySegment<byte> buffer)
        {
            string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
            Console.WriteLine($"[From Server] {recvData}");
            return buffer.Count;
        }

        public override void OnSend(int numOfBytes)
        {
            Console.WriteLine($"Transferred bytes : {numOfBytes}");
        }
    }
}​

 

수정된 DummyClient의 Program Class
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using ServerCore; // Servercore 라이브러리 참조

namespace DummyClient
{
    class Program
    {
        static void Main(string[] args)
        {
            // DNS (Domain Name System) : Domain을 IP 네트워크에서 찾아갈 수 있는 IP로 변환해 준다. 
            string host = Dns.GetHostName(); // Local Computer의 host 이름을 반환
            IPHostEntry ipHost = Dns.GetHostEntry(host);
            IPAddress ipAddr = ipHost.AddressList[0]; // ip 주소를 배열로 반환 (예를 들어 Google과 같이 Traffic이 어마무시한 사이트는 여러개의 ip 주소를 가질 수 있기 때문)
            IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // ip 주소와 port 번호를 매개변수로 입력

            Connector connector = new Connector();
            connector.Connect(endPoint, () => { return new ServerSession(); });

            while (true)
            {
                try
                {

                }
                catch (Exception e)
                {
                    Console.WriteLine(e.ToString());
                }

                Thread.Sleep(100);
            }
        }
    }
}​

 

Server에 ClientSession Class 생성 후 Server의 Program Class 와 내용 분리
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace Server
{
    class Packet // 패킷 설계시 최대한 Size를 압축하여 보내는 것이 중요하다.
    {
        // Packet이 완전체로 왔는지? 잘려서 왔는지? 구분할 수 있어야 한다.
        public ushort size; // ushort는 2Byte
        public ushort packetId; // ushort는 2Byte
    }

    class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것
    {
        public long playerId;
    }

    class PlayerInfoOk : Packet // Server에서 Client로 요청에 대한 답변을 전달하는 것
    {
        public int hp;
        public int attack;
    }

    public enum PacketID
    {
        PlayerInfoReq = 1,
        PlayerInfoOk = 2,
    }

    class ClientSession : PacketSession
    {
        public override void OnConnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnConnected : {endPoint}");

            Packet packet = new Packet() { size = 100, packetId = 10 };

            ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
            byte[] buffer = BitConverter.GetBytes(packet.size);
            byte[] buffer2 = BitConverter.GetBytes(packet.packetId);
            // 어느 배열의? 어디서부터? 어느 배열의? 어디로? 얼마만큼?
            Array.Copy(buffer, 0, openSegment.Array, openSegment.Offset, buffer.Length);
            Array.Copy(buffer2, 0, openSegment.Array, buffer.Length, buffer2.Length);
            ArraySegment<byte> sendBuff = SendBufferHelper.Close(buffer.Length + buffer2.Length);

            Send(sendBuff);

            Thread.Sleep(5000);

            Disconnect();
        }

        // OnRecvPacket 코드 수정
        public override void OnRecvPacket(ArraySegment<byte> buffer)
        {
            ushort count = 0; // 지금까지 몇 Byte를 Buffer에 밀어 넣었는가?
            ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count); // ToUInt16은 Byte 배열을 ushort로 뽑아달라는 것
            count += 2;
            ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count); // ToUInt16은 Byte 배열을 ushort로 뽑아달라는 것
            count += 2;

            switch ((PacketID)id)
            {
                case PacketID.PlayerInfoReq:
                    {
                        long playerId = BitConverter.ToInt64(buffer.Array, buffer.Offset + count); // ToUInt16은 Byte 배열을 long으로 뽑아달라는 것
                        count += 8;
                        Console.WriteLine($"PlayerInfoReq: {playerId}");
                    }
                    break;
            }

            Console.WriteLine($"RecvPacketID: {id}, Size: {size}");
        }

        public override void OnDisconnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnDisconnected : {endPoint}");
        }

        public override void OnSend(int numOfBytes)
        {
            Console.WriteLine($"Transferred bytes : {numOfBytes}");
        }
    }
}​

 

수정된 Server의 Program Class
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;

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

        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);

            // 손님을 입장시킨다.
            _listener.Init(endPoint, () => { return new ClientSession(); });
            Console.WriteLine("Listening...");

            while (true)
            {

            }
        }
    }
}​

 

# Serialization #2

- 자동화 하기에 앞서 인터페이스를 통한 코드 수정을 할 예정이다.

ServerSession Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace DummyClient
{
    public abstract class Packet
    {
        public ushort size;
        public ushort packetId;

        // 최상위 Class인 Packet에 인터페이스 생성
        public abstract ArraySegment<byte> Write();
        public abstract void Read(ArraySegment<byte> s);
    }

    class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것
    {
        public long playerId;

        // 생성자
        public PlayerInfoReq()
        {
            this.packetId = (ushort)PacketID.PlayerInfoReq;
        }

        // 인터페이스 구현
        public override ArraySegment<byte> Write()
        {
            ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);

            bool success = true;
            ushort count = 0; // 지금까지 몇 Byte를 Buffer에 밀어 넣었는가?
            count += 2;
            success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), this.packetId);
            count += 2;
            success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), this.playerId);
            count += 8;

            success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count), count); // size는 모든 작업이 끝난 뒤 초기화

            if (success == false)
                return null;

            return SendBufferHelper.Close(count);
        }

        // 인터페이스 구현
        public override void Read(ArraySegment<byte> s)
        {
            ushort count = 0;
            //ushort size = BitConverter.ToUInt16(s.Array, s.Offset + count); ~> 사용할 일이 없어 필요 X
            count += 2;
            //ushort id = BitConverter.ToUInt16(s.Array, s.Offset + count); ~> Read를 실행했다는 것은 이미 패킷 분해 후 id에 대한 정보를 얻은 뒤이므로 필요 X
            count += 2;

            // 수정 부분 (Client가 악의적으로 잘못된 Packet Size를 보낸 경우를 방지하기 위함)
            this.playerId = BitConverter.ToInt64(new ReadOnlySpan<byte>(s.Array, s.Offset + count, s.Count - count));
            count += 8;
        }
    }

    // PlayerInfoOk는 추후에 구현 예정
    //class PlayerInfoOk : Packet // Server에서 Client로 요청에 대한 답변을 전달하는 것
    //{
    //    public int hp;
    //    public int attack;
    //}

    // ...

    class ServerSession : Session
    {
        public override void OnConnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnConnected : {endPoint}");

            PlayerInfoReq packet = new PlayerInfoReq() { playerId = 1001 };

            for (int i = 0; i < 5; i++)
            {
                ArraySegment<byte> s = packet.Write(); // 직렬화

                if (s != null) // success시 Send
                    Send(s);
            }
        }

        // ...
    }
}​

 

ClientSession Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace Server
{
    public abstract class Packet
    {
        public ushort size;
        public ushort packetId;

        // 최상위 Class인 Packet에 인터페이스 생성
        public abstract ArraySegment<byte> Write();
        public abstract void Read(ArraySegment<byte> s);
    }

    class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것
    {
        public long playerId;

        // 생성자
        public PlayerInfoReq()
        {
            this.packetId = (ushort)PacketID.PlayerInfoReq;
        }

        // 인터페이스 구현
        public override ArraySegment<byte> Write()
        {
            ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);

            bool success = true;
            ushort count = 0; // 지금까지 몇 Byte를 Buffer에 밀어 넣었는가?
            count += 2;
            success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), this.packetId);
            count += 2;
            success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), this.playerId);
            count += 8;

            success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count), count); // size는 모든 작업이 끝난 뒤 초기화

            if (success == false)
                return null;

            return SendBufferHelper.Close(count);
        }

        // 인터페이스 구현
        public override void Read(ArraySegment<byte> s)
        {
            ushort count = 0;
            //ushort size = BitConverter.ToUInt16(s.Array, s.Offset + count); ~> 사용할 일이 없어 필요 X
            count += 2;
            //ushort id = BitConverter.ToUInt16(s.Array, s.Offset + count); ~> Read를 실행했다는 것은 이미 패킷 분해 후 id에 대한 정보를 얻은 뒤이므로 필요 X
            count += 2;

            // 수정 부분 (Client가 악의적으로 잘못된 Packet Size를 보낸 경우를 방지하기 위함)
            this.playerId = BitConverter.ToInt64(new ReadOnlySpan<byte>(s.Array, s.Offset + count, s.Count - count));
            count += 8;
        }
    }

    // PlayerInfoOk는 추후에 구현 예정
    //class PlayerInfoOk : Packet // Server에서 Client로 요청에 대한 답변을 전달하는 것
    //{
    //    public int hp;
    //    public int attack;
    //}

    // ...

    class ClientSession : PacketSession 
    {
        // ...

        public override void OnRecvPacket(ArraySegment<byte> buffer)
        {
            // 패킷을 분해하여 id 에 대한 정보를 얻은 뒤
            ushort count = 0;
            ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
            count += 2;
            ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
            count += 2;

            // 해당 id 에 맞는 코드를 실행
            switch ((PacketID)id)
            {
                case PacketID.PlayerInfoReq:
                    {
                        PlayerInfoReq p = new PlayerInfoReq();
                        p.Read(buffer); // 역직렬화
                        Console.WriteLine($"PlayerInfoReq: {p.playerId}");
                    }
                    break;
            }

            Console.WriteLine($"RecvPacketID: {id}, Size: {size}");
        }

        // ...
    }
}​

 

# UTF-8 vs UTF-16

 

- 컴퓨터가 세상에 처음 등장할 당시에는 영어와 몇가지 특수문자만을 사용하였고, 이를 저장하기 위해 1Byte로 충분했다.

 ~> ASCII 코드 (1Byte) 등장

 

- 그러나 인터넷 시대 도입 후 언어의 다양성으로 인하여 1Byte 만으로는 모든 나라의 언어를 표현할 수 없다.

 ~> UNICODE (2Byte) 등장

 

- Encoding은 컴퓨터에서 문자와 기호를 표현하기 위해 문자를 이진 데이터로 변환하는 과정이며 문자와 이진 데이터 간의

  Mapping 규칙을 정의하는 방법이다.

 ~> Variable-Width Encoding (가변 너비 인코딩) : UTF-8, UTF-16

 ~> Fixed-Length Encoding (고정 길이 인코딩) : UTF-32

 

- UTF-8

 ~> 영문 : 1Byte

 ~> 한글 : 3Byte

 

- UTF-16

 ~> BMP X : 2Byte

 ~> BMP O : 4Byte

 ~> 영문 : 2Byte

 ~> 한글 : 2Byte

 

# Serialization #3

- 데이터의 길이가 가변적인 String은 어떻게 처리해야 할까?

String 처리를 위한 ServerSession Class 와 ClientSession Class 수정
(아래 코드는 ServerSession Class지만 ClientSession Class도 동일한 코드로 수정)
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace DummyClient
{
    public abstract class Packet
    {
        public ushort size;
        public ushort packetId;

        public abstract ArraySegment<byte> Write();
        public abstract void Read(ArraySegment<byte> openSegment);
    }

    class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것
    {
        public long playerId;
        public string name; // 가변 길이의 멤버 변수는 어떻게 처리?

        // 생성자
        public PlayerInfoReq()
        {
            this.packetId = (ushort)PacketID.PlayerInfoReq;
        }

        public override ArraySegment<byte> Write()
        {
            ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);

            bool success = true;
            ushort count = 0;

            Span<byte> span = new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);

            count += sizeof(ushort);
            success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), this.packetId); // Slice는 실질적으로 Span에 변화를 주지 X
            count += sizeof(ushort);
            success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), this.playerId); // Slice는 실질적으로 Span에 변화를 주지 X
            count += sizeof(long);

            // string 처리 #1 (Buffer에 2Byte인 string len을 먼저 삽입 후 string data 삽입)
            //ushort nameLen = (ushort)Encoding.Unicode.GetByteCount(this.name); // GetByteCount()는 UTF-16 기준의 byte 배열 크기를 반환
            //success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), nameLen); // Slice는 실질적으로 Span에 변화를 주지 X
            //count += sizeof(ushort);
            //Array.Copy(Encoding.Unicode.GetBytes(this.name), 0, openSegment.Array, count, nameLen); // GetBytes()는 string을 받아 Byte 배열로 변환
            //count += nameLen;

            // string 처리 #2 (Buffer에 2Byte인 string len을 위한 공간을 남겨둔 채로 string data를 먼저 삽입 후 string len 삽입) 
            ushort nameLen = (ushort)Encoding.Unicode.GetBytes(this.name, 0, name.Length, openSegment.Array, openSegment.Offset + count + sizeof(ushort)); // Buffer에 string data를 삽입함과 동시에 string len을 반환
            success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), nameLen);
            count += sizeof(ushort);
            count += nameLen;

            success &= BitConverter.TryWriteBytes(span, count); // size는 모든 작업이 끝난 뒤 초기화

            if (success == false)
                return null;

            return SendBufferHelper.Close(count);
        }

        public override void Read(ArraySegment<byte> openSegment)
        {
            ushort count = 0;

            ReadOnlySpan<byte> span = new ReadOnlySpan<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);

            count += sizeof(ushort);
            count += sizeof(ushort);
            this.playerId = BitConverter.ToInt64(span.Slice(count, span.Length - count)); // Slice는 실질적으로 Span에 변화를 주지 X
            count += sizeof(long);

            // string 처리
            ushort nameLen = BitConverter.ToUInt16(span.Slice(count, span.Length - count));
            count += sizeof(ushort);
            this.name = Encoding.Unicode.GetString(span.Slice(count, nameLen)); // GetString()는 Byte 배열을 받아 string으로 변환
            count += nameLen;
        }
    }

    // ...
}​

 

# Serialization #4

- 데이터의 길이가 가변적인 List는 어떻게 처리해야 할까?

List 처리를 위한 ServerSession Class 와 ClientSession Class 수정
(아래 코드는 ServerSession Class지만 ClientSession Class도 동일한 코드로 수정)
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace DummyClient
{
    public abstract class Packet
    {
        public ushort size;
        public ushort packetId;

        public abstract ArraySegment<byte> Write();
        public abstract void Read(ArraySegment<byte> openSegment);
    }

    class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것
    {
        public long playerId;
        public string name; 

        public struct SkillInfo
        {
            public int id;
            public short level;
            public float duration;

            public bool Write(Span<byte> span, ref ushort count) // span은 전체 Byte 배열을, count는 실시간으로 현재 어느 곳을 작업하는지
            {
                bool success = true;
                success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), id);
                count += sizeof(int);
                success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), level);
                count += sizeof(short);
                success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), duration);
                count += sizeof(float);

                return success;
            }

            public void Read(ReadOnlySpan<byte> span, ref ushort count)
            {
                id = BitConverter.ToInt32(span.Slice(count, span.Length - count));
                count += sizeof(int);
                level = BitConverter.ToInt16(span.Slice(count, span.Length - count));
                count += sizeof(short);
                duration = BitConverter.ToSingle(span.Slice(count, span.Length - count));
                count += sizeof(float);
            }
        }

        public List<SkillInfo> skills = new List<SkillInfo>(); // 가변 길이의 멤버 변수는 어떻게 처리?

        // 생성자
        public PlayerInfoReq()
        {
            this.packetId = (ushort)PacketID.PlayerInfoReq;
        }

        public override ArraySegment<byte> Write()
        {
            ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);

            bool success = true;
            ushort count = 0;

            Span<byte> span = new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);

            count += sizeof(ushort);
            success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), this.packetId); 
            count += sizeof(ushort);
            success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), this.playerId); 
            count += sizeof(long);

            // string 처리
            ushort nameLen = (ushort)Encoding.Unicode.GetBytes(this.name, 0, name.Length, openSegment.Array, openSegment.Offset + count + sizeof(ushort)); // Buffer에 string data를 삽입함과 동시에 string len을 반환
            success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), nameLen);
            count += sizeof(ushort);
            count += nameLen;

            // list 처리
            success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), (ushort)skills.Count);
            count += sizeof(ushort);
            foreach (SkillInfo skill in skills)
                success &= skill.Write(span, ref count);

            success &= BitConverter.TryWriteBytes(span, count); // size는 모든 작업이 끝난 뒤 초기화

            if (success == false)
                return null;

            return SendBufferHelper.Close(count);
        }

        public override void Read(ArraySegment<byte> openSegment)
        {
            ushort count = 0;

            ReadOnlySpan<byte> span = new ReadOnlySpan<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);

            count += sizeof(ushort);
            count += sizeof(ushort);
            this.playerId = BitConverter.ToInt64(span.Slice(count, span.Length - count)); 
            count += sizeof(long);

            // string 처리
            ushort nameLen = BitConverter.ToUInt16(span.Slice(count, span.Length - count));
            count += sizeof(ushort);
            this.name = Encoding.Unicode.GetString(span.Slice(count, nameLen));
            count += nameLen;

            // list 처리
            skills.Clear();
            ushort skillLen = BitConverter.ToUInt16(span.Slice(count, span.Length - count));
            count += sizeof(ushort);  
            for (int i = 0; i < skillLen; i++)
            {
                SkillInfo skill = new SkillInfo();
                skill.Read(span, ref count);
                skills.Add(skill);
            }
        }
    }

    // ...
}​

 

# Packet Generator #1

- [ 솔루션 ] - [ 오른쪽 마우스 ] - [ 추가 ] - [ 새 솔루션 폴더 ] 를 통해 폴더를 추가할 수 있다.

- [ 프로젝트 ] - [ 오른쪽 마우스 ] - [ 추가 ] - [ 새 항목 ] 의 [ C# 항목 ] - [ 데이터 ] 에서 XML 파일을 추가할 수 있다.

 ~> 생성된 XML 파일은 [ 프로젝트 ] - [ 오른쪽 마우스 ] - [ 파일 탐색기에서 폴더 열기 ] - [ bin ] - [ Debug ] - [ net ] 안에

      위치하도록 한다. (즉, 해당 프로젝트의 실행파일이 있는 곳에 위치하도록)

- 패킷의 정의를 어떤 방식으로 할지 결정해야 한다. (JSON, XML, 자체 정의 IDL) 

 ~> XML이 JSON에 비해 Hierarchy가 잘 보인다는 장점을 가지므로 XML을 사용할 것이다.

 ~> XML에서 정보는 시작 Tag와 끝 Tag 사이에 담겨지며, Tag는 쌍을 이룬다.

     (Tag 사이에 삽입할 정보가 없는 경우 시작 Tag 끝에 /를 추가하여 끝 Tag 생략 가능)

- List를 제외한 나머지 부분들을 자동화하기 위한 Template을 만들고자 한다.

PacketGenerator 프로젝트 생성 후 PDL XML 파일 생성
<?xml version="1.0" encoding="utf-8" ?>
<PDL>
	<packet name="PlayerInfoReq">
		<long name="playerId"/>
		<string name="name"/>
		<list name="skill">
			<int name="id"/>
			<short name="level"/>
			<float name="duration"/>
		</list>
	</packet>
</PDL>​

 

PacketGenerator 코드 수정
using System.Xml;

namespace PacketGenerator
{
    internal class Program
    {
        static void Main(string[] args)
        {
            XmlReaderSettings settings = new XmlReaderSettings()
            {
                IgnoreComments= true, // 주석을 무시
                IgnoreWhitespace = true // 공백을 무시
            };

            // XML 파싱
            using (XmlReader reader = XmlReader.Create("PDL.xml", settings))
            {
                reader.MoveToContent(); // header를 건너뛰고 내용 부분으로 이동
                while (reader.Read()) // 한줄씩 읽어나간다.
                {
                    if (reader.Depth == 1 && reader.NodeType == XmlNodeType.Element) // Element는 시작 Tag, EndElement는 끝 Tag
                        ParsePacket(reader);

                    // Console.WriteLine(reader.Name + " " + reader["name"]); // Name은 Type을 반환, []는 Attribute를 반환
                }
            }

            // reader.Dispose(); ~> using 사용시 using 범위를 벗어날 경우 자동으로 Dispose()를 호출
        }

        public static void ParsePacket(XmlReader reader)
        {
            if (reader.NodeType == XmlNodeType.EndElement)
                return;

            if (reader.Name.ToLower() != "packet")
            {
                Console.WriteLine("Invalid packet node");
                return;
            }

            string packetName = reader["name"];
            if (string.IsNullOrEmpty(packetName) )
            {
                Console.WriteLine("Packet without name");
                return;
            }

            ParseMembers(reader);
        }

        public static void ParseMembers(XmlReader reader)
        {
            string packetName = reader["name"];

            int depth = reader.Depth + 1;
            while(reader.Read())
            {
                if (reader.Depth != depth)
                    break;

                string memberName = reader["name"];
                if (string.IsNullOrEmpty(memberName) )
                {
                    Console.WriteLine("Member without name");
                    return;
                }

                string memberType = reader.Name.ToLower();
                switch (memberType)
                {
                    case "bool":
                    case "byte":
                    case "short":
                    case "ushort":
                    case "int":
                    case "long":
                    case "float":
                    case "double":
                    case "string":
                    case "list":
                        break;
                    default:
                        break;
                }
            }
        }
    }
}​

 

PacketFormat Class 생성
using System;
using System.Collections.Generic;
using System.Text;

namespace PacketGenerator
{
    class PacketFormat
    {
        // 여러줄에 거쳐 문자열을 정의하고 싶은 경우 @"" 
        // 고정적인 부분을 제외한 변경되는 부분을 {}로 표시(일반적인 소괄호는 { { } }로 표시)

        // {0} 패킷 이름
        // {1} 멤버 변수들
        // {2} 멤버 변수 Read
        // {3} 멤버 변수 Write
        public static string packetFormat =
@"
class {0}
{{
    {1}

    public void Read(ArraySegment<byte> openSegment)
    {{
        ushort count = 0;

        ReadOnlySpan<byte> span = new ReadOnlySpan<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);
        count += sizeof(ushort);
        count += sizeof(ushort);

        {2}
    }}

    public ArraySegment<byte> Write()
    {{
        ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);

        bool success = true;
        ushort count = 0;

        Span<byte> span = new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);

        count += sizeof(ushort);
        success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), (ushort)PacketID.{0}); 
        count += sizeof(ushort);

        {3}

        success &= BitConverter.TryWriteBytes(span, count);

        if (success == false)
            return null;

        return SendBufferHelper.Close(count);
    }}
}}
";

        // {0} 변수 형식
        // {1} 변수 이름
        public static string memberFormat =
@"public {0} {1}";

        // {0} 변수 이름
        // {1} To변수형식 (ex : ToInt16, ToInt32, ToSingle ...)
        // {2} 변수 형식
        public static string readFormat =
@"
this.{0} = BitConverter.{1}(span.Slice(count, span.Length - count));
count += sizeof({2});
";

        // {0} 변수 이름
        public static string readStringFormat =
@"
ushort {0}Len = BitConverter.ToUInt16(span.Slice(count, span.Length - count));
count += sizeof(ushort);
this.{0} = Encoding.Unicode.GetString(span.Slice(count, {0}Len));
count += {0}Len;
";

        // {0} 변수 이름
        // {1} 변수 형식
        public static string writeFormat =
@"
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), this.{0}); 
count += sizeof({1});
";

        // {0} 변수 이름
        public static string writeStringFormat =
@"
ushort {0}Len = (ushort)Encoding.Unicode.GetBytes(this.{0}, 0, this.{0}.Length, openSegment.Array, openSegment.Offset + count + sizeof(ushort)); // Buffer에 string data를 삽입함과 동시에 string len을 반환
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), {0}Len);
count += sizeof(ushort);
count += {0}Len;
";
    }
}​

 

+ 추가 검색 (https://velog.io/@mercurios0603/%ED%8C%8C%EC%8B%B1Parsing%EC%9D%B4%EB%9E%80)

 - Parsing은 컴퓨터 과학 및 프로그래밍에서 특정 형식으로 구성된 데이터를 분석하고 그 의미를 이해하는 과정을 의미

 - Parsing은 주로 텍스트 기반 데이터를 해석하거나, 프로그래밍 언어의 소스 코드를 이해하거나, 문서를 구조화하고

   내용을 추출하는 데 사용

 

# Packet Generator #2

- 지난시간 제외한 List 부분을 자동화하기 위한 Template을 만들고, Packet Generator가 잘 실행되는지 확인하고자 한다.

 ~> Packet Generator의 결과를 "GenPackets.cs" 파일에 저장하였다.

 ~> "GenPackets.cs" 파일은 [ 프로젝트 ] - [ 오른쪽 마우스 ] - [ 파일 탐색기에서 폴더 열기 ] - [ bin ] - [ Debug ] - [ net ]

      에서 확인할 수 있다.

PacketFormat Class 수정
using System;
using System.Collections.Generic;
using System.Text;

namespace PacketGenerator
{
    class PacketFormat
    {
        // ...

        // {0} 리스트 이름 [대문자]
        // {1} 리스트 이름 [소문자]
        // {2} 멤버 변수들
        // {3} 멤버 변수 Read
        // {4} 멤버 변수 Write
        public static string memberListFormat =
@"
public struct {0}
{{
    {2}

    public void Read(ReadOnlySpan<byte> span, ref ushort count)
    {{
        {3}
    }}

    public bool Write(Span<byte> span, ref ushort count)
    {{
        bool success = true;
        {4}
        return success;
    }}
}}

public List<{0}> {1}s = new List<{0}>();
";

        // ...

        // {0} 리스트 이름 [대문자]
        // {1} 리스트 이름 [소문자]
        public static string readListFormat =
@"
this.{1}s.Clear();
ushort {1}Len = BitConverter.ToUInt16(span.Slice(count, span.Length - count));
count += sizeof(ushort);  
for (int i = 0; i < {1}Len; i++)
{{
    {0} {1} = new {0}();
    {1}.Read(span, ref count);
    {1}s.Add({1});
}}
";

        // ...

        // {0} 리스트 이름 [대문자]
        // {1} 리스트 이름 [소문자]
        public static string writeListFormat =
@"
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), (ushort)this.{1}s.Count);
count += sizeof(ushort);
foreach ({0} {1} in this.{1}s)
    success &= {1}.Write(span, ref count);
";
    }
}

 

PacketGenerator 코드 수정
using System.Xml;

namespace PacketGenerator
{
    internal class Program
    {
        static string genPackets; // 실시간으로 만들어지는 패킷

        static void Main(string[] args)
        {
            XmlReaderSettings settings = new XmlReaderSettings()
            {
                IgnoreComments= true, // 주석을 무시
                IgnoreWhitespace = true // 공백을 무시
            };

            // XML 파싱
            using (XmlReader reader = XmlReader.Create("PDL.xml", settings))
            {
                reader.MoveToContent(); // header를 건너뛰고 내용 부분으로 이동
                while (reader.Read()) // 한줄씩 읽어나간다.
                {
                    if (reader.Depth == 1 && reader.NodeType == XmlNodeType.Element) // Element는 시작 부분, EndElement는 끝 부분
                        ParsePacket(reader);

                    // Console.WriteLine(reader.Name + " " + reader["name"]); // Name은 Type을 반환, []는 Attribute를 반환
                }
            }

            File.WriteAllText("GenPackets.cs", genPackets); // genPackets의 내용을 통해 GenPackets.cs 파일 생성
        }

        public static void ParsePacket(XmlReader reader)
        {
            if (reader.NodeType == XmlNodeType.EndElement)
                return;

            if (reader.Name.ToLower() != "packet")
            {
                Console.WriteLine("Invalid packet node");
                return;
            }

            string packetName = reader["name"];
            if (string.IsNullOrEmpty(packetName) )
            {
                Console.WriteLine("Packet without name");
                return;
            }

            Tuple<string, string, string> tuple = ParseMembers(reader);
            genPackets += string.Format(PacketFormat.packetFormat,
                packetName, tuple.Item1, tuple.Item2, tuple.Item3);
        }

        // 멤버 변수들, 멤버 변수 Read, 멤버 변수 Write 에 관한 코드를 알맞게 제작한 뒤 이를 string으로 반환
        public static Tuple<string, string, string> ParseMembers(XmlReader reader)
        {
            string packetName = reader["name"];
            string memberCode = "";
            string readCode = "";
            string writeCode = "";

            int depth = reader.Depth + 1;
            while(reader.Read())
            {
                if (reader.Depth != depth)
                    break;

                string memberName = reader["name"];
                if (string.IsNullOrEmpty(memberName) )
                {
                    Console.WriteLine("Member without name");
                    return null;
                }

                if (string.IsNullOrEmpty(memberCode) == false) // 이미 내용이 존재하는 경우
                    memberCode += Environment.NewLine; // Enter를 치는 것과 같은 동작
                if(string.IsNullOrEmpty(readCode) == false) // 이미 내용이 존재하는 경우
                    readCode += Environment.NewLine; // Enter를 치는 것과 같은 동작
                if(string.IsNullOrEmpty(writeCode) == false) // 이미 내용이 존재하는 경우
                    writeCode += Environment.NewLine; // Enter를 치는 것과 같은 동작

                string memberType = reader.Name.ToLower();
                switch (memberType)
                {
                    case "bool":
                    case "short":
                    case "ushort":
                    case "int":
                    case "long":
                    case "float":
                    case "double":
                        memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName);
                        readCode += string.Format(PacketFormat.readFormat, memberName, ToMemberType(memberType), memberType);
                        writeCode += string.Format(PacketFormat.writeFormat, memberName, memberType);
                        break;
                    case "string":
                        memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName);
                        readCode += string.Format(PacketFormat.readStringFormat, memberName);
                        writeCode += string.Format(PacketFormat.writeStringFormat, memberName);
                        break;
                    case "list":
                        Tuple<string, string, string> tuple = ParseList(reader);
                        memberCode += tuple.Item1;
                        readCode += tuple.Item2;
                        writeCode += tuple.Item3;
                        break;
                    default:
                        break;
                }
            }

            // 가독성을 위해 Text를 정렬
            memberCode = memberCode.Replace("\n", "\n\t"); // Enter가 입력된 곳은 Enter 입력 후 Tab 까지 입력되도록 수정
            readCode = readCode.Replace("\n", "\n\t\t");  // Enter가 입력된 곳은 Enter 입력 후 Tab Tab 까지 입력되도록 수정
            writeCode = writeCode.Replace("\n", "\n\t\t");  // Enter가 입력된 곳은 Enter 입력 후 Tab Tab 까지 입력되도록 수정
            return new Tuple<string, string, string>(memberCode, readCode, writeCode);
        }

        public static Tuple<string, string, string> ParseList(XmlReader reader)
        {
            string listName = reader["name"];
            if (string.IsNullOrEmpty(listName))
            {
                Console.WriteLine("List without name");
                return null;
            }

            // memberListFormat의 {2}, {3}, {4}는 순서대로 멤버 변수들, 멤버 변수 Read, 멤버 변수 Write 이므로 ParseMembers() 함수 사용 
            Tuple<string, string, string> tuple = ParseMembers(reader);

            string memberCode = string.Format(PacketFormat.memberListFormat,
                FirstCharToUpper(listName), FirstCharToLower(listName),
                tuple.Item1, tuple.Item2, tuple.Item3);

            string readCode = string.Format(PacketFormat.readListFormat,
                FirstCharToUpper(listName), FirstCharToLower(listName));

            string writeCode = string.Format(PacketFormat.writeListFormat,
                FirstCharToUpper(listName), FirstCharToLower(listName));

            return new Tuple <string, string, string> (memberCode, readCode, writeCode);
        }

        public static string FirstCharToUpper(string input)
        {
            if (string.IsNullOrEmpty(input))
                return "";
            return input[0].ToString().ToUpper() + input.Substring(1);
        }

        public static string FirstCharToLower(string input)
        {
            if (string.IsNullOrEmpty(input))
                return "";
            return input[0].ToString().ToLower() + input.Substring(1);
        }

        public static string ToMemberType(string memberType)
        {
            switch (memberType)
            {
                case "bool":
                    return "ToBoolean";
                case "short":
                    return "ToInt16";
                case "ushort":
                    return "ToUInt16";
                case "int":
                    return "ToInt32";
                case "long":
                    return "ToInt64";
                case "float":
                    return "ToSingle";
                case "double":
                    return "ToDouble";
                default:
                    return "";
            } 
        }
    }
}​

 

# Packet Generator #3

- using 과 enum 및 byte 부분을 자동화하기 위한 Template을 만들고, 패킷이 여러개 존재해도 자동화가 잘 되는지

  확인하고자 한다.

PDL XML 파일 수정 (패킷 및 sbyte 멤버 변수 추가)
<?xml version="1.0" encoding="utf-8" ?>
<PDL>
	<packet name="PlayerInfoReq">
		<sbyte name="testByte"/>
		<long name="playerId"/>
		<string name="name"/>
		<list name="skill">
			<int name="id"/>
			<short name="level"/>
			<float name="duration"/>
		</list>
	</packet>
	<packet name="Test">
		<int name="testInt"/>
	</packet>
</PDL>​

 

PacketFormat Class 수정
using System;
using System.Collections.Generic;
using System.Text;

namespace PacketGenerator
{
    class PacketFormat
    {
        // {0} 패킷 이름/번호 목록
        // {1} 패킷 목록
        public static string fileFormat =
@"
using System;
using System.Collections.Generic;
using System.Text;
using System.Net;
using ServerCore;

public enum PacketID
{{
    {0}
}}

{1}
";
        // {0} 패킷 이름
        // {1} 패킷 번호
        public static string packetEnumFormat =
@"{0} = {1},";

        // ...

        // {0} 변수 이름
        // {1} 변수 형식
        public static string readByteFormat =
@"
this.{0} = ({1})openSegment.Array[openSegment.Offset + count];
count += sizeof({1});
";

        // ...

        // {0} 변수 이름
        // {1} 변수 형식
        public static string writeByteFormat =
@"
openSegment.Array[openSegment.Offset + count] = (byte)this.{0};
count += sizeof({1});
";

        // ...
    }
}​

 

PacketGenerator 코드 수정
using System.Xml;

namespace PacketGenerator
{
    internal class Program
    {
        // ...

        static void Main(string[] args)
        {
            // ...
            
            static ushort packetId; // 몇개의 패킷을 처리하였는지
            static string packetEnums; // Parsing 처리된 패킷의 이름과 번호

            // fileFormat을 통해 using과 enum 추가
            string fileText = string.Format(PacketFormat.fileFormat, packetEnums, genPackets);
            File.WriteAllText("GenPackets.cs", fileText);
        }

        public static void ParsePacket(XmlReader reader)
        {
            // ...

            Tuple<string, string, string> tuple = ParseMembers(reader);

            genPackets += string.Format(PacketFormat.packetFormat,
                packetName, tuple.Item1, tuple.Item2, tuple.Item3);

            // 패킷 1개를 Parsing 할때마다 packetEnums에 Parsing 처리된 패킷의 이름과 번호를 저장
            packetEnums += string.Format(PacketFormat.packetEnumFormat, packetName, ++packetId) + Environment.NewLine + "\t";
        }

        // 멤버 변수들, 멤버 변수 Read, 멤버 변수 Write 에 관한 코드를 알맞게 제작한 뒤 이를 string으로 반환
        public static Tuple<string, string, string> ParseMembers(XmlReader reader)
        {
            string packetName = reader["name"];
            string memberCode = "";
            string readCode = "";
            string writeCode = "";

            int depth = reader.Depth + 1;
            while(reader.Read())
            {
                // ...

                if (string.IsNullOrEmpty(memberCode) == false) // 이미 내용이 존재하는 경우
                    memberCode += Environment.NewLine; // Enter를 치는 것과 같은 동작
                if(string.IsNullOrEmpty(readCode) == false) // 이미 내용이 존재하는 경우
                    readCode += Environment.NewLine; // Enter를 치는 것과 같은 동작
                if(string.IsNullOrEmpty(writeCode) == false) // 이미 내용이 존재하는 경우
                    writeCode += Environment.NewLine; // Enter를 치는 것과 같은 동작

                string memberType = reader.Name.ToLower();
                switch (memberType)
                {
                    case "byte":
                    case "sbyte":
                        memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName);
                        readCode += string.Format(PacketFormat.readByteFormat, memberName, memberType);
                        writeCode += string.Format(PacketFormat.writeByteFormat, memberName, memberType);
                        break;
                    case "bool":
                    case "short":
                    case "ushort":
                    case "int":
                    case "long":
                    case "float":
                    case "double":
                        memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName);
                        readCode += string.Format(PacketFormat.readFormat, memberName, ToMemberType(memberType), memberType);
                        writeCode += string.Format(PacketFormat.writeFormat, memberName, memberType);
                        break;
                    case "string":
                        memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName);
                        readCode += string.Format(PacketFormat.readStringFormat, memberName);
                        writeCode += string.Format(PacketFormat.writeStringFormat, memberName);
                        break;
                    case "list":
                        Tuple<string, string, string> tuple = ParseList(reader);
                        memberCode += tuple.Item1;
                        readCode += tuple.Item2;
                        writeCode += tuple.Item3;
                        break;
                    default:
                        break;
                }
            }

            // ...
        }

        // ...
    }
}​

 

# Packet Generator #4

- Packet Generator의 결과가 저장된 "GenPackets.cs" 파일의 내용을 수동으로 ServerSession 과 ClientSession class에

 추가하였는데 해당 부분을 자동화하기 위한 Template을 만들고자 한다.

- 출력 경로는 [ 프로젝트 ] - [ 오른쪽 마우스 ] - [ 속성 ] - [ 빌드 ] - [ 일반 ] 의 [ 출력 ] 에서 설정 가능하다.

 ~> 그 후 [ 프로젝트 ] - [ 오른쪽 마우스 ] - [ 파일 탐색기에서 폴더 열기 ] 에서

      "프로젝트이름.csproj" 파일을 메모장으로 연 뒤 <PropertyGroup></PropertyGroup> 사이에                         

      <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> 를 추가하고,

      <BaseOutputPath>출력 경로</BaseOutputPath> 를 <OutputPath>출력 경로</OutputPath> 로 수정한다.

 ~> 모든 설정이 끝난 뒤 [ 프로젝트 ] - [ 오른쪽 마우스 ] - [ 빌드 ] 시 설정한 출력 경로에 실행파일이 생성된다.

- 배치 파일은 명령 인터프리터에 의해 실행되게끔 공안된 명령어들이 나열된 텍스트 파일이다.

 ~> .bat 또는 .cmd 형식의 확장자 파일을 직접 실행하거나 명령 프롬프트에서 배치 파일의 이름으로 실행할 수 있다.

 ~> 배치파일을 통해 [ 프로젝트 ] - [ 오른쪽 마우스 ] - [ 파일 탐색기에서 폴더 열기 ] - [ bin ] 내의 프로젝트 실행파일을

      대신 실행한 뒤 실행 파일의 결과에 저장된 내용을 자동으로 복사할 예정이다. (출력 경로를 /bin 으로 설정)

 ~> START는 응용 프로그램 실행 명령어로 실행하고자 하는 파일의 경로와 인자를 넘겨준다.

 ~> XCOPY는 파일 복사 명령어로 복사 대상과 복사 위치를 넘겨준다.

     (/Y 옵션은 같은 파일이 있는 경우 무조건 덮어쓴다는 것이다.)

GenPackets.bat 배치 파일 생성 (배치 파일은 [ 솔루션 ] - [ Common ] - [ Packet ] 내에 존재)

(배치 파일 실행시 PDL.xml을 인자로 넘긴 Packet Generator가 실행되어 DummyClient/Packet 과 Server/Packet 산하의 GenPackets에 Packet Generator의 결과가 저장된 "GenPackets.cs" 파일의 내용이 자동으로 복사된다.)

START ../../PacketGenerator/bin/PacketGenerator.exe ../../PacketGenerator/PDL.xml
XCOPY /Y GenPackets.cs "../../DummyClient/Packet"
XCOPY /Y GenPackets.cs "../../Server/Packet"​

 

PacketGenerator 코드 수정
using System.Xml;

namespace PacketGenerator
{
    internal class Program
    {
        // ...

        static void Main(string[] args)
        {
            string pdlPath = "../PDL.xml"; // bin 폴더가 아닌 PacketGenerator 폴더에 있는 PDL.xml 찾기 위한 것 (실행 파일이 위치한 곳 기준으로 이동)

            // ...

            if (args.Length >= 1) // 프로그램 실행시 인자로 무언가를 넘겨준 경우
                pdlPath = args[0]; // pdlPath를 전달받은 인자로 초기화

            using (XmlReader reader = XmlReader.Create(pdlPath, settings))
            {
                // ...
            }

            // ...
        }

        // ...
    }
}​

 

# Packet Generator #5

- 게임 규모가 커질수록 패킷의 종류는 상당히 많아진다.

 ~> 즉, ClientSession Class의 OnRecvPacket() 함수 안의 switch-case문 길이가 상당히 길어질 수 있다.

- switch-case문 및 OnRecvPacket() 함수 자동화를 위해 모든 패킷들이 base interface를 상속받도록 한다.

 ~> 모든 패킷에 대해 공통 인수로 넘길 수 있기 때문에 편리하다는 장점을 가진다.

PacketFormat Class 수정
using System;
using System.Collections.Generic;
using System.Text;

namespace PacketGenerator
{
    class PacketFormat
    {
        // {0} 패킷 이름/번호 목록
        // {1} 패킷 목록
        public static string fileFormat =
@"
using System;
using System.Collections.Generic;
using System.Text;
using System.Net;
using ServerCore;

public enum PacketID
{{
    {0}
}}

interface IPacket
{{
	ushort Protocol {{ get; }}
	void Read(ArraySegment<byte> openSegment);
	ArraySegment<byte> Write();
}}

{1}
";
        // ...

        // {0} 패킷 이름
        // {1} 멤버 변수들
        // {2} 멤버 변수 Read
        // {3} 멤버 변수 Write
        public static string packetFormat =
@"
public class {0} : IPacket
{{
    {1}

    public ushort Protocol {{ get {{ return (ushort)PacketID.{0}; }} }}

    public void Read(ArraySegment<byte> openSegment)
    {{
        ushort count = 0;

        ReadOnlySpan<byte> span = new ReadOnlySpan<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);
        count += sizeof(ushort);
        count += sizeof(ushort);

        {2}
    }}

    public ArraySegment<byte> Write()
    {{
        ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);

        bool success = true;
        ushort count = 0;

        Span<byte> span = new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);

        count += sizeof(ushort);
        success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), (ushort)PacketID.{0}); 
        count += sizeof(ushort);

        {3}

        success &= BitConverter.TryWriteBytes(span, count);

        if (success == false)
            return null;

        return SendBufferHelper.Close(count);
    }}
}}
";

        // ...
    }
}​

 

DummyClient/Packet 과 Server/Packet 산하에 PacketHandler Class 생성
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;

namespace Server
{
    class PacketHandler 
    {
        // 해당 패킷이 전부 조립된 경우 무엇을 할까?
        // PacketHandler는 자동화 없이 수동으로 추가
        public static void PlayerInfoReqHandler(PacketSession session, IPacket packet)
        {
            PlayerInfoReq p = packet as PlayerInfoReq;

            Console.WriteLine($"PlayerInfoReq: {p.playerId} {p.name}");

            foreach (PlayerInfoReq.Skill skill in p.skills)
            {
                Console.WriteLine($"Skill({skill.id})({skill.level})({skill.duration})");
            }
        }
    }
}​

 

DummyClient/Packet 과 Server/Packet 산하에 PacketManager Class 생성
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;

namespace Server
{
    class PacketManager
    {
        // PacketManager는 Singleton 패턴 사용
        #region Singleton
        static PacketManager _instance;
        public static PacketManager Instance
        {
            get { 
                if (_instance == null)
                    _instance = new PacketManager();
                return _instance; 
            }
        }
        #endregion

        // 구분하기 위한 Protocol ID, 어떤 작업을 수행할지
        Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>> _onRecv = new Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>>();
        // 구분하기 위한 Protocol ID, 어떤 Handler를 호출할지
        Dictionary<ushort, Action<PacketSession, IPacket>> _handler = new Dictionary<ushort, Action<PacketSession, IPacket>>();

        public void Register() // 추후 자동화할 예정
        {
            _onRecv.Add((ushort)PacketID.PlayerInfoReq, MakePacket<PlayerInfoReq>);
            _handler.Add((ushort)PacketID.PlayerInfoReq, PacketHandler.PlayerInfoReqHandler);
        }

        public void OnRecvPacket(PacketSession session, ArraySegment<byte> buffer)
        {
            // 패킷을 분해하여 id 에 대한 정보를 얻은 뒤
            ushort count = 0;
            ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
            count += 2;
            ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
            count += 2;

            // 이젠 switch-case문이 아닌 Dictionary에서 찾아 Invoke()
            Action<PacketSession, ArraySegment<byte>> action = null;
            if (_onRecv.TryGetValue(id, out action))
                action.Invoke(session, buffer);
        }

        void MakePacket<T>(PacketSession session, ArraySegment<byte> buffer) where T : IPacket, new()
        {
            T packet = new T();
            packet.Read(buffer); // 역직렬화

            // Dictionary에서 찾아 Invoke()
            Action<PacketSession, IPacket> action = null;
            if (_handler.TryGetValue(packet.Protocol, out action))
                action.Invoke(session, packet);
        }
    }
}​

 

ClientSession Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace Server
{
    class ClientSession : PacketSession 
    {
        // ...

        public override void OnRecvPacket(ArraySegment<byte> buffer)
        {
            PacketManager.Instance.OnRecvPacket(this, buffer);
        }

        // ...
    }
}​

 

Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;

namespace Server
{
    class Program
    {
        // ...

        static void Main(string[] args)
        {
            // MultiThread가 개입하지 않는 부분에서 실행
            PacketManager.Instance.Register();

            // ...
        }
    }
}​

 

# Packet Generator #6

- PacketManager 를 자동화하기 위한 Template을 만들고자 한다.

 ~> 양방향 패킷은 거의 존재하지 않는다. (대부분 Client에서 Server 또는 Server에서 Client)

 ~> 그러나 분산 Server인 경우 Client와 Server만 소통하는 것이 아닌 Server 끼리도 소통한다.

 ~> 패킷 이름에 규칙을 설정하고 이를 통해 파일을 분리한다.

     (패킷 이름 앞에 C_ 가 붙은 것은 Client에서 Server로, S_ 가 붙은 것은 Server에서 Client로)

     (PacketManager 를 자동화할때 Server 쪽에 추가되는 PacketManager의 Register에는 C_가 붙은 것들만,
      DummyClient 쪽에 추가되는 PacketManager의 Register에는 S_가 붙은 것들만 등록한다.)

     (즉, Register에 온갖 패킷을 등록하는 것이 아닌 필요한 패킷들만 등록한다.)

PDL XML 파일 수정
<?xml version="1.0" encoding="utf-8" ?>
<PDL>
	<packet name="C_PlayerInfoReq">
		<sbyte name="testByte"/>
		<long name="playerId"/>
		<string name="name"/>
		<list name="skill">
			<int name="id"/>
			<short name="level"/>
			<float name="duration"/>
		</list>
	</packet>
	<packet name="Test">
		<int name="testInt"/>
	</packet>
</PDL>​

 

PacketFormat Class 수정
using System;
using System.Collections.Generic;
using System.Text;

namespace PacketGenerator
{
    class PacketFormat
    {
        // {0} 패킷 등록
        public static string managerFormat =
@"
using ServerCore;
using System;
using System.Collections.Generic;

class PacketManager
{{
    #region Singleton
    static PacketManager _instance;
    public static PacketManager Instance
    {{
        get 
        {{
            if (_instance == null)
                _instance = new PacketManager();
            return _instance; 
        }}
    }}
    #endregion

    Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>> _onRecv = new Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>>();
    Dictionary<ushort, Action<PacketSession, IPacket>> _handler = new Dictionary<ushort, Action<PacketSession, IPacket>>();

    public void Register() 
    {{
{0}
    }}

    public void OnRecvPacket(PacketSession session, ArraySegment<byte> buffer)
    {{
        ushort count = 0;
        ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
        count += 2;
        ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
        count += 2;

        Action<PacketSession, ArraySegment<byte>> action = null;
        if (_onRecv.TryGetValue(id, out action))
            action.Invoke(session, buffer);
    }}

    void MakePacket<T>(PacketSession session, ArraySegment<byte> buffer) where T : IPacket, new()
    {{
        T packet = new T();
        packet.Read(buffer);

        Action<PacketSession, IPacket> action = null;
        if (_handler.TryGetValue(packet.Protocol, out action))
            action.Invoke(session, packet);
    }}
}}
";

        // {0} 패킷 이름
        public static string managerRegisterFormat =
@"
        _onRecv.Add((ushort)PacketID.{0}, MakePacket<{0}>);
        _handler.Add((ushort)PacketID.{0}, PacketHandler.{0}Handler);
";

        // ...
    }
}​

 

PacketGenerator 코드 수정
using System.Xml;

namespace PacketGenerator
{
    internal class Program
    {
        // ...

        static string clientRegister;
        static string serverRegister;

        static void Main(string[] args)
        {
            // ...
            
            string clientManagerText = string.Format(PacketFormat.managerFormat, clientRegister);
            File.WriteAllText("ClientPacketManager.cs", clientManagerText);
            string serverManagerText = string.Format(PacketFormat.managerFormat, serverRegister);
            File.WriteAllText("ServerPacketManager.cs", serverManagerText);
        }

        public static void ParsePacket(XmlReader reader)
        {
            // ...
            
            if (packetName.StartsWith("S_") || packetName.StartsWith("s_"));
                clientRegister += string.Format(PacketFormat.managerRegisterFormat, packetName) + Environment.NewLine;
            else
                serverRegister += string.Format(PacketFormat.managerRegisterFormat, packetName) + Environment.NewLine;
        }

        // ...
    }
}​

 

GenPackets.bat 배치 파일 수정
START ../../PacketGenerator/bin/PacketGenerator.exe ../../PacketGenerator/PDL.xml
XCOPY /Y GenPackets.cs "../../DummyClient/Packet"
XCOPY /Y GenPackets.cs "../../Server/Packet"
XCOPY /Y ClientPacketManager.cs "../../DummyClient/Packet"
XCOPY /Y ServerPacketManager.cs "../../Server/Packet"

 

[ 섹션 4. Job Queue ]

# 채팅 테스트 #1

- Server 구현시 대부분 채팅 프로그램을 통해 테스트가 이루어진다.

- 우선 ServerCore에 예외 처리를 하지 않은 부분이 존재하기 때문에 이를 수정할 예정이다.

 ~> Disconnect() 중복 호출은 예방하였으나, Disconnect() 호출시 Send와 Receive는 끊김에 대한 예방이 없다.

     (동시다발적으로 누군가는 Disconnect를 통해 socket을 shutdown하고, 누군가는 Send나 Receive를 호출시 문제 발생)

- 또한 채팅 테스트를 위해 Server 입장에서 코드 추가 및 수정을 할 예정이다.

PDL XML 파일 수정
<?xml version="1.0" encoding="utf-8" ?>
<PDL>
	<packet name="C_Chat">
		<string name="chat"/>
	</packet>
	<packet name="S_Chat">
		<int name="playerId"/>
		<string name ="chat"/>
	</packet>
</PDL>​

 

예외 처리를 위해 Session Class 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;

namespace ServerCore
{
    // ...

    public abstract class Session
    {
        // ...

        void Clear() // _sendQueue 와 _pendingList 를 초기화하기 위한 함수 추가
        {
            lock (_lock)
            {
                _sendQueue.Clear();
                _pendingList.Clear();
            }
        }

        // ...

        public void Disconnect()
        {
            // ...

            Clear(); // _sendQueue 와 _pendingList 를 초기화
        }

        void RegisterSend()
        {
            if (_disconnected == 1) // 최소한의 예방책
                return;

            while (_sendQueue.Count > 0)
            {
                ArraySegment<byte> buff = _sendQueue.Dequeue();
                _pendingList.Add(buff);
            }
            _sendArgs.BufferList = _pendingList;

            // socket을 다루는 부분을 try-catch문으로 감싸준다. (MultiThread 환경을 위한 예방책)
            // ~> 누군가는 위의 if문을 통과하여 아래 부분을 마저 실행하려고 하는 도중에 다른 Thread에서 socket을 disconnect시 문제가 발생하기 때문
            try
            {
                bool pending = _socket.SendAsync(_sendArgs);
                if (pending == false)
                    OnSendCompleted(null, _sendArgs);
            }
            catch (Exception e)
            {
                Console.WriteLine($"RegisterSend Failed {e}");
            }
        }

       // ...

        void RegisterRecv()
        {
            if (_disconnected == 1) // 최소한의 예방책
                return;

            _recvBuffer.Clean();
            ArraySegment<byte> segment = _recvBuffer.WriteSegment;
            _recvArgs.SetBuffer(segment.Array, segment.Offset, segment.Count);

            // socket을 다루는 부분을 try-catch문으로 감싸준다. (MultiThread 환경을 위한 예방책)
            // ~> 누군가는 위의 if문을 통과하여 아래 부분을 마저 실행하려고 하는 도중에 다른 Thread에서 socket을 disconnect시 문제가 발생하기 때문
            try
            {
                bool pending = _socket.ReceiveAsync(_recvArgs);
                if (pending == false)
                    OnRecvCompleted(null, _recvArgs);
            }
            catch(Exception e)
            {
                Console.WriteLine($"RegisterRecv Failed {e}");
            }
        }

        // ...
    }
}​

 

Server에 GameRoom Class 생성
using System;
using System.Collections.Generic;
using System.Text;

namespace Server
{
    class GameRoom
    {
        List<ClientSession> _sessions = new List<ClientSession>(); // GameRoom에 존재하는 session들
        object _lock = new object(); // List나 Dictionary 등 대부분의 자료 구조들은 MultiThread 환경에서 잘 돌아간다는 보장이 없기 때문에 lock 생성

        public void Broadcast(ClientSession session, string chat) // 현재 session이 접속중인 방에 존재하는 모두에게 chat을 뿌린다.
        {
            S_Chat packet = new S_Chat();
            packet.playerId = session.SessionId;
            packet.chat = chat;
            ArraySegment<byte> segment = packet.Write();

            lock(_lock)
            {
                foreach (ClientSession s in _sessions)
                    s.Send(segment);
            }
        }

        public void Enter(ClientSession session) // 방 입장
        {
            lock (_lock)
            {
                _sessions.Add(session);
                session.Room = this;
            }   
        }

        public void Leave(ClientSession session) // 방 퇴장
        {
            lock (_lock)
            {
                _sessions.Remove(session);
            }
        }
    }
}​

 

Server에 SessionManager Class 생성
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Server
{
    class SessionManager // SessionManager는 Engine 쪽에서 관리해도 되고, Content 쪽에서 관리해도 된다. (선택의 차이)
    {
        // SessionManager는 Singleton 패턴 사용
        static SessionManager _session = new SessionManager();
        public static SessionManager Instance { get { return _session; } }

        int _sessionId = 0; // session을 구분하기 위한 Id
        Dictionary<int, ClientSession> _sessions = new Dictionary<int, ClientSession>(); // 현재 존재하는 session들
        object _lock = new object(); // List나 Dictionary 등 대부분의 자료 구조들은 MultiThread 환경에서 잘 돌아간다는 보장이 없기 때문에 lock 생성

        public ClientSession Generate() // session 생성
        {
            lock (_lock)
            {
                int sessionId = ++_sessionId;

                ClientSession session = new ClientSession();
                session.SessionId = sessionId;
                _sessions.Add(sessionId, session);

                Console.WriteLine($"Connected : {sessionId}");

                return session;
            }
        }

        public ClientSession Find(int id) // sessionId를 통해 session을 찾는 함수
        {
            lock (_lock)
            {
                ClientSession session = null;
                _sessions.TryGetValue(id, out session);
                return session;
            }
        }

        public void Remove(ClientSession session) // session 삭제
        {
            lock (_lock)
            {
                _sessions.Remove(session.SessionId);
            }
        }
    }
}​

 

Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;

namespace Server
{
    class Program
    {
        static Listener _listener = new Listener();
        public static GameRoom Room = new GameRoom(); // GameRoom 생성 (딴 곳에서도 접근이 가능하도록 public으로 생성)

        static void Main(string[] args)
        {
            PacketManager.Instance.Register();

            string host = Dns.GetHostName();
            IPHostEntry ipHost = Dns.GetHostEntry(host);
            IPAddress ipAddr = ipHost.AddressList[0];
            IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);

            // 손님을 입장시킨다.
            _listener.Init(endPoint, () => { return SessionManager.Instance.Generate(); }); // new를 통해 Session을 생성하는 것이 아닌 SessionManager를 통해 생성하도록 수정
            Console.WriteLine("Listening...");

            while (true)
            {

            }
        }
    }
}​

 

ClientSession 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace Server
{
    class ClientSession : PacketSession 
    {
        public int SessionId { get; set; } // Session 구분을 위해
        public GameRoom Room { get; set; } // 현재 어떤 방에 위치하는지 알기 위해

        public override void OnConnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnConnected : {endPoint}");

            Program.Room.Enter(this); // Client가 접속시 방에 입장시킨다. (Program 산하에 static으로 Room을 생성하였기 때문에 다음과 같이 호출)

            Thread.Sleep(5000);
            Disconnect();
        }

        // ...

        public override void OnDisconnected(EndPoint endPoint)
        {
            SessionManager.Instance.Remove(this); // 내 자신을 (즉, session을) sessionManager를 통해 삭제 요청
            if (Room != null) // 
            {
                Room.Leave(this);
                Room = null;
            }

            Console.WriteLine($"OnDisconnected : {endPoint}");
        }

        // ...
    }
}​

 

Server의 PacketHandler 수정
using Server;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;

class PacketHandler 
{
    // 해당 패킷이 전부 조립된 경우 무엇을 할까?
    // PacketHandler는 자동화 없이 수동으로 추가
    public static void C_ChatHandler(PacketSession session, IPacket packet)
    {
        C_Chat chatPacket = packet as C_Chat;
        ClientSession clientSession = session as ClientSession;

        if (clientSession.Room == null)
            return;

        clientSession.Room.Broadcast(clientSession, chatPacket.chat); // 현재 clientSession이 접속중인 방에 존재하는 모두에게 채팅 메시지를 뿌린다.
    }
}​

 

# 채팅 테스트 #2

- 채팅 테스트를 위해 Client 입장에서 코드 추가 및 수정을 할 예정이다.

 ~>  현재 1명의 유저만 접속하고 있는 상황이므로, 이를 다수의 유저들이 접속하는 상황으로 가정하고 변경할 예정이다.

- 기존 Server의 Register 부분을 생성자를 통해 자동으로 생성하도록 변경할 예정이다.

ServerSession Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace DummyClient
{
    class ServerSession : PacketSession // Session이 아닌 PacketSession 을 상속 받도록 수정
    {
        public override void OnConnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnConnected : {endPoint}");
        }

        public override void OnDisconnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnDisconnected : {endPoint}");
        }

        public override void OnRecvPacket(ArraySegment<byte> buffer) // PacketSession 을 상속 받으므로 OnRecv가 아닌 OnRecvPacket으로 수정 (반환값도 int가 아닌 void로)
        {
            PacketManager.Instance.OnRecvPacket(this, buffer);
        }

        public override void OnSend(int numOfBytes)
        {
            // Console.WriteLine($"Transferred bytes : {numOfBytes}"); ~> session이 많아지면 OnSend() 가 자주 호출되므로 일단은 출력되지 않도록 주석 처리
        }
    }
}

 

ServerCore의 Connector 수정
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;

namespace ServerCore
{
    public class Connector
    {
        Func<Session> _sessionFactory;

        public void Connect(IPEndPoint endPoint, Func<Session> sessionFactory, int count = 1) // 다수의 Client 환경에서 Test 하고 싶을 수도 있기 때문에
        {
            for (int i = 0; i < count; i++) // 입력받은 매개변수 count 만큼 아래의 과정을 반복하도록 수정
            {
                // 휴대폰 설정
                Socket socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
                _sessionFactory = sessionFactory;

                SocketAsyncEventArgs args = new SocketAsyncEventArgs();
                args.Completed += OnConnectCompleted;
                args.RemoteEndPoint = endPoint;
                args.UserToken = socket;

                RegisterConnect(args);
            } 
        }

        // ...
    }
}​

 

DummyClient에 SessionManager Class 생성
using System;
using System.Collections.Generic;
using System.Text;

namespace DummyClient
{
    class SessionManager
    {
        // SessionManager는 Singleton 패턴 사용
        static SessionManager _session = new SessionManager();
        public static SessionManager Instance { get { return _session; } }

        List<ServerSession> _sessions = new List<ServerSession>(); // 현재 존재하는 session들
        object _lock = new object(); // List나 Dictionary 등 대부분의 자료 구조들은 MultiThread 환경에서 잘 돌아간다는 보장이 없기 때문에 lock 생성

        public void SendForEach() // Server쪽으로 채팅 패킷을 전송
        {
            foreach (ServerSession session in _sessions)
            {
                C_Chat chatPacket = new C_Chat();
                chatPacket.chat = $"Hello Server!";
                ArraySegment<byte> segment = chatPacket.Write();

                session.Send(segment);
            }
        }

        public ServerSession Generate()  // session 생성
        {
            lock (_lock)
            {
                ServerSession session = new ServerSession();
                _sessions.Add(session);
                return session;
            }
        }
    }
}​

 

DummyClient 코드 수정
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using ServerCore;

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);

            Connector connector = new Connector();
            connector.Connect(endPoint, () => { return SessionManager.Instance.Generate(); }, 10); // new를 통해 Session을 생성하는 것이 아닌 SessionManager를 통해 생성하도록 수정, 원하는 Client 수를 인자로 넘김

            while (true)
            {
                try
                {
                    SessionManager.Instance.SendForEach(); // 모든 Session들이 Server쪽으로 계속해서 채팅 패킷을 쏘도록
                }
                catch (Exception e)
                {
                    Console.WriteLine(e.ToString());
                }

                Thread.Sleep(250); // 0.25초 휴식
            }
        }
    }
}​

 

DummyClient의 PacketHandler 수정
using DummyClient;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;

class PacketHandler
{
    // C_Chat을 Server에 보낸뒤, Server는 방에 있는 모든 애들에게 S_Chat으로 답장을 주는 부분을 다룬다.
    public static void S_ChatHandler(PacketSession session, IPacket packet)
    {
        S_Chat chatPacket = packet as S_Chat;
        ServerSession serverSession = session as ServerSession;

        Console.WriteLine(chatPacket.chat);
    }
}​

 

PacketFormat Class 수정
(Main에서 PacketManager.Instance.Register(); 를 직접 입력하는 것이 아닌 생성자를 통해 자동으로 생성되도록 수정)
using System;
using System.Collections.Generic;
using System.Text;

namespace PacketGenerator
{
    class PacketFormat
    {
        // {0} 패킷 등록
        public static string managerFormat =
@"
using ServerCore;
using System;
using System.Collections.Generic;

class PacketManager
{{
    #region Singleton
    static PacketManager _instance = new PacketManager();
    public static PacketManager Instance {{ get {{ return _instance; }} }}
    #endregion

    PacketManager()
    {{
        Register();
    }}

    Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>> _onRecv = new Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>>();
    Dictionary<ushort, Action<PacketSession, IPacket>> _handler = new Dictionary<ushort, Action<PacketSession, IPacket>>();

    public void Register() 
    {{
{0}
    }}

    public void OnRecvPacket(PacketSession session, ArraySegment<byte> buffer)
    {{
        ushort count = 0;
        ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
        count += 2;
        ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
        count += 2;

        Action<PacketSession, ArraySegment<byte>> action = null;
        if (_onRecv.TryGetValue(id, out action))
            action.Invoke(session, buffer);
    }}

    void MakePacket<T>(PacketSession session, ArraySegment<byte> buffer) where T : IPacket, new()
    {{
        T packet = new T();
        packet.Read(buffer);

        Action<PacketSession, IPacket> action = null;
        if (_handler.TryGetValue(packet.Protocol, out action))
            action.Invoke(session, packet);
    }}
}}
";

        // ...
    }
}​

 

- 위의 결과로 GameRoom 안에 존재하는 10명의 유저들에게 채팅 패킷을 뿌려주고 있다는 것을 알 수 있다.

 ~>  그러나 위와 같은 방식을 MMORPG에 도입할 경우 속도가 상당히 느려질 수 있다.

       (유저의 수가 증가할수록, 패킷을 뿌리는 양이 많아지므로)

- 위의 Thread들은 모두 Broadcast의 lock 부분에서 대기중이다.

 ~> 이는 당연한 결과이다. 왜냐하면 Thread.Sleep(250) 을 통해 0.25초에 1번 동작하도록 설정하였으므로 만약 100명의 

      유저가 있다고 가정하면 100 * 100 = 10,000번이므로 1초에 40,000번 동작하고 있기 때문이다. (250 * 4 = 1000 = 1초)

      이에 lock 부분에 동시다발적으로 수많은 Thread들이 들어오지만 lock 때문에 1번에 1개의 Thread만 처리할 수 있다.

 ~> 따라서 위의 수많은 작업들이 밀리게 되면서 Thread를 관리하는 입장에서는 Thread를 보냈으나 일 처리가 완료되지 

      않았기 때문에 다시 Thread를 보내고 있는 상황이 발생한다. 이에 Thread가 계속해서 쌓이게 되는 것이다.

- 이러한 문제가 발생하는 이유는 Recv를 하자마자, lock을 통해 패킷을 전송하였기 때문이다.

 ~> 해결 방법으로는 하나의 Thread만 Queue에 쌓여있는 일감을 처리하고, 다른 Thread들의 일감은 Queue에 담아두는

      것이다. (이를 Job 또는 Task라고 하며, 중요한 것은 패킷을 저장하고 하나의 Thread에서 처리하는 것)

 

# Command 패턴

- 다음 시간에 만들어볼 Job 또는 Task를 관리하는 Queue가 전형적인 Command 패턴의 예제이다.

 

- 손님을 대리하는 ClientSession이 직원인 Thread에게 주문을 한다.

 

 

- 만약 직원이 서빙, 요리, 계산을 전부 도맡아 할 경우 주문을 받자마자 주방에 달려가 요리를 바로 시작할 것이다.

 ~> 지금까지 구현된 코드가 위와 비슷하다.

 

 

- 만약 주방의 크기가 너무 작아 동시에 1명만 요리가 가능한 경우 주문을 받은 직원들은 주방 앞에서 자신의 요리 차례가

  올때까지 계속해서 기다리게 된다.

 

 

- 모든 직원들이 자신의 요리 차례를 기다리고 있기 때문에 주문을 받을 직원이 부족한 경우 식당은 직원을 더 고용한다.

 ~> 지금까지 구현된 코드의 결과가 위와 비슷하다.

 

 

- 위와 같이 1명의 직원이 서빙, 요리, 계산을 모두 담당하는 것이 아닌 직원들 각각이 업무를 분담받도록 한다.

 ~> 서빙을 담당하는 직원이 주문을 받아 주문서를 주방장에게 전달한다.

 ~> 이러한 방법이 command 패턴과 유사하다.

 

+ 추가 검색 (https://velog.io/@kyeun95/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4-%EC%BB%A4%EB%A7%A8%EB%93%9C-%ED%8C%A8%ED%84%B4Command-Pattern)

- command 패턴이란?

 ~> command 패턴은 객체 지향 디자인 패턴 중 하나로, 객체 간의 결합도를 낮추고 유연성을 높이는 패턴이다.

 ~> command 패턴의 주요 목적은 사용자가 보낸 요청을 객체의 형태로 캡슐화하여 이를 나중에 이용할 수 있도록

     이름, 매개변수 등 요청에 필요한 정보를 저장 또는 로깅, 취소할 수 있도록 하는 패턴으로, 이를 통해 메소드를

     호출하는 Class와 메소드를 구현하는 Class 사이의 결합을 느슨하게 만든다.

 ~> 따라서 Client가 요청의 수신자를 알 필요 없이 다양한 요청을 보낼 수 있게 된다.

 

+ 추가 검색 (https://bamtory29.tistory.com/entry/%EC%BB%A4%EB%A7%A8%EB%93%9C-%ED%8C%A8%ED%84%B4-Command-Pattern)

Command 인터페이스
public interface Command 
{
    public void execute();
}​

 

ComputerOnCommand Class
public class ComputerOnCommand implements Command
{
    private Computer computer;

    public ComputerOnCommand(Computer computer) 
    {
        this.computer = computer;
    }
    
    @Override
    public void execute() 
    {
        computer.turnOn();
    }
}​

 

ComputerOffCommand Class
public class ComputerOffCommand implements Command 
{
    private Computer computer;

    public ComputerOffCommand(Computer computer) 
    {
        this.computer = computer;
    }
    
    @Override
    public void execute() 
    {
        computer.turnOff();
    }
}​

 

Computer Class
public class Computer 
{
    public void Computer() {}

    public void turnOn() 
    {
        System.out.println("컴퓨터 전원 켜짐");
    }

    public void turnOff() 
    {
        System.out.println("컴퓨터 전원 꺼짐");
    }
}​

 

Button Class
public class Button 
{
    private Command command;

    public Button(Command command) 
    {
        this.command = command;
    }

    public void setCommand(Command command) 
    {
        this.command = command;
    }

    public void pressButton() 
    {
        this.command.execute();
    }
}​

 

Main 메소드
public static void main(String[] args) 
{
        Computer computer = new Computer(); //컴퓨터는 Receiver

	    //컴퓨터 객체 생성
        ComputerOnCommand computerOnCmd = new ComputerOnCommand(computer);
        ComputerOffCommand computerOffCmd = new ComputerOffCommand(computer);

        Button btn = new Button(computerOnCmd); //버튼이 Invoker 역할
        btn.pressButton();
        btn.setCommand(computerOffCmd);
        btn.pressButton();
}

 

# JobQueue #1

- 지난 시간 학습한 Comman 패턴을 활용하여 JobQueue를 구현할 예정이다.

 ~> Queue에 쌓인 일감을 처리하는 Thread는 Push시 _jobQueue에 처음으로 일감을 밀어 넣는 Thread가 담당한다.

ServerCore에 JobQueue Class 생성
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ServerCore
{
    public interface IJobQueue
    {
        void Push(Action job);
    }

    public class JobQueue : IJobQueue // IJobQueue를 상속 받는다.
    {
        Queue<Action> _jobQueue = new Queue<Action>(); // 일감 목록을 담는 곳
        object _lock = new object(); // MultiThread 환경을 위한 lock 선언
        bool _flush = false; // Queue에 쌓인 일감들을 본인이 처리할 것인지 아닌지

        public void Push(Action job)  // _jobQueue에 일감을 밀어 넣기 위한 함수
        {
            bool flush = false; // MultiThread 환경에서 단 1개의 Thread만 일감 처리를 담당하도록
            lock (_lock)
            {
                _jobQueue.Enqueue(job);
                if (_flush == false) // _flush가 false인 경우 Queue에 쌓인 일감들을 본인이 처리
                    flush = _flush = true;
            }

            if (flush)
                Flush();
        }

        void Flush()
        {
            while (true)
            {
                Action action = Pop();
                if (action == null)
                    return;

                action.Invoke();
            }
        }

        Action Pop() // 일감 처리를 위해 _jobQueue에서 일감을 꺼내기 위한 함수
        {
            lock (_lock)
            {
                if (_jobQueue.Count == 0)
                {
                    _flush = false;
                    return null;
                }

                return _jobQueue.Dequeue();
            }
        }
    }
}​

 

GameRoom Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;

namespace Server
{
    class GameRoom : IJobQueue // IJobQueue를 상속 받는다.
    {
        // ...
        
        JobQueue _jobQueue = new JobQueue();

        public void Push(Action job)
        {
            _jobQueue.Push(job);
        }

        // ...
    }
}​

 

ClientSession Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace Server
{
    class ClientSession : PacketSession 
    {
        // ...

        public override void OnConnected(EndPoint endPoint)
        {
            // ...

            // 바로 실행하는 것이 아닌 일감을 _jobQueue에 담는 것
            Program.Room.Push(() => Program.Room.Enter(this));
            // Program.Room.Enter(this); // Client가 접속시 방에 입장시킨다. (Program 산하에 static으로 Room을 생성하였기 때문에 다음과 같이 호출)

            // ...
        }

        // ...

        public override void OnDisconnected(EndPoint endPoint)
        {
            // ...
            
            if (Room != null)
            {
                // 실행 도중 Client 종료시 Null Crash 방지를 위한 것 & 바로 실행하는 것이 아닌 일감을 _jobQueue에 담는 것
                GameRoom room = Room;
                room.Push(() => room.Leave(this));
                //Room.Leave(this);
                Room = null;
            }

            // ...
        }

        // ...
    }
}​

 

PacketHandler Class 수정
using Server;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;

class PacketHandler 
{
    public static void C_ChatHandler(PacketSession session, IPacket packet)
    {
        // ...

        // 실행 도중 Client 종료시 Null Crash 방지를 위한 것 & 바로 실행하는 것이 아닌 일감을 _jobQueue에 담는 것
        GameRoom room = clientSession.Room;
        room.Push(() => room.Broadcast(clientSession, chatPacket.chat));
        // clientSession.Room.Broadcast(clientSession, chatPacket.chat); // 현재 clientSession이 접속중인 방에 존재하는 모두에게 채팅 메시지를 뿌린다.
    }
}​

 

# JobQueue #2

- 지난 시간에 구현한 JobQueue와 똑같이 동작하지만 수동적으로 Task를 만들어주는 방법에 대해 알아볼 예정이다.

 ~> 람다식 개념이 등장한지 얼마 되지 않아 현업과 같은 실무에서는 수동적으로 일 처리가 필요한 함수들을 구현해서

      처리하는 형식이 자주 사용된다.

 ~> 그러나 위와 같이 수동적인 방법은 일 처리가 필요한 함수를 모두 구현해야 한다는 단점이 존재한다.

Server에 TaskQueue Class 생성
(_queue에 쌓인 일감 처리는 지난 시간의 Flush() 와 같은 메소드에서 처리하도록)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace Server
{
    interface ITask
    {
        void Execute();
    }

    // 일 처리가 필요한 함수에 맞춰 클래스를 생성
    class BroadcastTask : ITask
    {
        // 함수 실행시 필요한 변수들을 선언
        GameRoom _room;
        ClientSession _session;
        string _chat;

        // 생성자를 통한 변수 초기화
        BroadcastTask(GameRoom room, ClientSession session, string chat)
        {
            _room = room;
            _session = session;
            _chat = chat;
        }

        public void Execute()
        {
            _room.Broadcast(_session, _chat);
        }
    }

    class TaskQueue
    {
        Queue<ITask> _queue = new Queue<ITask>();
    }
}​

 

부하 Test를 위한 Listener Class 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;

namespace ServerCore
{
    public class Listener
    {
        Socket _listenSocket;

        Func<Session> _sessionFactory;

        // 문지기 수를 10명으로 증가, 최대 대기수를 100명으로 증원
        public void Init(IPEndPoint endPoint, Func<Session> sessionFactory, int register = 10, int backlog = 100)
        {
            // 문지기 고용
            _listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);

            _sessionFactory += sessionFactory;

            // 문지기 교육
            _listenSocket.Bind(endPoint);

            // 영업 시작
            // backlog : 최대 대기 수
            _listenSocket.Listen(backlog);

            for (int i = 0; i < register; i++)
            {
                SocketAsyncEventArgs args = new SocketAsyncEventArgs();
                args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted);
                RegisterAccept(args);
            }
        }

        // ...
    }
}​

 

- 만약 Client 접속 인원을 500명으로 늘린 뒤에 실행할 경우 메모리가 계속해서 상승하는 것을 볼 수 있다.

 ~> 이는 수많은 작업들이 밀리게 되면서 Thread를 관리하는 입장에서는 Thread를 보냈으나 일 처리가 완료되지 않았기

      때문에 ThreadPool에서 새로운 Thread를 뽑아 보내고 있기 때문에 메모리가 계속해서 상승하는 것이다.

- 가장 심한 부하를 유발하는 곳은 Broadcast의 foreach문 내의 Send이다.

 ~> 왜냐하면 Thread.Sleep(250) 을 통해 0.25초에 1번 동작하도록 설정하였으므로 500명의 유저가 있다고 가정하면

      500 * 500 = 250,000번이므로 1초에 1,000,000번 동작하고 있기 때문이다. 

 ~> 이는 N^2의 시간 복잡도를 가진다.

 ~> 해결 방법으로는 패킷 요청이 올때마다 바로 Send 하는 것이 아닌 패킷을 모은 뒤 추후에 한번에 보내는 것이다.

 

+ 추가 검색 (https://shung2.tistory.com/1445)

- Zone 형식의 게임은 맵을 넘어갈 때 로딩 시간이 있기때문에 하나의 Zone 단위에 JobQueue를 넣어 처리하도록 만들면

 되므로 비교적 JobQueue 활용 처리가 용이하다.

- 그러나 Seamless 게임은 넓은 맵에서 임의로 영역을 구분하여 JobQueue를 두고 처리할 수는 있겠지만, 이렇게 된다면

  구분된 영역 사이에서는 어떻게 처리할 지에 대한 의문점이 생길 수 밖에 없다.

 ~> JobQueue를 모든 객체들에게 넣는 방법이 있다. (즉, User, Monster, Skill ...)

 

+ 추가 검색 (https://drehzr.tistory.com/1683)

 

# 패킷 모아 보내기

- 패킷을 모아 보내는 것은 Engine 쪽에서도 처리할 수 있고 Content 쪽에서도 처리할 수 있다.

 ~> 만약 Engine 쪽에서 처리하고자 할 경우 Session의 Send() 메소드에서 처리가 가능하다.

      (어느정도 쌓아둔 뒤 일정 조건에 따라 보내는 구조로 변경)

 ~> 그러나 이번 시간에는 Content 쪽에서 패킷 모아 보내기를 구현할 예정이다.

 ~> 패킷이 불규칙하게 전송될 경우 RecvBuffer의 크기를 늘려주면 된다.

Session Class 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;

namespace ServerCore
{
    // ...

    public abstract class Session
    {
        // ...
        
        // List<ArraySegment<byte>> 를 매개변수로 받아 List에 담긴 패킷들을 전부 처리하는 Send 메소드 추가
        public void Send(List<ArraySegment<byte>> sendBuffList)
        {
            if (sendBuffList.Count == 0)
                return;

            lock (_lock)
            {
                foreach (ArraySegment<byte> sendBuff in sendBuffList)
                    _sendQueue.Enqueue(sendBuff);

                if (_pendingList.Count == 0)
                    RegisterSend();
            }
        }
        
        // 기존의 Send 메소드
        public void Send(ArraySegment<byte> sendBuff)
        {
            lock (_lock)
            {
                _sendQueue.Enqueue(sendBuff);

                if (_pendingList.Count == 0)
                    RegisterSend();
            }
        }

        // ...
    }
}​

 

GameRoom Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;

namespace Server
{
    class GameRoom : IJobQueue 
    {
        // ...
        
        List<ArraySegment<byte>> _pendingList = new List<ArraySegment<byte>>(); // 패킷을 모으기 위한 List

        // ...

        public void Flush() // 모아둔 패킷을 처리
        {
            foreach (ClientSession s in _sessions)
                s.Send(_pendingList);

            Console.WriteLine($"Flushed {_pendingList.Count} items");
            _pendingList.Clear();
        }

        // ..
    }
}​

 

Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;

namespace Server
{
    class Program
    {
        // ...

        static void Main(string[] args)
        {
            // ...

            while (true)
            {
                Room.Push(() => Room.Flush());
                Thread.Sleep(250);
            }
        }
    }
}​

 

# JobTimer

- GameRoom 뿐만이 아닌 다양한 Room들이 추가될 경우 각각의 룸들은 서로 다른 대기시간을 가지며 실행된다.

 ~> 첫번째로는 Tick을 이용하는 방법이 있다.

 ~> 두번째로는 PriorityQueue를 이용하는 방법이 있다.

Tick을 통해 처리하기 위한 Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;

namespace Server
{
    class Program
    {
        // ...

        static void Main(string[] args)
        {
            // ...

            int roomTick = 0;
            // room이 추가될때마다 Tick 변수도 추가된다.
            while (true)
            {
                int now = System.Environment.TickCount;
                if (roomTick < now) // 그러나 이와 같은 방법은 불필요한 if문 체크가 반복된다.
                {
                    Room.Push(() => Room.Flush());
                    roomTick = now + 250;
                }
                // room이 추가될때마다 if문도 추가된다.
            }
        }
    }
}​

 

ServerCore에 PriorityQueue Class 생성
using System;
using System.Collections.Generic;
using System.Text;

namespace ServerCore
{
    public class PriorityQueue<T> where T : IComparable<T>
    {
        List<T> _heap = new List<T>();

        public int Count { get { return _heap.Count; } }

        // 0 (logN)
        public void Push(T data)
        {
            // 힙의 맨 끝에 새로운 데이터를 삽입한다.
            _heap.Add(data);

            int now = _heap.Count - 1;
            // 도장깨기를 시작
            while (now > 0)
            {
                // 도장깨기를 시도
                int next = (now - 1) / 2;
                if (_heap[now].CompareTo(_heap[next]) < 0) // 대상값이 비교값과 같은 경우 0, 작은 경우 -1, 큰 경우 1 ( 대상값.CompareTo(비교값) )
                    break; // 실패

                // 두 값을 교체한다.
                T temp = _heap[now];
                _heap[now] = _heap[next];
                _heap[next] = temp;

                // 검사 위치를 이동한다.
                now = next;
            }
        }

        // 0 (logN)
        public T Pop()
        {
            // 반환할 데이터를 따로 저장
            T ret = _heap[0];

            // 마지막 데이터를 루트로 이동한다.
            int lastIndex = _heap.Count - 1;
            _heap[0] = _heap[lastIndex];
            _heap.RemoveAt(lastIndex);
            lastIndex--;

            // 역으로 내려가는 도장깨기 시작
            int now = 0;
            while (true)
            {
                int left = 2 * now + 1;
                int right = 2 * now + 2;

                int next = now;
                // 왼쪽값이 현재값보다 크면, 왼쪽으로 이동
                if (left <= lastIndex && _heap[next].CompareTo(_heap[left]) < 0)
                    next = left;
                // 오른쪽값이 현재값(왼쪽 이동 포함)보다 크면, 오른쪽으로 이동
                if (right <= lastIndex && _heap[next].CompareTo(_heap[right]) < 0)
                    next = right;

                // 왼쪽/오른쪽 모두 현재값보다 작으면 종료
                if (next == now)
                    break;

                // 두 값을 교체한다.
                T temp = _heap[now];
                _heap[now] = _heap[next];
                _heap[next] = temp;
                // 검사 위치를 이동한다.
                now = next;
            }

            return ret;
        }

        public T Peek() // 일감 내용 확인을 위한 함수
        {
            if (_heap.Count == 0)
                return default(T);
            return _heap[0];
        }
    }
}​

 

Server에 JobTimer Class 생성
using System;
using System.Collections.Generic;
using System.Text;
using ServerCore;

namespace Server
{
    struct JobTimerElem : IComparable<JobTimerElem> // 하나의 일감 단위
    {
        public int execTick; // 실행 시간
        public Action action; // 일감 (즉, Job)

        // IComparable 인터페이스의 필수 구성 요소인 CompareTo()
        public int CompareTo(JobTimerElem other)
        {
            // 실행 시간이 적을수록 먼저 튀어나오도록 (즉, 우선순위가 높은 항목)
            return other.execTick - execTick;
        }
    }

    class JobTimer // Job을 예약할 수 있는 시스템
    {
        // 우선순위 큐는 대소관계 연산 처리 속도가 상당히 빠르다.
        PriorityQueue<JobTimerElem> _pq = new PriorityQueue<JobTimerElem>();
        object _lock = new object(); // MultiThread 환경을 위한 lock 선언

        public static JobTimer Instance { get; } = new JobTimer();

        public void Push(Action action, int tickAfter = 0) // 예약하고자 하는 일감, 몇 Tick 후에 실행해야하는지 (입력 받지 못한 경우 바로 실행하도록 0으로 설정)
        {
            JobTimerElem job;
            job.execTick = System.Environment.TickCount + tickAfter;
            job.action = action;

            lock (_lock)
            {
                _pq.Push(job);
            }
        }

        public void Flush() // 일감 처리
        {
            while (true)
            {
                int now = System.Environment.TickCount;

                JobTimerElem job;

                lock (_lock)
                {
                    if (_pq.Count == 0) // 일감이 없는 경우
                        break; // while문을 나간다는 의미

                    job = _pq.Peek(); // 꺼내지 않고 일감 내용만 확인하는 것
                    if (job.execTick > now) // 현재 시간보다 실행 시간이 많이 남은 경우
                        break;

                    _pq.Pop();
                }

                // 일감을 실행시킨다.
                job.action.Invoke();
            }
        }
    }
}​

 

Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;

namespace Server
{
    class Program
    {
        // ...
        
        static void FlushRoom()
        {
            Room.Push(() => Room.Flush());
            JobTimer.Instance.Push(FlushRoom, 250);
        }

        static void Main(string[] args)
        {
            // ...

            JobTimer.Instance.Push(FlushRoom);

            while (true)
            {
                JobTimer.Instance.Flush();
            }
        }
    }
}​

 

+ 추가 검색 (https://velog.io/@aenyoung/%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0-%EC%9A%B0%EC%84%A0%EC%88%9C%EC%9C%84-%ED%81%90%EC%99%80-%ED%9E%99)

- Priority Queue란? (즉, 우선순위 큐란?) 

 ~> 선입선출(FIFO)의 원칙에 의하여 먼저 들어온 데이터가 먼저 나가는 Queue와 달리 Priority Queue는 데이터들이

      우선순위를 가지고 있어 우선순위가 높은 데이터가 먼저 출력되는 자료구조이다.

 

[ 섹션 5. 유니티 연동 ]

# 유니티 연동 #1

- 유니티 연동 전 좋은 소식과 나쁜 소식이 존재한다.

 ~> 좋은 소식은 네트워크 통신을 위해 구현한 코드들을 어느정도 재사용할 수 있다는 것이다.

 ~> 나쁜 소식은 Unity의 정책상 C#에서 허용되는 문법과 허용되지 않는 문법들이 존재하기 때문에 GenPacket의 

      Span, ReadOnlySpan, TryWriteBytes 와 같은 것들을 사용할 수 없다는 것이다.

      (Unity 2021 버전 이후부터는 Engine에서 지원하지만 오류가 난다는 가정하에 강의를 들을 예정)

- 또한 Unity에서 MultiThread 환경은 제약사항이 존재한다.

 ~> Main Thread가 아닌 Background Thread에서 Unity가 관리하는 객체들을 접근하거나 코드를 실행하려고 하는 경우

      Crash가 발생한다.

 ~> 따라서 Game Logic은 Main Thread 에서만 접근 및 실행하도록 조정해야 한다.

 

- 우선 Unity Hub를 통해 새로운 Client 프로젝트를 생성한 뒤 Scripts 폴더 산하에 재사용하고자 하는 코드들을 복붙한다.

 ~> 프로젝트의 경로는 여태까지 실습하던 프로젝트의 경로로 설정

PacketFormat Class 수정
using System;
using System.Collections.Generic;
using System.Text;

namespace PacketGenerator
{
    class PacketFormat
    {
        // ...

        // {0} 패킷 이름
        // {1} 멤버 변수들
        // {2} 멤버 변수 Read
        // {3} 멤버 변수 Write
        public static string packetFormat =
@"
public class {0} : IPacket
{{
	{1}

	public ushort Protocol {{ get {{ return (ushort)PacketID.{0}; }} }}

	public void Read(ArraySegment<byte> segment)
	{{
		ushort count = 0;
		count += sizeof(ushort);
		count += sizeof(ushort);
		{2}
	}}

	public ArraySegment<byte> Write()
	{{
		ArraySegment<byte> segment = SendBufferHelper.Open(4096);
		ushort count = 0;

		count += sizeof(ushort);
		Array.Copy(BitConverter.GetBytes((ushort)PacketID.{0}), 0, segment.Array, segment.Offset + count, sizeof(ushort));
		count += sizeof(ushort);
		{3}

		Array.Copy(BitConverter.GetBytes(count), 0, segment.Array, segment.Offset, sizeof(ushort));

		return SendBufferHelper.Close(count);
	}}
}}
";
        // ...

        // {0} 리스트 이름 [대문자]
        // {1} 리스트 이름 [소문자]
        // {2} 멤버 변수들
        // {3} 멤버 변수 Read
        // {4} 멤버 변수 Write
        public static string memberListFormat =
@"public class {0}
{{
	{2}

	public void Read(ArraySegment<byte> segment, ref ushort count)
	{{
		{3}
	}}

	public bool Write(ArraySegment<byte> segment, ref ushort count)
	{{
		bool success = true;
		{4}
		return success;
	}}	
}}
public List<{0}> {1}s = new List<{0}>();";

        // {0} 변수 이름
        // {1} To~ 변수 형식
        // {2} 변수 형식
        public static string readFormat =
@"this.{0} = BitConverter.{1}(segment.Array, segment.Offset + count);
count += sizeof({2});";

        // ...

        // {0} 변수 이름
        public static string readStringFormat =
@"ushort {0}Len = BitConverter.ToUInt16(segment.Array, segment.Offset + count);
count += sizeof(ushort);
this.{0} = Encoding.Unicode.GetString(segment.Array, segment.Offset + count, {0}Len);
count += {0}Len;";

        // {0} 리스트 이름 [대문자]
        // {1} 리스트 이름 [소문자]
        public static string readListFormat =
@"this.{1}s.Clear();
ushort {1}Len = BitConverter.ToUInt16(segment.Array, segment.Offset + count);
count += sizeof(ushort);
for (int i = 0; i < {1}Len; i++)
{{
	{0} {1} = new {0}();
	{1}.Read(segment, ref count);
	{1}s.Add({1});
}}";

        // {0} 변수 이름
        // {1} 변수 형식
        public static string writeFormat =
@"Array.Copy(BitConverter.GetBytes(this.{0}), 0, segment.Array, segment.Offset + count, sizeof({1}));
count += sizeof({1});";

        // ...

        // {0} 변수 이름
        public static string writeStringFormat =
@"ushort {0}Len = (ushort)Encoding.Unicode.GetBytes(this.{0}, 0, this.{0}.Length, segment.Array, segment.Offset + count + sizeof(ushort));
Array.Copy(BitConverter.GetBytes({0}Len), 0, segment.Array, segment.Offset + count, sizeof(ushort));
count += sizeof(ushort);
count += {0}Len;";

        // {0} 리스트 이름 [대문자]
        // {1} 리스트 이름 [소문자]
        public static string writeListFormat =
@"Array.Copy(BitConverter.GetBytes((ushort)this.{1}s.Count), 0, segment.Array, segment.Offset + count, sizeof(ushort));
count += sizeof(ushort);
foreach ({0} {1} in this.{1}s)
	{1}.Write(segment, ref count);";

    }
}​

 

GenPackets.bat 배치 파일 수정 (배치 파일 실행시 Unity Script 또한 같이 변경되도록)
START ../../PacketGenerator/bin/PacketGenerator.exe ../../PacketGenerator/PDL.xml
XCOPY /Y GenPackets.cs "../../DummyClient/Packet"
XCOPY /Y GenPackets.cs "../../Client/Assets/Scripts/Packet"
XCOPY /Y GenPackets.cs "../../Server/Packet"
XCOPY /Y ClientPacketManager.cs "../../DummyClient/Packet"
XCOPY /Y ClientPacketManager.cs "../../Client/Assets/Scripts/Packet"
XCOPY /Y ServerPacketManager.cs "../../Server/Packet"​

 

Unity Project에 NetworkManager Script 생성후 빈 객체에 Component로 추가
using DummyClient;
using ServerCore;
using System.Collections;
using System.Collections.Generic;
using System.Net;
using UnityEngine;

public class NetworkManager : MonoBehaviour
{
    ServerSession _session = new ServerSession();

    void Start()
    {
        // DNS (Domain Name System)
        string host = Dns.GetHostName();
        IPHostEntry ipHost = Dns.GetHostEntry(host);
        IPAddress ipAddr = ipHost.AddressList[0];
        IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);

        Connector connector = new Connector();

        connector.Connect(endPoint, () => { return _session; }, 1);
    }

    void Update()
    {
        
    }
}​

 

통신이 원활하게 이루어지는지 확인하기 위해 Unity Project의 PacketHandler Script 수정
using DummyClient;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;

class PacketHandler
{
	public static void S_ChatHandler(PacketSession session, IPacket packet)
	{
		S_Chat chatPacket = packet as S_Chat;
		ServerSession serverSession = session as ServerSession;

        if (chatPacket.playerId == 1)
            Debug.Log(chatPacket.chat);
		//if (chatPacket.playerId == 1)
			//Console.WriteLine(chatPacket.chat);
	}
}​

 

 

- Test를 위해 Server 솔루션 프로젝트를 실행시켜 Server와 Client를 구동시킨 뒤, Unity의 Play 버튼을 누르면

  Console 창에 Log가 뜨는 것을 확인할 수 있다.

 

# 유니티 연동 #2

- 이번 시간에는 단순히 Log만 띄우는 것이 아닌 실질적인 액션을 취하도록 만들 예정이다.

 ~> 우선 Unity에서 [ Hierarchy ] - [ 오른쪽 마우스 ] - [ 3D Object ] - [ Cylinder ] 를 통해 3D 객체 생성 후 이름을

      Player로 설정해준다.

Unity Project의 PacketHandler Script 수정
using DummyClient;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;

class PacketHandler
{
	public static void S_ChatHandler(PacketSession session, IPacket packet)
	{
        S_Chat chatPacket = packet as S_Chat;
        ServerSession serverSession = session as ServerSession;

        if (chatPacket.playerId == 1)
        {
            Debug.Log(chatPacket.chat);

            // Unity 내의 객체를 찾는 Logic 추가
            GameObject go = GameObject.Find("Player");
            if (go == null)
                Debug.Log("Player not found");
            else
                Debug.Log("Player found");
        }
    }
}​

 

- 위의 추가한 Logic은 정상적으로 실행되지 않는다.

 ~> 기존의 우리가 작성한 Server 솔루션의 Logic은 비동기로 네트워크 통신을 하고 있다.

 ~> 따라서 Unity를 구동하는 Main Thread에서 네트워크 패킷을 실행하는 것이 아닌, Thread Pool에서 Thread를 꺼내와

      실행하고 있는 것이 문제가 된다.

 ~> Unity는 다른 Thread에서 게임과 관련된 부분을 접근하여 실행하는 것을 원천적으로 차단해 두었기 때문에 

      정상적으로 실행되지 않는 것이다.

 ~> 해결 방법으로는 PacketHandler Class가 Main Thread에서 실행되도록 만들면 된다.

      (S_ChatHandler에서 Logic을 처리하는 것이 아닌 Queue에 일감을 등록후, 처리하는 Logic을 구분 지어 사용)

Unity Project의 GenPacket Script 수정
using System;
using System.Collections.Generic;
using System.Text;
using System.Net;
using ServerCore;

// ...

public interface IPacket // 접근한정자 public 추가
{
	ushort Protocol { get; }
	void Read(ArraySegment<byte> segment);
	ArraySegment<byte> Write();
}

// ...​

 

Unity Project에 NetworkManager Script 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// Main Thread와 Background Thread (즉, 네트워크를 처리하는 애들끼리) 는 PacketQueue라는 통로를 통해 소통한다.
//  ~> Background Thread는 Pakcet을 Push하여 밀어 넣고, Main Thread에서는 Packet을 Pop하여 처리한다.
public class PacketQueue // Component로 사용할 건 X ~> MonoBehaviour 상속 X
{
    public static PacketQueue Instance { get; } = new PacketQueue();

    Queue<IPacket> _packetQueue = new Queue<IPacket>();
    object _lock = new object();

    public void Push(IPacket packet)
    {
        lock (_lock)
        {
            _packetQueue.Enqueue(packet);
        }
    }

    public IPacket Pop()
    {
        lock ( _lock)
        {
            if (_packetQueue.Count == 0)
                return null;

            return _packetQueue.Dequeue();
        }
    }
}​

 

일감 등록을 위한 Unity Project의 ClientPacketManager Script 수정
using ServerCore;
using System;
using System.Collections.Generic;

class PacketManager
{
    #region Singleton
    static PacketManager _instance = new PacketManager();
    public static PacketManager Instance { get { return _instance; } }
    #endregion

    PacketManager()
    {
        Register();
    }

    Dictionary<ushort, Func<PacketSession, ArraySegment<byte>, IPacket>> _makeFunc = new Dictionary<ushort, Func<PacketSession, ArraySegment<byte>, IPacket>>();
    Dictionary<ushort, Action<PacketSession, IPacket>> _handler = new Dictionary<ushort, Action<PacketSession, IPacket>>();
		
    public void Register()
    {
        _makeFunc.Add((ushort)PacketID.S_Chat, MakePacket<S_Chat>);
        _handler.Add((ushort)PacketID.S_Chat, PacketHandler.S_ChatHandler);
    }

    // Action<PacketSession, IPacket> Type의 매개변수인 onRecvCallback 을 추가로 입력 받는다.
    public void OnRecvPacket(PacketSession session, ArraySegment<byte> buffer, Action<PacketSession, IPacket> onRecvCallback = null)
    {
        ushort count = 0;

        ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset);
        count += 2;
        ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
        count += 2;

        Func<PacketSession, ArraySegment<byte>, IPacket> func = null;
        if (_makeFunc.TryGetValue(id, out func))
        {
            IPacket packet = func.Invoke(session, buffer);
            if (onRecvCallback != null)
                onRecvCallback.Invoke(session, packet);
            else
                HandlePacket(session, packet);
        }
    }

    T MakePacket<T>(PacketSession session, ArraySegment<byte> buffer) where T : IPacket, new()
    {
        T pkt = new T();
        pkt.Read(buffer);
        return pkt;
    }

    public void HandlePacket(PacketSession session, IPacket packet)
    {
        Action<PacketSession, IPacket> action = null;
        if (_handler.TryGetValue(packet.Protocol, out action))
            action.Invoke(session, packet);
    }
}​

 

일감 등록을 위한 Unity Project의 ServerSession Script 수정
using System;
using System.Collections.Generic;
using System.Text;
using System.Net;
using ServerCore;

namespace DummyClient
{
    class ServerSession : PacketSession
    {
        // ...

        // Action<PacketSession, IPacket> Type의 매개변수인 onRecvCallback 을 추가로 입력
        public override void OnRecvPacket(ArraySegment<byte> buffer)
        {
            PacketManager.Instance.OnRecvPacket(this, buffer, (s, p) => PacketQueue.Instance.Push(p));
        }

        // ...
    }
}​

 

일감 처리를 위한 Unity Project의 NetworkManager Script 수정
using DummyClient;
using ServerCore;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Net;
using UnityEngine;

public class NetworkManager : MonoBehaviour
{
    ServerSession _session = new ServerSession();

    void Start()
    {
        // DNS (Domain Name System)
        string host = Dns.GetHostName();
        IPHostEntry ipHost = Dns.GetHostEntry(host);
        IPAddress ipAddr = ipHost.AddressList[0];
        IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);

        Connector connector = new Connector();

        connector.Connect(endPoint,
            () => { return _session; }, 1);

        StartCoroutine("CoSendPacket");
    }

    void Update()
    {
        // 일감 처리
        IPacket packet = PacketQueue.Instance.Pop();
        if (packet != null)
        {
            PacketManager.Instance.HandlePacket(_session, packet);
        }
    }

    // 3초마다 패킷을 보내도록 (즉, DummyClient의 역할)
    IEnumerator CoSendPacket()
    {
        while (true)
        {
            yield return new WaitForSeconds(3.0f);

            C_Chat chatPacket = new C_Chat();
            chatPacket.chat = "Hello Unity !";
            ArraySegment<byte> segment = chatPacket.Write();

            _session.Send(segment);
        }
    }
}​

 

PacketFormat Class 수정
using System;
using System.Collections.Generic;
using System.Text;

namespace PacketGenerator
{
    class PacketFormat
    {
        // {0} 패킷 등록
        public static string managerFormat =
@"using ServerCore;
using System;
using System.Collections.Generic;

public class PacketManager
{{
	#region Singleton
	static PacketManager _instance = new PacketManager();
	public static PacketManager Instance {{ get {{ return _instance; }} }}
	#endregion

	PacketManager()
	{{
		Register();
	}}

	Dictionary<ushort, Func<PacketSession, ArraySegment<byte>, IPacket>> _makeFunc = new Dictionary<ushort, Func<PacketSession, ArraySegment<byte>, IPacket>>();
	Dictionary<ushort, Action<PacketSession, IPacket>> _handler = new Dictionary<ushort, Action<PacketSession, IPacket>>();
		
	public void Register()
	{{
{0}
	}}

	public void OnRecvPacket(PacketSession session, ArraySegment<byte> buffer, Action<PacketSession, IPacket> onRecvCallback = null)
	{{
		ushort count = 0;

		ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset);
		count += 2;
		ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
		count += 2;

		Func<PacketSession, ArraySegment<byte>, IPacket> func = null;
		if (_makeFunc.TryGetValue(id, out func))
		{{
            IPacket packet = func.Invoke(session, buffer);
			if (onRecvCallback != null)
				onRecvCallback.Invoke(session, packet);
			else
				HandlePacket(session, packet);
        }}
	}}

	T MakePacket<T>(PacketSession session, ArraySegment<byte> buffer) where T : IPacket, new()
	{{
		T pkt = new T();
		pkt.Read(buffer);
		return pkt;
	}}

	public void HandlePacket(PacketSession session, IPacket packet)
	{{
        Action<PacketSession, IPacket> action = null;
        if (_handler.TryGetValue(packet.Protocol, out action))
            action.Invoke(session, packet);
    }}
}}";

        // {0} 패킷 이름
        public static string managerRegisterFormat =
@"		_makeFunc.Add((ushort)PacketID.{0}, MakePacket<{0}>);
		_handler.Add((ushort)PacketID.{0}, PacketHandler.{0}Handler);";

        // {0} 패킷 이름/번호 목록
        // {1} 패킷 목록
        public static string fileFormat =
@"using System;
using System.Collections.Generic;
using System.Text;
using System.Net;
using ServerCore;

public enum PacketID
{{
	{0}
}}

public interface IPacket
{{
	ushort Protocol {{ get; }}
	void Read(ArraySegment<byte> segment);
	ArraySegment<byte> Write();
}}

{1}
";

        // ...
    }
}​

 

# 유니티 연동 #3

- 이번 시간과 다음 시간을 통해 Server에서의 Player 생성 및 움직임을 구현 예정이다.

 ~> 우선 Server Logic 수정 후, DummyClient Logic을 수정할 것이다.

PDL XML 파일 수정
<?xml version="1.0" encoding="utf-8" ?>
<PDL>
	<packet name="S_BroadcastEnterGame">
		<int name="playerId"/>
		<float name="posX"/>
		<float name="posY"/>
		<float name="posZ"/>
	</packet>
	<packet name="C_LeaveGame">
	</packet>
	<packet name="S_BroadcastLeaveGame">
		<int name="playerId"/>
	</packet>
	<packet name="S_PlayerList">
		<list name="player">
			<bool name="isSelf"/>
			<int name="playerId"/>
			<float name="posX"/>
			<float name="posY"/>
			<float name="posZ"/>
		</list>
	</packet>
	<packet name="C_Move">
		<float name="posX"/>
		<float name="posY"/>
		<float name="posZ"/>
	</packet>
	<packet name="S_BroadcastMove">
		<int name="playerId"/>
		<float name="posX"/>
		<float name="posY"/>
		<float name="posZ"/>
	</packet>
</PDL>​

 

ClientSession Class 수정
using System;
using System.Collections.Generic;
using System.Text;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;
using System.Net;

namespace Server
{
    class ClientSession : PacketSession
    {
        public int SessionId { get; set; }
        public GameRoom Room { get; set; }
        public float PosX { get; set; } // x 좌표 값 저장을 위해 추가
        public float PosY { get; set; } // y 좌표 값 저장을 위해 추가
        public float PosZ { get; set; } // z 좌표 값 저장을 위해 추가

        // ...
    }
}

 

GameRoom Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;

namespace Server
{
    class GameRoom : IJobQueue
    {
        // ...

        public void Broadcast(ArraySegment<byte> segment)
        {
            _pendingList.Add(segment);		
        }

        public void Enter(ClientSession session)
        {
            // 플레이어 추가
            _sessions.Add(session);
            session.Room = this;

            // 신입생한테 모든 플레이어 목록 전송
            S_PlayerList players = new S_PlayerList();
            foreach (ClientSession s in _sessions)
            {
                players.players.Add(new S_PlayerList.Player()
                {
                    isSelf = (s == session),
                    playerId = s.SessionId,
                    posX = s.PosX,
                    posY = s.PosY,
                    posZ = s.PosZ
                });
            }
            session.Send(players.Write());

            // 신입생 입장을 모두에게 알린다
            S_BroadcastEnterGame enter = new S_BroadcastEnterGame();
            enter.playerId = session.SessionId;
            enter.posX = 0;
            enter.posY = 0;
            enter.posZ = 0;
            Broadcast(enter.Write());
        }

        public void Leave(ClientSession session)
        {
            // 플레이어 제거
            _sessions.Remove(session);

            // 플레이어 퇴장을 모두에게 알린다
            S_BroadcastLeaveGame leave = new S_BroadcastLeaveGame();
            leave.playerId = session.SessionId;
            Broadcast(leave.Write());
        }

        public void Move(ClientSession session, C_Move packet)
        {
            // 좌표를 바꿔주고
            session.PosX = packet.posX;
            session.PosY = packet.posY;
            session.PosZ = packet.posZ;

            // 모두에게 알린다
            S_BroadcastMove move = new S_BroadcastMove();
            move.playerId = session.SessionId;
            move.posX = session.PosX;
            move.posY = session.PosY;
            move.posZ = session.PosZ;
            Broadcast(move.Write());
        }
    }
}​

 

Server의 PacketHandler 수정
using Server;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;

class PacketHandler
{
    public static void C_LeaveGameHandler(PacketSession session, IPacket packet)
    {
        C_LeaveGame chatPacket = packet as C_LeaveGame;
        ClientSession clientSession = session as ClientSession;

        if (clientSession.Room == null)
            return;

        GameRoom room = clientSession.Room;
        room.Push(() => room.Leave(clientSession));
    }

    public static void C_MoveHandler(PacketSession session, IPacket packet)
    {
        C_Move movePacket = packet as C_Move;
        ClientSession clientSession = session as ClientSession;

        if (clientSession.Room == null)
            return;

        GameRoom room = clientSession.Room;
        room.Push(() => room.Move(clientSession, movePacket));
    }
}​

 

DummyClient의 PacketHandler 수정
(빌드가 통과할 수 있도록 함수만 만들어줄 뿐, 실질적인 작업은 유니티 내부에서 처리)
using DummyClient;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;

class PacketHandler
{
    public static void S_BroadcastEnterGameHandler(PacketSession session, IPacket packet)
    {
        S_BroadcastEnterGame pkt = packet as S_BroadcastEnterGame;
        ServerSession serverSession = session as ServerSession;
    }

    public static void S_BroadcastLeaveGameHandler(PacketSession session, IPacket packet)
    {
        S_BroadcastLeaveGame pkt = packet as S_BroadcastLeaveGame;
        ServerSession serverSession = session as ServerSession;
    }

    public static void S_PlayerListHandler(PacketSession session, IPacket packet)
    {
        S_PlayerList pkt = packet as S_PlayerList;
        ServerSession serverSession = session as ServerSession;
    }

    public static void S_BroadcastMoveHandler(PacketSession session, IPacket packet)
    {
        S_BroadcastMove pkt = packet as S_BroadcastMove;
        ServerSession serverSession = session as ServerSession;
    }
}​

 

DummyClient의 SessionManager 수정
using System;
using System.Collections.Generic;
using System.Text;

namespace DummyClient
{
    class SessionManager
    {
        // ...
        
        Random _rand = new Random();

        public void SendForEach()
        {
            lock (_lock)
            {
                // 채팅 패킷이 아닌 이동 패킷을 보내도록 수정
                foreach (ServerSession session in _sessions)
                {
                    C_Move movePacket = new C_Move();
                    movePacket.posX = _rand.Next(-50, 50);
                    movePacket.posY = 0;
                    movePacket.posZ = _rand.Next(-50, 50);
                    session.Send(movePacket.Write());
                }
            }
        }

        // ...
    }
}

 

# 유니티 연동 #4

- 지난 시간에 이어 Server에서의 Player 생성 및 움직임을 마저 구현할 예정이다.

Unity Project의 PacketQueue Script 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PacketQueue
{
    // ...

    public List<IPacket> PopAll()
    {
        List<IPacket> list = new List<IPacket>();

        lock (_lock) 
        {
            while (_packetQueue.Count > 0)
                list.Add(_packetQueue.Dequeue());
        }

        return list;
    }
}​

 

Unity Project의 NetworkManager Script 수정
using DummyClient;
using ServerCore;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Net;
using UnityEngine;

public class NetworkManager : MonoBehaviour
{
    // ...
    
    public void Send(ArraySegment<byte> sendBuff)
    {
        _session.Send(sendBuff);
    }
    
    // ...

    void Update()
    {
        // 일감 처리
        // 프레임마다 1개의 일감만을 처리하는 것이 아닌, 프레임마다 모든 일감하도록 수정
        List<IPacket> list = PacketQueue.Instance.PopAll(); // 즉, Pop()이 아닌 PopAll()을 실행하도록 수정
        foreach (IPacket packet in list)
            PacketManager.Instance.HandlePacket(_session, packet);
    }
}​

 

Unity Project에 Player Script 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Player : MonoBehaviour
{
    public int PlayerId { get; set; }
}​

 

Unity Project에 MyPlayer Script 생성
using ServerCore;
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MyPlayer : Player
{
    NetworkManager _network;

    void Start()
    {
        StartCoroutine("CoSendPacket");
        _network = GameObject.Find("NetworkManager").GetComponent<NetworkManager>();
    }

    void Update()
    {
        
    }

    IEnumerator CoSendPacket()
    {
        while (true)
        {
            yield return new WaitForSeconds(0.25f);

            C_Move movePacket = new C_Move();
            movePacket.posX = UnityEngine.Random.Range(-50, 50);
            movePacket.posY = 0;
            movePacket.posZ = UnityEngine.Random.Range(-50, 50);

            _network.Send(movePacket.Write());
        }
    }
}​

 

Unity Project에 PlayerManager Script 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerManager // 기생하는 것이 아닌 데이터만 들고 있도록 MonoBehaviour 상속 X
{
    MyPlayer _myPlayer;
    Dictionary<int, Player> _players = new Dictionary<int, Player>();

    public static PlayerManager Instance { get; } = new PlayerManager();

    public void Add(S_PlayerList packet)
    {
        Object obj = Resources.Load("Player");

        foreach (S_PlayerList.Player p in packet.players)
        {
            GameObject go = Object.Instantiate(obj) as GameObject;

            if (p.isSelf)
            {
                MyPlayer myPlayer = go.AddComponent<MyPlayer>();
                myPlayer.PlayerId = p.playerId;
                myPlayer.transform.position = new Vector3(p.posX, p.posY, p.posZ);
                _myPlayer = myPlayer;
            }
            else
            {
                Player player = go.AddComponent<Player>();
                player.PlayerId = p.playerId;
                player.transform.position = new Vector3(p.posX, p.posY, p.posZ);
                _players.Add(p.playerId, player);
            }
        }
    }

    public void Move(S_BroadcastMove packet)
    {
        if (_myPlayer.PlayerId == packet.playerId)
        {
            _myPlayer.transform.position = new Vector3(packet.posX, packet.posY, packet.posZ);
        }
        else
        {
            Player player = null;
            if (_players.TryGetValue(packet.playerId, out player))
            {
                player.transform.position = new Vector3(packet.posX, packet.posY, packet.posZ);
            }
        }
    }

    public void EnterGame(S_BroadcastEnterGame packet)
    {
        if (packet.playerId == _myPlayer.PlayerId)
            return;

        Object obj = Resources.Load("Player");
        GameObject go = Object.Instantiate(obj) as GameObject;

        Player player = go.AddComponent<Player>();
        player.transform.position = new Vector3(packet.posX, packet.posY, packet.posZ);
        _players.Add(packet.playerId, player);
    }

    public void LeaveGame(S_BroadcastLeaveGame packet)
    {
        if (_myPlayer.PlayerId == packet.playerId)
        {
            GameObject.Destroy(_myPlayer.gameObject);
            _myPlayer = null;
        }
        else
        {
            Player player = null;
            if (_players.TryGetValue(packet.playerId, out player))
            {
                GameObject.Destroy(player.gameObject);
                _players.Remove(packet.playerId);
            }
        }
    }
}​

 

Unity Project의 PacketHandler Script 수정
using DummyClient;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;

class PacketHandler
{
    public static void S_BroadcastEnterGameHandler(PacketSession session, IPacket packet)
    {
        S_BroadcastEnterGame pkt = packet as S_BroadcastEnterGame;
        ServerSession serverSession = session as ServerSession;

        PlayerManager.Instance.EnterGame(pkt);
    }

    public static void S_BroadcastLeaveGameHandler(PacketSession session, IPacket packet)
    {
        S_BroadcastLeaveGame pkt = packet as S_BroadcastLeaveGame;
        ServerSession serverSession = session as ServerSession;

        PlayerManager.Instance.LeaveGame(pkt);
    }

    public static void S_PlayerListHandler(PacketSession session, IPacket packet)
    {
        S_PlayerList pkt = packet as S_PlayerList;
        ServerSession serverSession = session as ServerSession;

        PlayerManager.Instance.Add(pkt);
    }

    public static void S_BroadcastMoveHandler(PacketSession session, IPacket packet)
    {
        S_BroadcastMove pkt = packet as S_BroadcastMove;
        ServerSession serverSession = session as ServerSession;

        PlayerManager.Instance.Move(pkt);
    }
}​

 

 

 

 

 

[ 섹션 0. 개론 ]

# 서버 OT

- 서버란 다른 컴퓨터에서 연결이 가능하도록 대기 상태로 상시 실행중인 프로그램이다.

- Web 서버 (aka. HTTP Server)

 ~> 테이크아웃 포장 전문 식당

 ~> 손님이 음식을 받아서 떠나면, 그 이후론 연락이 끊긴다.

 ~> 드물게 정보를 요청/갱신한다.

 ~> 식당에서 손님한테 먼저 접근할 일은 없다. (ex : 물 따라드릴까요?)

 ~> 주문 후 손님이 바로 떠나면, 손님의 상태를 당분간 잊고 지낸다. (Stateless)

 ~> Web 서버 제작은 처음부터 만드는 경우는 사실상 없고, Framework를 하나 골라서 사용한다.

 ~> 질의/응답 형태

- Game 서버 (aka. TCP Server, Binary Server, Stateful Server ...)

 ~> 일반 식당

 ~> 서빙 직원이 와서 손님에게 물어볼 수도 있고, 손님이 추가 주문을 하기도 한다.

 ~> 요청/갱신 횟수가 많다.

 ~> 언제라도 직원이 손님한테 접근이 가능해야 한다.

 ~> 손님이 식당에 머무는 동안, 손님의 상태를 보며 최상의 서비스를 제공한다. (Stateful)

 ~> 게임/장르에 따라 요구사항이 너무 달라 최적의 Framework라는 것이 존재하기 애매하다.

 ~> 실시간 Interaction이 존재

 

# 환경설정

- [ 솔루션 ] - [ 오른쪽 마우스 ] - [ 속성 ] 의 [ 공용 속성 ] - [ 시작 프로젝트 ] 에서 [ 한 개의 시작 프로젝트 ] 가 아닌

  [ 여러 개의 시작 프로젝트 ] 를 선택한 뒤 동시에 실행하고자하는 프로젝트의 작업 상태를 [ 없음 ] 에서 [ 시작 ] 으로

  변경하면 작업 상태가 [ 시작 ] 으로 설정된 여러개의 프로젝트를 동시에 실행 가능하다.

 

[ 섹션 1. 멀티쓰레드 프로그래밍 ]

# 멀티쓰레드 개론

- Process는 운영체제로부터 자원을 할당받는 작업의 단위이다.

- Thread는 한 Process가 할당받은 자원을 이용하는 실행의 단위이다.

- MultiThread란 하나의 Process를 다수의 실행 단위로 구분하여, 자원을 공유하고 자원의 생성과 관리의 중복성을

  최소화하여 수행 능력을 향상 시키는 것이다.

 

- 한 고급 레스토랑이 운영되기 위해서는 로봇 직원들이 필요하며 로봇 직원 각각에게는 업무가 주어진다. (계산, 서빙 ...)

 ~> 또한, 영혼의 개수가 한정적이기 때문에 동시에 작동시킬 수 있는 로봇 직원의 수 역시 제한된다.

- 위의 내용에서 고급 레스토랑은 Process, 로봇 직원은 Thread, 영혼은 CPU 코어에 해당한다.

 

- 영혼의 개수가 1개로 한정적이기 때문에 1명의 로봇 직원만 작동시킬 수 있다.

 ~> 만약, 해당 영혼을 4명의 직원에게 0.1초씩 빠르게 번갈아가면서 주입할 경우 어떻게 될까?

 ~> 우리의 눈에는 4명의 직원 모두 작동하는 것처럼 보일 것이다.

 

- 우리가 보기에는 다수의 Process가 동시에 동작되는 것처럼 보이지만, 실제로 컴퓨터는 운영체제의 Scheduling에 의해

  Task를 아주 짧은 시간동안 번갈아가며 수행하는 것이다.

 ~> 이를 MultiTasking 이라고 한다.

 

- CPU 코어의 Clock Cycle 증가를 통해 컴퓨터의 성능 개선을 꿈꿨던 개발자들은 전력 문제와 발열 문제라는 한계에 도달

  하게 되고, 이에 선택한 대안이 CPU 코어의 개수를 늘리는 것이다.

 ~> 이를 MultiProcessor 라고 한다.

- CPU 코어가 4개일 경우 동시에 실행할 수 있는 Thread는 4개이므로 CPU 코어의 수가 많다고 해서 Thread의 수를 늘려

  봤자 한계가 존재한다. ( CPU 코어의 수와 Thread의 수를 최대한 맞춰주는 것이 중요 )

 ~> 또한, Thread의 수가 많으면 많을수록 MultiTasking 부하가 상당해진다.

 

- Thread 사용시 Thread 배치는 구성하기 나름이다.

 

- MultiProcess 는 데이터 영역, Stack 영역, Heap 영역을 Process 끼리 전부 공유하지 않는다.

- MultiThread 는 Stack 영역을 제외한 데이터 영역, Heap 영역은 Thread 끼리 전부 공유한다.

 

+ 추가 검색 (https://inpa.tistory.com/entry/%F0%9F%91%A9%E2%80%8D%F0%9F%92%BB-multi-process-multi-thread)

- MultiProcessor 와 MultiProcess 의 차이점

 ~> Processor는 CPU 코어를, Process는 프로그램의 실행 상태를 일컫는다.

 ~> MultiProcessor 는 여러개의 CPU 코어가 하나의 시스템에서 동시에 실행되는 것을 의미한다.

 ~> MultiProcess 는 하나의 프로그램에서 여러 개의 Process를 실행하는 것을 의미한다. ( ex : fork() )

 

+ 추가 검색 ( https://wooody92.github.io/os/%EB%A9%80%ED%8B%B0-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4%EC%99%80-%EB%A9%80%ED%8B%B0-%EC%8A%A4%EB%A0%88%EB%93%9C/ )

 - MultiProcess 와 MultiThread 의 차이점

 1. MultiThread는 MultiProcess 보다 적은 메모리 공간을 차지하고, Context Switching 이 빠르다는 장점을 가진다.

  ~> 그러나 MultiThread의 단점으로는 동기화 문제와 1개의 Thread 장애가 전체 Thread 에게 영향을 줄 수 있다는 것이다.

 2. MultiProcess는 1개의 Process가 죽더라도 다른 Process 에게 영향을 주지 않아 안정성이 높다는 장점을 가진다.

  ~> 그러나 MultiProcess의 단점으로는 MultiThread 보다 많은 메모리 공간과 CPU 시간을 차지한다는 것이다.

 

# 쓰레드 생성

- 고급 레스토랑에서 정직원을 고용하는 것은 상당히 부담스럽다. ( 인건비, 4대 보험 등 ... )

 ~> 인력 상담소에서 단기 알바를 구하는 것이 좋다.

- 즉, Thread를 생성하는 것은 상당한 부하를 가져다 준다.

 ~> C#은 사용 가능한 Thread를 할당 받아 사용할 수 있는 Thread Pool을 지원한다. ( 사용 후 반환 / 일꾼이 없으면 대기 )

Thread 응용
using System;
using System.Threading; // 추가해야 Thread 사용 가능

namespace ServerCore
{
    class Program
    {
        static void MainThread()
        {
            for (int i = 0; i < 5; i++)
                Console.WriteLine("Hello Thread!");
        }

        static void Main(string[] args)
        {
            Thread t = new Thread(MainThread); // 한명의 직원을 고용한 뒤 MainThread 라는 업무를 할당한 것
            t.IsBackground = true; // Main 함수가 종료될 경우 해당 Thread도 종료 (기본값은 foreground)
            t.Name = "Test Thread"; // Thread 이름 설정
            t.Start(); // Thread 실행

            Console.WriteLine("Waiting for Thread");
            t.Join(); // 해당 Thread가 종료될때까지 기다리는 것

            Console.WriteLine("Hello World");
        }
    }
}​

 

Thread Pool 응용
using System;
using System.Threading;

namespace ServerCore
{
    class Program
    {
        static void MainThread(object state)
        {
            for (int i = 0; i < 5; i++)
                Console.WriteLine("Hello Thread!");
        }

        static void Main(string[] args)
        {
            // Thread를 최소 1개에서 최대 5개까지만 빌려줄 수 있도록 제한
            ThreadPool.SetMinThreads(1, 1);
            ThreadPool.SetMaxThreads(5, 5);

            // 람다식을 통해 Pool에서 Thread를 빌린뒤 영원히 반환하지 않는 함수 생성
            for (int i = 0; i < 5; i++)
                ThreadPool.QueueUserWorkItem((obj) => { while (true) { } });

            // Thread Pool에 더이상 남아있는 Thread가 없어 실행 X
            ThreadPool.QueueUserWorkItem(MainThread);
        }
    }
}​

 

Task 응용
using System;
using System.Threading;
using System.Threading.Tasks; // 추가해야 Task 사용 가능

namespace ServerCore
{
    class Program
    {
        static void MainThread(object state)
        {
            for (int i = 0; i < 5; i++)
                Console.WriteLine("Hello Thread!");
        }

        static void Main(string[] args)
        {
            // Thread를 최소 1개에서 최대 5개까지만 빌려줄 수 있도록 제한
            ThreadPool.SetMinThreads(1, 1);
            ThreadPool.SetMaxThreads(5, 5);
            
            for (int i = 0; i < 5; i++)
            {
                // Task로 직원이 할 일감을 생성하여 던져주면, ThreadPool에 대기중인 Thread가 해당 일감을 실행
                // Task 생성시 TaskCreationOptions.LongRunning 옵션을 설정할 경우 Thread를 따로 관리
                Task t = new Task(() => { while (true) { } }, TaskCreationOptions.LongRunning);
                t.Start();
            }

            // TaskCreationOptions.LongRunning 옵션을 통해 Thread를 따로 관리하므로 실행 가능
            ThreadPool.QueueUserWorkItem(MainThread);
        }
    }
}

 

# 컴파일러 최적화

 

- Debug 모드와 Release 모드의 가장 큰 차이점은 Debug 모드에서는 코드 최적화를 하지 않고, Release 모드에서는 코드

  최적화를 한다는 것이다.

 ~> 따라서 Debug 모드에서는 잘 실행되던 코드가 Release 모드에서는 실행되지 않을 수 있다.

 ~> volatile Keyword를 통해 최적화를 금지시킬 수 있다.

volatile Keyword 응용
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Program
    {
        // volatile Keyword를 통해 해당 변수에 대한 최적화를 금지시킨다.
        volatile static bool _stop = false; // 전역 변수는 모든 Thread들이 공유

        static void ThreadMain()
        {
            Console.WriteLine("Thread 시작!");

            // Debug 모드가 아닌 Release 모드에서는 코드 최적화로 인해 일어나지 않던 Bug들이 발생할 수 있다.
            while (_stop == false)
            {
                // 누군가가 stop 신호를 true로 바꿔주기를 기다린다.
            }

            // 위의 while문은 컴파일러 최적화시 아래의 코드와 같이 변경된다. ( 즉, 없던 Bug 발생 )
            //if (_stop == false)
            //{
            //    while (true)
            //    {
                      // 무한 루프에 빠진다.
            //    }
            //}

            Console.WriteLine("Thread 종료!");
        }

        static void Main(string[] args)
        {
            Task t = new Task(ThreadMain);
            t.Start();

            Thread.Sleep(1000); // 1초동안 일시정지

            _stop = true;
            Console.WriteLine("Stop 호출!");

            Console.WriteLine("종료 대기중");
            t.Wait(); // 해당 Task가 종료될때까지 기다리는 것 ( Thread의 Join() 과 같은 기능 )
            Console.WriteLine("종료 성공");
        }
    }
}​

 

# 캐시 이론

 

- 직원이 서빙을 통해 주문을 받고 주방과 원격으로 연결된 주문 현황에 주문 내역을 입력할 경우 주방에서 조리를

  시작한다. ( 이때 직원이 주문 현황을 입력하기 위한 동선 자체가 많이 길다고 가정 )

- 직원은 보다 더 효율적으로 움직이기 위해서 주문을 1개 받자마자 멀리 떨어진 주문 현황에 주문 내역을 입력하러

  가는 것이 아닌, 단기 기억/미니 수첩/대형 수첩 을 활용하여 주문들을 전부 기록해둔 뒤 나중에 한꺼번에 입력을 한다.

 ~> 만약 손님이 주문은 번복할 경우 본인이 수첩에 기록해둔 주문만 수정하면 되기 때문에 보다 더 효율적이다.

 

- 그러나 이러한 방법이 MultiThread 에서는 골치아플 수 있다.

 ~> 직원 1이 2번 테이블에 대한 콜라 주문을 받고, 2번 테이블 손님들이 직원 2에게 콜라가 아닌 사이다를 달라고

      주문을 번복할 경우 혼란이 발생한다.

 ~> 직원 2 입장에서는 본인이 2번 테이블에 대해 콜라 주문을 받지도 않았고, 확인하고자 주문 현황을 확인해봐도

      2번 테이블이 콜라를 주문했다는 사실을 알 수 없다.

     ( 2번 테이블에 대한 콜라 주문은 아직까지는 직원 1의 미니 수첩에 기록됨 )

 

- 위의 내용에서 로봇 직원은 Thread, 단기 기억/미니 수첩/대형 수첩이 Cache, 주문 현황은 Main Memory에 해당한다.

 

- 캐시 철학으로 시간적 지역성, 공간적 지역성이 있다.

 ~> 시간적 지역성은 최근에 참조된 주소의 내용이 다시 참조될 가능성이 높다는 것이다.

 ~> 공간적 지역성은 기억장치 내에 서로 인접하여 저장되어 있는 데이터들이 연속적으로 접근될 가능성이 높다는 것이다.

캐시 응용
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Program
    {
        static void Main(string[] args)
        {
            int[,] arr = new int[10000, 10000];

            {
                long start = DateTime.Now.Ticks;
                for (int x = 0; x < 10000; x++)
                    for (int y = 0; y < 10000; y++)
                        arr[x, y] = 1;
                long end = DateTime.Now.Ticks;
                Console.WriteLine($"(x, y) 순서 걸린 시간 {end - start}"); // 결과 : 3138744
            }

            {
                long start = DateTime.Now.Ticks;
                for (int x = 0; x < 10000; x++)
                    for (int y = 0; y < 10000; y++)
                        arr[y, x] = 1;
                long end = DateTime.Now.Ticks;
                Console.WriteLine($"(y, x) 순서 걸린 시간 {end - start}"); // 결과 : 4003940
            }
        }
    }
}​

 

# 메모리 배리어

- 코드 최적화 뿐만이 아니라 HW 역시 최적화를 진행한다.

 ~> CPU에게 일련의 명령어들을 시킬 경우 CPU가 의존성이 없다고 판단되는 명령어들의 순서를 제멋대로 재배치한다.

 ~> Memory Barrier를 통해 코드 순서 재배치를 억제할 수 있다.

- Memory Barrier를 통해 가시성 또한 확보할 수 있다.

 ~> 이때의 가시성 확보는 MultiThread 환경에서 Thread의 Cache의 상황을 Main Memory에 반영하는 것을 말한다.

 ~> 따라서 Store 후에 1번, Load 전에 1번 해주는 것이 가시성 확보에 도움이 된다. ( 최신 정보 반영 및 사용 )

HW 최적화 예시
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Program
    {
        static int x = 0;
        static int y = 0;
        static int result1 = 0;
        static int result2 = 0;

        static void Thread_1()
        {
            // CPU가 아래의 명령어들이 의존성이 없다고 판단하여 코드의 순서를 제멋대로 재배치
            y = 1; // Store y
            result1 = x; // Load x
        }

        static void Thread_2()
        {
            // CPU가 아래의 명령어들이 의존성이 없다고 판단하여 코드의 순서를 제멋대로 재배치
            x = 1; // Store x
            result2 = y; // Load y
        }

        static void Main(string[] args)
        {
            int count = 0;
            while (true)
            {
                count++;
                x = y = result1 = result2 = 0;

                Task t1 = new Task(Thread_1);
                Task t2 = new Task(Thread_2);
                t1.Start();
                t2.Start();

                Task.WaitAll(t1, t2); // 2개의 Thread가 전부 끝날때까지 Main Thread는 대기

                // CPU가 코드의 순서를 제멋대로 재배치하여 둘 다 0인 상황이 발생할 수 있었다.
                if (result1 == 0 && result2 == 0)
                    break;
            }

            Console.WriteLine($"{count}번만에 빠져나옴!");
        }
    }
}​

 

Memory Barrier 응용 ( 코드 순서 재배치 억제, 가시성 )
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    // 메모리 배리어 기능
    // A) 코드 순서 재배치 억제
    // B) 가시성 확보 (MultiThread 환경에서 Thread의 Cache의 상황을 Main Memory에 반영)

    // 메모리 배리어 종류
    // 1) Full Memory Barrier (어셈블리어 : MFENCE, C# : Thread.MemoryBarrier) : Store/Load 둘 다 막는다.
    // 2) Store Memory Barrier (어셈블리어 : SFENCE) : Store 만 막는다.
    // 3) Load Memory Barrier (어셈블리어 : LFENCE) : Load 만 막는다.

    class Program
    {
        static int x = 0;
        static int y = 0;
        static int result1 = 0;
        static int result2 = 0;

        static void Thread_1()
        {
            y = 1; // Store y ( Main Memory에 y의 값을 반영 )

            Thread.MemoryBarrier(); // 코드 순서 재배치 억제

            result1 = x; // Load x ( Main Memory에 있는 x의 값을 반영 )
        }

        static void Thread_2()
        {
            x = 1; // Store x ( Main Memory에 x의 값을 반영 )

            Thread.MemoryBarrier(); // 코드 순서 재배치 억제

            result2 = y; // Load y ( Main Memory에 있는 y의 값을 반영 )
        }

        static void Main(string[] args)
        {
            int count = 0;
            while (true)
            {
                count++;
                x = y = result1 = result2 = 0;

                Task t1 = new Task(Thread_1);
                Task t2 = new Task(Thread_2);
                t1.Start();
                t2.Start();

                Task.WaitAll(t1, t2); // 2개의 Thread가 전부 끝날때까지 Main Thread는 대기

                // CPU의 코드 재배치를 억제하였기 때문에 둘 다 0인 경우는 존재 X ~> 무한루프
                if (result1 == 0 && result2 == 0)
                    break;
            }

            Console.WriteLine($"{count}번만에 빠져나옴!");
        }
    }
}

 

# interlocked

- Race Condition (경합 조건) 이란 2개 이상의 Process 혹은 Thread가 공유 자원을 서로 사용하려고 경합(Race)하는

  현상을 의미한다.

- MultiThread 환경에서는 Process 내의 모든 자원을 공유할 수 있다는 점에서 동기화 문제가 발생한다.

 ~> Interlocked 를 통해 해결이 가능하다. ( 메모리 동기화 구현 )

 ~> Interlocked 는 정수만 사용할 수 있다는 단점이 존재한다.

Race Condition 예시
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Program
    {
        static int number = 0;

        static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
                number++;

            // number++은 어셈블리어에서 아래와 같이 동작한다. (즉, atomic operation이 X)
            // int temp = number;
            // temp += 1;
            // number = temp;
        }

        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
                number--;

            // number--은 어셈블리어에서 아래와 같이 동작한다. (즉, atomic operation이 X)
            // int temp = number;
            // temp -= 1;
            // number = temp;
        }

        static void Main(string[] args)
        {
            Task t1 = new Task(Thread_1);
            Task t2 = new Task(Thread_2);
            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);

            // 위의 주석 처리된 6줄의 코드 순서를 정확히 알지 못하기 때문에 결과 예측 X
            Console.WriteLine(number);
        }
    }
}​

 

Interlocked 를 통한 원자성 및 순서 보장
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Program
    {
        static int number = 0;

        static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
                Interlocked.Increment(ref number); // 원자성 보장 및 순서 보장 (All or Nothing)
        }

        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
                Interlocked.Decrement(ref number); // 원자성 보장 및 순서 보장 (All or Nothing)
        }

        static void Main(string[] args)
        {
            Task t1 = new Task(Thread_1);
            Task t2 = new Task(Thread_2);
            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);

            Console.WriteLine(number);
        }
    }
}​

 

# Lock 기초

- Critical Section (임계 영역) 은 2개 이상의 Thread가 동시에 접근할 수 없는 공유 자원을 사용하는 코드 영역을 의미한다.

- Mutual Exclusive (상호 배제) 란 특정 시점에서 공유 자원을 1개의 Thread 만이 사용할 수 있으며, 다른 Thread 들은

  접근하지 못하도록 제어하는 방법을 의미한다.

Critical Section 및 Mutual Exclusive 응용
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Program
    {
        static int number = 0;
        static object _obj = new object();

        static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
            {
                Monitor.Enter(_obj); // 문에 잠금장치 설정 (이미 잠겨있을 경우 대기)
                number++;
                Monitor.Exit(_obj); // 문에 잠금장치 해제 (생략할 경우 deadlock 발생 가능)
            }
        }

        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
            {
                Monitor.Enter(_obj); // 문에 잠금장치 설정 (이미 잠겨있을 경우 대기)
                number--;
                Monitor.Exit(_obj); // 문에 잠금장치 해제 (생략할 경우 deadlock 발생 가능)
            }
        }

        static void Main(string[] args)
        {
            Task t1 = new Task(Thread_1);
            Task t2 = new Task(Thread_2);
            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);

            Console.WriteLine(number);
        }
    }
}​

 

lock Keyword 응용
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Program
    {
        static int number = 0;
        static object _obj = new object();

        static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
            {
                lock(_obj) // 내부는 Monitor.Enter 과 Monitor.Exit 로 구현됨 (자동으로 lock 해제)
                {
                    number++;
                }
            }
        }

        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
            {
                lock (_obj) // 내부는 Monitor.Enter 과 Monitor.Exit 로 구현됨 (자동으로 lock 해제)
                {
                    number--;
                }
            }
        }

        static void Main(string[] args)
        {
            Task t1 = new Task(Thread_1);
            Task t2 = new Task(Thread_2);
            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);

            Console.WriteLine(number);
        }
    }
}​

 

# DeadLock

- Dead Lock (교착 상태) 이란 2개 이상의 Process 또는 Thread가 Mutual Exclusive 으로 사용하던 자원을 요청하면서

  서로가 가진 자원을 대기하는 현상을 말한다.

- Dead Lock을 try-catch문, Monitor.TryEnter()를 통해 예외를 처리하기보단 Crash 를 통해 오류를 수정하는 것이 더 좋다.

- Dead Lock은 개발 단계에서 잘 발생하지 않다가 출시 이후 User들이 몰리는 상황에 갑작스럽게 발생하는 경우가 많다.

Dead Lock 응용
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class SessionManager
    {
        static object _lock = new object();

        public static void TestSession()
        {
            lock (_lock)
            {

            }
        }

        public static void Test()
        {
            lock (_lock)
            {
                UserManager.TestUser();
            }
        }
    }

    class UserManager
    {
        static object _lock = new object();

        public static void TestUser()
        {
            lock (_lock)
            {

            }
        }

        public static void Test()
        {
            lock (_lock)
            {
                SessionManager.TestSession();
            }
        }
    }

    class Program
    {
        static void Thread_1()
        {
            for (int i = 0; i < 10000; i++)
            {
                SessionManager.Test();
            }
        }

        static void Thread_2()
        {
            for (int i = 0; i < 10000; i++)
            {
                UserManager.Test();
            }
        }

        static void Main(string[] args)
        {
            Task t1 = new Task(Thread_1);
            Task t2 = new Task(Thread_2);
            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);
        }
    }
}​

 

# Lock 구현 이론

- Lock 요청시 이미 누가 Lock을 점유하고 있을 경우 이를 어떻게 처리하는지에 따라 성능이 결정된다.

 1. 루프를 돌면서 계속 점유를 시도한다. ( SpinLock )

 2. Thread가 자신의 소유권을 포기하고, 나중에 다시 lock을 요청한다. ( 랜덤성 / context switching 비용 발생 )

 3. 운영체제에게 lock이 해제될 경우 본인에게 통보해달라고 요청한다. ( AutoResetEvent )

 

# SpinLock

오류가 발생하는 SpinLock 구현 (즉, 루프를 돌면서 계속 점유를 시도)
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class SpinLock
    {
        volatile bool _locked = false; // lock 잠금 상태 (해당 lock의 사용 유무)

        public void Acquire() // lock 설정
        {
            // 오류가 발생하는 이유는 lock 잠금 상태 확인과 lock을 잠그는 것을 따로 하고 있기 때문
            // 만약 2개의 Thread가 거의 동시다발적으로 들어와 동시에 while 문을 통과할 경우 문제 발생
            // 따라서 아래의 lock 잠금 상태 확인과 lock을 잠그는 코드를 하나로 묶어줘야 한다.

            // 오류 발생 원인 시작
            while (_locked)
            {
                // 잠금 상태가 풀릴때까지 무작정 기다린다.
            }

            _locked = true; 
            // 오류 발생 원인 종료
        }

        public void Release() // lock 해제
        {
            _locked = false;
        }
    }

    class Program
    {
        static int _num = 0;
        static SpinLock _lock = new SpinLock();

        static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
            {
                _lock.Acquire();
                _num++;
                _lock.Release();
            }
        }

        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
            {
                _lock.Acquire();
                _num--;
                _lock.Release();
            }
        }

        static void Main(string[] args)
        {
            Task t1 = new Task(Thread_1);
            Task t2 = new Task(Thread_2);

            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);

            Console.WriteLine(_num);
        }
    }
}​

 

정상적으로 동작하는 SpinLock 구현 (즉, 루프를 돌면서 계속 점유를 시도)
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class SpinLock
    {
        volatile int _locked = 0; // lock 잠금 상태 (해당 lock의 사용 유무)

        public void Acquire() // lock 설정
        {
            while (true)
            {
                // 방법 #1
                // Interlocked.Exchange는 1번째 인자의 값을 2번째 인자의 값으로 변경하며, 반환값은 1번째 인자의 변경 전 값이다.
                int original = Interlocked.Exchange(ref _locked, 1); 
                if (original == 0) // 즉, 아무도 lock을 사용하지 않는 상황에서 내가 lock을 건 경우
                    break; // 무작정 기다리는 것을 그만두겠다. (즉, lock 휙득 성공)

                // 방법 #2
                // Interlocked.CompareExchange는 1, 3번째 인자의 값이 같다면 2번째 인자의 값을 1번째 인자에 대입하며, 반환값은 1번째 인자의 변경 전 값이다.
                // Interlocked.CompareExchange는 CAS (Compare-And-Swap) 연산 수행
                int expected = 0; // 예상한 값
                int desired = 1; // 기대한 값
                int original2 = Interlocked.CompareExchange(ref _locked, desired, expected);
                if (original2 == 0)  // 즉, 아무도 lock을 사용하지 않는 상황에서 내가 lock을 건 경우
                    break;  // 무작정 기다리는 것을 그만두겠다. (즉, lock 휙득 성공)
            }
        }

        public void Release() // lock 해제
        {
            _locked = 0;
        }
    }

    class Program
    {
        static int _num = 0;
        static SpinLock _lock = new SpinLock();

        static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
            {
                _lock.Acquire();
                _num++;
                _lock.Release();
            }
        }

        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
            {
                _lock.Acquire();
                _num--;
                _lock.Release();
            }
        }

        static void Main(string[] args)
        {
            Task t1 = new Task(Thread_1);
            Task t2 = new Task(Thread_2);

            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);

            Console.WriteLine(_num);
        }
    }
}​

 

# Context Switching

- 루프를 돌면서 계속 점유를 시도하는 SpinLock과 달리, Sleep과 Yield를 통해 Thread가 자신의 소유권을 포기하고,

  나중에 다시 lock을 요청하는 lock 구현 방식이 마냥 장점만 존재한다고는 할 수 없다.

 ~> Context Switching 비용 문제가 발생한다.

- Context Switching 이란 CPU가 어떤 Process 또는 Thread를 실행하고 있는 상태에서 운영체제의 Scheduling에 의해

  더 높은 우선순위를 가진 Process 또는 Thread가 실행되어야 할 때, Register에 저장된 기존 Process 또는 Thread의 정보

  를 Kernel에 저장하고 실행하고자 하는 Process 또는 Thread의 정보를 복원하는 일련의 과정을 말하며 이러한 과정은

  lock 구현때뿐만이 아닌 늘상 발생하고 있는 자연스러운 현상이다.

Sleep과 Yield 를 통한 lock 구현 (즉, Thread가 자신의 소유권을 포기하고, 나중에 다시 lock을 요청)
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Lock
    {
        volatile int _locked = 0; // lock 잠금 상태 (해당 lock의 사용 유무)

        public void Acquire() // lock 설정
        {
            while (true)
            {
                // 방법 #1
                int original = Interlocked.Exchange(ref _locked, 1);
                if (original == 0)
                    break;

                // 방법 #2
                int expected = 0;
                int desired = 1;
                int original2 = Interlocked.CompareExchange(ref _locked, desired, expected);
                if (original2 == 0)
                    break;

                // 잠시 쉬다 올게~ (3개의 방법중 1개 선택)
                Thread.Sleep(1); // 무조건 휴식 => 1ms 정도 쉬고 싶은데 정확한 시간은 운영체제가 결정
                Thread.Sleep(0); // 조건부 양보 => 나보다 우선순위가 낮은 애들한테는 양보 X => 나보다 우선순위가 높거나 같은 애들이 없다면 남은 시간을 본인이 소진
                Thread.Yield(); // 관대한 양보 => 관대하게 양보할테니, 지금 실행이 가능한 애가 있다면 실행하세요 => 실행 가능한 애가 없다면 남은 시간을 본인이 소진
            }
        }

        public void Release() // lock 해제
        {
            _locked = 0;
        }
    }

    class Program
    {
        static int _num = 0;
        static Lock _lock = new Lock();

        static void Thread_1()
        {
            for (int i = 0; i < 100000; i++)
            {
                _lock.Acquire();
                _num++;
                _lock.Release();
            }
        }

        static void Thread_2()
        {
            for (int i = 0; i < 100000; i++)
            {
                _lock.Acquire();
                _num--;
                _lock.Release();
            }
        }

        static void Main(string[] args)
        {
            Task t1 = new Task(Thread_1);
            Task t2 = new Task(Thread_2);

            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);

            Console.WriteLine(_num);
        }
    }
}​

 

# AutoResetEvent

AutoResetEvent를 통한 lock 구현 (즉, 운영체제에게 lock이 해제될 경우 본인에게 통보해달라고 요청)
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Lock
    {
        // bool <- Kernel
        AutoResetEvent _avaliable = new AutoResetEvent(true); // true일 경우 아무나 들어올 수 있는 상태, false일 경우 누구도 들어올 수 없는 상태

        public void Acquire() // lock 설정
        {
            _avaliable.WaitOne(); // 입장 시도 (true일 경우 입장이 가능하며, 자동으로 문을 닫아준다.)
            // _avaliable.Reset()은 bool의 값을 false로 변경 -> AutoResetEvent에서는 WaitOne() 내부에 포함되어 있다.
        }

        public void Release() // lock 해제
        {
            _avaliable.Set(); // bool의 값을 true로 변경
        }
    }

    class Program
    {
        static int _num = 0;
        static Lock _lock = new Lock();

        static void Thread_1()
        {
            for (int i = 0; i < 1000; i++)
            {
                _lock.Acquire();
                _num++;
                _lock.Release();
            }
        }

        static void Thread_2()
        {
            for (int i = 0; i < 1000; i++)
            {
                _lock.Acquire();
                _num--;
                _lock.Release();
            }
        }

        static void Main(string[] args)
        {
            Task t1 = new Task(Thread_1);
            Task t2 = new Task(Thread_2);

            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);

            Console.WriteLine(_num);
        }
    }
}​

 

오류가 발생하는 ManualResetEvent를 통한 lock 구현
(즉, 운영체제에게 lock이 해제될 경우 본인에게 통보해달라고 요청)
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Lock
    {
        // bool <- Kernel
        ManualResetEvent _avaliable = new ManualResetEvent(true); // true일 경우 아무나 들어올 수 있는 상태, false일 경우 누구도 들어올 수 없는 상태

        public void Acquire() // lock 설정
        {
            // 오류가 발생하는 이유는 입장 시도와 문을 닫는 것을 따로 하고 있기 때문
            _avaliable.WaitOne(); // 입장 시도 (true일 경우 입장이 가능하며, 자동으로 문을 닫아주지 않는다.)
            _avaliable.Reset();  // bool의 값을 false로 변경 -> ManualResetEvent에서는 WaitOne() 내부에 포함되어 있지 않다.
        }

        public void Release() // lock 해제
        {
            _avaliable.Set(); // bool의 값을 true로 변경
        }
    }

    class Program
    {
        static int _num = 0;
        static Lock _lock = new Lock();

        static void Thread_1()
        {
            for (int i = 0; i < 1000; i++)
            {
                _lock.Acquire();
                _num++;
                _lock.Release();
            }
        }

        static void Thread_2()
        {
            for (int i = 0; i < 1000; i++)
            {
                _lock.Acquire();
                _num--;
                _lock.Release();
            }
        }

        static void Main(string[] args)
        {
            Task t1 = new Task(Thread_1);
            Task t2 = new Task(Thread_2);

            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);

            Console.WriteLine(_num);
        }
    }
}​

 

Mutex를 통한 lock 구현
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Program
    {
        static int _num = 0;
        static Mutex _lock = new Mutex();

        static void Thread_1()
        {
            for (int i = 0; i < 1000; i++)
            {
                _lock.WaitOne(); // 입장 시도 (입장 성공시 문을 잠근다.)
                _num++;
                _lock.ReleaseMutex(); // 나가면서 문의 잠금을 해제한다.
            }
        }

        static void Thread_2()
        {
            for (int i = 0; i < 1000; i++)
            {
                _lock.WaitOne(); // 입장 시도 (입장 성공시 문을 잠근다.)
                _num--;
                _lock.ReleaseMutex(); // 나가면서 문의 잠금을 해제한다.
            }
        }

        static void Main(string[] args)
        {
            Task t1 = new Task(Thread_1);
            Task t2 = new Task(Thread_2);

            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);

            Console.WriteLine(_num);
        }
    }
}

 

+ 추가 정리

- ManualResetEvent 사용 이유?

 ~> ManualResetEvent는 1개의 Thread만 통과시키고 문을 닫는 AutoResetEvent와 달리, 문이 1번 열리면 대기중이던

      여러개의 Thread 를 동시에 수행하고자 할 때 유용하다.

- AutoResetEvent와 Mutex의 차이?

 ~> Mutex는 AutoResetEvent 보다 더 많은 정보를 가진다. (잠금 횟수, Thread ID 등...) 이에 따른 추가비용도 존재한다.

 

# ReaderWriterLock

ReaderWriterLock 응용
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Program
    {
        static ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();

        // 예를 들어 게임에서 일일퀘스트 보상 지급시 코인, 경험치, 물약은 고정적인 보상이며
        // 주말, 명절 이벤트로 일일 퀘스트 보상에 +a 정도의 보상이 추가로 지급된다고 가정하자.
        // 보상을 얻는 함수는 수없이 실행될거고, 보상을 추가하는 함수는 가끔 실행될 것이다.
        // 따라서 보상을 추가하는 함수의 lock은 실행될 확률이 1.0%, 실행되지 않을 확률이 99.0% 이다.
        // 이럴때 쓰는 lock이 ReaderWriterLock이다. (ReaderWriterLockSlim이 더 최신버전)

        class Reward
        {

        }

        static Reward GetRewardById(int id)
        {
            _lock.EnterReadLock(); // 아무도 WriteLock 을 잡고 있지 않은 경우에 마치 lock이 없는것처럼 Thread들이 동시다발적으로 들어올 수 있다.

            _lock.ExitReadLock();

            return null;
        }

        void AddReward(Reward reward)
        {
            _lock.EnterWriteLock(); // 1번에 1개의 Thread만 휙득할 수 있다.

            _lock.ExitWriteLock();
        }


        static void Main(string[] args)
        {

        }
    }
}

 

+ 추가 검색 ( https://rito15.github.io/posts/06-cs-reader-writer-lock/ )

- Thread 간에 공유되는 데이터가 존재할 경우, 항상 모든 Thread가 해당 데이터를 읽고 쓰는 것은 아니다.

 ~> 어떤 Thread는 해당 데이터를 읽기만 하고, 어떤 Thread는 해당 데이터를 쓰기만 할 수도 있다.

 ~> 또한 다수의 읽기 Thread, 소수의 쓰기 Thread가 수행되는 경우도 존재한다.

 ~> 이럴때 일반적인 lock을 구현하여 읽기/쓰기가 수행되는 동안에 항상 lock을 설정/해제할 경우

      데이터를 단순히 읽기만 하여 값이 변경되지 않는 상황에도 불필요하게 Critical Section을 만들게 되므로

      성능 관점에서 손해다.

 ~> ReaderWriterlock 을 통해 해결이 가능하다.

- ReaderWriterlock은 데이터에 대한 쓰기 작업시에만 lock을 설정하고 읽기 작업시에는 lock을 설정하지 않는

  비대칭적인 lock을 구현하여 성능 관점에서 이득을 취했다.

 

# ReaderWriterLock 구현 연습

재귀적 lock을 허용하지 않는 ReaderWriterLock 구현
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    // 재귀적 lock을 허용할지 (No)
    // Spinlock 정책 (5000번 Spin 후 Yield)
    class Lock
    {
        const int EMPTY_FLAG = 0x00000000;
        const int WRITE_MASK = 0X7FFF0000; // WRITE 부분만 추출하기 위한 MASK (AND 연산을 통한 추출)
        const int READ_MASK = 0x0000FFFF; // READ 부분만 추출하기 위한 MASK (AND 연산을 통한 추출)
        const int MAX_SPIN_COUNT = 5000;

        // [ Unused (1bit) ] [ WriteThreadId (15bit) ] [ ReadCount (16bit) ] ~> 32bit (int형)
        // Unused : 최상위 bit는 부호 bit이므로 음수가 될 가능성이 있어 사용 X / WriteThreadId : Write lock을 가진 Thread의 Id / ReadCount : 읽기 Thread의 총 수
        int _flag = EMPTY_FLAG;

        public void WriteLock()
        {
            // 아무도 WriteLock of ReadLock을 휙득하고 있지 않을 때, 경합해서 소유권을 얻는다.
            int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK; // WriteThreadId (15bit)에는 본인의 Thread Id를 , WriteThreadId (15bit)를 제외한 나머지 bit는 전부 0으로 설정
            while (true)
            {
                for (int i = 0; i < MAX_SPIN_COUNT; i++)
                {
                    // 시도를 해서 성공하면 return
                    if (Interlocked.CompareExchange(ref _flag, desired, EMPTY_FLAG) == EMPTY_FLAG)
                        return;
                }

                Thread.Yield();
            }
        }

        public void WriteUnlock()
        {
            Interlocked.Exchange(ref _flag, EMPTY_FLAG);
        }

        public void ReadLock()
        {
            // 아무도 WriteLock을 휙득하고 있지 않을 때, ReadCount를 1 늘린다.
            while (true)
            {
                for (int i = 0; i < MAX_SPIN_COUNT; i++)
                {
                    int expected = (_flag & READ_MASK); // READ_MASK와 AND 연산시 WRITE 부분도 0 -> 아무도 WriteLock을 휙득하고 있지 X
                    if (Interlocked.CompareExchange(ref _flag, expected + 1, expected) == expected)
                        return;

                    // 위의 연산이 실패하는 경우 ?
                    // #1. 누군가 WriteLock을 휙득하고 있는 경우
                    // #2. A와 B Thread가 거의 동시에 도착하여 expected의 값은 동일하게 얻지만,
                    //     A Thread가 먼저 CompareExchange 를 통해 expected의 값을 +1 할 경우
                    //     B Thread가 CompareExchange 시 _flag의 값이 expected + 1 로 변경되어 요청이 실패한다.
                }

                Thread.Yield();
            }
        }

        public void ReadUnlock()
        {
            Interlocked.Decrement(ref _flag);
        }
    }
}​

 

재귀적 lock을 허용하는 ReaderWriterLock 구현
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    // 재귀적 lock을 허용할지 (Yes) : WriteLock ~> WriteLock (OK), WriteLock ~> ReadLock (OK), ReadLock ~> WriteLock (Not OK)
    class Lock
    {
        const int EMPTY_FLAG = 0x00000000;
        const int WRITE_MASK = 0X7FFF0000;
        const int READ_MASK = 0x0000FFFF;
        const int MAX_SPIN_COUNT = 5000;

        int _flag = EMPTY_FLAG;
        int _writeCount = 0; // 재귀적으로 몇개의 Write를 할 것인지

        public void WriteLock()
        {
            // 동일 Thread가 WriteLock을 이미 휙득하고 있는지 확인
            int lockThreadId = (_flag & WRITE_MASK);
            if (Thread.CurrentThread.ManagedThreadId == lockThreadId)
            {
                _writeCount++;
                return;
            }

            // 아무도 WriteLock of ReadLock을 휙득하고 있지 않을 때, 경합해서 소유권을 얻는다.
            int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK; // WriteThreadId (15bit)에는 본인의 Thread Id를 , WriteThreadId (15bit)를 제외한 나머지 bit는 전부 0으로 설정
            while (true)
            {
                for (int i = 0; i < MAX_SPIN_COUNT; i++)
                {
                    // 시도를 해서 성공하면 return
                    if (Interlocked.CompareExchange(ref _flag, desired, EMPTY_FLAG) == EMPTY_FLAG)
                    {
                        _writeCount = 1;
                        return;
                    }
                }

                Thread.Yield();
            }
        }

        public void WriteUnlock()
        {
            int lockCount = --_writeCount;
            if (lockCount == 0)
                Interlocked.Exchange(ref _flag, EMPTY_FLAG);
        }

        public void ReadLock()
        {
            // 동일 Thread가 WriteLock을 이미 휙득하고 있는지 확인
            int lockThreadId = (_flag & WRITE_MASK);
            if (Thread.CurrentThread.ManagedThreadId == lockThreadId)
            {
                Interlocked.Increment(ref _flag);
                return;
            }

            // 아무도 WriteLock을 휙득하고 있지 않을 때, ReadCount를 1 늘린다.
            while (true)
            {
                for (int i = 0; i < MAX_SPIN_COUNT; i++)
                {
                    int expected = (_flag & READ_MASK); // READ_MASK와 AND 연산시 WRITE 부분도 0 -> 아무도 WriteLock을 휙득하고 있지 X
                    if (Interlocked.CompareExchange(ref _flag, expected + 1, expected) == expected)
                        return;
                }

                Thread.Yield();
            }
        }

        public void ReadUnlock()
        {
            Interlocked.Decrement(ref _flag);
        }
    }
}​

 

구현한 ReaderWriterLock 동작 결과 확인
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Program
    {
        static volatile int count = 0;
        static Lock _lock = new Lock();

        static void Main(string[] args)
        {
            Task t1 = new Task(delegate ()
            {
                for (int i = 0; i < 100000; i++)
                {
                    _lock.WriteLock();
                    count++;
                    _lock.WriteUnlock();
                }
            });

            Task t2 = new Task(delegate ()
            {
                for (int i = 0; i < 100000; i++)
                {
                    _lock.WriteLock();
                    count--;
                    _lock.WriteUnlock();
                }
            });

            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);

            Console.WriteLine(count);
        }
    }
}​

 

# Thread Local Storage

 

- MultiThread 환경에서 수많은 Thread가 하나의 자원에 접근하고자 할 때 lock은 상호 배타적인 개념이기 때문에

  1번에 1개의 작업만을 처리할 수 있다. 이에 MultiThread 환경의 장점을 활용하지 못할뿐더러 하나의 Thread가 처리하는 

  속도보다 못할 수도 있다.

 ~> Thread Local Storage를 통해 해결이 가능하다.

- Thread Local Storage는 전역변수이지만, Thread 마다 고유하게 접근할 수 있는 전역 변수이다.

Thread Local Storage 응용
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Program
    {
        // Thread 마다 고유하게 접근할 수 있는 전역변수
        static ThreadLocal<string> ThreadName = new ThreadLocal<string>();

        static void WhoAmI()
        {
            ThreadName.Value = $"My Name Is {Thread.CurrentThread.ManagedThreadId}";

            Thread.Sleep(1000);

            Console.WriteLine(ThreadName.Value);
        }

        static void Main(string[] args)
        {
            Parallel.Invoke(WhoAmI, WhoAmI, WhoAmI, WhoAmI); // 매개변수로 입력하는 Action 만큼 Task를 만들어준다.

            ThreadName.Dispose(); // 자원 해제
        }
    }
}​

 

보다 성능을 개선한 Thread Local Storage 응용
using System;
using System.Threading;
using System.Threading.Tasks;

namespace ServerCore
{
    class Program
    {
        // Thread 마다 고유하게 접근할 수 있는 전역변수
        // new ThreadLocal의 인자로 Func delegate를 받을 수 있다.
        // 이를 통해 TLS가 새로 만들어질 경우 ThreadLocal.Value에 return 값을 넣어준 뒤 그대로 보관한다.
        static ThreadLocal<string> ThreadName = new ThreadLocal<string>(() => { return $"My Name Is {Thread.CurrentThread.ManagedThreadId}"; });

        static void WhoAmI()
        {
            // IsValueCreated는 ThreadLocal.Value의 값이 초기화 된 경우 true, 그렇지 않을 경우 false를 반환한다.  
            bool repeat = ThreadName.IsValueCreated;
            if (repeat)
                Console.WriteLine(ThreadName.Value + " (repeat)");
            else
                Console.WriteLine(ThreadName.Value);
        }

        static void Main(string[] args)
        {
            ThreadPool.SetMinThreads(1, 1);
            ThreadPool.SetMaxThreads(3, 3);
            Parallel.Invoke(WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI); // 매개변수로 입력하는 Action 만큼 Task를 만들어준다.

            ThreadName.Dispose(); // 자원 해제
        }
    }
}​

 

[ 섹션 2. 네트워크 프로그래밍 ]

# 네트워크 기초 이론

 

- 삼성 아파트 201호에 사는 사람이 삼성 아파트 102호에 사는 사람에게 택배를 보내고자 한다.

- 삼성 아파트 202호에 사는 사람이 현대 아파트 101호에 사는 사람에게 택배를 보내고자 한다.

 ~> 이때 본인이 직접 택배를 배송하는 것이 아닌 우선 경비실에 본인이 보내고자 하는 택배를 전달한다.

 ~> 경비실에서 보내는 사람과 받는 사람을 확인한 뒤, 만약 두 사람이 사는 아파트가 같다면 경비 아저씨가 직접

      택배를 전달해주고, 같지 않다면 택배 배송센터로 해당 택배를 전달한다.

 ~> 택배 배송 센터에서 받는 사람이 거주하는 아파트의 경비실에 택배를 전달하면, 경비아저씨가 직접 택배를

      전달해준다.

- 위의 내용에서 고객은 단말기, 경비실은 스위치, 택배 배송센터는 라우터, 아파트 단지는 네트워크에 해당한다.

 

# 통신 모델

 

- 게임의 규모, 장르, 개발자의 역량에 따라 Protocol을 선택하는 것이 중요하다.

 ~> MMO RPG는 주로 TCP, Latency가 중요한 FPS는 주로 UDP를 사용한다.

 ~> TCP는 느린 속도와 높은 신뢰성을 가지고, UDP는 빠른 속도와 낮은 신뢰성을 가진다.

 

# 소켓 프로그래밍 입문 #1

 

- 손님은 본인의 핸드폰을 통해 식당에 전화를 걸어 입장이 가능한지 물어보고, 만약 입장이 가능한 경우 본인의 대리인을

  식당에 입장 시킨다. 손님은 대리인을 통해 식당과 대화가 가능하다.

- 식당은 우선 문지기를 고용하고, 해당 문지기에게 손님들의 입장 문의 전화를 받을 수 있는 식당 번호를 알려준다.

  문지기는 손님 대리인을 통해 손님과 대화가 가능하다.

- 직원 고용 및 교육이 끝난 경우 식당은 영업 및 안내를 시작한다.

 

 

- 위의 내용에서 손님은 Client, 대리인은 Session, 식당은 Server, 문지기는 Listener 소켓, 문지기 교육은 Bind,

  영업 시작은 Listen, 안내는 Accept에 해당한다.

 

# 소켓 프로그래밍 입문 #2

 

- 우선 Test를 위해 [ 솔루션 ] - [ 오른쪽 마우스 ] - [ 속성 ] 의 [ 공용 속성 ] - [ 시작 프로젝트 ] 를 다음과 같이 설정한다.

Server 코드 구현
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;

namespace ServerCore
{
    class Program
    {
        static void Main(string[] args)
        {
            // DNS (Domain Name System) : Domain을 IP 네트워크에서 찾아갈 수 있는 IP로 변환해 준다. 
            string host = Dns.GetHostName(); // Local Computer의 host 이름을 반환
            IPHostEntry ipHost = Dns.GetHostEntry(host);
            IPAddress ipAddr = ipHost.AddressList[0]; // ip 주소를 배열로 반환 (예를 들어 Google과 같이 Traffic이 어마무시한 사이트는 여러개의 ip 주소를 가질 수 있기 때문)
            IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // ip 주소와 port 번호를 매개변수로 입력

            // 문지기 고용
            Socket listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);

            try
            {
                // 문지기 교육
                listenSocket.Bind(endPoint);

                // 영업 시작
                // backlog : 최대 대기 수
                listenSocket.Listen(10); // backlog를 매개변수로 입력

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

                    // 손님을 입장시킨다
                    // Accept() 는 Blocking 함수이므로 Client가 입장하지 않을 경우 무한히 대기한다.
                    Socket clientSocket = listenSocket.Accept(); // 이때의 clientSocket은 대리인에 해당한다.

                    // 받는다
                    byte[] recvBuff = new byte[1024];
                    int recvBytes = clientSocket.Receive(recvBuff); // 몇 byte를 받았는지 int로 반환
                    string recvData = Encoding.UTF8.GetString(recvBuff, 0, recvBytes); // 어디에서, 어디부터, 얼만큼 받아올 것인지를 매개변수로 입력
                    Console.WriteLine($"[From Client] {recvData}");

                    // 보낸다
                    byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server!");
                    clientSocket.Send(sendBuff); // 대리인을 통해 Client와 대화

                    // 쫓아낸다
                    clientSocket.Shutdown(SocketShutdown.Both); // 연결 해제를 미리 예고
                    clientSocket.Close();
                }
            }
            catch (Exception e)
            {
                Console.WriteLine(e.ToString());
            }
        }
    }
}

 

Client 코드 구현
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;

namespace DummyClient
{
    class Program
    {
        static void Main(string[] args)
        {
            // DNS (Domain Name System) : Domain을 IP 네트워크에서 찾아갈 수 있는 IP로 변환해 준다. 
            string host = Dns.GetHostName(); // Local Computer의 host 이름을 반환
            IPHostEntry ipHost = Dns.GetHostEntry(host);
            IPAddress ipAddr = ipHost.AddressList[0]; // ip 주소를 배열로 반환 (예를 들어 Google과 같이 Traffic이 어마무시한 사이트는 여러개의 ip 주소를 가질 수 있기 때문)
            IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // ip 주소와 port 번호를 매개변수로 입력

            // 휴대폰 준비
            Socket socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);

            try
            {
                // 입장 문의
                socket.Connect(endPoint);
                Console.WriteLine($"Connected To {socket.RemoteEndPoint.ToString()}"); // RemoteEndPoint는 본인과 연결된 대상

                // 보낸다
                byte[] sendBuff = Encoding.UTF8.GetBytes("Hello World");
                socket.Send(sendBuff);

                // 받는다
                byte[] recvBuff = new byte[1024];
                int recvBytes = socket.Receive(recvBuff); // 몇 byte를 받았는지 int로 반환
                string recvData = Encoding.UTF8.GetString(recvBuff, 0, recvBytes); // 어디에서, 어디부터, 얼만큼 받아올 것인지를 매개변수로 입력
                Console.WriteLine($"[From Server] {recvData}");

                // 나간다
                socket.Shutdown(SocketShutdown.Both); // 연결 해제를 미리 예고
                socket.Close();
            }
            catch (Exception e)
            {
                Console.WriteLine(e.ToString());
            }
        }
    }
}

 

# Listener

- 위의 코드에서 Accept() 는 Blocking 함수이므로 Client가 입장하지 않을 경우 무한히 대기한다.

 ~> Accept() 뿐만이 아니라 Send(), Receive() 역시 Blocking 방식으로 구현 되었다.

 ~> 무수한 Client를 받기 위해 Blocking 함수를 채택하는 것은 바람직하지 않을 수 있다.

 ~> 이를 NonBlocking 방식으로 구현하여 해결할 수 있다.

- Blocking 과 NonBlocking 의 차이

 ~> 낚시로 비유를 하자면 Blocking 방식은 낚시대를 던져놓고, 물고기가 잡힐때까지 아무 것도 하지 않으면서 낚시대만

      뜷어져라 쳐다보고 있는 것이다.

 ~> 그러나 NonBlocking 방식은 낚시대를 던져놓고, 던지자마자 입질이 올 경우 바로 낚시대를 끌어 올리지만

      던진 그 순간에 바로 입질이 오지 않을 경우 본인이 할 다른 일들을 하고 있다가 입질이 올 경우 그제서야 돌아와

      낚시대를 끌어올리는 것이다.

Accept를 NonBlocking 방식으로 구현하기 위한 Listener Class 생성
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;

namespace ServerCore
{
    internal class Listener
    {
        Socket _listenSocket;
        Action<Socket> _onAcceptHandler;

        public void Init(IPEndPoint endPoint, Action<Socket> onAcceptHandler) // Socket 함수의 매개변수를 위한 endPoint, _onAcceptHandler의 event 함수로 등록하기 위한 onAcceptHandler (delegate를 통해 함수를 인자로 받은 것)
        {
            // 문지기 고용
            _listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
            _onAcceptHandler += onAcceptHandler; // onAcceptHandler 함수가 _onAcceptHandler event를 구독 신청

            // 문지기 교육
            _listenSocket.Bind(endPoint);

            // 영업 시작
            // backlog : 최대 대기 수
            _listenSocket.Listen(10); // backlog를 매개변수로 입력

            // SocketAsyncEventArgs 는 일꾼 같은 친구임 ( Async 특징상 우리가 당장 값을 추출할 수 없었으니 이런 저런 정보들을 이 친구를 통해 전달받는 것 )
            SocketAsyncEventArgs args = new SocketAsyncEventArgs(); // 한번만 만들어주면 계속해서 재사용이 가능하다는 장점을 가진다. ( args 라는 event를 생성 )

            // AcceptAsync() 가 완료된 경우 우리에게 call back 방식으로 연락을 줄 수 있도록 event 사용 (딴 일을 하고 있다가 입질이 올 경우 args가 OnAcceptCompleted 함수를 자동으로 호출)
            args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted); // OnAcceptCompleted 메소드가 args Completed event를 구독 신청

            // *** 낚시대를 던진다 ***
            RegisterAccept(args); // 최초 1번은 직접 등록해준다.
        }

        void RegisterAccept(SocketAsyncEventArgs args) // AcceptAsync 함수의 매개변수를 위한 args
        {
            args.AcceptSocket = null; // 초기화 하지 않을 경우 Crash 발생

            bool pending = _listenSocket.AcceptAsync(args); // 비동기 Accept() 함수

            // *** 낚시대를 던지자마자 물고기가 잡힌 경우 ***
            if (pending == false) // AcceptAsync를 실행하는 동시에 Client로부터 접속 요청이 와서 pending 없이 완료된 경우
                OnAcceptCompleted(null, args); // 직접 OnAcceptCompleted 함수를 호출

            // 만약 pending이 true인 경우 당장은 완료되지 않았지만 나중에라도 완료가 된다면 위의 args가 자동으로 본인을 구독 신청한 구독자들에게 알려준다. (OnAcceptCompleted 함수를 호출)
        }

        void OnAcceptCompleted(object sender, SocketAsyncEventArgs args) // call back 함수로 등록하기 위한 매개변수 sender와 args ( 즉, 형식을 맞춰주기 위한 것 )
        {
            // *** 낚은 물고기를 빼낸다 ***
            if (args.SocketError == SocketError.Success) // Client로부터 접속 요청이 와서 Accept까지 성공적으로 마친 경우
            {
                _onAcceptHandler.Invoke(args.AcceptSocket); // _onAcceptHandler가 본인을 구독 신청한 구독자들에게 알려준다. (onAcceptHandler 함수를 호출) (args.AcceptSocket 는 ClientSocket을 뱉어준다.)
            }
            else
                Console.WriteLine(args.SocketError.ToString());

            // *** 다시 낚시대를 던진다 ***
            RegisterAccept(args); // 여기까지 도달한 경우 다음 Client를 위해서 다시 등록해주는 것
        }
    }
}​

 

Accept를 NonBlocking 방식으로 구현하기 위한 Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;

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

        static void OnAcceptHandler(Socket clientSocket) // Client Accept가 완료된 경우
        {
            try
            {
                // 받는다
                byte[] recvBuff = new byte[1024];
                int recvBytes = clientSocket.Receive(recvBuff); // 몇 byte를 받았는지 int로 반환
                string recvData = Encoding.UTF8.GetString(recvBuff, 0, recvBytes); // 어디에서, 어디부터, 얼만큼 받아올 것인지를 매개변수로 입력
                Console.WriteLine($"[From Client] {recvData}");

                // 보낸다
                byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server!");
                clientSocket.Send(sendBuff);

                // 쫓아낸다
                clientSocket.Shutdown(SocketShutdown.Both); // 미리 예고
                clientSocket.Close();
            }
            catch (Exception e)
            {
                Console.WriteLine(e.ToString());
            }
        }

        static void Main(string[] args)
        {
            // DNS (Domain Name System) : Domain을 IP 네트워크에서 찾아갈 수 있는 IP로 변환해 준다. 
            string host = Dns.GetHostName(); // Local Computer의 host 이름을 반환
            IPHostEntry ipHost = Dns.GetHostEntry(host);
            IPAddress ipAddr = ipHost.AddressList[0]; // ip 주소를 배열로 반환 (예를 들어 Google과 같이 Traffic이 어마무시한 사이트는 여러개의 ip 주소를 가질 수 있기 때문)
            IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // ip 주소와 port 번호를 매개변수로 입력
			
            // 손님을 입장시킨다.
            _listener.Init(endPoint, OnAcceptHandler); // 문지기에게 endPoint 전달, 혹시라도 누군가 접속 요청 후 Accept 완료시 call back을 받기 위해 OnAcceptHandler를 event 함수로 등록하고자 전달
            Console.WriteLine("Listening...");

            while (true)
            {
                
            }
        }
    }
}

 

+ 추가 정리

 

# Session #1

Receive를 NonBlocking 방식으로 구현하기 위한 Session Class 생성
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;

namespace ServerCore
{
    class Session
    {
        Socket _socket;
        int _disconnected = 0; // 끊김의 여부를 판단할 flag (MultiThread 환경에서 Disconnect() 를 2번 이상 호출하는 것을 방지)

        public void Start(Socket socket)
        {
            _socket = socket;
            SocketAsyncEventArgs recvArgs = new SocketAsyncEventArgs();

            // ReceiveAsync() 가 완료된 경우 우리에게 call back 방식으로 연락을 줄 수 있도록 event 사용 (딴 일을 하고 있다가 데이터가 올 경우 recvArgs가 OnRecvCompleted 함수를 자동으로 호출)
            recvArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnRecvCompleted);

            // 데이터를 받기 위한 Buffer 설정
            recvArgs.SetBuffer(new byte[1024], 0, 1024);

            RegisterRecv(recvArgs);
        }

        public void Send(byte[] sendBuff)
        {
            _socket.Send(sendBuff);
        }

        public void Disconnect()
        {
            if (Interlocked.Exchange(ref _disconnected, 1) == 1)
                return;
            
            _socket.Shutdown(SocketShutdown.Both); // 미리 예고
            _socket.Close();
        }


        void RegisterRecv(SocketAsyncEventArgs args)
        {
            bool pending = _socket.ReceiveAsync(args); // 비동기 Receive() 함수

            if (pending == false) // ReceiveAsync를 실행하는 동시에 받고자 하는 데이터가 존재하여 pending 없이 완료된 경우
                OnRecvCompleted(null, args); // 직접 OnRecvCompleted 함수를 호출
        }

        void OnRecvCompleted(object sender, SocketAsyncEventArgs args)
        {
            if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success) // 데이터를 성공적으로 받은 경우
            {
                try
                {
                    string recvData = Encoding.UTF8.GetString(args.Buffer, args.Offset, args.BytesTransferred);
                    Console.WriteLine($"[From Client] {recvData}");

                    RegisterRecv(args);
                }
                catch (Exception e)
                {
                    Console.WriteLine($"OnRecvCompleted Failed {e}");
                }
            }
            else
            {
                Disconnect();
            }
        }
    }
}​

 

Receive를 NonBlocking 방식으로 구현하기 위한 Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;

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

        static void OnAcceptHandler(Socket clientSocket) // Client Accept가 완료된 경우
        {
            try
            {
                // 받는다.
                Session session = new Session(); // Session Class 객체 생성
                session.Start(clientSocket);

                // 보낸다.
                byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server!");
                session.Send(sendBuff);

                // 쫓아낸다.
                session.Disconnect();
            }
            catch (Exception e)
            {
                Console.WriteLine(e.ToString());
            }
        }

        static void Main(string[] args)
        {
            // DNS (Domain Name System) : Domain을 IP 네트워크에서 찾아갈 수 있는 IP로 변환해 준다. 
            string host = Dns.GetHostName(); // Local Computer의 host 이름을 반환
            IPHostEntry ipHost = Dns.GetHostEntry(host);
            IPAddress ipAddr = ipHost.AddressList[0]; // ip 주소를 배열로 반환 (예를 들어 Google과 같이 Traffic이 어마무시한 사이트는 여러개의 ip 주소를 가질 수 있기 때문)
            IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // ip 주소와 port 번호를 매개변수로 입력

            // 손님을 입장시킨다.
            _listener.Init(endPoint, OnAcceptHandler); // 문지기에게 endPoint 전달, 혹시라도 누군가 접속 요청 후 Accept 완료시 call back을 받기 위해 OnAcceptHandler를 event 함수로 등록하고자 전달
            Console.WriteLine("Listening...");

            while (true)
            {
                
            }
        }
    }
}​

 

# Session #2

- Send는 시점이 정해져 있지 않다.

 ~> Send시 보내줄 Buffer와 Msg를 같이 설정해줘야 하는데 미래에 어떤 Msg를 보낼지 모르기 때문에

      Receive와 같은 방식으로 접근할 수 없다.

- 또한 Send는 OnSendCompleted() 호출 후 RegisterSend(sendArgs) 를 통해 다시 등록하는 것이 불가능하다.

 ~> 이는 똑같은 정보를 다시 보내는 것이기 때문이며, 따라서 재사용이 불가능하다.

- 마지막으로 MultiThread 환경에서 동시다발적으로 Send() 를 호출시 매번 RegisterSend() 가 호출되어 SendAsync를

  매번 호출하게 되는데 이는 네트워크 과부화를 유발한다.

 ~> 이는 운영체제가 Kernel에서 처리하기 때문이다.

- 이를 바탕으로 Send는 다음과 같이 구현할 예정이다.

 ~>  _sendArgs를 전역변수로 선언하여 재사용이 가능하도록 만들 것이다.

 ~>  SendAsync를 매번 호출하는 것이 아닌 _sendQueue에 데이터가 어느정도 모일 경우 호출할 것이다.

Send를 NonBlocking 방식으로 구현하기 위한 Session Class 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;

namespace ServerCore
{
    class Session
    {
        // ...

        object _lock = new object();
        Queue<byte[]> _sendQueue = new Queue<byte[]>();
        bool _pending = false; // pending 여부 판단 (true인 경우 누군가 보내고 있는 것, false인 경우 아무도 보내고 있지 않은 것)
        SocketAsyncEventArgs _sendArgs = new SocketAsyncEventArgs(); // Send는 언제 호출될지 모르기 때문에 전역 변수로 선언

        public void Start(Socket socket)
        {
            // ...

            // SendAsync() 가 완료된 경우 우리에게 call back 방식으로 연락을 줄 수 있도록 event 사용 (딴 일을 하고 있다가 데이터를 보낼 경우 _sendArgs가 OnSendCompleted 함수를 자동으로 호출)
            _sendArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnSendCompleted);

            // ...
        }

        public void Send(byte[] sendBuff)
        {
            lock (_lock)
            {
                // 누군가 RegisterSend()를 하고 있어 SendAsync() 가 끝난 뒤 OnSendCompleted() 가 완료되기 전까지는 보내지않고 sendBuff를 Queue에 넣고 종료
                // ~> 즉, 누군가 보내고 있으면 _pending 은 true이므로 본인은 보내지 않고 Queue에 집어넣기만 하는 것
                _sendQueue.Enqueue(sendBuff);
                if (_pending == false) // RegisterSend() 를 가장 먼저 호출하여 전송까지 할 수 있는 상태인 경우
                    RegisterSend();
            }
        }

        public void Disconnect()
        {
            if (Interlocked.Exchange(ref _disconnected, 1) == 1)
                return;
            
            _socket.Shutdown(SocketShutdown.Both); // 미리 예고
            _socket.Close();
        }

        void RegisterSend()
        {
            _pending = true;
            byte[] buff = _sendQueue.Dequeue();
            _sendArgs.SetBuffer(buff, 0, buff.Length);

            bool pending = _socket.SendAsync(_sendArgs); // 비동기 Send() 함수
            if (pending == false) // SendAsync를 실행하는 동시에 받고자 하는 데이터가 존재하여 pending 없이 완료된 경우
                OnSendCompleted(null, _sendArgs);
        }

        void OnSendCompleted(object sender, SocketAsyncEventArgs args)
        {
            lock (_lock) // 오직 RegisterSend() 를 통해서만 OnSendCompleted() 가 호출되는 경우에는 필요 X (그러나, 우린 call back을 통해서도 호출 O)
            {
                if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success) // 데이터를 성공적으로 보낸 경우
                {
                    try
                    {
                        // _pending이 false인 상황에서 A Thread가 Send()시 RegisterSend() 를 호출하게 되는데
                        // 만약 pending이 true여서 OnSendCompleted() 가 바로 호출되는 것이 아닌 call back을 통해 나중에 호출될 경우를 가정하자.
                        // 이러한 상황에서 만약 다른 Thread들이 Send()시 _pending이 true이기 때문에 RegisterSend() 를 호출하지 않고 Queue에 집어넣기만 한다.
                        // 이때 A Thread가 OnSendCompleted() 호출시 본인 데이터뿐만이 아닌 Queue에 있는 데이터들까지 처리해준다.
                        // ~> 즉, 가장 첫번째 예약 대기자가 본인의 예약을 기다리는 동안, 그 후에 예약을 건 사람들의 몫까지 처리해주는 것이다.
                        if (_sendQueue.Count > 0) // Queue에 보내야 할 데이터가 남아있는 경우
                            RegisterSend(); // 다음 전송을 위해 RegisterSend() 호출
                        else // Queue에 보내야 할 데이터가 남아있지 않은 경우
                            _pending = false; 
                    }
                    catch (Exception e)
                    {
                        Console.WriteLine($"OnSendCompleted Failed {e}");
                    }
                }
                else
                {
                    Disconnect();
                }
            }
        }


        // ...
    }
}

 

+ 추가 정리

- 어차피 OnSendCompleted()에서 _sendQueue.Count가 0보다 큰 경우 RegisterSend()를 계속해서 호출하게 되는데

  그렇게 되면 결국에는 성능적인 관점에서 달라진 것은 거의 없다고 볼 수 있다.

 ~> 100명의 사람이 Send() 를 호출시 SendAsync() 도 언젠가는 100번 호출 되기 때문이다.

 ~> 즉, 위의 코드가 완전한 해결책이라고는 보기 어렵다.

 ~> 따라서 위의 코드는 1차적으로 Send() 를 Async 계열로 바꿨다는 것에 의의를 가진다.

# Session #3

- 위의 코드에서 아쉬운 점은 _sendQueue에 있는 데이터 1개당 SendAsync() 를 1번씩 호출하고 있다는 것이다.

 ~> BufferList를 통해 해결이 가능하다.

BufferList를 통해 NonBlocking 방식의 Send를 개선하기 위한 Session Class 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;

namespace ServerCore
{
    class Session
    {
        // ...
        // _pendingList 사용시 _pending 변수도 필요 X
        SocketAsyncEventArgs _sendArgs = new SocketAsyncEventArgs(); 
        SocketAsyncEventArgs _recvArgs = new SocketAsyncEventArgs(); // Receive도 Send와 마찬가지로 전역변수로 선언해도 무방
        List<ArraySegment<byte>> _pendingList = new List<ArraySegment<byte>>(); // 대기중인 목록들을 담기 위한 List

        // ...

        public void Send(byte[] sendBuff)
        {
            lock (_lock)
            {
                _sendQueue.Enqueue(sendBuff);

                // _pending 변수 대신 _pendingList.Count 사용
                if (_pendingList.Count == 0) // RegisterSend() 를 가장 먼저 호출하여 전송까지 할 수 있는 상태인 경우
                    RegisterSend();
            }
        }

        // ...

        void RegisterSend() // 해당 함수로 들어오는 시점에 _pendingList 는 언제나 null
        {
            // _sendQueue의 데이터들을 List로 연결하여 담아주게 되면 이를 SendAsync() 에 한번에 보낼 수 있다.
            // ~> 즉 _sendQueue에 있는 데이터 1개당 SendAsync() 를 1번씩 호출하는 것을 보완
            while (_sendQueue.Count > 0)
            {
                byte[] buff = _sendQueue.Dequeue();

                // _sendArgs.BufferList에 직접 Add할 경우 오류 발생
                _pendingList.Add(new ArraySegment<byte>(buff, 0, buff.Length)); // ArraySegment 는 어떤 배열의 일부를 나타내는 구조체이다.
            }
            _sendArgs.BufferList = _pendingList;         

            bool pending = _socket.SendAsync(_sendArgs);
            if (pending == false)
                OnSendCompleted(null, _sendArgs);
        }

        void OnSendCompleted(object sender, SocketAsyncEventArgs args)
        {
            lock (_lock)
            {
                if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success) // 데이터들을 성공적으로 보낸 경우
                {
                    try
                    {
                        // 초기화
                        _sendArgs.BufferList = null;
                        _pendingList.Clear();

                        if (_sendQueue.Count > 0)
                            RegisterSend();
                    }
                    catch (Exception e)
                    {
                        Console.WriteLine($"OnSendCompleted Failed {e}");
                    }
                }
                else
                {
                    Disconnect();
                }
            }
        }

        // ...
    }
}​

 

+ 추가 정리

- 위의 코드 역시 완벽하다고 할 수 없다.

 ~> RegisterSend() 에서 _sendQueue를 무조건 비워 모든 정보를 한번에 보내고 있는데, 일정 짧은 시간 동안 몇 Byte를

      보냈는지 추적하여 너무 과하게 보낸다 싶으면 쉬어 가며 보내는 것이 성능적으로 더 좋다.

 ~> 또한 상대방이 악의적으로 의미없는 정보를 담은 다수의 패킷을 보내는 경우 이를 체크하여 비정상적이다 싶으면

      받지 않도록 하는 것이 좋다.

 ~> 또한 때에 따라서는 패킷 자체를 1번에 작은 단위로 보내는 것이 아닌 뭉쳐 보내야 하는 경우도 존재한다.

 

# Session #4

- 추후를 위해 Engine과 Content를 분리하고자 한다.

 ~> event handler 를 통한 방법과 상속을 통한 방법이 존재한다.

 ~> 상속을 통한 방법이 더 간단하기 때문에 Session을 상속 받은 GameSession Class를 생성하여

      Engine과 Content를 분리해 볼 것이다.

- Content 에서는 Session을 상속받은 GameSession을 생성하여 실행하고자 하는 코드들을 추가하고,

  Engine 에서는 GameSession 내의 함수들이 무엇을 실행하는지는 잘 모르겠지만 적절한 타이밍에 해당 함수들을

  호출만 해주는 것이다.

Engine과 Content를 분리하기 위한 Session Class 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;

namespace ServerCore
{
    abstract class Session
    {
        // ...

        // Session을 상속받은 Class에서 구현하도록 abstract로 선언
        // Engine에서 구현하는 것이 아닌 Content에서 구현하는 것이다.
        // Engine에서는 해당 함수를 호출만 한다.
        public abstract void OnConnected(EndPoint endPoint); // OnConnected() 함수는 엄밀히 말하면 Listener Class 에서 호출 (즉, Session Class 에서는 호출 X)
        public abstract void OnRecv(ArraySegment<byte> buffer);
        public abstract void OnSend(int numOfBytes);
        public abstract void OnDisconnected(EndPoint endPoint);

        // ...

        public void Disconnect()
        {
            if (Interlocked.Exchange(ref _disconnected, 1) == 1)
                return;

            OnDisconnected(_socket.RemoteEndPoint); // OnDisconnected() 함수 호출 (RemoteEndPoint는 본인과 연결된 대상)

            _socket.Shutdown(SocketShutdown.Both);
            _socket.Close();
        }

        // ...

        void OnSendCompleted(object sender, SocketAsyncEventArgs args)
        {
            lock (_lock)
            {
                if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
                {
                    try
                    {
                        _sendArgs.BufferList = null;
                        _pendingList.Clear();

                        OnSend(_sendArgs.BytesTransferred); // OnSend() 함수 호출

                        if (_sendQueue.Count > 0)
                            RegisterSend();
                    }
                    catch (Exception e)
                    {
                        Console.WriteLine($"OnSendCompleted Failed {e}");
                    }
                }
                else
                {
                    Disconnect();
                }
            }
        }


        // ...

        void OnRecvCompleted(object sender, SocketAsyncEventArgs args)
        {
            if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
            {
                try
                {
                    OnRecv(new ArraySegment<byte>(args.Buffer, args.Offset, args.BytesTransferred)); // OnRecv() 함수 호출

                    RegisterRecv();
                }
                catch (Exception e)
                {
                    Console.WriteLine($"OnRecvCompleted Failed {e}");
                }
            }
            else
            {
                Disconnect();
            }
        }
    }
}

 

Engine과 Content를 분리하기 위한 Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using static System.Collections.Specialized.BitVector32;

namespace ServerCore
{
    class GameSession : Session // Session Class를 상속 받은 GameSession Class 생성
    {
        public override void OnConnected(EndPoint endPoint) // OnConnected() 함수 override
        {
            Console.WriteLine($"OnConnected : {endPoint}");

            // 보낸다.
            byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server!");
            Send(sendBuff);

            Thread.Sleep(1000);

            // 쫓아낸다.
            Disconnect();
        }

        public override void OnDisconnected(EndPoint endPoint) // OnDisconnected() 함수 override
        {
            Console.WriteLine($"OnDisconnected : {endPoint}");
        }

        public override void OnRecv(ArraySegment<byte> buffer) // OnRecv() 함수 override
        {
            string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
            Console.WriteLine($"[From Client] {recvData}");
        }

        public override void OnSend(int numOfBytes) // OnSend() 함수 override
        {
            Console.WriteLine($"Transferred bytes : {numOfBytes}");
        }
    }

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

        // 기존에는 OnAcceptHandler() 를 call back 하는 방식이였다.
        //  ~> 잘못된건 아니지만 Session Class 를 상속받은 다양한 Session 개념 등장으로 수정
        //  ~> OnAcceptHandler() 함수 내의 session.Start(clientSocket) 는 Content 보단 Engine 에 있는것이 적절

        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);

            // 손님을 입장시킨다.
            // 따라서 어떤 Session을 만들지를 Init() 매개변수로 전달하면 Engine에서 이를 처리해주는 방식으로 수정
            _listener.Init(endPoint, () => { return new GameSession(); }); // Func<Session> 을 Lambda 식으로 작성
            Console.WriteLine("Listening...");

            while (true)
            {
                
            }
        }
    }
}​

 

Engine과 Content를 분리하기 위한 Listener Class 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;

namespace ServerCore
{
    internal class Listener
    {
        Socket _listenSocket;

        Func<Session> _sessionFactory; // Session을 어떤 방식으로, 어떤 Session 만들 것인지

        // GameSession Class 는 Content 쪽에 존재하기 때문에 
        // Listenr와 Session과 같이 Engine 쪽에서 new 를 통해 생성하지 않도록 
        // Init()의 delegate 매개변수를 통해 어떤 Session을 만들지에 대한 함수를 인자로 받은 뒤
        // 해당 함수를 _sessionFactory event 구독 신청을 통해 실행되도록 수정
        public void Init(IPEndPoint endPoint, Func<Session> sessionFactory)
        {
            // 문지기 고용
            _listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);

            _sessionFactory += sessionFactory; // sessionFactory 함수가 _sessionFactory event를 구독 신청

            // 문지기 교육
            _listenSocket.Bind(endPoint);

            // 영업 시작
            // backlog : 최대 대기 수
            _listenSocket.Listen(10);

            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) 
            {
                // 받는다.
                Session session = _sessionFactory.Invoke(); // _sessionFactory 를 통한 GameSession 생성 (Content 딴에서 요구한 방식대로 Session 생성)
                session.Start(args.AcceptSocket);
                session.OnConnected(args.AcceptSocket.RemoteEndPoint);
            }
            else
                Console.WriteLine(args.SocketError.ToString());

            RegisterAccept(args);
        }
    }
}​

 

# Connector

- 현재 DummyClient 에서 Connect() 는 Blocking 함수이다.

 ~> 게임에서 Blocking 함수 사용은 지양해야하므로 이를 NonBlocking 방식으로 구현하여 해결할 수 있다.

- 분산 Server 구현시 Server 끼리 통신하기 위해서는 한쪽은 Listener의 역할을, 한쪽은 Connector의 역할을 해야만 한다.

Connect를 NonBlocking 방식으로 구현하기 위한 Connector Class 생성
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;

namespace ServerCore
{
    class Connector
    {
        Func<Session> _sessionFactory; // Session을 어떤 방식으로, 어떤 Session 만들 것인지

        public void Connect(IPEndPoint endPoint, Func<Session> sessionFactory)
        {
            // 휴대폰 설정
            Socket socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
            _sessionFactory = sessionFactory; 

            SocketAsyncEventArgs args = new SocketAsyncEventArgs();
            args.Completed += OnConnectCompleted;
            args.RemoteEndPoint = endPoint; // 연결하기 위해 상대방의 주소를 넘겨준다.
            args.UserToken = socket; // UserToken을 통해 원하는 정보를 넘겨줄 수 있다.
            // socket을 전역변수로 선언하는 것이 아닌 UserToken을 통해 정보를 넘겨주는 이유는
            // Listener 가 계속해서 뺑뺑이를 돌며 여러명을 받을 수 있듯이
            // Connector 역시 여러명을 받을 수 있기 때문이다.

            RegisterConnect(args);
        }

        void RegisterConnect(SocketAsyncEventArgs args)
        {
            Socket socket = args.UserToken as Socket;
            if (socket == null)
                return;

            bool pending = socket.ConnectAsync(args);
            if (pending == false)
                OnConnectCompleted(null, args);
        }

        void OnConnectCompleted(object sender, SocketAsyncEventArgs args)
        {
            if (args.SocketError == SocketError.Success)
            {
                Session session = _sessionFactory.Invoke(); // _sessionFactory 를 통한 Session 생성 (Content 딴에서 요구한 방식대로 Session 생성)
                session.Start(args.ConnectSocket);
                session.OnConnected(args.RemoteEndPoint);
            }
            else
            {
                Console.WriteLine($"OnConnectCompleted Fail: {args.SocketError}");
            }
        }
    }
}​

 

- 현재 Connector를 DummyClient 에서 사용할 수 없다. (Connector는 ServerCore의 Class)

 ~> 그렇다고 ServerCore의 Connector, Listener, Session Class를 복사하여 DummyClient 에 붙여넣는 것은 세련되지 X

- 추후에 DummyClient, Server, ServerCore 를 동시에 사용하게 된다.

 ~> 이때 ServerCore 는 실제로 실행되는 것이 아닌 라이브러리로만 사용할 예정이다.

- ServerCore를 라이브러리로 세팅한 뒤 DummyClient와 Server는 ServerCore 라이브러리를 참조하도록 설정한다.

 

- 우선 [ ServerCore 프로젝트 ] - [ 오른쪽 마우스 ] - [ 속성 ] 의 [ 애플리케이션 ] - [ 일반 ] 에서 출력 유형을

  [ 콘솔 애플리케이션 ] 이 아닌 [ 클래스 라이브러리 ] 를 선택한다.

 ~> 클래스 라이브러리 선택시 해당 프로젝트는 독립적으로 실행할 수 없다.

 ~> 즉, 다른 프로젝트에 기생하여 간접적으로 실행될 수 있다.

 

- 그 후 [ DummyClient / Server 프로젝트 ] - [ 오른쪽 마우스 ] - [ 추가 ] - [ 프로젝트 참조 ] 의 [ 프로젝트 ] 에서 ServerCore

  를 선택한다.

 ~> 이를 통해 DummyClient 와 Server 프로젝트는 ServerCore 라이브러리를 참조한다.

더이상 ServerCore의 Program Class 는 사용하지 않으므로 해당 내용을 Server의 Program Class로 복사한 뒤 삭제

(Server 입장에서 ServerCore는 다른 프로젝트이므로 ServerCore의 Class들의 보호 수준을 public으로 변경)
(이를 통해 Server는 Content, ServerCore는 Engine의 역할을 맡은 것)

using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using ServerCore; // Servercore 라이브러리 참조

namespace Server
{
    class GameSession : Session
    {
        public override void OnConnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnConnected : {endPoint}");

            // 보낸다.
            byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server!");
            Send(sendBuff);

            Thread.Sleep(1000);

            // 쫓아낸다.
            Disconnect();
        }

        public override void OnDisconnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnDisconnected : {endPoint}");
        }

        public override void OnRecv(ArraySegment<byte> buffer)
        {
            string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
            Console.WriteLine($"[From Client] {recvData}");
        }

        public override void OnSend(int numOfBytes)
        {
            Console.WriteLine($"Transferred bytes : {numOfBytes}");
        }
    }

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

        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);

            // 손님을 입장시킨다.
            _listener.Init(endPoint, () => { return new GameSession(); });
            Console.WriteLine("Listening...");

            while (true)
            {

            }
        }
    }
}​

 

 

- [ 솔루션 ] - [ 오른쪽 마우스 ] - [ 속성 ] 의 [ 공용 속성 ] - [ 시작 프로젝트 ] 에서 [ 한 개의 시작 프로젝트 ] 가 아닌

  [ 여러 개의 시작 프로젝트 ] 를 선택한 뒤 DummyClient 와 Server 프로젝트의 작업 상태를 [ 없음 ] 에서 [ 시작 ] 으로,

  ServerCore 프로젝트의 작업 상태를 [ 시작 ] 에서 [ 없음 ] 으로 변경한다.

Connector Class 사용을 위한 Client 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using ServerCore; // Servercore 라이브러리 참조

namespace DummyClient
{
    // GameSession Class 추가
    class GameSession : Session
    {
        public override void OnConnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnConnected : {endPoint}");

            // 보낸다
            byte[] sendBuff = Encoding.UTF8.GetBytes("Hello World");
            Send(sendBuff);
        }

        public override void OnDisconnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnDisconnected : {endPoint}");
        }

        public override void OnRecv(ArraySegment<byte> buffer)
        {
            string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
            Console.WriteLine($"[From Server] {recvData}");
        }

        public override void OnSend(int numOfBytes)
        {
            Console.WriteLine($"Transferred bytes : {numOfBytes}");
        }
    }

    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);

            // Connector Class 사용
            Connector connector = new Connector();
            connector.Connect(endPoint, () => { return new GameSession(); });

            try
            {
                
            }
            catch (Exception e)
            {
                Console.WriteLine(e.ToString());
            }
        }
    }
}​

 

# TCP vs UDP

- 게임에서는 패킷 단위로 통신이 이루어진다.

 

# RecvBuffer

- TCP 특성상 (일부만 보내는 흐름/혼잡제어) Client 에서 보낸 패킷이 100 Byte 라고 해서 100 Byte가 완전히 도착한다는

 보장은 없다.

 ~> 100 Byte 미만의 패킷이 도착한 경우 이를 바로 처리할 수 없기 때문에 이를 recvBuffer에 보관만 하고 있다가

      추후에 나머지 패킷이 마저 도착하면 이를 조립한 뒤 한번에 처리할 수 있도록 수정한다.

RecvBuffer 개선을 위한 RecvBuffer Class 생성
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ServerCore
{
    public class RecvBuffer
    {
        // byte 배열을 들고 있어도 되지만 byte 를 들고 있는 이유?
        // ~> 엄청 큰 Byte 배열에서 부분적으로 잘라 사용하고 싶을 수도 있기 때문
        ArraySegment<byte> _buffer;

        // _readPos와 _writePos는 Buffer에서 커서 역할을 한다.
        // Buffer의 크기는 8Byte, 1패킷이 3Byte 라고 가정했을때 아래의 예시를 살펴보자.
        // [r w] [ ] [ ] [ ] [ ] [ ] [ ] [ ]  (초기 상태)
        // [r] [ ] [ ] [w] [ ] [ ] [ ] [ ]  (Client로부터 3Byte를 받은 상태)
        // [ ] [ ] [ ] [r w] [ ] [ ] [ ] [ ]  (Content 코드에서 1패킷을 처리한 상태)
        int _readPos;
        int _writePos;

        // 생성자
        public RecvBuffer(int bufferSize)
        {
            _buffer = new ArraySegment<byte>(new byte[bufferSize], 0, bufferSize);
        }

        public int DataSize { get { return _writePos - _readPos; } }
        public int FreeSize { get { return _buffer.Count - _writePos; } }

        public ArraySegment<byte> ReadSegment // 현재까지 받은 데이터의 유효 범위가 어디서부터 어디까지인가?
        {
            get { return new ArraySegment<byte>(_buffer.Array, _buffer.Offset + _readPos, DataSize); }
        }

        public ArraySegment<byte> WriteSegment // 다음 Recv시 빈 공간의 유효 범위가 어디서부터 어디까지인가?
        {
            get { return new ArraySegment<byte>(_buffer.Array, _buffer.Offset + _writePos, FreeSize); }
        }

        // Clean 함수를 통해 Buffer를 정리하지 않을 경우 [ ] [ ] [ ] [ ] [ ] [ ] [r] [w] 와 같이 _readPos와 _writePos가 Buffer의 끝까지 밀릴 수 있다.
        // _readPos와 _writePos의 위치가 다른 경우 _readPos 부터  _writePos - 1 까지의 데이터를 복사한 뒤 _writePos 를 이동시킨다.
        // _readPos와 _writePos의 위치가 같은 경우 데이터를 복사하지 않고 _readPos 와  _writePos 를 Buffer의 시작 위치로 이동시킨다.
        public void Clean()
        {
            int dataSize = DataSize;
            if (dataSize == 0) // _readPos와 _writePos의 위치가 같은 경우 (Client에서 보낸 모든 데이터를 처리한 상태)
            {
                _readPos = _writePos = 0;
            }
            else // _readPos와 _writePos의 위치가 다른 경우
            {
                // 어느 배열의? 어디서부터? 어느 배열의? 어디로? 얼마만큼?
                Array.Copy(_buffer.Array, _buffer.Offset + _readPos, _buffer.Array, _buffer.Offset, dataSize);
                _readPos = 0;
                _writePos = dataSize;
            }
        }

        public bool OnRead(int numOfBytes) // Content 코드에서 성공적으로 데이터를 가공한 경우 해당 함수를 통해 커서 위치를 이동시킨다.
        {
            if (numOfBytes > DataSize)
                return false;
            _readPos += numOfBytes;
            return true;
        }

        public bool OnWrite(int numOfBytes) // Client로부터 데이터를 받은 경우 해당 함수를 통해 커서 위치를 이동시킨다.
        {
            if (numOfBytes > FreeSize)
                return false;
            _writePos += numOfBytes;
            return true;
        }
    }
}​

 

RecvBuffer Class 생성에 따른 Session Class 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;

namespace ServerCore
{
    public abstract class Session
    {
        // ...

        // RecvBuffer Class 사용
        RecvBuffer _recvBuffer = new RecvBuffer(1024);

        // ...

        public abstract int OnRecv(ArraySegment<byte> buffer); // 반환값을 void에서 int로 수정 (얼마만큼의 데이터를 처리했는지 반환하도록)
        
        // ...

        public void Start(Socket socket)
        {
            _socket = socket;

            _recvArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnRecvCompleted);
            
            // 아래 부분 삭제
            // _recvArgs.SetBuffer(new byte[1024], 0, 1024);

            _sendArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnSendCompleted);

            RegisterRecv();
        }

        // ...

        void RegisterRecv()
        {
            // 아래 부분 추가
            _recvBuffer.Clean();
            ArraySegment<byte> segment = _recvBuffer.WriteSegment;
            _recvArgs.SetBuffer(segment.Array, segment.Offset, segment.Count);

            bool pending = _socket.ReceiveAsync(_recvArgs);
            if (pending == false)
                OnRecvCompleted(null, _recvArgs);
        }

        void OnRecvCompleted(object sender, SocketAsyncEventArgs args)
        {
            if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
            {
                try
                {
                    // Write 커서 이동
                    if (_recvBuffer.OnWrite(args.BytesTransferred) == false) // BytesTransferred 는 수신 받은 Byte
                    {
                        Disconnect();
                        return;
                    }

                    // Content 쪽으로 데이터를 넘겨주고 얼마나 처리했는지 받는다.
                    int processLen = OnRecv(_recvBuffer.ReadSegment);
                    if (processLen < 0 | _recvBuffer.DataSize < processLen)
                    {
                        Disconnect();
                        return;
                    }

                    // Read 커서 이동
                    if (_recvBuffer.OnRead(processLen) == false)
                    {
                        Disconnect();
                        return;
                    }
                    
                    RegisterRecv();
                }
                catch (Exception e)
                {
                    Console.WriteLine($"OnRecvCompleted Failed {e}");
                }
            }
            else
            {
                Disconnect();
            }
        }
    }
}​

 

Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using ServerCore; // Servercore 라이브러리 참조

namespace Server
{
    class GameSession : Session
    {
        // ...

        // 반환값이 void 에서 int 로 수정됨에 따른 코드 수정
        public override int OnRecv(ArraySegment<byte> buffer)
        {
            string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
            Console.WriteLine($"[From Client] {recvData}");
            
            return buffer.Count;
        }

        // ...
    }

    // ...
}​

 

Client 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using ServerCore; // Servercore 라이브러리 참조

namespace DummyClient
{
    class GameSession : Session
    {
        // ...

        // 반환값이 void 에서 int 로 수정됨에 따른 코드 수정
        public override int OnRecv(ArraySegment<byte> buffer)
        {
            string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
            Console.WriteLine($"[From Server] {recvData}");
            
            return buffer.Count;
        }

        // ...
    }

    // ...
}​

 

# SendBuffer

- Session 마다 자신의 고유 RecvBuffer를 가진다.

 ~> Client 가 보내는 정보는 각기 다 다르기 때문에 이는 당연한 사실이다.

- 그러나 sendBuffer는 Session 마다 고유하지 않고 보내는 순간에 외부에서 만들어진다.

 ~> 이는 성능적 이슈와 관련이 있다.

 ~> 만약 100명의 User가 같은 Zone 안에 있는 경우 User 1명이 이동시 해당 User의 이동 정보를 나머지 99명에게 전부

      보내야 한다. 하지만 User 1명만 이동하는 것이 아니기 때문에 이동 패킷이 99 * 100 개가 전송되어야 한다.

- sendBuffer의 Size는 어떻게 결정해야 할까?

 ~> 만약 보내고자 하는 Class의 멤버 변수로 string 과 List 와 같이 가변적인 길이를 갖는다면 어떻게 해야 할까?

 ~> Thread 마다 자신만의 Chunk 를 크게 할당한 뒤 이를 계속해서 쪼개서 사용하도록 만든다.

SendBuffer 개선을 위한 SendBuffer Class 생성
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ServerCore
{
    public class SendBufferHelper
    {
        // ThreadLocal : 본인의 Thread 에서만 사용 가능한 전역 변수 (Thread 끼리의 경합을 없애고자 사용)
        public static ThreadLocal<SendBuffer> CurrentBuffer = new ThreadLocal<SendBuffer>(() => { return null; });

        public static int ChunkSize { get; set; } = 4096 * 100;

        public static ArraySegment<byte> Open(int reserveSize)
        {
            if (CurrentBuffer.Value == null) // 한번도 사용하지 않은 경우
                CurrentBuffer.Value = new SendBuffer(ChunkSize); // 새로 생성

            if (CurrentBuffer.Value.FreeSize < reserveSize) // 사용한 적은 있지만 요구한 Size보다 FreeSize가 더 작은 경우
                CurrentBuffer.Value = new SendBuffer(ChunkSize); // 기존 Chunk 를 날린뒤 새로운 Chunk 로 교체

            return CurrentBuffer.Value.Open(reserveSize);
        }

        public static ArraySegment<byte> Close(int usedSize)
        {
            return CurrentBuffer.Value.Close(usedSize);
        }
    }

    public class SendBuffer
    {
        byte[] _buffer;

        // _usedSize는 Buffer에서 커서 역할을 한다.
        // Buffer의 크기는 10Byte 라고 가정했을때 아래의 예시를 살펴보자.
        // [u] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] (초기 상태)
        int _usedSize = 0;

        public int FreeSize { get { return _buffer.Length - _usedSize; } }

        // 생성자
        public SendBuffer(int chunkSize)
        {
            _buffer = new byte[chunkSize];
        }

        public ArraySegment<byte> Open(int reserveSize) // 최대 reserveSize 만큼의 Buffer 공간을 사용하고자 한다.
        {
            if (reserveSize > FreeSize)
                return null;

            return new ArraySegment<byte>(_buffer, _usedSize, reserveSize);
        }

        public ArraySegment<byte> Close(int usedSize) // 실제로 사용한 Buffer 공간을 반환하고자 한다.
        {
            ArraySegment<byte> segment = new ArraySegment<byte>(_buffer, _usedSize, usedSize);
            _usedSize += usedSize;
            return segment;
        }

        // Send는 1명이 아닌 여러명에게 보내는 경우도 있기 때문에 본인이 Send 하였다고 해서 막바로 Clean 할 수 X
        // (옮기고자 하는 곳을 다른 누군가가 아직 참조중일 수도 있기 때문)
        // 따라서 SendBuffer는 Clean 하는 것이 아닌 1회용으로 사용한다.
    }
}​

 

SendBuffer Class 생성에 따른 Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using ServerCore; // Servercore 라이브러리 참조

namespace Server
{
    class Knight
    {
        public int hp;
        public int attack;
        public string name;
        public List<int> skills = new List<int>();
    }

    class GameSession : Session
    {
        public override void OnConnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnConnected : {endPoint}");

            Knight knight = new Knight() { hp = 100, attack = 10 };

            ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
            
            byte[] buffer = BitConverter.GetBytes(knight.hp);
            byte[] buffer2 = BitConverter.GetBytes(knight.attack);
            
            // 어느 배열의? 어디서부터? 어느 배열의? 어디로? 얼마만큼?
            Array.Copy(buffer, 0, openSegment.Array, openSegment.Offset, buffer.Length);
            Array.Copy(buffer2, 0, openSegment.Array, buffer.Length, buffer2.Length);
            
            ArraySegment<byte> sendBuff = SendBufferHelper.Close(buffer.Length + buffer2.Length);

            Send(sendBuff);
            
            Thread.Sleep(1000);
            
            // 쫓아낸다.
            Disconnect();
        }

        // ...
    }

    // ...
}​

 

Session 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;

namespace ServerCore
{
    public abstract class Session
    {
        // ...
        
        Queue<ArraySegment<byte>> _sendQueue = new Queue<ArraySegment<byte>>(); // Queue의 타입을 byte[] 에서 ArraySegment<byte> 로 수정
        
        // ...

        // 매개변수의 타입을 byte[] 에서 ArraySegment<byte> 로 수정
        public void Send(ArraySegment<byte> sendBuff)
        {
            lock (_lock)
            {
                _sendQueue.Enqueue(sendBuff);

                if (_pendingList.Count == 0)
                    RegisterSend();
            }
        }

        // ...

        void RegisterSend()
        {
            while (_sendQueue.Count > 0)
            {
                ArraySegment<byte> buff = _sendQueue.Dequeue(); // buff의 타입을 byte[] 에서 ArraySegment<byte> 로 수정
                _pendingList.Add(buff);
            }
            _sendArgs.BufferList = _pendingList;         

            bool pending = _socket.SendAsync(_sendArgs);
            if (pending == false)
                OnSendCompleted(null, _sendArgs);
        }

        // ...
    }
}​

 

# PacketSession

- 게임에서는 패킷 단위로 통신이 이루어진다.

 ~> 패킷 전용 OnRecv 를 만들 필요가 있다.

Session 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;

namespace ServerCore
{
    // abstract class PacketSession 추가
    public abstract class PacketSession : Session
    {
        public static readonly int HeaderSize = 2;

        // 패킷을 받은 경우 [ size (2Byte) ] [ packetId (2Byte) ] [ ... ]
        public sealed override int OnRecv(ArraySegment<byte> buffer) // sealed는 다른 Class가 PacketSession을 상속 받은 뒤 OnRecv를 override 하고자 할 경우 오류 발생
        {
            int processLen = 0; // 내가 몇 Byte 를 처리했는가

            while (true) // 패킷을 처리할 수 있을때까지 계속해서 반복
            {
                // 최소한 Header 는 Parsing 할 수 있는지 확인
                if (buffer.Count < HeaderSize) 
                    break;

                // 패킷이 완전체로 도착했는지 확인
                ushort dataSize = BitConverter.ToUInt16(buffer.Array, buffer.Offset); // ToUInt16은 Byte 배열을 ushort로 뽑아달라는 것
                if (buffer.Count < dataSize)
                    break;

                // 여기까지 왔으면 패킷 조립 가능
                OnRecvPacket(new ArraySegment<byte>(buffer.Array, buffer.Offset, dataSize)); // 패킷의 유효 범위를 넘겨준다.

                processLen += dataSize;
                buffer = new ArraySegment<byte>(buffer.Array, buffer.Offset + dataSize, buffer.Count - dataSize);
            }

            return processLen;
        }

        public abstract void OnRecvPacket(ArraySegment<byte> buffer);
    }

    // ...
}​

 

Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;

namespace Server
{
    class Packet // 패킷 설계시 최대한 Size를 압축하여 보내는 것이 중요하다.
    {
        // Packet이 완전체로 왔는지? 잘려서 왔는지? 구분할 수 있어야 한다.
        public ushort size; // ushort는 2Byte
        public ushort packetId; // ushort는 2Byte
    }

    class GameSession : PacketSession // Session 이 아닌 PacketSession 을 상속 받도록 수정
    {
        public override void OnConnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnConnected : {endPoint}");

            Packet packet = new Packet() { size = 100, packetId = 10 };

            ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
            byte[] buffer = BitConverter.GetBytes(packet.size);
            byte[] buffer2 = BitConverter.GetBytes(packet.packetId);
            // 어느 배열의? 어디서부터? 어느 배열의? 어디로? 얼마만큼?
            Array.Copy(buffer, 0, openSegment.Array, openSegment.Offset, buffer.Length);
            Array.Copy(buffer2, 0, openSegment.Array, buffer.Length, buffer2.Length);
            ArraySegment<byte> sendBuff = SendBufferHelper.Close(buffer.Length + buffer2.Length);

            Send(sendBuff);

            Thread.Sleep(1000);

            // 쫓아낸다.
            Disconnect();
        }

        // abstract 함수인 OnRecvPacket override 는 필수
        public override void OnRecvPacket(ArraySegment<byte> buffer)
        {
            ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset); // ToUInt16은 Byte 배열을 ushort로 뽑아달라는 것
            ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + 2);
            Console.WriteLine($"RecvPacketID: {id}, Size: {size}");
        }

        // Sealed 로 OnRecv 함수를 override 하는 것을 금지 시켰기 때문에 아래 부분 삭제
        //public override int OnRecv(ArraySegment<byte> buffer)
        //{
        //    string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
        //    Console.WriteLine($"[From Client] {recvData}");
        //    return buffer.Count;
        //}

        // ...
    }

    // ...
}​

 

Client 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using ServerCore; // Servercore 라이브러리 참조

namespace DummyClient
{
    class Packet
    {
        public ushort size; // ushort는 2Byte
        public ushort packetId; // ushort는 2Byte
    }

    class GameSession : Session
    {
        public override void OnConnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnConnected : {endPoint}");

            Packet packet = new Packet() { size = 4, packetId = 7 };

            for (int i = 0; i < 5; i++)
            {
                ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
                byte[] buffer = BitConverter.GetBytes(packet.size);
                byte[] buffer2 = BitConverter.GetBytes(packet.packetId);
                // 어느 배열의? 어디서부터? 어느 배열의? 어디로? 얼마만큼?
                Array.Copy(buffer, 0, openSegment.Array, openSegment.Offset, buffer.Length);
                Array.Copy(buffer2, 0, openSegment.Array, buffer.Length, buffer2.Length);
                ArraySegment<byte> sendBuff = SendBufferHelper.Close(packet.size);

                Send(sendBuff);
            }
        }

        // ...
    }

    // ...
}​

 

# 서버 프레임워크 요약 정리

+ Recent posts