안녕하세요!

이전 글에서는 실질적인 프로그램을 개발하기에 앞서, 개발 환경이나 사용 모델, 구성 및 디자인에 대해 소개드렸습니다.

이전 글: C#을 이용하여 간단한 1:N 비동기 채팅 프로그램을 만들어보자! - 윈폼 디자인편


이번 글에서는 소켓을 이용하여 서버 프로그램을 만들어 보도록 하겠습니다!


먼저, 서버가 해야 할 역할들은 다음과 같습니다.

  • 클라이언트의 연결 요청이 들어올 때마다 연결 요청을 수락해야 한다.
  • 수신한 데이터가 있다면, 데이터를 송신자를 제외한 클라이언트들에게 다시 전달해줘야 한다.
  • 클라이언트의 연결 요청을 관리해야 한다.


우선, 서버의 역할에 대해 깊게 들어가기 전에 소켓을 사용하기 위해선 소켓 개체를 생성해줘야 합니다.

이번 예제에서는 TCP 프로토콜을 이용하여 통신을 할 것이기 때문에 아래와 같이 소켓을 생성합니다.


TCP 소켓 초기화 (C#)
mainSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.IP);



하지만, 예상치 못한 오류로 인해 소켓 생성이 실패할 수 있으므로 try ~ catch 를 이용하여 예외 처리를 해줍니다.


TCP 소켓 초기화 및 예외 처리 (C#)
try {
    mainSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.IP);
} catch (Exception e) {
    // 발생한 예외 정보 e에 대한 예외 처리
}



위 작업을 마치면 TCP 프로토콜을 사용하는 소켓을 사용할 준비가 완료된 것입니다.

이제, 서버의 역할에 대해서 들어가 보도록 하죠!



클라이언트의 연결 요청이 들어올 때마다 연결 요청을 수락해야 한다.

서버는 클라이언트의 연결 요청이 언제 발생할 것인지 모르기 때문에 계속 연결 요청을 대기하는 상태로 있어야 합니다.

연결 요청을 대기하는 작업은 Socket.Listen 함수를 이용하여 할 수 있는데요! 소켓엔 포트 번호란 것이 존재하기 때문에 바로 대기를 하는 것이 아니라 사용할 포트 번호에 소켓을 바인딩해준 후에야 연결 요청에 대한 대기를 할 수가 있게 됩니다. 그렇다면 소켓

바인딩 작업은 어떤 함수를 이용해야 할까요? 바로 Socket.Bind 함수입니다.

소켓 바인딩을 한 후 Socket.Listen 함수를 이용하여 연결 요청에 대한 대기를 한다고 끝이 아닙니다. 연결 요청을 기다리고는 있지만 연결 요청을 수락하는 행동은 하지 않았기 때문이죠. 들어온 클라이언트의 연결 요청을 수락하는 함수가 바로 Socket.Accept, Socket.BeginAccept 함수입니다.

클라이언트의 연결 요청을 수락해주는 과정을 거친 후에야 비로소 클라이언트가 서버와 통신할 수 있게 연결됩니다.



아직 개념이 이해가 안가신다구요? 그렇다면 아래 비유를 한 번 읽어보시죠!


철수는 0호부터 65535호까지 총 65,536세대가 있는 아파트에 살고 있습니다. 철수가 생일을 맞이하여 친구들을 집에 초대했는데, 만약 철수의 집이 몇 호라는 것을 알려주지 않았다면 어떤 일이 일어날까요? 철수의 친구들이 철수의 집을 찾아오지 못하게 됩니다.

반대로 철수가 친구들을 초대할 때 "우리 집은 1024호야" 라고 알려줬다면 철수의 친구들은 철수의 집을 찾아가서 초인종을 누르고 철수가 문을 열어줄 때까지 기다립니다. 마침내 철수가 문을 열고 친구들을 맞이하고 집으로 들여보내주면 그 때부터 친구들과 철수가 생일 파티를 즐기게 됩니다.


여기서 철수의 친구들은 클라이언트, 철수의 집은 서버 그리고 철수가 살고 있는 집의 호 수는 포트 번호가 됩니다.

철수의 친구들이 초인종을 누르고 문을 열어줄 때까지 기다리는 것클라이언트가 서버에 연결 요청을 하고 연결 요청이 수락될 때까지 기다리는 것이고, 철수가 문을 열고 친구들을 집으로 들여보내주는 것클라이언트의 연결 요청을 수락하여 클라이언트와 서버가 통신할 수 있게 되는 것입니다.



