제임스딘딘의
Tech & Life

개발자의 기록 노트/C#

[C#] 닷넷 프레임워크 기반의 소켓 프로그래밍

제임스-딘딘 2012. 2. 28. 18:02

출처 :  http://www.devtimes.com/45 

요약 (Summary)

이 문서는 닷넷 프레임워크 환경과 이기종 환경 간의 소켓 통신을 위한 간략한 개요를 제시합니다. 특히 HTTP 또는 SOAP 등의 텍스트 기반의 통신이 아닌, C/C++ 개발자들이 선호하는 구조체 패킷을 이용한 통신에 초점을 두었습니다.

뿐만 아니라 이를 지원하기 위한 소켓 라이브러리(C# 2.0 지원)도 함께 제공합니다. MIT 라이센스를 채택하였으며, 라이센스에 명시된 바와 같이 상업적인 목적으로도 사용할 수 있습니다.


소개 (Introduction)

이 문서는 닷넷 프레임워크 기반의 소켓 프로그램을 작성하려는 개발자를 위해 작성되었습니다. 닷넷 개발자는 C/C++ 등으로 작성된 서버/클라이언트와 통신하기 위한 모듈 또는 소프트웨어를 제작해야 하는 상황에 부닥치게 됩니다. 닷넷 환경에서의 소켓 프로그래밍에 익숙하지 않은 개발자는 많은 어려움에 직면하게 됩니다.

이 문서는 다음과 같은 문제점을 해결하기 위한 가이드라인을 제시합니다.

  • 닷넷 환경에서 구조체를 이용한 소켓 통신 구현이 어렵습니다.
  • 닷넷 환경과 이기종 환경 간의 통신에서는 데이터의 타입 변환을 신중히 생각해야 합니다.
  • 그밖에 문자열 변환 등에서 부닥치게 되는 예기치 못한 문제들이 있습니다.

또한 닷넷 환경에서 소켓 통신을 할 때 자주 쓰이는 주요 함수 또는 패턴을 기술합니다.

이 문서의 주요 섹션은 다음과 같이 구성되어 있습니다.


시스템 요구사항 (System Requirements)

이 문서 상에 기술된 작업을 위한 시스템 요구사항은 다음과 같습니다.

  • Microsoft Windows® 2000, Windows XP Professional, or Windows 2003 operating system

  • Microsoft .NET Framework Software Development Kit (SDK), version 1.1.

이 문서 상에 기술된 작업이 테스트된 환경은 다음과 같습니다.

  • Microsoft Windows® 2000 Professional operating system

  • Microsoft .NET Framework Software Development Kit (SDK), version 1.1

  • Microsoft Visual Studio® 2003 development system

  • Microsoft Windows® XP Professional operating system

  • Microsoft .NET Framework Software Development Kit (SDK), version 2.0

  • Microsoft Visual Studio® 2005 development system


참고 문헌 (Related Links)


누가 이 문서를 읽어야 하는가? (Who Should Read This Document?)

이 문서는 C/C++ 소켓 프로그래밍에 익숙하지만 닷넷 프레임워크 환경에서 소켓 프로그래밍을 해야 하는 개발자를 위해 가이드라인을 제시합니다.


반드시 알아야 할 것 (What You Must Know)

C/C++ 환경에서의 소켓 프로그래밍에 대한 기본적인 지식이 필요합니다. 관리되는 메모리의 구조 및 관리되지 않는 메모리의 구조에 대한 차이를 이해하고 있어야 합니다. 닷넷 기반에서 Binary 데이터를 다루기 위해 필요한 기본적인 지식이 필요합니다.


새로운 사항 (What’s New)

  1. 2006사용자 정의 함수 ExtendedTrim의 버그 수정.

  2. 2007.07.25소켓 라이브러리 추가


닷넷 환경에서 구조체는 관리되는 메모리 구조를 갖는다

일반적인 C/C++에서는 구조체를 이용하여 소켓 통신을 합니다. 다음은 인증을 위한 구조체를 정의하고, 이를 통해 통신을 하는 예제 코드입니다.

[표: 인증을 위한 패킷 구조체]

[C]
typedef struct tag_BIND
{
char szID[16];
char szPWD[16];
} BIND;

#define BIND_SIZE sizeof(BIND)

[표: 인증 패킷 송신 예제]

[C]
if( SendData(sockfd,(char*)&packetbind,BIND_SIZE) != BIND_SIZE )
{
// Error Handling
cout << "ERROR : BIND 송신 실패" << endl;
return 0;
}

닷넷 환경에서는 위와 같이 구조체를 사용한 소켓 통신이 지원되지 않습니다.

[표: C#으로 작성된 인증 패킷 구조체

[C#]
public struct BIND
{
public char szID[16];
public char szPWD[16];
};

만약 위와 같은 구조체를 선언해서 전송하면, 수신하는 측에서는 16 bytes + 16 bytes을 받아야 합니다. 그러나 실제로는 약 100 bytes의 데이터를 수신하게 됩니다. 이는 닷넷 환경에서 구조체가 관리되는 메모리 구조를 갖기 때문입니다.


닷넷 환경에서의 구조체를 이용한 소켓 통신 구현 방법

닷넷 환경에서 구조체를 이용한 소켓 통신을 지원하기 위한 방법은 크게 두 가지로 나뉩니다.

  • 마샬링(Marshaling)을 이용한 구조체 사용.
  • 바이너리 포매터(Binary Formatter)의 사용.

마샬링을 이용하면, C/C++에서와 비슷한 방식으로 소켓 프로그래밍을 할 수 있습니다. 하지만 이 문서에서는 두 번째 방법을 사용합니다.


패킷 송신 방법

다음과 같은 인증 요청 패킷을 송신해야 한다고 가정합니다.

[표: 인증 요청 패킷]

[C]
typedef struct tag_BIND
{
int nCode;
char szPWD[16];
} BIND;

이러한 구조의 패킷을 보내기 위해 다음과 같은 코드가 필요합니다.

[C#]
// 패킷 사이즈 
public int BODY_BIND_SIZE = 4 + 16;

// 인증 패킷 구조체 
public struct Bind
{
public int nCode;
public string strPWD;
}

// 인증 패킷 구조체를 바이트 배열로 변환하는 함수. 
public byte[] GetBytes_Bind(Bind bind)
{
byte[] btBuffer = new byte[BODY_BIND_SIZE];

MemoryStream ms = new MemoryStream(btBuffer,true); 
BinaryWriter bw = new BinaryWriter(ms);

// nCode 
bw.Write( IPAddress.HostToNetworkOrder( bind.nCode ) );

// szPWD 
try
{
byte[] btPWD = new byte[16];
Encoding.Default.GetBytes(bind.strPWD,0,bind.strPWD.Length,btPWD,0);
bw.Write( btPWD );
}
catch(Exception ex)
{
// Error Handling 
}

bw.Close();
ms.Close();

return btBuffer;
}

// 사용 예제
Bind bind = new Bind();
bind.nCode = 12345;
bind.strPWD = “98765”;

byte[] buffer = GetBytes_Bind(bind);

socket.Send(buffer);

BinaryWriter를 사용하여 4 bytes의 Integer값과 16 bytes의 문자열을 바이트 배열에 써넣습니다. 위의 GetBytes_Bind를 실행하면 다음과 같은 구조의 바이트 배열이 생성됩니다. 이때 Integer 타입의 nCode를 Network Byte Order로 변환하는 것에 주의하십시오.

9

8

7

6

5

\0

\0

\0

\0

\0

\0

\0

\0

\0

\0

\0

파란색: Integer 타입의 nCode
녹색: String 타입의 strPWD
흰색: NULL로 구성된 바이트 배열

패킷 수신

위에서 송신한 인증 요청에 대한 응답을 다음과 같은 형태로 받는다고 가정합니다.

[표: 인증 요청 패킷]

[C] 
typedef struct tag_BIND_ACK
{
int nCode;
char szPWD[16];
int nResult;
} BIND_ACK;

위와 같은 패킷을 수신하기 위해서는 다음과 같은 코드가 필요합니다.

[C#] 
public struct BindAck
{
public int nCode;
public string strPWD;
public int nResult;
}

public static BodyAck0 GetBindAck(byte[] btBuffer)
{
BindAck bindAck = new BindAck();

MemoryStream ms = new MemoryStream(btBuffer,false);
BinaryReader br = new BinaryReader(ms);

// Integer 타입의 nCode
bindAck.nCode = IPAddress.NetworkToHostOrder( br.ReadInt32() );

// 16 bytes의 문자열
bindAck.strPWD = ExtendedTrim( Encoding.Default.GetString(br.ReadBytes(16)) );

// Integer 타입의 nResult
bindAck.nResult = IPAddress.NetworkToHostOrder( br.ReadInt32() );

br.Close();
ms.Close();

return bindAck;
}

// 문자열 뒤쪽에 위치한 NULL을 제거한 후에 공백문자를 제거한다.
public string ExtendedTrim(string source)
{
string dest = source;

int index = dest.IndexOf('\0');
if( index > -1 )
{
dest = source.Substring(0,index+1);
}
return dest.TrimEnd('\0').Trim();
}

// 사용 예제
int BIND_ACK_SIZE = 4 + 16 + 4;

byte[] buffer = new byte[BIND_ACK_SIZE];

if( socket.Receive(buffer) == BIND_ACK_SIZE )
{
BindAck bindAck = GetBindAck(buffer);
}
else
{
// ERROR HANDLINGM
}

위에서 ExtendedTrim() 라는 함수를 정의해서 사용했습니다. 이 함수가 필요한 이유에 대해서는 다음 장에서 설명하겠습니다.


주요 함수

  1. System.Net.IPAddresss.NetworkToHostByteOrder(…)

    1. 요약

      네트워크 바이트 순서에서 호스트 바이트 순서로 숫자를 변환합니다. NetworkToHostByteOrder 함수는 오버로딩 되어 있어서 short, int, long 타입을 모두 변환할 수 있습니다.

    2. 설명

      서로 다른 컴퓨터에서는 멀티바이트 정수 값 내에서 바이트의 순서를 지정하는 데 서로 다른 규칙을 사용합니다. 일부 컴퓨터에서는 MSB(최상위 바이트)를 먼저 배치(big-endian 순서)하지만 다른 컴퓨터에서는 LSB(최하위 바이트)를 먼저 배치(little-endian 순서)합니다. 바이트 순서가 다르게 지정된 컴퓨터를 사용하려면 네트워크를 통해 보내는 모든 정수 값을 네트워크 바이트 순서로 보냅니다.

    3. 예제

      [C#] 
      public void NetworkToHostOrder_Long(long networkByte)
      {
      long hostByte;
      // Converts a long value from network byte order to host byte order. 
      hostByte = IPAddress.NetworkToHostOrder(networkByte);
      
      Console.WriteLine("Network byte order to Host byte order of {0} is {1}", networkByte, hostByte);
      }
  2. System.Net.IPAddresss.HostToNetworkByteOrder(…)

    1. 요약

      호스트 바이트 순서에서 네트워크 바이트 순서로 숫자를 변환합니다. HostToNetworkByteOrder 함수는 오버로딩 되어 있어서 short, int, long 타입을 모두 변환할 수 있습니다.

    2. 설명

      여기를 참고하십시오.

  3. System.Text.Encoding.Default.GetBytes(…)

    1. 요약

      지정된 String 이나 문자의 전부 또는 일부를 바이트 배열로 인코딩한 후, 패킷을 전송합니다. Encoding 타입은 Default 외에도 여러 가지가 있습니다.

    2. 인코딩 타입

      1. Default: 시스템의 현재 ANSI 코드 페이지에 대한 인코딩을 가져옵니다.

      2. ASCII: ASCII(7비트) 문자 집합에 대한 인코딩을 가져옵니다.

      3. BigEndianUnicode

        1. 요약

          big-endian 바이트 순서로 유니코드 형식에 대한 인코딩을 가져옵니다.

        2. 설명

          유니 코드 문자는 big-endian과 little-endian의 두 가지 바이트 순서로 저장할 수 있습니다. Intel 컴퓨터 같이 little-endian 플랫폼을 사용하는 컴퓨터에서는 일반적으로 유니코드 문자를 little-endian 순서로 저장하는 것이 보다 효율적이지만 대부분의 다른 플랫폼에서는 유니코드 문자를 big-endian 순서로 저장합니다.

          유니코드 파일은 big-endian 플랫폼에서는 16진수 0xFE 0xFF로 표시되고 little-endian 플랫폼에서는 16진수 0xFF 0xFE로 표시되는 바이트 순서 표시(U+FEFF)로 구별할 수 있습니다.

      4. Unicode: little-endian 바이트 순서로 유니코드 형식에 대한 인코딩을 가져옵니다.

      5. UTF7: UTF-7 형식에 대한 인코딩을 가져옵니다.

      6. UTF8: UTF-8 형식에 대한 인코딩을 가져옵니다.

    3. 예제

      [C#]
      string strPWD = "password";
      
      try
      {
      byte[] btPWD = new byte[16];
      Encoding.Default.GetBytes(strPWD,0,strPWD.Length,btPWD,0);
      }
      catch(Exception ex)
      {
      // Error Handling 
      }
  4. System.Text.Encoding.Default.GetString(…)

    1. 요약

      지정된 바이트 배열을 지정된 인코딩 타입의 문자열로 디코딩합니다.

    2. 인코딩 타입

      인코딩 타입을 참조하십시오.

    3. 예제

      다음 예제에서는 GetString의 오버로드된 버전에 대한 사용법을 보여 줍니다. 사용할 수 있는 다른 예제를 보려면 개별 오버로드 항목을 참조하십시오.

      [C#] 
      private string ReadAuthor(Stream binary_file) {
      System.Text.Encoding encoding = System.Text.Encoding.UTF8;
      // Read string from binary file with UTF8 encoding 
      byte[] buffer = new byte[30];
      binary_file.Read(buffer, 0, 30);
      return encoding.GetString(buffer);
      }
  5. 사용자 정의 함수 ExtendedTrim(…)

    1. 요약

      NULL 종료 문자열을 유니코드 기반의 System.String 으로 변환하기 위한 함수입니다. 종료문자 NULL 이후의 문자열을 제거합니다.

    2. 함수 정의

      // 문자열 뒤쪽에 위치한 NULL을 제거한 후에 공백문자를 제거한다.
      public string ExtendedTrim(string source)
      {
      string dest = source;
      
      int index = dest.IndexOf('\0');
      if( index > -1 )
      {
      dest = source.Substring(0,index+1);
      }
      return dest.TrimEnd('\0').Trim();
      }
    3. 설명

      아래와 같은 코드를 고려해보겠습니다.

      [C#] 
      string strPWD = "password";
      byte[] btPWD = new byte[16]; 
      
      try 
      { 
      Encoding.Default.GetBytes(strPWD,0,strPWD.Length,btPWD,0); 
      } 
      catch(Exception ex) 
      { 
      // Error Handling 
      }

      위의 코드가 실행되면 바이트 배열 btPWD의 구조는 다음과 같습니다.

      p

      a

      s

      s

      w

      o

      r

      d

      \0

      \0

      \0

      \0

      \0

      \0

      \0

      \0

      녹색: String 타입의 strPWD
      흰색: NULL로 구성된 바이트 배열

      바이트 배열 btPWD를 다시 문자열로 변환해보겠습니다.

      [C#] 
      string strPassword = Encoding.Default.GetString(btPWD);
      strPassword.Trim();
      Console.WriteLine(strPassword + "!");

      위의 코드에서 기대했던 출력값은 password!였습니다. 그러나 실제로 코드를 실행시켜보면 password !라고 출력됩니다. 클래스 System.String 에 정의된 Trim()함수는 문자열 양측에 놓인 공백문자는 제거하지만, NULL은 제거하지 않습니다.

      이 문제 때문에 여러 가지 기능이 예치기 않게 오작동할 수 있습니다. 특히 이벤트 로그에 NULL이 들어간 문자열을 기록하려고 시도하면, 문제가 발생합니다. 다음 예제에서 위에서 생성한 문자열을 이벤트 로그에 기록합니다.

      [C#] 
      // Create the source, if it does not already exist.
      if(!EventLog.SourceExists("MySource"))
      {
      EventLog.CreateEventSource("ySource", "MyNewLog");
      Console.WriteLine("CreatingEventSource");
      }
      
      // Create an EventLog instance and assign its source.
      EventLog myLog = new EventLog();
      myLog.Source = "MySource";
      
      // Write an informational entry to the event log. 
      string strLog = String.Format("{0}{1}{2}",strPassword,Environment.NewLine,strPassword);
      
      myLog.WriteEntry(strLog);

      위의 예제에서 기대했던 출력 값은 다음과 같습니다.

      password
      password

      그러나 실제로는 다음과 같은 출력을 얻게 됩니다.

      password

      문자열 strLog 중간에 NULL이 포함되어 있기 때문에, 뒤에 이어지는 문자열은 기록되지 않습니다. 일반적인 C/C++ 프로그래밍에서는 처음 문자열 뒤쪽에 위치한 NULL을 제외하고, 두 번째 문자열을 연결합니다. 다음의 예제 코드를 보십시오.

      [C++] 
      char szPassword[16];
      memset(szPassword,NULL,sizeof(szPassword));
      sprintf(szPassword,"%s","password");
      
      char szBuffer[128];
      memset(szBuffer,NULL,sizeof(szBuffer));
      sprintf(szBuffer,"%s%s",szPassword,szPassword);
      
      printf("%s",szBuffer);

      위의 코드에서 szPassword 두 개를 연결하여 szBuffer에 저장합니다. 이 경우에 다음과 같은 메모리 구조가 됩니다.

      p

      a

      s

      s

      w

      o

      r

      d

      p

      a

      s

      s

      w

      o

      r

      d

      \0

      \0

      \0

      \0

      \0

      \0

      녹색 : 첫번째 문자열
      파란색 : 두번째 문자열

      위에서 알 수 있듯이 일반적으로 C/C++ 프로그래밍에서는 NULL을 제외하고 두 문자열을 연결합니다. 그러나 닷넷에서 기본으로 제공되는 Trim() 함수는 NULL을 제외하지 않고 두 문자열을 연결합니다. 그 때문에 앞서 제시한 이벤트로그 예제에서는 다음과 같은 메모리 구조를 갖게 됩니다.

      p

      a

      s

      s

      w

      o

      r

      d

      \0

      \0

      p

      a

      s

      s

      w

      o

      r

      d

      \0

      녹색 : 첫번째 문자열
      파란색 : 두번째 문자열

데이터 타입

일반적으로 C/C++의 경우에 CPU의 종류에 따라 숫자형 데이터 타입의 크기가 달라집니다. 32 bits CPU 환경에서 Long Integer 타입은 4 bytes(32 bits)이지만, 64 bits CPU 환경에서는 8 bytes (64 bits)가 됩니다. 보통의 경우라면 닷넷 개발자는 32 bits환경의 이기종 서버/클라이언트와 통신하는 소프트웨어를 제작하게 됩니다. 이때 다음의 표를 참고하여 데이터 변환을 하도록 해야 합니다.

형식

범위

크기

.Net Framework 형식

Sbyte

-128 ~ 127

부호 있는 8비트 정수

System.SByte

Byte

0 ~ 255

부호 없는 8비트 정수

System.Byte

Char

U+0000 ~ U+ffff

유니코드 16비트 문자

System.Char

Short

-32,768 ~ 32,767

부호 있는 16비트 정수

System.Int16

Ushort

0 ~ 65,535

부호 없는 16비트 정수

System.UInt16

Int

-2,147,483,648 ~ 2,147,483,647

부호 있는 32비트 정수

System.Int32

Uint

0 ~ 4,294,967,295

부호 없는 32비트 정수

System.UInt32

Long

?9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807

부호 있는 64비트 정수

System.Int64

Ulong

0 ~ 18,446,744,073,709,551,615

부호 없는 64비트 정수

System.UInt64

예를 들어 32 bits CPU 환경일지라도 .Net Framework 하에서 Long Integer 타입의 크기는 8 bytes입니다.