.NET 에서는 아주 다양한 구조체, 클래스, 인터페이스 등을 제공하고 있습니다.

그 양은 정말 다 쓰기가 힘들 정도로 방대하지요.


이 많고 많은 클래스들 중에서도 네트워크 통신을 담당하는 녀석이 있습니다.

네트워크와 관련된 클래스나 열거형 등은 모두 System.Net 네임스페이스에 정의가 되어 있는데요.

이번 글에선 소켓을 이용한 간단한 1:1 비동기 채팅 프로그램을 만들어볼까 합니다.


Tip!

비동기(Asynchronous)란, 동기화를 하지 않는다는 뜻으로! 자료를 전송이나 수신, 연결 수락 등의 작업들이 완료되기를 기다리는 것이 아니라 개별적인 쓰레드를 생성하여 그 쓰레드 내에서 처리하게끔 만들어놓은 것입니다.

즉, 사용자로 하여금 동시에 여러 가지 작업을 할 수 있도록 작업의 동기화를 하지 않는 것입니다.


비동기 소켓 모델은 이렇게 표현할 수 있습니다.


Server

Client

 [1] Bind - Listen - BeginAccept 순서대로 호출

 [2] Connect/BeginConnect 이용하여 연결 시도

 [3] 연결 요청이 있다면, EndAccept 로 연결 수락을 한 후 BeginReceive 로 자료 수신 대기

 [4] 연결에 성공했다면, BeginReceive 로 자료 수신 대기, Send/BeginSend 이용하여 자료를  전송

 [5] 수신한 자료가 있을 경우, EndReceive 로 자료를 수신하고 다시 BeginReceive 호출하여 자료 수신 대기


앞의 표기된 순번(1, 2, 3, 4, 5) 대로 진행됩니다.

글로 표시해서 알아보기가 힘들 수도 있지만, 막상 해보면 별거 아닙니다. 정말 쉽기 때문입니다.

그리고 설명은 서버쪽(1, 3, 5)을 다 설명하고 클라이언트 쪽(2, 4, 5)으로 진행하도록 하겠습니다.




소켓 통신을 하기 위해선 System.Net.Sockets 네임스페이스를 임포트 해주어야 합니다.

그리고, 같이 사용되는 EndPoint, IPAddress 등의 클래스를 사용하기 위해서 System.Net 네임스페이스 역시 임포트를 해주어야 합니다.


그럼 서버쪽부터 차근차근 설명해보도록 하겠습니다.

서버의 경우, 클라이언트가 접속 요청을 할 수 있도록 통신에 사용할 특정 포트를 바인딩한 후 들어올 연결 요청을 대기한 상태로 기다려야 합니다.


Tip!

포트(Port)란, 네트워크 통신에 사용되는 통로라고 보시면 됩니다. 일상 생활에 빗대어 표현하자면 집 주소는 IP 주소가 되는 것이고 번지 수와 동/호 등을 포트로 비유할 수 있겠습니다.


요청을 대기하는 것은 어렵지 않습니다.

Bind, Listen 이 두 개의 메서드를 성공적으로 호출한 후에 BeginAccept 메서드를 사용하여 비동기적으로 들어오는 연결 요청을 처리하게 하면 됩니다.


Bind 메서드 (Socket.Bind 메서드 문서, MSDN)

Bind 메서드는 1개의 EndPoint 형 매개 변수를 필요로 합니다.

이 매개 변수는 어느 주소에서 연결 요청을 대기할 것인지를 나타냅니다.

EndPoint란 단어를 풀어서 해석한 그대로 '끝 점' 이라는 의미를 가지고 있습니다.

(더 자세한 설명은 저도 잘 몰라서.. 위X백과사전한테 그 임무를 넘겨주겠습니다 ㅠㅠ)


제가 이해하고 있는 EndPoint란 네트워크 상의 주소를 나타내는 것입니다.

IP 주소 및 포트 번호를 저장하고 있기 때문입니다. 또한 이 EndPoint 클래스는 원격 호스트에 연결할 때에도 사용되게 됩니다.

이만, EndPoint에 대한 제 개인적인 생각은 그만 말하고 본론으로 돌아가겠습니다.


