저번 시간에 구현한 소켓 프로그래밍에서 한계점이 있었다.
그것은 바로 블로킹 방식이 사용되었다는 점이다.
즉 기존에 구현한 listenSocket.Accept()은 고객이 오기 전까지 하염없이 기달려야 한다는 것이 문제이다. Accept 뿐만이 아니라 Send, Receive가 다 블로킹 함수로 구현됬었다.
따라서 이번 시간에는 Listener 클래스를 만들어서 Accept을 논 블로킹 방식으로 구현해보 겠다.
블로킹과 논블로킹에 대한 설명은 아래 Rookiss 님의 글을 참조하자.
class Listener
{
Socket _listenSocket;
Action<Socket> _onAcceptHandler;
public void Init(IPEndPoint endPoint, Action<Socket> onAcceptHandler)
{
_listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
// 첫 번째 매개변수: IP 버전4 인지 버전6인지
// 그 다음 매개변수: TCP를 쓸건지 UDP를 쓸건지
_onAcceptHandler += onAcceptHandler;
// 문지기 교육
_listenSocket.Bind(endPoint); // 문지기의 핸드폰에 비밀식당의 주소를 알려주는 것.
// 영업시작
_listenSocket.Listen(10);
SocketAsyncEventArgs args = new SocketAsyncEventArgs();
args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted);
RegisterAccept(args);
}
먼저 Listener 소켓을 초기화하는 Init 메소드를 만들어 보자.
onAcceptHandler는 Init의 delegate 매개변수로, Action delegate인 Listener._onAccpetHandler에 연결하여 사용한다.
SocketAsyncEventArgs는 EventArgs를 상속받은 클래스로, 이벤트 데이터를 포함하는 클래스이다.
args.Completed는 이벤트 핸들러이다. 이벤트 핸들러는 이벤트가 발생했을 때 어떤 명령들을 실행할지 지정해주는 것이다. 이벤트 핸들러에 인자로 OnAcceptCompleted 라는 메소드를 넘겨줘서 args에 데이터가 들어왔다는 이벤트가 발생할 시 OnAcceptCompoleted가 실행되게 끔 한다.
그 다음 이벤트 데이터를 RegisterAccept(Accept를 예약하는 기능)에 넘겨준다.
일단 RegisterAccept 함수를 먼저 살펴보자.
public void RegisterAccept(SocketAsyncEventArgs args)
{
args.AcceptSocket = null; // 초기화 (중요)
bool pending = _listenSocket.AcceptAsync(args); // 비동기
if (pending == false) // 운 좋게 바로 연결
{
OnAcceptCompleted(null, args);
}
}
함수 이름에 Register가 들어가 있듯이 Accept를 예약한다는 의미이다.
Socket.AcceptAsync에 인자로 SocketAsyncEventArgs 를 넘겨준다.
이 함수의 반환값은 불리안 형이다. false가 나왔다는 뜻은 타이밍이 맞아 동기로 바로 처리가 되었다는 뜻이다. 그 경우는 OnAcceptCompleted 를 바로 호출해준다.
중요한 건 SocketAsyncEventArgs.AcceptSocket 을 초기화하는 작업이다. 이를 살펴보기 위해 OnAcceptCompleted 메소드를 살펴보자.
public void OnAcceptCompleted(object sender, SocketAsyncEventArgs args)
{
if (args.SocketError == SocketError.Success)
{
// 실제로 유저가 왔으면 할 것
_onAcceptHandler.Invoke(args.AcceptSocket); // 대리인에게 소켓을 주는 역활을 한다.
// args.AcceptSocket 한테 대리인의 소켓을 넣어준다.
}
else
Console.WriteLine(args.SocketError.ToString());
RegisterAccept(args); // 낚시대를 바다에 다시 던진다.
}
}
OnAcceptCompleted 메소드는 EventHandler<>의 인자로 들어가야 하므로 (object, EventArgs) 타입을 매개변수로 삼아야 한다.
Action<>.Invoke 으로 SocketAsyncEventArgs.AcceptSocket에 대리인의 소켓을 넣어준다.
작업이 완료되었으면 RegisterAccept를 호출하여 Accept를 예약한다.
그리고 SocketAsyncEventArgs.AcceptSocket에 넣어준 대리인의 소켓을 초기화 시키는 작업을 RegisterAccept에서 수행한다.
class Program
{
static Listener _listener = new Listener();
static void OnAcceptHandler(Socket clientSocket)
{
try
{
// 연락을 받는다.
byte[] recvBuff = new byte[1024];
int recvBytesLength = clientSocket.Receive(recvBuff);
string recvData = Encoding.UTF8.GetString(recvBuff, 0, recvBytesLength); // 문자열을 받는다고 가정
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);
}
}
static void Main(string[] args)
{
//DNS(Domain Name System)
string host = Dns.GetHostName(); // DESKTOP-S7EG95G
IPHostEntry ipHost = Dns.GetHostEntry(host); // System.Net.IPHostEntry
IPAddress ipAddr = ipHost.AddressList[0]; // fe80::b599:93ca:869a:5736%18
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // 주소와 포트번호
_listener.Init(endPoint, OnAcceptHandler);
while (true)
// _listener.Init 이 무한루프를 돌기 때문에 사실상 필요없음
// 프로그램이 안 꺼지게 해주는 역할
{
Thread.Sleep(100);
Console.WriteLine("Listening...");
}
}
}
이제 Program Class를 살펴보자.
_listener.Init(endPoint, OnAcceptHandler)로 Listen(영업 개시)을 한다.
우리가 Init 함수에 OnAcceptHandler를 인자로 넘겨줬기 때문에
OnAcceptHandler 함수는 Accept일 때 (대리인의 입장이 허용욀 때) 송수신 후 대리인을 쫓아내는 기능을 수행하는 이벤트 핸들러가 된 셈이다.
사실 이벤트 형식이라 굳이 while문이 필요하지는 않지만 Main 코드가 끝나지 않게 하기위해 넣었다. Thread.Sleep(100)으로 0.1초마다 Listeneing이 출력되게 만들었다.
그림과 같이 서버와 클라이언트가 잘 연결된 것을 볼 수 있다.
주의할 점이 하나있다.
이벤트가 완료될 시 쓰레드풀에서 쓰레드를 가져와서 이벤트 핸들러를 동작한다.
멀티쓰레드 환경이 되기 때문에 OnAcceptCompleted 이벤트 핸들러를 작성할 때는 이 점을 유의해야 한다.
Rookiss님의 동기와 비동기 설명
블로킹 방식은 낚시대 한개를 던져놓고 그 앞에 기다리면서 하염없이 뚫어져라 쳐다보고 있는 셈입니다. 낚시대에 물고기가 낚이지 않으면 일평생 그 앞에서 아무것도 안하면서 대기를 타야 할 수도 있습니다.
논블로킹 방식은 낚시대를 N개 던져놓고, 그냥 다른 일을 하러 간 셈입니다. 폰 게임을 하다가 낚시대에 입질이 와서 뭔가 신호가 오면, 그제서야 돌아가서 낚시대를 끌어올릴 수 있겠죠.
대기 방식의 차이만 미세하게 있는 정도라고 볼 수는 없고,오히려 대기 방식에 따라서 아무것도 못하느냐 vs 다른걸 할 수 있느냐는 엄청 큰 차이가 있습니다.
Accept 정도는 와닿지 않을 수도 있지만 Send/Recv는 더 치명적입니다. 만약 클라/서버 통신에서 Send를 동기 함수로 만들다면, 실제로 클라에서 데이터를 보내고 서버에서 그 데이터를 받아서 처리하기 전까진 클라는 렌더링은 커녕 그 자리에서 멈춰서 서버 신호만 기다리는 상태가 됩니다.
논블로킹 방식의 함수를 호출하면, 당장은 다른 일을 하러 갈 수는 있지만 어찌됐건 그 함수가 성공적으로 처리가 되는지는 확인할 수는 있어야 하는데 크게 분류하면 polling 방식 (주기적으로 우리가 직접 체크하는 것)과 이벤트 방식 (처음에 걸어놓은 콜백 함수가 알아서 호출되는 것)이 존재합니다.