소켓을 바인딩한다? 잘 이해가 안가요!


소켓을 바인딩한다는 것은 "이 집(포트 번호)을 임대하겠습니다" 와 같습니다.

만약, 집에 이미 사람이 살고 있다면 임대를 해줄 수 없지만 사람이 살고 있지 않다면 임대를 해줄 수 있는 것처럼 소켓을 바인딩하는 작업도 해당 포트 번호가 이미 사용 중이라면 바인딩에 실패하게 되고, 사용 중이지 않다면(=사용할 수 있다면) 바인딩에 성공하게 됩니다.


소켓의 바인딩 작업이 성공한 후에야 비로소 연결 요청에 대한 대기를 할 수가 있게 되는 것이죠.

따라서, 서버 소켓이 해야하는 작업의 순서는 다음과 같습니다.

  • Socket.Bind 함수를 이용해 사용할 포트 번호에 소켓을 바인딩한다.
  • Socket.Listen 함수를 이용해 클라이언트의 연결 요청을 대기한다.
  • Socket.AcceptSocket.BeginAccept 함수를 이용해 클라이언트의 연결 요청을 수락한다.


코드를 작성하려고 보니 누락된 설명이 있네요.

소켓을 바인딩할 때엔 포트 번호만 필요한 것이 아니라 어떤 주소를 사용할 것인지도 필요합니다.

이는 위 비유에서 철수가 막연하게 "나는 아파트에 살고 있어"라고 말하는 것과 "나는 AA아파트에 살고 있어"라고 말하는 것의 차이입니다.

다시말해, 집 호수만 말하는 것아파트 주소와 집 호수를 말하는 것의 차이입니다.



그럼 이제 정말로, 코드로는 위 작업을들 어떻게 나타내는지 보도록 하겠습니다!

먼저, 소켓을 바인딩하는 코드입니다.


