본문 바로가기
wpf/c#, wpf 채팅프로그램

c# 채팅프로그램 만들기 - 5 - 소켓통신(TcpListener, TcpClient)을 이용한 채팅프로그램을 만들어보자(콘솔 채팅서버)

by devjh 2020. 8. 12.
반응형

채팅프로그램 만들기는 총 12개의 게시글로 구성되어있습니다.

 

첫번째 게시글 : 1:1단발성통신(동기서버 동기클라)

 

두번째 게시글 : 1:1지속성통신(동기서버 완성본 동기 클라이언트)

 

세번째 게시글 : 1:1통신(비동기서버)

 

네번째 게시글: 1:N통신(여기서부터는 여러명을 받아야하므로 당연히 비동기서버입니다.)

 

다섯번째 게시글 : 채팅프로그램 콘솔 서버

 

여섯번째 게시글 : 채팅프로그램 콘솔 클라이언트

 

일곱번째 게시글 : wpf를 통해 View를 구현한 서버

 

여덟번째 게시글 : wpf를 통해 View를 구현한 클라이언트(메인화면 만들기)

 

아홉번째 게시글 : wpf를 통해 View를 구현한 클라이언트(로그인화면 만들기)

 

열번째 게시글 : wpf를 통해 View를 구현한 클라이언트(채팅상대 선택화면 만들기)

 

열한번째 게시글 : wpf를 통해 View를 구현한 클라이언트(채팅화면 만들기)

 

열두번째 게시글 : wpf를 통해 View를 구현한 클라이언트(로직 구현)

 


 

이번 게시글에서는 그동안 만들었던 내용을 기반으로 콘솔 채팅프로그램 서버편을 만들어보겠습니다.

 

클라이언트들은 서버에게 현재 접속자목록을 요청하고

 

원하는 클라이언트들에게 메시지를 보낼수 있으며,

 

본인이 보낸 메시지를 확인하는 기능까지 구현하였으며

 

서버에서는 현재 접속자들을 확인하는 기능, 접속자들의 접속기록을 확인하는 기능,

 

클라이언트들의 대화한 로그를 확인하는 기능까지 구현하였습니다.

 

 

 

이번솔루션파일은 Program, MainServer, ClientManager, ClientData, StaticDefine

 

총 5개의 클래스로 구성되어있습니다. 

 

코드와 함께 볼수 있도록 주석에 설명을 넣겠습니다.

1. Program.cs

namespace ChattingServiceConsoleServer
{
    class Program
    {
        static void Main(string[] args)
        {
            MainServer a = new MainServer();
            a.ConSoleVIew();
        }
    }
}

MainServer객체를 만들어주고 ConsoleView 메서드를 실행해줍니다.

 

 

2. ClientManager.cs

namespace ChattingServiceConsoleServer
{
    public class MainServer
    {
        ClientManager _clientManager = null;
        ConcurrentBag<string> chattingLog = null;
        ConcurrentBag<string> AccessLog = null;
        Thread conntectCheckThread = null;
        

        public MainServer()
        {
            // 생성자에서는 클라이언트매니저의 객체를 생성,
            // 채팅로그와 접근로그를 담을 컬렉션 생성
            // 서버 스레드 및 하트비트 스레드 시작

            _clientManager = new ClientManager();
            chattingLog = new ConcurrentBag<string>();
            AccessLog = new ConcurrentBag<string>();
            _clientManager.EventHandler += ClientEvent;
            _clientManager.messageParsingAction += MessageParsing;
            Task serverStart = Task.Run(() =>
            {
                ServerRun();
            });

            conntectCheckThread = new Thread(ConnectCheckLoop);
            conntectCheckThread.Start();

        }

        // 하트비트 스레드
        private void ConnectCheckLoop()
        {
            while (true)
            {
                foreach (var item in ClientManager.clientDic)
                {
                    try
                    {
                        string sendStringData = "관리자<TEST>";
                        byte[] sendByteData = new byte[sendStringData.Length];
                        sendByteData = Encoding.Default.GetBytes(sendStringData);

                        item.Value.tcpClient.GetStream().Write(sendByteData, 0, sendByteData.Length);
                    }
                    catch (Exception e)
                    {
                        RemoveClient(item.Value);
                    }
                }
                Thread.Sleep(1000);
            }
        }

