본문 바로가기
Visual C++/General

메모리 디버깅 기법 (한글)

by hyperhand 2009. 2. 5.

메모리 디버깅 기법 (한글)

출처: http://www.ibm.com/developerworks/kr/library/au-memorytechniques.html

C에서 가장 큰 난제라는 미신을 파헤쳐보자.

난이도 : 중급
Cameron Laird, Vice President, Phaseit, Inc.

옮긴이: 박재호, 이해영 dwkorea@kr.ibm.com
2008 년 4 월 22 일
이 기사에서는 다양한 프로그래밍 예제를 통해 메모리 결함을 줄이는 훌륭한 메모리 구현 기법을 소개합니다. C/C++ 프로그래밍에서 메모리 오류는 악의 근원입니다. 자주 발생할 뿐만 아니라, 20여년 동안이나 중요성이 알려졌으나 사라지지 않았습니다. 응용 프로그램에 심각한 영향을 미치지만, 구체적인 대비책을 세운 후 개발에 뛰어드는 개발팀은 거의 없었습니다. 하지만 다행스럽게도 조금만 신경쓰면 암흑 속을 해멜 필요가 없습니다.
들어가면서
C/C++ 프로그램에서 메모리 오류는 심각하다. 자주 발생할 뿐만 아니라 치명적이기도 하다. CERT(Computer Emergency Response Team)와 개발업체가 가장 심각하게 통보하는 보안 문제 중 다수가 단순한 메모리 오류와 관련이 있다(참고자료 참조). C 프로그래머들이 1970년대 후반부터 겪어온 메모리 오류는 2007년에도 여전히 심각함이 사라지지 않았다. 내 느낌을 피력하자면, 요즘 C/C++ 프로그래머들은 메모리 오류를 '통제 불가능하고 불가사의한 고통'으로 감내하는 듯이 보인다. 그래서 방지할 엄두는 못내고 (일단 발생한 다음에야) 복구에 매달린다.
그래야 할 필요가 없다. 이 기사에서는 메모리 결함을 줄이는 우수한 구현 기법 중에서도 가장 근본적인 기법을 짚어본다.
올바른 메모리 관리가 중요한 이유
메모리 오류가 있는 C/C++ 프로그램은 결국 문제를 일으킨다. 프로그램에 메모리 누수가 일어나면 속력이 점차 느려지다가 결국은 죽어버린다. 코드에서 메모리를 덮어쓴다면 보안성 저하로 악의 있는 사용자가 프로그램을 가로채기 쉬워진다. 1988년에 나왔던 유명한 모리스 웜(Morris worm)부터 최근 플래시 재생기와 기타 주요 리테일 프로그램에서 발생한 보안 문제는 모두 버퍼 넘침(buffer overflow)을 이용했다. 2004년 Rodney Bates는 "대다수 컴퓨터 보안 구멍은 버퍼 넘침이다."라고 말했다.
그래서 C/C++ 대신에 자바(Java™), 루비, 하스켈, C#, 펄, 스몰토크 등 범용 언어가 널리 각광을 받았다. 물론, 각 언어는 나름대로 상당한 이익을 제공하며 추종자도 많다. 그러나 사용성 측면에서 다른 범용 언어가 C/C++보다 나은 점은 메모리 관리가 편하다는 사실 하나뿐이다. 올바른 메모리 프로그래밍이 너무나 중요하고 어려운 탓에 (객체 지향 언어, 함수형 언어, 고차원 언어, 선언형 언어 등) 프로그래밍 언어가 제공하는 특성과 이론을 따지기에 앞서 메모리 관리가 편하다는 이유 하나만으로 다른 프로그래밍 언어를 선호하게 되었다.
프로그램에서 드러나는 오류 유형은 다양하지만, 메모리 오류만큼 잠행성인 오류도 없다. 오류를 재현하기 어려울 뿐만 아니라 전혀 엉뚱한 장소와 시기에 증상이 나타난다. 예를 들어, 메모리 누수는 실제 메모리 누수를 일으키는 코드와 전혀 상관 없는 곳에서 프로그램을 죽이기도 한다.
이런 이유로 C/C++ 언어로 프로그램을 짤 때는 메모리 관리에 각별히 신경써야 한다. 단순히 C/C++ 언어를 기피해서 해결할 일이 아니다. 이 기사에서는 메모리 오류에 적극적으로 대처하는 방안을 소개한다.
메모리 오류 유형
우선 좌절하지 말자. 메모리 오류라는 난관을 헤쳐갈 방법이 있다. 실제로 발생하는 난관을 모두 열거해보자.
  • 메모리 누수
  • 할당 오류 -- 이미 free()로 해제한 메모리 중복 해제, 초기화하지 않은 메모리 참조
  • 허상(dangling) 포인터
  • 배열 경계 위반
