네트워크 프로그래밍
현재 모든 코드가 Main 함수 내부에서 동작하므로 서버 코어 프로젝트 내에 리스너 클래스를 새로 생상하여 기존 코드들을 정리해주자. 리스너 클래스가 하는 일은 서버-클라이언트가 연결할 때에 사용할 소켓의 생성, 결합, 대기, 승인들의 작업을 리스너에서 처리한다. 즉 Block 함수로 사용되던 부분을 Non-Block 함수 형태로 변경하는 것이다.
왜 Non-Block 함수 형태로 수정해야 할까? 이는 지난 시간에 짤막하게 언급한바와 같다. Listener의 Accept() 메서드와 같은 함수들은 서버에서 실행하게 될 경우 클라이언트의 입장 요청이 들어오기 전 까지 값을 영영 리턴하지 않고 기다리게 된다. 따라서 우리가 기껏 만들어놓은 메인 쓰레드는 계속 일을 해야 하는데, Accept() 메서드에서 클라이언트 입장 요청이 들어오는 것만 기다리고 있으므로 정상적으로 일을 하지 못하는 것이다.
예를 들어 만 명의 동시 접속자 수가 있는 상태라면 여러 유저의 동작을 처리해야 하는데 Accept() 메서드 내에서 무한정으로 대기를 한다면 다른 사람들의 로직이 정상적으로 처리가 되지 않을것이다. 따라서 Accept, Receive, Send 메서드들은 모두 비동기 함수 즉. Non-Blocking 함수로 변경해야 한다.
Listener.Init()
public void Init(IPEndPoint endPoint, Action<Socket> onAcceptHandler)
{
_listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
// 문지기 교육
_listenSocket.Bind(endPoint);
// 영업 시작
// backlog - 최대 대기수, 초과 시 접속 불가(fail)
_listenSocket.Listen(10);
}
먼저 Init 메서드를 통해 처음 로직이 동작할 때 사용되었던 생성, 결합, 대기등의 업무를 구현해준다. 이 후, Accept()의 기능을 비동기 함수로 구현해보자.
Listener.AcceptAsync()
_listenSocket.Accept();
_listenSocket.AcceptAsync(args);
기존의 리스너 소켓은 Accept 메서드를 사용했지만, 비동기 함수로 구현하고자 할 때에는 Accept 뒤에 Async가 붙어 있는 메서드를 이용해 구현한다. 해당 메서드는 성공/실패의 유무와 상관없이 bool 값을 이용하여 값을 return 해준다. 하지만 로직이 실패했을 경우 실패했음을 알려주는 로직을 추가적으로 구현해야 하기 때문에 구현 난이도가 상승한다.
SocketAsyncEventArgs 클래스를 이용하면 비동기 소켓 작업에 대해서 구현할 수 있다. 더 알아보기 👉 https://learn.microsoft.com/ko-kr/dotnet/api/system.net.sockets.socketasynceventargs?view=net-7.0
따라서 Init 메서드 하단에 해당 클래스를 이용해 args 변수를 생성해주고 비동기적으로 처리를 할 것이므로 이벤트를 붙여주는 작업을 처리한다. args 변수는 최초 한 번 실행해야 하기 때문에 Init 메서드 내부에서 실행해야 하며, 그 이후에는 계속 재사용하며 사용한다. 또한 Completed를 OnAcceptCompleted 메서드에 붙여주어 이벤트(=콜백) 형식으로 동작하게 한다.
SocketAsyncEventArgs args = new SocketAsyncEventArgs();
args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted);
RegisterAccept(args);
이후 Accept를 등록하기 위한 RegisterAccept 메서드와 Accept가 완료되었을 때를 처리하기 위한 OnAcceptCompleted 메서드를 만들어 준다.
Listener.RegisterAccept()
void RegisterAccept(SocketAsyncEventArgs args)
{
_listenSocket.AcceptAsync(args);
}
위와 같이 코드를 작성하면 Accept의 과정이 성공하게 된다면 통신 가능한 상태가 된다. 하지만 우리는 성공/실패의 유무에 따라서 구현할 것이므로 해당 결과를 bool 값을 이용함으로써 받아준다. 따라서 코드를 아래와 같이 수정한다.
void RegisterAccept(SocketAsyncEventArgs args)
{
bool pending = _listenSocket.AcceptAsync(args);
if (pending == false)
{
OnAcceptCompleted(null, args);
}
}
pending의 값이 실패하는 경우 두 가지의 경우의 수가 있다.
1. 어떠한 오류로 인해 실패하는 경우
2. AcceptAsync()를 실행하자마자 통과해버려 pending의 값이 false인 경우 (= 사실상 통과한 상태이나, 이러한 경우가 생길 수 있기 때문에 예외적으로 처리한다.)
Listener.OnAcceptCompleted()
void OnAcceptCompleted(object sender, SocketAsyncEventArgs args)
{
if (args.SocketError == SocketError.Success)
{
_onAcceptHandler.Invoke(args.AcceptSocket);
}
else
{
Console.WriteLine(args.SocketError.ToString());
}
// 다음 이벤트를 위해 다시 등록해준다.
RegisterAccept(args);
}
OnAcceptCompleted 메서드는 이벤트로 등록하기 위한 함수이기 때문에 위에서 선언한 성공/실패 여부와는 상관없이 해당 메서드로 진입하게 된다. 이 때 만약 에러가 난다면, 에러 내용을 출력해주고, 에러가 없다면 메인 메서드에서 메시지를 송/수신하는 함수를 동작시킨다. 즉 콜백 방식을 이용해 다음에 행동해야할 동작을 알려준다.
이 후, args에 값이 들어있는 채로 함수를 다시 동작시키면 문제가 생기므로 args의 값을 RegisterAccept() 메서드 내부에서 초기화를 진행해준다. 아래는 비로소 모든 것들이 다 설명되어 들어간 메서드의 전문이다.
void RegisterAccept(SocketAsyncEventArgs args)
{
args.AcceptSocket = null;
bool pending = _listenSocket.AcceptAsync(args);
if (pending == false)
{
OnAcceptCompleted(null, args);
}
}
void OnAcceptCompleted(object sender, SocketAsyncEventArgs args)
{
if (args.SocketError == SocketError.Success)
{
_onAcceptHandler.Invoke(args.AcceptSocket);
}
else
{
Console.WriteLine(args.SocketError.ToString());
}
// 다음 이벤트를 위해 다시 등록해준다.
RegisterAccept(args);
}
Action을 통한 송/수신 처리
Action<Scoket>을 이용하여 송/수신 업무를 처리한다. 따라서 아래와 같이 리스너 클래스에 액션을 선언하자. 이 후 Init() 메서드 내부에서 해당 값을 매개변수로 받아 사용한다. 여기서 받는 값이 메인에서 메시지를 송/수신을 담당하던 영역이다. 따라서 Listner 클래스의 전문을 보면 아래와 같이 구현된 모습을 볼 수 있다. 🤠
using System;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
class Listener
{
Socket _listenSocket;
Action<Socket> _onAcceptHandler;
public void Init(IPEndPoint endPoint, Action<Socket> onAcceptHandler)
{
_listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
_onAcceptHandler += onAcceptHandler;
// 문지기 교육
_listenSocket.Bind(endPoint);
// 영업 시작
// backlog - 최대 대기수, 초과 시 접속 불가(fail)
_listenSocket.Listen(10);
SocketAsyncEventArgs args = new SocketAsyncEventArgs();
args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted);
RegisterAccept(args);
}
void RegisterAccept(SocketAsyncEventArgs args)
{
// args의 값이 null이 아닌 채로 동작할 수 있으므로 null 처리를 진행한다.
args.AcceptSocket = null;
// AcceptAsync 메서드를 이용하여 비동기로 접속을 받는다.
bool pending = _listenSocket.AcceptAsync(args);
if (pending == false)
{
// 실패할 경우 SocketSocketError의 값을 null로 보내어
// 아무런 동작을 하지 않게 만든다.
OnAcceptCompleted(null, args);
}
}
void OnAcceptCompleted(object sender, SocketAsyncEventArgs args)
{
// 소켓 에러가 발생하지 않았다면
if (args.SocketError == SocketError.Success)
{
// 인보크(=이벤트)를 이용해 다음 동작을 진행한다.
_onAcceptHandler.Invoke(args.AcceptSocket);
}
else
{
Console.WriteLine(args.SocketError.ToString());
}
// 다음 이벤트를 위해 새로이 등록해준다.
RegisterAccept(args);
}
}
}
이제 콜백을 이용해 Send, Receive를 진행하므로 서버코어의 Program 스크립트로 돌아가 해당 콜백을 받을 수 있는 OnAcceptHanler() 만들어 넣어주고 이를 리스너에서 Init() 할 때 매개변수로 담아 Action 할 수 있게 해준다.
Program.cs
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace ServerCore
{
class Program
{
static Listener _listener = new Listener();
static void OnAcceptHanler(Socket clientSocket)
{
try
{
// 듣는다.
byte[] recvBuff = new byte[1024];
int recvBytes = clientSocket.Receive(recvBuff);
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)
{
string host = Dns.GetHostName();
IPHostEntry ipHost = Dns.GetHostEntry(host);
IPAddress ipAddr = ipHost.AddressList[0];
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);
_listener.Init(endPoint, OnAcceptHanler);
Console.WriteLine("Listening...");
// 접속 할 때 까지 대기하기 위한 while
while (true)
{
;
}
}
}
}
'공부 > 인프런 - Rookiss' 카테고리의 다른 글
Part 4-3-6. 네트워크 프로그래밍 : Session #2 (Send 분리) (0) | 2023.09.22 |
---|---|
Part 4-3-5. 네트워크 프로그래밍 : Session #1 (Receive 분리) (0) | 2023.09.21 |
Part 4-3-3. 네트워크 프로그래밍 : 소켓 프로그래밍 입문 (0) | 2023.09.19 |
Part 4-3-2. 네트워크 프로그래밍 : 통신 모델(OSI 7 계층) (0) | 2023.09.19 |
Part 4-3-1. 네트워크 프로그래밍 : 네트워크 기초 이론 (0) | 2023.09.19 |