서버에서 소켓을 바인딩하는 방법 (C#)
// IPEndPoint 클래스의 생성자엔 총 두 개의 매개변수가 필요합니다.
// 첫 번째는 IP 주소를 나타내는 IPAddress 클래스이고,
// 두 번째는 포트 번호를 나타내는 값입니다.
// IPAddress 클래스를 보면 정적 필드로 정의된 필드들이 있습니다.
// 그 중 자주 사용되는 몇 가지 필드에 대해서 설명드리도록 하겠습니다.
// IPAddress.Any - 사용 중인 '모든' 네트워크 인터페이스(랜카드에 할당된 IP 주소)를 나타냅니다.
// 일반 데스크탑의 경우엔 유선 랜카드 하나만 있어서 상관이 없지만, 노트북의 경우 유/무선 랜카드가 각각
// 하나씩 있는 경우엔 어떤 주소를 사용하여 바인딩 할 것인지 결정해야 합니다.
// IPAddress.Loopback - 127.0.0.1, 또는 localhost로 알려진 루프백 주소입니다.
// 이 주소는 자기 자신만 사용하고 연결할 수 있습니다.
IPEndPoint serverEP = new IPEndPoint(IPAddress.Any, port);

// Bind 함수의 매개변수는 EndPoint 클래스입니다.
// EndPoint는 네트워크 주소를 나타내기 위해 사용되는 클래스인데, 
// IP 주소 + 포트 번호를 나타낼 때는 IPEndPoint 클래스를 사용합니다.
mainSock.Bind(serverEP);



길어보이지만 90% 이상이 주석입니다. 따라서, 주석을 제거하면 실 코드는 단 두 줄입니다.

다음으로, 연결 요청을 대기하는 코드입니다.


클라이언트 연결 요청을 대기하는 방법 (C#)
// Listen 함수의 매개변수는 한 개로, 연결 요청을 보류할 큐(backlog)의 크기를 나타내는 값입니다.
// "연결 요청을 보류할 큐" 라는 말은 얼핏 보면 "연결 가능한 클라이언트의 최대 수" 라고 보실 수 있는데
// 그게 아니라 "연결을 기다리는 클라이언트의 최대 수" 입니다.
// 예를 들어 설명드리겠습니다.
// backlog의 값이 10인 상태에서 100개의 클라이언트가 동시에 연결 요청을 하게되면
// 100개의 연결 요청이 즉시 처리되는 것이 아니라, 10개씩 처리되는 것입니다.
mainSock.Listen(10);



마찬가지로, 단 한 줄로 끝입니다.

마지막으로, 연결 요청을 수락하는 코드를 보겠습니다.


클라이언트의 연결 요청을 수락하는 방법 (C#)
// 동기식 연결 요청을 받는 방법입니다.
// Accept 함수는 "연결 요청이 있을 때까지 무한 루프를 돌며 대기"하게 됩니다
// 따라서 연결 요청이 오지 않으면 다음 코드로 넘어가지 않습니다.
// 비동기식 코드는 나중에 설명하겠습니다.
Socket client = mainSock.Accept();


연결 요청을 수락하는 것 또한 단 한 줄로 끝입니다.

종합해보면 서버 소켓을 특정 주소와 포트에 바인딩하고, 연결 요청을 대기하고 수락하는 코드는 4줄이 전부가 됩니다.


여기까지가 정말 기본적인 소켓 설정 단계였고, 이제 비동기식 소켓 운용 방법에 대해서 한 번 알아보겠습니다.

동기(Synchronous)란 시간을 맞춘다는 것입니다. 비슷한 표현으론 "작업이 완료될 때까지 기다리는 것"이라고 표현할 수 있겠네요.


프로그래밍을 하면서 스레드(Thread)를 사용한다거나 언어 자체적으로 비동기화 컨텍스트를 제공하는 언어를 사용하지 않는 한 여태껏 프로그래밍해왔던 코드들은 전부 동기식으로 실행됩니다.

예제 코드를 보겠습니다.


1부터 (2^31) - 1까지의 합을 구하고 출력하는 코드 (C#)
long sum = 0;
for (int i = 1; i <= int.MaxValue; i++) {
    sum += i;
}

Console.WriteLine(sum);


위 코드는 1부터 (2^31) - 1까지의 합을 계산하는 "동기식" 코드입니다.

합을 계산하기 전까진 for문 내에서 반복을 하게 됩니다. 다르게 말하면 합이 계산되기 전까진 결과가 출력되지 않는다는 이야기죠.

한번 가정을 해볼게요. 덧셈 연산을 한 번 할때마다 1ms가 소요된다고 가정하면, 합이 계산되고 그 결과가 콘솔 창에 출력되기까지는 (2^31)ms가 소요됩니다. 이걸 초로 환산하면 2,147,483초가 걸리게 되는거죠. 사용자는 이 결과를 보기 위해서 214만 초를 기다려야 한다면 그건 너무나 끔찍하겠죠...

즉! 동기식이란 작업(코드)이 끝날 때까지 기다렸다가 다음 작업을 수행하는 것을 말합니다.


반대로 비동기식이란 무엇일까요?

시간을 맞추지 않는 것, 작업이 끝나지 않아도 다음 작업을 수행하는 것입니다.

코드를 보죠.


비동기 설명용 의사 코드 (C#)
void Main() {
    SumOfInteger();
    Console.WriteLine("계산은 아직 끝나지 않았지만 이 문자열은 출력될겁니다");
}

// SumOfInteger 함수는 비동기식으로 작동한다고 가정한다.
void SumOfInteger() {
    long sum = 0;
    for (int i = 0; i < int.MaxValue; i++) {
        sum += i;
    }
    
    Console.WriteLine(sum);
}


위와 같은 코드가 있고, SumOfInteger 함수는 비동기식으로 작동한다고 가정하겠습니다.

그럼 Main 함수에서 SumOfInteger 함수를 호출하게 되고 SumOfInteger 함수에선 for문을 반복하게 됩니다.

동기식의 경우 for문이 끝날 때까지 다음 코드로 넘어가지 않지만, 비동기식의 경우엔 SumOfInteger 함수를 호출하는 즉시 다음 코드(Console.WriteLine)로 넘어가게 됩니다.


비유로 간단하게 설명드리자면 동기식의 경우엔 한 명이 작업을 하는 것이고, 비동기식의 경우 여러 명이 작업을 하는 것입니다.

따라서, 동기식의 경우 한 명이 밀린 작업을 혼자 다 해야하므로 1번 작업을 마친 후에야 2번 작업을 하고, ... N번째 작업까지 마쳐야 일이 끝나게 됩니다.

반면 비동기식의 경우 여러 명이 작업을 하므로 A가 1번 작업을 하고, B가 2번 작업을 하고, C가 N번 작업을 하게 됩니다.


설명이 너무 길어졌네요. 바로 비동기식 코드에 대해서 살펴보도록 하겠습니다.


비동기식 연결 요청 수락 방법 (C#)
// 첫 번째 매개변수
//    연결 요청이 발생하면 호출될 함수(콜백)의 이름을 적어준다.
//    실행되는 함수의 반환 형식은 void, 매개 변수는 IAsyncResult 하나가 있어야 합니다.
//    void FunctinoName(IAsyncResult ar) <-- 처럼 말이죠.
//
// 두 번째 매개변수
//    연결 요청이 발생했을 때 실행되는 함수에서 사용할 추가적인 데이터를 넘겨줍니다.
//    이 예제에선 메인 소켓을 전역 변수로 사용하고 있기 때문에 넘겨줄 필요가 없지만,
//    나중에 데이터를 수신하거나 송신할 때엔 사용되므로 잠시 후에 설명하겠습니다. 
mainSock.BeginAccept(AcceptCallback, null);

// 연결 요청이 발생하면 호출될 콜백 함수
void AcceptCallback(IAsyncResult ar) {
     // 클라이언트의 연결 요청을 수락한다.
    Socket client = mainSock.EndAccept(ar);
}



위 코드처럼 작성을 하시면, Socket.BeginAccept 함수가 내부적으로 클라이언트 연결 요청을 수락하기 위한 루프를 돌리고 루프가 끝나는 것을 기다리는 것이 아닌 다음 코드로 제어가 이동됩니다.


이제 연결 요청을 수락하는 코드를 완성했습니다. 하지만, 서버쪽에서 해줘야 할 일은 이게 다가 아닙니다. 연결 요청을 수락해준 후엔 서버에서 데이터를 보내거나 데이터를 수신하는 작업을 해줘야 합니다. 만약 연결만 해놓고 데이터를 보내지도, 받지도 않는다면 연결된 소켓끼리는 아무 작업도 하지 않게 됩니다. 이번 글에선 연결이 수락된 후 클라이언트에게 보낼 데이터가 필요하지 않으므로 바로 데이터 수신을 대기합니다.

이번 코드에서도 마찬가지로 비동기식으로 데이터를 받습니다.

* 단 이번 코드에선 데이터 및 소켓 등을 저장하기 위한 컨테이너 클래스가 사용됩니다.


데이터 수신 대기 및 비동기 작업에 대한 컨테이너 클래스 코드 (C#)
// 비동기 작업에서 사용하는 소켓과 해당 작업에 대한 데이터 버퍼를 저장하는 클래스
public class AsyncObject {
    public byte[] Buffer;
    public Socket WorkingSocket;
    public readonly int BufferSize;
    public AsyncObject(int bufferSize) {
        BufferSize = bufferSize;
        Buffer = new byte[BufferSize];
    }
    
    public void ClearBuffer() {
        Array.Clear(Buffer, 0, BufferSize);
    }
}

void AcceptCallback(IAsyncResult ar) {
    // 클라이언트의 연결 요청을 수락한다.
    Socket client = mainSock.EndAccept(ar);

     // 클라이언트의 연결 요청을 대기한다. (다른 클라이언트가 또 연결할 수 있으므로)
    mainSock.BeginAccept(AcceptCallbacknull);

    AsyncObject obj = new AsyncObject(4096);
    obj.WorkingSocket = client;
    client.BeginReceive(obj.Buffer, 0, 4096, 0, DataReceived, obj);
}

void DataReceived(IAsyncResult ar) {
    // BeginReceive에서 추가적으로 넘어온 데이터를 AsyncObject 형식으로 변환한다.
    AsyncObject obj = (AsyncObject) ar.AsyncState;
    
    // 클라이언트가 보낸 데이터는 obj.Buffer에 바이트 형식으로 저장된다.
    // 따라서 이 데이터는 System.Text.Encoding 클래스의 GetString 함수를 이용하여 문자열로
    // 변환해야 사용이 가능하다.
    string text = System.Text.Encoding.UTF8.GetString(obj.Buffer);
    
    // 데이터를 받은 후엔 다시 버퍼를 비워주고 같은 방법으로 수신을 대기한다.
    obj.ClearBuffer();
    
    // 수신 대기
    // AcceptCallback 함수에서의 client와 obj.WorkingSocket은 동일한 소켓 개체이다!
    obj.WorkingSocket.BeginReceive(obj.Buffer, 0, 4096, 0, DataReceived, obj);
}



여기까지가 서버에서 클라이언트의 연결 요청이 들어왔을 때 연결을 수락하고, 데이터 수신을 대기하는 법입니다.

글 내용에 개념이나 설명을 많이 덧붙여서 글이 길어졌지만, 코드만 작성한다면 30줄도 안되는 매우 짧은 코드입니다.



수신한 데이터가 있다면, 데이터를 송신자를 제외한 클라이언트들에게 다시 전달해줘야 한다.

클라이언트의 연결을 관리해야 한다.

이는 결국 클라이언트의 연결을 관리해야 가능하기 때문에 두 주제를 묶게 되었습니다.

방법은 바로 위에서 설명해드린 AcceptCallback 함수와 DataReceived 함수만 약간씩 수정하면 됩니다.

먼저, AcceptCallback 함수에선 연결 요청이 온 클라이언트의 연결을 수락할 때 클라이언트 소켓을 리스팅해주는 것이 필요합니다.

그래야 나중에 연결된 클라이언트에게 전송받은 데이터를 전달해줄 수 있기 때문이죠.


그리고, DataReceived 함수에선 클라이언트 소켓이 저장된 리스트를 반복하면서 데이터를 보낸 클라이언트만 제외한 나머지 클라이언트에게 데이터를 보내주면 되는 것이죠.


전달 기능이 추가된 코드 (C#)
List<Socket> connectedClients = new List<Socket>();
void AcceptCallback(IAsyncResult ar) {
    // 클라이언트의 연결 요청을 수락한다.
    Socket client = mainSock.EndAccept(ar);

    AsyncObject obj = new AsyncObject(4096);
    obj.WorkingSocket = client;

    // 연결된 클라이언트 리스트에 추가해준다.
    connectedClients.Add(client);


    client.BeginReceive(obj.Buffer, 0, 4096, 0, DataReceived, obj);
}

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

    // 클라이언트가 보낸 데이터는 obj.Buffer에 바이트 형식으로 저장된다.
    // 따라서 이 데이터는 System.Text.Encoding 클래스의 GetString 함수를 이용하여 문자열로
    // 변환해야 사용이 가능하다.
    string text = System.Text.Encoding.UTF8.GetString(obj.Buffer);

    // for을 통해 "역순"으로 클라이언트에게 데이터를 보낸다.
    // 원래 순서대로 리스트를 반복하다 보면 개체가 제거될 때 오류가 발생하게 된다.
    // 역순으로 반복 루프를 돌면 해결된다.
    // 이유: 리스트 내 요소가 제거되는 방법이 해당 요소를 뺀 후 뒤의 요소들을 붙이는 방법이기 때문이다.
    for (int i = connectedClients.Count - 1; i >= 0; i--) {
        Socket socket = connectedClients[i];
                
        // 핸들 값을 비교하여 이 데이터를 보낸 소켓인지 구분한다.
        if (socket.Handle != obj.WorkingSocket.Handle) {
            // 데이터를 보낸 소켓이 아니라면
            // 수신받은 데이터를 전달해준다.
            socket.Send(obj.Buffer);
        }
    }


    // 데이터를 받은 후엔 다시 버퍼를 비워주고 같은 방법으로 수신을 대기한다.
    obj.ClearBuffer();

    // 수신 대기
    // AcceptCallback 함수에서의 client와 obj.WorkingSocket은 동일한 소켓 개체이다!
    obj.WorkingSocket.BeginReceive(obj.Buffer, 0, 4096, 0, DataReceived, obj);
}


변경된 코드 중 추가된 부분은 굵은 기울임꼴로 표시해놨습니다.
이제 알려드렸던 코드를 이용하여 서버 폼을 완성하면 됩니다.
폼 내의 코드와 여기서 소개된 함수들의 코드는 차이가 있을 수 있습니다.

먼저, 이 글에서 소개되지 않은 "서버" 에서 처리해야할 작업들은 다음과 같습니다.
  • * DataReceived 콜백 함수에서 텍스트를 0x01 바이트 기준으로 보낸 클라이언트의 IP 주소, 메세지를 잘라내고 텍스트박스에 표시
  • 프로그램 시작 시 "서버 주소" 텍스트박스에 로컬 컴퓨터의 IP 주소 자동 입력



앞에 별표가 붙은 것에 대한 내용은 Cross-Thread 작업 문제가 있으므로 해당 작업이 뭐고 생기는 문제가 무엇인지 궁금하신 분들은 아래 게시글을 읽어보시는 것을 강하게 권장합니다!!


위 작업들에 대한 코드는 모두 작성해놓았으므로, 프로젝트 파일을 받으셔서 직접 확인해보시기 바랍니다 :)


게시글의 내용이 너무 길어지는 것 같아서 중요한 부분만 설명드리고 글을 마치도록 하겠습니다. 긴 글 읽어주셔서 감사합니다.


+ Recent posts