이상이다. C++ 객체 지향 언어를 사용해도 위 유형은 크게 달라지지 않는다. C와 C++ 언어는 메모리를 관리하고 참조하는 방식이 근본적으로 같기 때문이다. C는 구조체와 간단한 자료 유형을 사용하는 반면 C++는 클래스를 사용한다는 차이가 있을 뿐이다. "순수 C"에 C++ 확장을 붙여서 따라오는 특징 대부분은 연습 문제로 남겨 놓겠다.메모리 누수
메모리 누수는 자원을 할당했으나 해제하지 않는 오류를 가리킨다. 무엇이 잘못되었는지 Listing 1을 살펴보자.

Listing 1. 힙 메모리를 손상시키고 버퍼를 덮어쓸 가능성이 있는 코드
void f1(char *explanation) { char *p1; p1 = malloc(100); (void) sprintf(p1, "The f1 error occurred because of '%s'.", explanation); local_log(p1); }

무엇이 문제인지 알겠는가? local_log() 함수가 인수로 넘어온 메모리를 free()로 직접 해제하지 않는 한, 프로그램에서 함수 f1을 호출할 때마다 100바이트씩 누수가 생긴다. 사은품으로 수백 메가바이트짜리 메모리 스틱을 나눠주는 요즘 세상에 100바이트 정도는 새 발에 피겠지만, 프로그램을 지속적으로 돌리는 경우라면 물방울이 모여 바위도 뚫는 법이다.
실생활에서 C/C++ 프로그램을 짤 때는 malloc()이나 new만 꼼꼼히 따진다고 메모리 누수 문제가 사라지지 않는다. 이 절 첫 첫 문장에서 "메모리"가 아니라 "자원"이라고 한 이유는 Listing 2와 같은 경우 때문이다. FILE 핸들이 메모리 블록과 다르게 보일지도 모르지만, 올바로 처리하지 못하면 문제를 일으키기는 마찬가지다.

Listing 2. 자원 관리 오류로 힙 메모리를 손상시킬 가능성이 있는 코드
int getkey(char *filename) { FILE *fp; int key; fp = fopen(filename, "r"); fscanf(fp, "%d", &key); return key; }

fopen을 호출한 후에는 fclose를 호출해야 한다. C 표준에서는 fclose()가 없어서 생기는 결과를 별도로 명시하지 않으나, 메모리 누수가 발생할 가능성이 농후하다. 세마포, 네트워크 핸들, 데이터베이스 연결 등과 같은 자원도 마찬가지로 주의해야 한다.
메모리 할당 오류
메모리 할당 오류는 처리하기가 다소 쉽다. Listing 3을 살펴보자.

Listing 3. 초기화하지 않은 포인터
void f2(int datum) { int *p2; /* 이런, 이런! 아무도 p2를 초기화하지 않았다. */ *p2 = datum; ... }