        // 클라이언트의 접속종료가 감지됐을때 static 예약어로 저장된 clientDic에서
        // 해당클라이언트를 제거하고, 로그를 남깁니다.
        private void RemoveClient(ClientData targetClient)
        {
            ClientData result = null;
            ClientManager.clientDic.TryRemove(targetClient.clientNumber, out result);
            string leaveLog = string.Format("[{0}] {1} Leave Server", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), result.clientName);
            AccessLog.Add(leaveLog);
        }

        // 클라이언트에게 메시지를 보내는 첫번째 과정입니다.
        private void MessageParsing(string sender, string message)
        {
            List<string> msgList = new List<string>();

            string[] msgArray = message.Split('>');
            foreach (var item in msgArray)
            {
                if (string.IsNullOrEmpty(item))
                    continue;
                msgList.Add(item);
            }
            SendMsgToClient(msgList, sender);

        }

        // 클라이언트에게 메시지를 보내는 두번째 과정입니다.
        private void SendMsgToClient(List<string> msgList, string sender)
        {
            string LogMessage = "";
            string parsedMessage = "";
            string receiver = "";

            int senderNumber = -1;
            int receiverNumber = -1;

            foreach (var item in msgList)
            {
                string[] splitedMsg = item.Split('<');

                receiver = splitedMsg[0];
                parsedMessage = string.Format("{0}<{1}>", sender, splitedMsg[1]);

                senderNumber = GetClinetNumber(sender);
                receiverNumber = GetClinetNumber(receiver);

                if (senderNumber == -1 || receiverNumber == -1)
                {
                    return;
                }

                if (parsedMessage.Contains("<GiveMeUserList>"))
                {
                    string userListStringData = "관리자<";
                    foreach (var el in ClientManager.clientDic)
                    {
                        userListStringData += string.Format("${0}", el.Value.clientName);
                    }
                    userListStringData += ">";
                    byte[] userListByteData = new byte[userListStringData.Length];
                    userListByteData = Encoding.Default.GetBytes(userListStringData);
                    ClientManager.clientDic[receiverNumber].tcpClient.GetStream().Write(userListByteData, 0, userListByteData.Length);
                    return;
                }



                LogMessage = string.Format(@"[{0}] [{1}] -> [{2}] , {3}", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), sender, receiver, splitedMsg[1]);

                ClientEvent(LogMessage, StaticDefine.ADD_CHATTING_LOG);

                byte[] sendByteData = Encoding.Default.GetBytes(parsedMessage);

                ClientManager.clientDic[receiverNumber].tcpClient.GetStream().Write(sendByteData, 0, sendByteData.Length);
            }
        }

        // 클라이언트의 이름을 통해 ClientNumber를 얻는 메서드입니다.
        // ClientDictionary는 ClientNumber를 키로 사용하고있어서
        // 클라이언트의 번호를 통해 클라이언트 객체를 반환받을 수 있습니다.
        private int GetClinetNumber(string targetClientName)
        {
            foreach (var item in ClientManager.clientDic)
            {
                if (item.Value.clientName == targetClientName)
                {
                    return item.Value.clientNumber;
                }
            }
            return -1;
        }

        // 접근로그와 채팅로그를 저장하는 메서드입니다.
        // StaticDefine은 제가만든 클래스로 정적변수에 번호를 지정해놨습니다.
        private void ClientEvent(string message, int key)
        {
            switch (key)
            {
                case StaticDefine.ADD_ACCESS_LOG:
                    {
                        AccessLog.Add(message);
                        break;
                    }
                case StaticDefine.ADD_CHATTING_LOG:
                    {
                        chattingLog.Add(message);
                        break;
                    }
            }
        }

        // 서버를 돌리는 과정입니다.
        private void ServerRun()
        {
            TcpListener listener = new TcpListener(new IPEndPoint(IPAddress.Any, 9999));
            listener.Start();

            while (true)
            {
                Task<TcpClient> acceptTask = listener.AcceptTcpClientAsync();

                acceptTask.Wait();

                TcpClient newClient = acceptTask.Result;

                _clientManager.AddClient(newClient);
            }
        }

        // 기본적으로 서버가 돌아가는 콘솔 로직입니다.
        public void ConSoleVIew()
        {
            while (true)
            {
                Console.WriteLine("=============서버=============");
                Console.WriteLine("1.현재접속인원확인");
                Console.WriteLine("2.접속기록확인");
                Console.WriteLine("3.채팅로그확인");
                Console.WriteLine("0.종료");
                Console.WriteLine("==============================");

                string key = Console.ReadLine();
                int order = 0;


                if (int.TryParse(key, out order))
                {
                    switch (order)
                    {
                        case StaticDefine.SHOW_CURRENT_CLIENT:
                            {
                                ShowCurrentClient();
                                break;
                            }
                        case StaticDefine.SHOW_ACCESS_LOG:
                            {
                                ShowAccessLog();
                                break;
                            }
                        case StaticDefine.SHOW_CHATTING_LOG:
                            {
                                ShowCattingLog();
                                break;
                            }

                        case StaticDefine.EXIT:
                            {
                                conntectCheckThread.Abort();
                                return;
                            }
                        default:
                            {
                                Console.WriteLine("잘못 입력하셨습니다.");
                                Console.ReadKey();
                                break;
                            }
                    }
                }

                else
                {
                    Console.WriteLine("잘못 입력하셨습니다.");
                    Console.ReadKey();
                }
                Console.Clear();
                Thread.Sleep(50);
            }
        }

        // 채팅로그확인
        private void ShowCattingLog()
        {
            if (chattingLog.Count == 0)
            {
                Console.WriteLine("채팅기록이 없습니다.");
                Console.ReadKey();
                return;
            }

            foreach (var item in chattingLog)
            {
                Console.WriteLine(item);
            }
            Console.ReadKey();
        }

        // 접근로그확인
        private void ShowAccessLog()
        {
            if (AccessLog.Count == 0)
            {
                Console.WriteLine("접속기록이 없습니다.");
                Console.ReadKey();
                return;
            }

            foreach (var item in AccessLog)
            {
                Console.WriteLine(item);
            }
            Console.ReadKey();
        }

        // 현재접속유저확인
        private void ShowCurrentClient()
        {
            if (ClientManager.clientDic.Count == 0)
            {
                Console.WriteLine("접속자가 없습니다.");
                Console.ReadKey();
                return;
            }

            foreach (var item in ClientManager.clientDic)
	        {
                Console.WriteLine(item.Value.clientName);
	        }
            Console.ReadKey();
        }
    }
}

 

