C# 네트워크 프로그래밍
참고 도서 : 가볍게 시작하는 리얼 C# 프로그래밍
실 세계의 집주소는 네트워크에서는 IP 주소에 비유할 수 있다.
192.168.0.32
192는 가장 큰 범위 주소, 뒤로 갈수록 작은 범위의 주소이다.
ip주소와 Port 번호를 이용해 연결하며, Port 번호는 이름이라고 할 수도 있을 것이다.
TCP
TCP는 연결 지항형 서비스로, 데이터 전송 전에 Hand Shake를 통해 상대방과 연결을 형성한다.
UPD
UDP는 Hand Shake가 필요하지 않으며, 비연결형 서비스이므로 상대방과 1:1 회선이 형성되지 않는다.
패킷에 상대방의 주소를 입력해 네트워크를 전송하며, 이는 편지에 비유할 수 있다. 통화는 바로 서로간 연결을 하지만, 편지는 써서 우체통에 넣을 뿐이니 말이다.
TCP의 혼잡 제어(Congestion Control)
특정 TCP 연결이 과도한 양의 데이터를 전송하려하면, 송신측의 TCP가 전송하려는 양의 데이터를 조절해서 (통신하는 호스트들 사이에)
네트워크 자원이 폭주되는 것을 방지한다.
TCP의 신뢰적 데이터 전달(Reliable data transfer)
TCP는 데이터가 순서대로, 정확하게 전달되는 것을 보장한다. TCP 연결이 이렇듯 많은 기능을 지원하지만,
UPD에 비해선 연결이 무겁고 데이터 전송에 더 큰 비용이 든다. (흐름 제어, 순서번호, 확인 응답 등의 메커니즘을 사용하므로)
이런 이유로 UDP는 화면 공유 등에 주로 이용되고, TCP는 채팅 프로그램에 많이 사용된다.
내 PC의 IP 주소 출력하기
using System;
using System.Net;
namespace Network
{
class Program
{
static void Main(string[] args)
{
string hostName = Dns.GetHostName();
Console.WriteLine(hostName);
IPHostEntry host = Dns.GetHostEntry(hostName);
Console.WriteLine(host.AddressList[1].ToString());
for (int i = 0; i < host.AddressList.Length; i++)
{
string myIPAddress = host.AddressList[i].ToString();
Console.WriteLine(myIPAddress);
}
}
}
}
네트워크 프로그래밍의 여러 클래스들
IPAddress 클래스
도트 4자리 표기법의 IP 주소 예) 192.168.210.21
IPAddress 객체의 초기화
IPAddress ipAddress = IPAddress.Parse("192.168.210.21");
IPAddress(Int64) // Int64로 지정된 주소 사용
.Parse // IP 주소 문자열을 IPAddress 인스턴스로 변환
IPEndPoint 클래스
하나의 컴퓨터에는 여러 프로그램이 동시 동작하므로, 원하는 프로그램을 찾기위해 포트 번호가 필요하다.
IPEndPoint는 특정 컴퓨터의 프로그램을 가리키는 클래스이다.
IPAddress ipAddress = IPAddress.Parse("192.168.210.21");
IPEndPoint ipep = new IPEndPoint(ipAddress, 9999);
소켓 클래스
소켓 클래스는 실제 서버와 클라이언트를 구축하기 위해 필요한 인터페이스이다.
소켓은 서로 다른 OS간에 네트워크 통신을 가능하게 해주며, 네트워크 전문 지식이 없이도 프로그래밍이 가능하게 한다.
이 과정을 크게 잡으면 다음과 같다.
1. 서버에서 소켓 생성 및 서버 설정 후 접속 대기
2. 접속이 일어나면 데이터 전송이 발생하고 소켓을 닫음
서버의 구현
이미지나 동영상 등의 전송을 위해선 byte로 데이터를 주고 받아야 한다.
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace TCP_Server
{
class Program
{
static void Main(string[] args)
{
Socket server = null;
Socket client = null;
byte[] data = new byte[1024];
// 3317포트에 대해선 모든 형태의 ip에 접속 허가
IPEndPoint ipep = new IPEndPoint(IPAddress.Any, 9999);
server = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
server.Bind(ipep); // ipep를 통해 서버 소켓과 결합
server.Listen(10); // 최대 접속 가능한 클라이언트의 수를 지정 후, 대기
Console.WriteLine("서버 시작 \n 클라이언트 접속 대기.");
// 클라이언트 접속 대기, 동기화 상태 진입. 접속이 일어나면 클라이언트 소켓이 반환
client = server.Accept();
Console.WriteLine("클라이언트 접속 완료.");
// 접속한 클라이언트로부터 데이터 수신, DATA 변수 BYTE 배열 형태로 저장
client.Receive(data);
Console.WriteLine("클라이언트로부터 데이터 수신. \n 메시지 : "
+ Encoding.Default.GetString(data));
// 클라이언트와 서버 소켓을 각각 닫는다.
client.Close();
server.Close();
}
}
}
클라이언트의 구현
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace TCP_Client
{
class Program
{
static void Main(string[] args)
{
byte[] data = new byte[1024];
IPAddress ipAddress = IPAddress.Parse("210.119.12.79"); // 접속할 서버의 IP 주소
IPEndPoint ipep = new IPEndPoint(ipAddress, 9999); // 1433은 port번호, 특정 애플리케이션을 지정
// 서버 소켓 생성
Socket server = new Socket(AddressFamily.InterNetwork, SocketType.Stream,
ProtocolType.Tcp);
Console.WriteLine("서버 접속...");
// 서버 접속
server.Connect(ipep);
Console.WriteLine("서버 접속 완료.");
data = Encoding.Default.GetBytes("클라이언트에서 보내는 메시지");
server.Send(data);
Console.WriteLine("서버에 데이터 전송");
server.Close();
}
}
}
비동기화를 위한 thread 적용
동기화는 어떤 입력이 들어올 때까지 프로그램의 제어가 멈추어져 있으므로, 네트워크 접속에서는 비효율적이고 불편함을 초래한다.
반면에, 비동기화는 작업을 요청한 뒤 결과의 수신 여부와 상관없이 다음 작업을 곧바로 진행하므로 실시간 처리와 다중 접속 형태에 알맞다.
비동기화를 위해 thread를 이용할 수 있으며, 하나의 프로그램을 두 개 이상의 흐름으로 나누어 병렬로 실행할 수 있게 된다.
(하나의 thread는 Accept() 메소드를 실행하고, 기존의 프로그램 제어는 다른 thread가 담당)
커널 버퍼
https://colorscripter.com/s/dbnDSsE
위의 소스에서 Receive() 메소드는 상대에게서 바로 데이터를 받아오는 것이 아니다.
Receive() 메소드를 호출하기 전에 이미 커널 버퍼에 데이터는 수신되어 있고, Receive() 메소드를 호출함으로써 가져오는 것이다.
이는 자원을 효율적으로 가져오기 위함이지만 문제가 있다.
몇 번의 데이터를 수신하였는지 알 수 없고, 얼마 만큼 데이터를 한번에 처리해야 할지 알수 없는 것이다.
이를 해결하기 위해서 패킷을 정의할 필요가 있다.
패킷의 정의
패킷은 네트워크 전송에 있어서 하나의 단위이다.
보통 TCP 통신을 통해 패킷을 전송할 때는 크게 두 가지 형태가 있다.
여러 패킷을 모아 한번에 전송하거나, 하나의 패킷을 나누어서 여러 번에 걸쳐 전송하는 것이다.
첫 번째는 데이터 크기가 작을 때 자원을 효율적으로 사용하기 위함이고, 두 번째는 데이터 크기가 클 때 내부 버퍼 용량과 전송 실패시의 부담을 고려한 것이다.
패킷의 정보 구성
데이터 패킷은 Header와 Body로 구성된다.
헤더에는 한 패킷의 크기를 나타내는 정보가 담겨있다. 데이터 처리시 이 부분을 먼저 읽게 된다.
바디에는 데이터가 들어있다.
필요에 따라 하나의 패킷이 다른 패킷에 포함될 수도 있다.
패킷을 수신할 때, 패킷을 처리해 원하는 결과값을 얻을 것이다. 데이터의 종류는 수신 메시지 외에도 여러가지가 있다.
그렇기 때문에 패킷에는 데이터의 종류를 나타내는 정보도 담을 필요가 있다.
패킷 2는 데이터의 종류를 나타내는 헤더와 패킷 1로 구성될 수도 있다.
(패킷 2 = 데이터의 종류를 나타내는 헤더 + 헤더와 데이터로 구성된 패킷 1)
패킷2의 헤더를 읽고 난 뒤에는 데이터의 종류를 알기에 패킷1의 데이터를 어디에서 처리할지 알 수 있다.
그 다음은 패킷1의 헤더를 통해 실제 데이터 크기를 알고 남은 데이터를 읽게 된다.
패킷1의 헤더에는 데이터의 총 크기가 담겨 있다.
만약 수신한 데이터가 총 크기보다 작을 경우, 남은 데이터를 모두 읽을 때까지 반복하여 Receive() 함수를 호출하면 될 것이다.
패킷 개념을 적용한 소스는 다음과 같다. 우선 패킷으로 데이터를 수신하도록 Receive 함수의 일부분이 바뀌었다.
private void Receive() // 상대 호스트로부터 데이터 수신
{
( ... 생략)
//패킷 개념 적용을 위해 주석처리
//mClientSocket.Receive(data, SocketFlags.None);
data = ReceiveData(); // 정의한 패킷으로 데이터 수신
message = Encoding.Default.GetString(data); // 수신데이터 string 형태로 데이터 변환
mChatWnd.ReceiveMessage(message); // chat창에 메시지 전달
}
}
다음과 같이 Send() 메소드의 일부분이 바뀌고 실질적인 데이터 전송은, 추가된 SendData 메소드를 통하도록 바뀌었다.
public void Send(string message)
{
(... 생략)
data = Encoding.Default.GetBytes(message);
//mClientSocket.Send(data, 0, data.Length, SocketFlags.None);
SendData(data);
}
패킷 헤더의 크기를 상수로 정의하고 다음과 같이 패킷을 통한 전송/수신 메소드들을 추가하도록 한다.
private const int PACKET_HEADER_SIZE = 4; // 패킷 해더 크기
private byte[] ReceiveData()
{
byte[] headerBuffer = new byte[PACKET_HEADER_SIZE];
byte[] dataBuffer = null;
int totalDataSize = 0; // 전체 데이터 크기
int accumulatedDataSize = 0; // 누적 수신한 데이터 크기
int leftDataSize = 0; // 미 수신 데이터 크기
int receivedDataSize = 0; // 총 수신한 데이터 크기
// 데이터 수신. Receive 메소드 호출로 수신한 데이터 크기 저장
receivedDataSize = mClientSocket.Receive(headerBuffer, 0,
PACKET_HEADER_SIZE, SocketFlags.None);
// BitConverter 클래스의 메소드를 사용하기 위해 using System; 추가
totalDataSize = BitConverter.ToInt32(headerBuffer, 0);
leftDataSize = totalDataSize;
dataBuffer = new byte[totalDataSize];
while (leftDataSize > 0)
{
receivedDataSize = mClientSocket.Receive(dataBuffer, accumulatedDataSize,
leftDataSize, 0); // 데이터 수신
accumulatedDataSize += receivedDataSize; // 총 누적 수신 데이터
leftDataSize -= receivedDataSize; // 남은 미 수신 데이터
}
return dataBuffer; // 수신된 데이터 반환
}
private void SendData(byte[] dataBuffer)
{
byte[] headerBuffer = new byte[PACKET_HEADER_SIZE];
int totalDataSize = 0; // 전체 데이터 크기
int accumulatedDataSize = 0; // 누적 전송한 데이터 크기
int leftDataSize = 0; // 미 전송 데이터 크기
int sentDataSize = 0; // 전송한 데이터 크기
totalDataSize = dataBuffer.Length;
leftDataSize = totalDataSize - sentDataSize;
headerBuffer = BitConverter.GetBytes(totalDataSize); // 전송할 데이터 총 크기
mClientSocket.Send(headerBuffer); // 전체 데이터 크기 전송
while (leftDataSize > 0)
{
sentDataSize = mClientSocket.Send(dataBuffer, accumulatedDataSize,
leftDataSize, SocketFlags.None); // 데이터 전송
accumulatedDataSize += sentDataSize; // 총 누적 전송 데이터
leftDataSize -= sentDataSize; // 남은 미 전송 데이터
}
}
이렇게 패킷 개념을 적용해 프로그램을 작성하면 대용량의 데이터 전송/수신시에도 더 안정적으로 작동할 수 있게 된다.
좀 더 세심하게 예외처리까지 한다면 훨씬 안정적인 프로그램이 될 것이다.
c# winform 텍스트 박스 글씨 *로 보이기
TextBox 속성 중 PasswordChar에 *를 입력하면 된다. 이는 로그인 폼의 패스워드 입력을 위한 것이다.