다행스럽게도 이 유형에 속하는 오류는 대개 극적인 결과를 초래한다. AIX®에서 초기화하지 않은 메모리에 값을 할당하면 거의 즉각적으로 세그멘테이션 폴트(segmentation fault)가 발생한다. 디버깅이 오래 걸리고 재현하기 까다로운 다른 유형에 비하면, 오류가 즉각 드러나므로 매우 양호한 유형이라 하겠다.
이 유형에 속하는 또 다른 오류로, malloc()을 호출한 후 free()를 여러 차례 호출하는 오류가 있다. Listing 4를 참조한다.

Listing 4. 같은 메모리를 두 번 해제하는 코드
/* 할당 한 번에 해제 두 번. */ void f3() { char *p; p = malloc(10); ... free(p); ... free(p); } /* 할당하지 않고 해제는 한 번. */ void f4() { char *p; /* 여기서 p가 초기화되지 않은 상태로 존재한다. */ free(p); }

위와 같은 오류가 프로그램에 심각한 문제를 일으키는 경우는 드물다. C 표준에서는 구체적인 영향을 명시하지 않는데, 일반적으로는 무시하거나 즉석에서 경고를 날린다. 위 코드에서 보듯이 별다른 위험은 없다.
허상(dangling) 포인터
허상 포인터는 프로그래머가 앞서 해제한 메모리 자원을 다시 사용할 때 생기는 오류다. 다른 유형에 비해 좀더 성가시고 골치 아프다. Listing 5를 참조한다.

Listing 5. 허상 포인터를 사용하는 코드
void f8() { struct x *xp; xp = (struct x *) malloc(sizeof (struct x)); xp.q = 13; ... free(xp); ... /* 문제 발생! xp가 가리키는 영역을 겹쳐쓸 가능성이 있기에 메모리 블록 유효성을 보장하지 못한다. */ return xp.q; }

기존 “디버깅” 방식으로는 이런 오류를 찾아내기 어렵다. 다음 몇 가지 이유로 재현하기도 쉽지 않다.
  • 메모리를 성급하게 해제한 후 다시 사용하는 코드가 아주 일부분에 국한할지라도, 프로그램 내 전혀 다른 부분이나 (극단적으로는) 다른 프로세스가 실행되는 방식에 따라, 다시 사용하는 부분에서 오류가 생기기도 하고 생기지 않기도 한다.
  • 대개 허상 포인터 오류는 메모리를 까다롭게 사용하는 코드에서 발생한다. 따라서 누군가 해제한 메모리를 즉시 덮어써서 새 값이 예상한 값과 달라지더라도 새 값을 오류로 인식하지 못하기도 한다.
C/C++ 프로그램에서 허상 포인터는 항상 조심해야 할 함정이다.배열 경계 위반
마지막으로 주요한 메모리 오류 유형 중 하나가 배열 경계 위반으로, 전혀 안전하지 못한 오류 유형이다. Listing 1을 다시 살펴보자. explanation 값이 80자를 넘으면 어떻게 될까? 답: 정확히 예측하기는 어려우나 좋은 결과는 아니리라. 구체적으로 설명하자면, C는 p1에서 할당한 배열 길이 100을 초과하는 문자열도 모두 복사한다. 즉, 이 "초과" 문자열은 메모리 내 다른 자료를 덮어쓴다. 메모리에서 할당된 자료 블록이 놓이는 순서는 복잡하다. 똑같은 프로그램을 돌려도 똑같이 배치되지 않는다. 따라서 증상만으로 문제의 소스 코드를 찾아내기 어렵다. 배열 경계 위반은 쉽사리 수백만 달러에 달하는 피해를 초래하는 오류에 속한다.
메모리 프로그래밍 전략
메모리 오류를 거의 다 없애려면 근면과 규율이 가장 중요하다. 몇 가지 구체적인 전략을 제시하겠다. 내 경험에 따르면, 아래 전략을 따른 회사는 메모리 오류 발생율이 적어도 몇 분의 1로 줄었다.
구현 스타일
가장 중요하지만 (내가 아는 한) 누구도 강조하지 않는 전략이 바로 코딩 표준(coding standard)이다. 자원, 특히 메모리 자원에 영향을 미치는 함수와 메서드는 그 사실을 명시적으로 밝혀야 한다. Listing 6은 적절한 헤더, 주석, 이름을 보여주는 예다.

