본문 바로가기
IT/Programming

C 포인터, 확실히 알자(3) - 문자열과 포인터

by Jany 2009. 3. 31.
반응형

서론

C 언어에서는 기본적인 자료형인 배열(Array)과 문자열(Stirng)을 다루는 것 조차도 포인터를 이해하지 않으면 안될만큼, 언어차원에서 포인터의 개념이 뿌리깊게 자리잡고 있습니다. 때문에 포인터를 이해하는 것은 곧 C 언어 그 자체를 이해하는 것과도 통하는 것이 있습니다. 이때문에 C가 어렵게 느껴지는 것이기도 하겠지요.

제 나름대로 C를 쉽게 정의 내려본다면, "왠만한건 다 포인터로 처리한다" 라고 말하겠습니다. 정말 C에서는 포인터로 못하게는게 거의 없습니다. 물론 그만큼 강력하지만 잘못다루면 심각한 에러를 만들어 낼 수 있고, 또 C를 이해하기 어렵게 만드는 요인이기도 합니다.

그 때문에 JAVA나 C# 같은 언어에서는 C++을 확장한 언어임에도 불구하고 포인터라는 것을 과감하게 없애버렸습니다. 하지만 그래도 내부적으로는 레퍼런스(참조)라는 개념으로써 포인터의 개념은 여전히 살아있습니다.

따라서 C를 학습하는 것, 특히 포인터의 개념을 이해하는 것은 지금 시대에서도 필요할 뿐만 아니라. JAVA나 C#, 그외 다른 언어를 학습할 때도 도움이 될 수 있습니다.

말이 좀 길어졌군요. 이만 각설하고 본론으로 들어가겠습니다. 이번 주제는 문자열에 대한 겁니다.


1. "문자열 타입"이란 것은 존재하지 않는다

C언어에서 문자열 리터럴(상수)은 존재하지만, 문자열 타입이라는 것은 없습니다. 때문에 C에서 문자열을 다루는 변수를 선언하려면 문자(char)의 배열로서 선언해야 합니다.

#include <stdio.h>

int main()
{
 char strVar[10] = "abcdef";
 puts(strVar);

 return 0;
}

그런데 이렇게 문자열을 배열에 집어넣는 것 까지는 좋았습니다만, 한가지 문제는 어디까지가 문자열의 끝인지를 판단해야 한다는 것입니다.

위 예제에서는 배열 크기를 10으로 잡았지만, 그렇다고 항상 10개가 문자가 들어있는건 아니지요. 9개가 있을 수도 있고, 5개만 있을 수도 있습니다. 그러면 그 배열에 문자열이 몇개가 들어 있는지 확인 할 수 있는 방법이 필요하겠군요.

그래서 C에서는 문자열의 마지막에 NULL(0) 값을 덧붙이는 방식을 사용합니다. 문자열 리터럴의 경우는 자동으로 문자열 끝에 NULL값을 추가합니다.
이렇게 문자열의 끝에 NULL값이 추가로 들어가기 때문에, 실제 문자 배열에 집어넣을 수 있는 문자수는 배열의 크기보다 하나가 더 적습니다. 즉, 크기가 10인 문자 배열은 최대 9개의 문자가 들어갈 수 있습니다.

C++에서는 라이브러리를 통해 문자열 타입(클레스)이 추가되었습니다. 하지만 C와의 호환성 때문인지, C++에서도 문자열 리터럴은 C와 동일하게 처리됩니다.

즉 C++에서 문자열 리터럴은 객체가 될 수 없기 때문에 JAVA나 C#에서처럼 문자열 연결을 다음과 같이 쓸 수는 없습니다. (수정: (char*)형 문자열을 인수로 받는 생성자를 String 클레스에서 구현하고, + 연산자를 전역에서 재정의하면 가능하다고 합니다.)

 strBuff = "abcd" + strVar;

하지만 재미있게도 반대로는 가능합니다.

 strBuff = strVar + "abcd";

문자열 클레스에서 + 연산자를 오버로딩하면 위의 표현은 가능하죠. 하지만 순서를 바꾸게 되면 문자열 리터럴은 + 연잔자의 의미를 받아들이지 못하기 때문에 에러가 납니다.

<조엘 온 소프트웨어>에서는 이것을 "허술한 추상화"의 한 예로써 꼬집었습니다.
(수정: 조엘 씨가 말한것은 "abc" + "def" 같은 것이 불가능하다는 거였습니다.)

cf. 조엘 온 소프트웨어/조엘 스폴스키/에이콘出 - 26장


2. 문자 포인터와 문자 배열

지난 강좌(배열과 포인터)에서 배열은 포인터로 대체할 수 있음을 알았습니다. 그렇다면, '문자의 배열'도 마찬가지로 '문자의 포인터'로 대신 사용할 수 있겠죠?

#include <stdio.h>

