WebSocket 채팅 기능

C# 콘솔 게임을 제작하면 추가적인 온라인 채팅 기능을 구현

Nuget Package => .Net.WebSocket-Sharp Git Hub

최초 Nest Js 의 Socket.io 와 C# 의 SocketIOClient 로 구현 하려고 하였으나 아래의 문제들이 발견

Decoding Error : 두 프레임 워크 및 라이브러리간의 호환성이 문제가 되어 연결이 되지 않는 문제

해당 이슈로 인해서 호환 되는 라이브러리를 찾다가 ‘ws’ 라이브러리를 사용 하기로 함 .Net.WebSocket-Sharp Git Hub

하지만 다음 문제가 발생

toHandShake Fail : .NET.WebSocket Nuget Package와 npm의 ws의 통신이 원활하지 못하는 문제

해당 문제로 계속해서 스크립트를 변경 하였을때 도저히 서로의 소켓이 붙지 않는 문제가 발생

Nest Js의 경우 자체 프레임워크에서 지원하는 Gateway가 .Net의 프레임워크가 서로 호환이 되지 않는것으로 보여 Nest를 Express로 프레임워크를 변경하여 개발 진행하여 성공

Node JS Socket Server Script


const WebSocket = require("ws");

const wss = new WebSocket.Server({ port: 3000 });

console.log("Server opened on port", wss.address().port);


wss.on("connection", function connection(client) {
	console.log("Client connected");
	
	client.on("message", function incoming(message) {

	try {
		const chatMessage = JSON.parse(message);
		console.log(
		`Received message from ${chatMessage.User}:${chatMessage.Message}`);
		broadcastMessage(message);
		} catch (e) {
		console.error("Error parsing message:", e.message);
		}
		});
	client.send(
		JSON.stringify(
			{ User: "Server", Message: "Hello from server!" }
			));
});

  

function broadcastMessage(message) {
	wss.clients.forEach(function each(client) {
		if (client.readyState === WebSocket.OPEN) {
			client.send(message);
			}
	});
}

소켓 연결

  • 소켓 연결 시 클라이언트 연결 및 메세지 이벤트 처리
  • 클라이언트에서 해당 "connection"이라는 메세지가 오면 function connection(client) 를 Callback으로 실행
  • 서버에서 정상적으로 연결시 Client connected를 터미널에 출력
  • 연결된 Client에 메세지를 보내어 결과를 출력
const wss = new WebSocket.Server({ port: 3000 });
wss.on("connection", function connection(client) {
	console.log("Client connected");

     ,,,,,,

	client.send(
	JSON.stringify(
		{ User: "Server", Message: "Hello from server!" }
		));
});

메세지 입력 및 전송

  • 소켓에 연결되어 있는 클라이언트에서 message라는 이벤트를 호출시 Json Type의 메세지를 터미널에 출력
  • 출력된 메세지는 다시 broadcastMessage 함수를 호출하여 클라이언트 상태가 OPEN인 소켓 클라이언트에 전체 메세지를 broadcast 하여 전달
client.on("message", function incoming(message) {
	try {
		const chatMessage = JSON.parse(message);
		console.log(
		`Received message from ${chatMessage.User}:${chatMessage.Message}`);
		broadcastMessage(message);
		} catch (e) {
		console.error("Error parsing message:", e.message);
		}
		});

