안녕하세요!


이번 글에서는 이번 주제의 마지막 장인 1:N 비동기 채팅 프로그램 - 클라이언트 부분을 작성하도록 하겠습니다.

이전 글에서 윈폼 디자인이나 서버쪽을 만들면서 거의 대부분의 작업을 설명드렸기 때문에 이번 글의 내용은 상당히 짧습니다.


이전 글:


시작합니다!!


클라이언트에서 서버와 연결하고 통신하기 위해 해야 할 역할들은 다음과 같습니다.

  • 서버의 주소와 포트를 이용하여 연결을 시도해야 한다.
  • 비동기적으로 들어오는 데이터(텍스트)를 텍스트박스에 표시해야 한다.
  • 서버가 데이터를 보내지 않았어도 클라이언트가 원할 때 데이터를 보낼 수 있어야 한다.



서버의 주소와 포트를 이용하여 연결을 시도해야 한다.

서버 또는 특정 소켓에 연결하기 위해서는 정말로 간단하게 Socket.Connect 메서드를 호출하시면 됩니다.

원래는 DNS 형태 또는 점-10진 표기법으로 표기된 주소는 컴퓨터가 이해하지 못하기 때문에 변환 과정을 거쳐야 하는데, .NET의 소켓에서는 문자열을 넘겨주면 변환 과정을 내부에서 처리해주기 때문에 정말 간단하게 연결 작업을 수행할 수 있습니다.


