Server programming

02_13_네트워크 프로그래밍_SendBuffer

devRiripong 2023. 4. 7.
반응형

확인 테스트

Q1. 작업한 것을 나열해 보세요.

Q2. send와 recv의 다른 점은 무엇인가?

Q3. 왜 sendBuffer는 sesseion안에 넣을 수 없는 걸까?

Q4. sendBuffer는 clean 기능 없이 1회용으로 사용하는 이유는?

Q5. SendBuffer를 보면 멀티 스레드 처리를 안해줘도 괜찮은 이유는?

Q6. 왜 Server에 sendBuff가 있으면 공용이 되고, Session에 있으면 고유의 버퍼를 갖게 되는건가?

 

답안

1.

-> Server에 Knight 클래스를 추가한다

-> Server의 OnConnected(EndPoint endPoint)의 코드를 수정한다.

메시지가 아닌 sendBuff를 생성하고, Knight를 byte[]로 전환해 Send에 전달하게 바꾼다.

-> ServerCore 프로젝트에 SendBuffer 클래스 파일을 하나 생성한다. 외부에서도 사용할 거니 public으로 바꾼다.

byte[] _buffer;

int _usedSize = 0; // recvBuffer에서 wrtieSize에 해당

public int FreeSize { get { return _buffer.Length - _usedSize; } } 추가한다. Open, Close 인터페이스 추가한다.

Open은 사용 예정인 크기로 ArraySegement<byte>를 할당해 리턴하고, Close는 _usedSize를 업데이트하고 사용한 만큼을 할당해 리턴한다.

-> SendBuffer 클래스 위에 SendBufferHelper라는 헬퍼클래스를 만들어 준다.

public static ThreadLocal<SendBuffer> CurrentBuffer를 생성하고,

public static int ChunkSize { get; set; } = 4096 * 100; 를 추가한다.

->SendBuffer 클래스에 생성자를 만들어 준다. public SendBuffer(int chunkSize) { _buffer = new byte[chunkSize]; }

->SendBufferHelper 클래스에 SendBuffer의 Open, Close를 쉽게 쓸 수 있게 Wraping한 함수를 정의해준다.

->Server의 GameSession의 OnConnected를 수정해 준다.

SendHelper의 Open, Close를 이용해서 sendBuffer 데이터 넣고 Send에 전달한다.

->byte[]를 ArraySegment<byte>로 수정해준다.

Session의 Send함수의 매개변수를 수정해준다.

Session의 Queue<byte[]> _sendQueue = new Queue<byte[]>();

Session의 void RegisterSend()를 수정해 준다.

 

2.

Recv는 안에 recvBuff를 생성하지만, Send는 바깥에 생성했다.

Session 내부에서 생성된 **_recvBuffer**는 각 Session 객체별로 고유한 RecvBuffer를 가집니다. 이는 Session 객체가 서로 다른 클라이언트와 연결되어 있을 때 각각의 클라이언트에서 전송된 데이터를 구분하여 처리할 수 있도록 하기 위함입니다.

반면에 Session 객체 바깥에서 생성된 **sendBuffer**는 Send로 값을 전달받은 Session 객체들이 공유해서 사용하게 됩니다.

3.

복사를 너무 많이 하게 되어 성능이 하락한다.

4.

Send하면 sendBuff의 내용이 일단 _sendQueue에 저장되는데 아직 참조된 상태에서 Clean 해버릴 위험이 있기 때문이다.

5.

다른 스레드가 접근할 때는 다른 애가 이어서 할 수 있는 RegisterSend의 경우 읽기만 하기 때문이다. 쓰는 건 처음에 Open, Close 할 때만인데 ThreadLocal을 쓰고 있기 때문에 다른 스레드가 끼어들 수 없다.

6.

Server에서 sendBuff 생성하면 한번 만들어서 여러 클라이언트에 Send하면 하나를 여러 클라이언트의 Session에서 공유해서 쓰는게 된다. Session안에서 생성한 recvBuff의 경우 Session 자체가 각 서버, 클라이언트마다 고유로 갖고 있는 것이기 때문에 각 서버, 클라이언트 마다 생성이 따로 되면서 고유의 버퍼의 역할을 하게 된다.

 

 

지난 시간에 만든 RecvBuffer와 짝꿍

SereverCore에 SendBuffer라는 새 클래스를 만든다.

RecvBuffer보다 좀 더 어렵다. 애당초 완전히 철학이 달라지게 된다. 이게 무슨 말이냐

기존의 Send하는 방식을 살펴보면 Server의 OnConnected에서

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

            **byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server !");**
            **Send**(sendBuff);
// 여기서 Send를 하고 있었어.  
            Thread.Sleep(10000);
            Disconnect();
        }

이렇게 외부에서 sendBuff같은 바이트 배열을 만든 다음에 Send라는 인터페이스에 넣어주고 있었어.

그럼 Session에서

public void Send(byte[] sendBuff)   
        {
            lock (_lock)
            {
                **_sendQueue.Enqueue(sendBuff);**
                if (_pendingList.Count == 0)
                    RegisterSend();
            }           
        }

_sendQueue에서 버퍼를 일단 저장을 해 놓은 다음에

나중에 시간이 될 때 RegisterSend에 가서 순차적으로 보내는 그런 작업을 하고 있었어.

sendBuff와 recvBuff의 가장 다른 점

recvBuff는

