C# 에서 패킷 통신을 해보자 한다.
통신 프로토콜은 대표적으로 TCP와 UDP가 있으며 이 글에서는 TCP를 사용한다.
또한 자체 제작한 Socket 통신 라이브러리를 사용한다.
또한 개발 환경은 프레임워크 .NET 7.0 으로 시작한다.
먼저 프로젝트를 하나 만든다.
서버용 프로젝트이므로 알기 쉽도록 Server로 프로젝트를 제작했다.
프로젝트를 우클릭하고 Nuget 창을 열어준다.
NetworkLib을 검색하고 설치한다.
설치가 완료되면 Program.cs에서 NetworkListener를 하나 만들어준다.
using NetworkLibrary.Networks;
using NetworkLibrary.Networks.Packet;
var listener = new NetworkListener(new PacketFactory(), 12345);
PacketFactory는 사용자가 제작한 패킷을 등록해주는 class이다.
현재는 아무 패킷이 없으니 그냥 생성만 해서 넣어준다.
listener.OnAcceptEventHandler = (sender, args) =>
{
Console.WriteLine("클라이언트 접속");
};
listener.Listen(20);
Console.WriteLine("Server Open");
다음으로 클라이언트가 접속했을 때 실행되는 이벤트를 등록해주고, Listen을 통해 서버를 열어준다.
일단 클라이언트를 받을 수 있는 환경이 완성됐다.
다음으로 클라이언트를 만들어서 접속을 확인해본다.
이번에도 동일하게 .NET 7.0을 사용해 프로젝트 하나를 만들었다.
똑같이 Nuget을 통해 NetworkLib을 받아준다.
그 후 Program.cs 에서 클라이언트 클래스를 생성해준다.
using NetworkLibrary.Networks;
using NetworkLibrary.Networks.Packet;
var client = new NetworkClient(new PacketFactory(), "127.0.0.1", 12345, timeout:2000);
마찬가지로 제작된 패킷이 없으므로 빈 PacketFactory를 넣어준다.
로컬 환경에서 테스트하므로 host는 127.0.0.1를 입력해준다.
포트는 서버 포트와 동일하게 입력해준다.
또한 Socket을 연결할 때 쓰레드가 잠기는 것을 막고, 연결 최대시간을 지정해주기 위해 timeout을 지정해준다.
(안해도 상관은 없다.)
client.OnConnected += (sender, args) =>
{
Console.WriteLine("연결 성공");
};
client.OnConnectFailed += (sender, args) =>
{
Console.WriteLine("연결 실패");
};
client.Connect();
timeout을 지정하여 비동기적으로 연결되므로 연결 상태를 확인할 수 있게 끔, 연결 성공 이벤트와 실패 이벤트를 등록해준다.
그후 Connect()를 호출하여 서버에 연결한다.
서버를 먼저 실행시킨 후 클라이언트를 실행하면 무사히 접속 되는 것을 확인할 수 있다.
다음으로 패킷을 만들어서 클라이언트끼리 대화할 수 있게 해보자.
using NetworkLibrary.Networks.Packet;
public class MessagePacket : IPacket
{
}
채팅을 전달할 MessagePacket 클래스를 Client 프로젝트에 하나 만든다.
public int PacketPrimaryKey => 0;
public MessagePacket() { }
public void Read(ByteBuf buf)
{
throw new NotImplementedException();
}
public void Write(ByteBuf buf)
{
throw new NotImplementedException();
}
인터페이스를 구현해준다.
PacketPrimaryKey는 패킷이 가지는 고유한 ID 값이다.
Read는 패킷을 받았을 때 값을 입력할 수 있도록 호출된다.
Write는 패킷을 전송할 때 값을 전달할 수 있도록 호출된다.
또한 패킷을 받을 경우 코드상으로 생성이 가능해야하므로 파라미터가 없는 생성자를 반드시 하나 만들어 줘야 한다.
public string Data { get; private set; }
public MessagePacket(string data)
{
Data = data;
}
public void Read(ByteBuf buf)
{
Data = buf.ReadString();
}
public void Write(ByteBuf buf)
{
buf.WriteString(Data);
}
채팅을 전달할 것이므로 string 변수를 하나 만들고 변수에 값을 입력해줄 생성자를 하나 더 만든다.
또한 Read, Write 함수에서 Data를 읽고 쓸 수 있도록 ReadString()과 WriteString()을 사용한다.
string값 말고도 정수 등 여러 값들을 주고받을 수 있다.
이제 채팅 패킷이 완성되었으므로 Server 프로젝트에도 똑같이 MessagePacket을 만들어준다.
var client = new NetworkClient(
new PacketFactory().RegisterPacket(new MessagePacket()),
"127.0.0.1",
12345,
timeout:2000
);
var listener = new NetworkListener(
new PacketFactory().RegisterPacket(new MessagePacket()),
12345);
다음으로 아까 클라이언트와 서버를 생성한 곳으로 가서 PacketFactory에 RegisterPacket을 통해 패킷을 등록해준다.
public class PacketHandler : IPacketHandler
{
public void Handle(Network network, IPacket packet)
{
throw new NotImplementedException();
}
}
Client 프로젝트로 다시 가서 패킷을 다루는 클래스인 PacketHandler를 만들고 IPacketHandler를 상속받는다.
Handle 함수는 패킷을 전송받았을 때 호출된다.
public void Handle(Network network, IPacket packet)
{
switch(packet)
{
case MessagePacket p:
Console.WriteLine(p.Data);
break;
};
}
switch case를 통해 MessagePacket을 구분하고 받은 메세지를 출력해준다.
public class PacketHandler : IPacketHandler
{
private readonly NetworkListener listener;
public PacketHandler(NetworkListener listener)
{
this.listener = listener;
}
public void Handle(Network network, IPacket packet)
{
switch(packet)
{
case MessagePacket p:
Console.WriteLine(p.Data);
listener.Broadcast(p, network);
break;
};
}
}
서버도 똑같이 PacketHandler를 만들어준다.
다만 생성자를 통해 Listener를 받고, 출력과 함께 발송 클라이언트를 제외한 나머지에 listener.Broadcast를 통해 패킷을 전달한다.
var handler = new PacketHandler(listener);
listener.OnAcceptEventHandler = (sender, args) =>
{
Console.WriteLine("클라이언트 접속");
args.network!.PacketHandler = handler;
};
서버에서 클라이언트를 받으면 handler를 등록해준다.
client.OnConnected += (sender, args) =>
{
Console.WriteLine("연결 성공");
args.network!.PacketHandler = new PacketHandler();
};
클라이언트도 연결을 성공할 경우 PacketHandler를 등록해준다.
while(true)
{
client.SendPacket(new MessagePacket(Console.ReadLine()));
}
클라이언트에서 Connect() 호출 이후 입력받은 텍스트를 MessagePacket을 통해 전달한다.
빌드 후 실행해보면 잘 전달 되는 것을 볼 수 있다.
그러나 보낸 클라이언트가 누군지 인식할 수 없다는 문제가 있다.
이를 해결하기 위해 이름을 전달 받을 수 있도록 한다.
public class NetworkManager : Network
{
public string Name { get; internal set; } = string.Empty;
public NetworkManager(Socket socket) : base(socket)
{
}
}
서버 프로젝트에 Network를 상속받는 NetworkManager 클래스를 만들고 이름을 저장할 변수 하나를 만든다.
listener.SetNetworkInstance(typeof(NetworkManager));
listener.Listen(20);
그 후 Program.cs에서 Listen을 해주기 전에 SetNetworkInstance를 통해 NetworkManager를 등록해준다.
이럴 경우 서버에서 클라이언트를 받으면 기존 Network 클래스가 생성되는 것이 아닌 NetworkManager 클래스가 생성된다.
public void Handle(Network network, IPacket packet)
{
switch(packet)
{
case MessagePacket p:
var manager = (NetworkManager)network;
var message = string.Empty;
if(string.IsNullOrEmpty(manager.Name))
{
manager.Name = p.Data;
message = $"{manager.Name}님이 입장했습니다.";
}else
message = $"{manager.Name}: {p.Data}";
Console.WriteLine(message);
listener.Broadcast(new MessagePacket(message), network);
break;
};
}
서버의 PacketHandler로 가서 MessagePacket을 받았을때 이름이 없다면 이름을 지정해주고 입장했다는 메세지를 저장한다.
이외에는 이름과 채팅을 저장한다.
콘솔에 출력 후 Broadcast를 통해 발송인을 제외한 전체에게 전달해준다.
listener.OnDisconnectEventHandler = (sender, args) =>
{
var network = (NetworkManager)args.network!;
if (!string.IsNullOrEmpty(network.Name))
{
var message = $"{network.Name}님이 퇴장했습니다.";
Console.WriteLine(message);
listener.Broadcast(new MessagePacket(message), network);
}
};
추가적으로 Program.cs에서 클라이언트 연결이 끉겼을때를 받아, 이름이 지정되어있다면 퇴장 메세지를 전달해준다.
Console.Write("이름: ");
while(true)
{
client.SendPacket(new MessagePacket(Console.ReadLine()));
}
또한 클라이언트 프로젝트의 Program.cs에서 이름을 입력받고 있다는 것을 알려준다.
빌드 후 실행했을 때 여러 클라이언트에서 이름과 채팅이 잘 나오는 것을 확인할 수 있다.
또한 클라이언트가 종료되었을때 퇴장 메세지도 뜨는 것을 볼 수 있다.