주석으로 간단히 설명을 했지만, 추가로 설명이 필요한 부분을 설명하겠습니다.

 

(1) ConcurrentBag

ConcurrentBag 및 ConcurrentDictionary는 우리가 아는 List와 Dictionary와 기능이 매우 유사합니다.

 

단 ConcurrentBag은 스레드로부터 안전합니다.

 

내부적으로 Lock처리가 되어있습니다.

 

여러 스레드가 접근해서 내용을 수정해야하는 List는 ConcurrentBag으로 처리해야 합니다.

 

 

(2) event

event, delegate 사용예제입니다.

frozenpond.tistory.com/3

 

c# delegate, event 사용법 및 사용예제

delegate는 메서드 대리자입니다. delegate로 메서드 대리자를 선언해주고 원하는 메서드를 참조시킬수 있습니다. 쉽게말하면 함수를 보관하는 통을 만들고(대리자선언) 그 통안에 함수를 넣고 나중

frozenpond.tistory.com

 

(3) 하트비트스레드(현재 접속자 처리방법)

현재 접속자는 try catch구문을 사용해 제어합니다.

 

1번 클라이언트가 2번클라이언트에게 메시지를 보냈으나,

 

2번클라이언트가 접속을 종료한 상태라면 서버가 해당 클라이언트에게 Write하는 과정에서 예외가 발생하여, catch블럭에서 해당 클라이언트를 현재 접속자에서 제외하게 구현하였습니다.

 

