10 분 소요

  • 수정될 필요가 없는 문자열 데이터는 const char*const wchar_t*로 관리하라.(배열이나 string, wstring을 쓰면 복제된다.)
  • 멀티 바이트 문자열은 권장하지 않는다. 사용하지 마라.
  • 소스 코드 저장시에는 다국어 처리에 적합하도록 UTF-8 인코딩로 저장하라.

모던 C++

개요

문자열 상수에서 언급한 것처럼 문자열은 const char*const wchar_t*문자열 상수가 있는 영역을 참조하거나, 배열을 이용하여 복사할 수 있습니다.

또한, STL 에서 string, wstring개체를 제공하여 문자열을 저장 및 관리 할 수 있습니다.(문자열 참고)

널종료 문자열

C언어에서는 문자열의 끝에 널문자(정수 0인 문자, '\0')를 사용합니다. 따라서, C스타일 문자열 함수들은 문자열의 끝이 널문자라고 가정하고 개발되었습니다. 예를 들어 문자열의 길이를 구하는 strlen()함수는 널문자를 만날때까지 문자들을 카운팅합니다.

1
2
3
4
5
6
7
8
9
size_t MyStrlen(const char * str) {
    size_t length = 0;
    while (*str != '\0') { // 널문자를 만날때까지 카운팅합니다.
        ++str;
        ++length;
    }
    return length;
}
EXPECT_TRUE(MyStrlen("abc") == 3);

따라서, 저장 공간은 널문자를 포함한 크기이며, 문자열의 길이는 널문자를 제외한 길이입니다.

1
2
3
4
5
6
7
8
char str[] = "abc"; // (O) {'a', `b`, 'c', '\0'};
EXPECT_TRUE(str[0] == 'a');
EXPECT_TRUE(str[1] == 'b');
EXPECT_TRUE(str[2] == 'c');
EXPECT_TRUE(str[3] == '\0'); // 널문자가 추가됨
EXPECT_TRUE(sizeof(str) == 4); // 널문자까지 포함하여 4byte 크기 입니다.
EXPECT_TRUE(sizeof(str) / sizeof(char) == 4); // 배열 갯수는 널문자를 포함하여 4 입니다.
EXPECT_TRUE(strlen(str) == 3); // 문자열의 길이는 널문자를 제외한 3입니다.

문자열 상수 수정

문자열 상수는 rodata 영역에 할당(데이터 세그먼트 참고)되기 때문에 수정할 수 없습니다. 따라서 포인터를 통해서 받은 문자열 상수를 수정하려 하면, 예외가 발생합니다. 하지만, 배열로 저장하면 복제본이므로 수정할 수 있습니다.

1
2
3
4
5
6
7
8
9
const char* str1 = "abc"; // 문자열 상수
char* temp = const_cast<char*>(str1);
// (X) 예외 발생. 문자열 상수는 rodata에 있기 때문에 수정할 수 없습니다.
*temp = 'd';

char str2[] = "abc"; // {'a', `b`, 'c', '\0'};
// (O) 배열은 문자열 상수의 복제본이어서 항목을 수정할 수 있습니다.
str2[0] = 'd';
EXPECT_TRUE(str2[0] == 'd');

image

문자열 상수의 내용을 수정할 필요가 없다면, 배열이나 string, wstring에 저장하지 마세요. 불필요하게 복제되어 복사 부하만 생깁니다.

아스키 코드