public abstract class Session
    {
        Socket _socket;
        int _disconnect = 0;

        RecvBuffer _recvBuffer = new RecvBuffer(1024);

이런 식으로 안에 있었어.

Session마다 자신의 고유한 recvBuffer 가지고 있었어.

그렇게 구현하는게 당연한 이유는 애당초 모든 Session들이, 즉 클라이언트가 보내는 정보들이 각기 다를거야. 요청사항이 다를테니까 자신의 개인적인 공간이 있어서 유저들마다 무슨 정보를 보내고 있는지를 추적을 한 다음에 한번씩 정리하고 이런 작업을 하고 있었어.

// [ ][ ][r][ ][ ][w][ ][ ][ ][ ]

그럴 이유가 클라이어트 마다 보내는 정보들이 각기 다르기 때문이다.

세션이랑 RecvBuffer가 1:1 관계였기 때문에 내부적으로 관리할 수 있었던 거다.

SendBuffer의 경우는 안에 있는게 아니라 밖으로 빠져있다.

Server의 GameSession 클래스의 OnConnect에서 어떻게 했는지 보면

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

    **byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server !!");**
    **Send**(**sendBuff**);
    Thread.Sleep(5000);
    Disconnect();
}

Send할 때 외부에서 byte를 만들어 주고있었어.

(이 버퍼 객체를 Session 객체 바깥에서 생성하고, 세션들이 이를 참조하도록 하면 모든 세션에서 같은 버퍼 객체를 공유하여 사용할 수 있다. 같은 값을 Send에 넣어서 각 클라이언트에 보내는 식으로 공유해서 사용할 수 있다는 의미다.

하지만 전역으로 사용할 수 있다는 것과는 다른 의미다. Send 하는 순간 각각 다른 스레드의 Session이 작동하기 때문에 그 때부터는 sendBuffer를 공유하지 않고 Send에 넣기 전 까지의 값을 공유한다는 말이다. 여러 클라이언트에 Send하는 순간 각각 다른 스레드의 Session이 작동합니다. 이때 sendBuffer는 Session 객체 내부에서 사용되기 때문에 스레드 간에 공유되지 않습니다. 대신, SendBufferHelper를 사용하여 sendBuffer를 생성하고, 이를 이용하여 ArraySegment<byte>를 만들어서 Send 메서드의 인자로 전달합니다. 이때 sendBuffer에 들어있는 값을 ArraySegment<byte>에 복사하여 사용하게 되므로, Send 이전까지는 sendBuffer의 값이 공유되는 것이 맞습니다. )

지금은 간단하게 하려고 문자열을 보냈지만 나중에 게임을 복잡하게 만든다고 하면 패킷으로 만들어서 보낼 거야.

어떤 느낌인지 미리 예습을 해보자면 Server에 Knight라는 클래스가 있는데

class Knight
{ 
		public int hp;
		public int attack; 
} 

이렇게 두개의 정보가 있다고 가정을 하면

이제는 Server의 OnConnect에 Encoding.UTF8.GetBytes("Welcome to MMORPG Server !!"); 이런식으로 문자열을 보내는 게 아니고

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

          **Knight knight = new Knight() { hp = 100, attack = 10 };**
// sendBuff에 어떻게든 Knight 정보를 넣어줘야 한다는 건데 
// 여러 방법이 있지만 BitConverter를 사용해보자.

          **byte[] sendBuff = new byte[1024];** // 이런식으로 바이트를 할당해야 한다.
**//** Encoding.UTF8.GetBytes("Welcome to MMORPG Server !!");
          **byte[] buffer = BitConverter.GetBytes(knight.hp); // int형 값을 byte 배열로 바꿔준다. 
// 4바이트라는 buffer에 100이라는 값이 들어간다. 
          byte[] buffer2 = BitConverter.GetBytes(knight.attack); // 똑같이 추출한 다음에
// sendBuffer에 넣어준다
          Array.Copy(buffer, 0, sendBuff, 0, buffer.Length); 
          Array.Copy(buffer2, 0, sendBuff, buffer.Length, buffer.Length); 
// 최종적으로 sendBuff를 Send에 밀어 넣어준다.
// 1024로 할당했지만 8바이트만 필요했어.** 
          Send(sendBuff);
          Thread.Sleep(10000);
          Disconnect();
      }

여기서 궁금한 점은 왜 sendBuff는 안에다가 넣을 수 없는지 궁금할 수 있어

애당초 sendBuff를 Session안에다가 만들어준 다음에

정보를 Send 인터페이스를 통해 받은 다음에

Session의 Send내부에서 우리가 했던 복사작업을 하면 어떨까 생각이 들거야.

그러면 똑같은 거 아닌가 싶기도 하다. 그렇게 할 수도있다. 첫번째 옵션이긴 하다.

내부에서 복사를 하는 방식은 문제가 되는 경우가 무엇이냐 하면 성능적 이슈가 있다

MMORPG를 만들 때 유저가 같은 존 안에 100명 있다고 가정을 해보자.

100명의 정보가 계속 바뀔테니까 패킷으로 나머지 유저들에게 알려줘야 하는데

만약 유저 1명이 이동했다 가정을 하면 걔의 이동 패킷이 나머지 100명에게 전송이 되어야 한다.

근데 이 움직이는 작업을 모든 이용자가 움직이고 스킬을 쓸거야.

그러면 100*100 10000개 전송되어야 한다는 얘기다. 사실 Send를 하는 횟수가 굉장 빈번한데