그러나 클라이언트가 접속을 종료한 클라이언트에게 메시지를 보내야만 서버가 해당 클라이언트가 현재 접속자가 아니라는걸 인식한다???????

 

서버는 항상 현재접속자가 누군지 알고있어야 합니다.

 

그래서 저는 하트비트스레드를 하나 돌렸습니다.

 

말그대로 현재 클라이언트들의 심장이 뛰나(소켓연결이 종료되지는 않았나) 1초에 한번씩 확인하는 스레드입니다.

 

관리자<TEST> 라는 메시지를 1초에 한번씩 모든 클라이언트들에게 보내고

 

접속이 끊긴 클라이언트에 대해서는 예외가 발생하여 접속자목록에서 제외하도록 하는 스레드입니다.

 

 

 

(4) 채팅 프로토콜

클라이언트는 서버에게 내가 누구한테 어떤메시지를 보낼지 구별하기 위해

 

"상대방<채팅내용>" 을 서버에게 보내도록 구현해놨습니다.

 

'<' 기호는 상대방과 내용을 구별하기위해 사용하며

 

'>' 기호는 버퍼에 쌓인 메시지를 구별하기 위해 사용됩니다.

 

현재 서버는 비동기서버로 메시지가 오면 데이터를 처리하고 다시 읽을준비를 하게 해놨습니다.

 

클라이언트에게서 메시지가 오고나서 시간이 지나 메시지를 읽을준비를 한 상태에서 메시지가 온다면 

 

문제될것이 없으나

 

메시지가 오고 메시지를 처리하는 도중에 메시지가 또 오는경우에는

 

"1번사용자<제이름은>1번사용자<xx입니다.>" 라는 메시지가 쌓이게 됩니다.

 

'>' 기호를 사용하지 않게되면

 

"1번사용자<제이름은1번사용자<xx입니다." 라는 메시지가 쌓여 상대방과 채팅내용을

 

구별하기 힘들어집니다.

 

그래서 <>기호를 사용해 채팅상대와 내용을 구별할 수 있도록 하였습니다.

 

 

 

(5) 서버가 클라이언트들의 채팅을 전달해주는 로직

 

"상대방<내용>" 으로 클라이언트에게 메시지가 오면

 

상대방의 이름이 저장된 TcpClient 객체에게 Write를 해주고 채팅로그를 남깁니다.

 

 

 

(6)<GiveMeUserList>

 

클라이언트는 유저리스트를 보고싶을때 "본인이름<GiveMeUserList>" 를 서버에 보내도록 해놨습니다.

 

해당 프로토콜이 오면 서버는 클라이언트에게 현재 접속자를 보내 사용자가 채팅상대를 정할 수 있게 해놨습니다.

 

 

3. ClientManager.cs

namespace ChattingServiceConsoleServer
{
    class ClientManager
    {
        // ConcurentBag과 마찬가지로 스레드로부터 안전한 해시맵의 자료구조입니다.
        // key는 클라이언트의 IP의 네번째 옥텟이며
        // Value는 연결된 클라이언트 객체입니다.
        
        public static ConcurrentDictionary<int, ClientData> clientDic = new ConcurrentDictionary<int, ClientData>();
        public event Action<string, string> messageParsingAction = null;
        public event Action<string, int> EventHandler = null;

        public void AddClient(TcpClient newClient)
        {
            ClientData currentClient = new ClientData(newClient);

            try
            {
                currentClient.tcpClient.GetStream().BeginRead(currentClient.readBuffer, 0, currentClient.readBuffer.Length, new AsyncCallback(DataReceived), currentClient);
                clientDic.TryAdd(currentClient.clientNumber, currentClient);
            }

            catch (Exception e)
            {

            }
        }