컴퓨터는 이진수로 처리되며, 문자열을 구성하는 문자들도 사실은 정수값에 매핑됩니다. 최초에는 아스키 코드(https://www.ascii-code.com/ASCII)를 사용했다가, 다양한 문자 처리를 위해 현재는 유니코드를 사용하고 있습니다.

아스키 코드는 기본적으로 1byte 크기이며, 영문자 a는 정수 0x61(97)이며, 영문자 b는 정수 0x62(98)이고, 영문자 c는 정수 0x63(99)입니다.

따라서, 문자열 "abc"는 메모리에 다음과 같이 저장됩니다.

image

확장 아스키 코드

아스키 코드는 0 ~ 127(0111 1111) 까지만 사용하기 때문에, 다양한 국가의 다양한 문자를 표현하기엔 부족합니다.

그래서 확장 아스키 코드로 128 ~ 255 영역(10000 0000 ~ 1111 1111)을 사용하는 방법이 도입되었는데요, 0 ~ 127 의 값이면 아스키 코드를 사용하고, 그 이상의 값이면 코드 페이지(437, ISO-8859-1, Windows-1250)에 따라 다른 기호들을 매핑합니다. 아스키 코드(https://www.ascii-code.com/ASCII)를 보면, 코드 페이지에 따라 128 ~ 255 영역이 서로 다른 기호로 매핑되는걸 확인할 수 있습니다.

국가별 코드 테이블(조합형 한글, 완성형 한글)

확장 아스키 코드를 사용하는 방법은 라틴 문자를 사용하는 곳에서는 어느 정도 사용할 수 있으나, 한중일의 문자(CJK라고 합니다.)를 표현하기엔 턱없이 부족합니다.

그래서 각 국가별로 자체적인 코드 테이블을 만들었고, 127 이상이면 추가 바이트를 사용합니다. 한글의 경우엔 2byte 크기의 코드 테이블과 매핑하며, 조합형 한글완성형 한글이 있습니다.

항목 명칭 내용
조합형 한글 KSSM 총 2byte(16bit)로 한글의 초성, 중성, 종성을 표현합니다. 완성형 한글과 표준화 경합을 했으나 표준화되지 못했습니다.
1bit : 최상위 비트가 1이면 한글이고, 0이면 영문입니다.
5bit : 초성
5bit : 중성
5bit : 종성
완성형 한글 KS X 1001(KSC-5601) 1987년에 표준화 되었으며, 한글 2,350자를 2byte로 정의합니다. 일부 한글을 표현하지 못하는 문제가 있습니다.

유니코드

국가별 코드 테이블을 사용하면, 해당 국가의 문자를 표현할 수는 있으나, 여러 국가의 문자를 함께 표현할 수는 없습니다. 그래서, 전 세계의 모든 문자에 고유 숫자를 부여한 유니코드를 만들었는데요, 이것도 점진적으로 추가되고, 하위 호환을 유지하다보니 처리 방식이 좀 복잡합니다.

유니코드는 기본적으로 2byte를 사용하도록 했으나, 아시아권 문자를 포함하다 보니 3byte가 필요하게 되었고, 다양한 추가 문자들을 지원하다 보니 4byte가 필요(악보 기호, 이모지등 특수 기호 지원)하게 되었습니다. 이러다 보니 유니코드는 2byte다 4byte다 혼선이 있는데, 결과적으로는 2byte 이상이다가 맞겠습니다.

유니코드에서는 현대 한글의 조합 가능한 모든 문자 11,172개를 2byte로 표현하며 U+AC00 ~ U+D7A3에 할당해 두었습니다.(유니코드U+16진수의 형태로 표기합니다. 예를 들어 한글 “가”는 AC00(10진수의 44032)인데, U+AC00으로 표기합니다.)

항목 명칭 내용
유니코드 한글 ISO/IEC 10646 1996년 2.0이 제정 되었으며, 완성형 한글에서 표현 못하는 문자들을 포함하여 한글 11,172자를 2byte로 정의합니다.

인코딩

사용하는 코드가 조합형 한글인지, 완성형 한글인지, 유니코드인지에 따라 동일한 문자라도 서로 다른 코드값을 갖게 됩니다.

다음은 한글 , , 의 코드 테이블 값입니다. 모두 서로 다르죠.

코드 테이블 설명
조합형 한글(KSSM) 0x8861 0x9061 0x9461 이젠 거의 쓰이지 않습니다.
완성형 한글(KS X 1001) 0xBOA1 0xB3AA 0xB4D9 euc-kr, cp-949인코딩에서 사용합니다.
유니코드 U+AC00 U+B098 U+B2E4 UTF-8 인코딩, UTF-16 인코딩, UTF-32 인코딩에서 사용합니다.

따라서 완성형 한글로 저장된 문자를 유니코드로 읽거나, 유니코드로 저장된 문자를 완성형으로 읽으려면 서로 코드 변환을 해야 합니다.(한글 코드간의 변환은 charset.fandom.com/을 참고하시기 바랍니다.)

또한, 유니코드를 최종본(유니코드 로드맵 참고)인 4byte로 표현하기엔 메모리 낭비가 심하므로 UTF-8 인코딩, UTF-16 인코딩, UTF-32 인코딩 의 3가지 인코딩 방식을 사용하고 있습니다.

euc-kr 인코딩

euc-kr 인코딩을 사용하면, 한글은 KS X 1001을 사용하고, 영문은 KS X 1003(KSC-5636. 아스키 코드에서 역슬래쉬(\)를 원()으로 대체했습니다.)으로 처리합니다.

따라서 가나다를 저장하면 다음과 같이 저장됩니다.

image

cp-949 인코딩

Microsoft에서 사용하는 확장 완성형으로서 euc-kr 인코딩의 확장형입니다. KS X 1001에서 표현하지 못한 한글 8822자를 추가했습니다.

UTF-8 인코딩

웹의 기본 인코딩 방식이며, 문자마다 다른 크기로 저장합니다. 이에 따라 문자 데이터 앞에 크기 정보가 필요하며, 첫째 바이트의 상위 비트가 0이면 남은 7비트에 데이터를 저장하고, 이진수 110이면 2byte, 이진수 1110이면 3byte, 이진수 11110이면 4byte에 데이터를 분산해서 저장합니다. 이때 추가 byte의 상위 비트는 이진수 10으로 마킹합니다.

항목 내용
영어 및 기호 1byte
추가 라틴 및 중동 2byte
한글 및 아시아 3byte(한글은 코드값은 2byte이지만, 크기 정보를 포함하면 3byte가 필요합니다.)
추가 문자 4byte

예를 들어 한글 (U+AC00)는 다음과 같이 저장됩니다.

image

따라서 가나다를 저장하면 다음과 같이 저장됩니다.

image

UTF-16 인코딩

JAVA 에서 기본으로 사용하며, BMP(Basic Multilingual Plane)라 불리는 기본적인 문자들은 2byte로 처리(한글은 2byte입니다.)하고, 2byte로 표현할 수 없는 확장된 것들은 4byte로 처리합니다. 이렇게 확장된 영역을 서로게이트(Surrogate) 영역이라 하며 상위 2byte를 상위 서로게이트라 하고, 하위 2byte를 하위 서로게이트라고 합니다.

BMP 에서는 0xD800(1101 0000 0000 0000) ~ 0xDFFF(1101 1111 1111 1111 1111)을 사용하지 않으며, 이에 따라 서로게이트를 표현할때 상기 범위의 값으로 변환하여 처리합니다.

예를 들어 2byte로 표현할 수 없는 𐐷(U+10437)의 변환 방법은 다음과 같습니다.

  1. 상위 1byte에서 하위 5bit에서 1을 빼서 4bit로 만듭니다.
  2. 상위 서로게이트 영역을 이진수 1101 10, 하위 서로게이트 영역을 이진수 1101 11로 마킹하고 데이터를 채웁니다.

image

따라서 𐐷UTF-16 인코딩으로 저장하면 0xD801DC37 이 되는데요, 빅 엔디안(낮은 주소에 상위 바이트를 저장)이냐 리틀 엔디안(낮은 주소에 하위 바이트 저장)이냐에 따라 다음과 같이 저장됩니다.

  • 빅 엔디안(UTF-16 BE) : 0xD801DC37
  • 리틀 엔디안(UTF-16 LE) : 0x01D837DC

UTF-32 인코딩

모든 문자를 4byte로 처리합니다. 메모리 낭비가 심하여 잘 사용하지 않습니다.

소스 코드와 인코딩

소스 코드가 어떤 인코딩 방식을 사용하느냐에 따라 문자열 데이터의 값은 완전히 달라집니다.

다음 예제는 const char* str = "가";UTF-8euc-kr로 저장했을때의 차이입니다. 문자열의 길이가 서로 다르며, 코드값도 다릅니다.

UTF-8 인코딩으로 저장한 경우

1
2
3
4
5
6
const char* str = "가"; // UTF-8 가[0xEA 0xB0 0x80] 가 저장된 곳을 가리키는 포인터 입니다.

EXPECT_TRUE(strlen(str) == 3); // UTF-8에서 한글 1글자는 3byte 입니다.
EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 0) == 0xEA);
EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 1) == 0xB0);
EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 2) == 0x80);