Bind 메서드에 넘겨줄 EndPoint 클래스는 정말로 단순합니다.

Socket.Bind(new IPEndPoint(IPAddress.Any, /* 사용할 포트 번호 */ 1000));


IPEndPoint 클래스의 생성자에 IPAddress.Any 와 사용할 포트 번호를 넘겨주면 끝입니다.

IPAddress 클래스에 정의된 정적 필드에 대한 설명 및 목록은 [여기] 를 참고하시면 되겠습니다.


Bind 메서드를 성공적으로 호출하였다면, 이제 Listen 메서드를 호출할 차례입니다.

Listen 메서드는 Bind 메서드보다 더 .. 더 ! 쉽습니다.

Listen 메서드 (Socket.Listen 메서드 문서, MSDN)

Listen 메서드는 Bind 메서드와 마찬가지로 1개의 매개 변수를 필요로 하지만, 매개 변수의 형식이 int 형입니다.

이 매개 변수는 클라이언트의 연결 요청을 동시에 몇 개 까지 처리할 것인가를 나타냅니다.

(5 로 입력한 경우, 최대 5개의 동시 요청을 처리할 수 있게 됩니다)


Listen 메서드에 넘겨줄 매개 변수는 이렇습니다.

Socket.Listen(/* 백로그 큐 갯수 */ 5));


Listen 메서드 또한 성공적으로 호출되었다면, 이제 BeginAccept 메서드를 호출할 차례입니다.

여기부터는 과정이 약간 복잡해지니 잘 따라오셔야 합니다.

일단, BeginAccept 메서드에 대한 설명이 있는 [여기] 를 참고하시기 바랍니다.

(귀찮으시더라도 이 메서드에 대한 설명은 반드시 숙지하셔야 하기에, 링크로 이동하여 꼭 설명을 볼 것을 권장합니다)


BeginAccept 메서드는 2개의 매개 변수를 필요로 합니다.

Socket.BeginAccept(AsyncCallback, Object);


첫번째 매개 변수는 AsyncCallback 형식으로 대리자라고 칭해지는데, 함수 포인터로 보시면 되겠습니다.

두번째 매개 변수는 Object 형식으로, 비동기 작업에 대한 추가 정보를 넘겨주는 것입니다.


AsyncCallback 형식은 이렇게 정의가 되어 있습니다.

delegate void AsyncCallback(IAsyncResult ar);


IAsyncResult 인터페이스는 BeginAccept 등의 비동기 메서드에서 넘겨준 추가 정보 및 작업에 대한 정보를 저장하는 인터페이스로써, 비동기 작업을 할 때 아주 유용하게 사용되니 꼭! 숙지하시기 바랍니다.


모든 대리자는 생성자를 호출할 때 대리자와 호환되는 매개 변수 및 반환 형식을 가진 메서드를 필요로 합니다.

대리자는 다시 말하면 메서드 호출을 대신 해주는 사람으로도 표현할 수가 있겠네요.

그럼 어떻게 대리자의 새 개체를 초기화하는지 한번 보시겠습니다.

delegate void EmptyDelegate();
private EmptyDelegate ed = new EmptyDelegate(dummyProc);
private void dummyProc() { } // 성공
private Int32 dummyProc() { } // 오류, 반환 형식이 맞지 않음
private void dummyProc(Int32 dummy) { } // 오류, 매개 변수가 맞지 않음

대리자는 그냥 메서드 그 자체입니다. 단지, 메서드를 캡슐화할 수 있다는 것이 다릅니다.

그래서 메서드처럼 반환 형식이나 매개 변수를 프로그래머 입맛에 맞게끔 설정할 수 있습니다.



그럼 이제.. BeginAccept를 어떻게 호출하는지 한번 볼까요?

일단 어떤 대리자를 요구하는 매개 변수던 2가지 방법을 사용할 수가 있습니다.

  1. 대리자를 변수로 만들어서 변수를 넘겨주는 방법
  2. 즉시 대리자의 새 개체를 만들고 만들어진 개체를 넘겨주는 방법

