C# string에 대한 3가지 오해

[제목] C# string에 대한 3가지 오해

C#에서 한 문자는 항상 2바이트를 차지하는가? C# string 객체는 코드페이지에 따라 다른 인코딩을 가질 수 있는가? 하나의 C# string 변수는 동일한 메모리에 여러 번 다른 문자열을 가질 수 있는가?

오해 1. C#에서 한 문자는 메모리상에서 항상 2바이트를 차지한다.

C#의 string은 모든 문자들을 유니코드로 저장한다. 좀 더 정확히 말하면 문자들을 UTF-16으로 인코딩하여 저장한다. 유니코드는 본래 1바이트를 차지하는 영어권 ASCII 문자들의 한계를 극복하고, 전세계 모든 문자들을 포함하기 위해 2바이트 안에 각 문자별로 하나의 Code Point를 갖도록 한 것이다. 이러한 초기의 유니코드는 UCS-2 (Universal Character Set)이라 불리었는데, 이 UCS-2는 곧이어 65536 문자의 한계에 부딪히게 되었다. 아주 드물게 쓰이는 문자들까지 따지면 65536 문자를 훨씬 넘는 것이었다. 다행히 UCS-2의 U+0000 ~ U+FFFF 중 U+D800 ~ U+DFFF 영역은 어떤 문자도 할당되지 않았었는데, 이를 이용해서 4바이트 Unicode를 만들 수 있었다. UCS-2의 2바이트 유니코드는 기본적인 자주 사용되는 문자들로서 BMP (Basic Multilingual Plane)이라 불렀고, UTF-16은 2바이트의 BMP를 기본적으로 지원하면서 또한 나머지 Rare 문자들을 Lead (U+D800 ~ U+DFFF) + Trail 형태로 4바이트로 표현하였다 (이를 Surrogate Pair라 부른다). 따라서, C#의 string 문자열은 대부분 2바이트를 차지하지만 특별히 Rare 문자를 표현할 경우 4바이트를 차지할 수 있다. 이를 확인해 보기 위해 다음과 같은 샘플 코드를 살펴보자.

// 한글
string ko = "가각";

// Surrogate pairs 2A601 = U+D869 + U+DE01 => 𪘁
byte[] sp = new byte[] { 0x69, 0xD8, 0x01, 0xDE };
string spStr = Encoding.Unicode.GetString(sp);

위의 변수 ko 에 들어간 문자가 어떻게 Heap 상에 표현되는지 보기 위해 해당 string 객체를 살펴보면, 아래와 같이 ac00 ac01 이 들어 있음을 알 수 있다. 이는 [가각] 이라는 문자열의 유니코드 코드 포인트이다.

0:004> .load sos
0:004> ~0s
0:000> !clrstack -a
CSString.Program.Main(System.String[]) [d:\TestProjects\CSString\Program.cs @ 48]
    PARAMETERS:
        args (0x0035ed0c) = 0x025e22a8
    LOCALS:
        0x0035ed04 = 0x025e22b8
        0x0035ed00 = 0x025e3c7c
        0x0035ecfc = 0x026054f8  <== 

0:000> !do 0x026054f8
Name:        System.String
MethodTable: 56ab224c
EEClass:     566e3444
Size:        18(0x12) bytes
File:        C:\Windows\Microsoft.Net\assembly\GAC_32\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
String:      가각  <==
Fields:
      MT    Field   Offset                 Type VT     Attr    Value Name
56ab3aa8  40000aa        4         System.Int32  1 instance        2 m_stringLength
56ab2c44  40000ab        8          System.Char  1 instance     ac00 m_firstChar  <==
56ab224c  40000ac        c        System.String  0   shared   static Empty
    >> Domain:Value  004985c0:NotInit  <<

0:000> dw 0x026054f8 + 8
02605500  ac00 ac01 0000 0000 0000 0000 8428 56ab <==
02605510  0000 0000 0000 0000 0000 0000 0000 0000


* 한글 유니코드 Code Point

유니코드 한글 코드 포인트 예

그러면 Surrogate Pair 문자는 어떠한가? 마찬가지로 해당 string 객체를 찾아 보면 한 중국 문자에 대해 4바이트 (d869 de01) 가 들어 있음을 알 수 있다. 이것은 UTF-16 인코딩의 결과이다.