        private void DataReceived(IAsyncResult ar)
        {
            
            ClientData client = ar.AsyncState as ClientData;

            try
            {
                int byteLength = client.tcpClient.GetStream().EndRead(ar);

                string strData = Encoding.Default.GetString(client.readBuffer, 0, byteLength);

                client.tcpClient.GetStream().BeginRead(client.readBuffer, 0, client.readBuffer.Length, new AsyncCallback(DataReceived), client);

                if (string.IsNullOrEmpty(client.clientName))
                {
                    if (EventHandler != null)
                    {
                        if (CheckID(strData))
                        {
                            string userName = strData.Substring(3);
                            client.clientName = userName;
                            string accessLog = string.Format("[{0}] {1} Access Server", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), client.clientName);
                            EventHandler.Invoke(accessLog, StaticDefine.ADD_ACCESS_LOG);
                            return;
                        }
                    }

                }


                if (messageParsingAction != null)
                {
                    messageParsingAction.BeginInvoke(client.clientName, strData, null, null);
                }

            }
            catch (Exception e)
            {

            }
        }

        // 클라이언트는 최초 접속시 "%^&이름" 을 보내도록 구현되어있습니다.
        // '%^&' 기호가 왔다면 서버는 해당클라이언트에게 이름을 부여합니다.
        private bool CheckID(string ID)
        {
            if (ID.Contains("%^&"))
                return true;

            return false;
        }
    }
}

 

ClientDic이 추가되었습니다.

 

서버에서는 클라이언트의 IP의 네번째 옥텟을 Key로 하여 TcpClient의 객체를 해당 딕셔너리에 저장해놓습니다.

 

그리고 클라이언트는 접속할때 $%^기호와 함께 이름을 보내도록 구현해놓았습니다.

 

추후 클라이언트가 상대방을 지정할때 상대방의 이름을 입력하면 서버에서 이름에 맞는 클라이언트의 키를 반환하고

 

해당키를 통해 TcpCLient의 객체에 접근하여 Write하는 방식입니다.  

 

 

4. ClientData.cs

namespace ChattingServiceConsoleServer
{
    class ClientData
    {
        public TcpClient tcpClient { get; set; }
        public Byte[] readBuffer { get; set; }
        public StringBuilder currentMsg { get; set; }
        public string clientName { get; set; }
        public int clientNumber { get; set; }

        public ClientData(TcpClient tcpClient)
        {
            currentMsg = new StringBuilder();
            readBuffer = new byte[1024];

            this.tcpClient = tcpClient;

            char[] splitDivision = new char[2];
            splitDivision[0] = '.';
            splitDivision[1] = ':';

            string[] temp = null;

            temp = tcpClient.Client.LocalEndPoint.ToString().Split(splitDivision);

            this.clientNumber = int.Parse(temp[3]);
        }
    }
}

 

역시 지난번 게시글과 비슷합니다.

 

다른부분은 clientName이라는 멤버변수가 추가되었습니다.

 

서버는 클라이언트가 접속할때마다 이름과 번호를 부여하고

 

클라이언트가 상대방의 이름과 메시지를 보내면

 

해당 이름에 맞는 클라이언트번호를 키로 딕셔너리에 접근하는 방식입니다.

 

5. StaticDefine.cs

namespace ChattingServiceConsoleServer
{
    // 가독성을 위해 정적멤버를 만들어놨습니다.
    class StaticDefine
    {
        public const int SHOW_CURRENT_CLIENT = 1;
        public const int SHOW_ACCESS_LOG = 2;
        public const int SHOW_CHATTING_LOG = 3;
        public const int ADD_ACCESS_LOG = 5;
        public const int ADD_CHATTING_LOG = 6;
        public const int EXIT = 0;
    }
}

switch문의 가독성을 위해 작성하였습니다.

6. 실행결과

(다음게시글(6번째 게시글)의 클라이언트를 사용하였습니다.

 

클라이언트는 접속할때 이름을 입력합니다.

 

서버는 접속기록을 확인할수 있습니다.

 

클라이언트가 메시지보내기를할때 서버는 현재접속인원을 클라이언트에게 보내줍니다.

 

오른쪽(이성계)클라이언트가 왼쪽(을지문덕)클라이언트에게 메시지를 보낸 화면입니다.

 

채팅로그입니다.

 

클라이언트도 받은 메시지와 보낸 메시지를 확인할 수 있습니다.

 

왼쪽(을지문덕)클라이언트를 종료시켰습니다. 하트비트스레드로 서버에서 바로 확인이 가능합니다.

 

반응형

댓글