WPF TCP 채팅 프로젝트 소스코드
코드 설명
MVVM 구조로 만들어서 View 는 MainWindow 하나로 구성되어 있고 주요 기능은 MainWindow에서 MainViewModel을 호출하는 구조 코어기능과 통신을 담당하는 기능은 Core 폴더와 Net.IO 폴더로 따로 작성하였고 복잡한 디자인은 Themes 폴더에 xaml 로 작성해서 스타일을 적용하는 방식으로 구현하였음
클라이언트에서 통신을 담당하는 Server.cs 파일과 따로 만든 콘솔앱 서버와 통신하는 서버-클라이언트 구조로 구현하였음
View (MainWindow.xaml) 코드
<Window x:Class="WpfVanillaChat.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WpfVanillaChat" xmlns:viewmodel="clr-namespace:WpfVanillaChat.MVVM.ViewModel"
mc:Ignorable="d"
Height="650" Width="1200"
Background="#36393F"
WindowStyle="None"
AllowsTransparency="True"
ResizeMode="CanResizeWithGrip">
<Window.DataContext>
<viewmodel:MainViewModel/>
</Window.DataContext>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="25"/>
<RowDefinition/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Border Grid.ColumnSpan="2" Background="#252525" MouseDown="Border_MouseDown">
<Grid HorizontalAlignment="Stretch">
<Label Content="VanillaChat"
Foreground="Gray"
FontWeight="SemiBold"/>
<StackPanel HorizontalAlignment="Right"
Orientation="Horizontal">
<Button x:Name="btnMinimaze" Width="20" Height="20"
Content="_" Background="Transparent"
BorderThickness="0"
Foreground="Gray"
FontWeight="Bold"
Margin="0,0,0,3"
Click="btnMinimaze_Click"/>
<Button x:Name="btnWindowState" Width="20" Height="20"
Content="[ ]" Background="Transparent"
BorderThickness="0"
Foreground="Gray"
FontWeight="Bold"
Click="btnWindowState_Click"/>
<Button x:Name="btnClose" Width="20" Height="20"
Content="X" Background="Transparent"
BorderThickness="0"
Foreground="Gray"
FontWeight="Bold"
Click="btnClose_Click"/>
</StackPanel>
</Grid>
</Border>
<Grid Background="#2F3136" Grid.Row="1">
<Grid.RowDefinitions>
<RowDefinition Height="50"/>
<RowDefinition/>
<RowDefinition Height="60"/>
</Grid.RowDefinitions>
<Label Content="@Channel" VerticalAlignment="Center" FontWeight="SemiBold" Foreground="White" Margin="8,0,0,0"/>
<ListView ItemsSource="{Binding Users}"
SelectedItem="{Binding SelectedContact}"
Background="Transparent"
BorderThickness="0" Grid.Row="1"
ItemContainerStyle="{StaticResource ContactCard}"/>
<StackPanel Grid.Row="2">
<TextBox Height="30"
Background="#292B2F"
Foreground="White"
FontWeight="SemiBold"
Text="{Binding Username, UpdateSourceTrigger=PropertyChanged}"/>
<Button Height="30"
Background="#292B2F"
Foreground="White"
FontWeight="SemiBold"
Content="Connect"
Command="{Binding ConnectToServerCommand}"/>
</StackPanel>
</Grid>
<Grid Grid.Column="1" Grid.Row="1">
<Grid.RowDefinitions>
<RowDefinition Height="50"/>
<RowDefinition/>
<RowDefinition Height="60"/>
</Grid.RowDefinitions>
<Border BorderBrush="#2F3136" BorderThickness="0,0,0,2">
<Grid HorizontalAlignment="Stretch" Margin="8">
<Label Content="@Username" Foreground="White" FontWeight="Bold" Margin="5,0,5,0" VerticalAlignment="Center"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Image Width="20" Height="20" RenderOptions.BitmapScalingMode="Fant" Margin="5,0,5,0" Source="./Icons/call.png"/>
</StackPanel>
</Grid>
</Border>
<ListView ItemsSource="{Binding Chats}"
Background="Transparent" BorderThickness="0"
Foreground="White" FontSize="16"
Margin="8,0,0,0"
Grid.Row="1"/>
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition Width="90"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Row="1" Height="59" Width="910" HorizontalAlignment="Left"
VerticalAlignment="Stretch"
VerticalContentAlignment="Center"
Text="{Binding Chat, UpdateSourceTrigger=PropertyChanged}"
Grid.ColumnSpan="2"
Background="#292B2F"
BorderThickness="1"
Foreground="White"
FontWeight="Medium"/>
<StackPanel Orientation="Horizontal" Width="90"
HorizontalAlignment="Right"
Background="#292B2F"
Grid.Column="1">
<Button Content="Enter"
Height="59"
Width="89"
Background="#292B2F"
Foreground="White"
FontSize="20"
Command="{Binding SendMessageCommand}"
/>
</StackPanel>
</Grid>
</Grid>
</Grid>
</Window>
ViewModel 구성 요소
namespace WpfVanillaChat.MVVM.ViewModel
{
public class MainViewModel : INotifyPropertyChanged
{
public ObservableCollection<UserModel> Users { get; set; } = new ObservableCollection<UserModel>();
public ObservableCollection<string> Chats { get; set; } = new ObservableCollection<string>();
public ObservableCollection<ContactModel> Contacts { get; set; } = new ObservableCollection<ContactModel>();
public ObservableCollection<MessageModel> Messages { get; set; } = new ObservableCollection<MessageModel>();
/* Commands */
public ICommand ConnectToServerCommand { get; set; }
public ICommand SendMessageCommand { get; set; }
/* Username 선언 */
private string _username = string.Empty;
public string Username
{
get => _username;
set
{
if (_username != value)
{
_username = value;
OnPropertyChanged();
}
}
}
/* Chat 선언 */
private string _chat = string.Empty;
public string Chat
{
get => _chat;
set
{
if (_chat != value)
{
_chat = value;
OnPropertyChanged();
}
}
}
/* ContactModel 선언 */
private ContactModel _selectedcontact = new ContactModel();
public ContactModel Selectedcontact
{
get { return _selectedcontact; }
set
{
_selectedcontact = value;
OnPropertyChanged();
}
}
/* 서버&파이어베이스 선언 */
private readonly Server _server;
private readonly FirebaseHelper _firebaseHelper;
public MainViewModel()
{
_server = new Server();
_firebaseHelper = new FirebaseHelper();
_server.UserConnectedEvent += UserConnected;
_server.MsgReceivedEvent += MessageReceived;
_server.UserDisconnectedEvent += RemoveUser;
ConnectToServerCommand = new RelayCommand(async o => await ConnectToServer(), o => !string.IsNullOrEmpty(Username));
SendMessageCommand = new RelayCommand(async o => await SendMessage(), o => !string.IsNullOrEmpty(Chat));
}
private async Task ConnectToServer()
{
_server.ConnectToServer(Username);
await LoadMessages();
}
/* Chat Load&Send 1:N */
private async Task LoadMessages()
{
if (Users.Count > 1)
{
var roomname = GetRoomName(Users.Select(u => u.Username).ToList());
var chats = await _firebaseHelper.LoadMessagesAsync(roomname);
Application.Current.Dispatcher.Invoke(() =>
{
Chats.Clear();
foreach (var chat in chats)
{
Chats.Add(chat);
}
});
}
}
private async Task SendMessage()
{
if (Users.Count > 1)
{
var roomname = GetRoomName(Users.Select(u => u.Username).ToList());
await _server.SendMessageToServer(Chat, roomname);
await _firebaseHelper.SaveMessageAsync(roomname, Username, Chat);
Chat = string.Empty; // 메시지 전송 후 입력창 비우기
}
}
/* Action */
private async void UserConnected(string username, string uid)
{
var user = new UserModel
{
Username = username,
UID = uid,
};
if (!Users.Any(x => x.UID == user.UID))
{
Application.Current.Dispatcher.Invoke(() => Users.Add(user));
await LoadMessages(); // 사용자 연결 시 메시지 불러오기 시도
}
}
private void MessageReceived()
{
if (_server.PacketReader != null)
{
var msg = _server.PacketReader.ReadMessage();
Application.Current.Dispatcher.Invoke(() => Chats.Add(msg));
}
}
private void RemoveUser()
{
if (_server.PacketReader != null)
{
var uid = _server.PacketReader.ReadMessage();
var user = Users.FirstOrDefault(x => x.UID == uid);
if (user != null)
{
Application.Current.Dispatcher.Invoke(() => Users.Remove(user));
}
}
}
/* RelayCommend 추가 */
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string propertyName = "")
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private string GetRoomName(List<string> usernames)
{
var users = usernames;
users.Sort();
return $"ChatRoom_{string.Join("_", users)}";
}
}
}
- 옵저버블컬렉션으로 리스트 관리
Users: 현재 연결된 사용자 목록을 관리하는 ObservableCollection
- 명령(Command) 및 데이터 바인딩
릴레이커맨드를 만들어서 적용하는 방식을 시도함. ConnectToServerCommand: 서버에 연결하는 명령. SendMessageCommand: 메시지를 전송하는 명령.
- 사용자 이름 및 채팅 내용 관리
Username: 사용자의 이름을 저장하고 변경을 알리는 속성. Chat: 현재 입력된 채팅 메시지를 저장하고 변경을 알리는 속성.
TCP 클라이언트 부분 구현
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
using WpfVanillaChat.Firebase;
using WpfVanillaChat.Net.IO;
namespace WpfVanillaChat.Net
{
public class Server
{
private readonly TcpClient _client;
public PacketReader? PacketReader { get; private set; }
private readonly FirebaseHelper _firebaseHelper;
public event Action<string, string>? UserConnectedEvent;
public event Action? MsgReceivedEvent;
public event Action? UserDisconnectedEvent;
private string _username = string.Empty;
public Server()
{
_client = new TcpClient();
_firebaseHelper = new FirebaseHelper();
}
public void ConnectToServer(string username)
{
if (!_client.Connected)
{
_username = username;
_client.Connect("127.0.0.1", 7891);
PacketReader = new PacketReader(_client.GetStream());
if (!string.IsNullOrEmpty(username))
{
var connectPacket = new PacketBuilder();
connectPacket.WriteOpCode(0);
connectPacket.WriteMessage(username);
_client.Client.Send(connectPacket.GetPacketBytes());
}
ReadPackets();
}
}
private void ReadPackets()
{
Task.Run(() =>
{
while (true)
{
if (PacketReader == null)
continue;
var opcode = PacketReader.ReadByte();
switch (opcode)
{
case 1:
var username = PacketReader.ReadMessage();
var uid = PacketReader.ReadMessage();
UserConnectedEvent?.Invoke(username, uid);
break;
case 5:
MsgReceivedEvent?.Invoke();
break;
case 10:
UserDisconnectedEvent?.Invoke();
break;
default:
Console.WriteLine("Unknown opcode received...");
break;
}
}
});
}
public async Task SendMessageToServer(string chat, string roomname)
{
var messagePacket = new PacketBuilder();
messagePacket.WriteOpCode(5);
messagePacket.WriteMessage(chat);
_client.Client.Send(messagePacket.GetPacketBytes());
await _firebaseHelper.SaveMessageAsync(roomname, _username, chat);
}
}
}
Net.IO 폴더에 패킷빌더와 패킷리더를 구현하고 호출하는 방식으로 작성함
서버 콘솔앱
using System.Net;
using System.Net.Sockets;
using System.Collections.Generic;
using VanillaServer.Net.IO;
namespace VanillaServer
{
public class Program
{
private static readonly List<Client> _users = new();
private static readonly TcpListener _listener = new(IPAddress.Parse("127.0.0.1"), 7891);
static void Main(string[] args)
{
_listener.Start();
while (true)
{
var client = new Client(_listener.AcceptTcpClient());
_users.Add(client);
SendUserList(client);
BroadcastConnection(client);
}
}
private static void SendUserList(Client client)
{
foreach (var usr in _users)
{
var userListPacket = new PacketBuilder();
userListPacket.WriteOpCode(1);
userListPacket.WriteMessage(usr.Username);
userListPacket.WriteMessage(usr.UID.ToString());
client.ClientSocket.Client.Send(userListPacket.GetPacketBytes());
}
}
private static void BroadcastConnection(Client newClient)
{
foreach (var client in _users)
{
if (client == newClient) continue; // 이미 신규 클라이언트에게는 보냈으므로 생략
var broadcastPacket = new PacketBuilder();
broadcastPacket.WriteOpCode(1);
broadcastPacket.WriteMessage(newClient.Username);
broadcastPacket.WriteMessage(newClient.UID.ToString());
client.ClientSocket.Client.Send(broadcastPacket.GetPacketBytes());
}
}
public static void BroadcastMessage(string message)
{
foreach (var user in _users)
{
var msgPacket = new PacketBuilder();
msgPacket.WriteOpCode(5);
msgPacket.WriteMessage(message);
user.ClientSocket.Client.Send(msgPacket.GetPacketBytes());
}
}
public static void BroadcastDisconnect(string uid)
{
var disconnectedUser = _users.FirstOrDefault(x => x.UID.ToString() == uid);
if (disconnectedUser != null)
{
_users.Remove(disconnectedUser);
foreach (var user in _users)
{
var broadcastPacket = new PacketBuilder();
broadcastPacket.WriteOpCode(10);
broadcastPacket.WriteMessage(uid);
user.ClientSocket.Client.Send(broadcastPacket.GetPacketBytes());
}
BroadcastMessage($"[{disconnectedUser.Username}] Disconnected!");
}
}
}
}
using System.Net.Sockets;
using VanillaServer.Net.IO;
namespace VanillaServer
{
public class Client
{
public string Username { get; set; }
public Guid UID { get; set; }
public TcpClient ClientSocket { get; set; }
PacketReader _PacketReader;
public Client(TcpClient client)
{
ClientSocket = client;
UID = Guid.NewGuid();
_PacketReader = new PacketReader(ClientSocket.GetStream());
var opcode = _PacketReader.ReadByte();
Username = _PacketReader.ReadMessage();
Console.WriteLine($"[{DateTime.Now}]: Client has Connected With the username: {Username}");
Task.Run(Process);
}
private void Process()
{
while (true)
{
try
{
var opcode = _PacketReader.ReadByte();
switch (opcode)
{
case 5:
var msg = _PacketReader.ReadMessage();
Console.WriteLine($"[{DateTime.Now}]: Message received! {msg}");
Program.BroadcastMessage($"[{DateTime.Now}]: [{Username}]: {msg}");
break;
default:
break;
}
}
catch (Exception)
{
Console.WriteLine($"[{UID}]: Disconnected!");
Program.BroadcastDisconnect(UID.ToString());
ClientSocket.Close();
break;
}
}
}
}
}
Firebase 연동
namespace WpfVanillaChat.Firebase
{
public class FirebaseHelper
{
private static readonly string FirebaseDatabaseUrl = "파이어베이스 URL";
private readonly FirebaseClient _firebaseClient;
public FirebaseHelper()
{
_firebaseClient = new FirebaseClient(FirebaseDatabaseUrl);
}
public async Task SaveMessageAsync(string room, string username, string message)
{
var chatMessage = new
{
Username = username,
Message = message,
Timestamp = DateTime.UtcNow
};
await _firebaseClient
.Child("ChatRooms")
.Child(room)
.Child("Messages")
.PostAsync(chatMessage);
}
public async Task<List<string>> LoadMessagesAsync(string room)
{
var messages = await _firebaseClient
.Child("ChatRooms")
.Child(room)
.Child("Messages")
.OrderByKey()
.OnceAsync<Dictionary<string, object>>();
List<string> chatMessages = new List<string>();
foreach (var message in messages)
{
var msg = message.Object;
chatMessages.Add($"{msg["Username"]}: {msg["Message"]}");
}
return chatMessages;
}
}
}
리얼타임데이터베이스에 채팅로그가 저장되고 같은 유저끼리 채팅할 때 이전내용이 호출되게끔 구성함 채팅앱에 참가하는 유저의 조합으로 채팅방이름이 설정되고 같은 사람끼리 구성된 방일 경우만 채팅이 호출됨
전체 소스코드는 깃허브에 정리되어있음 https://github.com/woogooree/WpfChat/tree/main