public abstract class Session
    {
        Socket _socket;
        int _disconnect = 0;

        RecvBuffer _recvBuffer = new RecvBuffer(1024);
			  byte[] _sendBuff = new byte[1024]; 

이런 식으로 Session내부에다가 Send 버퍼를 두면 복사를 10000번씩 해야 한다.

하지만 외부에서

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

          Knight knight = new Knight() { hp = 100, attack = 10 };

          **byte[] sendBuff = new byte[1024];** 
          byte[] buffer = BitConverter.GetBytes(knight.hp); 
          byte[] buffer2 = BitConverter.GetBytes(knight.attack);
          Array.Copy(buffer, 0, sendBuff, 0, buffer.Length); 
          Array.Copy(buffer2, 0, sendBuff, buffer.Length, buffer2.Length); 
****
          **Send(sendBuff);**
          Thread.Sleep(5000);
          Disconnect();

이런식으로 한번만 만들어준 다음에 Send를 할 때 한번만 보내는게 아니라

for(; ; )
{
    Session.Send(sendBuff); 
}

이런 식으로 루프를 돌면서 보내게 될거야. 주번에 있는 인근의 유저들을 다 찾으면서 네들을 내상으로 Send를 해주면 복사 횟수가 현저하게 줄어들게 된다.

한번만 만들어서 100명에게 꽂아주면 되니까 불필요하게 복사하는 작업이 필요 없어지게 된다.

일반적으로 sendBuff는 Session 내부에 위치시키기 보다는 Session 외부에서 만들어준 다음에 꽂아 넣는 방식이 효율적이다.

어쨌든 Send를 하고 싶은 시점에서 밖에서 버퍼를 만들어준 다음에 밀어 넣어 주는 게 낫다는 거 까지는 이해를 했는데

여기서 또 발생하는 문제가

          **byte[] sendBuff = new byte[1024];** 

버퍼 사이즈를 뭐로 해야할지가 문제다.

Knight하나만 보내면 8바이트로 하면 된다. 매번마다 계산해서 만들어주면 좋겠다는 생각이 드는데, 까다로워 지는 경우가 고정 사이즈가 아닌 경우가 생긴다.

예를 들어

class Knight
    {
        public int hp;
        public int attack;
        public string name; 
				public List<int> skills = new List<int>();
    }

게임 ID 같은 name이 있다고 하자. string이 가변적인 사이즈야.

또 스킬 목록이 들어가면 Knight의 사이즈 크기를 계산하기 어려워질 것이다.

예측할 수 없으면 버퍼 사이즈를 무조건 크게 잡아 놓는게 하나의 방법이 될텐데

byte[] sendBuff = new byte[4096];

이렇게 하면 낭비가 심하다는 생각이 든다. 지금 8바이트 밖에 사용 안했는데 이렇게 크게 잡는건 낭비야.

큰 덩어리를 만들어서 조금씩 잘라서 사용하면 좋겠다는 생각이 든다.

// [ ][ ][r][ ][ ][w][ ][ ][ ][ ]

recvBuffer도 보면 w커서를 우측으로 이동하면서 쓰는 위치를 변경했던 것처럼 SendBuffer도 일단은 아주 크게 잡아놓은 다음에 계속 얘를 잘라서 사용한다면 효율적으로 사용할 수 있을거야.

오늘은 그걸 만들어 볼거야.

SendBuffer클래스로 돌아와서 구현을 해보자.

using System;
using System.Collections.Generic;
using System.Text;

namespace ServerCore
{
    public class SendBuffer
    {
        // [u] [] [] [] [] [] [] [] [] []  이해를 돕기 위해 10바이트라고 가정
// 사용할 떄 마다 우측으로 이동
        byte[] _buffer;
        int _usedSize = 0; // 얼마만큼 사용했는지. recvBuffer에서 wrtieSize에 해당

        public int FreeSize { get { return _buffer.Length - _usedSize;  } }
// _buffer.Length가 최대 크기이고 거기다가 _usedSize를 빼주면 남은 공간이 된다.

        public ArraySegment<byte> Open(int reserveSize) // 얼마만큼의 사이즈를 최대치로 사용할 건지 넣어준.
        {
            if (reserveSize > FreeSize) // 예약공간보다 남은 공간이 더 적다면 버퍼를 더이상 사용 못하니 null 리턴
                return null;

            return new ArraySegment<byte>(_buffer, _usedSize, reserveSize); 
// 그게 아니라면 작업할 영역 찝어 주는거. 
// _usedSize는 사용할 거라는 거지 아직 사용한게 아니니까 변동을 주지 않을거야. 
// 아까 예를 들어 설명할 때 할당할 4096을 resrveSize에 넘겨줄거야. 
// 다 쓴 다음에는 내가 실제로 사용할 사이즈를 Close의 int usedSize에 넣어주게 될거야. 
        }

        public ArraySegment<byte> Close(int usedSize) // 다 쓴 다음에는 실제로 사용한 사이즈를 넣어주게 될거야. 
        {
// 일단 Close를 했다는 건 확정을 했다는 거야. 
// [] [] [] [] [u] [] [] [] [] []
// u에서 3개를 찝어 줬는데 실제로 이런 저런 데이터를 넣다 보니까 실제로는 2바이트만 사용했어. 
// 컴펌을 해줘가지고 최종적으로 버퍼를 다썼다고 반환을 하는 그런 개념이 된다. 
// 여기선 _usedSize를 usedSize만큼 늘리는 개념이 되는데, 즉 u를 이동 시켜 줘야 하는데
// 그전에 다시 한번 유효 범위를 찝어서 리턴을 해주긴 할거야. 
            ArraySegment<byte> segment = new ArraySegment<byte>(_buffer, _usedSize, usedSize); 
// 처음 Open에서 3바이트 찝었었는데 실제로 2바이트만 사용했으니까 2바이트 만큼을 찝어서 반환을 해줄 것이다. 
            _usedSize += usedSize;
            return segment;  // 너가 사용한 범위는 여기까지 였다.라고 뱉어주게되는거. 
        }
    }
}

이게 간단하게 말한 SendBuffer의 양식.

sendBuffer에는 recieve 버퍼와는 다르게 정리하는 개념이 없다.

recvBuffer에서 clean에서 보면 찌끄레기 있나 없나 확인서 read, write 커서를 초기화 했었어. 땡겨 주면서 무한대로 재사용할 수 있었는데

sendBuffer의 경우에는 사용하다 반환해서 옮기기가 까다로운게 뭐냐면, 애당초 Send를 한게 한명한테만 보낸게 아니라 정말 여러명에게 쭉 보낼 수 있어. 그렇기 때문에 나는 비록 사용이 끝났지만 이전에 있던

// [] [] [] [] [u] [] [] [] [] []

이 부분을 다른 세션에서 애를 Send를 하기 위해서 Session에 있는 큐에다가

Queue<byte[]> _sendQueue = new Queue<byte[]>();

여기다가

_sendQueue.Enqueue(sendBuff);

넣어놓은 것일 수도 있기 때문에 u를 막바로 다시 밀어서 다시 사용하기가 애매하다. 누군가가 계속 참조하고 있을 수 있기 때문이다. 그렇기 떄문에 sendBuffer는 1회용으로만 사용할 예정이다.

그 부분을 이어서 만들어 볼건데,

얘를 사용하기 쉽게 SendBuffer에 헬퍼 클래스를 만들어 준다.

public class SendBufferHelper
    {
// SendBuffer의 경우에는 여러  방법이 있겠지만 한번만 만들어준 다음에 
// 걔를 고갈 될 떄 까지는 쭉 재사용을 할 예정이긴 한데 그렇기 떄문에 
// 전역으로 만들면 편하겠다는 생각이 들기는 하다.
// 하지만 멀티스레드 환경이니까 전역으로 만들게 되면 스레드들끼리 서로 경합을 하게 될거야. 
// 그래서 그렇게 해줄 수 없으니 전에 배운 **ThreadLocal**로 만든다.
// 전역은 전역인데 나의 스레드에서만 고유하게 사용할 수 있는 그런 전역이라 했어.  
        public static ThreadLocal<SendBuffer> CurrentBuffer 
= new ThreadLocal<SendBuffer>(() => { return null;  });
// ThreadLocal<SendBuffer>()에 들어갈 인자로 맨 처음에 만들어 줄 때 무엇을 해줄 것인가를 기입을 할건데
// 얘는 그냥 아무것도 안하고 null인 상태로만 반환을 해 달라고 만들어 준다.  

        public static int ChunkSize { get; set; } = 4096 * 100; 
// ChunkSize를 get,set해주도록 한다. 나중에 외부에서 ChunkSize를 바꾸고 싶으면 조절해주면 된다. 
    }

그리고 SendBuffer 클래스에 SendBuffer 생성자를 만들어 준다.

public SendBuffer(int chunkSize)
        {
            _buffer = new byte[chunkSize];  
        }

왜 굳이 Size가 아니라 chunkSize라고 하면 어마어마하게 크게 잡을거라는 메시지를 주기 위해

SendBufferHelper클래스에 Open, Close를 좀 더 쉽게 쓸 수 있게 Wraping해준다.

public static ArraySegment<byte> Open(int reserveSize)
{
    if (CurrentBuffer.Value == null) // 한번도 사용 안한 셈
        CurrentBuffer.Value = new SendBuffer(ChunkSize); // 만들어 준다.

    if (CurrentBuffer.Value.FreeSize < reserveSize)
        CurrentBuffer.Value = new SendBuffer(ChunkSize); // 기존 청크 날리고 새로운 아이로 교체를 한다.

// 여기까지 오면 현재 버퍼가 공간이 남아있다는 얘기 
    return CurrentBuffer.Value.Open(reserveSize); 
}

public static ArraySegment<byte> Close(int usedSize)
{
    return CurrentBuffer.Value.Close(usedSize);  // CurrentBuffer에서 그냥 매핑을 한거
}

이제는 이걸 어떻게 사용하면 되냐면 Server의 OnConnected에서 보내고 있던 부분을 조금 바꿔 보면

기존 코드

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

        Knight knight = new Knight() { hp = 100, attack = 10 };

        byte[] sendBuff = new byte[1024];
        byte[] buffer = BitConverter.GetBytes(knight.hp);
        byte[] buffer2 = BitConverter.GetBytes(knight.attack);
        Array.Copy(buffer, 0, sendBuff, 0, buffer.Length);
        Array.Copy(buffer2, 0, sendBuff, buffer.Length, buffer2.Length);

        Send(sendBuff);
        Thread.Sleep(1000);
        Disconnect();
    }