euc-kr 또는 cp-949 인코딩으로 저장한 경우

1
2
3
4
5
const char* str = "가"; // 완성형 가[0xB0 0xA1] 가 저장된 곳을 가리키는 포인터 입니다.

EXPECT_TRUE(strlen(str) == 2); // euc-kr에서 한글 1글자는 2byte 입니다.
EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 0) == 0xB0);
EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 1) == 0xA1);

따라서 소스 코드의 인코딩도 잘 결정해서 사용해야 하는데요, euc-kr이나 Windows의 cp-949유니코드와의 호환성이 없기에 다국어 처리에 적합하지 않습니다. 소스 코드는 UTF-8로 저장하시길 추천합니다.

인코딩과 문자열 상수

문자열 상수\x, \u와 같은 이스케이프 문자를 이용하여 코드값을 직접 기재하여 사용할 수도 있는데요,

"abc가나다"UTF-8 코드값으로 기재하면 다음과 같습니다.

1
"\x61\x62\x63\xEA\xB0\x80\xEB\x82\x98\xEB\x8B\xA4" // abc가나다의 UTF-8 코드값입니다.

또한 유니코드로 기재하면 다음과 같습니다.

1
"\u0061\u0062\u0063\uAC00\uB098\uB2E4" // abc가나다를 유니코드로 작성했습니다.