Listing 6. 자원 관련 정보를 명시하는 원시 코드 예
/******** * ... * * protected_file_read()를 호출하는 함수는 * 반환 값으로 돌아오는 핸들을 fclose()로 닫아야 할 책임이 있다. * 반환 값이 NULL인 경우는 예외다. * ********/ FILE *protected_file_read(char *filename) { FILE *fp; fp = fopen(filename, "r"); if (fp) { ... } else { ... } return fp; } /******* * ... * * get_message 반환값은 고정 메모리 영역을 가리킨다. * 절대 free()하지 말자. * 계속해서 유지할 필요가 있다면 복사해놓아야 한다. * ********/ char *get_message() { static char this_buffer[400]; ... (void) sprintf(this_buffer, ...); return this_buffer; } /******** * ... * 이 함수가 힙 메모리를 사용하며 일시적으로 메모리 크기를 늘이므로, * 사용이 끝난 후 적절히 메모리를 정리해야 한다. * ********/ int f6(char *item1) { my_class c1; int result; ... c1 = new my_class(item1); ... result = c1.x; delete c1; return result; } /******** * ... * f8()은 힙으로 반환될 필요가 있는 반환 값을 돌려준다. * f7은 f8을 얇게 감싸고 있으므로, f7()을 호출하는 어떤 코드도 * 반환 값을 주의 깊게 free()해야 한다. * ********/ int *f7() { int *p; p = f8(...); ... return p; }

위와 같은 스타일 요소를 함수에 항상 추가한다. 코딩 스타일 외에도 메모리 문제를 해결할 목적으로 시도되는 방식은 많다.
  • 특수 목적으로 만든 라이브러리
  • 언어
  • 소프트웨어 도구
  • 하드웨어 검사기
그러나 이제껏 경험으로 가장 유용하고도, 결과가 확실하며, 투자 대비 효과가 큰 방법이 바로 신중한 소스 코드 스타일 개선이었다. 많은 시간과 노력을 투자하거나 지나치게 공식적일 필요가 없다. 메모리와 관련 없는 부분은 예전처럼 내버려 두고, 메모리에 영향을 미치는 부분만 확실히 설명하면 된다. 메모리 결과를 분명히 밝히는 말 몇 마디만 추가해도 메모리 결함은 크게 줄어든다.통제된 실험으로 이 스타일이 안겨주는 효과를 검증한 적은 없다. 그러나 여러분이 나와 같은 경험을 겪는다면, 코드에 자원 영향을 명시하는 정책을 반드시 실천하리라 믿는다. 간단한 수고지만 그 효과는 막대하다.
검사(Inspection)
코딩 표준을 보완하는 전략이 코드 검사(code inspection)다. 둘 다 각각 효과가 있지만 둘을 합하면 강력한 상승 작용이 생긴다. 빈틈 없는 C/C++ 프로그래머라면 익숙하지 않은 코드에서도 메모리 문제를 손쉽게 짚어낸다. 텍스트 검색 기능을 활용하여 조금만 연습하면 누구나 *alloc()free() 혹은 newdelete가 쌍을 이루는지 검증할 수 있다. 이렇듯 사람이 수행하는 소스 코드 검토는 Listing 7과 같은 문제를 쉽게 찾아낸다.

Listing 7. 까다로운 메모리 누수
static char *important_pointer = NULL; void f9() { if (!important_pointer) important_pointer = malloc(IMPORTANT_SIZE); ... if (condition) /* 이런! 이미 잡혀 있는 important_pointer 참조를 잃어버린다. */ important_pointer = malloc(DIFFERENT_SIZE); ... }