public override void OnConnected(EndPoint endPoint)
{
    Console.WriteLine($"OnConnected : {endPoint}");

    Knight knight = new Knight() { hp = 100, attack = 10 };

**// 일단 SendBufferHelper에서 Open을 할거야. 버퍼를 사용할 건데 최대 몇 바이트 사용할 것인가를 넣어주는 거.**
    **ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
// openSegment에 이 공간을 찝어 준 거니까, 여기다가 뭔가 값들을 적어줘야 한다.** 
    byte[] buffer = ****BitConverter.GetBytes(knight.hp);
    ****byte[] buffer2 = ****BitConverter.GetBytes(knight.attack);
// sendBuffer를 **openSegment**로 바꿔주면 된다.
   **** Array.Copy(buffer, 0, **openSegment.Array, openSegment.Offset,** buffer.Length);
    Array.Copy(buffer2, 0, **openSegment.Array, openSegment.Offset + buffer.Length,** buffer2.Length);
**//** 여기까지 했으면 계속 추적할거야. 몇바이트를 넣어 줬는지 추적을 할건데, 지금은 buffer.Length+buffer2.Length를 넣어줘서 8바이트 사용한 셈이야. 
// 이제 다 썼으니까 닫아줘 할건데 buffer.Length+buffer2.Length를 해서 8바이트를 사용했어 하고 Close를 해주는 거. 
// 그럼 ArraySegment<byte>를 뱉어 줄건데 이게 실질적으로 우리가 보내줘야 하는 SendBuffer가 되는거. 
		**ArraySegment<byte> sendBuff = SendBufferHelper.Close(buffer.Length + buffer2.Length);**

    Send(sendBuff);
    Thread.Sleep(1000);
    Disconnect();
}

