Socket에서 할 수 있는 행동에는 Bind
, Listen
, Accept
, Connect
, Send/Receive
, Close
가 있으며 C#에서는 다음과 같은 함수로 사용할 수 있다.
void Socket.Bind(EndPoint localEP)
매개변수로 설정하고자 하는 EndPoint
를 넘겨주며 EndPoint
를 상속받은 IPEndPoint
를 사용할 수 있다.
void Socket.Listen(int backlog)
매개변수가 없는 버전도 있으며 backlog
는 응답을 대기하는 호스트의 최대 수를 의미한다.
Socket Socket.Accept()
서버는 Accept로부터 생성된 Socket으로 요청한 클라이언트와 통신을 할 수 있다.
void Socket.Connect(EndPoint remoteEP)
매개변수로 EndPoint
를 넘겨 EndPoint
의 IP주소, 포트번호로 연결을 시도한다. EndPoint
를 넘기는 버전외에 IPAddress
와 PortNumber
를 같이 넘기는 것과 같이 다양한 오버로드를 지원한다.
int Socket.Send(byte[] buffer)
int Socket.Receive(byte[] buffer)
Buffer에 있는 정보들을 송신/수신하는 함수로, Flag를 지정하거나 Error를 반환하는 버전이 존재한다.
void Socket.Close()
소켓의 연결을 끊는 함수로 timeout
을 지정하는 버전도 존재한다.
아래의 예제는
TCP연결의 예제
로 클라이언트와 서버 두개의 예제이다.
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);
Socket socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
try
{
socket.Connect(endPoint);
Console.WriteLine($"Connected To {socket.RemoteEndPoint.ToString()}");
byte[] sendBuffer = Encoding.UTF8.GetBytes("Hello World!");
int sendBytes = socket.Send(sendBuffer);
byte[] recvBuff = new byte[1024];
int recvBytes = socket.Receive(recvBuff);
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());
}
}
위의 코드는 크게 단계로 구분할 수 있으며 각 단계는 다음과 같다.
string host = Dns.GetHostName();
IPHostEntry ipHost = Dns.GetHostEntry(host);
IPAddress ipAddr = ipHost.AddressList[0];
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);
IPEndPoint
를 설정하기 위해 호스트이름, IP주소를 알아낸 후 임의의 포트번호를 설정한다.
Socket socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
socket.Connect(endPoint);
Console.WriteLine($"Connected To {socket.RemoteEndPoint.ToString()}");
클라이언트는 Socket을 생성하고 서버와의 연결을 시도한다. Socket을 생성할 때 Socket의 주소체계, 통신 매커니즘과 프로토콜을 설정해준다.
byte[] sendBuffer = Encoding.UTF8.GetBytes("Hello World!");
int sendBytes = socket.Send(sendBuffer);
byte[] recvBuff = new byte[1024];
int recvBytes = socket.Receive(recvBuff);
string recvData = Encoding.UTF8.GetString(recvBuff, 0, recvBytes);
Console.WriteLine($"[From Server] {recvData}");
Socket을 이용한 통신은 Buffer에 있는 데이터를 송신, 수신한 데이터를 Buffer에 저장하는 방식으로 이뤄지는데 받은 데이터가 문자열인지, 숫자인지 구분할 필요가 있다.
위 예제는 송신할 때에 Bytes
로, 수신할 때에 String
으로 변환해주었다.
socket.Shutdown(SocketShutdown.Both);
socket.Close();
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);
Socket listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
try
{
listenSocket.Bind(endPoint);
listenSocket.Listen(10);
while (true)
{
Console.WriteLine("Listening...");
Socket clientSocket = listenSocket.Accept();
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());
}
서버는 클라이언트와 다르게 클라이언트의 요청을 받는 용도의 Socket을 생성하고 요청이 들어올 때의 응답을 해줘야 한다. 이러한 Socket을 Listen 소켓이라 하며 Listen 소켓은 클라이언트의 요청이 들어올 때까지 Listen 상태 즉 대기상태에 있어야 한다.
string host = Dns.GetHostName();
IPHostEntry ipHost = Dns.GetHostEntry(host);
IPAddress ipAddr = ipHost.AddressList[0];
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);
Socket listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
EndPoint
를 설정하는 과정과 Socket을 생성하는 과정은 위에 있는 클라이언트의 과정과 동일하다.
listenSocket.Bind(endPoint);
listenSocket.Listen(10);
//Loop
Socket clientSocket = listenSocket.Accept();
Listen Socket의 IP주소와 포트번호를 설정하고 클라이언트의 요청을 기다린다. 클라이언트의 요청이 여러개가 왔다면 대기는 최대 10개만 가능하다. 그리고 서버는 루프를 돌면서 클라이언트의 연결을 수락한다.
Accept에서 반환되는 Socket으로 클라이언트와 데이터를 주고 받는다.
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 Server !");
clientSocket.Send(sendBuff);
클라이언트와 비슷하게 Buffer의 내용을 송신하고 수신한 데이테를 Buffer에 저장한다. 서버 역시 주고 받는 데이터의 변환을 신경써줘야 한다.
clientSocket.Shutdown(SocketShutdown.Both);
clientSocket.Close();
함수를 호출할 때 요청이나 응답이 올 때까지 기다리는 현상을 블로킹이라 한다. 위 서버예제에서 클라이언트의 요청이 올때까지 기다리는 Accept
와 클라이언트가 전송하는 데이터가 올때까지 기다리는 Receive
가 블로킹이 발생하는 함수라고 할 수 있다.
이를 해결하기 위해 비동기함수
를 사용할 수 있다. 비동기라는것은 지금 당장 처리되는 것은 아니지만 이후에 작업이 완료되면 콜백함수를 통해 작업이 끝났음을 알리는 방식을 의미한다.
위의 서버예제에서 서버는 Listen Socket으로부터 오는 클라이언트의 요청이 올때까지 기다리고 요청이 올 때마다 작업을 처리하는 구조를 가지고 있다. 이런 구조는 클라이언트의 요청을 기다리는 동안 아무런 작업을 할 수 없다는 단점이 있다. Accept를 위한 쓰레드를 하나 만들어 기다리게 하는 구조도 해당 쓰레드는 요청을 기다리는 동안 연산자원이 낭비되는 상황이 발생한다.
이를 위해 Listener Socket을 비동기로 Accept하도록 바꿔야 할 필요가 있다. 즉, 서버는
클라이언트의 연결요청을 비동기적으로 Accept대기하며 대기하는 동안 다른 작업을 수행할 수 있다.
아래의 코드는 AcceptAsync를 이용해 Listener Socket을 구현한 코드이다.
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);
_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)
{
_onAcceptHandler.Invoke(args.AcceptSocket);
}
else
Console.WriteLine(args.SocketError.ToString());
RegisterAccept(args);
}
}
비동기 Listener Socket을 구현하기 위해서 다음과 같은 요소를 고려해야한다.
EndPoint
와 같은 설정을 해주어야 한다. Socket _listenSocket;
Action<Socket> _onAcceptHandler;
_listenSocket
은 클라이언트의 연결 요청을 수신할 Socket이고 _onAccpetHandler
는 연결 요청이 Accept될 때 호출될 콜백 함수이다.
public void Init(IPEndPoint endPoint, Action<Socket> onAcceptHandler)
{
_listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
_onAcceptHandler = onAcceptHandler;
_listenSocket.Bind(endPoint);
_listenSocket.Listen(10);
SocketAsyncEventArgs args = new SocketAsyncEventArgs();
args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted);
RegisterAccept(args);
}
서버가 Listener Socket의 설정을 위해 호출할 함수로 Socket 설정을 위한 정보와 실행할 콜백함수를 매개변수로 넘겨준다.
SocketAsyncEventArgs
는 소켓 연결과 데이터 송수신에 관련된 정보를 저장하는 클래스로 Complete는 작업이 완료될 때 결과를 처리하기 위한 이벤트 핸들러이다.
void RegisterAccept(SocketAsyncEventArgs args)
{
args.AcceptSocket = null;
bool pending = _listenSocket.AcceptAsync(args);
if (pending == false)
OnAcceptCompleted(null, args);
}
비동기 연결 요청을 Accept하는 함수이다. 원래의 예제와 다르게 Socket.Accept()
가 아닌 Socket.AcceptAsync()
를 호출한다. Socket.AcceptAsync()
는 매개변수로 SocketAsyncEventArgs
를 넘기는데 연결이 수락될 때 연결에 관한 정보가 저장된다.
pending
은 비동기 Accept과정에서 비동기가 아닌 동기적으로 왔는지 판별하기 위한 변수로 동기적으로 왔다는 것은 AcceptAsync을 실행하는 중에 클라이언트의 요청이 온 상황을 의미한다.
arg.AccpetSocket을 null로 해주는 이유는 이전 클라이언트 요청에 대한 값을 지워주기 위함이다.
void OnAcceptCompleted(object sender, SocketAsyncEventArgs args)
{
if (args.SocketError == SocketError.Success)
{
_onAcceptHandler.Invoke(args.AcceptSocket);
}
else
Console.WriteLine(args.SocketError.ToString());
RegisterAccept(args);
}
연결 요청이 수락되었을 때 호출되는 콜백 함수로 연결이 Success라면 _onAcceptHandler
콜백을 호출하고 그렇지 않으면 오류 메세지를 출력한다. 그 후, 다시 비동기 Accept를 위해 RegisterAccept
를 호출한다.
SocketAsyncEventArgs는 소켓 작업에 필요한 정보와 이벤트를 한번에 다룰 수 있다는 장점이 있다. 비동기 작업에 대한 정보에는 다음과 같은 속성과 이벤트가 있다.
RemoteEndPoint
에 대한 정보위의 Listener Class를 서버에 적용 시킨 코드는 아래와 같다.
static Listener _listener = new Listener();
static void OnAcceptHandler(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 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, OnAcceptHandler);
Console.WriteLine("Listening...");
while (true)
{
// 추가 연산이 가능!
}
}
원래 while문에 있던 연산 부분을 Listener Class의 콜백함수로 지정해주고 EndPoint
와 같이 Listener를 설정해준다. while문에는 클라이언트의 요청이 없을 때 추가적인 연산이 가능하다.