위 코드에서는 condition이 참인 경우에만 메모리 누수가 발생한다. 따라서 자동 런타임 도구를 사용해도 condition이 거짓이면 오류를 감지하지 못한다. 조건을 따져 가면서 코드를 주의 깊게 분석하면 문제를 찾아내서 고칠 수 있다. 앞서 구현 스타일에서도 언급했지만 다시 한 번 강조하겠다. 지금까지 메모리 문제를 해결하려는 대다수 시도는 도구와 언어에 초점을 맞추어왔지만, 나는 개발자를 중심으로 공정을 개선하는 “소프트”한 전략이 가장 효과적이라고 믿는다. 스타일을 개선하고 코드를 검사하면, 자동화 도구가 내놓는 진단 정보도 이해하기 쉬워진다.
정적 자동 구문 분석
물론, 사람만이 아니라 프로그램도 소스 코드를 읽을 줄 안다. 개발 과정에서 정적 구문 분석을 도입해도 효과적이다. 정적 구문 분석을 수행하는 방법으로는 lint, 엄격한 컴파일 모드, 기타 여러 가지 상용 도구가 있다. 소스 코드를 읽어들여 문제 부분을 지적해 주기는 하지만, 원인보다 증상을 집어낼 가능성이 크다.
코드는 lint를 통과해야 한다. lint가 오래되고 제한적인 도구라고 (혹은 개선된 lint 후손 도구까지) 무시하는 프로그래머가 많은데, 아주 큰 실수다. 일반적으로 lint를 통과하는 전문가 수준의 우수한 코드를 내놓기는 그리 어렵지 않으며, 이 과정에서 상당한 오류가 걸러진다. 이렇게 미리 걸러내는 오류에는 메모리 오류도 존재하기 마련이다. 상용 제품 사용료가 아무리 비싸더라도 고객이 먼저 메모리 오류를 찾아내 생기는 비용 손실에 비하면 아무 것도 아니다. 소스 코드를 깨끗이 정리하라. 꼭 필요한 코드라는 핑계로 lint 플래그를 무시하지 마라. 십중팔구 더 깨끗한 구현 방식이 존재한다. 안정성과 이식성이 있고 lint도 만족하는 방식으로 프로그램을 구현하라.
메모리 라이브러리
마지막으로 소개할 두 가지 치료 전략은 앞서 소개한 세 가지 전략과 다르다. 앞서 소개한 전략은 개별 프로그래머가 쉽게 이해하고 구현할 수 있는 경량형 전략이다. 반면, 메모리 라이브러리는 사용료가 만만찮은 도구로, 개발자에게 지식과 판단력이 있어야 한다. 라이브러리와 도구를 효과적으로 사용하는 프로그래머는 경량형 정적(static) 전략을 제대로 이해하는 프로그래머다. 사용 가능한 도구와 라이브러리 수는 상당히 많으며, 전반적으로 품질도 매우 우수하다. 하지만 작정하고 메모리 관리 기본을 무시하는 고집 센 프로그래머는 아무리 우수한 도구로도 감당하지 못한다. 내 경험으로는 혼자 일하는 B급 프로그래머에게 메모리 라이브러리와 도구는 좌절감만 안겨줄 뿐이다.
이런 이유로 나는 C/C++ 프로그래머에게 먼저 자기 코드를 살펴서 메모리 문제를 잡아내라고 충고한다. 그런 다음에야 라이브러리를 고려하는 편이 바람직하다.
일부 라이브러리는 개선된 메모리 관리 도움을 받는 경우 기존 스타일로 C/C++ 코드를 구현해도 괜찮다. Jonathan Bartlett는 (아래 참고자료 절에 언급한) 2004년 developerWorks 기사에서 우수한 제품 몇 가지를 소개한다. 메모리 라이브러리는 매우 다양한 문제를 겨냥하기에 제품을 서로 비교하기가 어렵다. 일반적으로는 가비지 컬렉션(garbage collection), 스마트 포인터(smart pointer), 스마트 컨테이너(smart container) 등과 같은 기준으로 비교한다. 한 마디로 표현하자면, 메모리 라이브러리는 메모리 관리를 자동화해 프로그래머가 저지르는 오류를 줄여준다.
나는 메모리 라이브러리를 찬성하지도 반대하지도 않는다. 효과는 분명히 있지만, (이제까지 참여했던 프로젝트에서) 기대만큼 크지가 않았다. 특히 C 프로그램에서 생각보다 못했다. 하지만 실망스러운 결과를 뒷받침할 근거 자료는 없다. 예를 들어, 성능이 수동 메모리 관리만큼 좋아야 하겠지만 막상 비교하려면 애매하다. 가비지 컬렉션을 수행하는 라이브러리는 때때로 성능이 느려지기 때문이다. 지금까지 내 경험에 의하면, C 프로그래머 그룹보다 C++ 문화가 스마트 포인터 개념을 좀더 쉽게 수용하는 듯이 보인다.
메모리 도구
중요한 C 프로그램을 구현하는 개발팀은 런타임 메모리 도구를 개발 전략의 일부로 채택해야 한다. 앞서 설명한 기술은 귀중하고도 필수적이다. 메모리 도구가 제공하는 품질과 기능은 직접 사용해 봐야 그 진가를 안다.
이 기사는 소프트웨어 기반 메모리 도구에만 주로 언급했다. 하드웨어 메모리 디버거도 존재하는데, 아주 특수한 상황에만 필요하다고 생각한다. 예를 들어, 다른 도구를 지원하지 않는 특수 호스트라면 하드웨어 메모리 도구가 필요할지도 모른다.
현재 시장에 나와 있는 소프트웨어 메모리 도구로는 IBM Rational® Purify와 같은 독점 도구, , Electric Fence와 같은 여러 오픈 소스 도구가 있다. AIX를 비롯하여 다양한 운영체제에서 사용 가능한 도구도 몇 개 있다.
모든 메모리 도구는 거의 같은 방식으로 동작한다. (컴파일 시 -g 플래그를 주어 디버그 버전을 생성하듯이) 특수한 실행 파일을 생성한 후 응용 프로그램을 돌린다. 그러면 도구는 자동으로 보고서를 생성한다. Listing 8과 같은 프로그램을 고려해 보자.