이렇게 수정한다.

근데 지금까지 우리가 사용하던 Session의 Send하는 부분을 보면,

public void Send(byte[] sendBuff) 
        {
            lock (_lock)
            {
                _sendQueue.Enqueue(sendBuff);
                if (_pendingList.Count == 0)
                    RegisterSend();
            }
        }

byte[] sendBuff로 받고 있는데 byte[]형식으로 받을 필요가 없으니까 인터페이스를 수정한다.

public void Send(**ArraySegment<byte>** sendBuff)   
        {
            lock (_lock)
            {
                _sendQueue.Enqueue(sendBuff);
                if (_pendingList.Count == 0)
                    RegisterSend();
            }           
        }

얘가 일반적인 상황이었어. ArraySegment를 받을 수 있도록 Send를 바꿔줬고,

그리고 Session의 _sendQueue로 가서 보면

Queue<byte[]> _sendQueue = new Queue<byte[]>();

에서

Queue<ArraySegment<byte>> _sendQueue = new Queue<ArraySegment<byte>>();

이렇게 ArraySegment<byte>로 받을 수 있게 수정해 준다.

또 에러가 나는 부분이 없나 보면

void RegisterSend()
        {
            while (_sendQueue.Count > 0)
            {
                byte[] buff = _sendQueue.Dequeue();
                _pendingList.Add(new ArraySegment<byte>(buff, 0, buff.Length)); 
            }

            _sendArgs.BufferList = _pendingList; 

            bool pending = _socket.SendAsync(_sendArgs);
            if (pending == false)
                OnSendCompleted(null, _sendArgs); 
        }

네트워크 통신 부분도 수정을 해주자.

#region 네트워크 통신

        void RegisterSend()
        {
            while (_sendQueue.Count > 0)
            {
                **ArraySegment<byte> buff = _sendQueue.Dequeue();
                _pendingList.Add(buff);** 
            }

            _sendArgs.BufferList = _pendingList; 

            bool pending = _socket.SendAsync(_sendArgs);
            if (pending == false)
                OnSendCompleted(null, _sendArgs); 
        }

이렇게 해서 수정이 되었다.

인터페이스가 지금은 어려워보이는데 수동으로 만들어서 사용할 일은 없을거야. 나중에 패킷 시리얼라이즈 라는 개념이 들어가면 이런 것들 다 자동화 해서 처리할거라 사용하기 어렵다고 걱정할 거 없다.

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, openSegment.Offset + buffer.Length, buffer2.Length);
            ArraySegment<byte> sendBuff = SendBufferHelper.Close(buffer.Length + buffer2.Length);**

            Send(sendBuff);
            Thread.Sleep(1000);
            Disconnect();
        }

여기서 중요한 건 SendBuffer를 공용으로 관리하는 거에서 꺼내오는 게 중요했어.

(SendBufferHelper는 전역으로 공유되는 클래스입니다. 따라서 SendBufferHelper.Open과 SendBufferHelper.Close 메서드를 호출하여 사용하면, 해당 메서드 내부에서 공용으로 관리되는 SendBuffer를 꺼내와서 사용하는 것입니다. SendBufferHelper.Open 메서드를 호출하면, SendBuffer를 재활용하기 위해 꺼내오고, SendBufferHelper.Close 메서드를 호출하면 꺼내온 SendBuffer를 다시 반환하여 재사용할 수 있도록 합니다. 이렇게 함으로써, SendBuffer를 공유함으로써 발생할 수 있는 메모리 낭비를 최소화하고, 재사용을 통해 성능을 개선할 수 있습니다.)

