Coding Memo

[C#] 에코 서버 만들기 본문

etc

[C#] 에코 서버 만들기

minttea25 2023. 7. 3. 13:53

에코 서버?

 

클라이언트로 부터 들어온 데이터(메세지)를 서버에서 그대로 다시 클라이언트로 전송하는 서버를 말한다.


서버와 클라이언트 설계

 

TCP 소켓을 이용해 서버와 클라이언트를 먼저 연결한 후에 메시지를 보낸다.

기억해야할 점은 데이터는 byte[] 타입으로 주고받는다는 것이다. 이를 잘 처리해야한다.

 

공통

1. TCP 소켓을 이용해 데이터를 송수신

2. 메시지 타입은 string, 인코딩 형식은 UTF-8로 통일

3. localhost를 이용하되, 코드를 통해 컴퓨터의 주소를 가져오고 그 첫 번째 주소를 엔드포인트로 사용 (port: 8888)

4. 간단하게 작성하고 테스트하기 위해 blocking 함수로 소켓 프로그래밍

5. 소켓에 대한 에러 확인 및 예외 처리는 생략

 

 

서버에서 이뤄져야 할 것들

1. 소켓 생성

2. 서버를 오픈 포인트로 한 엔드포인트 생성

3. 소켓에 엔드포인트를 Bind

4. 위 소켓을 Listening 소켓으로 함

5. 소켓 Accept로 연결 대기

6. 클라이언트와 연결

7. 클라이언트로 부터 메시지가 오면 메시지를 출력

8. 위 메시지를 클라이언트로 전송

9. 클라이언트와 연결되었던 소켓 close

10. 다시 소켓 Listen

 

Note: Listen 하는 소켓과 직접 클라이언트와 연결되어 데이터를 주고 받는 소켓은 다른 소켓이다.

 

클라이언트에서 이뤄져야 할 것들

1. 소켓 생성

2. 연결할 서버에 대한 엔드 포인트 생성

3. 위 엔드포인트에 소켓으로 Connect

4. 서버와 연결 완료

5. 서버에 메시지 전송

6. 서버로 부터 응답 대기

7. 서버로 부터 받은 메시지 출력

8. 소켓 Close

 

마지막으로 공통적으로 필요한 참조는

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

코드 작성

 

프로젝트를 2개 열어놓고 작업하면 편할 것이다. 서버와 클라이언트에서 공통적인 코드는 별도의 표시를 하지 않았고, 서버의 경우 [Server]를 붙이고, 클라이언트의 경우 [Client]를 앞에 붙였다.

 

1. 로컬 호스트의 주소 얻어오기

string host = Dns.GetHostName(); // local host name of my pc
IPHostEntry ipHost = Dns.GetHostEntry(host);
IPAddress ipAddr = ipHost.AddressList[0];

출력을 해보면 알겠지만 나의 경우는 로컬 호스트의 첫번째 주소 (AddressList[0]) 값이 IPv6 주소였다.

 

 

2. TCP로 소켓 생성

Socket socket = new(
	addressFamily: ipAddr.AddressFamily,
	socketType: SocketType.Stream,
	protocolType: ProtocolType.Tcp);

// Server의 경우 listenSocket으로 이름을 지어도 좋을 것 같다!

소켓 생성 시에 위에서 IP 주소에 대한 AddressFamily (IP 주소의 타입)와 TCP 소켓이므로 소켓 타입은 Stream으로, 프로토콜 타입은 TCP로 지정한다.

 

 

3. 엔드 포인트 생성

IPEndPoint endPoint = new(address: ipAddr, port: 8888);

IP주소와 포트 번호를 지정해준다.

 

 

4. [Server] 소켓에 엔드포인트를 Bind 하기

listenSocket.Bind(endPoint);

socket의 Bind 함수를 통해 엔드포인트를 지정한다.

 

 

5. [Server] 소켓을 Listen으로 만들기

listenSocket.Listen(backlog: 10);
Console.WriteLine($"Listening on port: {endPoint.Port}....");

backlog는 최대 대기 수이다. 소켓의 listen를 호출하여 이제 이 소켓으로 연결 요청이 들어오길 기다리고 있는 것이다.

 

 

6. [Server] 클라이언트 요청 Accept 하기

Socket clientSocket = listenSocket.Accept(); // blocking method
Console.WriteLine($"Client is connected {clientSocket.RemoteEndPoint}");

Accept()로 클라이언트 요청이 들어오면 연결을 형성하고 간단하게 클라이언트 정보를 출력한다.

Note: Accept() 함수는 blocking 메서드로 Accpet()가 return이 될 때까지 다음 코드가 실행되지 않는다.
물론 AcceptAsync() 이라는 비동기 함수가 존재한다.

 

7. [Client] 서버에 연결하기

socket.Connect(endPoint); // blocking
Console.WriteLine("Connected to Server.");

Connect 함수에 엔드포인트를 지정하여 엔드포인트(서버)에 연결을 요청한다.

Connection이 만들어질려면, 해당 엔드포인트의 소켓이 Listen 상태여야 한다.

Connect또한 blocking 함수이므로 연결이 확립(established) 될 때까지 다음 코드를 실행하지 않는다.

 

Note: 마찬가지로 ConnectAsync()이라는 비동기 함수가 있다.

 

 

8. 소켓으로 데이터를 받고 읽을 수 있는 데이터로 변환하기 (Deserialize, 역직렬화)

// Read received data
byte[] data = new byte[1024];
int bytesRead = clientSocket.Receive(data);
string msg = Encoding.UTF8.GetString(data, 0, bytesRead);
Console.WriteLine($"Received data: {msg}");

메시지가 1024바이트를 넘지 않는다는 가정 하에 1024 길이의 바이트 배열인 버퍼 data를 만든다.

 

소켓으로 받은 데이터를 이 버퍼에 쓰고 실제 버퍼에 쓴 길이(바이트 수)를 bytesRead 값으로 가지고 있는다.

이제 받은 데이터가 원래 string이라는 것은 우리가 약속했던 것이니 데이터를 string으로 변환하면 된다.

 

Encoding.UTF8.GetString()으로 바이트 배열을 스트링으로 가져올 수 있다.

인자로 바이트 배열, 시작 오프셋(offset), 마지막으로 string으로 가져올 바이트 수를 인자로 넣어주면 된다.

 

 

9. 메시지를 바이트 배열로 바꾸고 소켓으로 데이터를 전송하기 (Serialize, 직렬화)

string msg = @"Hello, it's C# Client!";
byte[] msgBytes = Encoding.UTF8.GetBytes(msg);
socket.Send(msgBytes);

역직렬화와 비슷하게 Encoding.UTF8.GetBytes() 함수로 string을 byte[]으로 직렬화 할 수 있다.

이후 socket의 Send를 통해 데이터(byte[])를 전송한다.

 

Note: 서버는 받은 string 값을 byte[]로 변환 후, 그대로 전송하면 될 것이다.

 

 

10. 소켓 닫기 (소켓 리소스 반환)

clientSocket.Close();

더 이상 송수신 할 데이터가 없으면 소켓을 닫는다.

 


서버 코드

namespace Server
{
    class Program
    {
        static void Main(string[] args)
        {
            try
            {
                string host = Dns.GetHostName(); // local host name of my pc
                IPHostEntry ipHost = Dns.GetHostEntry(host);
                IPAddress ipAddr = ipHost.AddressList[0];
                IPEndPoint endPoint = new(address: ipAddr, port: 8888);

                Socket listenSocket = new(
                    addressFamily: ipAddr.AddressFamily,
                    socketType: SocketType.Stream,
                    protocolType: ProtocolType.Tcp);

                listenSocket.Bind(endPoint);
                listenSocket.Listen(backlog: 10);

                Console.WriteLine($"Listening on port: {endPoint.Port}....");

                while (true)
                {
                    Socket clientSocket = listenSocket.Accept(); // blocking method
                    Console.WriteLine($"Client is connected {clientSocket.RemoteEndPoint}");

                    // Read received data
                    byte[] data = new byte[1024];
                    int bytesRead = clientSocket.Receive(data);
                    string msg = Encoding.UTF8.GetString(data, 0, bytesRead);
                    Console.WriteLine($"Received data: {msg}");

                    // Send received data to client
                    clientSocket.Send(Encoding.UTF8.GetBytes(msg)); // echo

                    clientSocket.Close();
                }

                listenSocket.Close();
            }
            catch (Exception e) { Console.WriteLine(e); }
            
        }
    }
}

 

클라이언트 코드

namespace Client
{
    class Program
    {
        static void Main(string[] args)
        {
            try
            {
                string host = Dns.GetHostName(); // local host name of my pc
                IPHostEntry ipHost = Dns.GetHostEntry(host);
                IPAddress ipAddr = ipHost.AddressList[0];
                IPEndPoint endPoint = new(address: ipAddr, port: 8888);

                Socket socket = new(
                    addressFamily: ipAddr.AddressFamily,
                    socketType: SocketType.Stream,
                    protocolType: ProtocolType.Tcp);

                socket.Connect(endPoint); // blocking
				Console.WriteLine("Connected to Server.");
                
                // Send msg to Server
                string msg = @"Hello, it's C# Client!";
                byte[] msgBytes = Encoding.UTF8.GetBytes(msg);
                socket.Send(msgBytes);

                // Read msg from Server
                byte[] recvData = new byte[1024];
                int bytesRead = socket.Receive(recvData);
                string recvMsg = Encoding.UTF8.GetString(recvData, 0, bytesRead);
                Console.WriteLine(recvMsg);

                socket.Close();
            }
            catch (Exception e) { Console.WriteLine(e); }
        }
    }
}

테스트 

 

Server
Client

클라이언트가 보낸 값을 그대로 서버에서 다시 클라이언트로 전송하는 것을 확인할 수 있다.

 

위에서 연결한 클라이언트의 엔드포인의 값을 가려놓기는 했지만 뒤에 ':xxxx'가 포트 번호이다.

이 포트번호는 우리가 코드에서 명시적으로 지정해준적이 없는 것을 눈치챈 사람도 있을 것이다.

 

클라이언트가 다른 엔드포인트에 연결 할 때의 포트 값은 OS에 의해 자동으로 할당된다. OS는 랜덤으로 포트 번호를 할당한다.

(물론, 서버의 특정 프로그램이나 어플리케이션에 대한 연결 시는 명시적으로 포트 번호를 지정할 수 있다. TCP에서  사용할 수 있는 포트 범위가 제한되어 있고 몇몇 포트 번호는 HTTP, SMTP, HTTPS 등에 고정적으로 사용된다.)