/* 1. 대리자 변수를 하나 만들고 대리자 변수를 넘겨주는 방법 */
private AsyncCallback fnAsyncAccept = new AsyncCallback(asyncAcceptProc);
Socket.BeginAccept(fnAsyncAccept, /* 추가 정보 */ null);

/* 2. 즉시 대리자의 새 개체를 만들고 만들어진 개체를 넘겨주는 방법 */
Socket.BeginAccept(new AsyncCallback(asyncAcceptProc), /* 추가 정보*/ null);

// 대리자가 사용하는 메서드
private void asyncAcceptProc(IAsyncResult ar) {
}



첫 번째 방법의 경우, 변수를 선언해야 하기 때문에 코드가 길어지면 다소 복잡하게 보일 수도 있습니다만,

첫 번째 방법으로 하는 것이 두 번째 방법보다 안전합니다.


두 번째 방법의 경우, 다소 단순해서 코드가 길어져도 그냥 그러려니 합니다만,

그래도 첫 번째 방법보다는 불안정한게 현실입니다.


?

여기서 왜 제가 안정성을 논하느냐.. 저는 C#, VB.NET 이 두 개를 이용해서 웹 서버를 만들어본 적이 있는데 성능을 시험하던 도중 예외가 발생했고(어떤 예외가 발생했는지는 자세히 기억이 나지가 않습니다 ㅠㅠ) 그 예외에 대한 해결 방법은 1번 방법처럼 대리자 변수를 선언하고, 그 선언한 대리자 변수를 넘겨줌으로써 해결 가능한 것이였기 때문입니다.


대게 일반적으로 클라이언트의 연결을 1회만 허용할 것이 아니라면 BeginAccept 메서드를 호출할 때 추가 정보엔 서버 소켓을 전달해주어야 합니다. 서버 소켓을 전달해 줌으로써, 들어온 클라이언트의 연결을 수락하고 또 다른 클라이언트의 연결 요청을 수락할 수 있기 때문입니다. 하지만 여기선 매우 간단한 통신 프로그램을 만들 것이기에, 추가 정보는 전달하지 않겠습니다.



그럼 1번 단계를 코드로 짜보겠습니다.

private Socket m_ServerSocket = null;
private AsyncCallback m_fnAcceptHandle = new AsyncCallback(handleClientConnectionRequest);

public void startServer() {
    // TCP 통신을 위한 소켓을 생성합니다.
    m_ServerSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.IP);

    // 특정 포트에서 모든 주소로부터 들어오는 연결을 받기 위해 포트를 바인딩합니다.
    // 사용한 포트: 1234
    m_ServerSocket.Bind(new IPEndPoint(IPAddress.Any, 1234));

    // 연결 요청을 받기 시작합니다.
    m_ServerSocket.Listen(5);

    // BeginAccept 메서드를 이용해 들어오는 연결 요청을 비동기적으로 처리합니다.
    // 연결 요청을 처리하는 함수는 handleClientConnectionRequest 입니다.
    m_ServerSocket.BeginAccept(m_fnAcceptHandler, null);
}

private void handleClientConnectionRequest(IAsyncResult ar) {
}


이제~ 기본 골격이 만들어 졌습니다.




그럼 2단계로 넘어가서 EndAccept 로 연결 요청을 수락하고, BeginReceive 메서드를 이용해서 자료를 수신받는 것을 만들 차례입니다.

우선, EndAccept 메서드 설명부터 하도록 하겠습니다.


EndAccept 메서드 (Socket.EndAccept 메서드 문서, MSDN)

이 메서드는 한 개의 매개 변수를 필요로 하고 있습니다. 매개 변수의 형식은 IAsyncResult 형식인데요..

어디서 본듯한 형식이 아닌가요? 네 맞습니다. 대리자에 사용될 함수의 매개 변수도 IAsyncResult 로 되어 있었지요~!!

이 메서드도 호출하는 방법은 매우 간단합니다. 그냥 집어넣으면 됩니다. 그러면 쨘~!!! 소켓이 만들어집니다.

private void someAsyncAcceptHandlerProc(IAsyncResult ar) {
    Socket sockClient = sockServer.EndAccept(ar);
}

보시면 아시겠지만, sockServer 은 서버의 소켓, sockClient 는 클라이언트의 소켓을 의미합니다.