따라서 UTF-8 인코딩으로 소스코드를 저장한 경우 "abc가나다"는 다음과 동일합니다.(문자열 상수의 메모리 주소가 동일합니다. 데이터 세그먼트 참고)

1
2
EXPECT_TRUE("abc가나다" == "\x61\x62\x63\xEA\xB0\x80\xEB\x82\x98\xEB\x8B\xA4"); // UTF-8로 저장하면, UTF-8 인코딩 데이터와 동일합니다.
EXPECT_TRUE("abc가나다" == "\u0061\u0062\u0063\uAC00\uB098\uB2E4"); // 유니코드로 작성해도 동일합니다.  

바이트 문자열

영문자는 0 ~ 127 까지의 7bit 만으로 표현이 충분하기 때문에 1byte만으로도 저장할 수 있습니다. 이렇게 1byte 단위로 문자를 저장하는 문자열을 바이트 문자열 이라 합니다.

C++에서 기본적으로 사용하는 처리방식이며, 영문자만 처리됩니다.

1
2
3
4
5
6
7
const char* str = "abc"; // 0x61 0x62 0x63 가 저장된 영역을 가리키는 포인터 입니다.

EXPECT_TRUE(strlen(str) == 3); // UTF-8에서 영문 3글자는 3byte입니다.
EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 0) == 0x61); // 0x61. 아스키 코드 a
EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 1) == 0x62); // 0x62. 아스키 코드 b
EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 2) == 0x63); // 0x63. 아스키 코드 c
EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 3) == 0x00); // 널문자

멀티 바이트 문자열

한글등 다양한 국가의 문자들은 1byte로 처리할 수 없으며, 파일의 인코딩에 따라 2byte이상의 코드값이 필요합니다. 이처럼 1byte외 여러 byte를 혼용하는 문자열을 멀티 바이트 문자열 이라고 합니다. Microsoft에서 초창기에 만들어 사용했지만, 표준화 되지 않았고, 현재는 비권고 되고 있습니다.

다음은 UTF-8인코딩된 파일에서 멀티 바이트 문자열을 사용하는 예입니다.

  1. const char* str = "abc가나다";UTF-8인코딩 되어 abc[0x61 0x62 0x63] 가[0xEA 0xB0 0x80] 나[0xEB 0x82 0x98] 다[0xEB 0x8B 0xA4]가 저장되어 있습니다.
  2. 바이트 문자열strlen()함수를 사용하면 아무 생각없이 널문자까지 카운트하므로, 12가 됩니다.
  3. locale()함수를 호출하여 멀티 바이트 함수 호출전에 인코딩 정보를 전달합니다.
  4. mblen()함수를 이용하여 해당 주소의 문자가 몇 바이트 크기인지 구합니다.
  5. mbstowcs()함수를 이용하여 해당 주소의 문자들의 코드를 유니코드로 변환하여 wstr에 저장합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const char* str = "abc가나다"; // abc[0x61 0x62 0x63] 가[0xEA 0xB0 0x80] 나[0xEB 0x82 0x98] 다[0xEB 0x8B 0xA4] 가 저장된 영역을 가리키는 포인터 입니다.
EXPECT_TRUE(strlen(str) == 12); // UTF-8에서 한글 1글자는 12byte입니다. a(1) + b(1) + c(1) + 가(3) + 나(3) + 다(3) 1 + 1 + 1 + 3 + 3 + 3 = 12 

EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 0) == 0x61); // 0x61. 아스키 코드 a
EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 1) == 0x62); // 0x62. 아스키 코드 b
EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 2) == 0x63); // 0x63. 아스키 코드 c

EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 3) == 0xEA); // 가
EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 4) == 0xB0); 
EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 5) == 0x80); 

EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 6) == 0xEB); // 나
EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 7) == 0x82); 
EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 8) == 0x98); 

EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 9) == 0xEB); // 다
EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 10) == 0x8B); 
EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 11) == 0xA4); 

EXPECT_TRUE(*reinterpret_cast<const unsigned char*>(str + 12) == 0x00); // 널문자  

std::setlocale(LC_ALL, "en_US.utf8");
EXPECT_TRUE(mblen(str + 0, MB_CUR_MAX) == 1); // a 문자는 1byte 크기임
EXPECT_TRUE(mblen(str + 1, MB_CUR_MAX) == 1); // b 문자는 1byte 크기임
EXPECT_TRUE(mblen(str + 2, MB_CUR_MAX) == 1); // c 문자는 1byte 크기임
EXPECT_TRUE(mblen(str + 3, MB_CUR_MAX) == 3); // 가 문자는 3byte 크기임
EXPECT_TRUE(mblen(str + 6, MB_CUR_MAX) == 3); // 나 문자는 3byte 크기임
EXPECT_TRUE(mblen(str + 9, MB_CUR_MAX) == 3); // 다 문자는 3byte 크기임

wchar_t wstr[7];
std::mbstowcs(wstr, str, 7); // UTF-8로 저장되어 있는 멀티 바이트 문자열을 디코딩하여 문자 1개씩 유니코드로 변경하여 와이드 문자열로 저장합니다.

EXPECT_TRUE(wstr[0] == 0x0061); // 0x0061. 아스키 코드 a
EXPECT_TRUE(wstr[1] == 0x0062); // 0x0062. 아스키 코드 b
EXPECT_TRUE(wstr[2] == 0x0063); // 0x0063. 아스키 코드 c
EXPECT_TRUE(wstr[3] == 0xAC00); // 0xAC00. 유니코드 가
EXPECT_TRUE(wstr[4] == 0xB098); // 0xB098. 유니코드 나
EXPECT_TRUE(wstr[5] == 0xB2E4); // 0xB2E4. 유니코드 다
EXPECT_TRUE(wstr[6] == 0x0000); // 널문자

와이드 문자열

와이드 문자열은 영문자이건, 다국어 문자이건 모두 wchar_t로 관리하는 문자열입니다. 안타깝게도 Windows 에서는 2byte이고 리눅스에서는 4byte 이기 때문에 운영체제에 따라 다르게 동작할 수 있어 주의해야 합니다.(기본 타입 참고)

와이드 문자열의 코드값은 OS에 따라 UTF-16 인코딩이나 UTF-32 인코딩으로 저장됩니다.

항목 wchar_t 크기 인코딩
Windows 2byte UTF-16 인코딩
Windows 외 운영체제 4byte UTF-32 인코딩

한글의 경우는 UTF-16 인코딩의 BMP(Basic Multilingual Plane) 영역이므로 서로게이트(Surrogate) 처리없이 간단하게 사용할 수 있습니다.

다음은 Windows 에서 UTF-8 인코딩로 저장한 소스 코드의 실행예입니다.

메모리에 UTF-16 인코딩으로 저장되어 있으며, wchar_t 단위로 읽었을때 유니코드값이 잘 저장되어 있습니다.

1
2
3
4
5
6
7
8
9
10
const wchar_t* wstr = L"abc가나다"; // Windows는 UTF-16. abc[0x61 0x62 0x63] 가[0xAC00] 나[0xB098] 다[0xB2E4] 가 저장된 영역을 가리키는 포인터 입니다.
EXPECT_TRUE(wcslen(wstr) == 6); // Windows에서는 2byte 단위로 저장합니다.

EXPECT_TRUE(wstr[0] == 0x0061); // 0x0061. UTF-16 인코딩. 아스키 코드 a
EXPECT_TRUE(wstr[1] == 0x0062); // 0x0062. UTF-16 인코딩. 아스키 코드 b
EXPECT_TRUE(wstr[2] == 0x0063); // 0x0063. UTF-16 인코딩. 아스키 코드 c
EXPECT_TRUE(wstr[3] == 0xAC00); // 0xAC00. UTF-16 인코딩. 유니코드 가
EXPECT_TRUE(wstr[4] == 0xB098); // 0xB098. UTF-16 인코딩. 유니코드 나
EXPECT_TRUE(wstr[5] == 0xB2E4); // 0xB2E4. UTF-16 인코딩. 유니코드 다
EXPECT_TRUE(wstr[6] == 0x0000); // 널문자   

댓글남기기