Listing 8. 예제 오류
int main() { char p[5]; strcpy(p, "Hello, world."); puts(p); }

많은 환경에서 위 프로그램은 "문제 없이" 돌아간다. 컴파일해 실행하면 화면에 "Hello world.\n"이 출력된다. 메모리 도구로 같은 응용 프로그램을 실행하면 네 번째 줄에 배열 경계 위반이 있다고 보고한다. 길이가 5인 배열에 문자 14개를 복사했기 때문이다. "어떻게 하다 보니 프로그램이 죽었다"라는 소리를 고객에게서 듣기보다 훨씬 저렴한 디버깅 방법이라 하겠다. 이것이 메모리 도구가 제공하는 가치다.
결론
성숙한 C/C++ 프로그래머라면 메모리 문제를 심각하게 취급해야 한다. 조금만 계획하고 조금만 주의하면 메모리 위험을 미연에 방지할 수 있다. 올바른 구현 기법을 배우고, 발생하기 쉬운 오류에 주목하고, 이 기사에서 설명한 기법을 몸에 배이도록 익혀라. 그러면 디버깅에 며칠 아니 몇 주가 걸릴 증상이 프로그램에서 점차 사라지리라.


참고자료
교육

제품 및 기술 얻기

토론


필자소개
Cameron은 developerWorks에 오랫 동안 기고해 온 필자이자 전 컬럼니스트다. 주로 회사에서 개발을 촉진하도록 안정성과 보안을 높여주는 오픈 소스 프로젝트에 관심을 가지고 기사를 쓴다. AIX가 여전히 실험 단계에 있었던 20여년 전에 AIX를 처음 접했다. 이후로 다양한 메모리 디버깅 도구를 사용하고 개발했다. 
 

반응형