그럼, EndAccept는 이정도로 설명해도 이전에 설명한 것이 있기 때문에 이해하셨으리라 믿고 BeginReceive 메서드를 설명하도록 하겠습니다. 이 메서드를 사용할땐 이제 자료형을 하나 만들어서 사용할 것입니다~! 잘 보시고 따라오세요!!


BeginReceive 메서드 (Socket.BeginReceive 메서드 문서, MSDN)

이 메서드는 무려 여섯 개의 매개 변수를 필요로 합니다. 하지만, 겁먹으실 필요 없습니다. 하나 하나 살펴보도록 하겠습니다.

byte[] buffer - 자료를 수신받을 바이트 배열을 입력하면 됩니다.

int offset - 바이트 배열의 몇 번째 요소부터 자료를 수신할 것인지를 입력하면 됩니다. 주로 을 사용합니다.

int size - 수신받을 자료의 크기를 입력하면 됩니다. 주로 배열의 크기를 사용합니다.

SocketFlags socketFlags - 소켓 옵션을 입력하면 됩니다. 주로 SocketFlags.None 을 사용합니다.

AsyncCallback callback - 자료를 수신하면 호출되는 비동기 대리자입니다.

Object state - 추가로 넘겨줄 정보를 입력하면 됩니다.


그럼 이제, 사용할 자료형에 대해서 설명을 해보도록 하겠습니다.

Begin 으로 시작하는 비동기 메서드를 보시면 아시겠지만, 모두 마지막 매개 변수는 Object 형으로 추가 정보를 받고 있습니다.

하지만, 일반적인 자료를 사용하면 한 가지 정보밖에 넘길 수가 없기 때문에 여기서 자료형 하나를 정의하고 정의한 자료형에 값을 할당함으로써 여러 가지 정보를 넘겨줄 것입니다.


그럼, 사용할 자료형을 정의해보도록 하겠습니다.

public class AsyncObject {
    public Byte[] Buffer;
    public Socket WorkingSocket;
    public AsyncObject(Int32 bufferSize) {
        this.Buffer = new Byte[bufferSize];
    }
}


생성자로 하나의 매개 변수를 필요로 하고 있습니다. 바로 바이트 배열의 크기를 결정해주는 것입니다.


이제 자료형도 정의했겠다, BeginReceive 메서드를 어떻게 호출하는지 보도록 하겠습니다.

AsyncObject ao = new AsyncObject(4096);
Socket.BeginReceive(ao.Buffer, 0, ao.Buffer.Length, SocketFlags.None, fnReceiveHandler, ao);


이렇게 하면 연결된 클라이언트가 서버로 자료를 전송한 경우 fnReceiveHandler 대리자를 만들 때 넘겨준 메서드가 호출되고, ao.Buffer 에 수신한 자료가, fnReceiveHandler 대리자 메서드에서 넘겨준 ao 변수를 받을 수 있게 됩니다.

이제 보니깐 BeginAccept 메서드하고 크게 차이는 없습니다. 추가 정보를 넘겨주고 자료를 받는 것 외엔 비슷비슷 하니까요!


그럼 설명은 이제 입아프죠. 바로 2번 단계를 코드로 짜보도록 하겠습니다.

/* 1번 단계 코드는 생략하였습니다. */
public class AsyncObject {
    public Byte[] Buffer;
    public Socket WorkingSocket;
    public AsyncObject(Int32 bufferSize) {
        this.Buffer = new Byte[bufferSize];
    }
}

private AsyncCallback m_fnReceiveHandler = new AsyncCallback(handleDataReceive);
private void handleClientConnectionRequest(IAsyncResult ar) {
    // 클라이언트의 연결 요청을 수락합니다.
    Socket sockClient = m_ServerSocket.EndAccept(ar);

    // 4096 바이트의 크기를 갖는 바이트 배열을 가진 AsyncObject 클래스 생성
    AsyncObject ao = new AsyncObject(4096);

    // 작업 중인 소켓을 저장하기 위해 sockClient 할당
    ao.WorkingSocket = sockClient;

    // 비동기적으로 들어오는 자료를 수신하기 위해 BeginReceive 메서드 사용!
    sockClient.BeginReceive(ao.Buffer, 0, ao.Buffer.Length, SocketFlags.None, m_fnReceiveHandler, ao);
}
private void handleDataReceive(IAsyncResult ar) {
}