0:000> !do 0x02542378
Name:        System.String
MethodTable: 626f224c
EEClass:     62323444
Size:        18(0x12) bytes
File:        C:\Windows\Microsoft.Net\assembly\GAC_32\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
String:      𪘁  <==
Fields:
      MT    Field   Offset                 Type VT     Attr    Value Name
626f3aa8  40000aa        4         System.Int32  1 instance        2 m_stringLength
626f2c44  40000ab        8          System.Char  1 instance     d869 m_firstChar  <==
626f224c  40000ac        c        System.String  0   shared   static Empty
    >> Domain:Value  007b80c8:NotInit  <<
0:000> dw 0x02542378+8
02542380  d869 de01 0000 0000 0000 0000 8428 626f  <==
02542390  0000 0000 0000 0000 0000 0000 0000 0000


2. C# string 객체는 Codepage에 따라 다른 인코딩을 가질 수 있다.

텍스트 Encoding은 문자를 바이트로 변환하는 과정이고, 반대로 Decoding은 바이트를 문자로 변환하는 과정이다. System.Text.Encoding 클래스는 몇 개의 기본적인 static 인코딩 Property들을 갖고 있다: ASCII, UTF7, UTF8, Unicode, UTF32 등. 이 중 C# string (즉, NET System.String)은 Encoding.Unicode 인코딩을 사용하고 있다. Encoding.Unicode 인코딩은 UTF-16 인코딩과 동일한 것으로, 위에서 언급했듯이 2바이트 혹은 4바이트로 문자들을 인코딩한다. (주: 위의 UTF* 들은 포괄하여 Unicode Encoding이라 불리우며, 유니코드를 표현한다는 점은 같지만 다른 바이트 표현을 갖는다)

동일한 문자는 인코딩하는 방법에 따라 다른 바이트들을 갖는다. 예를 들어, 문자 A를 ASCII 혹은 UTF8으로 인코딩하면, 메모리에 41 이라고 표현되지만, 이를 UTF-16 인코딩으로 하면 41 00 (U+0041)이 되고, UTF-32로 하면 41 00 00 00 이 된다.

이러한 인코딩 방식중 UTF-16과 더불어 가장 많이 쓰이는 방식은 UTF8 인코딩인데, 이 UTF8은 ASCII 문자들(U+0000 ~ U+007F)은 1바이트로, U+0080 ~ U+07FF는 2바이트, U+0800 ~ U+FFFF는 3바이트, 그리고 그 이상(U+10000 ~ )은 4바이트로 표현한다. 이 방식은 영어권에서는 바이트 수를 줄여준다는 점에서 잇점이 있지만, CJK (중국,일본,한국) 문자들에 대해서는 대개 3바이트가 할당되어 더 불리하다.

// 한글 UTF16
string ko = "가각";
byte[] bytes = Encoding.Unicode.GetBytes(ko.ToCharArray());
PrintBytes(bytes); // 00 AC 01 AC (endian!)

// 한글 UTF8
bytes = Encoding.UTF8.GetBytes(ko.ToCharArray());
PrintBytes(bytes); // EA B0 80 EA B0 81 

// 영문 UTF16
string en = "ABC";
bytes = Encoding.Unicode.GetBytes(en.ToCharArray());
PrintBytes(bytes); // 41 00 42 00 43 00

// 영문 UTF8
bytes = Encoding.UTF8.GetBytes(en.ToCharArray());
PrintBytes(bytes); // 41 42 43 

//...
static void PrintBytes(byte[] bytes)
{
	foreach (byte b in bytes)
	{
		string s = string.Format("{0:X2} ", b);
		Debug.Write(s);
	}
	Debug.WriteLine(string.Empty);
}

System.Text.Encoding 클래스는 위의 기본 인코딩 방법외에 유니코드 이전의 인코딩 방식 즉 코드페이지(Code page)를 지원한다. 유니코드 이전에는 1바이트로 혹은 1바이트 2~3개를 묶어 각 국 문자를 표현하려 하였는데, 제한된 바이트들에 서로 다른 문자들을 넣다보니 당연히 겹치게 되고 Unique한 Code point를 가질 수 없었다. 따라서, 각 언어마다 Code Page를 지정하고 바이트들에 대한 문자 매핑을 이 코드페이지에 따라 다르게 하도록 하였다. 따라서 동일한 바이트라도 어떤 코드페이지로 해석되는가에 따라 전혀 다른 문자로 인식되게 된다.