public class SendBufferHelper
{
    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)
		        CurrentBuffer.Value = new SendBuffer(ChunkSize); // 기존 청크 날리고 새로운 아이로 교체를 한다.
		
				// 여기까지 오면 현재 버퍼가 공간이 남아있다는 얘기 
		    return CurrentBuffer.Value.Open(reserveSize); 
		}

		public static ArraySegment<byte> Close(int usedSize)
		{
		    return CurrentBuffer.Value.Close(usedSize);  // CurrentBuffer에서 그냥 매핑을 한거
		}
}

굳이 ThreadLocal로 한 건 쓰레드 끼리의 경합을 없애기 위해

Thread마다 자신의 청크를 할당한 다음에 쪼개서 계속 쓴다는 게 된다.

Server의 Program에서

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, openSegment.Offset + buffer.Length, buffer2.Length);
        ArraySegment<byte> sendBuff = SendBufferHelper.**Close**(buffer.Length + buffer2.Length);

        Send(sendBuff);
        Thread.Sleep(1000);
        Disconnect();
    }

이 부분은 4096이라는 어마어마하게 큰 값으로 잡았다가

데이터를 넣다 보니까 생각보다 많이 안썼네? 실질적으로는 8바이트만 이렇게 최종적으로 컨펌을 하니까 8바이트를 사용한 상태로 u를 이동시킨 것이 된다.

// [] [] [] [] [] [] [] [] [u] []

이렇게 버퍼를 열고, 닫고 하는 개념이 들어갔어.

참고로 이걸 나중에 C++로 만든다고 하면 여기서 더 개선할 부분이 많다.

지금은 버퍼 고갈 되면

public class SendBufferHelper
{
    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)
		        CurrentBuffer.Value = new SendBuffer(ChunkSize);** // 기존 청크 날리고 새로운 아이로 교체를 한다.
		

여기서 바꿔치기 하고 이전 것은 날려버렸어. C++이라면 여기서 조금 더 세세하게 컨트롤 할 수 있으니까 날리는게 아니라 reference counting을 관리해가지고 더이상 참조하는 애가 없다 싶으면 메모리를 해제하는게 아니라 SendBufferPool에다가 다시한번 개를 반환해서 나중에 재사용하는 방식으로 효율적으로 사용할 수 있을 거야. 그걸 굳이 C#에서 하는 건 너무 어려우니까 그건 하지 않을거고, 애당초 그렇게 세세하게 컨트롤 하고 싶으면 그냥 C++로 서버를 만드는게 낫다.

 

여기까지 만든게 잘 작동하는지 테스트를 해보면

Server에서 8바이트를 정상적으로 보낸 걸 볼 수 있다.

잘 되는 거 볼 수 있다.

일단은 잘 동작하는 거 같긴 한데 나중에 더미클라이언트 여러개 붙여서 실험해 보기 전까지는 실제로 잘 되는지 알기 힘들다.

 

보내는 걸 살펴보면

Server의 Program에서

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, openSegment.Offset + buffer.Length, buffer2.Length);
        ArraySegment<byte> sendBuff = SendBufferHelper.**Close**(buffer.Length + buffer2.Length);

        **Send**(sendBuff);
        Thread.Sleep(1000);
        Disconnect();
    }

SendBufferOpen을 하고, 배열에 정보를 밀어 넣은 다음에 Send를 한다.

// [] [] [] [] [] [] [] [] [u] [] 여기 배열에다가 정보를 밀어 넣은 다음에 걔를 Send를 하고 있었어.

Send를 한 다음에

Session에서

public void Send(ArraySegment<byte> sendBuff)
        {
            lock (_lock)
            {
                _sendQueue.Enqueue(sendBuff);
                if (_pendingList.Count == 0)
                    **RegisterSend**();
            }
        }

RegisterSend하는 부분을 다시 한번 보면 이제부터 멀티 스레드를 고려하고 생각해야 한다.

SendBuffer를 보면

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

            _sendArgs.BufferList = _pendingList; 

            bool pending = _socket.SendAsync(_sendArgs);
            if (pending == false)
                OnSendCompleted(null, _sendArgs); 
        }

이제 부터는 이 지역은 멀티 쓰레드를 고려해서 생각을 해야 한다. 그렇기 떄문에 우리가 애당초 락을 잡은 거기도 하고,

public void Send(ArraySegment<byte> sendBuff)
        {
            **lock (_lock)**
            {
                _sendQueue.Enqueue(sendBuff);
                if (_pendingList.Count == 0)
                    RegisterSend();
            }
        }

이렇게 멀티 스레드로 작업을 하고 있는데 다시 우리가 작업한 SendBuffer를 보면

얘는 별도의 멀티 스레드 코드 처리가 되어 있지 않다. 이게 괜찮은지는 헷갈릴 수 있어.

TLS(Thread Local Storage) 영억에 만들었으니 Open, Close를 동시다발적으로 멀티스레드 환경에서 다른 스레드에서 내 걸 접근할 수는 없을 것이다.