int main()
{
 char *strVar = "abcdef";
 puts(strVar);

 return 0;
}

이렇게 해도 앞의 예제와 똑같이 동작합니다.

하지만 그렇다고 문자 배열과 문자 포인터가 완전히 동일한 건 아닙니다. 다음 예제를 돌려보면 재미있는 것을 볼 수 있습니다.

#include <stdio.h>

int main()
{
 char *strA;
 char strB[10];
 
 strA = "abcdef"
 strB = "abcdef"

 return 0;
}

언뜻 보기에는 문제가 없는 것 같지만, 컴파일을 하면 바로 에러가 나옵니다. 여기서 에러가 어디에서 발생했는가에 주목할 필요가 있습니다.

바로 이 부분 → strB = "abcdef";

문자 배열로 선선한 변수에 문자열 리터럴을 바로 대입시키려고 하면 에러가 뜹니다. 변수 선언시에 초기값으로 바로 대입하면 에러가 안뜨는데, 이처럼 선언 이후에 대입 시키려고 하면 에러가 됩니다.

왜 그럴까요?
포인터 변수에 대입하는 것은 어째서 문제가 없는 걸까요?

알고보면 간단합니다. C에서 " "로 둘러쌓인 문자열 리터럴의 타입이 바로 char* 입니다. 즉 문자열 리터럴 자체가 문자의 포인터로 구현되어 있는 것입니다. 따라서 같은 포인터 타입 변수인 strA에 대해서는 바로 대입이 가능한 겁니다.

그런데 배열 변수에 포인터 값을 할당하는 것은 불가능합니다. 이전 강좌(배열과 포인터)에서도 살펴봤지만, 배열은 주소값을 변경할 수 없는 상수형 포인터입니다. 왜냐면 배열은 항상 자신의 첫번째 원소(Array[0])에 대한 주소를 기억하고 있어야 하기 때문입니다.

#include <stdio.h>
int main()
{
 int arrA[3], arrB[3] = {1, 2, 3};

 // 따라서 배열에 배열을 직접 대입하는 것도 불가능.
 arrA = arrB;

 return 0;
}

" ... "를 통해 만들어진 문자열은 결국 포인터이기 때문에, 문자열 리터럴이 대입 연산자(=)를 통해 전달하는 것은 해당 문자열이 존재하는 메모리상의 주소(어드레스)입니다. 하지만 배열은 주소값을 임의로 지정할 수 없기 때문에 컴파일러가 에러를 내버린 것이죠.

배열의 특성상, 문자열 리터럴 뿐만 아니라 문자열을 담은 변수도 역시, 문자 배열에다가는 대입 연산자(=)로 값을 전달할 수는 없습니다. 그러면 배열에다가는 죽었다 깨어나도 문자열을 대입하지는 못한단 말인가요?

그럴때는 대입이 아니라 복사(Copy)를 하면 됩니다.

#include <stdio.h>
#include <string.h>

int main()
{
 char str[10];

 strcpy(str, "abcdef");
 puts(str);

 return 0;
}

char* 형 변수에 대해서도 마찬가지로 strcpy()를 통해 복사하는 방식으로 대입할 수도 있습니다.


3. NULL로 끝나는 문자열의 문제

그런데 C에서 쓰이고 있는 "NULL로 끝나는 문자열" 방식에는 몇가지 문제가 있습니다. 일단 가장 치명적인 문제점이라면, 문자열의 끝까지 가보지 않으면 문자열의 길이를 알 수 없다는 겁니다.

왜 문자열의 길이를 아는 것이 중요할까?

"abcd"와 "efg" 두 문자열을 연결한다고 생각해 보겠습니다.

#include <stdio.h>
#include <string.h>

int main()
{
 char str[10] = "abcd";

 strcat(str, "efg");
 puts(str);

 return 0;
}


그리고 문자열을 연결하는 데 쓰인 strcat() 함수의 구현은 다음과 같습니다.

void strcat(char* dest, char* src)
{
 while (*dest) dest++;
 while (*dest++ = *src++);
}

strcat()의 구현을 보면, 첫번째 while 루프가 하는 일은 첫번째 문자열(char* dest)에서 NULL(0) 값을 찾아서 헤메는 것입니다. 문자열 길이를 한방에 알아내지 못하기 때문이 이렇게 루프를 돌리지 않으면 안되는 것입니다.

이렇게 첫번째 문자열에서의 마지막 위치를 알아낸 다음에서야 비로소 두번째 문자열(char* src)을 이어붙일 수가 있는 것입니다.

때문에 문자열의 연결이 많으면 많아질수록 strcat()의 수행 성능은 떨어지게 됩니다.

#include <stdio.h>
#include <string.h>

int main()
{
 char str[100];

 strcpy(str, "ABCDE");

 strcat(str, "FGHIJK");
 strcat(str, "LMNOP");
 strcat(str, "QRSTUV");
 strcat(str, "WXYZ");
 puts(str);

 return 0;
}