이제 어느정도 끝이 보이려고 합니다. (ㅋㅋㅋ)


그럼 이제 마지막으로 서버/클라이언트 공통적인 수신할 자료가 있을 때 EndReceive 메서드를 이용해 자료를 수신하고,

다시 비동기적으로 들어오는 데이터를 수신받는 작업을 해보도록 하겠습니다.

여기선 뭐 설명할게 없네요. 그래도 EndReceive 메서드를 새로 사용하니 설명하도록 하겠습니다.

EndReceive 메서드 (Socket.EndReceive 메서드 문서, MSDN)

이 메서드는 한 개의 매개 변수를 필요로 합니다. EndAccept 와 똑같습니다.

다만 반환 값이 Socket이 아닌 int 형으로 수신받은 자료의 바이트 수를 반환합니다.

이게 끝입니다 . 설명할게 없군요 ㅋㅋ;



그럼 이제 설명은 그만하고 마지막 단계를 코드로 짜보도록 하겠습니다.

private void handleDataReceive(IAsyncResult ar) {

    // 넘겨진 추가 정보를 가져옵니다.
    // AsyncState 속성의 자료형은 Object 형식이기 때문에 형 변환이 필요합니다~!
    AsyncObject ao = (AsyncObject) ar.AsyncState;

    // 자료를 수신하고, 수신받은 바이트를 가져옵니다.
    Int32 recvBytes = ao.WorkingSocket.EndReceive(ar);

    // 수신받은 자료의 크기가 1 이상일 때에만 자료 처리
    if ( recvBytes > 0 ) {
        /*
            여기에 자료를 처리하는 작업을 하시면 됩니다.
        */
    }

    // 자료 처리가 끝났으면~
    // 이제 다시 데이터를 수신받기 위해서 수신 대기를 해야 합니다.
    // Begin~~ 메서드를 이용해 비동기적으로 작업을 대기했다면
    // 반드시 대리자 함수에서 End~~ 메서드를 이용해 비동기 작업이 끝났다고 알려줘야 합니다!
    ao.WorkingSocket.BeginReceive(ao.Buffer, 0, ao.Buffer.Length, SocketFlags.None, m_fnReceiveHandler, ao);
}


BeginReceive 메서드에 넘겨준 대리자 함수의 본문에서 비동기 수신 작업을 처리하고, 다시 수신을 비동기적으로 시작하는 코드가 완성되었습니다.


이번 장은 서버의 전체 코드를 작성함으로써 마무리를 짓고, 다음 장엔 클라이언트 쪽을 설명하도록 하겠습니다.

using System;
using System.Text;
using System.Net;
using System.Net.Sockets;

namespace simpleChat {
    public class chatServer {
        public class AsyncObject {
            public Byte[] Buffer;
            public Socket WorkingSocket;
            public AsyncObject(Int32 bufferSize) {
                this.Buffer = new Byte[bufferSize];
            }
        }
        
        private Socket m_ServerSocket = null;
        private AsyncCallback m_fnReceiveHandler;
        private AsyncCallback m_fnSendHandler;
        private AsyncCallback m_fnAcceptHandler;
        
        public void StartServer() {
            // 비동기 작업에 사용될 대리자를 초기화합니다.
            m_fnReceiveHandler = new AsyncCallback(handleDataReceive);
            m_fnSendHandler = new AsyncCallback(handleDataSend);
            m_fnAcceptHandler = new AsyncCallback(handleClientConnectionRequest);
            
            // TCP 통신을 위한 소켓을 생성합니다.
            m_ServerSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.IP);
        
            // 특정 포트에서 모든 주소로부터 들어오는 연결을 받기 위해 포트를 바인딩합니다.
            // 사용한 포트: 1234
            m_ServerSocket.Bind(new IPEndPoint(IPAddress.Any, 1234));
        
            // 연결 요청을 받기 시작합니다.
            m_ServerSocket.Listen(5);
        