public class SendBufferHelper
    {
        public static **ThreadLocal**<SendBuffer> CurrentBuffer = new **ThreadLocal**<SendBuffer>(() => { return null; });
****        // 전역이지만 나의 쓰레드에서만 고유하게 사용할 수 있는 전역

얘가 엄청난 보호막이 된다.

하지만 그럼에도 불구하고

public class SendBuffer
    {
        // [u] [] [] [] [] [] [] [] [] []
        byte[] **_buffer**;

여기있는 버퍼 자체는 다수의 스레드에서 참조를 하고 있을거야.

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

            bool pending = _socket.SendAsync(_sendArgs);
            if (pending == false)
                OnSendCompleted(null, _sendArgs); 
        }

Session에서 빼내는 작업 자체는 처음에 Send를 요청한 스레드에서 한다는 보장은 없다. 왜냐하면 요청한 스레드에서는

public void Send(ArraySegment<byte> sendBuff)
        {
            lock (_lock)
            {
                **_sendQueue.Enqueue(sendBuff);**
                if (_pendingList.Count == 0)
                    RegisterSend();
            }
        }

pending 상태라 _sendQueue에 넣기만 하고 빠져나오는 경우가 있고, 실제로 걔를 보내는 작업은 다른 애가 이어서 RegisterSend에서 하는 경우가 있다.

void OnSendCompleted(object sender, SocketAsyncEventArgs args)
        {
            lock (_lock)
            {
                if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
                {
                    try
                    {
                        _sendArgs.BufferList = null; // 더이상 굳이 pendingList를 갖고 있을 필요 없으니까
                        _pendingList.Clear(); // bool 역할을 얘가 대신 해주는 거. 
                        
                        OnSend(_sendArgs.BytesTransferred); 

                        if (_sendQueue.Count > 0)
                            **RegisterSend();** 
                    }

즉, 여기서 완료 됐으면 다른 애가 RegisterSend() 이부분에서 이어서 하는 경우가 있을 수 있기 때문에

애당초 우리가

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

            bool pending = _socket.SendAsync(_sendArgs);
            if (pending == false)
                OnSendCompleted(null, _sendArgs); 
        }

여기에 넣어준 ArraySegment 정보 (Send할 때 매개변수로 받은 sendBuff들을 enuque한 게 _sendQueue다)

public class SendBuffer
    {
        // [u] [] [] [] [] [] [] [] [] []
        byte[] **_buffer**;

결국에는 여기있는 이 정보는 여러 스레드에서 참조를 할 수 있기는 하다.

그럼에도 아무런 문제가 되지 않는 건 걔네들은 얘네들을 직접 수정하는게 아니라 정보를 읽기만 하기 때문이야.

실제로 여기다가 정보를 쓰는 애는 애당초 맨 처음에

public class SendBuffer
    {
        // [u] [] [] [] [] [] [] [] [] []
        byte[] _buffer;
        int _usedSize = 0; // recvBuffer에서 wrtieSize에 해당

        public int FreeSize { get { return _buffer.Length - _usedSize; } }

        public SendBuffer(int chunkSize)
        {
            _buffer = new byte[chunkSize];
        }

        public ArraySegment<byte> **Open**(int reserveSize) // 할당할 최대 크기를 넣어줄거야.
        {
            if (reserveSize > FreeSize)
                return null;

            return new ArraySegment<byte>(_buffer, _usedSize, reserveSize);
        }

        public ArraySegment<byte> **Close**(int usedSize) // 다 쓴 다음에는 실제로 사용한 사이즈를 넣어주게 될거야. 
        {
            ArraySegment<byte> segment = new ArraySegment<byte>(_buffer, _usedSize, usedSize);
            _usedSize += usedSize;
            return segment;
        }
    }
}

Open, Close를 통해가지고

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, openSegment.Offset + buffer.Length, buffer2.Length);
            ArraySegment<byte> sendBuff = SendBufferHelper.**Close**(buffer.Length + buffer2.Length);

            Send(sendBuff);
            Thread.Sleep(1000);
            Disconnect();
        }

초창기에 한번만 해줄 거.

다음에 이 뒷 부분은 그냥 읽기만 하는 거니 해서 멀티스레드 환경에서도 문제가 없는 것이다.

 

약간 미묘한 차이가 있긴 한데 이 부분을 이해를 하면 좋겠어.

SendBuffer 대충 이렇게 만들어 봤고,

하고 보니까 이렇게 까지 해야 하나 싶기는 한데

C++ 서버를 C#으로 이전하고 있어서, 이렇게 해본거.

경우에 따라 다른 라이브러리를 쓴다고 하다면 Knight 자체를 한번에 배열로 바꿔주는 그런 씨리얼라이즈 하는 기능이 있다면 그걸 사용해도 무방하긴 하다. 근데 지금 코드에서는 SendBuffer를 이용하는 방법으로 작업을 해봤어.

이렇게 버퍼를 할당 받은 것을 꺼낸 쓴 다음에 쪼개서 먹는 느낌을 한 다음에 데이터를 밀어 넣는 작업을 해 보았다.

 

 

작업 후의 코드

 

SendBuffer

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;

namespace ServerCore
{
    public class SendBufferHelper
    {
        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)
                CurrentBuffer.Value = new SendBuffer(ChunkSize);

            return CurrentBuffer.Value.Open(reserveSize);
        }

        public static ArraySegment<byte> Close(int usedSize)
        {
            return CurrentBuffer.Value.Close(usedSize);
        }
    }

    public class SendBuffer
    {
        // [u] [] [] [] [] [] [] [] [] []
        byte[] _buffer;
        int _usedSize = 0; // recvBuffer에서 wrtieSize에 해당

        public int FreeSize { get { return _buffer.Length - _usedSize; } }

        public SendBuffer(int chunkSize)
        {
            _buffer = new byte[chunkSize];
        }

        public ArraySegment<byte> Open(int reserveSize) // 할당할 최대 크기를 넣어줄거야.
        {
            if (reserveSize > FreeSize)
                return null;

            return new ArraySegment<byte>(_buffer, _usedSize, reserveSize);
        }

        public ArraySegment<byte> Close(int usedSize) // 다 쓴 다음에는 실제로 사용한 사이즈를 넣어주게 될거야. 
        {
            ArraySegment<byte> segment = new ArraySegment<byte>(_buffer, _usedSize, usedSize);
            _usedSize += usedSize;
            return segment;
        }
    }
}