위 예제에서, 처음에 "ABCDE" 와 "FGHIJK"를 결합할때, 문자열의 끝을 알아내기 위해서 strcat() 내부에서는 5번의 루프(첫번째 while)가 돌아갑니다.

그런데 다음에 "ABCDEFGHIJK" 와 "LMNOP"를 결합할 때는, 11번의 루프가 돌아가야합니다.

그 다음 "ABCDEFGHIJKLMNOP" 와 "QRSTUV"의 결합에서는 15번의 루프를 돌게 됩니다.

그 다음 "ABCDEFGHIJKLMNOPQRSTUV" 와 "WXYZ"에서는 21번의 루프를 돕니다.

그렇습니다. strcat()를 통해서 문자열을 누적시키게 되면, NULL문자를 탐색하는 루프의 길이가 갈수록 늘어나게 됩니다. 이처럼 ·NULL로 끝나는 문자열·은 문자열을 다루는 알고리즘에 있어서는 쥐약과도 같습니다.

한편 파스칼(PASCAL) 언어에서는 문자열을 이렇게 관리하고 있습니다.
배열의 첫번째 바이트에다 문자열의 길이를 저장합니다. (때문에 문자열의 최대길이는 255자로 제한됩니다) 이렇게 함으로써 PASCAL에서는 문자열의 길이를 한방에 알아낼 수 있습니다.

이런 PASCAL 문자열의 장점 때문에 속도가 중요시 되는 곳에서 널리 쓰이고 있습니다. MS社의 EXCEL에서도 이같은 방식으로 문자열을 처리합니다.

Bolrland社의 델파이(delphi)의 경우도 오브젝트 파스칼이라는 PASCAL을 확장한 언어를 쓰고 있기 때문에 역시 이 같은 형식의 문자열을 사용합니다.

그런데, 보다 더 심각한 문제는 따로 있습니다.

#include <stdio.h>
#include <string.h>

int main()
{
 char str[10];

 strcpy(str, "ABCDE");
 strcat(str, "1234567");
 puts(str);

 return 0;
}

이 예제를 실행하면 컴파일은 잘 되지만, 실행을 시키면 분명 에러가 날겁니다.

str 변수는 크기 10의 문자 배열로 선언되었음에도 불구하고, strcat()에서는 그 이상의 길이를 가진 문자열을 결합하려고 합니다. 그런데 이것이 아무 문제제기도 없이 그대로 수행된다는 점이 문제입니다.

위의 strcat() 함수의 구현을 보았듯이, 문자열을 저장하는 대상인 char* dest 에 대한 한계 길이에 대해서는 전혀 고려하고 있지 않습니다. 그때문에 본래 변수에 할당된 메모리의 범위를 초과해서 문자열이 기록될 수도 있습니다.

바로 이같은 현상이 그 유명한(그리고 고질적인) 버퍼 오버플로우입니다.

버퍼 오퍼플로우가 발생하게 되면, 본래는 건드려서는 안될 메모리 영역에 엉뚱한 값이 기록되어버립니다. 때문에 프로그램 실행시에 오류를 내고 뻗어버리게 되는 것이죠.
(하지만 해커가 이것을 이용하게 되면, 아주 기상천외한 것들을 할 수 있습니다...)

이같은 버퍼 오퍼플로우 문제는 전통적인 string 라이브러리(string.h)에서 가지고 있는 문제입니다. strcat() 뿐만 아니라 자주 쓰이는 strcpy() 함수 등도 마찬가지의 문제를 가지고 있습니다.

이것은 C의 포인터의 특성에서 기인한 문제입니다. C의 포인터 연산은 변수의 할당된 메모리 영역을 넘어가게 되는 것에 대한 검사과정이 없습니다. 이런 특성 때문에 C에서 문자열을 다룰때는 문자열을 담는 변수의 크기에 대해서 각별히 신경써야 합니다.


4. 버퍼 오버플로우의 예방

한마디로 char* 형의 문자열은 빌어먹을 문자열입니다.

C++에서는 이따위 문자열은 쓰지 않는게 속편합니다. 마음 편하게 문자열 클레스를 사용하십시요. JAVA나 C#이라면 문자열 리터럴 조차도 클레스이기 때문에 이런 걱정은 할 필요가 없겠지요.

C에서라면 strcpy(), strcat(), str머시기() 시리즈의 라이브러리는 아예 사용을 안하는게 좋습니다. 대신 문자열의 길이를 검사해 주는 strncpy(), strncat(), strn머시기() 시리즈를 사용할 것을 권장하고 있습니다. 하지만 이것도 완전한 해결책은 아닙니다. 근본적으로는 문자열의 크기에 대해서 항시 신경쓰고 코딩해야 합니다.
반응형

댓글