function broadcastMessage(message) {
	wss.clients.forEach(function each(client) {
		if (client.readyState === WebSocket.OPEN) {
			client.send(message);
			}
	});

이벤트 방식으로 처리하여 현재 socket에 연결되어있는 Client 모두에세 BroadCast 방식으로 메세지를 일괄로 전달하며, 추후에 게임을 채널을 나눈다고 하면 추가적인 조건에 Room Number를 추가하여 특정 채널의 유저에게만 전달

C# Socket Client Script

  • 채팅기능의 경우 비동기 방식을 적용하여 언제 올지 모르는 채팅을 기능을 원활히 사용할 수 있도록 개발
using System.Net.WebSockets;  
using System.Text;  
using System.Text.Json;  
  
namespace DungeonTextGame  
{  
	public static class ChatSocket  
	{  
	private static ClientWebSocket _client;  
	private static string ID { get; set; }  
	  
	public static async Task ChatRoomAsync()  
	{  
		ClearScreen();  
		WriteToConsole("[Chat Room]");  
		ID = Account.LoginCharacter.Id;  
		await ConnectToServerAsync();  
		var receiveTask = ReceiveMessagesAsync(); // 메시지 수신 작업 시작  
		while (_client.State == WebSocketState.Open)  
		{  
			string input = ReadInput(); // 사용자 입력 받기  
			if (string.IsNullOrEmpty(input))  
			break;  
			await SendMessageAsync(input);  
		}  
		await receiveTask; // 메시지 수신 작업 완료 대기  
	}  
	
	private static async Task SendMessageAsync(string message)  
	{  
		ClearCurrentConsoleLine(); // 메시지 보내기 전에 현재 콘솔 줄을 지웁니다.  
		  
		ChatMessage chatMessage = new ChatMessage { User = ID, Message = message };  
		string json = JsonSerializer.Serialize(chatMessage);  
		byte[] buffer = Encoding.UTF8.GetBytes(json);  
		await _client.SendAsync(new ArraySegment<byte>(buffer), WebSocketMessageType.Text, true, CancellationToken.None);  
	}  
	  
	private static void ClearCurrentConsoleLine()  
	{  
		int currentLineCursor = CursorTop - 1; // 현재 줄에서 한 줄 위로 이동  
		SetCursorPosition(0, currentLineCursor);  
		Write(new string(' ', WindowWidth));  
		SetCursorPosition(0, currentLineCursor);  
	}  
	  
	private static async Task ReceiveMessagesAsync()  
	{  
		var buffer = new byte[1024];  
		try  
		{  
			while (_client.State == WebSocketState.Open)  
			{  
			var result = await _client.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);  
			if (result.MessageType == WebSocketMessageType.Close)  
			break;  
		  
			string receivedMessageJson = Encoding.UTF8.GetString(buffer, 0, result.Count);  
			ChatMessage receivedMessage = JsonSerializer.Deserialize<ChatMessage>(receivedMessageJson);  
			WriteToConsole($"{receivedMessage.User} : {receivedMessage.Message}");  
			}  
		}  
		catch (Exception ex)  
		{  
			WriteToConsole($"Error in receiving messages: {ex.Message}");  
		}  
	}  
	  
	private static async Task ConnectToServerAsync()  
	{  
		_client = new ClientWebSocket();  
		await _client.ConnectAsync(new Uri("ws://127.0.0.1:3000"), CancellationToken.None);  
		WriteToConsole("Connected!");  
	}  
	
	private static void ClearScreen() => Clear();  
	
	private static void WriteToConsole(string message) => WriteLine(message);  
	
	private static string ReadInput() => ReadLine();  
	}  
	  
	  
	public class ChatMessage  
	{  
		public string User { get; set; }  
		public string Message { get; set; }  
	}  
}

Socket Connect

  • 현재 로그인한 케릭터의 ID를 채팅 ID로 설정
  • ConnectToServerAsync를 비동기 await로 사용하여 서버 접속이 완료 될 때까지 대기 할 수 있도록 조치
  • 접속시 콘솔에 Connected 출력
  • while문으로 입력값을 지속적으로 추적하여 사용자 입력을 감지
  • 입출력의 경우 서로 메세지가 꼬이지 않도록 await를 사용하여 비동기적 처리
public static async Task ChatRoomAsync()  
{  
	ClearScreen();  
	WriteToConsole("[Chat Room]");  
	ID = Account.LoginCharacter.Id;  
	await ConnectToServerAsync();  
	var receiveTask = ReceiveMessagesAsync(); // 메시지 수신 작업 시작  
	while (_client.State == WebSocketState.Open)  
	{  
		string input = ReadInput(); // 사용자 입력 받기  
		if (string.IsNullOrEmpty(input))  
		break;  
		await SendMessageAsync(input);  
	}  
	await receiveTask; // 메시지 수신 작업 완료 대기  
}  