            // BeginAccept 메서드를 이용해 들어오는 연결 요청을 비동기적으로 처리합니다.
            // 연결 요청을 처리하는 함수는 handleClientConnectionRequest 입니다.
            m_ServerSocket.BeginAccept(m_fnAcceptHandler, null);
        }
        
        public void StopServer() {
            // 가차없이 서버 소켓을 닫습니다.
            m_ServerSocket.Close();
        }
        
        public void SendMessage(String message) {
            // 추가 정보를 넘기기 위한 변수 선언
            // 크기를 설정하는게 의미가 없습니다.
            // 왜냐하면 바로 밑의 코드에서 문자열을 유니코드 형으로 변환한 바이트 배열을 반환하기 때문에
            // 최소한의 크기르 배열을 초기화합니다.
            AsyncObject ao = new AsyncObject(1);
        
            // 문자열을 바이트 배열으로 변환
            ao.Buffer = Encoding.Unicode.GetBytes(message);
        
            // 사용된 소켓을 저장
            ao.WorkingSocket = m_ServerSocket;
        
            // 전송 시작!
            m_ServerSocket.BeginSend(ao.Buffer, 0, ao.Buffer.Length, SocketFlags.None, m_fnSendHandler, ao);
        }
        
        
        
        private void handleClientConnectionRequest(IAsyncResult ar) {
            // 클라이언트의 연결 요청을 수락합니다.
            Socket sockClient = m_ServerSocket.EndAccept(ar);
        
            // 4096 바이트의 크기를 갖는 바이트 배열을 가진 AsyncObject 클래스 생성
            AsyncObject ao = new AsyncObject(4096);
        
            // 작업 중인 소켓을 저장하기 위해 sockClient 할당
            ao.WorkingSocket = sockClient;
        
            // 비동기적으로 들어오는 자료를 수신하기 위해 BeginReceive 메서드 사용!
            sockClient.BeginReceive(ao.Buffer, 0, ao.Buffer.Length, SocketFlags.None, m_fnReceiveHandler, ao);
        }
        private void handleDataReceive(IAsyncResult ar) {
        
            // 넘겨진 추가 정보를 가져옵니다.
            // AsyncState 속성의 자료형은 Object 형식이기 때문에 형 변환이 필요합니다~!
            AsyncObject ao = (AsyncObject) ar.AsyncState;
        
            // 자료를 수신하고, 수신받은 바이트를 가져옵니다.
            Int32 recvBytes = ao.WorkingSocket.EndReceive(ar);
        
            // 수신받은 자료의 크기가 1 이상일 때에만 자료 처리
            if ( recvBytes > 0 )
                Console.WriteLine("메세지 받음: {0}", Encoding.Unicode.GetString(ao.Buffer));
        
            // 자료 처리가 끝났으면~
            // 이제 다시 데이터를 수신받기 위해서 수신 대기를 해야 합니다.
            // Begin~~ 메서드를 이용해 비동기적으로 작업을 대기했다면
            // 반드시 대리자 함수에서 End~~ 메서드를 이용해 비동기 작업이 끝났다고 알려줘야 합니다!
            ao.WorkingSocket.BeginReceive(ao.Buffer, 0, ao.Buffer.Length, SocketFlags.None, m_fnReceiveHandler, ao);
        }
        private void handleDataSend(IAsyncResult ar) {
        
            // 넘겨진 추가 정보를 가져옵니다.
            AsyncObject ao = (AsyncObject) ar.AsyncState;
        
            // 자료를 전송하고, 전송한 바이트를 가져옵니다.
            Int32 sentBytes = ao.WorkingSocket.EndSend(ar);
        
            if ( sentBytes > 0 )
                Console.WriteLine("메세지 보냄: {0}", Encoding.Unicode.GetString(ao.Buffer));
        }
    }
}


긴 글 읽어주시느라 수고하셨습니다 ^^;

정말 '간단한' 채팅 프로그램이기에, 소켓 상태를 확인하는 부분도 없고.. 채팅만 가능합니다.

이 글을 읽으시는 개발자분들이 응용해 보시는건 어떨까요?


다음 장엔 클라이언트 편으로 만나길 고대합니다! 감사합니다!




+ Recent posts