Server

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

namespace Server
{
    class Knight
    {
        public int hp;
        public int attack;
    }

    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, openSegment.Offset + buffer.Length, buffer2.Length);
            ArraySegment<byte> sendBuff = SendBufferHelper.Close(buffer.Length + buffer2.Length);

            Send(sendBuff);
            Thread.Sleep(1000);
            Disconnect();
        }

        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($"[FromClient] {recvData}");
            return buffer.Count; 
        }

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

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

        static void Main(string[] args)
        {
            // DNS ( Domain Name System )
            string host = Dns.GetHostName();
            IPHostEntry ipHost = Dns.GetHostEntry(host);
            IPAddress ipAddr = ipHost.AddressList[0];
            IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);
            // ipAddre는 식당 주소, 7777은 식당 정문인지 후문인지 문의 번호

            //_listener.Init(endPoint, OnAcceptHandler);
            _listener.Init(endPoint, () => { return new GameSession(); });

            Console.WriteLine("Listening...");

            while (true)
            {
                ;
            }
        }
    }
}

Session

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

namespace ServerCore
{
    public abstract class Session
    {
        Socket _socket;
        int _disconnect = 0;

        RecvBuffer _recvBuffer = new RecvBuffer(1024);

        object _lock = new object();
        Queue<ArraySegment<byte>> _sendQueue = new Queue<ArraySegment<byte>>();
        List<ArraySegment<byte>> _pendingList = new List<ArraySegment<byte>>();
        SocketAsyncEventArgs _sendArgs = new SocketAsyncEventArgs();
        SocketAsyncEventArgs _recvArgs = new SocketAsyncEventArgs();

        public abstract void OnConnected(EndPoint endPoint);
        public abstract int OnRecv(ArraySegment<byte> buffer);
        public abstract void OnSend(int numOfBytes);
        public abstract void OnDisconnected(EndPoint endPoint);

        public void Start(Socket socket)
        {
            _socket = socket;
            _recvArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnRecvCompleted);
            _sendArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnSendCompleted);
           
            RegisterRecv();
        }

        public void Send(ArraySegment<byte> sendBuff)
        {
            lock (_lock)
            {
                _sendQueue.Enqueue(sendBuff);
                if (_pendingList.Count == 0)
                    RegisterSend();
            }
        }

        #region 네트워크 통신

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

            _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를 갖고 있을 필요 없으니까
                        _pendingList.Clear(); // bool 역할을 얘가 대신 해주는 거. 
                        
                        OnSend(_sendArgs.BytesTransferred); 

                        if (_sendQueue.Count > 0)
                            RegisterSend(); 
                    }
                    catch (Exception e)
                    {
                        Console.WriteLine($"OnRecvCompletedFailed {e}");
                    }
                }
                else
                {
                    Disconnect();
                }
            }
        }

        public void Disconnect()
        {
            if (Interlocked.Exchange(ref _disconnect, 1) == 1)
                return;

            OnDisconnected(_socket.RemoteEndPoint); 
            _socket.Shutdown(SocketShutdown.Both);
            _socket.Close(); 
        }

        void RegisterRecv()
        {
            _recvBuffer.Clean(); // 혹시라도 커서가 너무 뒤로 이동하는 것을 방지한다. 
            ArraySegment<byte> segment = _recvBuffer.WriteSegment; // 버퍼에서 다음으로 데이터를 받을 공간을 WriteSegment로 관리하고 있었어. 
            _recvArgs.SetBuffer(segment.Array, segment.Offset, segment.Count); // segment.Count가 애당초 freeSize였어. 

            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) // 모든 게 잘 처리 됐다는 뜻
            {
                // TODO
                try
                {
                    // Write 커서 이동
                    if (_recvBuffer.OnWrite(args.BytesTransferred) == false)
                    {
                        Disconnect();
                        return;
                    }

                    // 컨텐츠 쪽으로 데이터를 넘겨주고 얼마나 처리했는지 받는다. 
                    int processLen = OnRecv(_recvBuffer.ReadSegment);
                    if (processLen < 0 || _recvBuffer.DataSize < processLen) // 혹시 컨텐츠 단에서 이상한 값으로 넣어줘서 처리가 안됐거나, recvBuffer보다 처리된 데이터 사이즈가 크면 이상한 거니 체크
                    {
                        Disconnect();
                        return;
                    }// 여기까지 했으면 데이터를 처리 했거나 보류를 했거나 한 상태가 될건데 이제 Read커서를 이동 시키

                    // Read 커서 이동
                    if (_recvBuffer.OnRead(processLen) == false)
                    {
                        Disconnect();
                        return;
                    }

                    RegisterRecv();
                }
                catch (Exception e)
                {
                    Console.WriteLine($"OnRecvCompletedFailed {e}");
                }
            }
            else
            {
                Disconnect(); 
            }                
        }
        #endregion
    }
}

 

 

 

출처: https://inf.run/vBHz

반응형

댓글