private static async Task ConnectToServerAsync()  
{  
	_client = new ClientWebSocket();  
	await _client.ConnectAsync(
		new Uri("ws://127.0.0.1:3000"), CancellationToken.None);  
	WriteToConsole("Connected!");  
}  

Send to Message

  • 메세지의 경우 키가 입력이 되면 Console의 해당 입력이 출력되는 부분을 삭제
  • 사용자가 입력값과 서버에서 보내준 출력 값에 혼동이 없도록 구현

  • 입력값은 Json을 직렬화 하고 해당 Json을 UTF-8로 인코딩 하여 한글에 대한 문제점 조치
  • 보안에 대한 Token 값의 경우 구현이 안되어 있어 None 옵션으로 처리
  • 서버에서도 해당 클라이언트에서 보내온 데이터를 출력하여 디버깅

private static async Task SendMessageAsync(string message)  
{  
	ClearCurrentConsoleLine(); // 메시지 보내기 전에 현재 콘솔 줄을 지웁니다.  
	  
	ChatMessage chatMessage = new ChatMessage { User = ID, Message = message };  
	string json = JsonSerializer.Serialize(chatMessage);  
	byte[] buffer = Encoding.UTF8.GetBytes(json);  
	await _client.SendAsync(
		new ArraySegment<byte>(buffer), 
		WebSocketMessageType.Text, 
		true, 
		CancellationToken.None);  
}  
  
private static void ClearCurrentConsoleLine()  
{  
	int currentLineCursor = CursorTop - 1; // 현재 줄에서 한 줄 위로 이동  
	SetCursorPosition(0, currentLineCursor);  
	Write(new string(' ', WindowWidth));  
	SetCursorPosition(0, currentLineCursor);  
}  

Receive Message

  • While condition으로 State가 Open인 경우에만 메세지를 수신하여 불필요한 Access 방지
  • 수신 받은 Message의 경우 JSON을 역직렬화 하여 ChatMessage Class 형식으로 ID와 Message 분리
private static async Task ReceiveMessagesAsync()  
{  
	var buffer = new byte[1024];  
	try  
	{  
		while (_client.State == WebSocketState.Open)  
		{  
			var result = await _client.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);  
			if (result.MessageType == WebSocketMessageType.Close)  
			break;  
			  
			string receivedMessageJson = Encoding.UTF8.GetString(buffer, 0, result.Count);  
			ChatMessage receivedMessage = JsonSerializer.Deserialize<ChatMessage>(receivedMessageJson);  
			WriteToConsole($"{receivedMessage.User} : {receivedMessage.Message}");  
		}  
	}  
	catch (Exception ex)  
	{  
		WriteToConsole($"Error in receiving messages: {ex.Message}");  
	} 
}

public class ChatMessage  
{  
	public string User { get; set; }  
	public string Message { get; set; }  
}

Test

마치며

처음 소켓을 구현하였을 떄 Nest JS의 WebSocket 기능을 이용하여 쉽게 해결하려고 했는데 생각하지 못한 호환 이슈로 인해서 시간을 많이 잡아먹은것이 아쉬운 개발이었습니다.

정작 Express 로 다시 작업을 하니 약 2시간 만에 작업이 끝나게 되었고, Unity 로 계발을 진행하게 되었을 때는 Data 탈취에 대한 https 보안 처리 및 토큰을 사용하여 보안을 강화하고, 비 동기 작업 및 다중 쓰레드에 대한 이해를 높혀 더 빠른 개발을 진행 해야 할 것 입니다.

추가적으로 Nest JS를 이용한 방식을 다시 연구하여 Socket을 더욱 완벽하게 사용 가능하도록 노력해야 할 것 입니다.