소켓을 이용한 연결 (C#)
string address = "localhost"; // "127.0.0.1" 도 가능
int port = 12345;
mainSock.Connect(address, port);




비동기적으로 들어오는 데이터(텍스트)를 텍스트박스에 표시해야 한다.

이 주제는 서버를 다루는 글에서도 소개된 내용입니다.

Socket.BeginReceive 함수를 사용하여 비동기적으로 들어오는 데이터를 받을 수 있으며, 이전 글에서 소개된 AsyncObject 클래스가 추가 데이터로 사용됩니다.


비동기적으로 들어오는 데이터를 받기 위한 준비 (C#)
AsyncObject ao = new AsyncObject(4096);
ao.WorkingSocket = mainSock;
mainSock.BeginReceive(ao.Buffer, 0, ao.BufferSize, 0, DataReceived, ao);



그리고 받은 데이터를 표시해주는 작업을 해줘야 합니다.

이 작업은 서버와 동일하게 Cross-Thread 문제가 있기 때문에 아래 글을 꼭 읽어보시기 바랍니다.

(링크) '크로스 스레드 작업이 잘못되었습니다' - 넌 왜 나타나서 날 괴롭게 하니..


UI Thread에서의 컨트롤 작업 & 비동기로 수신한 데이터 처리 (C#)
delegate void AppendTextDelegate(Control ctrl, string s);
AppendTextDelegate _textAppender;

void AppendText(Control ctrl, string s) {
    // 텍스트를 추가해주는 대리자가 null이면 개체를 생성한다.
    if (_textAppender == null) _textAppender = new AppendTextDelegate(AppendText);
    
    // 컨트롤이 생성된 UI Thread가 아니라면 InvokeRequired 속성의 값이 true로 설정된다.
    // 따라서, Invoke를 통한 대리자 호출로 AppendText 메서드가 UI Thread에서 실행되도록 해줘야 한다.
    if (ctrl.InvokeRequired) ctrl.Invoke(_textAppender, ctrl, s);
    else {
        string source = ctrl.Text;
        ctrl.Text = source + Environment.NewLine + s;
    }
}

static void DataReceived(IAsyncResult ar) {
    // BeginReceive에서 추가적으로 넘어온 데이터를 AsyncObject 형식으로 변환한다.
    AsyncObject obj = (AsyncObject) ar.AsyncState;

    // 데이터 수신을 끝낸다.
    int received = obj.WorkingSocket.EndReceive(ar);

    // 받은 데이터가 없으면(연결끊어짐) 끝낸다.
    if (received <= 0) {
        obj.WorkingSocket.Close();
        return;
    }

    // UTF8 인코더를 사용하여 바이트 배열을 문자열로 변환한다.
    string text = Encoding.UTF8.GetString(obj.Buffer);

    // 0x01 기준으로 짜른다.
    // tokens[0] - 보낸 사람 IP
    // tokens[1] - 보낸 메세지
    string[] tokens = text.Split('\x01');
    string ip = tokens[0];
    string msg = tokens[1];

    // 텍스트박스에 추가해준다.
    // 비동기식으로 작업하기 때문에 폼의 UI 스레드에서 작업을 해줘야 한다.
    // 따라서 대리자를 통해 처리한다.
    AppendText(txtHistory, string.Format("[받음]{0}: {1}", ip, msg));
    
    // 클라이언트에선 데이터를 전달해줄 필요가 없으므로 바로 수신 대기한다.
    // 데이터를 받은 후엔 다시 버퍼를 비워주고 같은 방법으로 수신을 대기한다.
    obj.ClearBuffer();

    // 수신 대기
    obj.WorkingSocket.BeginReceive(obj.Buffer, 0, 4096, 0, DataReceived, obj);
}




서버가 데이터를 보내지 않았어도 클라이언트가 원할 때 데이터를 보낼 수 있어야 한다.

복잡하게 보일 수도 있지만, 정말 간단합니다.

앞에서 BeginReceive를 통해서 비동기식으로 데이터를 받도록 해줬기 때문에 데이터가 오기만을 무한정 대기할 필요가 없어진 것이죠.


그래서 이 주제에선 간단하게 텍스트를 바이트로 변환한 후 소켓으로 보내주기만 하면 끝입니다.


데이터 보내기 (C#)
void OnSendData(object sender, EventArgs e) {
    // 서버가 대기중인지 확인한다.
    if (!mainSock.IsBound) {
        MsgBoxHelper.Warn("서버가 실행되고 있지 않습니다!");
        return;
    }

    // 보낼 텍스트
    string tts = txtTTS.Text.Trim();
    if (string.IsNullOrEmpty(tts)) {
        MsgBoxHelper.Warn("텍스트가 입력되지 않았습니다!");
        txtTTS.Focus();
        return;
    }

    // 서버 ip 주소와 메세지를 담도록 만든다.
    IPEndPoint ip = (IPEndPoint) mainSock.LocalEndPoint;
    string addr = ip.Address.ToString();

    // 문자열을 utf8 형식의 바이트로 변환한다.
    byte[] bDts = Encoding.UTF8.GetBytes(addr + '\x01' + tts);

    // 서버에 전송한다.
    mainSock.Send(bDts);

    // 전송 완료 후 텍스트박스에 추가하고, 원래의 내용은 지운다.
    AppendText(txtHistory, string.Format("[보냄]{0}: {1}", addr, tts));
    txtTTS.Clear();
}



여기까지가 클라이언트의 전부입니다.

서버에서 거의 모든 내용과 개념을 설명하여 코드 제외하고 내용이 별로 없지만, 소켓 통신이란 것이 원래 이렇게 간단합니다.

내부적인 메커니즘까지 고려하면 정말 복잡하지만, .NET Framework에서 기본적으로 제공하는 소켓 클래스를 사용하면 단 몇 줄만에 서버-클라이언트 또는 Peer-to-Peer 통신이 가능해지죠.


그리고 대망의 최종 결과물입니다.

2개의 클라이언트와 한 개의 서버를 놓고 서로 채팅을 테스트하는 화면입니다.




마지막으로 클라이언트와 서버가 포함된 솔루션 파일을 올렸으니, 다운로드 받으셔서 직접 실행해보시고 확인해보시기 바랍니다 :)

여기까지 긴 글 읽어주시느라 고생하셨습니다.


MultiChat.zip


감사합니다.


p.s. 부족한 글이지만, 흥미를 갖고 읽어주시는 불꽃중년님께 감사드리고, 늦게 마무리해서 죄송합니다.

  1. 2017.07.14 15:32

    잘봤습니다 정말 도움이 많이 되었습니다.
    근데 제가 돌리니 이렇게 뜨는데 이건 어찌 해결해야할까요?
    System.IndexOutOfRangeException: '인덱스가 배열 범위를 벗어났습니다.'

    • Favicon of https://slaner.tistory.com SlaneR2017.08.03 11:41 신고

      어느 부분에서 예외가 발생하는지 확인이 가능하신가요? 만약 확인이 가능하시다면 알려주세요. 확인하고 바로 수정하도록 하겠습니다. 감사합니다.

  2. 디에찌2017.07.26 10:50

    1:1 채팅프로그램부터 쭉 보고있습니다

    이거 실행하려면 무슨 프로그램을 사용해야하나요???
    APM을 이용해서 localhost를 사용하는건가요???

    또 1:1은 콘솔로 되어있어서 그런데 1:N을 1:1로 사용할수있게 할수있나요???

    • Favicon of https://slaner.tistory.com SlaneR2017.08.03 11:42 신고

      Visual Studio 또는 SharpDevelop
      등 C#을 개발하기 위해 필요한 IDE하고 .NET Framework만 설치된 환경이면 실행이 가능합니다. 별도의 서버 또는 DB(APM 등) 없이 C# 내에서 소켓을 열고 작업하는 것입니다.

    • Favicon of https://slaner.tistory.com SlaneR2017.08.03 11:42 신고

      1:N을 1:1로 사용하시기 위해선 클라이언트의 연결 요청을 수락한 후에 또 다른 클라이언트의 연결 요청을 수락하지 않고, 연결된 클라이언트하고만 통신하는 형태로 작성하시면 됩니다.

  3. Favicon of http://www.inven.co.kr/board/powerbbs.php?come_idx=3154&l=62895 채팅사이트2017.07.28 16:39

    오호~ 능력자이시다..

  4. 잘 모르겟다..2017.08.14 19:16

    실행하면 오류(연결에 실패했습니다! 오류내용: 대상 컴퓨터에서 연결을 거부했으므로 연결하지 못했습니다. 서버주소) 가 뜨는데 어떻게 해야될까요?

    • Favicon of https://slaner.tistory.com SlaneR2017.08.19 14:58 신고

      서버가 켜지지 않았거나, 잘못된 서버 주소(IP 주소 등)가 입력된 경우에 발생할 수 있습니다. 또는 포트 번호가 일치하지 않을 때에도요.

  5. Favicon of https://autoitcafe.tistory.com 쥬피터T2017.09.01 07:31 신고

    좋은 소스에 / 도움이 될만한 해설에 감사드립니다.

    MultiChatServer.exe
    MultiChatClient.exe 실행후 작동중...

    MultiChatClient.exe 가 하나라도 종료하면
    [MultiChatServer 의 작동이 중지되었습니다. 라고 뜨면서 프로그램 닫기가 뜨는군요.
    디버그를 누르면 ChatForm.cs 92Line 을 체크해줍니다.
    2015 / 2010 둘다 컴파일후 이런 증상이 발현됩니다.
    원인이 어디있는지 궁금해서 글 남겨봅니다.

  6. Favicon of https://autoitcafe.tistory.com 쥬피터T2017.09.01 07:35 신고

    예외정보를 보면
    System.Net.Sockets.SocketException이(가) 처리되지 않았습니다.
    ErrorCode=10054
    HResult=-2147467259
    Message=현재 연결은 원격 호스트에 의해 강제로 끊겼습니다
    NativeErrorCode=10054
    Source=System
    StackTrace:
    위치: System.Net.Sockets.Socket.EndReceive(IAsyncResult asyncResult)
    위치: MultiChatServer.ChatForm.DataReceived(IAsyncResult ar) 파일 E:\download\c#변환작업\MultiChat\MultiChatServer\ChatForm.cs:줄 92
    위치: System.Net.LazyAsyncResult.Complete(IntPtr userToken)
    위치: System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
    위치: System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
    위치: System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state)
    위치: System.Net.ContextAwareResult.Complete(IntPtr userToken)
    위치: System.Net.LazyAsyncResult.ProtectedInvokeCallback(Object result, IntPtr userToken)
    위치: System.Net.Sockets.BaseOverlappedAsyncResult.CompletionPortCallback(UInt32 errorCode, UInt32 numBytes, NativeOverlapped* nativeOverlapped)
    위치: System.Threading._IOCompletionCallback.PerformIOCompletionCallback(UInt32 errorCode, UInt32 numBytes, NativeOverlapped* pOVERLAP)
    InnerException:
    이렇게 나오는군요.

    • Favicon of https://slaner.tistory.com SlaneR2017.09.11 18:23 신고

      해당 문제는 데이터를 수신할 때 발생하는 문제입니다. 데이터 수신을 대기하는 도중에 연결이 끊어지게 되면 EndReceive 함수가 실행될 때 연결이 끊어진 상태이므로 Exception이 발생하게 됩니다. 이 경우엔 EndReceive 함수에 try ~ catch 를 사용하여 예외처리를 해주는 식으로 해결이 가능합니다.

  7. jkw2017.10.11 01:40

    아직 학생이시니.. 조언 드리자면..
    async await 패턴을 공부하고 적용해보세요.
    지금은 아무도 BeginReceive 같은 함수 쓰지 않아요.
    ReceiveAsync 함수를 쓴답니다.
    callback 은 구시대의 유물..

    • cj2017.11.23 17:57

      불가피하게 프레임웍4.0에서 개발해야하는 경우가 많이 생깁니다.. 그럼 그 좋은 async await 패턴도 쓰질 못하죠... 님은 학생보다 더 모르시는듯... 사이즈 보니까 답 나옵니다..

  8. 2018.02.06 18:17

    비밀댓글입니다

  9. 소망이2018.02.06 18:18

    클라이언트 카운를 접속/해제를 해보려하는데

    어느쪽에서 처리해야할까요?

    • Favicon of https://slaner.tistory.com SlaneR2018.04.07 18:17 신고

      서버쪽에서 처리하시면 됩니다.
      연결이 수락되었을 때 변수의 값을 1 증가시키고, 도중 오류가 난 경우 혹은 연결이 끊어졌을 때 1 감소시키면 됩니다.

  10. nba20182018.12.17 15:22

    안녕하세요. 글 잘 보고 있습니다.

    저기서 클라이언트를 여러 개 띄워놓고 통신하던 중 클라이언트 중 하나라도 종료하면
    서버도 같이 자동으로 종료되던데 어떻게 해결해야 할지 모르겠네요......

    괜찮으시면 답장 부탁드립니다!

    • Favicon of https://slaner.tistory.com SlaneR2018.12.17 20:42 신고

      여기(클라이언트)에서는 예외 처리가 되어있는 상태인데요, 서버 코드에서는 EndReceive / BeginReceive 등의 소켓 작업을 try ~ catch 로 예외처리 해주셔야 클라이언트와의 연결이 강제로 끊어졌을 때에도 서버가 종료되지 않습니다.

      답: 서버쪽 코드에서 소켓 작업을 수행하는 곳(특히 데이터 받았을 때나 보낼 때 try ~ catch 로 예외처리한 후 클라이언트 코드와 동일하게 WorkingSocket 만 Close해주시면 됩니다)

  11. JK2018.12.19 11:50

    안녕하세요
    C# 소켓 공부중에 해당 글을 보고 많은 도움 받았습니다.
    클라이언트에서 서버쪽으로 데이터를 보낸 후 특정 시간동안 데이터를 리턴 받지 못하면 에러를 내야하는 상황인데요.
    동기식 receive 함수 사용시에는 receivetimeout 변수 값을 이용하여 타임아웃을 설정할 수 있는거 같은데
    비동기식에서는 어떤식으로 사용해야하나요?

    • Favicon of https://slaner.tistory.com SlaneR2018.12.22 19:03 신고

      비동기 방식으로 작업하실 때 타임아웃을 사용해야 한다면 타이머 등을 사용하실 수 있는데요, 작업이 다소 복잡해질 수 있습니다.

      저는, BeginXXX 메서드를 사용하여 반환된 IAsyncResult 개체를 가지고 있다가 일정 시간이 지난 후 IAsyncResult.IsCompleted 속성 값을 확인하여 false 일 경우 EndXXX 메서드를 try ~ catch 로 감싸서 중단하는 방법을 사용합니다.

  12. 2019.06.03 11:32

    안녕하세요.
    여기에다가 파일/이미지를 보내는 것을 추가하려고 하는데 메시지 보내는부분과 유사하게 처리하면 될까요??

  13. 서지민2019.11.21 13:01

    아 정말 감사합니다. 업무에 많은 도움이 되었습니다.

  14. 주니어개발자2020.03.03 22:46

    혹시 tcpListener 랑 tcpclient 클래스를 사용안하신 이유가 따로있나요?

    • Favicon of https://slaner.tistory.com SlaneR2020.03.08 23:28 신고

      TcpListener, TcpClient 클래스도 결국 내부적으론 Socket 을 사용하게 되어 있습니다.

      물론, TcpListener/TcpClient 를 사용하는 방법이 코드도 간결하고 특정 상황에 대한 컨트롤이 더 쉬운 반면 소켓의 동작 원리 등을 이해하기 위하여 사용하기엔 거리가 있어 Socket 을 사용하도록 작성하였습니다.

  15. 2020.06.13 23:05

    비밀댓글입니다

    • 2020.06.17 01:56

      비밀댓글입니다

  16. AnonyStudent2020.07.13 23:13

    사랑합니다

  17. Favicon of http://wkdkfl815@naver.com owen2020.09.22 13:24

    프로그램 동작하는순서를 잘 모르겠는데 알려주실수있나요? 예를들면 서버의cs파일을 실행시키고 클라이언트를 두번실행시켜 두개의화면을 켜놓고 연결을하는건지 등..

+ Recent posts