예를 들어 아래 예제에서 보면, (1) 에서는 먼저 유니코드인 한글 문자열 '가각' 을 949 코드페이지(Korean)를 사용하여 바이트배열로 변환한다. (변환 과정을 간단히 보면 '가각'이라는 먼저 유니코드 코드 포인트 (U+AC00 U+AC01)에 상응하는 문자를 코드페이지 949에서 찾게 되고 상응하는 코드포인트 (B0A1 B0A2)를 바이트 배열에 쓰게 된다)
(2) cp949로 인코딩된 바이트를 다시 cp949로 디코딩한후 해당 문자에 상응하는 유니코드 문자를 리턴한다. (실제로는 디코딩없이 CP949와 Unicode간의 매핑테이블을 사용하겠지만, 개념적인 설명임)
마지막 (3)은 cp949로 인코딩된 바이트들을 일본어 인코딩을 사용하여 해석해 본 것으로 결과적으로 다른 문자를 리턴하게 됨을 알 수 있다.

// (1) C# string 유니코드를 cp949로 변경한 후 바이트배열에 쓰기
string ko = "가각";
bytes = Encoding.GetEncoding("ks_c_5601-1987").GetBytes(ko.ToCharArray());
PrintBytes(bytes); // B0 A1 B0 A2 <== cp949

// (2) cp949로 인코딩된 바이트들을 949에서 유니코드로 변경
str = Encoding.GetEncoding("ks_c_5601-1987").GetString(bytes);
Debug.WriteLine(str); // 가각

// (3) cp949로 인코딩된 바이트들을 Japanese 인코딩을 사용하여 유니코드로
str = Encoding.GetEncoding("euc-jp").GetString(bytes);
Debug.WriteLine(str); // 亜唖

그런데 위의 (2)에서 한가지 주목할 점은 GetEncoding().GetString()을 호출하면 (모든 중간 변환을 마치고) 항상 유니코드 문자열을 리턴한다는 것이다. 즉, string 변수는 항상 Unicode에 상응하는 코드포인트를 메모리상에 갖고 있다. 그리고 코드페이지의 코드포인트는 항상 바이트 상에만 존재한다. 따라서, 다른 Code Page의 인코딩을 사용하여 C# string 변수에 대입했다고 해서 해당 인코딩에 상응하는(?) 문자열이 할당되는 것은 아니다.

그렇다면 만약 C#의 모든 문자열이 유니코드라면, 도대체 언제 Code Page 인코딩을 사용하게 되는가? 그것은 데이타가 C# 프로그램 외부에서 올 경우이다. 예를 들어 949 코드페이지를 사용하는 데이타베이스에서 데이타를 직접 바이트 레벨에서 가져오거나, 특정 코드페이지로 인코딩된 파일을 읽어 오거나 혹은 특정 인코딩을 사용하는 네트워크 데이타을 사용할 경우 등등이 있을 수 있다.

3. 하나의 C# string 변수는 동일한 메모리에 여러 번 다른 문자열을 가질 수 있다.

아래 코드에서 문자열 변수 s 는 처음 빈문자열을 할당하였고, 다음 1 부터 100 까지 서로 다른 숫자를 갖게 된다. 그리고 마지막에는 100 이라는 문자열을 출력한다. 여기서 라인6 의 변수 s는  라인1의 변수 s 와 동일한 객체를 가리키는가?

string s = "";
for (int i = 1; i <= 100; i++)
{
    s = i.ToString();
}
Console.WriteLine(s);

비록 이들이 동일한 변수명 s 를 사용하고 있지만 이들은 전혀 다른 객체이다. C#의 string은 Immutable 객체 즉 한번 그 값이 설정되면 다시는 그 값을 변경할 수 없는 객체이다. 만약 한번 문자가 설정된 변수에 다른 문자열을 할당하면 .NET은 새로운 string 객체를 생성하고 새 값을 할당한 후 그 객체의 레퍼런스를 변수에게 리턴한다.

위 코드의 경우 101개의 string 객체가 생성된다. 그렇다면 변수 s 가 가리키는 마지막 string 객체 이외의 나머지 100개의 객체는 어떻게 엑세스할 수 있을까? 불행히도 이들은 엑세스할 수 없다... 누구도 이들의 레퍼런스를 갖고 있지 않기 때문인데, 이들은 다음 Garbage Collection 때 소멸될 것이다.



본 웹사이트는 광고를 포함하고 있습니다. 광고 클릭에서 발생하는 수익금은 모두 웹사이트 서버의 유지 및 관리, 그리고 기술 콘텐츠 향상을 위해 쓰여집니다.