Client의 접속을 받아들이는 Listner 객체를 얘기합니다.
TCP 소켓 게임 서버를 구축할 때 Listner 객체로 정리할 수 있습니다.
Listener의 역할을 클래스로 따로 빼서 구현하지 않고 구현해도 아래와 같이 가능합니다.
static void Main(string[] args)
{
// 1. Host의 EndPoint 값을 세팅합니다.
string host = Dns.GetHostName();
IPHostEntry ipHost = Dns.GetHostEntry(host);
IPAddress ipAddr = ipHost.AddressList[0];
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);
// 2. 클라이언트의 접속을 받아들이는 Listen 소켓을 생성합니다.
Socket listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
// 3. Host의 EndPoint를 Listen 소켓에 바인딩합니다.
listenSocket.Bind(endPoint);
// 4. 클라이언트의 접속을 허용 할 갯수를 미리 정의합니다.
listenSocket.Listen(10);
Console.WriteLine("Listening...");
// 5. 클라이언트의 접속(Accept) 처리 하기 위한 반복문을 실행합니다.
while (true)
{
// 6. 클라이언트의 접속을 Accept 처리를 동기 방식으로 처리합니다.
Socket clientSocket = listenSocket.Accept();
// 7. Accept 처리가 완료되면 클라이언트로부터 온 데이터(메시지)를 받아서 출력합니다.
byte[] recvBuff = new byte[1024];
int recvBytes = clientSocket.Receive(recvBuff);
string recvData = Encoding.UTF8.GetString(recvBuff, 0, recvBytes);
Console.WriteLine($"[From Client] {recvData}");
// 8. 클라이언트에게 서버에 접속한 것을 환영한다는 데이터(메시지)를 보냅니다.
byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server !");
clientSocket.Send(sendBuff);
// 9. 클라이언트의 접속을 종료합니다.
clientSocket.Shutdown(SocketShutdown.Both);
clientSocket.Close();
}
}
위 코드를 보면 소켓 프로그래밍의 대략적인 순서(흐름)는 아래와 같습니다.
여기서 흐름에 해당하는 로직을 한 클래스나 함수에서 관리해도 작동하는데 문제는 없습니다.
하지만 게임의 규모가 커지고 코드길이가 길어지면 유지보수에 문제가 생기게 됩니다.
그래서 흐름에 맞게 역할을 분담하는 객체를 생성합니다.
그것 중 하나가 바로 Listner입니다.
public class Listener
{
Socket _listenSocket;
Func<Session> _sessionFactory;
public void Init(IPEndPoint endPoint, Func<Session> sessionFactory, int register = 10, int backlog = 100)
{
// 1. 클라이언트의 접속을 받아들이기 위한 객체 생성 및 초기화
_listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
// 2. 서버 정보를 소켓에 bind
_listenSocket.Bind(endPoint);
// 3. 소켓을 수신 상태로 둡니다.
// 보류 중인 연결 큐의 최대 길이를 매개변수로 입력합니다.
_listenSocket.Listen(backlog);
// 4. register의 수 만큼 소켓 Accept를 비동기로 처리한다.
for (int i = 0; i < register; i++)
{
SocketAsyncEventArgs args = new SocketAsyncEventArgs();
// 이벤트 핸들러 작업이 완료되면 이 곳에 추가된 함수가 실행된다.
// 추가된 함수 => OnAcceptCompleted
args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted);
RegisterAccept(args);
}
}
// AcceptAsync를 사용해 비동기로 처리
// SocketAsyncEventArgs를 사용해 AcceptAsync 비동기 성공과 실패에 대한 처리를 합니다.
private void RegisterAccept(SocketAsyncEventArgs args)
{
// Event를 재사용하기 위해 기존에 있던 것을 null 처리
args.AcceptSocket = null;
// 작업이 보류 되었는지 완료되었는지를 bool 값(pending)으로 return 해줍니다.
// 바로 처리를 했는지 아니면 못했는지를 확인합니다.
bool pending = _listenSocket.AcceptAsync(args);
// 바로 처리가 되었을 경우(false)
if (pending == false)
OnAcceptCompleted(null, args);
}
// SocketAsyncEventArgs를 사용해 성공 실패 여부 체크 후 세션 시작 및 연결
private void OnAcceptCompleted(object sender, SocketAsyncEventArgs args)
{
if (args.SocketError == SocketError.Success)
{
Session session = _sessionFactory.Invoke();
session.Start(args.AcceptSocket);
session.OnConnected(args.AcceptSocket.RemoteEndPoint);
}
else
Console.WriteLine(args.SocketError.ToString());
RegisterAccept(args);
}
}
메시지를 주고 받기 전까지의 단계를 Listner에서 담당을 하는 것입니다.
클라이언트의 연결을 처리하는 과정을 효율적으로 처리하기 위해 위와 같이 코드가 작성되어 있습니다.
코드에서 좀 더 알아야 할 부분이 AcceptAsync와 SocketAsyncEventArgs 라고 생각해서
한 번 정리해봤습니다.
C#에서 Accept 함수는 블로킹 방식으로 동작합니다.
Accept에 대한 함수의 동작이 처리 되지 않으면 다음으로 넘어가질 못합니다.
그래서 AcceptAsync를 사용해 성공여부를 기다리지 않도록 합니다.
Accept에 대한 동작에 대해서 결과에 상관 없이 바로 다음 클라이언트의 연결 처리를 할 수 있습니다.
여기서 비동기로 처리한다는 것은 해당 작업 완료 여부를 따지지 않고 다음 작업을 한다는 것입니다.
결국 구현되어 있는 Listner는 흐름을 막지 않도록 논 블로킹으로 처리하고,
다른 클라이언트의 연결도 처리하기 위해 비동기 처리를 합니다.
(블로킹과 논 블로킹, 동기와 비동기관련 잘 정리되어 있는 블로그 => 링크)
Listener의 코드는 상당히 짧은데 이해하는데 시간이 꽤 걸렸습니다.
코드를 보면
블로킹, 논 블로킹, 동기, 비동기, 소켓, 이벤트 처리와 콜백 함수 키워드들이 있습니다.
코드에서 왜 비동기 처리를 하는지 부터 이벤트로 처리하는 이유도 이유를 잘 몰랐습니다.
그런데 정리를 하면서 확실히 이해를 하는데 도움이 되었습니다.
이런 코드를 보면서 어떻게 성능적으로 이점을 주고 더 많은 트래픽을 해결하는데 도움을 주는지 알아가는 계기가 되었습니다.